mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-05 17: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",
|
||||
|
||||
@@ -35,6 +35,7 @@ import { ConsignmentsModule } from "./modules/consignments/consignments.module";
|
||||
import { LocomotivesModule } from "./modules/locomotives/locomotives.module";
|
||||
import { TruckTypesModule } from "./modules/truck-types/truck-types.module";
|
||||
import { TransitAgentsModule } from "./modules/transit-agents/transit-agents.module";
|
||||
import { TransitAssignmentsModule } from "./modules/transit-assignments/transit-assignments.module";
|
||||
import { WagonTypesModule } from "./modules/wagon-types/wagon-types.module";
|
||||
import { TrainSetsModule } from "./modules/train-sets/train-sets.module";
|
||||
import { TrainSchedulesModule } from "./modules/train-schedules/train-schedules.module";
|
||||
@@ -95,6 +96,7 @@ import { TrainsModule } from "./modules/trains/trains.module";
|
||||
import { VerifaydaModule } from "./modules/verifayda/verifayda.module";
|
||||
import { EimsModule } from "./modules/eims/eims.module";
|
||||
import { FleetHistoryModule } from "./modules/fleet-history/fleet-history.module";
|
||||
import { WagonHistoryModule } from "./modules/wagon-history/wagon-history.module";
|
||||
import { WagonsModule } from "./modules/wagons/wagons.module";
|
||||
import { ContainersModule } from "./modules/container-management/containers.module";
|
||||
import { CargoesModule } from "./modules/cargoes/cargoes.module";
|
||||
@@ -115,6 +117,7 @@ import { FacilitiesModule } from "./modules/facilities/facilities.module";
|
||||
import { GpsTrackingModule } from "./modules/gps-tracking/gps-tracking.module";
|
||||
import { FirstMileModule } from "./modules/first-mile/first-mile.module";
|
||||
import { LastMileModule } from "./modules/last-mile/last-mile.module";
|
||||
import { EmptyReturnRequestsModule } from "./modules/empty-return-requests/empty-return-requests.module";
|
||||
import { LastMileRequestsModule } from "./modules/last-mile-requests/last-mile-requests.module";
|
||||
import { InterchangeDocumentsModule } from "./modules/interchange-documents/interchange-documents.module";
|
||||
import { ImportOperationsModule } from "./modules/import-operations/import-operations.module";
|
||||
@@ -205,6 +208,7 @@ if (!process.env.APPLICATION_NAME) {
|
||||
LocomotivesModule,
|
||||
TruckTypesModule,
|
||||
TransitAgentsModule,
|
||||
TransitAssignmentsModule,
|
||||
WagonTypesModule,
|
||||
TrainSetsModule,
|
||||
TrainSchedulesModule,
|
||||
@@ -256,11 +260,13 @@ if (!process.env.APPLICATION_NAME) {
|
||||
FirstMileModule,
|
||||
LastMileModule,
|
||||
LastMileRequestsModule,
|
||||
EmptyReturnRequestsModule,
|
||||
InterchangeDocumentsModule,
|
||||
ImportOperationsModule,
|
||||
VerifaydaModule,
|
||||
EimsModule,
|
||||
FleetHistoryModule,
|
||||
WagonHistoryModule,
|
||||
AiModule,
|
||||
AuditModule,
|
||||
ChatModule,
|
||||
|
||||
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',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,25 +4,29 @@ import {
|
||||
ValidationOptions,
|
||||
ValidatorConstraint,
|
||||
ValidatorConstraintInterface,
|
||||
} from 'class-validator';
|
||||
import { isValidPhoneNumber, parsePhoneNumberFromString } from 'libphonenumber-js';
|
||||
} from "class-validator";
|
||||
import {
|
||||
isValidPhoneNumber,
|
||||
parsePhoneNumberFromString,
|
||||
} from "libphonenumber-js";
|
||||
|
||||
/**
|
||||
* Country-aware phone validation. The value is expected as a full international
|
||||
* number (E.164, e.g. "+251911223344"), so the country is derived from the
|
||||
* value itself — no separate country field needed.
|
||||
* number (E.164, e.g. "+25377834567" for Djibouti or "+251911223344" for
|
||||
* Ethiopia), so the country is derived from the value itself — no separate
|
||||
* country field needed.
|
||||
*/
|
||||
@ValidatorConstraint({ name: 'IsValidPhone', async: false })
|
||||
@ValidatorConstraint({ name: "IsValidPhone", async: false })
|
||||
export class IsValidPhoneConstraint implements ValidatorConstraintInterface {
|
||||
validate(value: unknown): boolean {
|
||||
// Empty is allowed here; pair with @IsOptional / @IsNotEmpty as needed.
|
||||
if (value === undefined || value === null || value === '') return true;
|
||||
if (typeof value !== 'string') return false;
|
||||
if (value === undefined || value === null || value === "") return true;
|
||||
if (typeof value !== "string") return false;
|
||||
return isValidPhoneNumber(value);
|
||||
}
|
||||
|
||||
defaultMessage(args: ValidationArguments): string {
|
||||
return `${args.property} must be a valid international phone number (E.164, e.g. +251911223344)`;
|
||||
return `${args.property} must be a complete international phone number (E.164, e.g. +25377834567 or +251911223344)`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,7 +57,7 @@ export function IsValidPhone(validationOptions?: ValidationOptions) {
|
||||
export function normalizeE164(
|
||||
value: string | null | undefined,
|
||||
): string | null | undefined {
|
||||
if (value === undefined || value === null || value === '') return value;
|
||||
const parsed = parsePhoneNumberFromString(value, 'ET');
|
||||
if (value === undefined || value === null || value === "") return value;
|
||||
const parsed = parsePhoneNumberFromString(value, "ET");
|
||||
return parsed?.isValid() ? parsed.number : value.trim();
|
||||
}
|
||||
|
||||
@@ -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,55 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Give a transit agent a portal login.
|
||||
*
|
||||
* Every column is NULLABLE and nothing is backfilled: production already holds
|
||||
* transit agents that exist only as a GL-assignable roster entry, and they must
|
||||
* keep working untouched. An agent gains an account when staff invite it — at
|
||||
* which point `user_id` is filled in — so "has a login" is exactly
|
||||
* `user_id IS NOT NULL`, and the assignment flow never has to care.
|
||||
*
|
||||
* The unique indexes are partial (`WHERE ... IS NOT NULL`) because Postgres
|
||||
* treats NULLs as distinct in a plain unique index only per-row; being explicit
|
||||
* documents that many account-less agents are expected to coexist.
|
||||
*/
|
||||
export class TransitAgentAccount3790000000000 implements MigrationInterface {
|
||||
name = "TransitAgentAccount3790000000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.transit_agents
|
||||
ADD COLUMN IF NOT EXISTS user_id uuid,
|
||||
ADD COLUMN IF NOT EXISTS email varchar(150),
|
||||
ADD COLUMN IF NOT EXISTS phone_number varchar(30)`,
|
||||
);
|
||||
// One IAM account can back at most one transit agent — otherwise a single
|
||||
// login would resolve to two agents in `findByUserId`.
|
||||
await queryRunner.query(
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS ux_transit_agents_user_id
|
||||
ON freight.transit_agents (user_id)
|
||||
WHERE user_id IS NOT NULL AND deleted_at IS NULL`,
|
||||
);
|
||||
// Case-insensitive, matching how the repository checks for duplicates.
|
||||
await queryRunner.query(
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS ux_transit_agents_email
|
||||
ON freight.transit_agents (lower(email))
|
||||
WHERE email IS NOT NULL AND deleted_at IS NULL`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`DROP INDEX IF EXISTS freight.ux_transit_agents_email`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX IF EXISTS freight.ux_transit_agents_user_id`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.transit_agents
|
||||
DROP COLUMN IF EXISTS phone_number,
|
||||
DROP COLUMN IF EXISTS email,
|
||||
DROP COLUMN IF EXISTS user_id`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Wagon footprint pinned for cancellation pricing. `wagons_required` is a LIVE
|
||||
* scheduling field — unassign clears it to NULL — so a paid booking pulled off
|
||||
* a train had nothing left to price a cancellation fee or credit against
|
||||
* ("This booking has no wagon requirement to cancel from."). This column is
|
||||
* stamped once, at first allocation, and never cleared: cancellation reads it
|
||||
* (falling back to a computed count for bookings never allocated).
|
||||
*/
|
||||
export class BookingCancellationWagons3800000000000 implements MigrationInterface {
|
||||
name = 'BookingCancellationWagons3800000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
ADD COLUMN IF NOT EXISTS cancellation_wagons numeric(6,2)
|
||||
`);
|
||||
// Backfill the bookings that still carry a live stamp.
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.bookings
|
||||
SET cancellation_wagons = wagons_required
|
||||
WHERE cancellation_wagons IS NULL
|
||||
AND wagons_required IS NOT NULL
|
||||
AND wagons_required > 0
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
DROP COLUMN IF EXISTS cancellation_wagons
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Transit assignments — one row per (booking × transit agent), so an agent
|
||||
* handles many bookings.
|
||||
*
|
||||
* Deliberately NOT the existing transit-assignee handshake on bookings
|
||||
* (`/bookings/:id/clearance/transit-assignee/...`, which stores its answer on
|
||||
* the booking itself): that is a pre-declaration agreement between GL Ethiopia
|
||||
* and GL Djibouti about WHO will handle customs. This is the work record —
|
||||
* status, timings and documents — and nothing here reads or writes that flow.
|
||||
*
|
||||
* There is no duration column on purpose. The time taken after the train
|
||||
* arrives is `finished_at − bookings.arrived_at`, and both halves already
|
||||
* exist; storing the difference would be a third source of truth that goes
|
||||
* stale the moment either timestamp is corrected. It is computed on read.
|
||||
*
|
||||
* Documents hang off `freight.files` with `resource = 'transit_assignments'`
|
||||
* and `resource_id = transit_assignments.id`. That table already carries the
|
||||
* MinIO object, the upload time (`created_at`), the uploader, the edit time
|
||||
* (`updated_at`) and the supersede history, so no file table is added here.
|
||||
*/
|
||||
export class TransitAssignments3810000000000 implements MigrationInterface {
|
||||
name = "TransitAssignments3810000000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.transit_assignments (
|
||||
id uuid NOT NULL DEFAULT gen_random_uuid(),
|
||||
booking_id uuid NOT NULL,
|
||||
transit_agent_id uuid NOT NULL,
|
||||
status varchar(32) NOT NULL DEFAULT 'NOT_STARTED',
|
||||
started_at timestamptz,
|
||||
finished_at timestamptz,
|
||||
assigned_by_user_id uuid,
|
||||
assigned_at timestamptz NOT NULL DEFAULT now(),
|
||||
note text,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
deleted_at timestamptz,
|
||||
CONSTRAINT pk_transit_assignments PRIMARY KEY (id),
|
||||
CONSTRAINT fk_transit_assignments_booking
|
||||
FOREIGN KEY (booking_id) REFERENCES freight.bookings (id),
|
||||
CONSTRAINT fk_transit_assignments_agent
|
||||
FOREIGN KEY (transit_agent_id) REFERENCES freight.transit_agents (id)
|
||||
)
|
||||
`);
|
||||
|
||||
// One live assignment per (booking, agent). Partial so a soft-deleted row
|
||||
// never blocks re-assigning the same agent to the same booking later.
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS ux_transit_assignments_booking_agent
|
||||
ON freight.transit_assignments (booking_id, transit_agent_id)
|
||||
WHERE deleted_at IS NULL
|
||||
`);
|
||||
|
||||
// The two list directions: a booking's assignments, and an agent's workload.
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS ix_transit_assignments_booking
|
||||
ON freight.transit_assignments (booking_id) WHERE deleted_at IS NULL
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS ix_transit_assignments_agent_status
|
||||
ON freight.transit_assignments (transit_agent_id, status)
|
||||
WHERE deleted_at IS NULL
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.transit_assignments`);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -17,6 +17,7 @@ describe('BookingWagonCancellationService.resolveRequestedCut (bulk)', () => {
|
||||
wagons: number;
|
||||
weightTons: number;
|
||||
quantities: { bulkTons?: number };
|
||||
totalWagons: number;
|
||||
}>;
|
||||
};
|
||||
const booking = {
|
||||
@@ -29,7 +30,39 @@ describe('BookingWagonCancellationService.resolveRequestedCut (bulk)', () => {
|
||||
|
||||
it('cancels every wagon with the exact total tonnage', async () => {
|
||||
const cut = await svc.resolveRequestedCut(booking, { wagons: 4 });
|
||||
expect(cut).toEqual({ wagons: 4, weightTons: 250.5, quantities: { bulkTons: 250.5 } });
|
||||
expect(cut).toEqual({
|
||||
wagons: 4,
|
||||
weightTons: 250.5,
|
||||
quantities: { bulkTons: 250.5 },
|
||||
totalWagons: 4,
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Unassigning a paid booking from a train clears `wagonsRequired` to NULL, so
|
||||
* cancellation used to reject it outright ("no wagon requirement to cancel
|
||||
* from"). The pinned `cancellationWagons`, stamped at first allocation, keeps
|
||||
* the footprint through the unassign.
|
||||
*/
|
||||
it('falls back to the pinned cancellation footprint when wagonsRequired is cleared', async () => {
|
||||
const unassigned = { ...booking, wagonsRequired: null, cancellationWagons: 4 };
|
||||
const cut = await svc.resolveRequestedCut(unassigned, { wagons: 4 });
|
||||
expect(cut.wagons).toBe(4);
|
||||
expect(cut.totalWagons).toBe(4);
|
||||
expect(cut.weightTons).toBe(250.5);
|
||||
});
|
||||
|
||||
/** NUMBER_OF_WAGONS bulk never allocated: the customer's pinned count sizes it. */
|
||||
it('sizes a never-allocated NUMBER_OF_WAGONS booking from bulkRequestedWagons', async () => {
|
||||
const fresh = {
|
||||
...booking,
|
||||
wagonsRequired: null,
|
||||
cancellationWagons: null,
|
||||
bulkRequestedWagons: 3,
|
||||
};
|
||||
const cut = await svc.resolveRequestedCut(fresh, { wagons: 3 });
|
||||
expect(cut.totalWagons).toBe(3);
|
||||
expect(cut.weightTons).toBe(250.5);
|
||||
});
|
||||
|
||||
it('rejects more wagons than the booking has', async () => {
|
||||
@@ -91,6 +124,8 @@ describe('BookingWagonCancellationService.rebook (odd-20ft consolidation)', () =
|
||||
};
|
||||
|
||||
it('refuses an odd-20ft rebook without a GL-picked partner', async () => {
|
||||
// An odd credit always leaves a half-empty wagon, so GL must name who fills
|
||||
// it — the rebook is refused rather than shipping a half-empty wagon.
|
||||
await expect(
|
||||
makeSvc().rebook('wc1', { scheduledDate: '2026-09-01' }),
|
||||
).rejects.toThrow(/pick a consolidation partner/i);
|
||||
@@ -110,6 +145,74 @@ describe('BookingWagonCancellationService.rebook (odd-20ft consolidation)', () =
|
||||
}),
|
||||
).rejects.toThrow(/already shares a wagon/i);
|
||||
});
|
||||
|
||||
/**
|
||||
* An EXPIRED partner has no pay window left, so pairing the PAID rebook
|
||||
* straight onto it strands the shared wagon: neither half can board and
|
||||
* nothing ever breaks the pair (BK-2026-001114). Its cargo must move to a
|
||||
* fresh booking that carries its own invoice.
|
||||
*/
|
||||
it('clones an EXPIRED partner into a new booking instead of pairing the dead one', async () => {
|
||||
const dead = {
|
||||
id: 'p1',
|
||||
reference: 'BK-2026-001114',
|
||||
status: 'EXPIRED',
|
||||
contractId: 'c1',
|
||||
consolidationPartnerId: null,
|
||||
paymentCurrency: 'USD',
|
||||
originYardId: 'y1',
|
||||
destinationYardId: 'y2',
|
||||
tradeDirection: 'IMPORT',
|
||||
scheduledDate: '2026-09-01',
|
||||
bookingContainers: [
|
||||
{
|
||||
containerSize: '20ft',
|
||||
quantity: 1,
|
||||
hazardousQuantity: 0,
|
||||
reeferQuantity: 0,
|
||||
containerType: { sizeFt: 20 },
|
||||
units: [
|
||||
{
|
||||
containerNumber: 'PCONT0',
|
||||
sealNumber: null,
|
||||
vgmTons: 9,
|
||||
isHazardous: false,
|
||||
isReefer: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const clone = { ...dead, id: 'p1-clone', reference: 'BK-2026-001116', status: 'SUBMITTED' };
|
||||
|
||||
const svc = makeSvc(dead) as Record<string, unknown>;
|
||||
let createdUnderContract: string | null = null;
|
||||
let pairedWith: string | null = null;
|
||||
(svc as { bookingsRepository: Record<string, unknown> }).bookingsRepository = {
|
||||
findById: async () => source,
|
||||
findByIdWithFiles: async (id: string) => (id === 'p1-clone' ? clone : dead),
|
||||
hasSpentCancellationCredit: async () => false,
|
||||
};
|
||||
(svc as { contractBooking: unknown }).contractBooking = {
|
||||
createUnderContract: async (contractId: string) => {
|
||||
createdUnderContract = contractId;
|
||||
return { booking: { id: 'p1-clone' } };
|
||||
},
|
||||
};
|
||||
(svc as { notifyCustomer: unknown }).notifyCustomer = () => undefined;
|
||||
|
||||
const cloned = await (
|
||||
svc as unknown as {
|
||||
cloneDeadPartner(p: unknown, d: string): Promise<{ id: string; reference: string }>;
|
||||
}
|
||||
).cloneDeadPartner(dead, '2026-09-01');
|
||||
|
||||
// The dead booking is left dead; the clone is what gets paired and paid.
|
||||
expect(cloned.id).toBe('p1-clone');
|
||||
expect(cloned.reference).toBe('BK-2026-001116');
|
||||
expect(createdUnderContract).toBe('c1');
|
||||
expect(pairedWith).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -199,3 +302,91 @@ describe('BookingWagonCancellationService.buildRebookDto (bulk wagon count)', ()
|
||||
expect(dto.requestedWagons).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The cancellation fee is paid BEFORE the credit is redeemed.
|
||||
*
|
||||
* An at-loading cut applies immediately and opens the credit while its fee
|
||||
* invoice stays open, so CREDIT_AVAILABLE on its own never means the fee was
|
||||
* settled. Without the gate the customer rebooks the same wagons and the
|
||||
* cancellation fee is simply never collected. EDR-fault cuts carry no fee and
|
||||
* must stay freely rebookable — partial or whole, container or bulk.
|
||||
*/
|
||||
describe('BookingWagonCancellationService.rebook (cancellation fee gate)', () => {
|
||||
const source = {
|
||||
id: 'b1',
|
||||
contractId: 'c1',
|
||||
paymentCurrency: 'ETB',
|
||||
originYardId: 'y1',
|
||||
destinationYardId: 'y2',
|
||||
tradeDirection: 'IMPORT',
|
||||
};
|
||||
|
||||
const makeSvc = (row: Record<string, unknown>) => {
|
||||
const svc = Object.create(BookingWagonCancellationService.prototype) as Record<
|
||||
string,
|
||||
unknown
|
||||
> & { rebook(id: string, dto: unknown): Promise<unknown> };
|
||||
svc.repo = { findById: async () => row };
|
||||
svc.bookingsRepository = {
|
||||
findById: async () => source,
|
||||
findByIdWithFiles: async () => null,
|
||||
};
|
||||
return svc;
|
||||
};
|
||||
|
||||
/** Bulk credit — no bySize, so nothing depends on container snapshots. */
|
||||
const bulkRow = (over: Record<string, unknown>) => ({
|
||||
id: 'wc1',
|
||||
bookingId: 'b1',
|
||||
status: 'CREDIT_AVAILABLE',
|
||||
creditAmount: 5000,
|
||||
wagonsCancelled: 2,
|
||||
cancelledQuantities: { bulkTons: 100 },
|
||||
feeCurrency: 'ETB',
|
||||
...over,
|
||||
});
|
||||
|
||||
it('blocks a rebook while a customer-fault fee is unpaid', async () => {
|
||||
const svc = makeSvc(
|
||||
bulkRow({ fault: 'CUSTOMER', feeAmount: 1500, feePaidAt: null }),
|
||||
);
|
||||
await expect(svc.rebook('wc1', { scheduledDate: '2026-09-01' })).rejects.toThrow(
|
||||
/pay the ETB 1500\.00 cancellation fee for 2 wagon\(s\)/i,
|
||||
);
|
||||
});
|
||||
|
||||
it('blocks a WHOLE-booking customer-fault cancel just the same', async () => {
|
||||
const svc = makeSvc(
|
||||
bulkRow({ fault: 'CUSTOMER', feeAmount: 4000, feePaidAt: null, wagonsCancelled: 4 }),
|
||||
);
|
||||
await expect(svc.rebook('wc1', { scheduledDate: '2026-09-01' })).rejects.toThrow(
|
||||
/4 wagon\(s\) before rebooking/i,
|
||||
);
|
||||
});
|
||||
|
||||
it('lets the rebook through once the fee is paid', async () => {
|
||||
const svc = makeSvc(
|
||||
bulkRow({ fault: 'CUSTOMER', feeAmount: 1500, feePaidAt: new Date() }),
|
||||
);
|
||||
// Past the gate it fails later (no contract/create wiring in this harness) —
|
||||
// what matters is that it is no longer the fee that stops it.
|
||||
await expect(
|
||||
svc.rebook('wc1', { scheduledDate: '2026-09-01' }),
|
||||
).rejects.not.toThrow(/cancellation fee/i);
|
||||
});
|
||||
|
||||
it('never charges an EDR-fault cut', async () => {
|
||||
const svc = makeSvc(bulkRow({ fault: 'EDR', feeAmount: 0, feePaidAt: null }));
|
||||
await expect(
|
||||
svc.rebook('wc1', { scheduledDate: '2026-09-01' }),
|
||||
).rejects.not.toThrow(/cancellation fee/i);
|
||||
});
|
||||
|
||||
it('leaves legacy rows without a fee untouched', async () => {
|
||||
const svc = makeSvc(bulkRow({ fault: null, feeAmount: 0, feePaidAt: null }));
|
||||
await expect(
|
||||
svc.rebook('wc1', { scheduledDate: '2026-09-01' }),
|
||||
).rejects.not.toThrow(/cancellation fee/i);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,6 +23,8 @@ import { wagonsPerUnitForSize } from '../rule-engine/container-type.util';
|
||||
import { ContainerType } from '../rule-engine/entities/container-type.entity';
|
||||
import { Rate } from '../rule-engine/entities/rate.entity';
|
||||
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
|
||||
import { requestedBulkWagons } from '../train-scheduling/train-capacity.util';
|
||||
import { wagonsRequiredForBooking } from '../train-scheduling/utils/fleet-plan.util';
|
||||
import { TrainSchedulingService } from '../train-scheduling/services/train-scheduling.service';
|
||||
import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity';
|
||||
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
||||
@@ -51,6 +53,8 @@ import {
|
||||
CancelledUnitSnapshot,
|
||||
WAGON_CANCEL_FEE_INVOICE_TYPE,
|
||||
} from './entities/booking-wagon-cancellation.entity';
|
||||
import { WagonEventType } from '@edr/types';
|
||||
import { WagonHistoryService } from '../wagon-history/wagon-history.service';
|
||||
|
||||
export { WAGON_CANCEL_FEE_INVOICE_TYPE };
|
||||
|
||||
@@ -74,6 +78,8 @@ interface RequestedCut {
|
||||
wagons: number;
|
||||
weightTons: number;
|
||||
quantities: CancelledQuantities;
|
||||
/** The booking's whole wagon footprint the cut came out of — credit divides by it. */
|
||||
totalWagons: number;
|
||||
}
|
||||
|
||||
/** The priced fee for a cut: total, currency and the rate(s) it came from. */
|
||||
@@ -130,6 +136,7 @@ export class BookingWagonCancellationService {
|
||||
private readonly firstMile: FirstMileService,
|
||||
private readonly inbox: NotificationInboxService,
|
||||
private readonly events: EventEmitter2,
|
||||
private readonly wagonHistory: WagonHistoryService,
|
||||
) {}
|
||||
|
||||
// ── T1: request ────────────────────────────────────────────────────────────
|
||||
@@ -165,7 +172,7 @@ export class BookingWagonCancellationService {
|
||||
feePerWagon: fee.perWagon,
|
||||
feeAmount: fee.amount,
|
||||
feeCurrency: fee.currency,
|
||||
creditAmount: this.creditFor(booking, Number(booking.wagonsRequired ?? 0)),
|
||||
creditAmount: round2(Number(booking.totalAmount ?? 0)),
|
||||
};
|
||||
}
|
||||
this.assertCutSparesSharedWagon(cut);
|
||||
@@ -177,7 +184,7 @@ export class BookingWagonCancellationService {
|
||||
feePerWagon: fee.perWagon,
|
||||
feeAmount: fee.amount,
|
||||
feeCurrency: fee.currency,
|
||||
creditAmount: this.creditFor(booking, cut.wagons),
|
||||
creditAmount: this.creditFor(booking, cut.wagons, cut.totalWagons),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -218,7 +225,7 @@ export class BookingWagonCancellationService {
|
||||
: await this.resolveRequestedCut(booking, dto);
|
||||
const fee = await this.priceFee(booking, cut);
|
||||
const feeAmount = fee.amount;
|
||||
const creditAmount = this.creditFor(booking, cut.wagons);
|
||||
const creditAmount = this.creditFor(booking, cut.wagons, cut.totalWagons);
|
||||
|
||||
const row = await this.repo.create({
|
||||
bookingId,
|
||||
@@ -318,7 +325,7 @@ export class BookingWagonCancellationService {
|
||||
const rows = await this.dataSource.getRepository(WagonBookingAllocation).count({
|
||||
where: { bookingId: row.bookingId },
|
||||
});
|
||||
if (rows < Math.round(Number(booking.wagonsRequired ?? 0))) {
|
||||
if (rows < Math.round(await this.wagonFootprint(booking))) {
|
||||
throw new ConflictException(
|
||||
'The train has no free wagon space left to restore the cancelled wagons — the request cannot be withdrawn. Pay the cancellation fee and rebook the credit on another day instead.',
|
||||
);
|
||||
@@ -363,7 +370,7 @@ export class BookingWagonCancellationService {
|
||||
const row = await this.openConsolidationBreak(
|
||||
booking,
|
||||
'ceil',
|
||||
this.creditFor(booking, Number(booking.wagonsRequired ?? 0)),
|
||||
round2(Number(booking.totalAmount ?? 0)),
|
||||
reason ?? 'Consolidated pair cancelled',
|
||||
userId,
|
||||
);
|
||||
@@ -371,7 +378,7 @@ export class BookingWagonCancellationService {
|
||||
await this.openConsolidationBreak(
|
||||
partner,
|
||||
'floor',
|
||||
this.creditFor(partner, Number(partner.wagonsRequired ?? 0)),
|
||||
round2(Number(partner.totalAmount ?? 0)),
|
||||
`Cancelled with its consolidation partner ${booking.reference}`,
|
||||
userId,
|
||||
);
|
||||
@@ -540,7 +547,8 @@ export class BookingWagonCancellationService {
|
||||
} as RequestWagonCancellationDto);
|
||||
}
|
||||
return this.resolveRequestedCut(booking, {
|
||||
wagons: Number(booking.wagonsRequired ?? 0),
|
||||
// Footprint, not the live wagonsRequired: unassign clears that to NULL.
|
||||
wagons: await this.wagonFootprint(booking),
|
||||
} as RequestWagonCancellationDto);
|
||||
}
|
||||
|
||||
@@ -568,7 +576,7 @@ export class BookingWagonCancellationService {
|
||||
const row = await this.openConsolidationBreak(
|
||||
booking,
|
||||
'ceil',
|
||||
this.creditFor(booking, Number(booking.wagonsRequired ?? 0)),
|
||||
round2(Number(booking.totalAmount ?? 0)),
|
||||
'Consolidation partner lapsed unpaid — paired booking cancelled, cancellation fee applies',
|
||||
);
|
||||
await this.dataSource.getRepository(Booking).update(booking.id, {
|
||||
@@ -729,9 +737,10 @@ export class BookingWagonCancellationService {
|
||||
// Whole-booking cut: nothing is left to ship, so the booking ends
|
||||
// CANCELLED (frees the contract slot/cap for the rebook) and drops off its
|
||||
// train. The credit row still points at it for T3.
|
||||
const wagonsLeft = round2(
|
||||
Number(booking.wagonsRequired ?? 0) - Number(row.wagonsCancelled),
|
||||
);
|
||||
// Off the pinned footprint, not the live wagonsRequired — unassign
|
||||
// clears that to NULL, which read as a full cut on any partial cancel.
|
||||
const footprint = await this.wagonFootprint(booking);
|
||||
const wagonsLeft = round2(footprint - Number(row.wagonsCancelled));
|
||||
const isFull = wagonsLeft <= 0;
|
||||
// NUMBER_OF_WAGONS bookings pin their count in bulkRequestedWagons, which
|
||||
// bulkTonWagonsRequired honours verbatim. Left stale it re-inflates the
|
||||
@@ -745,6 +754,9 @@ export class BookingWagonCancellationService {
|
||||
: null;
|
||||
await manager.getRepository(Booking).update(booking.id, {
|
||||
wagonsRequired: Math.max(0, wagonsLeft),
|
||||
// Keep the cancellation footprint in step, so a second partial cancel
|
||||
// prices against what is actually left, not the original booking.
|
||||
cancellationWagons: Math.max(0, wagonsLeft),
|
||||
...(requestedWagonsLeft !== null
|
||||
? { bulkRequestedWagons: requestedWagonsLeft }
|
||||
: {}),
|
||||
@@ -853,14 +865,37 @@ export class BookingWagonCancellationService {
|
||||
);
|
||||
}
|
||||
|
||||
// Staff may cut a SUBSET of the never-loaded wagons (picked in the loading
|
||||
// modal) instead of the whole remainder. Anything already LOADED is
|
||||
// rejected rather than silently dropped: the operator believes they are
|
||||
// cancelling that wagon, and it is on the train.
|
||||
let target = remaining;
|
||||
if (dto.wagonAllocationIds?.length) {
|
||||
const wanted = new Set(dto.wagonAllocationIds);
|
||||
const known = new Set(allocations.map((a) => a.id));
|
||||
const unknown = dto.wagonAllocationIds.filter((id) => !known.has(id));
|
||||
if (unknown.length) {
|
||||
throw new BadRequestException(
|
||||
'Some selected wagons are not allocated to this booking on this schedule.',
|
||||
);
|
||||
}
|
||||
const loaded = allocations.filter((a) => wanted.has(a.id) && !remaining.includes(a));
|
||||
if (loaded.length) {
|
||||
throw new BadRequestException(
|
||||
`${loaded.length} selected wagon(s) are already loaded and cannot be cancelled.`,
|
||||
);
|
||||
}
|
||||
target = remaining.filter((a) => wanted.has(a.id));
|
||||
}
|
||||
|
||||
const cut = await this.resolveRequestedCut(booking, {
|
||||
wagonAllocationIds: remaining.map((r) => r.id),
|
||||
wagonAllocationIds: target.map((r) => r.id),
|
||||
} as RequestWagonCancellationDto);
|
||||
if (booking.consolidationPartnerId) this.assertCutSparesSharedWagon(cut);
|
||||
|
||||
const edrFault = !!dto.edrFault;
|
||||
const fee = edrFault ? null : await this.priceFee(booking, cut);
|
||||
const creditAmount = this.creditFor(booking, cut.wagons);
|
||||
const creditAmount = this.creditFor(booking, cut.wagons, cut.totalWagons);
|
||||
|
||||
const row = await this.repo.create({
|
||||
bookingId,
|
||||
@@ -971,6 +1006,18 @@ export class BookingWagonCancellationService {
|
||||
'This cancellation has no rebooking credit — the booking was never paid. Create a new booking instead.',
|
||||
);
|
||||
}
|
||||
// Customer-fault fee settles BEFORE the credit is redeemed. An at-loading
|
||||
// cut applies immediately and opens the credit while its invoice stays
|
||||
// open, so CREDIT_AVAILABLE alone does not mean the fee was paid — without
|
||||
// this the customer rebooks the wagons and never pays the cancellation
|
||||
// fee the notice already promised. EDR fault carries no fee and is
|
||||
// unaffected; onFeePaid stamps feePaidAt and the gate opens by itself.
|
||||
if (row.fault === 'CUSTOMER' && Number(row.feeAmount) > 0 && !row.feePaidAt) {
|
||||
throw new BadRequestException(
|
||||
`Pay the ${row.feeCurrency} ${Number(row.feeAmount).toFixed(2)} cancellation fee for ` +
|
||||
`${Math.ceil(Number(row.wagonsCancelled))} wagon(s) before rebooking this credit.`,
|
||||
);
|
||||
}
|
||||
const source = await this.bookingsRepository.findById(row.bookingId);
|
||||
if (!source) throw new NotFoundException(`Booking ${row.bookingId} not found.`);
|
||||
if (!source.contractId) {
|
||||
@@ -988,6 +1035,9 @@ export class BookingWagonCancellationService {
|
||||
let partner: Booking | null = null;
|
||||
if (oddFt20) {
|
||||
createDto.skipAutoConsolidation = true;
|
||||
// An odd credit always leaves a half-empty wagon, so GL names who fills
|
||||
// it. The candidate list is wide enough (any unpaired, unspent booking on
|
||||
// the day) that a partner is expected to exist.
|
||||
if (!dto.partnerBookingId) {
|
||||
throw new BadRequestException(
|
||||
'This credit carries an odd 20ft container — pick a consolidation partner booking to share its wagon (see the rebook-partners list).',
|
||||
@@ -998,6 +1048,15 @@ export class BookingWagonCancellationService {
|
||||
dto.partnerBookingId,
|
||||
dto.scheduledDate,
|
||||
);
|
||||
// A dead partner cannot be paid where it stands — its cargo moves to a
|
||||
// fresh booking that can carry its own invoice and pay window.
|
||||
if (['EXPIRED', 'CANCELLED'].includes(partner.status)) {
|
||||
partner = await this.cloneDeadPartner(
|
||||
partner,
|
||||
dto.scheduledDate,
|
||||
userId,
|
||||
);
|
||||
}
|
||||
}
|
||||
const created = await this.contractBooking.createUnderContract(
|
||||
source.contractId,
|
||||
@@ -1032,6 +1091,15 @@ export class BookingWagonCancellationService {
|
||||
);
|
||||
}
|
||||
if (partner) {
|
||||
// Corrections GL made to the partner's own containers while pairing —
|
||||
// scoped to that booking by the repository, so a stray id cannot touch
|
||||
// another booking's cargo.
|
||||
if (dto.partnerUnits?.length) {
|
||||
await this.bookingsRepository.patchContainerUnitsForBooking(
|
||||
partner.id,
|
||||
dto.partnerUnits,
|
||||
);
|
||||
}
|
||||
// Consolidated rebook: never allocate the half-wagon booking alone. It
|
||||
// rides PAID and the batch engine settles the pair atomically once the
|
||||
// partner's own invoice is paid.
|
||||
@@ -1083,6 +1151,13 @@ export class BookingWagonCancellationService {
|
||||
status: string;
|
||||
scheduledDate: string | null;
|
||||
ft20Quantity: number;
|
||||
units: Array<{
|
||||
id: string;
|
||||
containerSize: string;
|
||||
containerNumber: string;
|
||||
sealNumber: string | null;
|
||||
vgmTons: number;
|
||||
}>;
|
||||
}>
|
||||
> {
|
||||
const row = await this.mustFind(cancellationId);
|
||||
@@ -1103,6 +1178,18 @@ export class BookingWagonCancellationService {
|
||||
ft20Quantity: (b.bookingContainers ?? [])
|
||||
.filter((line) => Number(line.containerType?.sizeFt) === 20)
|
||||
.reduce((sum, line) => sum + Number(line.quantity || 0), 0),
|
||||
// Editable while pairing — GL corrects these on the rebook form.
|
||||
units: (b.bookingContainers ?? []).flatMap((line) =>
|
||||
(line.units ?? []).map((u) => ({
|
||||
id: u.id,
|
||||
containerSize: line.containerType?.sizeFt
|
||||
? `${line.containerType.sizeFt}ft`
|
||||
: '',
|
||||
containerNumber: u.containerNumber,
|
||||
sealNumber: u.sealNumber ?? null,
|
||||
vgmTons: Number(u.vgmTons ?? 0),
|
||||
})),
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -1121,11 +1208,29 @@ export class BookingWagonCancellationService {
|
||||
`Booking ${partner.reference} already shares a wagon with another booking.`,
|
||||
);
|
||||
}
|
||||
if (!['SUBMITTED', 'PENDING_CONSOLIDATION'].includes(partner.status)) {
|
||||
// Mirrors findRebookConsolidationCandidates: a partner need not be a live
|
||||
// committed shipment. One that lost its slot or was called off still has
|
||||
// cargo to move, and the rebooked wagon is how it moves.
|
||||
if (
|
||||
![
|
||||
'SUBMITTED',
|
||||
'PENDING_CONSOLIDATION',
|
||||
'CLEARANCE_READY',
|
||||
'OPERATION_CHANGES_REQUESTED',
|
||||
'EXPIRED',
|
||||
'CANCELLED',
|
||||
].includes(partner.status)
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
`Booking ${partner.reference} cannot be consolidated (status ${partner.status}).`,
|
||||
);
|
||||
}
|
||||
// A booking whose own credit was already rebooked elsewhere is spent.
|
||||
if (await this.bookingsRepository.hasSpentCancellationCredit(partner.id)) {
|
||||
throw new BadRequestException(
|
||||
`Booking ${partner.reference} has already been rebooked from its cancellation credit.`,
|
||||
);
|
||||
}
|
||||
if (
|
||||
partner.originYardId !== source.originYardId ||
|
||||
partner.destinationYardId !== source.destinationYardId ||
|
||||
@@ -1153,6 +1258,74 @@ export class BookingWagonCancellationService {
|
||||
return partner;
|
||||
}
|
||||
|
||||
/**
|
||||
* A dead (EXPIRED/CANCELLED) partner still has cargo to move, but it can no
|
||||
* longer be paid: its pay window is gone and finalizing it issues nothing a
|
||||
* customer can settle, so pairing the PAID rebook with it strands the shared
|
||||
* wagon forever (BK-2026-001114: EXPIRED/PENDING, paired to a PAID rebook,
|
||||
* no payment_deadline — neither half could ever board). So the cargo is
|
||||
* cloned into a fresh booking under the same contract, which finalizes
|
||||
* normally into its own invoice and pay window; the dead booking stays dead.
|
||||
*/
|
||||
private async cloneDeadPartner(
|
||||
partner: Booking,
|
||||
scheduledDate: string,
|
||||
userId?: string,
|
||||
): Promise<Booking> {
|
||||
if (!partner.contractId) {
|
||||
throw new BadRequestException(
|
||||
`Booking ${partner.reference} has no contract to rebook its cargo under — pick a live partner instead.`,
|
||||
);
|
||||
}
|
||||
const dto: CreateBookingUnderContractDto = {
|
||||
scheduledDate,
|
||||
paymentCurrency: partner.paymentCurrency ?? undefined,
|
||||
// GL already chose this pairing — the auto-matcher must not re-home the
|
||||
// clone behind their back (same reasoning as the rebooked side).
|
||||
skipAutoConsolidation: true,
|
||||
containers: (partner.bookingContainers ?? []).map((line) => {
|
||||
const units = line.units ?? [];
|
||||
return {
|
||||
containerSize: line.containerSize ?? undefined,
|
||||
quantity: Number(line.quantity),
|
||||
units: units.map((u) => ({
|
||||
containerNumber: u.containerNumber,
|
||||
sealNumber: u.sealNumber ?? '',
|
||||
vgmTons: u.vgmTons,
|
||||
isHazardous: u.isHazardous,
|
||||
isReefer: u.isReefer,
|
||||
})),
|
||||
hazardousQuantity: Number(line.hazardousQuantity ?? 0),
|
||||
reeferQuantity: Number(line.reeferQuantity ?? 0),
|
||||
};
|
||||
}) as CreateBookingUnderContractDto['containers'],
|
||||
};
|
||||
const created = await this.contractBooking.createUnderContract(
|
||||
partner.contractId,
|
||||
dto,
|
||||
{ id: userId ?? partner.createdByUserId ?? undefined },
|
||||
{ permissions: [{ key: FREIGHT_PERMS.contracts.createBooking }] },
|
||||
// The dead partner's own contract may have lapsed while it sat expired;
|
||||
// its cargo is still the cargo GL picked to fill the shared wagon.
|
||||
{ allowExpiredContract: true },
|
||||
);
|
||||
const clone = await this.bookingsRepository.findByIdWithFiles(
|
||||
created.booking.id,
|
||||
);
|
||||
if (!clone) {
|
||||
throw new NotFoundException(
|
||||
`Replacement booking for ${partner.reference} could not be loaded.`,
|
||||
);
|
||||
}
|
||||
this.notifyCustomer(
|
||||
partner,
|
||||
'Replacement booking created',
|
||||
`${partner.reference} had expired, so its cargo moved to ${clone.reference} to share a wagon with a rebooked shipment. Pay ${clone.reference} to board.`,
|
||||
clone.id,
|
||||
);
|
||||
return clone;
|
||||
}
|
||||
|
||||
/**
|
||||
* Link the rebooked (already PAID) booking with the GL-picked partner. A
|
||||
* parked partner is resumed the way pairConsolidation would resume it —
|
||||
@@ -1253,7 +1426,7 @@ export class BookingWagonCancellationService {
|
||||
booking: Booking,
|
||||
dto: RequestWagonCancellationDto,
|
||||
): Promise<RequestedCut> {
|
||||
const totalWagons = Number(booking.wagonsRequired ?? 0);
|
||||
const totalWagons = await this.wagonFootprint(booking);
|
||||
if (totalWagons <= 0) {
|
||||
throw new BadRequestException('This booking has no wagon requirement to cancel from.');
|
||||
}
|
||||
@@ -1335,6 +1508,7 @@ export class BookingWagonCancellationService {
|
||||
weightTons: weightShare,
|
||||
// Bookings without unit records fall back to the T2 LIFO trim.
|
||||
quantities: { bySize, ...(units.length === requested ? { units } : {}) },
|
||||
totalWagons,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1360,7 +1534,7 @@ export class BookingWagonCancellationService {
|
||||
if (tons <= 0) {
|
||||
throw new BadRequestException('The requested cut is too small to release cargo.');
|
||||
}
|
||||
return { wagons, weightTons: tons, quantities: { bulkTons: tons } };
|
||||
return { wagons, weightTons: tons, quantities: { bulkTons: tons }, totalWagons };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1416,6 +1590,7 @@ export class BookingWagonCancellationService {
|
||||
wagons,
|
||||
weightTons: tons,
|
||||
quantities: { bulkTons: tons, allocationIds },
|
||||
totalWagons,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1460,12 +1635,54 @@ export class BookingWagonCancellationService {
|
||||
wagons,
|
||||
weightTons: round3(units.reduce((s, u) => s + Number(u.vgmTons || 0), 0)),
|
||||
quantities: { bySize, units, allocationIds },
|
||||
totalWagons,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The booking's wagon footprint for cancellation pricing.
|
||||
*
|
||||
* `wagonsRequired` is a LIVE scheduling field: unassign clears it to NULL, so
|
||||
* a paid booking pulled off a train read 0 wagons and could not be cancelled
|
||||
* at all. `cancellationWagons` is stamped once at first allocation and never
|
||||
* cleared — read it first. A booking never allocated has neither, so size it
|
||||
* from the cargo the same way the scheduler would: TEU geometry for
|
||||
* containers, the customer's pinned count for NUMBER_OF_WAGONS bulk, tonnage
|
||||
* ÷ wagon capacity for PER_TON bulk.
|
||||
*/
|
||||
private async wagonFootprint(booking: Booking): Promise<number> {
|
||||
const pinned = Number(booking.cancellationWagons ?? 0);
|
||||
if (pinned > 0) return round2(pinned);
|
||||
const stored = Number(booking.wagonsRequired ?? 0);
|
||||
if (stored > 0) return round2(stored);
|
||||
|
||||
const requested = requestedBulkWagons(booking);
|
||||
if (requested > 0) return requested;
|
||||
|
||||
// Cargo relations drive the sizing — reload when the caller passed a bare
|
||||
// booking (findById does not always hydrate them).
|
||||
const full =
|
||||
booking.bookingContainers || booking.cargoType
|
||||
? booking
|
||||
: ((await this.dataSource.getRepository(Booking).findOne({
|
||||
where: { id: booking.id },
|
||||
relations: {
|
||||
bookingContainers: { containerType: true },
|
||||
cargoType: { wagonTypes: true },
|
||||
},
|
||||
})) ?? booking);
|
||||
const capacities = (full.cargoType?.wagonTypes ?? [])
|
||||
.map((wt) => Number(wt.capacityTons))
|
||||
.filter((c) => c > 0);
|
||||
const bulkCapacity =
|
||||
full.freightType === 'BULK' && capacities.length
|
||||
? Math.max(...capacities)
|
||||
: undefined;
|
||||
return round2(wagonsRequiredForBooking(full, bulkCapacity));
|
||||
}
|
||||
|
||||
/** Credit = the cancelled share of the ORIGINAL price (old-price rebooking). */
|
||||
private creditFor(booking: Booking, wagons: number): number {
|
||||
const totalWagons = Number(booking.wagonsRequired ?? 0);
|
||||
private creditFor(booking: Booking, wagons: number, totalWagons: number): number {
|
||||
if (totalWagons <= 0) return 0;
|
||||
return round2(Number(booking.totalAmount) * (wagons / totalWagons));
|
||||
}
|
||||
@@ -1759,6 +1976,7 @@ export class BookingWagonCancellationService {
|
||||
.getRepository(WagonAllocationContainerItem)
|
||||
.delete(cut.map((i) => i.id));
|
||||
if (cut.length === items.length) {
|
||||
await this.recordAllocationRelease(manager, [alloc.id], bookingId, 'Containers cancelled from booking');
|
||||
await manager.getRepository(WagonBookingAllocation).delete(alloc.id);
|
||||
} else {
|
||||
const cutWeight = cut.reduce((s, i) => s + Number(i.grossWeightTons ?? 0), 0);
|
||||
@@ -1805,9 +2023,66 @@ export class BookingWagonCancellationService {
|
||||
await manager
|
||||
.getRepository(WagonAllocationBulkLoad)
|
||||
.delete({ wagonBookingAllocationId: In(ids) });
|
||||
await this.recordAllocationRelease(manager, ids, bookingId, 'Wagons cancelled from booking');
|
||||
await manager.getRepository(WagonBookingAllocation).delete(ids);
|
||||
}
|
||||
|
||||
/**
|
||||
* BOOKING_CANCELLED history row for every physical wagon behind the released
|
||||
* allocations — resolved through the slot BEFORE the allocation rows go, one
|
||||
* query for the whole batch. Slots with no wagon pinned yet leave no row.
|
||||
*/
|
||||
private async recordAllocationRelease(
|
||||
manager: EntityManager,
|
||||
allocationIds: string[],
|
||||
bookingId: string,
|
||||
reason: string,
|
||||
): Promise<void> {
|
||||
if (!allocationIds.length) return;
|
||||
const rows: Array<{
|
||||
allocationId: string;
|
||||
wagonId: string;
|
||||
wagonNumber: string;
|
||||
yardId: string | null;
|
||||
trainId: string | null;
|
||||
scheduleId: string | null;
|
||||
weightTons: string | null;
|
||||
loadType: string | null;
|
||||
}> = await manager.query(
|
||||
`SELECT a.id AS "allocationId",
|
||||
w.id AS "wagonId",
|
||||
w.wagon_number AS "wagonNumber",
|
||||
w.current_yard_id AS "yardId",
|
||||
w.train_id AS "trainId",
|
||||
w.current_train_schedule_id AS "scheduleId",
|
||||
a.allocated_weight_tons AS "weightTons",
|
||||
a.load_type AS "loadType"
|
||||
FROM freight.wagon_booking_allocations a
|
||||
JOIN freight.train_set_wagons tsw ON tsw.id = a.train_set_wagon_id
|
||||
JOIN freight.wagons w ON w.id = tsw.physical_wagon_id
|
||||
WHERE a.id = ANY($1::uuid[])`,
|
||||
[allocationIds],
|
||||
);
|
||||
await this.wagonHistory.record(
|
||||
manager,
|
||||
rows.map((r) => ({
|
||||
wagonId: r.wagonId,
|
||||
wagonNumber: r.wagonNumber,
|
||||
type: WagonEventType.BookingCancelled,
|
||||
fromYardId: r.yardId,
|
||||
trainId: r.trainId,
|
||||
trainScheduleId: r.scheduleId,
|
||||
bookingId,
|
||||
reason,
|
||||
metadata: {
|
||||
allocationId: r.allocationId,
|
||||
loadType: r.loadType,
|
||||
weightTons: r.weightTons == null ? null : Number(r.weightTons),
|
||||
},
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
/** Pre-reduction quantities snapshot (only when the booking was never split before). */
|
||||
private async currentQuantities(
|
||||
manager: EntityManager,
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
ForbiddenException,
|
||||
Get,
|
||||
HttpCode,
|
||||
NotFoundException,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
@@ -86,6 +87,7 @@ import {
|
||||
import { ContractViewDto } from "./dto/contract-view.dto";
|
||||
import { CustomerTruckAssignmentDto } from "./dto/customer-truck-assignment.dto";
|
||||
import { AddCustomerTruckDto } from "./dto/add-customer-truck.dto";
|
||||
import { BulkCustomerTrucksDto } from "./dto/bulk-customer-truck.dto";
|
||||
import { DepartCustomerTruckDto } from "./dto/depart-customer-truck.dto";
|
||||
import { LoadCustomerTruckDto } from "./dto/load-customer-truck.dto";
|
||||
import { CustomerTruckService } from "./customer-truck.service";
|
||||
@@ -597,6 +599,34 @@ export class BookingsController {
|
||||
return this.bookingsService.wagonAllocations(id);
|
||||
}
|
||||
|
||||
@Get(":id/wagons/export")
|
||||
@MixedAudience(FREIGHT_PERMS.bookings.view)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Download the booking's allocated wagons as an Excel workbook (customer name + one row per wagon)",
|
||||
})
|
||||
async wagonAllocationsExport(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
@Res() res: Response,
|
||||
) {
|
||||
const booking = await this.bookingsService.findById(id);
|
||||
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
|
||||
await this.bookingsService.assertCustomerCanAccessBooking(
|
||||
user?.id,
|
||||
booking,
|
||||
);
|
||||
}
|
||||
const { filename, buffer } =
|
||||
await this.bookingsService.wagonAllocationsWorkbook(id);
|
||||
res.setHeader(
|
||||
"Content-Type",
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
);
|
||||
res.setHeader("Content-Disposition", `attachment; filename="${filename}"`);
|
||||
res.send(buffer);
|
||||
}
|
||||
|
||||
// ── Partial wagon cancellation (paid bookings) ────────────────────────────
|
||||
// Customer endpoints are ownership-scoped (no portal permission keys); the
|
||||
// staff history/void/rebook variants are permission-gated below.
|
||||
@@ -664,9 +694,11 @@ export class BookingsController {
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
const booking = await this.bookingsService.findById(id);
|
||||
// GL (createBooking) rebooks credits and must see the ledger for that.
|
||||
const staff =
|
||||
hasFreightPermission(user, FREIGHT_PERMS.bookings.view) ||
|
||||
hasFreightPermission(user, FREIGHT_PERMS.bookings.wagonCancellationView);
|
||||
hasFreightPermission(user, FREIGHT_PERMS.bookings.wagonCancellationView) ||
|
||||
hasFreightPermission(user, FREIGHT_PERMS.contracts.createBooking);
|
||||
if (!staff) {
|
||||
await this.bookingsService.assertCustomerCanAccessBooking(
|
||||
user?.id,
|
||||
@@ -783,12 +815,78 @@ export class BookingsController {
|
||||
}
|
||||
|
||||
/** Owner-or-staff gate shared by the per-cancellation actions. */
|
||||
/**
|
||||
* Scope a clearance READ that a transit agent may be making.
|
||||
*
|
||||
* Transit agents are portal accounts holding no permission and belonging to
|
||||
* no company, so the audience guards admit them but the usual company-based
|
||||
* ownership check would 404 every booking. This narrows them to the shipments
|
||||
* assigned to them and leaves every other caller — staff and owning customers
|
||||
* — on the path they already had. Purely widening: nothing that passed before
|
||||
* starts failing here.
|
||||
*/
|
||||
private async assertTransitAgentScope(
|
||||
bookingId: string,
|
||||
user: TCurrentUser,
|
||||
): Promise<void> {
|
||||
const userId = user?.id;
|
||||
if (!userId) return;
|
||||
if (!(await this.bookingsService.isTransitAgent(userId))) return;
|
||||
if (
|
||||
!(await this.bookingsService.isTransitAgentForBooking(userId, bookingId))
|
||||
) {
|
||||
// Hidden behind a NotFound so booking ids stay unprobeable, matching the
|
||||
// customer-ownership failure mode.
|
||||
throw new NotFoundException(`Booking ${bookingId} not found`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gate a formerly staff-only clearance route that is now MixedAudience.
|
||||
*
|
||||
* Staff still pass on their permission. A portal caller must be a transit
|
||||
* agent assigned to THIS booking — an ordinary customer is rejected, because
|
||||
* relaxing the guard must not hand the whole customer base a route that was
|
||||
* previously staff-only.
|
||||
*
|
||||
* Used for the Djibouti-desk WRITES too (DO/RO upload, RO amendment): the
|
||||
* assigned agent files them in the desk's place, and the assignment is the
|
||||
* only thing standing between a portal token and the customs record.
|
||||
*/
|
||||
private async assertPortalClearanceAccess(
|
||||
bookingId: string,
|
||||
user: TCurrentUser,
|
||||
): Promise<void> {
|
||||
if (
|
||||
hasFreightPermission(user, FREIGHT_PERMS.contracts.clearanceEtActions) ||
|
||||
hasFreightPermission(user, FREIGHT_PERMS.contracts.clearanceDjActions)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!(await this.bookingsService.isTransitAgentForBooking(
|
||||
user?.id,
|
||||
bookingId,
|
||||
))
|
||||
) {
|
||||
throw new NotFoundException(`Booking ${bookingId} not found`);
|
||||
}
|
||||
}
|
||||
|
||||
private async assertWagonCancellationActor(
|
||||
cancellationId: string,
|
||||
user: TCurrentUser,
|
||||
staffPermission: string,
|
||||
): Promise<void> {
|
||||
if (hasFreightPermission(user, staffPermission)) return;
|
||||
// Rebooking a credit creates a booking under the contract — GL's booking
|
||||
// creation key covers it even where the dedicated rebook key was never granted.
|
||||
if (
|
||||
staffPermission === FREIGHT_PERMS.bookings.wagonCancellationRebook &&
|
||||
hasFreightPermission(user, FREIGHT_PERMS.contracts.createBooking)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const row = await this.wagonCancellationService.findById(cancellationId);
|
||||
const booking = await this.bookingsService.findById(row.bookingId);
|
||||
await this.bookingsService.assertCustomerCanAccessBooking(
|
||||
@@ -848,7 +946,7 @@ export class BookingsController {
|
||||
})
|
||||
async bulkAddCustomerTrucks(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() payload: { trucks: AddCustomerTruckDto[] },
|
||||
@Body() payload: BulkCustomerTrucksDto,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
const booking = await this.bookingsService.findById(id);
|
||||
@@ -1128,6 +1226,9 @@ export class BookingsController {
|
||||
}
|
||||
|
||||
@Get(":id/clearance")
|
||||
// A transit agent is a portal account, so MixedAudience admits them without a
|
||||
// permission; `assertTransitAgentScope` below narrows them to the shipments
|
||||
// actually assigned to them.
|
||||
@MixedAudience([
|
||||
FREIGHT_PERMS.bookings.clearanceView,
|
||||
FREIGHT_PERMS.bookings.reviewDocuments,
|
||||
@@ -1136,7 +1237,11 @@ export class BookingsController {
|
||||
summary:
|
||||
"Document-clearance grid (required docs + upload + GL review status)",
|
||||
})
|
||||
getClearance(@Param("id", ParseUUIDPipe) id: string) {
|
||||
async getClearance(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
await this.assertTransitAgentScope(id, user);
|
||||
return this.transitionService.getClearanceView(id);
|
||||
}
|
||||
|
||||
@@ -1278,8 +1383,11 @@ export class BookingsController {
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// Was staff-only. Opened to the transit agent assigned to the shipment, who
|
||||
// needs the clearance trail for the bookings they handle; every other portal
|
||||
// account is still rejected by the scope check below.
|
||||
@Get(":id/clearance/history")
|
||||
@BookingStaff([
|
||||
@MixedAudience([
|
||||
FREIGHT_PERMS.contracts.clearanceEtActions,
|
||||
FREIGHT_PERMS.contracts.clearanceDjActions,
|
||||
])
|
||||
@@ -1287,7 +1395,11 @@ export class BookingsController {
|
||||
summary:
|
||||
"Clearance action history for the booking — reviews, workflow steps, charges (newest first)",
|
||||
})
|
||||
getClearanceHistory(@Param("id", ParseUUIDPipe) id: string) {
|
||||
async getClearanceHistory(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
await this.assertPortalClearanceAccess(id, user);
|
||||
return this.clearanceEventService.list(id);
|
||||
}
|
||||
|
||||
@@ -1310,6 +1422,12 @@ export class BookingsController {
|
||||
hasFreightPermission(user, FREIGHT_PERMS.contracts.clearanceEtActions) ||
|
||||
hasFreightPermission(user, FREIGHT_PERMS.contracts.clearanceDjActions);
|
||||
if (isStaff) return this.clearanceChargeService.list(id);
|
||||
// The transit agent handling this shipment sees the same customer-facing
|
||||
// slice the customer does — charges actually sent, never the internal
|
||||
// draft/billing view `list()` returns.
|
||||
if (await this.bookingsService.isTransitAgentForBooking(user?.id, id)) {
|
||||
return this.clearanceChargeService.listForCustomer(id);
|
||||
}
|
||||
const booking = await this.bookingsService.findById(id);
|
||||
await this.bookingsService.assertCustomerCanAccessBooking(
|
||||
user?.id,
|
||||
@@ -1777,8 +1895,10 @@ export class BookingsController {
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
// Djibouti-desk write, also filed by the transit agent assigned to this
|
||||
// shipment — `assertPortalClearanceAccess` rejects every other portal caller.
|
||||
@Post(":id/clearance/delivery-order")
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
|
||||
@MixedAudience(FREIGHT_PERMS.contracts.clearanceDjActions)
|
||||
@UseInterceptors(AnyFilesInterceptor())
|
||||
@ApiConsumes("multipart/form-data")
|
||||
async uploadBookingDeliveryOrder(
|
||||
@@ -1788,6 +1908,7 @@ export class BookingsController {
|
||||
@Body("doCollectedDate") doCollectedDate: string | undefined,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
await this.assertPortalClearanceAccess(id, user);
|
||||
const booking = await this.bookingClearanceService.uploadDeliveryOrder(
|
||||
id,
|
||||
files ?? [],
|
||||
@@ -1798,7 +1919,7 @@ export class BookingsController {
|
||||
}
|
||||
|
||||
@Post(":id/clearance/release-order")
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
|
||||
@MixedAudience(FREIGHT_PERMS.contracts.clearanceDjActions)
|
||||
@UseInterceptors(AnyFilesInterceptor())
|
||||
@ApiConsumes("multipart/form-data")
|
||||
async uploadBookingReleaseOrder(
|
||||
@@ -1807,6 +1928,7 @@ export class BookingsController {
|
||||
@Body("vesselDepartureDate") vesselDepartureDate: string,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
await this.assertPortalClearanceAccess(id, user);
|
||||
const result = await this.bookingClearanceService.uploadReleaseOrder(
|
||||
id,
|
||||
files ?? [],
|
||||
@@ -1820,13 +1942,69 @@ export class BookingsController {
|
||||
};
|
||||
}
|
||||
|
||||
// Transit-agent arrival paperwork (export): gate pass and Djibouti T1 sets.
|
||||
// Same audience rule as the DO/RO uploads above — the desk, or the agent
|
||||
// assigned to this shipment.
|
||||
@Post(":id/clearance/gate-pass-documents")
|
||||
@MixedAudience(FREIGHT_PERMS.contracts.clearanceDjActions)
|
||||
@UseInterceptors(AnyFilesInterceptor())
|
||||
@ApiConsumes("multipart/form-data")
|
||||
async uploadBookingGatePassDocuments(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@UploadedFiles() files: Express.Multer.File[],
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
await this.assertPortalClearanceAccess(id, user);
|
||||
return this.bookingClearanceService.uploadTransitArrivalDocuments(
|
||||
id,
|
||||
"gate_pass",
|
||||
files ?? [],
|
||||
resolveAuthUserId(user),
|
||||
);
|
||||
}
|
||||
|
||||
@Post(":id/clearance/djibouti-t1-documents")
|
||||
@MixedAudience(FREIGHT_PERMS.contracts.clearanceDjActions)
|
||||
@UseInterceptors(AnyFilesInterceptor())
|
||||
@ApiConsumes("multipart/form-data")
|
||||
async uploadBookingDjiboutiT1Documents(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@UploadedFiles() files: Express.Multer.File[],
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
await this.assertPortalClearanceAccess(id, user);
|
||||
return this.bookingClearanceService.uploadTransitArrivalDocuments(
|
||||
id,
|
||||
"djibouti_t1",
|
||||
files ?? [],
|
||||
resolveAuthUserId(user),
|
||||
);
|
||||
}
|
||||
|
||||
@Delete(":id/clearance/transit-documents/:fileId")
|
||||
@MixedAudience(FREIGHT_PERMS.contracts.clearanceDjActions)
|
||||
@HttpCode(204)
|
||||
async removeBookingTransitDocument(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Param("fileId", ParseUUIDPipe) fileId: string,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
await this.assertPortalClearanceAccess(id, user);
|
||||
await this.bookingClearanceService.removeTransitArrivalDocument(
|
||||
id,
|
||||
fileId,
|
||||
resolveAuthUserId(user),
|
||||
);
|
||||
}
|
||||
|
||||
@Post(":id/clearance/ro-amendment")
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
|
||||
@MixedAudience(FREIGHT_PERMS.contracts.clearanceDjActions)
|
||||
async requestBookingRoAmendment(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() dto: RoAmendmentDto,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
await this.assertPortalClearanceAccess(id, user);
|
||||
const booking = await this.bookingClearanceService.requestRoAmendment(
|
||||
id,
|
||||
dto.note,
|
||||
|
||||
@@ -6,6 +6,7 @@ import { registerExchangeModule } from "../exchange-settings/exchange-module-opt
|
||||
|
||||
// import { CustomersModule } from '../customers/customers.module';
|
||||
import { CompaniesModule } from '../companies/companies.module';
|
||||
import { ExportsModule } from '../exports/exports.module';
|
||||
import { FilesModule } from '../files/files.module';
|
||||
import { MinioModule } from '../minio/minio.module';
|
||||
import { RuleEngineModule } from '../rule-engine/rule-engine.module';
|
||||
@@ -105,6 +106,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
|
||||
// CustomersModule,
|
||||
RuleEngineModule,
|
||||
FileUploadSettingsModule,
|
||||
ExportsModule,
|
||||
SignaturesModule,
|
||||
registerExchangeModule(),
|
||||
],
|
||||
|
||||
@@ -34,10 +34,12 @@ import {
|
||||
DocumentReviewStatus,
|
||||
} from './entities/booking-document-review.entity';
|
||||
import { BookingContainer } from './entities/booking-container.entity';
|
||||
import { BookingWagonCancellation } from './entities/booking-wagon-cancellation.entity';
|
||||
import { BookingContainerUnit } from './entities/booking-container-unit.entity';
|
||||
import { BookingRateSnapshot } from './entities/booking-rate-snapshot.entity';
|
||||
import { BookingReviewNote, ReviewNoteType } from './entities/booking-review-note.entity';
|
||||
import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity';
|
||||
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
||||
import { Booking } from './entities/booking.entity';
|
||||
import {
|
||||
BookingContractSignature,
|
||||
@@ -332,21 +334,22 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
* Bookings a GL operator may manually link to `booking` as its odd-20ft
|
||||
* consolidation partner (Path B customs flow). Unlike
|
||||
* {@link findComplementaryConsolidationPartner} — which auto-pairs on an exact
|
||||
* quantity complement — this lists CANDIDATES for a human to choose from, so
|
||||
* the filter is deliberately looser: any other customs booking on the same
|
||||
* route/direction that is itself carrying an odd 20ft count. Two odd counts
|
||||
* always sum to even, so any pick fills the shared wagon.
|
||||
* quantity complement — this lists CANDIDATES for a human to choose from, but
|
||||
* every row must still be a legal pick: another customs booking on the same
|
||||
* route/direction, riding the same booking day, that is itself carrying an odd
|
||||
* 20ft count. Two odd counts always sum to even, so any pick fills the shared
|
||||
* wagon.
|
||||
*
|
||||
* Bare instances awaiting completion have no persisted containers yet, so the
|
||||
* odd-count test runs on the requested container lines when they exist and the
|
||||
* booking is offered as a candidate when they do not (GL enters its cargo on
|
||||
* the split form).
|
||||
* A booking whose cargo is not entered yet is NOT a candidate: with no
|
||||
* container lines its 20ft count is unknown, so pairing with it cannot be
|
||||
* shown to fill the wagon. Same rule as
|
||||
* {@link findRebookConsolidationCandidates}.
|
||||
*/
|
||||
async findManualConsolidationCandidates(
|
||||
booking: Booking,
|
||||
limit = 50,
|
||||
): Promise<Booking[]> {
|
||||
const rows = await this.repository
|
||||
const qb = this.repository
|
||||
.createQueryBuilder('b')
|
||||
.leftJoinAndSelect('b.bookingContainers', 'bc')
|
||||
.leftJoinAndSelect('bc.containerType', 'ct')
|
||||
@@ -376,17 +379,27 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
'OPERATION_CHANGES_REQUESTED',
|
||||
'PENDING_CONSOLIDATION',
|
||||
],
|
||||
})
|
||||
.orderBy('b.createdAt', 'ASC')
|
||||
.take(limit)
|
||||
.getMany();
|
||||
});
|
||||
|
||||
// Odd-20ft test in memory: a bare instance has no containers yet (GL fills
|
||||
// them on the split form) and stays a candidate; one that already carries
|
||||
// cargo qualifies only when its 20ft total is odd.
|
||||
// Same EAT booking day — the pair shares one physical wagon, so it must
|
||||
// board one train. Applied only when this booking has a date of its own;
|
||||
// without one there is no day to match against and route/direction stand
|
||||
// alone, mirroring findComplementaryConsolidationPartner.
|
||||
if (booking.scheduledDate) {
|
||||
qb.andWhere(
|
||||
`DATE(b.scheduled_date AT TIME ZONE 'Africa/Addis_Ababa') = DATE(:bookingDate AT TIME ZONE 'Africa/Addis_Ababa')`,
|
||||
{ bookingDate: booking.scheduledDate },
|
||||
);
|
||||
}
|
||||
|
||||
const rows = await qb.orderBy('b.createdAt', 'ASC').take(limit).getMany();
|
||||
|
||||
// Odd-20ft test in memory. A booking with no container lines has an unknown
|
||||
// 20ft count, so it cannot be shown to complete the wagon and is not
|
||||
// offered.
|
||||
return rows.filter((row) => {
|
||||
const lines = row.bookingContainers ?? [];
|
||||
if (lines.length === 0) return true;
|
||||
if (lines.length === 0) return false;
|
||||
const ft20 = lines
|
||||
.filter((line) => Number(line.containerType?.sizeFt) === 20)
|
||||
.reduce((sum, line) => sum + Number(line.quantity || 0), 0);
|
||||
@@ -396,10 +409,16 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
|
||||
/**
|
||||
* Candidate partners for rebooking an odd-20ft cancellation credit: unpaired
|
||||
* odd-20ft bookings on the same route/direction riding the requested day —
|
||||
* SUBMITTED (committed direct booking) or parked PENDING_CONSOLIDATION.
|
||||
* odd-20ft bookings on the same route/direction riding the requested day.
|
||||
* Unlike {@link findManualConsolidationCandidates} this is not customs-only:
|
||||
* GL picks who shares the rebooked wagon whatever the contract kind.
|
||||
*
|
||||
* The status set is deliberately wide. A partner here is not required to be a
|
||||
* live, committed shipment — a booking that lost its slot (EXPIRED) or was
|
||||
* cancelled still has cargo that GL can put back on a train, and pairing it
|
||||
* with the rebooked credit is how both halves get moving again. What it must
|
||||
* not be is already spoken for: a booking whose own cancellation credit has
|
||||
* been rebooked elsewhere is excluded, as is one already paired.
|
||||
*/
|
||||
async findRebookConsolidationCandidates(
|
||||
booking: Booking,
|
||||
@@ -410,6 +429,9 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
.createQueryBuilder('b')
|
||||
.leftJoinAndSelect('b.bookingContainers', 'bc')
|
||||
.leftJoinAndSelect('bc.containerType', 'ct')
|
||||
// Units come back so GL can correct the partner's container numbers,
|
||||
// seals and VGMs while pairing.
|
||||
.leftJoinAndSelect('bc.units', 'unit')
|
||||
.leftJoinAndSelect('b.company', 'company')
|
||||
.where('b.id != :bookingId', { bookingId: booking.id })
|
||||
.andWhere('b.consolidationPartnerId IS NULL')
|
||||
@@ -423,8 +445,27 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
tradeDirection: booking.tradeDirection,
|
||||
})
|
||||
.andWhere('b.status IN (:...statuses)', {
|
||||
statuses: ['SUBMITTED', 'PENDING_CONSOLIDATION'],
|
||||
statuses: [
|
||||
'SUBMITTED',
|
||||
'PENDING_CONSOLIDATION',
|
||||
'CLEARANCE_READY',
|
||||
'OPERATION_CHANGES_REQUESTED',
|
||||
// Lost its slot or was called off — its cargo is still real and can
|
||||
// ride the rebooked wagon.
|
||||
'EXPIRED',
|
||||
'CANCELLED',
|
||||
],
|
||||
})
|
||||
// A cancelled booking whose own credit was already spent on a rebook is
|
||||
// gone — pairing with it would hand the same cargo out twice.
|
||||
.andWhere(
|
||||
`NOT EXISTS (
|
||||
SELECT 1 FROM freight.booking_wagon_cancellations c
|
||||
WHERE c.booking_id = b.id
|
||||
AND c.rebooked_booking_id IS NOT NULL
|
||||
AND c.deleted_at IS NULL
|
||||
)`,
|
||||
)
|
||||
// Same EAT booking day as the rebook — the pair shares one physical
|
||||
// wagon, so it must board one train.
|
||||
.andWhere(
|
||||
@@ -729,6 +770,89 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
return new Set(rows.map((r) => r.bookingId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Bookings among `bookingIds` that hold a redeemable wagon-cancellation
|
||||
* credit — the cut is settled (CREDIT_AVAILABLE), the credit is worth
|
||||
* something, and it has not been spent on a rebook yet. Surfaced on the GL
|
||||
* clearance queue so a paid-for credit is visibly rebookable from the list
|
||||
* rather than only from the booking's own page.
|
||||
*/
|
||||
async findBookingsWithRedeemableCredit(
|
||||
bookingIds: string[],
|
||||
): Promise<Map<string, string>> {
|
||||
if (bookingIds.length === 0) return new Map();
|
||||
const rows = (await this.dataSource
|
||||
.getRepository(BookingWagonCancellation)
|
||||
.createQueryBuilder('c')
|
||||
.select('c.booking_id', 'bookingId')
|
||||
.addSelect('c.id', 'cancellationId')
|
||||
.where('c.booking_id IN (:...bookingIds)', { bookingIds })
|
||||
.andWhere('c.status = :status', { status: 'CREDIT_AVAILABLE' })
|
||||
.andWhere('c.credit_amount > 0')
|
||||
.andWhere('c.rebooked_booking_id IS NULL')
|
||||
.andWhere('c.deleted_at IS NULL')
|
||||
.getRawMany()) as Array<{ bookingId: string; cancellationId: string }>;
|
||||
return new Map(rows.map((r) => [r.bookingId, r.cancellationId]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply container-unit corrections (number / seal / VGM) to units that belong
|
||||
* to `bookingId`. The ownership join is the point: a unit id from another
|
||||
* booking silently matches nothing rather than editing a stranger's cargo.
|
||||
* Sizes and quantities are never touched — only the identifying details.
|
||||
* Returns how many units were actually updated.
|
||||
*/
|
||||
async patchContainerUnitsForBooking(
|
||||
bookingId: string,
|
||||
patches: Array<{
|
||||
id: string;
|
||||
containerNumber?: string;
|
||||
sealNumber?: string;
|
||||
vgmTons?: number;
|
||||
}>,
|
||||
): Promise<number> {
|
||||
if (patches.length === 0) return 0;
|
||||
const unitRepo = this.dataSource.getRepository(BookingContainerUnit);
|
||||
const owned = await unitRepo
|
||||
.createQueryBuilder('u')
|
||||
.innerJoin('u.bookingContainer', 'bc')
|
||||
.where('bc.booking_id = :bookingId', { bookingId })
|
||||
.andWhere('u.id IN (:...ids)', { ids: patches.map((p) => p.id) })
|
||||
.select('u.id', 'id')
|
||||
.getRawMany<{ id: string }>();
|
||||
const ownedIds = new Set(owned.map((r) => r.id));
|
||||
|
||||
let updated = 0;
|
||||
for (const patch of patches) {
|
||||
if (!ownedIds.has(patch.id)) continue;
|
||||
const set: Record<string, unknown> = {};
|
||||
if (patch.containerNumber !== undefined)
|
||||
set.containerNumber = patch.containerNumber;
|
||||
if (patch.sealNumber !== undefined) set.sealNumber = patch.sealNumber;
|
||||
if (patch.vgmTons !== undefined) set.vgmTons = patch.vgmTons;
|
||||
if (Object.keys(set).length === 0) continue;
|
||||
await unitRepo.update(patch.id, set as never);
|
||||
updated += 1;
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Has this booking's own wagon-cancellation credit already been spent on a
|
||||
* rebook? Such a booking must not be offered or accepted as a consolidation
|
||||
* partner — its cargo has already moved to the rebooked booking.
|
||||
*/
|
||||
async hasSpentCancellationCredit(bookingId: string): Promise<boolean> {
|
||||
const count = await this.dataSource
|
||||
.getRepository(BookingWagonCancellation)
|
||||
.createQueryBuilder('c')
|
||||
.where('c.booking_id = :bookingId', { bookingId })
|
||||
.andWhere('c.rebooked_booking_id IS NOT NULL')
|
||||
.andWhere('c.deleted_at IS NULL')
|
||||
.getCount();
|
||||
return count > 0;
|
||||
}
|
||||
|
||||
findDocumentReview(
|
||||
bookingId: string,
|
||||
settingCode: string,
|
||||
@@ -1043,9 +1167,38 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
select: { bookingId: true, trainScheduleId: true },
|
||||
});
|
||||
const scheduleByBooking = new Map(links.map((link) => [link.bookingId, link.trainScheduleId]));
|
||||
|
||||
// The allocated train's own departure date — distinct from the customer's
|
||||
// requested `booking.scheduledDate`. The list column shows this once a
|
||||
// booking is on a train, so fetch it alongside the link ids.
|
||||
const scheduleIds = [...new Set([...scheduleByBooking.values()].filter(Boolean))] as string[];
|
||||
const schedules = scheduleIds.length
|
||||
? await this.dataSource.getRepository(TrainSchedule).find({
|
||||
where: { id: In(scheduleIds) },
|
||||
select: {
|
||||
id: true,
|
||||
reference: true,
|
||||
trainNumber: true,
|
||||
status: true,
|
||||
scheduledDepartureDate: true,
|
||||
},
|
||||
})
|
||||
: [];
|
||||
const scheduleById = new Map(schedules.map((schedule) => [schedule.id, schedule]));
|
||||
|
||||
for (const item of items) {
|
||||
(item as Booking & { trainScheduleId?: string | null }).trainScheduleId =
|
||||
scheduleByBooking.get(item.id) ?? null;
|
||||
const scheduleId = scheduleByBooking.get(item.id) ?? null;
|
||||
const enriched = item as Booking & {
|
||||
trainScheduleId?: string | null;
|
||||
trainScheduleReference?: string | null;
|
||||
trainScheduleDepartureDate?: string | null;
|
||||
};
|
||||
enriched.trainScheduleId = scheduleId;
|
||||
const schedule = scheduleId ? scheduleById.get(scheduleId) : undefined;
|
||||
enriched.trainScheduleReference = schedule?.reference ?? schedule?.trainNumber ?? null;
|
||||
enriched.trainScheduleDepartureDate = schedule?.scheduledDepartureDate
|
||||
? new Date(schedule.scheduledDepartureDate).toISOString()
|
||||
: null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1786,6 +1939,7 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
Booking,
|
||||
| 'schedulingStatus'
|
||||
| 'wagonsRequired'
|
||||
| 'cancellationWagons'
|
||||
| 'scheduledAt'
|
||||
| 'holdStartedAt'
|
||||
| 'holdExpiresAt'
|
||||
|
||||
@@ -12,6 +12,7 @@ import { Freight, SchedulingStatus } from '@edr/types';
|
||||
import { insertWithGeneratedReference, logCtx } from '@edr/api-common';
|
||||
// import { CustomersService } from '../customers/customers.service';
|
||||
import { CompaniesService } from '../companies/companies.service';
|
||||
import { TabularExportService } from '../exports/tabular-export.service';
|
||||
import { CompanyKind, CompanyStatus } from '../companies/entities/company.entity';
|
||||
import { TrainSchedulingService } from '../train-scheduling/services/train-scheduling.service';
|
||||
import { eatDay } from '../train-scheduling/batch-window.util';
|
||||
@@ -29,6 +30,11 @@ import { DataSource, In } from 'typeorm';
|
||||
|
||||
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
|
||||
import { assertExportReceivedWithGrn, DIRECT_TO_TRAIN } from '../../common/export-received-gate';
|
||||
import {
|
||||
EDR_HAULAGE_CONFLICT_MESSAGE,
|
||||
LAST_MILE_COMMITTED_SQL,
|
||||
edrHaulsThisBooking,
|
||||
} from '../../common/mile-haulage.util';
|
||||
import { Yard } from '../rule-engine/entities/yard.entity';
|
||||
import { ServiceType } from '../rule-engine/entities/service-type.entity';
|
||||
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
||||
@@ -99,6 +105,38 @@ export interface PaginatedBookings {
|
||||
};
|
||||
}
|
||||
|
||||
/** One container on an allocated wagon (raw SQL json_agg projection). */
|
||||
export interface WagonAllocationContainer {
|
||||
containerNumber: string | null;
|
||||
sealNumber: string | null;
|
||||
positionOnWagon: number | null;
|
||||
grossWeightTons: number | null;
|
||||
sizeFt: number | null;
|
||||
}
|
||||
|
||||
/** One allocated wagon as returned by `wagonAllocations` (raw SQL projection). */
|
||||
export interface WagonAllocationRow {
|
||||
allocationId: string;
|
||||
sequenceNo: number | null;
|
||||
wagonNumber: string | null;
|
||||
wagonType: string | null;
|
||||
wagonTypeCode: string | null;
|
||||
/** numeric columns arrive as strings from pg. */
|
||||
tareWeightTons: string | null;
|
||||
capacityTons: string | null;
|
||||
lengthMeters: string | null;
|
||||
allocatedWeightTons: string | null;
|
||||
loadType: string | null;
|
||||
status: string | null;
|
||||
trainNumber: string | null;
|
||||
departureAt: string | Date | null;
|
||||
originStation: string | null;
|
||||
destinationStation: string | null;
|
||||
bulkCargoDescription: string | null;
|
||||
bulkQuantity: string | null;
|
||||
containers: WagonAllocationContainer[];
|
||||
}
|
||||
|
||||
/** One wagon line on the carriage acceptance sheet (raw SQL projection). */
|
||||
interface CarriageAcceptanceWagonRow {
|
||||
sequenceNo: number;
|
||||
@@ -112,8 +150,13 @@ interface CarriageAcceptanceWagonRow {
|
||||
departureAt: Date | null;
|
||||
marshalledAt: string | null;
|
||||
arrivalAt: string | null;
|
||||
/** Per-row stations: the slot's own board/alight yard, else the schedule's endpoints. */
|
||||
departureStation: string | null;
|
||||
arrivalStation: string | null;
|
||||
containerNumbers: string | null;
|
||||
sealNumbers: string | null;
|
||||
/** Allocation status — LOADED/DEPARTED means EDR has the cargo. */
|
||||
status: string | null;
|
||||
}
|
||||
|
||||
/** A received-but-not-yet-marshalled export line, standing in for a wagon row. */
|
||||
@@ -162,6 +205,7 @@ export class BookingsService {
|
||||
private readonly bookingContractService: BookingContractService,
|
||||
@Inject(forwardRef(() => BookingBatchService))
|
||||
private readonly bookingBatchService: BookingBatchService,
|
||||
private readonly tabularExport: TabularExportService,
|
||||
) {}
|
||||
|
||||
async assignCustomerTruck(
|
||||
@@ -169,18 +213,23 @@ export class BookingsService {
|
||||
dto: CustomerTruckAssignmentDto,
|
||||
): Promise<Booking> {
|
||||
const booking = await this.findById(bookingId);
|
||||
const hasFirstMile = Boolean(booking.firstMilePickupAddress?.trim());
|
||||
const hasLastMile = Boolean(booking.lastMileDeliveryAddress?.trim());
|
||||
const usesMileService =
|
||||
booking.tradeDirection === 'IMPORT'
|
||||
? hasLastMile
|
||||
: booking.tradeDirection === 'EXPORT'
|
||||
? hasFirstMile
|
||||
: hasFirstMile || hasLastMile;
|
||||
if (usesMileService) {
|
||||
throw new BadRequestException(
|
||||
'Customer truck assignment is only allowed when first/last mile delivery is not selected',
|
||||
);
|
||||
// Same rule as CustomerTruckService.assertSelfHaulPaid: an EDR delivery leg
|
||||
// closes self-haul only once it has been approved.
|
||||
const [commitment]: Array<{ lastMileCommitted: boolean }> = await this.dataSource.query(
|
||||
`SELECT ${LAST_MILE_COMMITTED_SQL} AS "lastMileCommitted"
|
||||
FROM freight.bookings b
|
||||
WHERE b.id = $1`,
|
||||
[bookingId],
|
||||
);
|
||||
if (
|
||||
edrHaulsThisBooking({
|
||||
tradeDirection: booking.tradeDirection ?? null,
|
||||
firstMile: booking.firstMilePickupAddress ?? null,
|
||||
lastMile: booking.lastMileDeliveryAddress ?? null,
|
||||
lastMileCommitted: Boolean(commitment?.lastMileCommitted),
|
||||
})
|
||||
) {
|
||||
throw new BadRequestException(EDR_HAULAGE_CONFLICT_MESSAGE);
|
||||
}
|
||||
if (booking.customerTruckAssignedAt) {
|
||||
throw new ConflictException('Customer truck assignment is already submitted and locked');
|
||||
@@ -263,9 +312,13 @@ export class BookingsService {
|
||||
|
||||
/**
|
||||
* Carriage acceptance sheet — one per booking, listing every wagon the booking
|
||||
* occupies. Handed to the customer when EDR accepts the cargo (export) and when
|
||||
* the wagons are allocated before marshalling (import), so it is only available
|
||||
* once the booking has wagon allocations.
|
||||
* occupies. A booking is routinely loaded in parts (some containers go, the
|
||||
* rest wait for the next train), so each row carries a Status of Loaded or
|
||||
* Not loaded and the totals count only the loaded ones: the customer sees the
|
||||
* whole plan on one page without the sheet overstating what EDR has taken.
|
||||
*
|
||||
* Handed to the customer when EDR accepts the cargo (export) and when the
|
||||
* wagons are allocated before marshalling (import).
|
||||
*/
|
||||
async carriageAcceptanceSheet(bookingId: string): Promise<{ filename: string; buffer: Buffer }> {
|
||||
const booking = await this.findById(bookingId);
|
||||
@@ -285,6 +338,9 @@ export class BookingsService {
|
||||
s.scheduled_departure_date AS "departureAt",
|
||||
so.label AS "marshalledAt",
|
||||
sd.label AS "arrivalAt",
|
||||
COALESCE(by_.label, so.label) AS "departureStation",
|
||||
COALESCE(ay.label, sd.label) AS "arrivalStation",
|
||||
a.status AS "status",
|
||||
string_agg(DISTINCT ci.container_number, ', ') AS "containerNumbers",
|
||||
string_agg(DISTINCT ci.seal_number, ', ') AS "sealNumbers"
|
||||
FROM freight.wagon_booking_allocations a
|
||||
@@ -296,13 +352,31 @@ export class BookingsService {
|
||||
ON s.train_set_id = tsw.train_set_id AND s.deleted_at IS NULL
|
||||
LEFT JOIN freight.yards so ON so.id = s.origin_station_id
|
||||
LEFT JOIN freight.yards sd ON sd.id = s.destination_station_id
|
||||
LEFT JOIN freight.yards by_ ON by_.id = tsw.board_yard_id
|
||||
LEFT JOIN freight.yards ay ON ay.id = tsw.alight_yard_id
|
||||
LEFT JOIN freight.wagon_allocation_container_items ci
|
||||
ON ci.wagon_booking_allocation_id = a.id AND ci.deleted_at IS NULL
|
||||
AND (
|
||||
$2 <> 'EXPORT' OR $3 <> 'CONTAINER' OR EXISTS (
|
||||
SELECT 1
|
||||
FROM freight.booking_container_units received_unit
|
||||
JOIN freight.booking_container received_line
|
||||
ON received_line.id = received_unit.booking_container_id
|
||||
AND received_line.deleted_at IS NULL
|
||||
WHERE received_line.booking_id = a.booking_id
|
||||
AND received_unit.container_number = ci.container_number
|
||||
AND received_unit.received_to_port = true
|
||||
AND NULLIF(TRIM(received_unit.grn_number), '') IS NOT NULL
|
||||
AND received_unit.deleted_at IS NULL
|
||||
)
|
||||
)
|
||||
WHERE a.booking_id = $1 AND a.deleted_at IS NULL
|
||||
GROUP BY tsw.id, a.id, wt.code, wt.name, w.wagon_number, wt.tare_weight_tons,
|
||||
s.train_number, s.scheduled_departure_date, so.label, sd.label
|
||||
GROUP BY tsw.id, a.id, a.status, wt.code, wt.name, w.wagon_number, wt.tare_weight_tons,
|
||||
s.train_number, s.scheduled_departure_date, so.label, sd.label,
|
||||
by_.label, ay.label
|
||||
HAVING $2 <> 'EXPORT' OR $3 <> 'CONTAINER' OR COUNT(ci.id) > 0
|
||||
ORDER BY tsw.sequence_no`,
|
||||
[bookingId],
|
||||
[bookingId, booking.tradeDirection, booking.freightType],
|
||||
);
|
||||
// Export acceptance happens at the warehouse gate, not at marshalling: EDR
|
||||
// takes custody of the cargo when it receives it, and the customer is handed
|
||||
@@ -337,17 +411,17 @@ export class BookingsService {
|
||||
)
|
||||
: booking.tradeDirection === 'EXPORT'
|
||||
? await this.dataSource.query(
|
||||
`SELECT inv.weight AS "allocatedWeightTons",
|
||||
c.container_number AS "containerNumbers"
|
||||
FROM freight.warehouse_inventory inv
|
||||
LEFT JOIN freight.containers c
|
||||
ON c.id = inv.container_id AND c.deleted_at IS NULL
|
||||
WHERE inv.booking_id = $1 AND inv.deleted_at IS NULL
|
||||
AND COALESCE(
|
||||
NULLIF(TRIM(inv.grn_number), ''),
|
||||
substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)')
|
||||
) IS NOT NULL
|
||||
ORDER BY inv.created_at`,
|
||||
`SELECT unit.vgm_tons AS "allocatedWeightTons",
|
||||
unit.container_number AS "containerNumbers",
|
||||
unit.seal_number AS "sealNumbers"
|
||||
FROM freight.booking_container_units unit
|
||||
JOIN freight.booking_container line
|
||||
ON line.id = unit.booking_container_id AND line.deleted_at IS NULL
|
||||
WHERE line.booking_id = $1
|
||||
AND unit.deleted_at IS NULL
|
||||
AND unit.received_to_port = true
|
||||
AND NULLIF(TRIM(unit.grn_number), '') IS NOT NULL
|
||||
ORDER BY unit.received_at, unit.container_number`,
|
||||
[bookingId],
|
||||
)
|
||||
: [];
|
||||
@@ -381,8 +455,12 @@ export class BookingsService {
|
||||
departureAt: null,
|
||||
marshalledAt: null,
|
||||
arrivalAt: null,
|
||||
departureStation: null,
|
||||
arrivalStation: null,
|
||||
containerNumbers: row.containerNumbers,
|
||||
sealNumbers: row.sealNumbers ?? null,
|
||||
// A received line has no allocation; it is cargo EDR already holds.
|
||||
status: null,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -403,7 +481,7 @@ export class BookingsService {
|
||||
* an array per wagon, bulk load description when the wagon carries bulk).
|
||||
* Empty array until the booking has been allocated onto a train.
|
||||
*/
|
||||
async wagonAllocations(bookingId: string): Promise<unknown[]> {
|
||||
async wagonAllocations(bookingId: string): Promise<WagonAllocationRow[]> {
|
||||
return this.dataSource.query(
|
||||
`SELECT a.id AS "allocationId",
|
||||
tsw.sequence_no AS "sequenceNo",
|
||||
@@ -457,6 +535,103 @@ export class BookingsService {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The Wagons tab's Excel export: the booking's customer identity in the KPI
|
||||
* header, then one row per allocated wagon.
|
||||
*
|
||||
* Container numbers are flattened into a single cell rather than exploded
|
||||
* into one row per container — the sheet is a wagon manifest, and a reader
|
||||
* counting rows must get the wagon count.
|
||||
*/
|
||||
async wagonAllocationsWorkbook(
|
||||
bookingId: string,
|
||||
): Promise<{ filename: string; buffer: Buffer }> {
|
||||
const booking = await this.findById(bookingId);
|
||||
const wagons = await this.wagonAllocations(bookingId);
|
||||
|
||||
// Same precedence the booking list uses: a shipping line owns its bookings
|
||||
// directly, a government booking names its institution, everyone else is
|
||||
// the customer company.
|
||||
// `shippingLineCompany` is attached by `findById` (attachShippingLineCompanies),
|
||||
// not a declared relation on the entity — hence the cast, matching that helper.
|
||||
const shippingLine = (booking as Booking & { shippingLineCompany?: { name?: string } })
|
||||
.shippingLineCompany;
|
||||
const customerName =
|
||||
shippingLine?.name ??
|
||||
(booking.isGovernment ? booking.governmentInstitution : null) ??
|
||||
booking.company?.name ??
|
||||
'—';
|
||||
|
||||
const rows = wagons.map((w) => ({
|
||||
sequenceNo: w.sequenceNo,
|
||||
wagonNumber: w.wagonNumber ?? '—',
|
||||
wagonType: w.wagonType ?? '—',
|
||||
loadType: w.loadType ?? '—',
|
||||
status: w.status ?? '—',
|
||||
tareWeightTons: w.tareWeightTons === null ? null : Number(w.tareWeightTons),
|
||||
capacityTons: w.capacityTons === null ? null : Number(w.capacityTons),
|
||||
allocatedWeightTons:
|
||||
w.allocatedWeightTons === null ? null : Number(w.allocatedWeightTons),
|
||||
lengthMeters: w.lengthMeters === null ? null : Number(w.lengthMeters),
|
||||
containerCount: w.containers?.length ?? 0,
|
||||
containerNumbers:
|
||||
(w.containers ?? []).map((c) => c.containerNumber).filter(Boolean).join(', ') || '—',
|
||||
sealNumbers:
|
||||
(w.containers ?? []).map((c) => c.sealNumber).filter(Boolean).join(', ') || '—',
|
||||
bulkCargo: w.bulkCargoDescription ?? '—',
|
||||
bulkQuantity: w.bulkQuantity === null ? null : Number(w.bulkQuantity),
|
||||
trainNumber: w.trainNumber ?? '—',
|
||||
departureAt: w.departureAt ? new Date(w.departureAt).toISOString().slice(0, 10) : '—',
|
||||
originStation: w.originStation ?? '—',
|
||||
destinationStation: w.destinationStation ?? '—',
|
||||
// Repeated on every row so the sheet survives being filtered, sorted or
|
||||
// pasted into a combined workbook, where the header block is lost.
|
||||
customerName,
|
||||
bookingReference: booking.reference,
|
||||
}));
|
||||
|
||||
const totalAllocated = rows.reduce(
|
||||
(sum, r) => sum + (r.allocatedWeightTons ?? 0),
|
||||
0,
|
||||
);
|
||||
|
||||
const buffer = await this.tabularExport.toXlsx({
|
||||
title: `Wagons ${booking.reference}`.slice(0, 31),
|
||||
description: `Wagons allocated to booking ${booking.reference} — ${customerName}`,
|
||||
label: 'booking:wagon-allocations',
|
||||
kpis: [
|
||||
{ label: 'Wagons', value: rows.length },
|
||||
{ label: 'Containers', value: rows.reduce((sum, r) => sum + r.containerCount, 0) },
|
||||
{ label: 'Allocated weight', value: Number(totalAllocated.toFixed(3)), unit: 't' },
|
||||
],
|
||||
columns: [
|
||||
{ key: 'bookingReference', label: 'Booking', type: 'string' },
|
||||
{ key: 'customerName', label: 'Customer', type: 'string' },
|
||||
{ key: 'sequenceNo', label: 'Seq', type: 'number' },
|
||||
{ key: 'wagonNumber', label: 'Wagon number', type: 'string' },
|
||||
{ key: 'wagonType', label: 'Wagon type', type: 'string' },
|
||||
{ key: 'loadType', label: 'Load type', type: 'string' },
|
||||
{ key: 'status', label: 'Status', type: 'string' },
|
||||
{ key: 'tareWeightTons', label: 'Tare', type: 'tons' },
|
||||
{ key: 'capacityTons', label: 'Capacity', type: 'tons' },
|
||||
{ key: 'allocatedWeightTons', label: 'Allocated', type: 'tons' },
|
||||
{ key: 'lengthMeters', label: 'Length (m)', type: 'number' },
|
||||
{ key: 'containerCount', label: 'Containers', type: 'number' },
|
||||
{ key: 'containerNumbers', label: 'Container numbers', type: 'string' },
|
||||
{ key: 'sealNumbers', label: 'Seal numbers', type: 'string' },
|
||||
{ key: 'bulkCargo', label: 'Bulk cargo', type: 'string' },
|
||||
{ key: 'bulkQuantity', label: 'Bulk quantity', type: 'number' },
|
||||
{ key: 'trainNumber', label: 'Train', type: 'string' },
|
||||
{ key: 'departureAt', label: 'Departure', type: 'date' },
|
||||
{ key: 'originStation', label: 'Origin', type: 'string' },
|
||||
{ key: 'destinationStation', label: 'Destination', type: 'string' },
|
||||
],
|
||||
rows,
|
||||
});
|
||||
|
||||
return { filename: `wagons-${booking.reference}.xlsx`, buffer };
|
||||
}
|
||||
|
||||
/**
|
||||
* Split the booking amount across its wagons, proportional to allocated weight
|
||||
* (equal shares when no weights are recorded). The last row absorbs the rounding
|
||||
@@ -497,7 +672,17 @@ export class BookingsService {
|
||||
const header = wagons[0];
|
||||
const sheetDate = header.departureAt ? new Date(header.departureAt) : new Date();
|
||||
|
||||
const totals = wagons.reduce(
|
||||
// Loaded = EDR has the cargo. A booking is routinely loaded in parts, so the
|
||||
// totals count only those: the sheet shows the whole plan, but must never
|
||||
// total up cargo still sitting in the yard. A received-line sheet
|
||||
// (pendingWagons) has no allocation status, and every line on it is cargo
|
||||
// already accepted, so it counts in full.
|
||||
const isLoaded = (w: CarriageAcceptanceWagonRow) =>
|
||||
pendingWagons || w.status === 'LOADED' || w.status === 'DEPARTED';
|
||||
const loadedWagons = wagons.filter(isLoaded);
|
||||
const notLoadedCount = wagons.length - loadedWagons.length;
|
||||
|
||||
const totals = loadedWagons.reduce(
|
||||
(acc, w) => ({
|
||||
tare: acc.tare + (Number(w.tareWeightTons) || 0),
|
||||
capacity: acc.capacity + (Number(w.loadCapacityTons) || 0),
|
||||
@@ -507,7 +692,7 @@ export class BookingsService {
|
||||
{ tare: 0, capacity: 0, load: 0, length: 0 },
|
||||
);
|
||||
// A wagon carrying no weight and no container is running empty under this booking.
|
||||
const fullWagons = wagons.filter(
|
||||
const fullWagons = loadedWagons.filter(
|
||||
(w) => (Number(w.allocatedWeightTons) || 0) > 0 || Boolean(w.containerNumbers),
|
||||
).length;
|
||||
|
||||
@@ -520,11 +705,14 @@ export class BookingsService {
|
||||
<td class="num">${num(w.tareWeightTons, 2)}</td>
|
||||
<td class="num">${num(w.equatedLength)}</td>
|
||||
<td class="num">${num(w.loadCapacityTons)}</td>
|
||||
<td>${esc(arrivalStation)}</td>
|
||||
<td>${esc(w.arrivalStation ?? arrivalStation)}</td>
|
||||
<td>${esc(cargoName)}</td>
|
||||
<td>${esc(departureStation)}</td>
|
||||
<td>${esc(w.departureStation ?? departureStation)}</td>
|
||||
<td>${esc(w.containerNumbers)}</td>
|
||||
<td>${esc(w.sealNumbers)}</td>
|
||||
<td class="${isLoaded(w) ? 'loaded' : 'pending'}">${
|
||||
pendingWagons ? 'Accepted' : isLoaded(w) ? 'Loaded' : 'Not loaded'
|
||||
}</td>
|
||||
<td class="num">${money(prices[i])}</td>
|
||||
</tr>`,
|
||||
)
|
||||
@@ -535,23 +723,37 @@ export class BookingsService {
|
||||
// figure from the printed sheet.
|
||||
const totalsRow = `<tr class="totals">
|
||||
<td>TOT</td>
|
||||
<td>${wagons.length} ${pendingWagons ? 'received lines' : 'wagons'}</td>
|
||||
<td>${
|
||||
pendingWagons
|
||||
? 'pending marshalling'
|
||||
: `full ${fullWagons} / empty ${wagons.length - fullWagons}`
|
||||
}</td>
|
||||
<td>${loadedWagons.length} ${pendingWagons ? 'received lines' : 'wagons loaded'}</td>
|
||||
<td></td>
|
||||
<td class="num">${num(totals.tare, 2)}</td>
|
||||
<td class="num">${num(totals.length)}</td>
|
||||
<td class="num">${num(totals.capacity)}</td>
|
||||
<td></td>
|
||||
<td>Gross ${num(totals.tare + totals.load)} T</td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td>${notLoadedCount > 0 ? `loaded only (${notLoadedCount} not loaded)` : ''}</td>
|
||||
<td class="num">${money(totalAmount)}</td>
|
||||
</tr>`;
|
||||
|
||||
// The signed footer of the paper sheet. Rendered as .tile so the
|
||||
// Chromium-less fallback (buildTabularFallbackPdf parses .tile, not
|
||||
// arbitrary divs) still prints every figure.
|
||||
const footer = `
|
||||
<div class="summary footer-summary">
|
||||
<div class="tile"><span>In Total Wagon No.</span><strong>${loadedWagons.length}</strong></div>
|
||||
<div class="tile"><span>Tare Weight (T)</span><strong>${num(totals.tare, 2)}</strong></div>
|
||||
<div class="tile"><span>Load Capacity (T)</span><strong>${num(totals.capacity)}</strong></div>
|
||||
<div class="tile"><span>Gross Weight (T)</span><strong>${num(totals.tare + totals.load)}</strong></div>
|
||||
<div class="tile"><span>Equated Length</span><strong>${num(totals.length)}</strong></div>
|
||||
<div class="tile"><span>Full Wagon</span><strong>${pendingWagons ? '-' : fullWagons}</strong></div>
|
||||
<div class="tile"><span>Empty Wagon</span><strong>${
|
||||
pendingWagons ? '-' : loadedWagons.length - fullWagons
|
||||
}</strong></div>
|
||||
<div class="tile"><span>Total Amount (${esc(currency)})</span><strong>${money(totalAmount)}</strong></div>
|
||||
</div>`;
|
||||
|
||||
return `<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
@@ -568,6 +770,8 @@ export class BookingsService {
|
||||
.meta { text-align: right; font-size: 11px; color: #475569; min-width: 210px; }
|
||||
.meta strong { display: block; margin-top: 4px; color: #0f172a; font-size: 15px; }
|
||||
.summary { display: grid; grid-template-columns: repeat(6, 1fr); gap: 8px; margin: 14px 0; }
|
||||
.footer-summary { grid-template-columns: repeat(8, 1fr); margin: 10px 0 0; }
|
||||
.footer-summary .tile { background: #f8fafc; }
|
||||
.tile { border: 1px solid #cbd5e1; padding: 8px; min-height: 50px; }
|
||||
.tile span { display: block; color: #64748b; font-size: 9px; text-transform: uppercase; letter-spacing: .05em; margin-bottom: 4px; }
|
||||
.tile strong { font-size: 11px; }
|
||||
@@ -575,6 +779,8 @@ export class BookingsService {
|
||||
th { background: #f8fafc; color: #475569; text-align: left; }
|
||||
th, td { border: 1px solid #cbd5e1; padding: 5px 6px; font-size: 9.5px; vertical-align: top; }
|
||||
.num { text-align: right; }
|
||||
.loaded { color: #0f766e; font-weight: 700; }
|
||||
.pending { color: #b45309; font-weight: 700; }
|
||||
tr.totals td { background: #f8fafc; font-weight: 700; }
|
||||
.notice { margin-top: 10px; border-left: 4px solid #0f766e; background: #f0fdfa; padding: 8px 10px; font-size: 10px; color: #134e4a; }
|
||||
.signatures { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; margin-top: 34px; }
|
||||
@@ -618,6 +824,7 @@ export class BookingsService {
|
||||
<th>Departure Station</th>
|
||||
<th>Container No.</th>
|
||||
<th>Seal No.</th>
|
||||
<th>Status</th>
|
||||
<th class="num">Price (${esc(currency)})</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -626,6 +833,7 @@ export class BookingsService {
|
||||
${totalsRow}
|
||||
</tbody>
|
||||
</table>
|
||||
${footer}
|
||||
|
||||
<div class="notice">
|
||||
${
|
||||
@@ -1988,6 +2196,65 @@ export class BookingsService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* True when `userId` is a transit agent currently assigned to this booking.
|
||||
*
|
||||
* Deliberately NOT folded into {@link assertCustomerCanAccessBooking}: that
|
||||
* assertion guards ~29 call sites, including wagon cancellations, rebooking
|
||||
* and customer-truck writes. A transit agent must reach the clearance READS
|
||||
* for the shipments they handle and nothing else, so the two ownership rules
|
||||
* stay separate and each caller opts in explicitly.
|
||||
*
|
||||
* Queried directly rather than through TransitAssignmentsService: that module
|
||||
* imports BookingsModule, so injecting it here would close an import cycle.
|
||||
*/
|
||||
/** Is this portal account a transit agent at all? */
|
||||
async isTransitAgent(userId: string | undefined): Promise<boolean> {
|
||||
if (!userId) return false;
|
||||
const rows: { one: number }[] = await this.dataSource.query(
|
||||
`SELECT 1 AS one
|
||||
FROM freight.transit_agents a
|
||||
WHERE a.user_id = $1 AND a.deleted_at IS NULL
|
||||
LIMIT 1`,
|
||||
[userId],
|
||||
);
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
async isTransitAgentForBooking(
|
||||
userId: string | undefined,
|
||||
bookingId: string,
|
||||
): Promise<boolean> {
|
||||
if (!userId) return false;
|
||||
const rows: { one: number }[] = await this.dataSource.query(
|
||||
`SELECT 1 AS one
|
||||
FROM freight.transit_assignments ta
|
||||
JOIN freight.transit_agents a ON a.id = ta.transit_agent_id
|
||||
WHERE a.user_id = $1
|
||||
AND ta.booking_id = $2
|
||||
AND ta.deleted_at IS NULL
|
||||
AND a.deleted_at IS NULL
|
||||
LIMIT 1`,
|
||||
[userId, bookingId],
|
||||
);
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Authorize a clearance READ on one booking for either audience a portal
|
||||
* account can be: the owning customer, or a transit agent assigned to it.
|
||||
*
|
||||
* Read-only by contract — every caller is a GET. Writes keep using
|
||||
* {@link assertCustomerCanAccessBooking}, which a transit agent never passes.
|
||||
*/
|
||||
async assertCanReadBookingClearance(
|
||||
userId: string | undefined,
|
||||
booking: Booking,
|
||||
): Promise<void> {
|
||||
if (await this.isTransitAgentForBooking(userId, booking.id)) return;
|
||||
await this.assertCustomerCanAccessBooking(userId, booking);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the customer-facing shipment tracking payload for a booking from the
|
||||
* train schedule it is assigned to and the live checkpoint log. The caller is
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
|
||||
@@ -105,6 +105,31 @@ export class RebookContainerLineDto {
|
||||
units!: RebookUnitDto[];
|
||||
}
|
||||
|
||||
/** One edited container unit on the consolidation partner booking. */
|
||||
export class PartnerUnitPatchDto {
|
||||
@ApiProperty({ description: 'Id of the partner booking container unit being edited' })
|
||||
@IsUUID()
|
||||
id!: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Container number' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(64)
|
||||
containerNumber?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Seal number' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(64)
|
||||
sealNumber?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'VGM (tons) of the unit' })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
vgmTons?: number;
|
||||
}
|
||||
|
||||
export class RebookCancelledWagonsDto {
|
||||
@ApiProperty({ description: 'Shipment day the credit is rebooked onto (ISO date)' })
|
||||
@IsDateString()
|
||||
@@ -132,6 +157,19 @@ export class RebookCancelledWagonsDto {
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
partnerBookingId?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'Corrections to the partner booking\'s own container units (number / seal ' +
|
||||
'/ VGM). Only the units listed are touched; sizes and quantities are never ' +
|
||||
'changed. Ignored unless partnerBookingId is set.',
|
||||
type: [PartnerUnitPatchDto],
|
||||
})
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => PartnerUnitPatchDto)
|
||||
partnerUnits?: PartnerUnitPatchDto[];
|
||||
}
|
||||
|
||||
export class FilterWagonCancellationsDto {
|
||||
@@ -183,6 +221,19 @@ export class CancelRemainingWagonsDto {
|
||||
@IsUUID('4')
|
||||
scheduleId!: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'Cancel only THESE never-loaded wagons (wagon_booking_allocation ids from ' +
|
||||
'GET /bookings/:id/wagons). Omit to cancel the whole unloaded remainder. ' +
|
||||
'Already-loaded wagons are rejected — they are riding.',
|
||||
type: [String],
|
||||
})
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayNotEmpty()
|
||||
@IsUUID('4', { each: true })
|
||||
wagonAllocationIds?: string[];
|
||||
|
||||
@ApiProperty({ description: 'Why the remaining wagons are not riding' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
|
||||
@@ -543,6 +543,13 @@ export class Booking extends BaseEntity {
|
||||
@Column({ name: 'wagons_required', type: 'numeric', precision: 6, scale: 2, nullable: true })
|
||||
wagonsRequired?: number | null;
|
||||
|
||||
// Wagon footprint pinned for cancellation pricing. `wagonsRequired` above is
|
||||
// a LIVE scheduling field that unassign clears; this one is stamped once at
|
||||
// first allocation and never cleared, so a paid booking pulled off a train
|
||||
// can still price its cancellation fee and credit.
|
||||
@Column({ name: 'cancellation_wagons', type: 'numeric', precision: 6, scale: 2, nullable: true })
|
||||
cancellationWagons?: number | null;
|
||||
|
||||
@Column({ name: 'scheduling_status', type: 'varchar', length: 30, default: 'NOT_SCHEDULED' })
|
||||
schedulingStatus!: string;
|
||||
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -57,8 +57,10 @@ import { CompanyInfoResponseDto } from "./dto/company-info-response.dto";
|
||||
import {
|
||||
AccountInfoResponse,
|
||||
ShippingLineInfoResponseDto,
|
||||
TransitAgentInfoResponseDto,
|
||||
} from "./dto/account-info-response.dto";
|
||||
import { ShippingLineCompaniesService } from "../shipping-lines/shipping-line-companies.service";
|
||||
import { TransitAgentsService } from "../transit-agents/transit-agents.service";
|
||||
import { UpdateProfileDto } from "./dto/update-profile.dto";
|
||||
import { ProfileResponseDto } from "./dto/profile-response.dto";
|
||||
import { DashboardSummaryResponseDto } from "./dto/dashboard-summary-response.dto";
|
||||
@@ -104,6 +106,7 @@ export class CompaniesController {
|
||||
private readonly companiesService: CompaniesService,
|
||||
private readonly filesService: FilesService,
|
||||
private readonly shippingLineCompaniesService: ShippingLineCompaniesService,
|
||||
private readonly transitAgentsService: TransitAgentsService,
|
||||
) { }
|
||||
|
||||
/**
|
||||
@@ -133,10 +136,10 @@ export class CompaniesController {
|
||||
async getInfo(
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
): Promise<AccountInfoResponse> {
|
||||
// A shipping line has no company and no external profile, so the customer
|
||||
// lookup below would 404. Checked first, and reported with an explicit
|
||||
// `accountKind` so the portal can skip onboarding for shipping lines
|
||||
// without inferring it from a missing company.
|
||||
// Neither a shipping line nor a transit agent has a company or an external
|
||||
// profile, so the customer lookup below would 404 for both. Checked first,
|
||||
// and reported with an explicit `accountKind` so the portal can skip
|
||||
// onboarding for them without inferring it from a missing company.
|
||||
const shippingLine = await this.shippingLineCompaniesService.findByUserId(
|
||||
user.id,
|
||||
);
|
||||
@@ -144,6 +147,11 @@ export class CompaniesController {
|
||||
return new ShippingLineInfoResponseDto(shippingLine);
|
||||
}
|
||||
|
||||
const transitAgent = await this.transitAgentsService.findByUserId(user.id);
|
||||
if (transitAgent) {
|
||||
return new TransitAgentInfoResponseDto(transitAgent);
|
||||
}
|
||||
|
||||
const { profile, company } =
|
||||
await this.companiesService.getCompanyInfoByUserId(user.id);
|
||||
const review = await this.companiesService.getOpenChangeRequestForCompany(
|
||||
|
||||
@@ -18,6 +18,7 @@ import { CompanyChangeRequest } from "./entities/company-change-request.entity";
|
||||
import { CompanyRevision } from "./entities/company-revision.entity";
|
||||
import { Booking } from "../bookings/entities/booking.entity";
|
||||
import { ShippingLineCompaniesModule } from "../shipping-lines/shipping-line-companies.module";
|
||||
import { TransitAgentsModule } from "../transit-agents/transit-agents.module";
|
||||
import { CompanyProfileRepository } from "./company-profile.repository";
|
||||
import { CompanyChangeRequestRepository } from "./company-change-request.repository";
|
||||
import { CompanyRevisionRepository } from "./company-revision.repository";
|
||||
@@ -49,6 +50,10 @@ import { VerifaydaModule } from "../verifayda/verifayda.module";
|
||||
// shipping-line session, which has no company row to look up. forwardRef
|
||||
// because that module imports BillingModule, which imports this one.
|
||||
forwardRef(() => ShippingLineCompaniesModule),
|
||||
// `GET /companies/getInfo` resolves a transit-agent session before falling
|
||||
// through to the customer lookup. TransitAgentsModule is a leaf here — it
|
||||
// does not import CompaniesModule — so no forwardRef is needed.
|
||||
TransitAgentsModule,
|
||||
],
|
||||
controllers: [CompaniesController],
|
||||
providers: [
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
|
||||
import { ShippingLineCompany } from "../../shipping-lines/entities/shipping-line-company.entity";
|
||||
import { TransitAgent } from "../../transit-agents/entities/transit-agent.entity";
|
||||
import { CompanyInfoResponseDto } from "./company-info-response.dto";
|
||||
|
||||
/**
|
||||
@@ -9,10 +10,10 @@ import { CompanyInfoResponseDto } from "./company-info-response.dto";
|
||||
* The portal keys its onboarding gate off this rather than off "is `company`
|
||||
* missing?": a failed or slow company fetch also leaves `company` empty, and
|
||||
* treating that as "no onboarding needed" would let customers skip onboarding
|
||||
* whenever the request failed. A shipping line is identified positively, and
|
||||
* anything else defaults to `customer`.
|
||||
* whenever the request failed. A shipping line and a transit agent are each
|
||||
* identified positively, and anything else defaults to `customer`.
|
||||
*/
|
||||
export type AccountKind = "customer" | "shipping_line";
|
||||
export type AccountKind = "customer" | "shipping_line" | "transit_agent";
|
||||
|
||||
/** The signed-in shipping line. No company, no profile, no onboarding. */
|
||||
export class ShippingLineInfoResponseDto {
|
||||
@@ -61,6 +62,62 @@ export class ShippingLineInfoResponseDto {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The signed-in transit agent. Like a shipping line: no company, no profile, no
|
||||
* onboarding — but a separate account kind because the two share nothing beyond
|
||||
* that, and the portal shows each a different (much smaller) set of tabs.
|
||||
*/
|
||||
export class TransitAgentInfoResponseDto {
|
||||
@ApiProperty({ enum: ["transit_agent"] })
|
||||
accountKind: "transit_agent" = "transit_agent";
|
||||
|
||||
@ApiProperty()
|
||||
id: string;
|
||||
|
||||
@ApiProperty()
|
||||
name: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
email?: string | null;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
phoneNumber?: string | null;
|
||||
|
||||
@ApiProperty()
|
||||
isActive: boolean;
|
||||
|
||||
@ApiProperty({
|
||||
description: "Start of the agent's validity window (yyyy-MM-dd)",
|
||||
})
|
||||
validFrom: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: "End of the agent's validity window (yyyy-MM-dd)",
|
||||
})
|
||||
validTo: string;
|
||||
|
||||
/** Always null — see {@link ShippingLineInfoResponseDto.company}. */
|
||||
@ApiProperty({ nullable: true })
|
||||
company: null = null;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
profile: null = null;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
review: null = null;
|
||||
|
||||
constructor(entity: TransitAgent) {
|
||||
this.id = entity.id;
|
||||
this.name = entity.name;
|
||||
this.email = entity.email ?? null;
|
||||
this.phoneNumber = entity.phoneNumber ?? null;
|
||||
this.isActive = entity.isActive;
|
||||
this.validFrom = entity.validFrom;
|
||||
this.validTo = entity.validTo;
|
||||
}
|
||||
}
|
||||
|
||||
export type AccountInfoResponse =
|
||||
| (CompanyInfoResponseDto & { accountKind: "customer" })
|
||||
| ShippingLineInfoResponseDto;
|
||||
| ShippingLineInfoResponseDto
|
||||
| TransitAgentInfoResponseDto;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,9 @@ function makeService(overrides?: {
|
||||
findBookingsWithUnreviewedDocuments: jest
|
||||
.fn()
|
||||
.mockResolvedValue(new Set<string>()),
|
||||
findBookingsWithRedeemableCredit: jest
|
||||
.fn()
|
||||
.mockResolvedValue(new Map<string, string>()),
|
||||
};
|
||||
const bookingsService = {
|
||||
findById: jest.fn().mockResolvedValue(booking),
|
||||
@@ -116,6 +119,7 @@ function makeService(overrides?: {
|
||||
.fn()
|
||||
.mockResolvedValue({ id: 'ta-1', name: 'Ahmed Bourhan' }),
|
||||
} as never, // transit agents
|
||||
{ ensureAssignment: jest.fn() } as never, // transit assignments
|
||||
{ findAll: jest.fn().mockResolvedValue([]) } as never, // contracts repository
|
||||
{ getScopedYardIds: jest.fn().mockResolvedValue(overrides?.yardScope ?? null) } as never, // yard scope
|
||||
{ record: jest.fn() } as never, // clearanceEvents
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { In } from 'typeorm';
|
||||
import {
|
||||
ContractDocPhase,
|
||||
@@ -30,10 +30,12 @@ import { ClearanceMilestoneService } from './clearance-milestone.service';
|
||||
import { GlOperationsService } from './gl-operations.service';
|
||||
import { GlExchangeService } from './gl-exchange.service';
|
||||
import { TransitAgentsService } from '../transit-agents/transit-agents.service';
|
||||
import { TransitAssignmentsService } from '../transit-assignments/transit-assignments.service';
|
||||
import { YardScopeService } from '../rule-engine/services/yard-scope.service';
|
||||
import { ContractsRepository } from './contracts.repository';
|
||||
import { AdviseContractDutyDto } from './dto/phased-clearance.dto';
|
||||
import { buildWorkflowFiles, belongsOnDjClearanceQueue, belongsOnEtClearanceQueue, DJ_BOOKING_QUEUE_STATUSES, persistDeclarationUploads, persistDeliveryOrderUploads, persistDraftDeclarationUploads, persistReleaseOrderUploads, persistTransitPermitUploads, PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES } from './phased-clearance.util';
|
||||
import { buildWorkflowFiles, belongsOnDjClearanceQueue, belongsOnEtClearanceQueue, DJ_BOOKING_QUEUE_STATUSES, persistDeclarationUploads, persistDeliveryOrderUploads, persistDraftDeclarationUploads, persistReleaseOrderUploads, persistTransitArrivalUploads, persistTransitPermitUploads, PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES, transitArrivalDocumentMatcher } from './phased-clearance.util';
|
||||
import type { TransitArrivalDocumentKind } from '@edr/types';
|
||||
|
||||
import {
|
||||
buildClearanceDocHistory,
|
||||
@@ -45,6 +47,8 @@ import { clearanceDocumentsOpen } from '../bookings/clearance.util';
|
||||
const RO_VESSEL_MIN_DAYS_CODE = 'ro_vessel_min_days';
|
||||
|
||||
export interface BookingClearanceView {
|
||||
/** Booking creation stamp — the import DO is timed from it. */
|
||||
bookingCreatedAt?: string | null;
|
||||
bookingId: string;
|
||||
status: string;
|
||||
includesCustoms: boolean;
|
||||
@@ -176,6 +180,7 @@ export class BookingClearanceService {
|
||||
private readonly notifier: BookingLifecycleNotifierService,
|
||||
private readonly glExchangeService: GlExchangeService,
|
||||
private readonly transitAgentsService: TransitAgentsService,
|
||||
private readonly transitAssignmentsService: TransitAssignmentsService,
|
||||
private readonly contractsRepository: ContractsRepository,
|
||||
private readonly yardScope: YardScopeService,
|
||||
private readonly clearanceEvents: ClearanceEventService,
|
||||
@@ -365,6 +370,7 @@ export class BookingClearanceService {
|
||||
return {
|
||||
bookingId,
|
||||
status: booking.status,
|
||||
bookingCreatedAt: booking.createdAt ? new Date(booking.createdAt).toISOString() : null,
|
||||
includesCustoms,
|
||||
inputCode,
|
||||
outputCode,
|
||||
@@ -385,6 +391,7 @@ export class BookingClearanceService {
|
||||
status: m.status,
|
||||
ownerRegion: m.ownerRegion,
|
||||
metadata: (m.metadata ?? null) as Record<string, unknown> | null,
|
||||
triggeredAt: m.triggeredAt ? new Date(m.triggeredAt).toISOString() : null,
|
||||
sortOrder: m.sortOrder,
|
||||
})),
|
||||
nextAction,
|
||||
@@ -593,6 +600,17 @@ export class BookingClearanceService {
|
||||
transitAssigneeName: agent.name,
|
||||
transitAssigneeAssignedAt: new Date(),
|
||||
} as never);
|
||||
|
||||
// The booking only stores the officer's NAME, which is what the clearance
|
||||
// UI reads. The agent's own portal works off `transit_assignments` rows, so
|
||||
// without this the shipment never reaches the officer's work list — the
|
||||
// desk believes it handed the job over and nothing arrives.
|
||||
await this.transitAssignmentsService.ensureAssignment(
|
||||
bookingId,
|
||||
transitAgentId,
|
||||
userId,
|
||||
);
|
||||
|
||||
await this.clearanceEvents.record({
|
||||
bookingId,
|
||||
action: 'TRANSIT_ASSIGNEE_ASSIGNED',
|
||||
@@ -1161,6 +1179,83 @@ export class BookingClearanceService {
|
||||
return { booking: await this.bookingsService.findById(bookingId), hold: false };
|
||||
}
|
||||
|
||||
// ── Transit-agent arrival paperwork (export) ────────────────────────────
|
||||
// Gate pass and Djibouti T1 documents the assigned transit officer files at
|
||||
// Djibouti around train arrival. Append-only sets with per-file removal — see
|
||||
// `persistTransitArrivalUploads`. The clearance view stamps every file with
|
||||
// its upload time, so the portal can measure it against train departure and
|
||||
// arrival without a separate ledger.
|
||||
|
||||
private static readonly TRANSIT_ARRIVAL_LABELS: Record<
|
||||
TransitArrivalDocumentKind,
|
||||
{ name: string; uploaded: string; removed: string }
|
||||
> = {
|
||||
gate_pass: {
|
||||
name: 'gate pass',
|
||||
uploaded: 'GATE_PASS_DOCUMENTS_UPLOADED',
|
||||
removed: 'GATE_PASS_DOCUMENT_REMOVED',
|
||||
},
|
||||
djibouti_t1: {
|
||||
name: 'Djibouti T1',
|
||||
uploaded: 'DJIBOUTI_T1_DOCUMENTS_UPLOADED',
|
||||
removed: 'DJIBOUTI_T1_DOCUMENT_REMOVED',
|
||||
},
|
||||
};
|
||||
|
||||
async uploadTransitArrivalDocuments(
|
||||
bookingId: string,
|
||||
kind: TransitArrivalDocumentKind,
|
||||
files: Express.Multer.File[],
|
||||
userId?: string,
|
||||
): Promise<{ uploaded: number }> {
|
||||
const booking = await this.loadBooking(bookingId);
|
||||
if (booking.tradeDirection !== 'EXPORT') {
|
||||
throw new BadRequestException(
|
||||
'Gate pass and Djibouti T1 documents apply only to export bookings.',
|
||||
);
|
||||
}
|
||||
const labels = BookingClearanceService.TRANSIT_ARRIVAL_LABELS[kind];
|
||||
await persistTransitArrivalUploads(this.filesService, bookingId, kind, files ?? [], userId);
|
||||
await this.clearanceEvents.record({
|
||||
bookingId,
|
||||
action: labels.uploaded,
|
||||
label: `Uploaded ${files.length} ${labels.name} document(s)`,
|
||||
actorId: userId ?? null,
|
||||
metadata: { kind, fileNames: (files ?? []).map((f) => f.originalname) },
|
||||
});
|
||||
return { uploaded: files.length };
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove ONE gate pass / Djibouti T1 file. Only those two code families are
|
||||
* removable here: the route is reachable by the transit agent, and it must
|
||||
* never become a way to delete a declaration or a Release Order.
|
||||
*/
|
||||
async removeTransitArrivalDocument(
|
||||
bookingId: string,
|
||||
fileId: string,
|
||||
userId?: string,
|
||||
): Promise<void> {
|
||||
await this.loadBooking(bookingId);
|
||||
const files = await this.filesService.findByResource(bookingId, 'bookings');
|
||||
const file = files.find((f) => f.id === fileId);
|
||||
const kind = (['gate_pass', 'djibouti_t1'] as const).find((k) =>
|
||||
transitArrivalDocumentMatcher(k)(file?.code),
|
||||
);
|
||||
if (!file || !kind) {
|
||||
throw new NotFoundException('Document not found on this booking.');
|
||||
}
|
||||
await this.filesService.remove(fileId);
|
||||
const labels = BookingClearanceService.TRANSIT_ARRIVAL_LABELS[kind];
|
||||
await this.clearanceEvents.record({
|
||||
bookingId,
|
||||
action: labels.removed,
|
||||
label: `Removed ${labels.name} document ${file.name}`,
|
||||
actorId: userId ?? null,
|
||||
metadata: { kind, fileName: file.name },
|
||||
});
|
||||
}
|
||||
|
||||
async requestRoAmendment(
|
||||
bookingId: string,
|
||||
note?: string,
|
||||
@@ -1252,6 +1347,17 @@ export class BookingClearanceService {
|
||||
.hasDocumentsAwaitingReview = pending.has(b.id);
|
||||
}
|
||||
|
||||
// A cancelled booking may still hold a paid-for wagon-cancellation credit.
|
||||
// GL redeems it from this queue, so the row carries the cancellation id the
|
||||
// rebook action needs.
|
||||
const credits = await this.bookingsRepository.findBookingsWithRedeemableCredit(
|
||||
filtered.map((b) => b.id),
|
||||
);
|
||||
for (const b of filtered) {
|
||||
(b as Booking & { rebookableCancellationId?: string | null })
|
||||
.rebookableCancellationId = credits.get(b.id) ?? null;
|
||||
}
|
||||
|
||||
const rows = await this.attachContractSummary(filtered);
|
||||
return this.narrowToYardScope(rows, user);
|
||||
}
|
||||
|
||||
@@ -183,8 +183,8 @@ describe('ContractBookingService — manual odd-20ft consolidation', () => {
|
||||
{ quantity: 4, containerType: { sizeFt: 20 } },
|
||||
],
|
||||
},
|
||||
// A bare instance has no cargo yet — GL enters it on the split form, so it
|
||||
// stays a candidate.
|
||||
// Cargo not entered yet — its 20ft count is unknown, so it cannot be
|
||||
// shown to fill the wagon and is not offered.
|
||||
{ id: 'bare', reference: 'BK-BARE', bookingContainers: [] },
|
||||
];
|
||||
|
||||
@@ -198,7 +198,7 @@ describe('ContractBookingService — manual odd-20ft consolidation', () => {
|
||||
rows.filter((row) => {
|
||||
void booking;
|
||||
const lines = row.bookingContainers ?? [];
|
||||
if (lines.length === 0) return true;
|
||||
if (lines.length === 0) return false;
|
||||
const ft20 = lines
|
||||
.filter((l) => Number(l.containerType?.sizeFt) === 20)
|
||||
.reduce((sum, l) => sum + Number(l.quantity || 0), 0);
|
||||
@@ -209,8 +209,8 @@ describe('ContractBookingService — manual odd-20ft consolidation', () => {
|
||||
});
|
||||
|
||||
const candidates = await service.listConsolidationCandidates('c-1', 'b-1');
|
||||
expect(candidates.map((c) => c.reference)).toEqual(['BK-ODD', 'BK-BARE']);
|
||||
expect(candidates.map((c) => c.reference)).toEqual(['BK-ODD']);
|
||||
expect(candidates[0].ft20Quantity).toBe(3);
|
||||
expect(candidates[1].hasCargo).toBe(false);
|
||||
expect(candidates[0].hasCargo).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -176,6 +176,18 @@ export class ContractNotifierService {
|
||||
this.inApp(c, 'Contract suspension lifted', msg);
|
||||
}
|
||||
|
||||
/**
|
||||
* Backoffice cancelled the contract. Terminal — the customer is told they may
|
||||
* submit a new contract with the same details if they still need the service.
|
||||
*/
|
||||
cancelledByStaff(c: Contract, reason: string): void {
|
||||
const msg =
|
||||
`Your contract ${c.reference} has been cancelled. Reason: ${reason}. ` +
|
||||
`If you still need this service you can submit a new contract request with the same details.`;
|
||||
void this.notifyContact(c, msg, 'CANCELLED');
|
||||
this.inApp(c, 'Contract cancelled', msg);
|
||||
}
|
||||
|
||||
/** Customer cancelled their own contract — staff-side record. */
|
||||
cancelledByCustomer(c: Contract, reason: string): void {
|
||||
this.inAppStaff(
|
||||
|
||||
@@ -120,3 +120,34 @@ describe('contract base freight is priced on the contract lane only', () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('contract base freight ignores shipping-line rates', () => {
|
||||
it("never prices a customer contract off a line's negotiated rate (CTR-2026-00049)", async () => {
|
||||
// Both LIVE on the contract's own lane: the line rate sorted first and won,
|
||||
// so the contract quoted 32 USD/wagon instead of the standard 1690.
|
||||
const breakdown = await service([
|
||||
rate({
|
||||
containerTypeId: CT20,
|
||||
rateValue: 32,
|
||||
rateUnit: 'PER_WAGON',
|
||||
shippingLineCompanyId: 'line-1',
|
||||
}),
|
||||
rate({ containerTypeId: CT20, rateValue: 1690, rateUnit: 'PER_WAGON' }),
|
||||
]).buildBreakdown(contract({}));
|
||||
expect(breakdown.lineItems).toEqual([
|
||||
expect.objectContaining({ code: 'CONTAINER_20FT', unitPrice: 1690 }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('blocks when the only rate on the lane belongs to a shipping line', async () => {
|
||||
await expect(
|
||||
service([
|
||||
rate({
|
||||
containerTypeId: CT20,
|
||||
rateValue: 32,
|
||||
shippingLineCompanyId: 'line-1',
|
||||
}),
|
||||
]).buildBreakdown(contract({})),
|
||||
).rejects.toThrow(UnprocessableEntityException);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -85,7 +85,15 @@ export class ContractPricingService {
|
||||
* commodity rate) — NO totals or quantities (doc §9.1).
|
||||
*/
|
||||
async buildBreakdown(contract: Contract): Promise<ContractPricingBreakdown> {
|
||||
const liveRates = await this.ratesService.findLiveRates();
|
||||
// Contracts belong to a customer company — there is no shipping-line
|
||||
// contract (no shipping_line_company_id on the entity), so a contract may
|
||||
// only ever price off the standard rates. Without this filter a line's
|
||||
// negotiated rate on the same lane matched first and the contract froze it
|
||||
// for a customer: CTR-2026-00049 quoted a line's 32 USD/wagon 20ft and
|
||||
// 23 USD/container 40ft instead of the standard 1690 / 1676.
|
||||
const liveRates = (await this.ratesService.findLiveRates()).filter(
|
||||
(r) => !r.shippingLineCompanyId,
|
||||
);
|
||||
const currency = contract.paymentCurrency;
|
||||
const isEtb = currency === 'ETB';
|
||||
const usdToEtb = isEtb ? await this.exchangeService.getRate('USD', 'ETB') : 1;
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { ContractTransitionService } from './contract-transition.service';
|
||||
import type { Contract } from './entities/contract.entity';
|
||||
|
||||
/**
|
||||
* Staff cancel is terminal, so the rules that matter are: it needs its own
|
||||
* permission (suspend must NOT imply it), it refuses to strand live shipments,
|
||||
* it works on a suspended contract, and it cannot be applied twice.
|
||||
*/
|
||||
describe('ContractTransitionService — staff cancel', () => {
|
||||
const contract = (over: Partial<Contract> = {}): Contract =>
|
||||
({
|
||||
id: 'c-1',
|
||||
reference: 'CTR-2026-00042',
|
||||
companyId: 'co-1',
|
||||
status: 'CONTRACT_ACTIVE',
|
||||
freightType: 'CONTAINER',
|
||||
...over,
|
||||
}) as Contract;
|
||||
|
||||
let current: Contract;
|
||||
let repo: {
|
||||
update: jest.Mock;
|
||||
createReviewNote: jest.Mock;
|
||||
countActiveBookings: jest.Mock;
|
||||
};
|
||||
let notifier: { cancelledByStaff: jest.Mock };
|
||||
let service: ContractTransitionService;
|
||||
|
||||
const staff = {
|
||||
permissions: [{ key: 'edr_freight_app:contracts:cancel' }],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
current = contract();
|
||||
repo = {
|
||||
update: jest.fn().mockImplementation((_id: string, patch: object) => {
|
||||
current = { ...current, ...patch } as Contract;
|
||||
return Promise.resolve(current);
|
||||
}),
|
||||
createReviewNote: jest.fn().mockResolvedValue(undefined),
|
||||
countActiveBookings: jest.fn().mockResolvedValue(0),
|
||||
};
|
||||
notifier = { cancelledByStaff: jest.fn() };
|
||||
service = Object.create(
|
||||
ContractTransitionService.prototype,
|
||||
) as ContractTransitionService;
|
||||
Object.assign(service, {
|
||||
contractsRepository: repo,
|
||||
contractsService: { findById: () => Promise.resolve(current) },
|
||||
notifier,
|
||||
});
|
||||
});
|
||||
|
||||
it('cancels, records the reason as a staff note, and notifies the customer', async () => {
|
||||
await service.cancelByStaff('c-1', 'Duplicate request', 'staff-1', staff as never);
|
||||
|
||||
expect(repo.update).toHaveBeenCalledWith('c-1', {
|
||||
status: 'CANCELLED',
|
||||
statusBeforeSuspension: null,
|
||||
});
|
||||
expect(repo.createReviewNote).toHaveBeenCalledWith(
|
||||
'c-1',
|
||||
'Duplicate request',
|
||||
'CANCELLATION',
|
||||
'staff-1',
|
||||
'STAFF',
|
||||
);
|
||||
expect(notifier.cancelledByStaff).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('cancels a suspended contract — freezing it is exactly when staff kill it', async () => {
|
||||
current = contract({
|
||||
status: 'SUSPENDED',
|
||||
statusBeforeSuspension: 'CONTRACT_ACTIVE',
|
||||
} as Partial<Contract>);
|
||||
|
||||
await service.cancelByStaff('c-1', 'Customer withdrew', 'staff-1', staff as never);
|
||||
|
||||
expect(repo.update).toHaveBeenCalledWith('c-1', {
|
||||
status: 'CANCELLED',
|
||||
statusBeforeSuspension: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('refuses while a shipment is still running', async () => {
|
||||
repo.countActiveBookings.mockResolvedValue(2);
|
||||
|
||||
await expect(
|
||||
service.cancelByStaff('c-1', 'Change of plan', 'staff-1', staff as never),
|
||||
).rejects.toThrow('2 active shipments');
|
||||
expect(repo.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refuses to cancel an already-terminal contract', async () => {
|
||||
current = contract({ status: 'CANCELLED' });
|
||||
|
||||
await expect(
|
||||
service.cancelByStaff('c-1', 'Again', 'staff-1', staff as never),
|
||||
).rejects.toThrow(/already cancelled/i);
|
||||
expect(repo.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a user holding only the suspend key — cancel is a separate permission', async () => {
|
||||
const suspender = {
|
||||
permissions: [{ key: 'edr_freight_app:contracts:suspend' }],
|
||||
};
|
||||
|
||||
await expect(
|
||||
service.cancelByStaff('c-1', 'Not allowed', 'staff-1', suspender as never),
|
||||
).rejects.toThrow();
|
||||
expect(repo.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1452,6 +1452,54 @@ export class ContractTransitionService {
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Staff cancel — terminal, unlike suspend. The contract is dead; a fresh one
|
||||
* with the same parameters can be submitted afterwards (references are minted
|
||||
* per contract, so nothing about the old row blocks the new one).
|
||||
*
|
||||
* Cancellable from ANY non-terminal status, including SUSPENDED: a frozen
|
||||
* contract is exactly the one staff most often need to kill outright.
|
||||
*/
|
||||
async cancelByStaff(
|
||||
contractId: string,
|
||||
reason: string,
|
||||
actorId: string,
|
||||
user?: TCurrentUser | null,
|
||||
): Promise<Contract> {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
assertFreightPermission(user, FREIGHT_PERMS.contracts.cancel);
|
||||
if ((TERMINAL_CONTRACT_STATUSES as readonly string[]).includes(contract.status)) {
|
||||
throw new ConflictException(
|
||||
`Contract is already ${contract.status.toLowerCase().replace(/_/g, ' ')}.`,
|
||||
);
|
||||
}
|
||||
|
||||
// Same guard as the customer path: live shipments must be settled first,
|
||||
// otherwise cancelling the contract orphans cargo already in motion.
|
||||
const active = await this.contractsRepository.countActiveBookings(contractId);
|
||||
if (active > 0) {
|
||||
throw new BadRequestException(
|
||||
`This contract has ${active} active shipment${active === 1 ? '' : 's'}. ` +
|
||||
'Cancel or complete them before cancelling the contract.',
|
||||
);
|
||||
}
|
||||
|
||||
await this.contractsRepository.createReviewNote(
|
||||
contractId,
|
||||
reason,
|
||||
'CANCELLATION',
|
||||
actorId,
|
||||
'STAFF',
|
||||
);
|
||||
await this.contractsRepository.update(contractId, {
|
||||
status: 'CANCELLED',
|
||||
statusBeforeSuspension: null,
|
||||
} as never);
|
||||
const updated = await this.contractsService.findById(contractId);
|
||||
this.notifier.cancelledByStaff(updated, reason);
|
||||
return updated;
|
||||
}
|
||||
|
||||
async renew(contractId: string, userId?: string): Promise<Contract> {
|
||||
const source = await this.contractsService.findById(contractId);
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
NotFoundException,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
@@ -72,6 +73,7 @@ import {
|
||||
RequestChangesDto,
|
||||
ResumeContractDto,
|
||||
SuspendContractDto,
|
||||
CancelContractByStaffDto,
|
||||
} from './dto/approve-step.dto';
|
||||
import { SignContractDto } from './dto/sign-contract.dto';
|
||||
import { ReviewClearanceDocumentDto } from './dto/review-clearance-document.dto';
|
||||
@@ -552,6 +554,25 @@ export class ContractsController {
|
||||
);
|
||||
}
|
||||
|
||||
@Post(':id/staff/cancel')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.cancel)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Staff cancel a contract (terminal — a new contract with the same details may be submitted after)',
|
||||
})
|
||||
cancelByStaff(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: CancelContractByStaffDto,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
return this.transitionService.cancelByStaff(
|
||||
id,
|
||||
dto.reason,
|
||||
resolveAuthUserId(user),
|
||||
user,
|
||||
);
|
||||
}
|
||||
|
||||
@Post(':id/approval-steps/:stepId/approve')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.view)
|
||||
@ApiOperation({ summary: 'Approve one approval step in sequence' })
|
||||
@@ -1339,19 +1360,35 @@ export class ContractsController {
|
||||
return this.glOperationsService.uploadTransportDocument(bookingId, files ?? []);
|
||||
}
|
||||
|
||||
// Also filed by the transit agent assigned to the shipment — T1 is their own
|
||||
// transit paperwork. Any other portal caller is rejected below.
|
||||
@Post('bookings/:bookingId/t1-documents')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
|
||||
@MixedAudience(FREIGHT_PERMS.contracts.clearanceDjActions)
|
||||
@UseInterceptors(AnyFilesInterceptor())
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'GL Djibouti uploads T1 transit documents (multi-file) after wagon allocation; locked once the train departs',
|
||||
})
|
||||
uploadT1Documents(
|
||||
async uploadT1Documents(
|
||||
@Param('bookingId', ParseUUIDPipe) bookingId: string,
|
||||
@UploadedFiles() files: Express.Multer.File[],
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
return this.glOperationsService.uploadT1Documents(bookingId, files ?? []);
|
||||
if (
|
||||
!hasFreightPermission(user, FREIGHT_PERMS.contracts.clearanceDjActions) &&
|
||||
!(await this.bookingsService.isTransitAgentForBooking(
|
||||
user?.id,
|
||||
bookingId,
|
||||
))
|
||||
) {
|
||||
throw new NotFoundException(`Booking ${bookingId} not found`);
|
||||
}
|
||||
return this.glOperationsService.uploadT1Documents(
|
||||
bookingId,
|
||||
files ?? [],
|
||||
resolveAuthUserId(user),
|
||||
);
|
||||
}
|
||||
|
||||
@Post('bookings/:bookingId/t1-close')
|
||||
@@ -1514,6 +1551,8 @@ export class ContractsController {
|
||||
@MixedAudience(FREIGHT_PERMS.contracts.view)
|
||||
@ApiOperation({ summary: 'List cargo exception/damage reports for a shipment' })
|
||||
listIncidents(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
|
||||
// Reads are open to both audiences (a transit agent assigned to the
|
||||
// shipment included); reporting an incident stays staff-only below.
|
||||
return this.glOperationsService.listIncidents(bookingId);
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -51,6 +51,14 @@ export class CancelContractDto {
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
/** Staff cancel is terminal, so the reason is mandatory — it is the audit record. */
|
||||
export class CancelContractByStaffDto {
|
||||
@ApiProperty({ description: 'Why the contract is being cancelled — shown to the customer' })
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
reason!: string;
|
||||
}
|
||||
|
||||
export class SuspendContractDto {
|
||||
@ApiProperty({ description: 'Why the contract is being frozen — shown to the customer' })
|
||||
@IsString()
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user