mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 01:48:12 +00:00
Merge branch 'dev' of https://github.com/Tria-plc/edr-platform into feature/group-booking
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { applyDecorators, UseGuards } from '@nestjs/common';
|
||||
import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard';
|
||||
import { FreightJwtGuard } from './freight-jwt.guard';
|
||||
|
||||
import {
|
||||
FreightPermissionGuard,
|
||||
@@ -11,7 +11,7 @@ import { FREIGHT_PERMS } from '../seed/freight-permissions.registry';
|
||||
export const BookingStaff = (permission: string | string[]) =>
|
||||
applyDecorators(
|
||||
UseGuards(
|
||||
JwtGuard,
|
||||
FreightJwtGuard,
|
||||
FreightPermissionGuard(
|
||||
Array.isArray(permission) ? permission : [permission],
|
||||
),
|
||||
@@ -26,11 +26,11 @@ export const BookingStaff = (permission: string | string[]) =>
|
||||
* BookingStaff(<view key>) or MixedAudience(); kept for routes not yet swept.
|
||||
*/
|
||||
export const StaffReference = () =>
|
||||
applyDecorators(UseGuards(JwtGuard, FreightPermissionGuard([])));
|
||||
applyDecorators(UseGuards(FreightJwtGuard, FreightPermissionGuard([])));
|
||||
|
||||
/** Portal routes: customer accounts only; ownership scoping stays in services. */
|
||||
export const PortalCustomer = () =>
|
||||
applyDecorators(UseGuards(JwtGuard, PortalCustomerGuard));
|
||||
applyDecorators(UseGuards(FreightJwtGuard, PortalCustomerGuard));
|
||||
|
||||
/**
|
||||
* Routes both audiences call (sign, shared document reads, handover): staff
|
||||
@@ -40,7 +40,7 @@ export const PortalCustomer = () =>
|
||||
export const MixedAudience = (permission: string | string[]) =>
|
||||
applyDecorators(
|
||||
UseGuards(
|
||||
JwtGuard,
|
||||
FreightJwtGuard,
|
||||
MixedAudienceGuard(
|
||||
Array.isArray(permission) ? permission : [permission],
|
||||
),
|
||||
@@ -95,6 +95,24 @@ export const TrainSchedulingLoad = () =>
|
||||
export const TrainSchedulingUnload = () =>
|
||||
BookingStaff(FREIGHT_PERMS.trainScheduling.unload);
|
||||
|
||||
/**
|
||||
* Per-station loading/unloading time windows — the four buttons are four
|
||||
* permissions so start and end can be granted to different people. The same
|
||||
* endpoint that records a click also edits it (explicit `at`), so each
|
||||
* permission covers editing its own timestamp too.
|
||||
*/
|
||||
export const TrainSchedulingLoadingStart = () =>
|
||||
BookingStaff(FREIGHT_PERMS.trainScheduling.loadingStart);
|
||||
|
||||
export const TrainSchedulingLoadingEnd = () =>
|
||||
BookingStaff(FREIGHT_PERMS.trainScheduling.loadingEnd);
|
||||
|
||||
export const TrainSchedulingUnloadingStart = () =>
|
||||
BookingStaff(FREIGHT_PERMS.trainScheduling.unloadingStart);
|
||||
|
||||
export const TrainSchedulingUnloadingEnd = () =>
|
||||
BookingStaff(FREIGHT_PERMS.trainScheduling.unloadingEnd);
|
||||
|
||||
export const TrainSchedulingCancel = () =>
|
||||
BookingStaff(FREIGHT_PERMS.trainScheduling.cancel);
|
||||
|
||||
|
||||
101
apps/edr-freight-api/src/common/freight-jwt.guard.ts
Normal file
101
apps/edr-freight-api/src/common/freight-jwt.guard.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
|
||||
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 { DataSource } from 'typeorm';
|
||||
|
||||
/** One position as the login snapshot stores it (`iam.sessions.userInfo`). */
|
||||
type SnapshotPosition = { id?: string; [key: string]: unknown };
|
||||
|
||||
type SessionUserInfo = {
|
||||
employee?: { id?: string; positions?: SnapshotPosition[] }[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Like the IAM JwtGuard, but keeps the caller's SECONDARY positions.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
@Injectable()
|
||||
export class FreightJwtGuard extends IamJwtGuard implements CanActivate {
|
||||
// ponytail: unbounded-until-TTL map, cleared wholesale when it gets big.
|
||||
// Sessions are few and the value is small; swap for an LRU if that changes.
|
||||
private static readonly CACHE_TTL_MS = 30_000;
|
||||
private static readonly CACHE_MAX_ENTRIES = 5_000;
|
||||
private readonly cache = new Map<
|
||||
string,
|
||||
{ positions: SnapshotPosition[]; expiresAt: number }
|
||||
>();
|
||||
|
||||
constructor(
|
||||
reflector: Reflector,
|
||||
@InjectDataSource() private readonly ds: DataSource,
|
||||
) {
|
||||
super(reflector, ds);
|
||||
}
|
||||
|
||||
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;
|
||||
if (!employee || !user?.sessionId) return true;
|
||||
|
||||
const positions = await this.positionsForSession(
|
||||
user.sessionId,
|
||||
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;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Every position the login snapshot holds for this employee. */
|
||||
private async positionsForSession(
|
||||
sessionId: string,
|
||||
employeeId: string | undefined,
|
||||
): Promise<SnapshotPosition[]> {
|
||||
const now = Date.now();
|
||||
const hit = this.cache.get(sessionId);
|
||||
if (hit && hit.expiresAt > now) return hit.positions;
|
||||
|
||||
let positions: SnapshotPosition[] = [];
|
||||
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 ?? [];
|
||||
} catch {
|
||||
return []; // iam unreachable — caller keeps the parent's single position
|
||||
}
|
||||
|
||||
if (this.cache.size >= FreightJwtGuard.CACHE_MAX_ENTRIES)
|
||||
this.cache.clear();
|
||||
this.cache.set(sessionId, {
|
||||
positions,
|
||||
expiresAt: now + FreightJwtGuard.CACHE_TTL_MS,
|
||||
});
|
||||
return positions;
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
assertCanApproveContractStep,
|
||||
canEditContractStep,
|
||||
collectPermissionKeys,
|
||||
collectPositionTypeKeys,
|
||||
hasFreightPermission,
|
||||
setPositionTypePermissionResolver,
|
||||
} from './freight-permission.util';
|
||||
@@ -121,3 +122,66 @@ describe('collectPermissionKeys — position-type grants', () => {
|
||||
expect(hasFreightPermission(direct, CLEARANCE)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* IAM lets an employee hold several positions, but the vendored `JwtGuard`
|
||||
* collapses `employee.positions[]` down to a single `employee.position` and
|
||||
* drops the rest — so staff on two posts resolved to one post's permissions
|
||||
* and every check on the other rejected them. `FreightJwtGuard` restores the
|
||||
* full list as `employee.positions`; these cover the union that depends on it.
|
||||
*/
|
||||
describe('multiple positions', () => {
|
||||
// Shaped like the real two-post employee: GL chief AND GL director.
|
||||
const twoPost = {
|
||||
employee: {
|
||||
// What the vendored guard leaves behind — one of the two, arbitrarily.
|
||||
position: {
|
||||
positionType: { key: 'djibouti-gl-chief' },
|
||||
permissions: [{ key: FREIGHT_PERMS.contracts.view }],
|
||||
},
|
||||
// What FreightJwtGuard puts back.
|
||||
positions: [
|
||||
{
|
||||
positionType: { key: 'djibouti-gl-chief' },
|
||||
permissions: [{ key: FREIGHT_PERMS.contracts.view }],
|
||||
},
|
||||
{
|
||||
positionType: { key: 'djibouti-gl-director' },
|
||||
permissions: [{ key: FREIGHT_PERMS.bookings.view }],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
it('unions permissions across every position', () => {
|
||||
const keys = collectPermissionKeys(twoPost);
|
||||
expect(keys).toContain(FREIGHT_PERMS.contracts.view);
|
||||
expect(keys).toContain(FREIGHT_PERMS.bookings.view);
|
||||
});
|
||||
|
||||
it('grants the secondary position’s permission, not just the first', () => {
|
||||
expect(hasFreightPermission(twoPost, FREIGHT_PERMS.bookings.view)).toBe(true);
|
||||
});
|
||||
|
||||
it('answers to both position types', () => {
|
||||
expect(collectPositionTypeKeys(twoPost)).toEqual(
|
||||
expect.arrayContaining(['djibouti-gl-chief', 'djibouti-gl-director']),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not double-count the position the guard also left singular', () => {
|
||||
const keys = collectPermissionKeys(twoPost);
|
||||
expect(keys.filter((k) => k === FREIGHT_PERMS.contracts.view)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('still resolves the single position when the array is absent', () => {
|
||||
// A request that skipped FreightJwtGuard must degrade to the old behaviour,
|
||||
// not to no permissions at all.
|
||||
const onePost = {
|
||||
employee: {
|
||||
position: { permissions: [{ key: FREIGHT_PERMS.contracts.view }] },
|
||||
},
|
||||
};
|
||||
expect(hasFreightPermission(onePost, FREIGHT_PERMS.contracts.view)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,6 +17,15 @@ type MeLikeUser = {
|
||||
permissions?: PermissionLike[];
|
||||
positionType?: PositionTypeLike | null;
|
||||
};
|
||||
/**
|
||||
* Every position the employee holds, restored by `FreightJwtGuard`
|
||||
* from the login snapshot. The IAM guard only ever sets the singular
|
||||
* `position` above; without this, a second post's grants are invisible.
|
||||
*/
|
||||
positions?: {
|
||||
permissions?: PermissionLike[];
|
||||
positionType?: PositionTypeLike | null;
|
||||
}[];
|
||||
delegatedPositions?: { permissions?: PermissionLike[] }[];
|
||||
}
|
||||
| {
|
||||
@@ -98,10 +107,15 @@ export function collectPermissionKeys(user: MeLikeUser | null | undefined): stri
|
||||
return [...keys];
|
||||
}
|
||||
|
||||
for (const p of employee.position?.permissions ?? []) {
|
||||
if (p.key) keys.add(p.key);
|
||||
// `position` is whichever single post the IAM guard selected; `positions` is
|
||||
// the full set FreightJwtGuard restores. Walk both — the array is absent on
|
||||
// a session the guard could not re-read, and the two overlap harmlessly.
|
||||
for (const pos of [employee.position, ...(employee.positions ?? [])]) {
|
||||
for (const p of pos?.permissions ?? []) {
|
||||
if (p.key) keys.add(p.key);
|
||||
}
|
||||
addTypePermissions(pos?.positionType);
|
||||
}
|
||||
addTypePermissions(employee.position?.positionType);
|
||||
for (const delegated of employee.delegatedPositions ?? []) {
|
||||
for (const p of delegated.permissions ?? []) {
|
||||
if (p.key) keys.add(p.key);
|
||||
@@ -158,8 +172,10 @@ export function collectPositionTypeKeys(
|
||||
return [...keys];
|
||||
}
|
||||
|
||||
if (employee.position?.positionType?.key) {
|
||||
keys.add(employee.position.positionType.key);
|
||||
// Both shapes, same reason as collectPermissionKeys: an employee holding two
|
||||
// posts answers to both their position types.
|
||||
for (const pos of [employee.position, ...(employee.positions ?? [])]) {
|
||||
if (pos?.positionType?.key) keys.add(pos.positionType.key);
|
||||
}
|
||||
return [...keys];
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { applyDecorators, UseGuards } from '@nestjs/common';
|
||||
import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard';
|
||||
import { FreightJwtGuard } from './freight-jwt.guard';
|
||||
|
||||
import { FreightPermissionGuard } from './freight-permission.guard';
|
||||
import {
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
|
||||
export const RuleEngineView = (slug: RuleEngineResourceSlug) =>
|
||||
applyDecorators(
|
||||
UseGuards(JwtGuard, FreightPermissionGuard([FREIGHT_PERMS.ruleEngine.view(slug)])),
|
||||
UseGuards(FreightJwtGuard, FreightPermissionGuard([FREIGHT_PERMS.ruleEngine.view(slug)])),
|
||||
);
|
||||
|
||||
// Granular CRUD replaces the retired coarse RuleEngineManage. Each write
|
||||
@@ -18,17 +18,17 @@ export const RuleEngineView = (slug: RuleEngineResourceSlug) =>
|
||||
// update on PATCH / reorder / move-order, delete on DELETE.
|
||||
export const RuleEngineCreate = (slug: RuleEngineResourceSlug) =>
|
||||
applyDecorators(
|
||||
UseGuards(JwtGuard, FreightPermissionGuard([FREIGHT_PERMS.ruleEngine.create(slug)])),
|
||||
UseGuards(FreightJwtGuard, FreightPermissionGuard([FREIGHT_PERMS.ruleEngine.create(slug)])),
|
||||
);
|
||||
|
||||
export const RuleEngineUpdate = (slug: RuleEngineResourceSlug) =>
|
||||
applyDecorators(
|
||||
UseGuards(JwtGuard, FreightPermissionGuard([FREIGHT_PERMS.ruleEngine.update(slug)])),
|
||||
UseGuards(FreightJwtGuard, FreightPermissionGuard([FREIGHT_PERMS.ruleEngine.update(slug)])),
|
||||
);
|
||||
|
||||
export const RuleEngineDelete = (slug: RuleEngineResourceSlug) =>
|
||||
applyDecorators(
|
||||
UseGuards(JwtGuard, FreightPermissionGuard([FREIGHT_PERMS.ruleEngine.delete(slug)])),
|
||||
UseGuards(FreightJwtGuard, FreightPermissionGuard([FREIGHT_PERMS.ruleEngine.delete(slug)])),
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -38,5 +38,5 @@ export const RuleEngineDelete = (slug: RuleEngineResourceSlug) =>
|
||||
*/
|
||||
export const RuleEngineApprove = (slug: RuleEngineApprovableSlug) =>
|
||||
applyDecorators(
|
||||
UseGuards(JwtGuard, FreightPermissionGuard([FREIGHT_PERMS.ruleEngine.approve(slug)])),
|
||||
UseGuards(FreightJwtGuard, FreightPermissionGuard([FREIGHT_PERMS.ruleEngine.approve(slug)])),
|
||||
);
|
||||
|
||||
@@ -122,6 +122,8 @@ export class ContractDocumentViewModelBuilder {
|
||||
contract.customsClearingEnabled,
|
||||
// Bulk templates are keyed by the contract's cargo type.
|
||||
(contract.cargoScope ?? []).find((c) => c.cargoTypeId)?.cargoTypeId,
|
||||
// Ethiopian-customs-only service types resolve to the Ethiopian variant.
|
||||
contract.serviceType?.includesEthiopianCustomsOnly,
|
||||
);
|
||||
dynamicTemplate = dynamicSource
|
||||
? {
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Adds `reference` to freight.audit_logs — the human identifier of the entity
|
||||
* the action touched (booking reference, schedule number, train number, …),
|
||||
* resolved at write time by the audit interceptor. `resource_id` stays the
|
||||
* machine id; this column is what staff actually type into the search box.
|
||||
*
|
||||
* Production safety:
|
||||
* - `ADD COLUMN ... NOT NULL DEFAULT ''` is metadata-only on Postgres 11+:
|
||||
* no table rewrite, no long lock, existing rows read '' without being
|
||||
* touched. Rows written before this migration keep '' permanently —
|
||||
* capture starts from deploy, by design (no backfill).
|
||||
* - Everything is IF NOT EXISTS so a hand-patched database converges
|
||||
* instead of failing the deploy.
|
||||
* - No existing column is altered and nothing is dropped: zero data-loss
|
||||
* surface.
|
||||
*
|
||||
* The index is an expression index on upper(reference) with
|
||||
* text_pattern_ops so the search endpoint's case-insensitive prefix match
|
||||
* (`upper(reference) LIKE upper($1) || '%'`) is indexed. '' rows are
|
||||
* excluded to keep it small — they are never searched for.
|
||||
*/
|
||||
export class AuditLogReference3690000000000 implements MigrationInterface {
|
||||
name = 'AuditLogReference3690000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.audit_logs
|
||||
ADD COLUMN IF NOT EXISTS reference varchar(64) NOT NULL DEFAULT ''
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_logs_reference_upper
|
||||
ON freight.audit_logs (upper(reference) text_pattern_ops)
|
||||
WHERE reference <> ''
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
// Down discards every captured reference — acceptable only because down
|
||||
// migrations are never run against production here.
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_audit_logs_reference_upper`);
|
||||
await queryRunner.query(`ALTER TABLE freight.audit_logs DROP COLUMN IF EXISTS reference`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Adds the customs clearing agent's contact details to freight.bookings.
|
||||
*
|
||||
* The agent moved from the contract to the booking: on a without-customs
|
||||
* service the customer now names their agent (name, email, phone) when
|
||||
* completing each booking, instead of once at contract creation. The existing
|
||||
* `customs_clearing_agent` column keeps the name; these two columns add the
|
||||
* contact info. Nullable — customs-bundled and legacy bookings have none.
|
||||
*/
|
||||
export class BookingClearingAgentContact3710000000000 implements MigrationInterface {
|
||||
name = 'BookingClearingAgentContact3710000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
ADD COLUMN IF NOT EXISTS customs_clearing_agent_email varchar(200),
|
||||
ADD COLUMN IF NOT EXISTS customs_clearing_agent_phone varchar(50)
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
DROP COLUMN IF EXISTS customs_clearing_agent_email,
|
||||
DROP COLUMN IF EXISTS customs_clearing_agent_phone
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Per-station loading/unloading time windows on a schedule, operator-clicked:
|
||||
* { [yardId]: { loading?: { startedAt, endedAt, startedByUserId, endedByUserId },
|
||||
* unloading?: { same } } }
|
||||
* Booking load/unload is gated on the matching window having been started.
|
||||
*/
|
||||
export class ScheduleStationWorkLogs3720000000000 implements MigrationInterface {
|
||||
name = 'ScheduleStationWorkLogs3720000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.train_schedules
|
||||
ADD COLUMN IF NOT EXISTS station_work_logs jsonb
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.train_schedules DROP COLUMN IF EXISTS station_work_logs
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Approval gate for detaching a wagon (or sending it to maintenance) from a
|
||||
* train whose run is already SCHEDULED.
|
||||
*
|
||||
* Before scheduling, the consist is the builder's to edit. After scheduling,
|
||||
* pulling a wagon changes a departure customers booked against, so it becomes
|
||||
* a two-person action: one staffer files a request with a reason, another
|
||||
* staffer (with trains:approve_wagon_detach) approves it — approval executes
|
||||
* the detach on the spot. Rows are never deleted; decided rows are the audit
|
||||
* trail of who asked, who decided, and why.
|
||||
*
|
||||
* One PENDING row per (train, wagon) at a time — a second request while one is
|
||||
* undecided is a coordination failure, not a workflow (partial unique index).
|
||||
*/
|
||||
export class WagonDetachRequests3730000000000 implements MigrationInterface {
|
||||
name = 'WagonDetachRequests3730000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE freight.wagon_detach_requests_status_enum
|
||||
AS ENUM ('PENDING', 'APPROVED', 'REJECTED');
|
||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.wagon_detach_requests (
|
||||
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
train_id uuid NOT NULL REFERENCES freight.trains (id),
|
||||
wagon_id uuid NOT NULL REFERENCES freight.wagons (id),
|
||||
-- Snapshot: the audit trail must still read correctly after the wagon
|
||||
-- is renumbered or deleted.
|
||||
wagon_number varchar(50) NOT NULL,
|
||||
action varchar(20) NOT NULL,
|
||||
reason varchar(500) NOT NULL,
|
||||
status freight.wagon_detach_requests_status_enum NOT NULL DEFAULT 'PENDING',
|
||||
-- Who asked and who decided. Both recorded: the point of the gate is
|
||||
-- that they are different people.
|
||||
requested_by uuid,
|
||||
decided_by uuid,
|
||||
decided_at timestamptz,
|
||||
decision_note varchar(500),
|
||||
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_wagon_detach_requests_train
|
||||
ON freight.wagon_detach_requests (train_id)
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_wagon_detach_requests_train_status
|
||||
ON freight.wagon_detach_requests (train_id, status)
|
||||
`);
|
||||
|
||||
// The workflow invariant, enforced where it cannot race: at most one
|
||||
// undecided request per wagon per train.
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_wagon_detach_requests_one_pending
|
||||
ON freight.wagon_detach_requests (train_id, wagon_id)
|
||||
WHERE status = 'PENDING' AND deleted_at IS NULL
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.wagon_detach_requests`);
|
||||
await queryRunner.query(`DROP TYPE IF EXISTS freight.wagon_detach_requests_status_enum`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* NUMBER_OF_WAGONS cargo unit: the customer books a wagon COUNT alongside the
|
||||
* bulk weight. `bulk_requested_wagons` drives allocation and PER_WAGON pricing;
|
||||
* `bulk_item_count` is the optional informational item count entered with it.
|
||||
* Nullable — every other cargo unit leaves both empty.
|
||||
*/
|
||||
export class BookingBulkRequestedWagons3740000000000 implements MigrationInterface {
|
||||
name = 'BookingBulkRequestedWagons3740000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
ADD COLUMN IF NOT EXISTS bulk_requested_wagons int,
|
||||
ADD COLUMN IF NOT EXISTS bulk_item_count int
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
DROP COLUMN IF EXISTS bulk_requested_wagons,
|
||||
DROP COLUMN IF EXISTS bulk_item_count
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
import { CONTRACT_TEMPLATE_DEFAULTS } from '../seed/data/contract-template-defaults';
|
||||
|
||||
/**
|
||||
* Third customs-clearing option on contract templates: Ethiopian-customs-only
|
||||
* (the Service Provider clears the Ethiopian side only, Djibouti stays with
|
||||
* the Client), matching service types with includes_ethiopian_customs_only.
|
||||
*
|
||||
* - ethiopian_customs_only column on contract_templates (bulk variant flag;
|
||||
* the seeded container variants carry it in the code suffix instead, like
|
||||
* the existing _CUSTOMS/_NO_CUSTOMS pair).
|
||||
* - The bulk unique index and intercity check widen to the new flag.
|
||||
* - Seeds the two new system container templates from the defaults pack.
|
||||
*/
|
||||
const SEEDED_CODES = [
|
||||
'IMPORT_CONTAINER_ETHIOPIAN_CUSTOMS',
|
||||
'EXPORT_CONTAINER_ETHIOPIAN_CUSTOMS',
|
||||
] as const;
|
||||
|
||||
export class EthiopianCustomsContractTemplates3750000000000 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.contract_templates
|
||||
ADD COLUMN IF NOT EXISTS ethiopian_customs_only boolean
|
||||
`);
|
||||
|
||||
await queryRunner.query(
|
||||
`DROP INDEX IF EXISTS freight.uq_contract_templates_cargo_dir_customs`,
|
||||
);
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_contract_templates_cargo_dir_customs
|
||||
ON freight.contract_templates
|
||||
(cargo_type_id, trade_direction,
|
||||
COALESCE(with_customs, false), COALESCE(ethiopian_customs_only, false))
|
||||
WHERE deleted_at IS NULL AND cargo_type_id IS NOT NULL
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.contract_templates
|
||||
DROP CONSTRAINT IF EXISTS ck_bulk_intercity_no_customs
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.contract_templates
|
||||
ADD CONSTRAINT ck_bulk_intercity_no_customs CHECK (
|
||||
cargo_type_id IS NULL
|
||||
OR (
|
||||
trade_direction IN ('IMPORT', 'EXPORT', 'INTERCITY')
|
||||
AND (trade_direction = 'INTERCITY') = (with_customs IS NULL)
|
||||
AND (ethiopian_customs_only IS NOT TRUE OR with_customs IS TRUE)
|
||||
)
|
||||
)
|
||||
`);
|
||||
|
||||
for (const code of SEEDED_CODES) {
|
||||
const seed = CONTRACT_TEMPLATE_DEFAULTS.find((t) => t.code === code);
|
||||
if (!seed) throw new Error(`Missing contract template default for ${code}`);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO freight.contract_templates
|
||||
(id, code, name, description, document_title, whereas_clauses, articles,
|
||||
is_active, is_system, created_at, updated_at)
|
||||
SELECT gen_random_uuid(), $1::varchar, $2, $3, $4, $5::jsonb, $6::jsonb,
|
||||
true, true, now(), now()
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM freight.contract_templates
|
||||
WHERE code = $1::varchar AND deleted_at IS NULL
|
||||
)`,
|
||||
[
|
||||
seed.code,
|
||||
seed.name,
|
||||
seed.description,
|
||||
seed.documentTitle,
|
||||
JSON.stringify(seed.whereasClauses),
|
||||
JSON.stringify(
|
||||
seed.articles.map((article, index) => ({ ...article, order: index + 1 })),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`DELETE FROM freight.contract_templates WHERE code = ANY($1) AND is_system = true`,
|
||||
[[...SEEDED_CODES]],
|
||||
);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.contract_templates
|
||||
DROP CONSTRAINT IF EXISTS ck_bulk_intercity_no_customs
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.contract_templates
|
||||
ADD CONSTRAINT ck_bulk_intercity_no_customs CHECK (
|
||||
cargo_type_id IS NULL
|
||||
OR (
|
||||
trade_direction IN ('IMPORT', 'EXPORT', 'INTERCITY')
|
||||
AND (trade_direction = 'INTERCITY') = (with_customs IS NULL)
|
||||
)
|
||||
)
|
||||
`);
|
||||
|
||||
await queryRunner.query(
|
||||
`DROP INDEX IF EXISTS freight.uq_contract_templates_cargo_dir_customs`,
|
||||
);
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_contract_templates_cargo_dir_customs
|
||||
ON freight.contract_templates
|
||||
(cargo_type_id, trade_direction, COALESCE(with_customs, false))
|
||||
WHERE deleted_at IS NULL AND cargo_type_id IS NOT NULL
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.contract_templates
|
||||
DROP COLUMN IF EXISTS ethiopian_customs_only
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Attaches an eTrade business licence to each operational profile.
|
||||
*
|
||||
* A TIN routinely holds a dozen or more licences, split by activity ("Export
|
||||
* trade in coffee", "Freight Forwarders"), and until now the company picked one
|
||||
* for the whole record — every role shared it. Each profile now names the
|
||||
* business it actually operates as.
|
||||
*
|
||||
* Stored as a snapshot ({@link ETradeBusinessOption}: licenceNumber, tradeName,
|
||||
* activity, renewedTo) rather than a bare licence number, so the portal and the
|
||||
* backoffice can show which business is attached without an eTrade round-trip —
|
||||
* eTrade is slow, serves a broken TLS chain, and is regularly down.
|
||||
*
|
||||
* Nullable: existing profiles have none until the customer attaches one, and a
|
||||
* co-operative or investor-licence company has no eTrade record at all.
|
||||
* Deliberately NOT unique — one business can back several profiles.
|
||||
*/
|
||||
export class CompanyProfileEtradeBusiness3760000000000 implements MigrationInterface {
|
||||
name = 'CompanyProfileEtradeBusiness3760000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.company_profiles
|
||||
ADD COLUMN IF NOT EXISTS etrade_business jsonb
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.company_profiles
|
||||
DROP COLUMN IF EXISTS etrade_business
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@
|
||||
* humanized handler name where a route has none.
|
||||
*
|
||||
* Excludes the AI Assist and Account entities.
|
||||
* Generated from the controllers under src/ — 517 endpoints.
|
||||
* Generated from the controllers under src/ — 528 endpoints.
|
||||
*/
|
||||
/** [title, method, entity] for one auditable route. */
|
||||
export type AuditEndpointMeta = readonly [title: string, method: string, entity: string];
|
||||
@@ -38,6 +38,7 @@ export const AUDIT_ENDPOINTS: Readonly<Record<string, AuditEndpointMeta>> = {
|
||||
"POST /api/bookings/:id/clearance/draft-declaration": ["GL ET sends a draft customs declaration (multi-file) with an estimated price for the customer to review", "POST", "Booking"],
|
||||
"POST /api/bookings/:id/clearance/draft-declaration/accept": ["Customer accepts the draft customs declaration — unlocks the real customs declaration step for GL Ethiopia", "POST", "Booking"],
|
||||
"POST /api/bookings/:id/clearance/draft-declaration/change": ["Customer requests a change to the draft customs declaration with a reason — GL Ethiopia sends a corrected draft (repeatable)", "POST", "Booking"],
|
||||
"POST /api/bookings/:id/clearance/draft-declaration/skip": ["GL ET skips the draft-declaration round: no estimate is sent to the customer, the real declaration is filed directly and duty & tax passes by default", "POST", "Booking"],
|
||||
"POST /api/bookings/:id/clearance/duty": ["GL ET sets duty/tax on booking with notice attachment", "POST", "Booking"],
|
||||
"POST /api/bookings/:id/clearance/duty-slip": ["Customer uploads duty/tax payment slip on booking", "POST", "Booking"],
|
||||
"POST /api/bookings/:id/clearance/export-release": ["Confirm Booking Export Release", "POST", "Booking"],
|
||||
@@ -51,7 +52,12 @@ export const AUDIT_ENDPOINTS: Readonly<Record<string, AuditEndpointMeta>> = {
|
||||
"POST /api/bookings/:id/clearance/charges/port-document": ["GL Djibouti uploads the port-charges document", "POST", "Booking"],
|
||||
"PATCH /api/bookings/:id/clearance/charges/:chargeId/bill": ["GL Ethiopia sets or revises a clearance charge's amount + currency", "PATCH", "Booking"],
|
||||
"POST /api/bookings/:id/clearance/charges/:chargeId/send": ["GL Ethiopia issues the clearance charge invoice to the customer", "POST", "Booking"],
|
||||
"POST /api/bookings/:id/clearance/charges/:chargeId/accept": ["Customer accepts a proposed clearance charge — issues the payable invoice and locks the charge", "POST", "Booking"],
|
||||
"POST /api/bookings/:id/clearance/charges/:chargeId/reject": ["Customer rejects a proposed clearance charge with a reason — GL Ethiopia revises and re-sends", "POST", "Booking"],
|
||||
"POST /api/bookings/:id/clearance/charges/miscellaneous": ["GL Ethiopia creates the miscellaneous clearance charge", "POST", "Booking"],
|
||||
"POST /api/bookings/:id/additional-charges": ["Finance raises a new additional charge — draft, or send to the customer immediately", "POST", "Booking"],
|
||||
"POST /api/bookings/:id/additional-charges/:chargeId/send": ["Issue the draft charge's payable invoice and notify the customer", "POST", "Booking"],
|
||||
"POST /api/bookings/:id/additional-charges/:chargeId/cancel": ["Withdraw a draft or unpaid additional charge", "POST", "Booking"],
|
||||
"POST /api/bookings/:id/clearance/ro-amendment": ["Request Booking RO Amendment", "POST", "Booking"],
|
||||
"POST /api/bookings/:id/clearance/transit-assignee/assign": ["GL Djibouti picks the transit officer from the roster — unblocks the customs declaration; calling again reassigns", "POST", "Booking"],
|
||||
"POST /api/bookings/:id/clearance/transit-assignee/request": ["GL ET asks GL Djibouti to name the transit officer — required before the import customs declaration", "POST", "Booking"],
|
||||
@@ -73,7 +79,7 @@ export const AUDIT_ENDPOINTS: Readonly<Record<string, AuditEndpointMeta>> = {
|
||||
"POST /api/bookings/:id/documents": ["Upload documents for a booking (DRAFT only)", "POST", "Booking"],
|
||||
"PATCH /api/bookings/:id/export-handover-mode": ["Export only: choose direct truck-to-train (no warehouse, no GRN) or warehouse first", "PATCH", "Booking"],
|
||||
"POST /api/bookings/:id/generate-grn": ["Generate a GRN over the received containers (all received, or a subset) — one GRN per batch", "POST", "Booking"],
|
||||
"POST /api/bookings/:id/generate-price": ["Generate price preview (DRAFT or CHANGES_REQUESTED)", "POST", "Booking"],
|
||||
// "POST /api/bookings/:id/generate-price": ["Generate price preview (DRAFT or CHANGES_REQUESTED)", "POST", "Booking"],
|
||||
"POST /api/bookings/:id/government-expedite": ["Expedite government booking to PAID / ELIGIBLE for scheduling", "POST", "Booking"],
|
||||
"POST /api/bookings/:id/marketing/approve": ["Staff contract signature and fully execute (use contract/sign STAFF preferred)", "POST", "Booking"],
|
||||
"POST /api/bookings/:id/operation/review": ["Operations reviews an operation request: ACCEPT (→ batch pool),", "POST", "Booking"],
|
||||
@@ -85,7 +91,7 @@ export const AUDIT_ENDPOINTS: Readonly<Record<string, AuditEndpointMeta>> = {
|
||||
"POST /api/bookings/:id/staff/request-changes": ["Staff return booking for customer updates", "POST", "Booking"],
|
||||
"POST /api/bookings/:id/submit": ["Customer submit booking", "POST", "Booking"],
|
||||
"POST /api/bookings/:id/wagon-cancellations": ["Request a partial wagon cancellation on a PAID booking — opens the cancellation-fee invoice; wagons are released only once the fee settles", "POST", "Booking"],
|
||||
"POST /api/bookings/:id/wagon-cancellations/preview": ["Preview the fee/credit of a partial wagon cancellation (no writes)", "POST", "Booking"],
|
||||
// "POST /api/bookings/:id/wagon-cancellations/preview": ["Preview the fee/credit of a partial wagon cancellation (no writes)", "POST", "Booking"],
|
||||
"POST /api/bookings/wagon-cancellations/:cancellationId/rebook": ["Rebook a wagon-cancellation credit: pick a shipment day only — the new booking is created under the contract and marked PAID (freight already paid; contract must still be valid)", "POST", "Booking"],
|
||||
"POST /api/bookings/wagon-cancellations/:cancellationId/withdraw": ["Withdraw a fee-pending wagon cancellation (owner, or staff with the void permission)", "POST", "Booking"],
|
||||
"POST /api/bookings/consolidation-approvals/:approvalId/approve": ["Approve a shared wagon: both bookings leave the gate and continue to Operations together.", "POST", "Booking"],
|
||||
@@ -207,7 +213,7 @@ export const AUDIT_ENDPOINTS: Readonly<Record<string, AuditEndpointMeta>> = {
|
||||
"POST /api/contracts/:id/staff/request-changes": ["Staff return contract for customer updates", "POST", "Contract"],
|
||||
"POST /api/contracts/:id/submit": ["Customer submit contract (freezes contract_rate_snapshots)", "POST", "Contract"],
|
||||
"POST /api/contracts/:id/suspend": ["Staff freeze a signed contract (reversible, any post-signature step)", "POST", "Contract"],
|
||||
"POST /api/contracts/:id/validate-shipment": ["Pre-create validation + authoritative price preview: full booking price breakdown (rail, first/last mile, surcharges), overweight lines and 20ft weight-pairing errors for a shipment payload (no booking created)", "POST", "Contract"],
|
||||
// "POST /api/contracts/:id/validate-shipment": ["Pre-create validation + authoritative price preview: full booking price breakdown (rail, first/last mile, surcharges), overweight lines and 20ft weight-pairing errors for a shipment payload (no booking created)", "POST", "Contract"],
|
||||
"POST /api/contracts/booking-requests/:reqId/accept": ["GL marks a shipment request accepted + links the created booking", "POST", "Contract"],
|
||||
"POST /api/contracts/booking-requests/:reqId/cancel": ["Customer cancels their own pending shipment request", "POST", "Contract"],
|
||||
"POST /api/contracts/booking-requests/:reqId/reject": ["GL rejects a shipment request", "POST", "Contract"],
|
||||
@@ -240,7 +246,7 @@ export const AUDIT_ENDPOINTS: Readonly<Record<string, AuditEndpointMeta>> = {
|
||||
"PUT /api/contract-templates/:code/articles": ["Replace the full ordered article list (used for reorder)", "PUT", "Contract Template"],
|
||||
"PATCH /api/contract-templates/:code/articles/:articleId": ["Update an article's title or body", "PATCH", "Contract Template"],
|
||||
"DELETE /api/contract-templates/:code/articles/:articleId": ["Remove an article from the template", "DELETE", "Contract Template"],
|
||||
"POST /api/contract-templates/:code/preview": ["Render an HTML preview of the template against mock contract data", "POST", "Contract Template"],
|
||||
// "POST /api/contract-templates/:code/preview": ["Render an HTML preview of the template against mock contract data", "POST", "Contract Template"],
|
||||
|
||||
// Driver
|
||||
"POST /api/drivers": ["Create a new driver", "POST", "Driver"],
|
||||
@@ -266,6 +272,8 @@ export const AUDIT_ENDPOINTS: Readonly<Record<string, AuditEndpointMeta>> = {
|
||||
"POST /api/invoices/:id/eims/receipt/sales": ["Register a sales receipt with MoR EIMS against a registered invoice", "POST", "EIMS Invoice"],
|
||||
"POST /api/invoices/:id/eims/receipt/withholding": ["Register a withholding receipt with MoR EIMS against a registered invoice", "POST", "EIMS Invoice"],
|
||||
"POST /api/invoices/eims/bulk-cancel": ["Cancel multiple invoices", "POST", "EIMS Invoice"],
|
||||
"POST /api/invoices/eims/bulk-register": ["Submit multiple invoices to MoR EIMS in one call. Asynchronous — this only confirms MoR", "POST", "EIMS Invoice"],
|
||||
"POST /api/eims/webhook/bulk-register": ["EIMS bulk-register webhook callback (MoR reports per-invoice results)", "POST", "EIMS Invoice"],
|
||||
|
||||
// Exchange Setting
|
||||
"PATCH /api/exchange-settings": ["Set the USD→ETB fallback by hand (used only while CBE is unreachable)", "PATCH", "Exchange Setting"],
|
||||
@@ -373,6 +381,14 @@ export const AUDIT_ENDPOINTS: Readonly<Record<string, AuditEndpointMeta>> = {
|
||||
"PATCH /api/notifications/:id/read": ["Mark one of my notifications as read", "PATCH", "Notification Inbox"],
|
||||
"POST /api/notifications/read-all": ["Mark all my notifications as read", "POST", "Notification Inbox"],
|
||||
|
||||
// Operations Standard
|
||||
"PATCH /api/operations-standards": ["Change one or more operating standards", "PATCH", "Operations Standard"],
|
||||
|
||||
// Operations Target
|
||||
"POST /api/operations-targets": ["Create a planned target", "POST", "Operations Target"],
|
||||
"PATCH /api/operations-targets/:id": ["Update a planned target", "PATCH", "Operations Target"],
|
||||
"DELETE /api/operations-targets/:id": ["Soft-delete a planned target", "DELETE", "Operations Target"],
|
||||
|
||||
// Organization User
|
||||
"PUT /api/backoffice/organizations/:orgId/employee-users/:userId/roles": ["Replace org-scoped roles assigned to an employee user", "PUT", "Organization User"],
|
||||
"POST /api/backoffice/organizations/:orgId/users": ["Create an organization user without assigning positions", "POST", "Organization User"],
|
||||
@@ -445,7 +461,8 @@ export const AUDIT_ENDPOINTS: Readonly<Record<string, AuditEndpointMeta>> = {
|
||||
// Two controllers register this same path; Nest serves whichever module loads first.
|
||||
"POST /api/train-scheduling/schedules/:id/maintenance": ["Reschedule train for maintenance (new departure + rebalance)", "POST", "Schedule"],
|
||||
"POST /api/train-scheduling/schedules/:id/reschedule/execute": ["Execute a confirmed reschedule plan", "POST", "Schedule"],
|
||||
"POST /api/train-scheduling/schedules/:id/reschedule/preview": ["Preview reschedule / government preempt plan", "POST", "Schedule"],
|
||||
"POST /api/train-scheduling/schedules/:id/reschedule/maintenance": ["Reschedule train for maintenance (new departure + rebalance)", "POST", "Schedule"],
|
||||
// "POST /api/train-scheduling/schedules/:id/reschedule/preview": ["Preview reschedule / government preempt plan", "POST", "Schedule"],
|
||||
|
||||
// Service Type
|
||||
"POST /api/service-types": ["Create a service type", "POST", "Service Type"],
|
||||
@@ -463,7 +480,7 @@ export const AUDIT_ENDPOINTS: Readonly<Record<string, AuditEndpointMeta>> = {
|
||||
// Shipping Line Booking
|
||||
"POST /api/shipping-line-bookings/initiate": ["Initiate a bare booking (no contract). Starts at AWAITING_DOCUMENTS so the shipping line can upload its documents for Operations to approve.", "POST", "Shipping Line Booking"],
|
||||
"POST /api/shipping-line-bookings/:id/cancel": ["Cancel one of the signed-in shipping line's own bookings. Allowed only before the booking is priced.", "POST", "Shipping Line Booking"],
|
||||
"POST /api/shipping-line-bookings/:id/price-preview": ["Authoritative price quote for the completion payload — same compute as /complete, saved as the booking's breakdown + rate snapshots (refreshed on every re-preview). Persists nothing else.", "POST", "Shipping Line Booking"],
|
||||
// "POST /api/shipping-line-bookings/:id/price-preview": ["Authoritative price quote for the completion payload — same compute as /complete, saved as the booking's breakdown + rate snapshots (refreshed on every re-preview). Persists nothing else.", "POST", "Shipping Line Booking"],
|
||||
"POST /api/shipping-line-bookings/:id/complete": ["Complete an approved (CLEARANCE_READY) booking: cargo + binding shipment day.", "POST", "Shipping Line Booking"],
|
||||
|
||||
// Shipping Line Credit
|
||||
@@ -510,6 +527,8 @@ export const AUDIT_ENDPOINTS: Readonly<Record<string, AuditEndpointMeta>> = {
|
||||
"PATCH /api/train-builder/:id/details": ["Edit the train's name and fixed import/export run numbers", "PATCH", "Train Build"],
|
||||
"PUT /api/train-builder/:id/locomotives": ["Replace the locomotive set (minimum 1, same yard)", "PUT", "Train Build"],
|
||||
"POST /api/train-builder/:id/reorder-wagons": ["Persist a drag-reorder of the full consist", "POST", "Train Build"],
|
||||
"PATCH /api/train-builder/:id/wagons/:wagonId/yard": ["Move one coupled wagon to another yard — refused while any live schedule has the wagon allocated", "PATCH", "Train Build"],
|
||||
"PATCH /api/train-builder/:id/wagons/yard": ["Move several coupled wagons to another yard in one transaction — refused outright if any is allocated to a live schedule", "PATCH", "Train Build"],
|
||||
"POST /api/train-builder/:id/wagons": ["Append AVAILABLE wagons from the train's yard to the consist", "POST", "Train Build"],
|
||||
"DELETE /api/train-builder/:id/wagons/:wagonId": ["Detach one wagon from the consist", "DELETE", "Train Build"],
|
||||
"POST /api/train-builder/:id/wagons/:wagonId/maintenance": ["Detach one wagon and move it to MAINTENANCE status", "POST", "Train Build"],
|
||||
@@ -520,16 +539,16 @@ export const AUDIT_ENDPOINTS: Readonly<Record<string, AuditEndpointMeta>> = {
|
||||
"POST /api/train-scheduling/bookings/:bookingId/expire": ["Staff: expire a reservation and free its capacity", "POST", "Train Schedule"],
|
||||
"POST /api/train-scheduling/bookings/:bookingId/mark-paid": ["Staff: mark a reserved booking paid and allocate it now", "POST", "Train Schedule"],
|
||||
"POST /api/train-scheduling/bookings/:bookingId/move-schedule": ["Re-point a booking to another OPEN same-route schedule", "POST", "Train Schedule"],
|
||||
"POST /api/train-scheduling/bulk/preview": ["Preview a bulk train schedule", "POST", "Train Schedule"],
|
||||
// "POST /api/train-scheduling/bulk/preview": ["Preview a bulk train schedule", "POST", "Train Schedule"],
|
||||
"POST /api/train-scheduling/bulk/schedules": ["Create a bulk train schedule", "POST", "Train Schedule"],
|
||||
"POST /api/train-scheduling/bulk/schedules/:id/assign-bookings": ["Assign bulk bookings to a train schedule", "POST", "Train Schedule"],
|
||||
"POST /api/train-scheduling/bulk/schedules/:id/cancel": ["Cancel bulk train schedule", "POST", "Train Schedule"],
|
||||
"POST /api/train-scheduling/container/preview": ["Preview a container train schedule", "POST", "Train Schedule"],
|
||||
// "POST /api/train-scheduling/container/preview": ["Preview a container train schedule", "POST", "Train Schedule"],
|
||||
"POST /api/train-scheduling/container/schedules": ["Create a container train schedule", "POST", "Train Schedule"],
|
||||
"POST /api/train-scheduling/container/schedules/:id/assign-bookings": ["Assign container bookings to a train schedule", "POST", "Train Schedule"],
|
||||
"POST /api/train-scheduling/container/schedules/:id/cancel": ["Cancel container train schedule", "POST", "Train Schedule"],
|
||||
"PATCH /api/train-scheduling/global-rules": ["Update global train scheduling rules (singleton)", "PATCH", "Train Schedule"],
|
||||
"POST /api/train-scheduling/preview": ["Preview a mixed-capable train schedule", "POST", "Train Schedule"],
|
||||
// "POST /api/train-scheduling/preview": ["Preview a mixed-capable train schedule", "POST", "Train Schedule"],
|
||||
"POST /api/train-scheduling/schedules/:id/adjust-consist": ["Permanently trim free wagons off / couple yard wagons onto the schedule's built train (weight & length limits incl. tolerance enforced, every change logged)", "POST", "Train Schedule"],
|
||||
"POST /api/train-scheduling/schedules/:id/arrive": ["Mark a dispatched train arrived (move assets to destination yard, free assets)", "POST", "Train Schedule"],
|
||||
"POST /api/train-scheduling/schedules/:id/assign-bookings": ["Assign bookings to a train schedule (mixed-capable)", "POST", "Train Schedule"],
|
||||
@@ -564,6 +583,7 @@ export const AUDIT_ENDPOINTS: Readonly<Record<string, AuditEndpointMeta>> = {
|
||||
"DELETE /api/train-scheduling/schedules/:id/wagons/:trainSetWagonId": ["Remove an empty wagon slot from a train", "DELETE", "Train Schedule"],
|
||||
"POST /api/train-scheduling/schedules/:id/wagons/:wagonId/move-load": ["Move a wagon's whole load to another wagon (empty → move/repin, loaded → swap loads)", "POST", "Train Schedule"],
|
||||
"PATCH /api/train-scheduling/schedules/:id/window-rule": ["Override the booking-window rule for one schedule (open/close hour, duration, doc-review, payment, lead days) — only before the window opens", "PATCH", "Train Schedule"],
|
||||
"PATCH /api/train-scheduling/schedules/:id/wagon-yards": ["Re-plan the yard this departure boards wagons from and/or cuts them at (schedule-only; physical yards untouched, dispatch requires alignment)", "PATCH", "Train Schedule"],
|
||||
"POST /api/train-scheduling/schedules/:id/merge": ["Merge another train into this schedule: its wagons join this consist, a same-day schedule on it is absorbed, and the emptied train is deactivated", "POST", "Train Schedule"],
|
||||
"PATCH /api/train-scheduling/schedules/:id/checkpoints/:sequenceNo": ["Edit a logged leg", "PATCH", "Train Schedule"],
|
||||
|
||||
@@ -611,7 +631,7 @@ export const AUDIT_ENDPOINTS: Readonly<Record<string, AuditEndpointMeta>> = {
|
||||
"POST /api/warehouse-allocation-rules": ["Create a warehouse allocation rule", "POST", "Warehouse"],
|
||||
"PATCH /api/warehouse-allocation-rules/:id": ["Update a warehouse allocation rule", "PATCH", "Warehouse"],
|
||||
"DELETE /api/warehouse-allocation-rules/:id": ["Delete a warehouse allocation rule", "DELETE", "Warehouse"],
|
||||
"POST /api/warehouse-allocation/preview": ["Preview the yard/warehouse/zone a booking would be allocated to", "POST", "Warehouse"],
|
||||
// "POST /api/warehouse-allocation/preview": ["Preview the yard/warehouse/zone a booking would be allocated to", "POST", "Warehouse"],
|
||||
"POST /api/warehouse-fee-rules": ["Create a storage / demurrage fee rule", "POST", "Warehouse"],
|
||||
"PATCH /api/warehouse-fee-rules/:id": ["Update a fee rule", "PATCH", "Warehouse"],
|
||||
"DELETE /api/warehouse-fee-rules/:id": ["Delete a fee rule", "DELETE", "Warehouse"],
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { BaseRepository } from '@edr/api-common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Between, FindOptionsWhere, LessThanOrEqual, MoreThanOrEqual, Repository } from 'typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import type { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity';
|
||||
|
||||
import { AuditLog } from './entities/audit-log.entity';
|
||||
import type { AuditReferenceSource } from './audit-reference.registry';
|
||||
|
||||
export interface AuditLogQuery {
|
||||
type?: string;
|
||||
@@ -12,6 +13,10 @@ export interface AuditLogQuery {
|
||||
method?: string;
|
||||
isSuccess?: boolean;
|
||||
resourceId?: string;
|
||||
reference?: string;
|
||||
userName?: string;
|
||||
title?: string;
|
||||
q?: string;
|
||||
from?: Date;
|
||||
to?: Date;
|
||||
skip: number;
|
||||
@@ -40,30 +45,91 @@ export class AuditLogRepository extends BaseRepository<AuditLog> {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the human identifier for one entity row (`WHERE id = $1`).
|
||||
*
|
||||
* `source` comes from the static `AUDIT_REFERENCE_SOURCES` registry — never
|
||||
* from user input — so interpolating its table/column is safe; the id is
|
||||
* bound as a parameter. Returns null when the row doesn't exist or the
|
||||
* identifier column is empty.
|
||||
*/
|
||||
async lookupReference(
|
||||
source: AuditReferenceSource,
|
||||
id: string,
|
||||
): Promise<string | null> {
|
||||
const rows = await this.auditLogRepository.manager.query<
|
||||
{ reference: string | null }[]
|
||||
>(
|
||||
`SELECT ${source.column}::varchar AS reference FROM ${source.table} WHERE id = $1::uuid`,
|
||||
[id],
|
||||
);
|
||||
return rows[0]?.reference || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Paginated, filtered read. Newest first — every index on this table is
|
||||
* ordered `created_at DESC` to match.
|
||||
*
|
||||
* Query builder rather than `findAndCount`: `q` needs an OR across four
|
||||
* columns, and `reference` needs the `upper(...) LIKE` shape that matches
|
||||
* the expression index — neither fits `FindOptionsWhere`.
|
||||
*/
|
||||
async search(query: AuditLogQuery): Promise<[AuditLog[], number]> {
|
||||
const where: FindOptionsWhere<AuditLog> = {};
|
||||
const qb = this.auditLogRepository.createQueryBuilder('audit_log');
|
||||
|
||||
if (query.type) where.type = query.type;
|
||||
if (query.userId) where.userId = query.userId;
|
||||
if (query.method) where.method = query.method;
|
||||
if (query.resourceId) where.resourceId = query.resourceId;
|
||||
if (query.isSuccess !== undefined) where.isSuccess = query.isSuccess;
|
||||
if (query.type) qb.andWhere('audit_log.type = :type', { type: query.type });
|
||||
if (query.userId) qb.andWhere('audit_log.user_id = :userId', { userId: query.userId });
|
||||
if (query.method) qb.andWhere('audit_log.method = :method', { method: query.method });
|
||||
if (query.resourceId) {
|
||||
qb.andWhere('audit_log.resource_id = :resourceId', { resourceId: query.resourceId });
|
||||
}
|
||||
if (query.isSuccess !== undefined) {
|
||||
qb.andWhere('audit_log.is_success = :isSuccess', { isSuccess: query.isSuccess });
|
||||
}
|
||||
|
||||
// Case-insensitive prefix match, shaped to hit idx_audit_logs_reference_upper.
|
||||
// The explicit <> '' repeats the index's partial predicate — without it the
|
||||
// planner cannot prove the partial index applies and falls back to a scan.
|
||||
if (query.reference) {
|
||||
qb.andWhere("audit_log.reference <> ''").andWhere(
|
||||
"upper(audit_log.reference) LIKE upper(:reference) || '%'",
|
||||
{ reference: escapeLike(query.reference) },
|
||||
);
|
||||
}
|
||||
if (query.userName) {
|
||||
qb.andWhere('audit_log.user_name ILIKE :userName', {
|
||||
userName: `%${escapeLike(query.userName)}%`,
|
||||
});
|
||||
}
|
||||
if (query.title) {
|
||||
qb.andWhere('audit_log.title ILIKE :title', {
|
||||
title: `%${escapeLike(query.title)}%`,
|
||||
});
|
||||
}
|
||||
|
||||
// One search box across the columns staff actually search by.
|
||||
// ponytail: ILIKE %…% scans the time-bounded window; add pg_trgm GIN
|
||||
// indexes if the table grows past a few million rows.
|
||||
if (query.q) {
|
||||
const q = `%${escapeLike(query.q)}%`;
|
||||
qb.andWhere(
|
||||
`(audit_log.reference ILIKE :q
|
||||
OR audit_log.resource_id ILIKE :q
|
||||
OR audit_log.user_name ILIKE :q
|
||||
OR audit_log.title ILIKE :q)`,
|
||||
{ q },
|
||||
);
|
||||
}
|
||||
|
||||
// Date range: either bound may be supplied alone.
|
||||
if (query.from && query.to) where.createdAt = Between(query.from, query.to);
|
||||
else if (query.from) where.createdAt = MoreThanOrEqual(query.from);
|
||||
else if (query.to) where.createdAt = LessThanOrEqual(query.to);
|
||||
if (query.from) qb.andWhere('audit_log.created_at >= :from', { from: query.from });
|
||||
if (query.to) qb.andWhere('audit_log.created_at <= :to', { to: query.to });
|
||||
|
||||
return this.auditLogRepository.findAndCount({
|
||||
where,
|
||||
order: { createdAt: 'DESC' },
|
||||
skip: query.skip,
|
||||
take: query.take,
|
||||
});
|
||||
return qb
|
||||
.orderBy('audit_log.created_at', 'DESC')
|
||||
.skip(query.skip)
|
||||
.take(query.take)
|
||||
.getManyAndCount();
|
||||
}
|
||||
|
||||
/** Distinct entity types present, for populating a filter dropdown. */
|
||||
@@ -76,4 +142,20 @@ export class AuditLogRepository extends BaseRepository<AuditLog> {
|
||||
|
||||
return rows.map((row) => row.type);
|
||||
}
|
||||
|
||||
/** Distinct action titles present, for the action filter dropdown. */
|
||||
async distinctTitles(): Promise<string[]> {
|
||||
const rows = await this.auditLogRepository
|
||||
.createQueryBuilder('audit_log')
|
||||
.select('DISTINCT audit_log.title', 'title')
|
||||
.orderBy('audit_log.title', 'ASC')
|
||||
.getRawMany<{ title: string }>();
|
||||
|
||||
return rows.map((row) => row.title);
|
||||
}
|
||||
}
|
||||
|
||||
/** Escape LIKE wildcards so a literal `%`/`_` in the search text stays literal. */
|
||||
function escapeLike(value: string): string {
|
||||
return value.replace(/[\\%_]/g, (ch) => `\\${ch}`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Where each audited entity type keeps its human identifier — the value staff
|
||||
* search by (booking reference, train number, invoice number).
|
||||
*
|
||||
* Used by `AuditService.record` for a single indexed primary-key lookup at
|
||||
* write time. Types not listed simply get `reference = ''`; the lookup is
|
||||
* best-effort and an audit row is never lost over it.
|
||||
*
|
||||
* Table and column names are static values from this file — never user input —
|
||||
* so interpolating them into SQL is safe. Ids are always bound as parameters.
|
||||
*/
|
||||
export interface AuditReferenceSource {
|
||||
/** Schema-qualified table holding the entity. */
|
||||
readonly table: string;
|
||||
/** Column with the human identifier. */
|
||||
readonly column: string;
|
||||
}
|
||||
|
||||
export const AUDIT_REFERENCE_SOURCES: Readonly<Record<string, AuditReferenceSource>> = {
|
||||
Booking: { table: 'freight.bookings', column: 'reference' },
|
||||
Contract: { table: 'freight.contracts', column: 'reference' },
|
||||
// "Schedule" (reschedule module) and "Train Schedule" are the same table.
|
||||
Schedule: { table: 'freight.train_schedules', column: 'reference' },
|
||||
'Train Schedule': { table: 'freight.train_schedules', column: 'reference' },
|
||||
Train: { table: 'freight.trains', column: 'train_number' },
|
||||
// Train Build routes carry the train id in :id.
|
||||
'Train Build': { table: 'freight.trains', column: 'train_number' },
|
||||
Wagon: { table: 'freight.wagons', column: 'wagon_number' },
|
||||
Locomotive: { table: 'freight.locomotives', column: 'code' },
|
||||
'EIMS Invoice': { table: 'freight.invoices', column: 'invoice_number' },
|
||||
// Payment paths mostly carry an invoice id; the ones that don't (e.g.
|
||||
// redirect-success/:bookingId) miss the lookup and fall back to ''.
|
||||
Payment: { table: 'freight.invoices', column: 'invoice_number' },
|
||||
Vehicle: { table: 'freight.vehicles', column: 'plate_number' },
|
||||
Company: { table: 'freight.companies', column: 'name' },
|
||||
};
|
||||
|
||||
/** Lookups run `WHERE id = $1::uuid` — guard non-uuid ids (template codes…). */
|
||||
export const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
@@ -43,4 +43,13 @@ export class AuditController {
|
||||
types(): Promise<string[]> {
|
||||
return this.auditService.listTypes();
|
||||
}
|
||||
|
||||
@Get('actions')
|
||||
@BookingStaff(FREIGHT_PERMS.auditLog.view)
|
||||
@ApiOperation({
|
||||
summary: 'Distinct action titles present in the audit log (filter dropdown)',
|
||||
})
|
||||
actions(): Promise<string[]> {
|
||||
return this.auditService.listActions();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,10 @@ import { PaginatedResponse } from '@edr/types';
|
||||
import { AuditLog } from './entities/audit-log.entity';
|
||||
import { AuditLogRepository } from './audit-log.repository';
|
||||
import { AuditLogQueryDto } from './dto/audit-log-query.dto';
|
||||
import {
|
||||
AUDIT_REFERENCE_SOURCES,
|
||||
UUID_PATTERN,
|
||||
} from './audit-reference.registry';
|
||||
import {
|
||||
buildPaginationMeta,
|
||||
normalizePagination,
|
||||
@@ -25,6 +29,7 @@ export class AuditService {
|
||||
*/
|
||||
async record(entry: Partial<AuditLog>): Promise<void> {
|
||||
try {
|
||||
entry.reference = await this.resolveReference(entry.type, entry.resourceId);
|
||||
await this.auditLogRepository.record(entry);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
@@ -35,6 +40,34 @@ export class AuditService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort human identifier (booking reference, train number, …) for the
|
||||
* entity the action touched — one primary-key lookup against the table
|
||||
* registered for the type. Always returns a string: '' when the type has no
|
||||
* registered source, the id isn't a uuid (template codes), the row is gone,
|
||||
* or the lookup itself fails. A missing reference must never cost the audit
|
||||
* row, so failures degrade to '' rather than throwing.
|
||||
*/
|
||||
private async resolveReference(
|
||||
type: string | undefined,
|
||||
resourceId: string | null | undefined,
|
||||
): Promise<string> {
|
||||
const source = type ? AUDIT_REFERENCE_SOURCES[type] : undefined;
|
||||
if (!source || !resourceId || !UUID_PATTERN.test(resourceId)) return '';
|
||||
|
||||
try {
|
||||
const reference = await this.auditLogRepository.lookupReference(source, resourceId);
|
||||
return reference?.slice(0, 64) ?? '';
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`Reference lookup failed for ${type} ${resourceId}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/** Paginated, filtered audit history, newest first. */
|
||||
async search(query: AuditLogQueryDto): Promise<PaginatedResponse<AuditLog>> {
|
||||
const { page, pageSize, skip, take } = normalizePagination(query);
|
||||
@@ -53,6 +86,10 @@ export class AuditService {
|
||||
userId: query.userId,
|
||||
method: query.method,
|
||||
resourceId: query.resourceId,
|
||||
reference: query.reference,
|
||||
userName: query.userName,
|
||||
title: query.title,
|
||||
q: query.q,
|
||||
isSuccess:
|
||||
query.isSuccess === undefined ? undefined : query.isSuccess === 'true',
|
||||
from,
|
||||
@@ -68,4 +105,9 @@ export class AuditService {
|
||||
async listTypes(): Promise<string[]> {
|
||||
return this.auditLogRepository.distinctTypes();
|
||||
}
|
||||
|
||||
/** Distinct action titles, for the action filter dropdown. */
|
||||
async listActions(): Promise<string[]> {
|
||||
return this.auditLogRepository.distinctTitles();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,6 +39,44 @@ export class AuditLogQueryDto extends PaginationQueryDto {
|
||||
@MaxLength(64)
|
||||
resourceId?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'Human identifier of the affected record — booking reference, schedule number, train number. Case-insensitive prefix match.',
|
||||
example: 'S-2026-00045',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(64)
|
||||
reference?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Staff name, case-insensitive substring match.',
|
||||
example: 'Mulu',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(150)
|
||||
userName?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Action title, case-insensitive substring match.',
|
||||
example: 'Cancel booking',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(255)
|
||||
title?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'Free-text search across reference, resource id, staff name and action title.',
|
||||
example: 'B-2026-00120',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
q?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Filter by outcome: true = succeeded, false = failed.',
|
||||
})
|
||||
|
||||
@@ -86,6 +86,20 @@ export class AuditLog {
|
||||
@Column({ name: 'resource_id', type: 'varchar', length: 64, nullable: true })
|
||||
resourceId?: string | null;
|
||||
|
||||
/**
|
||||
* Human identifier of the affected record — booking reference, schedule
|
||||
* number, train number — resolved at write time from
|
||||
* `AUDIT_REFERENCE_SOURCES`. This is what staff type into the search box;
|
||||
* `resourceId` stays the machine id.
|
||||
*
|
||||
* `''` (never NULL) when the entity type has no registered source, the
|
||||
* lookup found nothing, or the row predates the column. Empty string keeps
|
||||
* search SQL to one shape and matches how pre-existing rows read after the
|
||||
* metadata-only migration.
|
||||
*/
|
||||
@Column({ name: 'reference', type: 'varchar', length: 64, default: '' })
|
||||
reference!: string;
|
||||
|
||||
/**
|
||||
* Sanitized request body. Secrets are replaced with `[REDACTED]` and uploads
|
||||
* are reduced to `{ __file, originalName, mimeType, size }` descriptors —
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Body, Controller, Patch, Post, UseGuards } from "@nestjs/common";
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
import { CurrentUser } from "@tria-plc/api-common/modules/auth/decorators/current-user.decorator";
|
||||
import { JwtGuard } from "@tria-plc/api-common/modules/auth/services/jwt.guard";
|
||||
import { FreightJwtGuard } from "../../common/freight-jwt.guard";
|
||||
import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type";
|
||||
|
||||
import { AccountService } from "./account.service";
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
@ApiTags("auth")
|
||||
@Controller("me")
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtGuard)
|
||||
@UseGuards(FreightJwtGuard)
|
||||
export class AccountController {
|
||||
constructor(private readonly accountService: AccountService) {}
|
||||
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { InjectRepository } from "@nestjs/typeorm";
|
||||
import { In, Repository } from "typeorm";
|
||||
|
||||
import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity";
|
||||
|
||||
import { ExternalProfile } from "../companies/entities/external-profile.entity";
|
||||
|
||||
/**
|
||||
* One portal login belonging to a customer company: the company-side profile
|
||||
* joined to the IAM account that actually signs in.
|
||||
*
|
||||
* The two halves drift apart routinely — `company.email` is business contact
|
||||
* detail, while `email` here is the credential a reset link goes to — which is
|
||||
* exactly why staff need to see the IAM side rather than the company row.
|
||||
*/
|
||||
export interface CustomerAccount {
|
||||
/** external_profiles.id */
|
||||
profileId: string;
|
||||
userId: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
jobTitle: string | null;
|
||||
isPrimaryContact: boolean;
|
||||
onboardingStep: string | null;
|
||||
onboardingCompleted: boolean;
|
||||
/** Null when the profile points at a user row that no longer exists. */
|
||||
username: string | null;
|
||||
email: string | null;
|
||||
phoneNumber: string | null;
|
||||
phoneVerified: boolean | null;
|
||||
/** IAM account status (`EUserStatus`), surfaced as-is. */
|
||||
status: string | null;
|
||||
isActive: boolean | null;
|
||||
/** False means the account was created but never activated by its owner. */
|
||||
hasSetPassword: boolean | null;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class CustomerAccountsService {
|
||||
constructor(
|
||||
@InjectRepository(ExternalProfile)
|
||||
private readonly profiles: Repository<ExternalProfile>,
|
||||
@InjectRepository(User)
|
||||
private readonly users: Repository<User>,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Every portal account for a company, primary contact first.
|
||||
*
|
||||
* Deliberately NOT filtered to active accounts: a suspended or never-activated
|
||||
* login is the case staff are usually looking into, and hiding it would leave
|
||||
* "the customer says they can't log in" unanswerable from this screen.
|
||||
*/
|
||||
async listForCompany(companyId: string): Promise<CustomerAccount[]> {
|
||||
const profiles = await this.profiles.find({ where: { companyId } });
|
||||
if (profiles.length === 0) return [];
|
||||
|
||||
const userIds = profiles.map((p) => p.userId).filter(Boolean);
|
||||
// Explicit select: the User entity's relations include credentials and
|
||||
// sessions, and this response goes to a browser.
|
||||
const users = userIds.length
|
||||
? await this.users
|
||||
.createQueryBuilder("user")
|
||||
.select([
|
||||
"user.id",
|
||||
"user.username",
|
||||
"user.email",
|
||||
"user.phoneNumber",
|
||||
"user.isPhoneNumberVerified",
|
||||
"user.status",
|
||||
"user.isActive",
|
||||
"user.hasSetPassword",
|
||||
])
|
||||
.where({ id: In(userIds) })
|
||||
.getMany()
|
||||
: [];
|
||||
const byId = new Map(users.map((u) => [u.id, u]));
|
||||
|
||||
return profiles
|
||||
.map((p) => {
|
||||
const user = byId.get(p.userId);
|
||||
return {
|
||||
profileId: p.id,
|
||||
userId: p.userId,
|
||||
firstName: p.firstName,
|
||||
lastName: p.lastName,
|
||||
jobTitle: p.jobTitle ?? null,
|
||||
isPrimaryContact: p.isPrimaryContact,
|
||||
onboardingStep: p.onboardingStep ?? null,
|
||||
onboardingCompleted: p.onboardingCompleted ?? false,
|
||||
username: user?.username ?? null,
|
||||
email: user?.email ?? null,
|
||||
phoneNumber: user?.phoneNumber ?? null,
|
||||
phoneVerified: user?.isPhoneNumberVerified ?? null,
|
||||
status: user?.status ?? null,
|
||||
isActive: user?.isActive ?? null,
|
||||
hasSetPassword: user?.hasSetPassword ?? null,
|
||||
createdAt: p.createdAt,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => {
|
||||
// Primary contact first — it is the account every staff action
|
||||
// (password reset, notifications) actually targets.
|
||||
if (a.isPrimaryContact !== b.isPrimaryContact) {
|
||||
return a.isPrimaryContact ? -1 : 1;
|
||||
}
|
||||
return a.createdAt.getTime() - b.createdAt.getTime();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,10 @@ import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
import { BookingStaff } from "../../common/booking-guards";
|
||||
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
|
||||
import { BackofficeResetPasswordDto } from "./dto/forgot-password.dto";
|
||||
import {
|
||||
CustomerAccount,
|
||||
CustomerAccountsService,
|
||||
} from "./customer-accounts.service";
|
||||
import {
|
||||
CustomerResetService,
|
||||
CustomerResetTarget,
|
||||
@@ -25,7 +29,22 @@ import {
|
||||
@Controller("backoffice/customers")
|
||||
@ApiBearerAuth()
|
||||
export class CustomerResetController {
|
||||
constructor(private readonly customerResetService: CustomerResetService) {}
|
||||
constructor(
|
||||
private readonly customerResetService: CustomerResetService,
|
||||
private readonly customerAccountsService: CustomerAccountsService,
|
||||
) {}
|
||||
|
||||
@Get(":companyId/accounts")
|
||||
@BookingStaff(FREIGHT_PERMS.customers.view)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"The portal login accounts belonging to a customer, primary contact first",
|
||||
})
|
||||
async accounts(
|
||||
@Param("companyId", ParseUUIDPipe) companyId: string,
|
||||
): Promise<CustomerAccount[]> {
|
||||
return this.customerAccountsService.listForCompany(companyId);
|
||||
}
|
||||
|
||||
@Get(":companyId/reset-target")
|
||||
@BookingStaff(FREIGHT_PERMS.customers.resetPassword)
|
||||
|
||||
@@ -13,6 +13,7 @@ import { AccountController } from './account.controller';
|
||||
import { AccountService } from './account.service';
|
||||
import { CheckAvailabilityController } from './check-availability.controller';
|
||||
import { CheckAvailabilityService } from './check-availability.service';
|
||||
import { CustomerAccountsService } from './customer-accounts.service';
|
||||
import { CustomerResetController } from './customer-reset.controller';
|
||||
import { CustomerResetService } from './customer-reset.service';
|
||||
import { ForgotPasswordController } from './forgot-password.controller';
|
||||
@@ -50,6 +51,7 @@ import { ListUsersService } from './list-users.service';
|
||||
CheckAvailabilityService,
|
||||
ForgotPasswordService,
|
||||
CustomerResetService,
|
||||
CustomerAccountsService,
|
||||
],
|
||||
// Shipping-line registration mints activation links through the same
|
||||
// staff-triggered reset path customers use.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Controller, Get, UseGuards } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { CurrentUser } from '@tria-plc/api-common/modules/auth/decorators/current-user.decorator';
|
||||
import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard';
|
||||
import { FreightJwtGuard } from '../../common/freight-jwt.guard';
|
||||
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
||||
|
||||
import { FreightMeService } from './freight-me.service';
|
||||
@@ -13,7 +13,7 @@ export class FreightMeController {
|
||||
constructor(private readonly freightMeService: FreightMeService) {}
|
||||
|
||||
@Get()
|
||||
@UseGuards(JwtGuard)
|
||||
@UseGuards(FreightJwtGuard)
|
||||
@ApiOperation({
|
||||
summary: 'Current user with flat permissionKeys for backoffice gating',
|
||||
})
|
||||
|
||||
@@ -9,6 +9,9 @@ import {
|
||||
} from '../../common/freight-permission.util';
|
||||
import { PERMISSIONS_CATALOG } from '../../seed/freight-permissions.registry';
|
||||
|
||||
/** One position as the session snapshot carries it. */
|
||||
type TokenPosition = NonNullable<TCurrentUser['employee']>['position'];
|
||||
|
||||
@Injectable()
|
||||
export class FreightMeService {
|
||||
constructor(@InjectDataSource() private readonly dataSource: DataSource) {}
|
||||
@@ -65,49 +68,63 @@ export class FreightMeService {
|
||||
}
|
||||
|
||||
async getEnrichedProfile(user: TCurrentUser) {
|
||||
const positionId = user.employee?.position?.id;
|
||||
const [positionType, positionTypePermissionKeys] = await Promise.all([
|
||||
this.lookupPositionType(positionId),
|
||||
this.lookupPositionTypePermissions(positionId),
|
||||
]);
|
||||
const employeeRecord = user.employee as
|
||||
| (typeof user.employee & { positions?: TokenPosition[] })
|
||||
| undefined;
|
||||
|
||||
// Merge the type-level grants into the position's own permission list so
|
||||
// BOTH consumers see them: `collectPermissionKeys` below, and the
|
||||
// backoffice's `getPermissionKeys`, which walks this same nested array.
|
||||
const positionPermissions = [
|
||||
...(user.employee?.position?.permissions ?? []),
|
||||
];
|
||||
const seenPermissionKeys = new Set(
|
||||
positionPermissions.map((p) => p?.key).filter(Boolean),
|
||||
// `FreightJwtGuard` restores every position the login snapshot holds; the
|
||||
// stock IAM guard only ever leaves the single `position`. Fall back to it
|
||||
// so a request that somehow skipped our guard still resolves one post
|
||||
// rather than none.
|
||||
const rawPositions: TokenPosition[] = employeeRecord?.positions?.length
|
||||
? employeeRecord.positions
|
||||
: employeeRecord?.position
|
||||
? [employeeRecord.position]
|
||||
: [];
|
||||
|
||||
const enrichedPositions = await Promise.all(
|
||||
rawPositions.map(async (position) => {
|
||||
const [positionType, positionTypePermissionKeys] = await Promise.all([
|
||||
this.lookupPositionType(position.id),
|
||||
this.lookupPositionTypePermissions(position.id),
|
||||
]);
|
||||
|
||||
// Merge the type-level grants into this position's own permission list
|
||||
// so BOTH consumers see them: `collectPermissionKeys` below, and the
|
||||
// backoffice's `getPermissionKeys`, which walks this nested array.
|
||||
const permissions = [...(position.permissions ?? [])];
|
||||
const seen = new Set(permissions.map((p) => p?.key).filter(Boolean));
|
||||
for (const key of positionTypePermissionKeys) {
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key);
|
||||
permissions.push({ key } as (typeof permissions)[number]);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
positionTypePermissionKeys,
|
||||
position: {
|
||||
id: position.id,
|
||||
key: position.key,
|
||||
employeePositionId: position.employeePositionId,
|
||||
name: position.name,
|
||||
isDelegate: position.isDelegate,
|
||||
parentPositionId: position.parentPositionId,
|
||||
permissions,
|
||||
positionType,
|
||||
},
|
||||
};
|
||||
}),
|
||||
);
|
||||
for (const key of positionTypePermissionKeys) {
|
||||
if (!seenPermissionKeys.has(key)) {
|
||||
seenPermissionKeys.add(key);
|
||||
positionPermissions.push({ key } as (typeof positionPermissions)[number]);
|
||||
}
|
||||
}
|
||||
|
||||
const employee = user.employee
|
||||
const employee = employeeRecord
|
||||
? [
|
||||
{
|
||||
id: user.employee.id,
|
||||
organizationId: user.employee.organizationId,
|
||||
unitId: user.employee.unitId,
|
||||
name: user.employee.name,
|
||||
positions: user.employee.position
|
||||
? [
|
||||
{
|
||||
id: user.employee.position.id,
|
||||
key: user.employee.position.key,
|
||||
employeePositionId: user.employee.position.employeePositionId,
|
||||
name: user.employee.position.name,
|
||||
isDelegate: user.employee.position.isDelegate,
|
||||
parentPositionId: user.employee.position.parentPositionId,
|
||||
permissions: positionPermissions,
|
||||
positionType,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
id: employeeRecord.id,
|
||||
organizationId: employeeRecord.organizationId,
|
||||
unitId: employeeRecord.unitId,
|
||||
name: employeeRecord.name,
|
||||
positions: enrichedPositions.map((p) => p.position),
|
||||
},
|
||||
]
|
||||
: [];
|
||||
@@ -118,7 +135,7 @@ export class FreightMeService {
|
||||
const permissionKeys = [
|
||||
...new Set([
|
||||
...collectPermissionKeys(user),
|
||||
...positionTypePermissionKeys,
|
||||
...enrichedPositions.flatMap((p) => p.positionTypePermissionKeys),
|
||||
]),
|
||||
];
|
||||
|
||||
|
||||
@@ -939,8 +939,12 @@ describe("BillingService.document", () => {
|
||||
const build = (invoice: Record<string, unknown>) => {
|
||||
const render = jest.fn().mockResolvedValue({ filename: "x.pdf", buffer: Buffer.from("") });
|
||||
const renderThermal = jest.fn().mockResolvedValue({ filename: "x-thermal.pdf", buffer: Buffer.from("") });
|
||||
// `toDocumentModel` reads the booking (route/wagons, PNR) straight off the
|
||||
// data source for a booking-sourced invoice — a stub that answers "no such
|
||||
// booking" keeps these summary assertions about the invoice itself.
|
||||
const dataSource = { getRepository: () => ({ findOne: jest.fn().mockResolvedValue(null) }) };
|
||||
const service = new BillingService(
|
||||
{} as never,
|
||||
dataSource as never,
|
||||
{ findById: jest.fn().mockResolvedValue(invoice) } as never,
|
||||
{ findAll: jest.fn().mockResolvedValue([]) } as never,
|
||||
{} as never,
|
||||
@@ -1014,6 +1018,34 @@ describe("BillingService.document", () => {
|
||||
expect(model.qrImageUrl).toBe("data:image/png;base64,signed-payload");
|
||||
});
|
||||
|
||||
it("prints the provider transaction reference of a settled invoice", async () => {
|
||||
const { service, render } = build(
|
||||
invoiceRow({
|
||||
status: Freight.InvoiceStatus.Paid,
|
||||
paidAmount: 100,
|
||||
balanceAmount: 0,
|
||||
payments: [{ amount: 100, method: "GATEWAY", reference: "FT26082700123", paidAt: "2026-08-27T09:00:00.000Z" }],
|
||||
payment: { transactionId: "FT26082700123" },
|
||||
}),
|
||||
);
|
||||
|
||||
await service.document("inv-1");
|
||||
|
||||
const model = render.mock.calls[0][0];
|
||||
expect(model.summary).toContainEqual({ label: "Transaction ref", value: "FT26082700123" });
|
||||
});
|
||||
|
||||
it("adds no transaction reference row to an unpaid invoice", async () => {
|
||||
const { service, render } = build(invoiceRow());
|
||||
|
||||
await service.document("inv-1");
|
||||
|
||||
const model = render.mock.calls[0][0];
|
||||
expect(
|
||||
model.summary.find((r: { label: string }) => r.label === "Transaction ref"),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("calls render (not renderThermal) for the default format", async () => {
|
||||
const { service, render, renderThermal } = build(invoiceRow());
|
||||
jest.spyOn(service as never, "toDocumentModel").mockResolvedValue({} as never);
|
||||
|
||||
@@ -29,6 +29,7 @@ import { PaymentService } from "../payment/payment.service";
|
||||
import { InitiateResponseDto, IntentStatusDto } from "../payment/payments.dto";
|
||||
import {
|
||||
InvoiceDocumentModel,
|
||||
sameCompanyName,
|
||||
InvoiceDocumentService,
|
||||
pngDataUrl,
|
||||
} from "./documents/invoice-document.service";
|
||||
@@ -41,6 +42,7 @@ import {
|
||||
applySettlement,
|
||||
invoicePaymentMethodExpr,
|
||||
round2,
|
||||
settlementReferences,
|
||||
} from "./invoice-settlement.util";
|
||||
import { InvoiceRepository } from "./invoice.repository";
|
||||
|
||||
@@ -267,7 +269,7 @@ export class BillingService {
|
||||
private readonly files: FilesService,
|
||||
private readonly config: ConfigService,
|
||||
private readonly manualPaymentSettings: ManualPaymentSettingsService,
|
||||
) { }
|
||||
) {}
|
||||
|
||||
// ── Reads ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -300,7 +302,9 @@ export class BillingService {
|
||||
});
|
||||
}
|
||||
if (filter.sources?.length) {
|
||||
qb.andWhere("invoice.source IN (:...sources)", { sources: filter.sources });
|
||||
qb.andWhere("invoice.source IN (:...sources)", {
|
||||
sources: filter.sources,
|
||||
});
|
||||
}
|
||||
if (filter.eimsStatuses?.length) {
|
||||
qb.andWhere("invoice.eimsStatus IN (:...eimsStatuses)", {
|
||||
@@ -326,7 +330,9 @@ export class BillingService {
|
||||
});
|
||||
}
|
||||
if (filter.issuedTo) {
|
||||
qb.andWhere("invoice.issuedAt <= :issuedTo", { issuedTo: filter.issuedTo });
|
||||
qb.andWhere("invoice.issuedAt <= :issuedTo", {
|
||||
issuedTo: filter.issuedTo,
|
||||
});
|
||||
}
|
||||
if (filter.dueFrom) {
|
||||
qb.andWhere("invoice.dueAt >= :dueFrom", { dueFrom: filter.dueFrom });
|
||||
@@ -589,21 +595,28 @@ export class BillingService {
|
||||
/**
|
||||
* Finance's manual-settlement worklist: USD invoices (paid by bank transfer,
|
||||
* never through the gateway) and ETB invoices Finance settles by hand (bank
|
||||
* transfer / counter) instead of the customer paying online. Open ones by
|
||||
* default or a single status when filtered; both currencies unless
|
||||
* `currency` narrows it. Booking-sourced rows carry the booking's reference,
|
||||
* trade direction and pay-window deadline so the UI can show the countdown
|
||||
* and link to the booking.
|
||||
* transfer / counter) instead of the customer paying online. Both currencies
|
||||
* unless `currency` narrows it, and only ones whose manual-payment channel is
|
||||
* switched on. Open ones by default — pin `status` or `statuses` to widen
|
||||
* that. Every other dimension is the invoice list's own (`applyInvoiceFilters`
|
||||
* + `INVOICE_SORT_COLUMNS`), so the two screens filter and sort alike.
|
||||
* Booking-sourced rows carry the booking's reference, trade direction and
|
||||
* pay-window deadline so the UI can show the countdown and link to the
|
||||
* booking.
|
||||
*/
|
||||
async findOfflineUsdPaginated(
|
||||
filter: {
|
||||
status?: Freight.InvoiceStatus;
|
||||
search?: string;
|
||||
currency?: "USD" | "ETB";
|
||||
filter: InvoiceListFilters & {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
sortBy?: string;
|
||||
sortOrder?: "ASC" | "DESC";
|
||||
} = {},
|
||||
): Promise<{ items: OfflineUsdInvoiceRow[]; total: number }> {
|
||||
): Promise<{
|
||||
items: OfflineUsdInvoiceRow[];
|
||||
total: number;
|
||||
/** Sum of `balanceAmount` over the WHOLE filtered set, by currency. */
|
||||
outstanding: Record<string, number>;
|
||||
}> {
|
||||
const page = filter.page && filter.page > 0 ? filter.page : 1;
|
||||
const pageSize =
|
||||
filter.pageSize && filter.pageSize > 0 ? filter.pageSize : 20;
|
||||
@@ -612,33 +625,75 @@ export class BillingService {
|
||||
// a row Finance cannot act on is noise, and the confirm endpoint would
|
||||
// refuse it anyway. All off → nothing to work.
|
||||
const enabled = await this.manualPaymentSettings.enabledCurrencies();
|
||||
if (!enabled.length) return { items: [], total: 0 };
|
||||
const currencies = filter.currency
|
||||
? enabled.filter((c) => c === filter.currency)
|
||||
: enabled;
|
||||
if (!currencies.length) return { items: [], total: 0 };
|
||||
const empty = { items: [], total: 0, outstanding: {} };
|
||||
if (!enabled.length) return empty;
|
||||
const wanted = filter.currency?.toUpperCase();
|
||||
const currencies = wanted ? enabled.filter((c) => c === wanted) : enabled;
|
||||
if (!currencies.length) return empty;
|
||||
|
||||
const qb = this.dataSource
|
||||
.getRepository(Invoice)
|
||||
.createQueryBuilder("invoice")
|
||||
.leftJoinAndSelect("invoice.company", "company")
|
||||
.where("UPPER(invoice.currency) IN (:...currencies)", { currencies })
|
||||
.orderBy("invoice.issuedAt", "DESC")
|
||||
/**
|
||||
* The worklist narrows by the same vocabulary as the main invoice list, so
|
||||
* both share `applyInvoiceFilters` — which references the `company` and
|
||||
* `payment` aliases, hence the unconditional joins. `select` is false for
|
||||
* the aggregate pass, where joined columns would break the GROUP BY.
|
||||
*/
|
||||
const buildQb = (select: boolean) => {
|
||||
const qb = this.dataSource
|
||||
.getRepository(Invoice)
|
||||
.createQueryBuilder("invoice");
|
||||
if (select) {
|
||||
qb.leftJoinAndSelect("invoice.company", "company").leftJoinAndSelect(
|
||||
"invoice.payment",
|
||||
"payment",
|
||||
);
|
||||
} else {
|
||||
qb.leftJoin("invoice.company", "company").leftJoin(
|
||||
"invoice.payment",
|
||||
"payment",
|
||||
);
|
||||
}
|
||||
qb.where("UPPER(invoice.currency) IN (:...currencies)", { currencies });
|
||||
// "What still needs settling" is the default cut, but only until the
|
||||
// caller pins a status — either the single-status param or the filter
|
||||
// bar's multi-select.
|
||||
if (!filter.status && !filter.statuses?.length) {
|
||||
qb.andWhere("invoice.status IN (:...open)", { open: OPEN_STATUSES });
|
||||
}
|
||||
// `currency` is already enforced by the enabled-currency IN above, and
|
||||
// re-applying it would only repeat the same predicate.
|
||||
this.applyInvoiceFilters(qb, { ...filter, currency: undefined });
|
||||
return qb;
|
||||
};
|
||||
|
||||
const qb = buildQb(true)
|
||||
// sortBy is whitelisted through INVOICE_SORT_COLUMNS, never interpolated
|
||||
// raw; the id tiebreaker keeps paging stable when the column ties.
|
||||
.orderBy(
|
||||
INVOICE_SORT_COLUMNS[filter.sortBy ?? ""] ?? "invoice.issuedAt",
|
||||
filter.sortOrder ?? "DESC",
|
||||
)
|
||||
.addOrderBy("invoice.id", "ASC")
|
||||
.skip((page - 1) * pageSize)
|
||||
.take(pageSize);
|
||||
if (filter.status) {
|
||||
qb.andWhere("invoice.status = :status", { status: filter.status });
|
||||
} else {
|
||||
qb.andWhere("invoice.status IN (:...open)", { open: OPEN_STATUSES });
|
||||
}
|
||||
if (filter.search) {
|
||||
qb.andWhere(
|
||||
"(invoice.invoiceNumber ILIKE :search OR invoice.sourceId ILIKE :search)",
|
||||
{ search: `%${filter.search}%` },
|
||||
);
|
||||
}
|
||||
|
||||
const [rawItems, total] = await qb.getManyAndCount();
|
||||
|
||||
// Outstanding across the whole filtered set, not the visible page — the
|
||||
// KPI must not change as Finance pages through the worklist.
|
||||
const outstandingRows: { currency: string; outstanding: string }[] =
|
||||
await buildQb(false)
|
||||
.select("invoice.currency", "currency")
|
||||
.addSelect("SUM(invoice.balanceAmount)", "outstanding")
|
||||
.groupBy("invoice.currency")
|
||||
.getRawMany();
|
||||
// Folded case-insensitively on the way out: stored casing has drifted
|
||||
// ("usd" rows exist), so two groups can address the same currency.
|
||||
const outstanding: Record<string, number> = {};
|
||||
for (const row of outstandingRows) {
|
||||
const key = (row.currency ?? "").toUpperCase();
|
||||
outstanding[key] =
|
||||
(outstanding[key] ?? 0) + (Number(row.outstanding) || 0);
|
||||
}
|
||||
const items = await this.attachShippingLineCompanies(rawItems);
|
||||
|
||||
const bookingIds = items
|
||||
@@ -702,6 +757,7 @@ export class BillingService {
|
||||
} as OfflineUsdInvoiceRow;
|
||||
}),
|
||||
total,
|
||||
outstanding,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -848,7 +904,9 @@ export class BillingService {
|
||||
{
|
||||
label: "Wagons",
|
||||
value:
|
||||
booking.wagonsRequired != null ? String(booking.wagonsRequired) : null,
|
||||
booking.wagonsRequired != null
|
||||
? String(booking.wagonsRequired)
|
||||
: null,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -875,10 +933,21 @@ export class BillingService {
|
||||
totals.push({ label: "Paid", amount: Number(invoice.paidAmount) });
|
||||
totals.push({ label: "Balance", amount: Number(invoice.balanceAmount) });
|
||||
|
||||
const tradeName = invoice.companyProfile?.etradeBusiness?.tradeName?.trim();
|
||||
|
||||
const summary: InvoiceDocumentModel["summary"] = [
|
||||
// Buyer identity — was missing entirely; a MoR-registered invoice must show who it was
|
||||
// filed against, not just the seller. VatNumber shown only when the company has one.
|
||||
{ label: "Buyer", value: invoice.company?.name ?? null },
|
||||
// The trade name of the eTrade licence THIS profile operates as. A TIN
|
||||
// holds many licences and the invoiced role (importer/exporter/forwarder)
|
||||
// is usually a different business from the one the company registered
|
||||
// under, so the buyer's name alone doesn't say which one was billed.
|
||||
// Suppressed when it just repeats the buyer name — most companies trade
|
||||
// under their registered name and a duplicate row helps nobody.
|
||||
...(tradeName && !sameCompanyName(tradeName, invoice.company?.name)
|
||||
? [{ label: "Buyer trade name", value: tradeName }]
|
||||
: []),
|
||||
{ label: "Buyer TIN", value: invoice.company?.tin ?? null },
|
||||
...(invoice.company?.vatNumber
|
||||
? [{ label: "Buyer VAT No.", value: invoice.company.vatNumber }]
|
||||
@@ -907,11 +976,24 @@ export class BillingService {
|
||||
const eimsCfg = this.config.get<EimsConfig>("eims");
|
||||
if (eimsCfg?.tin) summary.push({ label: "Seller TIN", value: eimsCfg.tin });
|
||||
if (eimsCfg?.invoice?.sellerVatNumber) {
|
||||
summary.push({ label: "Seller VAT No.", value: eimsCfg.invoice.sellerVatNumber });
|
||||
summary.push({
|
||||
label: "Seller VAT No.",
|
||||
value: eimsCfg.invoice.sellerVatNumber,
|
||||
});
|
||||
}
|
||||
|
||||
// MoR EIMS reference — only once actually registered, never a placeholder row.
|
||||
if (invoice.eimsIrn) summary.push({ label: "EIMS IRN", value: invoice.eimsIrn });
|
||||
if (invoice.eimsIrn)
|
||||
summary.push({ label: "EIMS IRN", value: invoice.eimsIrn });
|
||||
|
||||
// The provider's transaction number for the money actually received — CBE's `FT…`,
|
||||
// telebirr's receipt number, or the bank-slip reference a teller recorded manually.
|
||||
// It is what a payer holding a receipt can match this invoice against, and what
|
||||
// finance reconciles a bank statement with; without it a PAID invoice proves only
|
||||
// that EDR says it was paid. `findById` already loads the `payment` relation, so both
|
||||
// sources are in hand here — see settlementReferences for why both are read.
|
||||
const txnRefs = settlementReferences(invoice);
|
||||
if (txnRefs) summary.push({ label: "Transaction ref", value: txnRefs });
|
||||
|
||||
// PNR — the CBE_BILL reference the customer pays against, written onto the booking at
|
||||
// payment-initiation time (see initiatePayment()). Not a column on Invoice/Payment, so
|
||||
@@ -921,7 +1003,8 @@ export class BillingService {
|
||||
where: { id: invoice.sourceId },
|
||||
select: ["id", "pnrCode"],
|
||||
});
|
||||
if (booking?.pnrCode) summary.push({ label: "PNR", value: booking.pnrCode });
|
||||
if (booking?.pnrCode)
|
||||
summary.push({ label: "PNR", value: booking.pnrCode });
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -942,7 +1025,9 @@ export class BillingService {
|
||||
currency: l.currency,
|
||||
})),
|
||||
totals,
|
||||
qrImageUrl: invoice.eimsSignedQr ? pngDataUrl(invoice.eimsSignedQr) : null,
|
||||
qrImageUrl: invoice.eimsSignedQr
|
||||
? pngDataUrl(invoice.eimsSignedQr)
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1201,7 +1286,9 @@ export class BillingService {
|
||||
metadata: l.metadata ?? null,
|
||||
}));
|
||||
|
||||
const total = round2(lines.reduce((sum, l) => sum + Number(l.amount ?? 0), 0));
|
||||
const total = round2(
|
||||
lines.reduce((sum, l) => sum + Number(l.amount ?? 0), 0),
|
||||
);
|
||||
if (!(total > 0)) {
|
||||
throw new BadRequestException("A memo must have a positive total.");
|
||||
}
|
||||
@@ -1231,7 +1318,9 @@ export class BillingService {
|
||||
subtotalAmount: total,
|
||||
taxAmount: 0,
|
||||
totalAmount: total,
|
||||
...(settled ? { status: Freight.InvoiceStatus.Paid, dueAt: new Date() } : {}),
|
||||
...(settled
|
||||
? { status: Freight.InvoiceStatus.Paid, dueAt: new Date() }
|
||||
: {}),
|
||||
},
|
||||
mg,
|
||||
code,
|
||||
@@ -1242,7 +1331,11 @@ export class BillingService {
|
||||
eimsReason: reason,
|
||||
relatedInvoiceId: original.id,
|
||||
...(settled
|
||||
? { paidAmount: memo.totalAmount, balanceAmount: 0, paidAt: new Date() }
|
||||
? {
|
||||
paidAmount: memo.totalAmount,
|
||||
balanceAmount: 0,
|
||||
paidAt: new Date(),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
await mg.update(Invoice, memo.id, patch);
|
||||
@@ -1302,7 +1395,7 @@ export class BillingService {
|
||||
input.dueAt ??
|
||||
new Date(
|
||||
Date.now() +
|
||||
(input.dueInDays ?? DEFAULT_DUE_DAYS) * 24 * 60 * 60 * 1000,
|
||||
(input.dueInDays ?? DEFAULT_DUE_DAYS) * 24 * 60 * 60 * 1000,
|
||||
);
|
||||
|
||||
const invoiceNumber = await this.nextInvoiceNumber(mg, code);
|
||||
@@ -1823,9 +1916,9 @@ export class BillingService {
|
||||
dueAt,
|
||||
...(issuing
|
||||
? {
|
||||
status: Freight.InvoiceStatus.Pending,
|
||||
issuedAt: invoice.issuedAt ?? new Date(),
|
||||
}
|
||||
status: Freight.InvoiceStatus.Pending,
|
||||
issuedAt: invoice.issuedAt ?? new Date(),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
await mg.update(Invoice, { id: invoice.id }, patch);
|
||||
@@ -1889,10 +1982,7 @@ export class BillingService {
|
||||
const repo = this.dataSource.getRepository(Invoice);
|
||||
const invoices = await repo.findBy({
|
||||
paymentId,
|
||||
status: In([
|
||||
Freight.InvoiceStatus.Issued,
|
||||
Freight.InvoiceStatus.Pending,
|
||||
]),
|
||||
status: In([Freight.InvoiceStatus.Issued, Freight.InvoiceStatus.Pending]),
|
||||
});
|
||||
for (const invoice of invoices) {
|
||||
await repo.update(
|
||||
@@ -2069,7 +2159,10 @@ export class BillingService {
|
||||
// Same reference, for an ad-hoc additional charge — its own column, since
|
||||
// an AdditionalCharge doesn't own a Booking-scoped `pnrCode` and a booking
|
||||
// can carry many of these at once.
|
||||
if (billReference && invoice.source === Freight.InvoiceSource.AdditionalCharge) {
|
||||
if (
|
||||
billReference &&
|
||||
invoice.source === Freight.InvoiceSource.AdditionalCharge
|
||||
) {
|
||||
await this.dataSource
|
||||
.getRepository(AdditionalCharge)
|
||||
.update({ id: invoice.sourceId }, { paymentReference: billReference });
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { InvoiceDocumentModel, InvoiceDocumentService } from "./invoice-document.service";
|
||||
import { InvoiceDocumentModel, InvoiceDocumentService, sameCompanyName } from "./invoice-document.service";
|
||||
|
||||
const model = (over: Partial<InvoiceDocumentModel> = {}): InvoiceDocumentModel => ({
|
||||
kind: "INVOICE",
|
||||
@@ -87,3 +87,38 @@ describe("InvoiceDocumentService.buildThermalHtml", () => {
|
||||
expect(html).not.toContain("right: 160px");
|
||||
});
|
||||
});
|
||||
|
||||
describe("sameCompanyName", () => {
|
||||
it("treats eTrade's legal-suffix spellings as the same name", () => {
|
||||
expect(sameCompanyName("ABIJOEL PLC", "ABIJOEL P L C")).toBe(true);
|
||||
expect(
|
||||
sameCompanyName(
|
||||
"WISH TRADING PLC",
|
||||
"WISH TRADING PRIVATE LIMITED COMPANY",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
sameCompanyName("TUTA TRADING PLC", "TUTA TRADING ONE MEMBER PLC"),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps a genuinely different trade name distinct", () => {
|
||||
// Real pairs from eTrade: the licence trades under a different name than
|
||||
// the company registered under, which is exactly the row worth printing.
|
||||
expect(
|
||||
sameCompanyName("Cozy Coffee Grower and Exporter", "ABIJOEL P L C"),
|
||||
).toBe(false);
|
||||
expect(sameCompanyName("MENNA PRODUCTION", "ICOFFEE TRADING PLC")).toBe(
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
sameCompanyName("YUNABEK TRADING PLC", "YUNABEK INVESTMENT PLC"),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("is false when either side is missing, so no row is printed", () => {
|
||||
expect(sameCompanyName("", "ABIJOEL P L C")).toBe(false);
|
||||
expect(sameCompanyName(null, null)).toBe(false);
|
||||
expect(sameCompanyName("ABIJOEL P L C", undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -48,6 +48,34 @@ function formatDate(value: unknown): string {
|
||||
return value ? new Date(value as string | Date).toLocaleDateString("en-GB") : "-";
|
||||
}
|
||||
|
||||
/**
|
||||
* Is this trade name just the company name again?
|
||||
*
|
||||
* Compared loosely on purpose: eTrade spells the same legal suffix as "PLC",
|
||||
* "P L C" and "PRIVATE LIMITED COMPANY", and pads names with double spaces, so
|
||||
* an exact comparison would call two spellings of one name different and print
|
||||
* a redundant row. Used only to decide whether a trade-name row is worth
|
||||
* showing — never to decide that two businesses ARE the same.
|
||||
*/
|
||||
export function sameCompanyName(
|
||||
a: string | null | undefined,
|
||||
b: string | null | undefined,
|
||||
): boolean {
|
||||
const norm = (v: string | null | undefined) =>
|
||||
(v ?? "")
|
||||
.toUpperCase()
|
||||
.replace(/[.,]/g, "")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
.replace(/\bPRIVATE LIMITED COMPANY\b/g, "PLC")
|
||||
.replace(/\bP L C\b/g, "PLC")
|
||||
.replace(/\bONE (MEMBER|PERSON) PLC\b/g, "PLC")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
const left = norm(a);
|
||||
return left !== "" && left === norm(b);
|
||||
}
|
||||
|
||||
/** One billed line on the document (charge type / fee type agnostic). */
|
||||
export interface InvoiceDocumentLine {
|
||||
description: string | null;
|
||||
@@ -309,7 +337,11 @@ export class InvoiceDocumentService {
|
||||
let y = 700;
|
||||
const colX = [36, 300];
|
||||
const colW = 250;
|
||||
model.summary.slice(0, 16).forEach((row, i) => {
|
||||
// 20, not 16: a booking invoice already fills 16 rows with every optional one present
|
||||
// (buyer trade name, buyer VAT, seller TIN/VAT, IRN, PNR) and the transaction ref is the
|
||||
// 17th — the old cap silently dropped whichever row landed last. Still fits: 20 rows end
|
||||
// at y=423, leaving the line-item table its full run down to the y<190 cut-off.
|
||||
model.summary.slice(0, 20).forEach((row, i) => {
|
||||
const x = colX[i % 2];
|
||||
if (i % 2 === 0 && i > 0) y -= 27;
|
||||
ops.push(textOp((row.label ?? "").toUpperCase(), x, y, 7, "F1", PdfColor.gray));
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { settlementReferences } from "./invoice-settlement.util";
|
||||
|
||||
describe("settlementReferences", () => {
|
||||
it("returns the provider reference recorded on the invoice ledger", () => {
|
||||
expect(
|
||||
settlementReferences({
|
||||
payments: [{ reference: "FT26082700123" }],
|
||||
}),
|
||||
).toBe("FT26082700123");
|
||||
});
|
||||
|
||||
it("reads the linked gateway payment row when the ledger has no reference", () => {
|
||||
expect(
|
||||
settlementReferences({
|
||||
payments: [{ reference: null }],
|
||||
payment: { transactionId: "TB998877" },
|
||||
}),
|
||||
).toBe("TB998877");
|
||||
});
|
||||
|
||||
it("does not repeat a reference that both sources carry", () => {
|
||||
expect(
|
||||
settlementReferences({
|
||||
payments: [{ reference: "FT26082700123" }],
|
||||
payment: { transactionId: "FT26082700123" },
|
||||
}),
|
||||
).toBe("FT26082700123");
|
||||
});
|
||||
|
||||
it("lists every leg of a partially-then-fully paid invoice, oldest first", () => {
|
||||
expect(
|
||||
settlementReferences({
|
||||
payments: [{ reference: "SLIP-001" }, { reference: "FT26082700123" }],
|
||||
}),
|
||||
).toBe("SLIP-001, FT26082700123");
|
||||
});
|
||||
|
||||
it("drops the internal intent id the gateway path falls back to", () => {
|
||||
expect(
|
||||
settlementReferences({
|
||||
payments: [{ reference: "3f8a1c2e-9b4d-4a71-8c6e-2d5f7a9b1c30" }],
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("is null for an unpaid invoice", () => {
|
||||
expect(settlementReferences({ payments: [] })).toBeNull();
|
||||
expect(settlementReferences({})).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -74,3 +74,44 @@ export const INVOICE_PAYMENT_METHODS = [
|
||||
/** Settled at a gateway whose provider row is no longer linked. */
|
||||
"GATEWAY",
|
||||
] as const;
|
||||
|
||||
/** Anything shaped enough to read settlement references off. */
|
||||
interface SettlementReferenceSource {
|
||||
payments?: Array<{ reference?: string | null }> | null;
|
||||
payment?: { transactionId?: string | null } | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A settlement reference is the PROVIDER's own transaction number, never ours.
|
||||
* The gateway path falls back to the intent id when a provider returns no txn
|
||||
* ref (`markInvoiceAsPaid`: `providerTxnId ?? paymentId`), and that id is a
|
||||
* uuid — an internal correlation key that means nothing to a payer holding a
|
||||
* bank slip, so it is dropped rather than printed. No provider's reference is
|
||||
* uuid-shaped: CBE sends `FT…`, telebirr/ebirr/waafi send digit strings.
|
||||
*/
|
||||
const INTERNAL_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
/**
|
||||
* Every provider transaction reference recorded against an invoice, oldest
|
||||
* first, joined for display — CBE's `FT…`, telebirr's receipt number, or the
|
||||
* bank-slip number a teller typed into a manual settlement. Null when nothing
|
||||
* identifiable was recorded.
|
||||
*
|
||||
* Reads BOTH sources because neither alone is complete: the invoice's own
|
||||
* ledger is the only record of manual settlements and of each leg of a
|
||||
* partially-paid invoice, while the linked `freight.payments` row is the only
|
||||
* place a provider txn id lands when it arrives after settlement (a webhook
|
||||
* that stamps `transactionId` on an already-settled intent). Deduped, since
|
||||
* the ordinary gateway path writes the same value to both.
|
||||
*/
|
||||
export function settlementReferences(
|
||||
invoice: SettlementReferenceSource,
|
||||
): string | null {
|
||||
const refs = [
|
||||
...(invoice.payments ?? []).map((p) => p.reference),
|
||||
invoice.payment?.transactionId,
|
||||
].filter(
|
||||
(ref): ref is string => Boolean(ref) && !INTERNAL_ID.test(ref as string),
|
||||
);
|
||||
return [...new Set(refs)].join(", ") || null;
|
||||
}
|
||||
|
||||
@@ -40,3 +40,70 @@ describe('BookingWagonCancellationService.resolveRequestedCut (bulk)', () => {
|
||||
expect(cut.weightTons).toBeCloseTo(62.625, 3);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Odd-20ft credit rebook: the rebooked booking shares a wagon again, so GL
|
||||
* must pick the consolidation partner — no partner, no rebook; a partner
|
||||
* already paired elsewhere is refused.
|
||||
*/
|
||||
describe('BookingWagonCancellationService.rebook (odd-20ft consolidation)', () => {
|
||||
const units = Array.from({ length: 3 }, (_, i) => ({
|
||||
containerSize: '20ft',
|
||||
containerNumber: `CONT${i}`,
|
||||
sealNumber: null,
|
||||
vgmTons: 10,
|
||||
isHazardous: false,
|
||||
isReefer: false,
|
||||
}));
|
||||
const row = {
|
||||
id: 'wc1',
|
||||
bookingId: 'b1',
|
||||
status: 'CREDIT_AVAILABLE',
|
||||
creditAmount: 100,
|
||||
cancelledQuantities: { bySize: { '20ft': 3 }, units },
|
||||
};
|
||||
const source = {
|
||||
id: 'b1',
|
||||
contractId: 'c1',
|
||||
paymentCurrency: 'USD',
|
||||
originYardId: 'y1',
|
||||
destinationYardId: 'y2',
|
||||
tradeDirection: 'IMPORT',
|
||||
};
|
||||
|
||||
const makeSvc = (partner?: 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 () => partner ?? null,
|
||||
};
|
||||
return svc;
|
||||
};
|
||||
|
||||
it('refuses an odd-20ft rebook without a GL-picked partner', async () => {
|
||||
await expect(
|
||||
makeSvc().rebook('wc1', { scheduledDate: '2026-09-01' }),
|
||||
).rejects.toThrow(/pick a consolidation partner/i);
|
||||
});
|
||||
|
||||
it('refuses a partner that already shares a wagon', async () => {
|
||||
const paired = {
|
||||
id: 'p1',
|
||||
reference: 'BK-1',
|
||||
status: 'SUBMITTED',
|
||||
consolidationPartnerId: 'someone-else',
|
||||
};
|
||||
await expect(
|
||||
makeSvc(paired).rebook('wc1', {
|
||||
scheduledDate: '2026-09-01',
|
||||
partnerBookingId: 'p1',
|
||||
}),
|
||||
).rejects.toThrow(/already shares a wagon/i);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { OnEvent } from '@nestjs/event-emitter';
|
||||
import { EventEmitter2, OnEvent } from '@nestjs/event-emitter';
|
||||
import { ExchangeService } from '@edr/api-common';
|
||||
import { Freight, NotificationAudience, NotificationType } from '@edr/types';
|
||||
import { DataSource, EntityManager, In, IsNull } from 'typeorm';
|
||||
@@ -126,6 +126,7 @@ export class BookingWagonCancellationService {
|
||||
@Inject(forwardRef(() => FirstMileService))
|
||||
private readonly firstMile: FirstMileService,
|
||||
private readonly inbox: NotificationInboxService,
|
||||
private readonly events: EventEmitter2,
|
||||
) {}
|
||||
|
||||
// ── T1: request ────────────────────────────────────────────────────────────
|
||||
@@ -590,6 +591,24 @@ export class BookingWagonCancellationService {
|
||||
this.logger.error(
|
||||
`Consolidation-lapse cancellation failed for paid booking ${payload.paidBookingId}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
// A silent failure here leaves a PAID half-wagon booking boarding alone
|
||||
// (BK-2026-000201: no LIVE IMPORT 20ft CANCELLATION_FEE rate — the fee
|
||||
// pricing threw and the booking stayed PAID). Scream to staff so it is
|
||||
// fixed and the booking cancelled by hand instead of shipping.
|
||||
try {
|
||||
const failed = await this.bookingsRepository.findById(
|
||||
payload.paidBookingId,
|
||||
);
|
||||
if (failed) {
|
||||
this.notifyStaff(
|
||||
failed,
|
||||
'Consolidation-lapse cancellation FAILED — action needed',
|
||||
`${failed.reference}: its consolidation partner lapsed unpaid, but the automatic cancellation failed: ${err instanceof Error ? err.message : String(err)}. Fix the cause (usually a missing LIVE per-wagon CANCELLATION_FEE rate for this trade direction + container size), then cancel the whole booking manually so the fee is invoiced and its wagons are freed.`,
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// Notification is best-effort — the error log above already fired.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -782,6 +801,26 @@ export class BookingWagonCancellationService {
|
||||
const createDto = this.buildRebookDto(row, dto.scheduledDate, dto.containers);
|
||||
// Same currency as the source booking — the credit is in it.
|
||||
createDto.paymentCurrency = source.paymentCurrency ?? undefined;
|
||||
|
||||
// An odd-20ft credit shares a wagon again on rebook. GL picks who — never
|
||||
// the auto-matcher (it could claim a partner behind GL's back), so the
|
||||
// create below runs with auto-consolidation off and the chosen partner is
|
||||
// linked once the booking exists and is PAID.
|
||||
const oddFt20 = this.creditFt20(row) % 2 === 1;
|
||||
let partner: Booking | null = null;
|
||||
if (oddFt20) {
|
||||
createDto.skipAutoConsolidation = true;
|
||||
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).',
|
||||
);
|
||||
}
|
||||
partner = await this.loadRebookPartner(
|
||||
source,
|
||||
dto.partnerBookingId,
|
||||
dto.scheduledDate,
|
||||
);
|
||||
}
|
||||
const created = await this.contractBooking.createUnderContract(
|
||||
source.contractId,
|
||||
createDto,
|
||||
@@ -814,12 +853,19 @@ export class BookingWagonCancellationService {
|
||||
`First-mile accept failed for rebooked ${newBookingId}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
try {
|
||||
await this.bookingBatch.ensurePaidBookingAllocated(newBookingId);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Allocation failed for rebooked ${newBookingId}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
if (partner) {
|
||||
// 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.
|
||||
await this.pairRebookedBooking(newBookingId, partner);
|
||||
} else {
|
||||
try {
|
||||
await this.bookingBatch.ensurePaidBookingAllocated(newBookingId);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Allocation failed for rebooked ${newBookingId}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const updated = (await this.repo.update(row.id, {
|
||||
@@ -837,6 +883,142 @@ export class BookingWagonCancellationService {
|
||||
return { cancellation: updated, bookingId: newBookingId };
|
||||
}
|
||||
|
||||
/** Total 20ft units the credit carries (odd ⇒ the rebook shares a wagon again). */
|
||||
private creditFt20(row: BookingWagonCancellation): number {
|
||||
return Object.entries(row.cancelledQuantities?.bySize ?? {})
|
||||
.filter(([size]) => sizeFtOf(size) === 20)
|
||||
.reduce((sum, [, qty]) => sum + Number(qty || 0), 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Partner candidates for rebooking an odd-20ft credit — what the GL rebook
|
||||
* form lists. Empty when the credit is even (no shared wagon) or spent.
|
||||
*/
|
||||
async rebookPartnerCandidates(
|
||||
cancellationId: string,
|
||||
scheduledDate: string,
|
||||
): Promise<
|
||||
Array<{
|
||||
id: string;
|
||||
reference: string;
|
||||
companyName: string | null;
|
||||
status: string;
|
||||
scheduledDate: string | null;
|
||||
ft20Quantity: number;
|
||||
}>
|
||||
> {
|
||||
const row = await this.mustFind(cancellationId);
|
||||
if (row.status !== 'CREDIT_AVAILABLE') return [];
|
||||
if (this.creditFt20(row) % 2 === 0) return [];
|
||||
const source = await this.bookingsRepository.findById(row.bookingId);
|
||||
if (!source) return [];
|
||||
const rows = await this.bookingsRepository.findRebookConsolidationCandidates(
|
||||
source,
|
||||
new Date(scheduledDate),
|
||||
);
|
||||
return rows.map((b) => ({
|
||||
id: b.id,
|
||||
reference: b.reference,
|
||||
companyName: b.company?.name ?? null,
|
||||
status: b.status,
|
||||
scheduledDate: b.scheduledDate ? b.scheduledDate.toISOString() : null,
|
||||
ft20Quantity: (b.bookingContainers ?? [])
|
||||
.filter((line) => Number(line.containerType?.sizeFt) === 20)
|
||||
.reduce((sum, line) => sum + Number(line.quantity || 0), 0),
|
||||
}));
|
||||
}
|
||||
|
||||
/** The GL-picked partner, validated to actually fit the rebooked shared wagon. */
|
||||
private async loadRebookPartner(
|
||||
source: Booking,
|
||||
partnerId: string,
|
||||
scheduledDate: string,
|
||||
): Promise<Booking> {
|
||||
const partner = await this.bookingsRepository.findByIdWithFiles(partnerId);
|
||||
if (!partner) {
|
||||
throw new NotFoundException(`Partner booking ${partnerId} not found.`);
|
||||
}
|
||||
if (partner.consolidationPartnerId) {
|
||||
throw new ConflictException(
|
||||
`Booking ${partner.reference} already shares a wagon with another booking.`,
|
||||
);
|
||||
}
|
||||
if (!['SUBMITTED', 'PENDING_CONSOLIDATION'].includes(partner.status)) {
|
||||
throw new BadRequestException(
|
||||
`Booking ${partner.reference} cannot be consolidated (status ${partner.status}).`,
|
||||
);
|
||||
}
|
||||
if (
|
||||
partner.originYardId !== source.originYardId ||
|
||||
partner.destinationYardId !== source.destinationYardId ||
|
||||
partner.tradeDirection !== source.tradeDirection
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
`Booking ${partner.reference} rides a different route/direction — it cannot share a wagon with this rebooking.`,
|
||||
);
|
||||
}
|
||||
const eatDay = (d: Date | string) =>
|
||||
new Date(d).toLocaleDateString('en-CA', { timeZone: 'Africa/Addis_Ababa' });
|
||||
if (!partner.scheduledDate || eatDay(partner.scheduledDate) !== eatDay(scheduledDate)) {
|
||||
throw new BadRequestException(
|
||||
`Booking ${partner.reference} is not booked for ${eatDay(scheduledDate)} — a shared wagon must board one train.`,
|
||||
);
|
||||
}
|
||||
const ft20 = (partner.bookingContainers ?? [])
|
||||
.filter((line) => Number(line.containerType?.sizeFt) === 20)
|
||||
.reduce((sum, line) => sum + Number(line.quantity || 0), 0);
|
||||
if (ft20 % 2 !== 1) {
|
||||
throw new BadRequestException(
|
||||
`Booking ${partner.reference} has no odd 20ft container — nothing to consolidate.`,
|
||||
);
|
||||
}
|
||||
return partner;
|
||||
}
|
||||
|
||||
/**
|
||||
* Link the rebooked (already PAID) booking with the GL-picked partner. A
|
||||
* parked partner is resumed the way pairConsolidation would resume it —
|
||||
* but only the partner: the rebooked side's PAID status must survive, so
|
||||
* the link is written directly. The paired event then runs the partner's
|
||||
* deferred contract finalize (invoice → pay window); the shared wagon
|
||||
* boards once that invoice is paid.
|
||||
*/
|
||||
private async pairRebookedBooking(
|
||||
newBookingId: string,
|
||||
partner: Booking,
|
||||
): Promise<void> {
|
||||
// ponytail: validate-then-link without a row lock — a concurrent claim in
|
||||
// this window loses silently; move to pairConsolidationIfUnpaired-style
|
||||
// locking if it ever bites.
|
||||
const fresh = await this.dataSource.getRepository(Booking).findOne({
|
||||
where: { id: partner.id },
|
||||
select: { id: true, consolidationPartnerId: true, status: true },
|
||||
});
|
||||
if (!fresh || fresh.consolidationPartnerId) {
|
||||
throw new ConflictException(
|
||||
`Booking ${partner.reference} was claimed by another consolidation while rebooking — pick another partner.`,
|
||||
);
|
||||
}
|
||||
if (fresh.status === 'PENDING_CONSOLIDATION') {
|
||||
await this.dataSource.getRepository(Booking).update(partner.id, {
|
||||
status: partner.consolidationResumeStatus ?? 'SUBMITTED',
|
||||
consolidationResumeStatus: null,
|
||||
});
|
||||
}
|
||||
await this.bookingsRepository.linkConsolidationPartners(
|
||||
newBookingId,
|
||||
partner.id,
|
||||
);
|
||||
this.events.emit('booking.consolidation.paired', {
|
||||
bookingIds: [partner.id],
|
||||
});
|
||||
this.notifyCustomer(
|
||||
partner,
|
||||
'Consolidation partner found',
|
||||
`${partner.reference} now shares a wagon with a rebooked shipment. Pay your booking to board — the shared wagon ships once both halves are paid.`,
|
||||
);
|
||||
}
|
||||
|
||||
// ── History ────────────────────────────────────────────────────────────────
|
||||
|
||||
list(filter: WagonCancellationListFilter) {
|
||||
|
||||
@@ -725,6 +725,30 @@ export class BookingsController {
|
||||
return this.wagonCancellationService.withdraw(cancellationId);
|
||||
}
|
||||
|
||||
@Get("wagon-cancellations/:cancellationId/rebook-partners")
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Consolidation partner candidates for rebooking an odd-20ft credit on the given day (GL picks who shares the rebooked wagon)",
|
||||
})
|
||||
async listRebookPartners(
|
||||
@Param("cancellationId", ParseUUIDPipe) cancellationId: string,
|
||||
@Query("scheduledDate") scheduledDate: string,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
await this.assertWagonCancellationActor(
|
||||
cancellationId,
|
||||
user,
|
||||
FREIGHT_PERMS.bookings.wagonCancellationRebook,
|
||||
);
|
||||
if (!scheduledDate) {
|
||||
throw new BadRequestException("scheduledDate is required.");
|
||||
}
|
||||
return this.wagonCancellationService.rebookPartnerCandidates(
|
||||
cancellationId,
|
||||
scheduledDate,
|
||||
);
|
||||
}
|
||||
|
||||
@Post("wagon-cancellations/:cancellationId/rebook")
|
||||
@ApiOperation({
|
||||
summary:
|
||||
|
||||
@@ -377,6 +377,58 @@ 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.
|
||||
* Unlike {@link findManualConsolidationCandidates} this is not customs-only:
|
||||
* GL picks who shares the rebooked wagon whatever the contract kind.
|
||||
*/
|
||||
async findRebookConsolidationCandidates(
|
||||
booking: Booking,
|
||||
scheduledDate: Date,
|
||||
limit = 50,
|
||||
): Promise<Booking[]> {
|
||||
const rows = await this.repository
|
||||
.createQueryBuilder('b')
|
||||
.leftJoinAndSelect('b.bookingContainers', 'bc')
|
||||
.leftJoinAndSelect('bc.containerType', 'ct')
|
||||
.leftJoinAndSelect('b.company', 'company')
|
||||
.where('b.id != :bookingId', { bookingId: booking.id })
|
||||
.andWhere('b.consolidationPartnerId IS NULL')
|
||||
.andWhere('b.originYardId = :originYardId', {
|
||||
originYardId: booking.originYardId,
|
||||
})
|
||||
.andWhere('b.destinationYardId = :destinationYardId', {
|
||||
destinationYardId: booking.destinationYardId,
|
||||
})
|
||||
.andWhere('b.tradeDirection = :tradeDirection', {
|
||||
tradeDirection: booking.tradeDirection,
|
||||
})
|
||||
.andWhere('b.status IN (:...statuses)', {
|
||||
statuses: ['SUBMITTED', 'PENDING_CONSOLIDATION'],
|
||||
})
|
||||
// Same EAT booking day as the rebook — the pair shares one physical
|
||||
// wagon, so it must board one train.
|
||||
.andWhere(
|
||||
`DATE(b.scheduled_date AT TIME ZONE 'Africa/Addis_Ababa') = DATE(:bookingDate AT TIME ZONE 'Africa/Addis_Ababa')`,
|
||||
{ bookingDate: scheduledDate },
|
||||
)
|
||||
.orderBy('b.createdAt', 'ASC')
|
||||
.take(limit)
|
||||
.getMany();
|
||||
|
||||
// Odd-20ft test in memory (two 20ft per wagon: odd + odd = whole wagons).
|
||||
return rows.filter((row) => {
|
||||
const lines = row.bookingContainers ?? [];
|
||||
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);
|
||||
return ft20 % 2 === 1;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find another booking whose container quantity complements this one to fill whole wagon(s)
|
||||
* (same route, same container type, partial wagon on both sides). Only 20ft lines ever
|
||||
|
||||
@@ -121,6 +121,15 @@ export class RebookCancelledWagonsDto {
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => RebookContainerLineDto)
|
||||
containers?: RebookContainerLineDto[];
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'Required when the credit carries an odd 20ft count: the odd-20ft booking ' +
|
||||
'GL picked to share the rebooked wagon (see the rebook-partners endpoint).',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
partnerBookingId?: string;
|
||||
}
|
||||
|
||||
export class FilterWagonCancellationsDto {
|
||||
|
||||
@@ -359,6 +359,12 @@ export class Booking extends BaseEntity {
|
||||
@Column({ name: 'customs_clearing_agent', type: 'varchar', length: 200, nullable: true })
|
||||
customsClearingAgent?: string | null;
|
||||
|
||||
@Column({ name: 'customs_clearing_agent_email', type: 'varchar', length: 200, nullable: true })
|
||||
customsClearingAgentEmail?: string | null;
|
||||
|
||||
@Column({ name: 'customs_clearing_agent_phone', type: 'varchar', length: 50, nullable: true })
|
||||
customsClearingAgentPhone?: string | null;
|
||||
|
||||
@Column({ name: 'equipment_return', type: 'varchar', length: 20 })
|
||||
equipmentReturn!: string;
|
||||
|
||||
@@ -411,6 +417,23 @@ export class Booking extends BaseEntity {
|
||||
@Column({ name: 'bulk_total_weight_tons', type: 'numeric', precision: 12, scale: 3, nullable: true })
|
||||
bulkTotalWeightTons?: number | null;
|
||||
|
||||
/**
|
||||
* NUMBER_OF_WAGONS bulk only: the wagon count the customer asked for at
|
||||
* booking. Allocation and PER_WAGON pricing use this count verbatim, and the
|
||||
* cargo weight spreads evenly across it (weight ÷ count per wagon — validated
|
||||
* against wagon capacity at creation). Null for every other cargo unit.
|
||||
*/
|
||||
@Column({ name: 'bulk_requested_wagons', type: 'int', nullable: true })
|
||||
bulkRequestedWagons?: number | null;
|
||||
|
||||
/**
|
||||
* NUMBER_OF_WAGONS bulk only: optional informational item count entered with
|
||||
* the weight. Never prices or sizes anything (unlike PER_ITEM, where the
|
||||
* count lives in cargoTotalWeightVgm).
|
||||
*/
|
||||
@Column({ name: 'bulk_item_count', type: 'int', nullable: true })
|
||||
bulkItemCount?: number | null;
|
||||
|
||||
@Column({ name: 'is_hazardous', type: 'boolean', default: false })
|
||||
isHazardous!: boolean;
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Controller, Get, Post, UseGuards } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { CurrentUser } from '@tria-plc/api-common/modules/auth/decorators/current-user.decorator';
|
||||
import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard';
|
||||
import { FreightJwtGuard } from '../../common/freight-jwt.guard';
|
||||
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
||||
|
||||
import { ChatSync } from '../../common/booking-guards';
|
||||
@@ -18,7 +18,7 @@ export class ChatController {
|
||||
) {}
|
||||
|
||||
@Get('sso')
|
||||
@UseGuards(JwtGuard)
|
||||
@UseGuards(FreightJwtGuard)
|
||||
@ApiOperation({ summary: 'One-click sign-in link into EDR internal chat' })
|
||||
getSso(@CurrentUser() user: TCurrentUser) {
|
||||
return this.sso.getSsoUrl(user);
|
||||
|
||||
@@ -32,7 +32,9 @@ import { CreateCompanyDto } from "./dto/create-company.dto";
|
||||
import { UpdateCompanyDto } from "./dto/update-company.dto";
|
||||
import { CreateExternalProfileDto } from "./dto/create-external-profile.dto";
|
||||
import { CreateCompanyWithProfileDto } from "./dto/create-company-with-profile.dto";
|
||||
import type { ETradeBusinessOption } from "@edr/types";
|
||||
import { AddCompanyProfilesDto } from "./dto/add-company-profiles.dto";
|
||||
import { AttachEtradeBusinessDto } from "./dto/attach-etrade-business.dto";
|
||||
import { CreateCompanyProfileDto } from "./dto/create-company-profile.dto";
|
||||
import {
|
||||
CompanyIdentityStateDto,
|
||||
@@ -260,11 +262,42 @@ export class CompaniesController {
|
||||
): Promise<ResponseCompanyProfileDto[]> {
|
||||
const profiles = await this.companiesService.addCompanyProfilesForUser(
|
||||
user.id,
|
||||
dto.types,
|
||||
dto.profiles,
|
||||
);
|
||||
return profiles.map((p) => new ResponseCompanyProfileDto(p));
|
||||
}
|
||||
|
||||
@Get("etrade-businesses")
|
||||
@PortalCustomer()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"The eTrade business licences under this company's TIN, for attaching to its operational profiles",
|
||||
})
|
||||
async listEtradeBusinesses(
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
): Promise<ETradeBusinessOption[]> {
|
||||
return this.companiesService.listEtradeBusinessesForUser(user.id);
|
||||
}
|
||||
|
||||
@Patch("company-profiles/:profileId/etrade-business")
|
||||
@PortalCustomer()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Attach one of the TIN's eTrade businesses to an operational profile (re-attaching refreshes the stored snapshot)",
|
||||
})
|
||||
async attachEtradeBusiness(
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
@Param("profileId") profileId: string,
|
||||
@Body() dto: AttachEtradeBusinessDto,
|
||||
): Promise<ResponseCompanyProfileDto> {
|
||||
const profile = await this.companiesService.attachEtradeBusinessToProfile(
|
||||
user.id,
|
||||
profileId,
|
||||
dto.licenceNumber,
|
||||
);
|
||||
return new ResponseCompanyProfileDto(profile);
|
||||
}
|
||||
|
||||
@Post("onboarding/start")
|
||||
@PortalCustomer()
|
||||
@ApiOperation({
|
||||
@@ -321,6 +354,7 @@ export class CompaniesController {
|
||||
user.id,
|
||||
dto.type,
|
||||
dto.businessLicense,
|
||||
dto.licenceNumber,
|
||||
);
|
||||
return new ResponseCompanyProfileDto(profile);
|
||||
}
|
||||
|
||||
@@ -162,7 +162,16 @@ function makeService(overrides: Partial<Ctx> = {}) {
|
||||
{} as never,
|
||||
deps.filesService as never,
|
||||
deps.fileUploadSettings as never,
|
||||
{} as never,
|
||||
// Only the business-licence lookup is exercised here: adding a role now
|
||||
// resolves which eTrade business it operates as.
|
||||
{
|
||||
findBusinessOption: async (_tin: string, licenceNumber: string) => ({
|
||||
licenceNumber,
|
||||
tradeName: "Test Trade Name",
|
||||
activity: "Freight Forwarders",
|
||||
renewedTo: "7/7/2026",
|
||||
}),
|
||||
} as never,
|
||||
deps.companyNotifier as never,
|
||||
{} as never,
|
||||
deps.verifayda as never,
|
||||
@@ -502,7 +511,9 @@ describe("the owner is checked against the eTrade licence", () => {
|
||||
|
||||
describe("the freight-forwarder gate", () => {
|
||||
const addForwarder = (service: CompaniesService) =>
|
||||
service.addCompanyProfilesForUser("user-1", [ProfileType.freightForwarder]);
|
||||
service.addCompanyProfilesForUser("user-1", [
|
||||
{ type: ProfileType.freightForwarder, licenceNumber: "LIC-1" },
|
||||
]);
|
||||
|
||||
it("blocks the role while the representative is unverified", async () => {
|
||||
const { service } = makeService({ attributes: { poaDeclared: "yes" } });
|
||||
|
||||
@@ -133,7 +133,16 @@ function makeService(overrides: Partial<Ctx> = {}) {
|
||||
{} as never,
|
||||
deps.filesService as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
// Only the business-licence lookup is exercised here: adding a role now
|
||||
// resolves which eTrade business it operates as.
|
||||
{
|
||||
findBusinessOption: async (_tin: string, licenceNumber: string) => ({
|
||||
licenceNumber,
|
||||
tradeName: "Test Trade Name",
|
||||
activity: "Freight Forwarders",
|
||||
renewedTo: "7/7/2026",
|
||||
}),
|
||||
} as never,
|
||||
deps.companyNotifier as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
@@ -200,6 +209,8 @@ describe("PoA delegation paper is enforced wherever PoA state changes", () => {
|
||||
service.createCompanyProfileForUser(
|
||||
"user-1",
|
||||
ProfileType.freightForwarder,
|
||||
undefined,
|
||||
"LIC-1",
|
||||
),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
@@ -263,6 +274,8 @@ describe("PoA delegation paper is enforced wherever PoA state changes", () => {
|
||||
service.createCompanyProfileForUser(
|
||||
"user-1",
|
||||
ProfileType.freightForwarder,
|
||||
undefined,
|
||||
"LIC-1",
|
||||
),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
@@ -277,6 +290,8 @@ describe("PoA delegation paper is enforced wherever PoA state changes", () => {
|
||||
service.createCompanyProfileForUser(
|
||||
"user-1",
|
||||
ProfileType.freightForwarder,
|
||||
undefined,
|
||||
"LIC-1",
|
||||
),
|
||||
).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import { BadRequestException, NotFoundException } from "@nestjs/common";
|
||||
import { CompaniesService } from "./companies.service";
|
||||
import { ProfileType } from "./entities/company-profile.entity";
|
||||
import { COOPERATIVE_KEY } from "./entities/company.entity";
|
||||
|
||||
/**
|
||||
* A TIN holds many business licences; each operational profile names the one it
|
||||
* trades as. What matters here is that the stored business is always eTrade's
|
||||
* own record, looked up under the company's own TIN — never the client's word
|
||||
* for it — and that the requirement lifts for a company eTrade knows nothing
|
||||
* about.
|
||||
*/
|
||||
const BUSINESSES = [
|
||||
{
|
||||
licenceNumber: "MT/AA/14/670/128936/2007",
|
||||
tradeName: "Pave Freight Forwarding",
|
||||
activity: "Freight Forwarders",
|
||||
renewedTo: "7/7/2026",
|
||||
},
|
||||
{
|
||||
licenceNumber: "MT/AA/14/670/11551235/2017",
|
||||
tradeName: "Pave Minerals Export",
|
||||
activity: "Export trade in minerals",
|
||||
renewedTo: "7/7/2026",
|
||||
},
|
||||
];
|
||||
|
||||
function makeService(attributes: Record<string, unknown> = {}) {
|
||||
const company = {
|
||||
id: "company-1",
|
||||
tin: "0045014036",
|
||||
type: "customer",
|
||||
attributes,
|
||||
companyProfiles: [{ id: "profile-1", type: ProfileType.exporter }],
|
||||
};
|
||||
|
||||
const created: Record<string, unknown>[] = [];
|
||||
const companyProfilesRepo = {
|
||||
findByCompanyId: jest.fn(async () => created),
|
||||
findByType: jest.fn(async () => null),
|
||||
create: jest.fn(async (row: Record<string, unknown>) => {
|
||||
created.push({ id: `profile-${created.length + 2}`, ...row });
|
||||
return created[created.length - 1];
|
||||
}),
|
||||
update: jest.fn(async (id: string, data: Record<string, unknown>) => ({
|
||||
id,
|
||||
...data,
|
||||
})),
|
||||
};
|
||||
|
||||
const etradeService = {
|
||||
listBusinessOptions: jest.fn(async () => BUSINESSES),
|
||||
findBusinessOption: jest.fn(async (_tin: string, licenceNumber: string) => {
|
||||
const match = BUSINESSES.find((b) => b.licenceNumber === licenceNumber);
|
||||
if (!match) throw new BadRequestException("no such licence");
|
||||
return match;
|
||||
}),
|
||||
};
|
||||
|
||||
const service = new CompaniesService(
|
||||
{} as never,
|
||||
companyProfilesRepo as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{ findByUserId: jest.fn(async () => ({ id: "ext-1", companyId: "company-1" })) } as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
etradeService as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
);
|
||||
|
||||
jest
|
||||
.spyOn(service, "getCompanyInfoByUserId")
|
||||
.mockImplementation(async () => ({ profile: {}, company }) as never);
|
||||
// Private, but every add path goes through it; stubbing it keeps this spec on
|
||||
// the business-attachment logic instead of the whole company lookup graph.
|
||||
(service as unknown as Record<string, unknown>).findCompanyById = async () =>
|
||||
company;
|
||||
|
||||
return { service, companyProfilesRepo, etradeService };
|
||||
}
|
||||
|
||||
describe("attaching an eTrade business to a company profile", () => {
|
||||
it("stores eTrade's own record for the chosen licence, not the client's", async () => {
|
||||
const { service, companyProfilesRepo } = makeService();
|
||||
const updated = await service.attachEtradeBusinessToProfile(
|
||||
"user-1",
|
||||
"profile-1",
|
||||
"MT/AA/14/670/128936/2007",
|
||||
);
|
||||
expect(companyProfilesRepo.update).toHaveBeenCalledWith("profile-1", {
|
||||
etradeBusiness: BUSINESSES[0],
|
||||
});
|
||||
expect(updated.etradeBusiness).toEqual(BUSINESSES[0]);
|
||||
});
|
||||
|
||||
it("refuses a licence eTrade does not list under this TIN", async () => {
|
||||
const { service } = makeService();
|
||||
await expect(
|
||||
service.attachEtradeBusinessToProfile("user-1", "profile-1", "SOMEONE/ELSES/LICENCE"),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it("refuses a profile belonging to another company", async () => {
|
||||
const { service } = makeService();
|
||||
await expect(
|
||||
service.attachEtradeBusinessToProfile("user-1", "not-mine", BUSINESSES[0].licenceNumber),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
it("the same business may back more than one profile", async () => {
|
||||
const { service, etradeService } = makeService();
|
||||
await service.addCompanyProfilesForUser("user-1", [
|
||||
{ type: ProfileType.exporter, licenceNumber: BUSINESSES[0].licenceNumber },
|
||||
{ type: ProfileType.importer, licenceNumber: BUSINESSES[0].licenceNumber },
|
||||
]);
|
||||
expect(etradeService.findBusinessOption).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("choosing a business is required when the company has one to choose", () => {
|
||||
it("rejects a role added without a licence", async () => {
|
||||
const { service } = makeService();
|
||||
await expect(
|
||||
service.addCompanyProfilesForUser("user-1", [{ type: ProfileType.exporter }]),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it("lifts the requirement for a co-operative, which has no eTrade record", async () => {
|
||||
const { service, companyProfilesRepo, etradeService } = makeService({
|
||||
[COOPERATIVE_KEY]: true,
|
||||
});
|
||||
await service.addCompanyProfilesForUser("user-1", [
|
||||
{ type: ProfileType.exporter },
|
||||
]);
|
||||
expect(etradeService.findBusinessOption).not.toHaveBeenCalled();
|
||||
expect(companyProfilesRepo.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ etradeBusiness: null }),
|
||||
);
|
||||
});
|
||||
|
||||
it("offers a co-operative no businesses to pick from", async () => {
|
||||
const { service } = makeService({ [COOPERATIVE_KEY]: true });
|
||||
await expect(service.listEtradeBusinessesForUser("user-1")).resolves.toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -84,6 +84,7 @@ export class CompaniesRepository extends BaseRepository<Company> {
|
||||
createdTo,
|
||||
onboardingCompleted,
|
||||
hasPendingChangeRequest,
|
||||
profileType,
|
||||
sortBy = 'review',
|
||||
sortOrder = 'DESC',
|
||||
} = query;
|
||||
@@ -138,20 +139,46 @@ export class CompaniesRepository extends BaseRepository<Company> {
|
||||
|
||||
if (search) {
|
||||
const term = `%${search.trim()}%`;
|
||||
// Staff search by whatever is in front of them: the company name, the
|
||||
// TIN/email, a profile reference off a document — and, since a TIN holds
|
||||
// many licences, the trade name or licence number of the specific
|
||||
// business a role operates as. All the per-profile terms share one EXISTS
|
||||
// so a match on any of them qualifies the company once.
|
||||
qb.andWhere(
|
||||
`(company.name ILIKE :term
|
||||
OR company.tin ILIKE :term
|
||||
OR company.email ILIKE :term
|
||||
OR company.licence_number ILIKE :term
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM freight.company_profiles cp
|
||||
WHERE cp.company_id = company.id
|
||||
AND cp.reference ILIKE :term
|
||||
AND cp.deleted_at IS NULL
|
||||
AND (
|
||||
cp.reference ILIKE :term
|
||||
OR cp.etrade_business->>'tradeName' ILIKE :term
|
||||
OR cp.etrade_business->>'licenceNumber' ILIKE :term
|
||||
)
|
||||
))`,
|
||||
{ term },
|
||||
);
|
||||
}
|
||||
|
||||
// Companies holding a given operational role. EXISTS rather than a filter
|
||||
// on the joined `companyProfiles` alias: constraining the join would drop
|
||||
// the company's OTHER profiles from the loaded entity, so the list would
|
||||
// render an exporter-and-importer as importer-only.
|
||||
if (profileType) {
|
||||
qb.andWhere(
|
||||
`EXISTS (
|
||||
SELECT 1 FROM freight.company_profiles cp_type
|
||||
WHERE cp_type.company_id = company.id
|
||||
AND cp_type.deleted_at IS NULL
|
||||
AND cp_type.type = :profileType
|
||||
)`,
|
||||
{ profileType },
|
||||
);
|
||||
}
|
||||
|
||||
// sortBy is whitelisted by @IsIn on the DTO, so it is safe to interpolate.
|
||||
if (sortBy === 'review') {
|
||||
// Queue ordering: actionable tiers first, newest first within each. The
|
||||
|
||||
@@ -47,7 +47,7 @@ import { ETradeService } from "./services/etrade.service";
|
||||
import { CompanyNotifierService } from "./company-notifier.service";
|
||||
import { OnboardingRequirementsResponseDto } from "./dto/onboarding-requirements-response.dto";
|
||||
import { normalizeE164 } from "../../common/validators/is-phone-number.validator";
|
||||
import type { CompanyRegistrationData } from "@edr/types";
|
||||
import type { CompanyRegistrationData, ETradeBusinessOption } from "@edr/types";
|
||||
import { CreateCompanyDto } from "./dto/create-company.dto";
|
||||
import { UpdateCompanyDto } from "./dto/update-company.dto";
|
||||
import { CreateExternalProfileDto } from "./dto/create-external-profile.dto";
|
||||
@@ -343,6 +343,11 @@ export class CompaniesService {
|
||||
companyId: company.id,
|
||||
type: input.type,
|
||||
businessLicense: input.businessLicense ?? null,
|
||||
etradeBusiness: await this.resolveProfileBusiness(
|
||||
company,
|
||||
input.licenceNumber,
|
||||
input.type,
|
||||
),
|
||||
status: ProfileStatus.Pending,
|
||||
});
|
||||
}
|
||||
@@ -2149,8 +2154,9 @@ export class CompaniesService {
|
||||
*/
|
||||
async addCompanyProfilesForUser(
|
||||
userId: string,
|
||||
types: ProfileType[],
|
||||
inputs: Array<{ type: ProfileType; licenceNumber?: string }>,
|
||||
): Promise<CompanyProfile[]> {
|
||||
const types = inputs.map((i) => i.type);
|
||||
const profile = await this.profilesRepo.findByUserId(userId);
|
||||
if (!profile)
|
||||
throw new NotFoundException(`Profile for user ${userId} not found`);
|
||||
@@ -2185,11 +2191,21 @@ export class CompaniesService {
|
||||
);
|
||||
}
|
||||
|
||||
// Which eTrade business this role operates as. Resolved (and rejected if
|
||||
// absent) BEFORE the row is created, so a role never lands unattached on
|
||||
// a company that has licences to pick from.
|
||||
const etradeBusiness = await this.resolveProfileBusiness(
|
||||
company,
|
||||
inputs.find((i) => i.type === type)?.licenceNumber,
|
||||
type,
|
||||
);
|
||||
|
||||
// Self-service role adds start Pending and carry no reference — a reference
|
||||
// is minted only when a backoffice reviewer approves the role.
|
||||
await this.companyProfilesRepo.create({
|
||||
companyId,
|
||||
type,
|
||||
etradeBusiness,
|
||||
status: ProfileStatus.Pending,
|
||||
});
|
||||
}
|
||||
@@ -2207,6 +2223,7 @@ export class CompaniesService {
|
||||
userId: string,
|
||||
type: ProfileType,
|
||||
businessLicense?: string,
|
||||
licenceNumber?: string,
|
||||
): Promise<CompanyProfile> {
|
||||
const profile = await this.profilesRepo.findByUserId(userId);
|
||||
if (!profile)
|
||||
@@ -2232,12 +2249,18 @@ export class CompaniesService {
|
||||
);
|
||||
}
|
||||
if (!created) {
|
||||
const etradeBusiness = await this.resolveProfileBusiness(
|
||||
company,
|
||||
licenceNumber,
|
||||
type,
|
||||
);
|
||||
// New self-service roles start Pending (awaiting backoffice approval) and
|
||||
// carry no reference until approved.
|
||||
created = await this.companyProfilesRepo.create({
|
||||
companyId,
|
||||
type,
|
||||
businessLicense: businessLicense ?? null,
|
||||
etradeBusiness,
|
||||
status: ProfileStatus.Pending,
|
||||
});
|
||||
}
|
||||
@@ -2320,6 +2343,7 @@ export class CompaniesService {
|
||||
type: p.type,
|
||||
reference: p.reference ?? "",
|
||||
uploaded: records.some((r) => r.code === LICENSE_CODE),
|
||||
etradeBusiness: p.etradeBusiness ?? null,
|
||||
};
|
||||
}),
|
||||
);
|
||||
@@ -2331,6 +2355,19 @@ export class CompaniesService {
|
||||
? []
|
||||
: licenseProfiles.filter((p) => !p.uploaded);
|
||||
|
||||
// Which eTrade business each role operates as. Enforced here rather than at
|
||||
// role creation because the wizard picks roles on its FIRST step, before a
|
||||
// TIN has been entered — there is nothing to pick from yet. The customer
|
||||
// attaches one on the documents step, alongside that role's licence file,
|
||||
// and onboarding cannot be submitted until every role has one.
|
||||
//
|
||||
// Lifted for a company with no eTrade record at all: a co-operative or a
|
||||
// foreign investor has no licence list, so the requirement would be
|
||||
// unsatisfiable (see `usesManualRegistration`).
|
||||
const missingBusinesses = usesManualRegistration(company)
|
||||
? []
|
||||
: licenseProfiles.filter((p) => !p.etradeBusiness);
|
||||
|
||||
// 4. Power of Attorney. Whether there is one at all is the company's own
|
||||
// declaration — the question the wizard asks outright — and that answer is
|
||||
// what decides whose identity gets verified, so an unanswered one is itself
|
||||
@@ -2378,6 +2415,10 @@ export class CompaniesService {
|
||||
(p) =>
|
||||
`Upload a business license for your ${p.type.replace(/_/g, " ")} profile`,
|
||||
),
|
||||
...missingBusinesses.map(
|
||||
(p) =>
|
||||
`Choose which eTrade business your ${p.type.replace(/_/g, " ")} profile operates as`,
|
||||
),
|
||||
...missingPoaFields.map((f) => `Add your ${f.label.toLowerCase()}`),
|
||||
...(missingDelegation
|
||||
? [`Upload the ${POA_DELEGATION_LABEL} for your Power of Attorney`]
|
||||
@@ -2416,6 +2457,8 @@ export class CompaniesService {
|
||||
requiredInfo.length +
|
||||
requiredDocCount +
|
||||
(cooperative ? 0 : licenseProfiles.length) +
|
||||
// One "which business?" item per role, on the same terms as the licences.
|
||||
(usesManualRegistration(company) ? 0 : licenseProfiles.length) +
|
||||
poaItemCount +
|
||||
// The declaration and the verification it selects.
|
||||
2;
|
||||
@@ -2424,6 +2467,7 @@ export class CompaniesService {
|
||||
(missingInfo.length +
|
||||
missingDocs.length +
|
||||
missingLicenses.length +
|
||||
missingBusinesses.length +
|
||||
missingPoaFields.length +
|
||||
(missingDelegation || flaggedDelegation ? 1 : 0) +
|
||||
missingIdentityCount);
|
||||
@@ -3747,6 +3791,86 @@ export class CompaniesService {
|
||||
return match?.id ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the eTrade business a new/updated profile is being attached to.
|
||||
*
|
||||
* The client sends a licence number; what gets stored is eTrade's own record
|
||||
* of it, looked up under THIS company's TIN. That is the whole check — a
|
||||
* licence belonging to someone else's TIN simply is not in the list, so a
|
||||
* client cannot attach a profile to a business the company does not hold.
|
||||
*
|
||||
* Returns null (rather than throwing) for a company that registered without
|
||||
* eTrade: a co-operative union or farm holds no business licence, and a
|
||||
* foreign investor's licence is the Investment Commission's, not the trade
|
||||
* registry's. There is no list for them to pick from, so the role is theirs
|
||||
* to hold unattached — the reviewer checks their uploaded documents instead.
|
||||
*/
|
||||
private async resolveProfileBusiness(
|
||||
company: Company,
|
||||
licenceNumber: string | undefined,
|
||||
type: ProfileType,
|
||||
): Promise<ETradeBusinessOption | null> {
|
||||
if (usesManualRegistration(company)) return null;
|
||||
if (!licenceNumber) {
|
||||
throw new BadRequestException(
|
||||
`Choose which of your eTrade business licences the ${type.replace(/_/g, " ")} profile operates as.`,
|
||||
);
|
||||
}
|
||||
return this.etradeService.findBusinessOption(company.tin, licenceNumber);
|
||||
}
|
||||
|
||||
/**
|
||||
* The eTrade business licences the current user's company can attach to its
|
||||
* operational profiles. Empty for a company that registered without eTrade.
|
||||
*/
|
||||
async listEtradeBusinessesForUser(
|
||||
userId: string,
|
||||
): Promise<ETradeBusinessOption[]> {
|
||||
const { company } = await this.getCompanyInfoByUserId(userId);
|
||||
if (usesManualRegistration(company)) return [];
|
||||
return this.etradeService.listBusinessOptions(company.tin);
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach (or re-attach) one of the TIN's eTrade businesses to a profile.
|
||||
*
|
||||
* Separate from role creation because the onboarding wizard picks roles
|
||||
* before the TIN is known — the business is chosen later, on the step that
|
||||
* already collects each role's licence document. Re-attaching also refreshes
|
||||
* the stored snapshot, which is how a renewed licence's new expiry lands.
|
||||
*/
|
||||
async attachEtradeBusinessToProfile(
|
||||
userId: string,
|
||||
profileId: string,
|
||||
licenceNumber: string,
|
||||
): Promise<CompanyProfile> {
|
||||
const { company } = await this.getCompanyInfoByUserId(userId);
|
||||
const profile = (company.companyProfiles ?? []).find(
|
||||
(p) => p.id === profileId,
|
||||
);
|
||||
if (!profile) {
|
||||
throw new NotFoundException(
|
||||
`Company profile ${profileId} not found for this company`,
|
||||
);
|
||||
}
|
||||
if (usesManualRegistration(company)) {
|
||||
throw new BadRequestException(
|
||||
"This company is not registered with eTrade, so it has no business licences to attach.",
|
||||
);
|
||||
}
|
||||
const business = await this.etradeService.findBusinessOption(
|
||||
company.tin,
|
||||
licenceNumber,
|
||||
);
|
||||
const updated = await this.companyProfilesRepo.update(profile.id, {
|
||||
etradeBusiness: business,
|
||||
});
|
||||
if (!updated) {
|
||||
throw new NotFoundException(`Company profile ${profileId} not found`);
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
/** Resolve a TIN's live eTrade registration data. Throws when eTrade has no matching business licence. */
|
||||
private async resolveEtradeRegistration(
|
||||
tin: string,
|
||||
|
||||
@@ -1,9 +1,37 @@
|
||||
import { IsArray, IsEnum, ArrayMinSize } from "class-validator";
|
||||
import { Type } from "class-transformer";
|
||||
import {
|
||||
ArrayMinSize,
|
||||
IsArray,
|
||||
IsEnum,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MaxLength,
|
||||
ValidateNested,
|
||||
} from "class-validator";
|
||||
import { ProfileType } from "../entities/company-profile.entity";
|
||||
|
||||
export class AddCompanyProfileInputDto {
|
||||
@IsEnum(ProfileType)
|
||||
type!: ProfileType;
|
||||
|
||||
/**
|
||||
* Which of the TIN's eTrade business licences this role operates as.
|
||||
*
|
||||
* Optional at the DTO layer, required by the service for any company that
|
||||
* HAS an eTrade record — a co-operative or investor-licence company has none
|
||||
* to pick from, and rejecting them here would be wrong. See
|
||||
* `CompaniesService.resolveProfileBusiness`.
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(120)
|
||||
licenceNumber?: string;
|
||||
}
|
||||
|
||||
export class AddCompanyProfilesDto {
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@IsEnum(ProfileType, { each: true })
|
||||
types!: ProfileType[];
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => AddCompanyProfileInputDto)
|
||||
profiles!: AddCompanyProfileInputDto[];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { IsNotEmpty, IsString, MaxLength } from "class-validator";
|
||||
|
||||
export class AttachEtradeBusinessDto {
|
||||
/**
|
||||
* The eTrade licence number of the business this profile operates as. Checked
|
||||
* against the licences eTrade lists under the company's own TIN, so an
|
||||
* unknown or someone else's licence is rejected rather than stored.
|
||||
*/
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(120)
|
||||
licenceNumber!: string;
|
||||
}
|
||||
@@ -9,4 +9,14 @@ export class CreateCompanyProfileDto {
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
businessLicense?: string;
|
||||
|
||||
/**
|
||||
* Which of the TIN's eTrade business licences this role operates as. Required
|
||||
* by the service for any company that has an eTrade record; see
|
||||
* `AddCompanyProfileInputDto.licenceNumber`.
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(120)
|
||||
licenceNumber?: string;
|
||||
}
|
||||
|
||||
@@ -22,6 +22,16 @@ export class CompanyProfileInputDto {
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
businessLicense?: string;
|
||||
|
||||
/**
|
||||
* Which of the TIN's eTrade business licences this role operates as. Required
|
||||
* by the service for any company that has an eTrade record; see
|
||||
* `CompaniesService.resolveProfileBusiness`.
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(120)
|
||||
licenceNumber?: string;
|
||||
}
|
||||
|
||||
export class CreateCompanyWithProfileDto {
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
CompanyStatus,
|
||||
CompanyType,
|
||||
} from "../entities/company.entity";
|
||||
import { ProfileType } from "../entities/company-profile.entity";
|
||||
|
||||
export class ListCompaniesQueryDto {
|
||||
@ApiPropertyOptional({ default: 1 })
|
||||
@@ -56,6 +57,16 @@ export class ListCompaniesQueryDto {
|
||||
@IsIn(Object.values(CompanyNationality))
|
||||
nationality?: CompanyNationality;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
enum: ProfileType,
|
||||
description:
|
||||
"Only companies holding this operational role. A company may hold " +
|
||||
"several; its other roles are still returned on the row.",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsIn(Object.values(ProfileType))
|
||||
profileType?: ProfileType;
|
||||
|
||||
@ApiPropertyOptional({ description: "Registered on or after this instant (ISO)." })
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
* truth the wizard uses to auto-finish.
|
||||
*/
|
||||
|
||||
import type { ETradeBusinessOption } from "@edr/types";
|
||||
import {
|
||||
CompanyIdentityStateDto,
|
||||
PoaDeclaration,
|
||||
@@ -38,6 +39,11 @@ export interface OnboardingLicenseProfile {
|
||||
reference: string;
|
||||
/** True when at least one business-license file is stored on the profile. */
|
||||
uploaded: boolean;
|
||||
/**
|
||||
* The eTrade business this role operates as, once the customer has attached
|
||||
* one. Null while outstanding — the wizard renders the picker off this.
|
||||
*/
|
||||
etradeBusiness: ETradeBusinessOption | null;
|
||||
}
|
||||
|
||||
export interface OnboardingPoaState {
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
hasInvestorLicence,
|
||||
isCooperative,
|
||||
} from '../entities/company.entity';
|
||||
import type { ETradeBusinessOption } from '@edr/types';
|
||||
import {
|
||||
CompanyProfile,
|
||||
ProfileLicenseFileView,
|
||||
@@ -31,6 +32,12 @@ export class ResponseCompanyProfileDto {
|
||||
*/
|
||||
licenseFiles: ProfileLicenseFileView[];
|
||||
attributes?: Record<string, any> | null;
|
||||
/**
|
||||
* The eTrade business licence this role operates as, or null when nothing is
|
||||
* attached yet (or the company registered without eTrade). Snapshot — see
|
||||
* `CompanyProfile.etradeBusiness`.
|
||||
*/
|
||||
etradeBusiness?: ETradeBusinessOption | null;
|
||||
/** Reviewer note when the role is rejected (drives the reapply prompt). */
|
||||
reviewNote?: string | null;
|
||||
createdAt: Date;
|
||||
@@ -45,6 +52,7 @@ export class ResponseCompanyProfileDto {
|
||||
this.businessLicense = profile.businessLicense;
|
||||
this.licenseFiles = [];
|
||||
this.attributes = profile.attributes;
|
||||
this.etradeBusiness = profile.etradeBusiness ?? null;
|
||||
this.reviewNote = profile.reviewNote ?? null;
|
||||
this.createdAt = profile.createdAt;
|
||||
this.updatedAt = profile.updatedAt;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { BaseEntity } from "@edr/api-common";
|
||||
import type { ETradeBusinessOption } from "@edr/types";
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
|
||||
import { Company } from "./company.entity";
|
||||
|
||||
@@ -126,6 +127,23 @@ export class CompanyProfile extends BaseEntity {
|
||||
@Column({ name: "business_license_files", type: "jsonb", nullable: true })
|
||||
businessLicenseFiles?: BusinessLicenseFile[] | null;
|
||||
|
||||
/**
|
||||
* Which of the TIN's eTrade business licences this profile operates as.
|
||||
*
|
||||
* A TIN holds many licences split by activity, so "exporter" and "freight
|
||||
* forwarder" are usually two different businesses under one company. Stored
|
||||
* as a snapshot rather than a bare licence number so the trade name and
|
||||
* activity render without an eTrade call — that API is slow and regularly
|
||||
* down, and this is display data, not a source of truth. Re-attaching
|
||||
* refreshes it.
|
||||
*
|
||||
* NULL when nothing is attached yet, or when the company registered without
|
||||
* eTrade at all (co-operative / investor licence — see
|
||||
* {@link usesManualRegistration}). One business may back several profiles.
|
||||
*/
|
||||
@Column({ name: "etrade_business", type: "jsonb", nullable: true })
|
||||
etradeBusiness?: ETradeBusinessOption | null;
|
||||
|
||||
@Column({ name: "attributes", type: "jsonb", nullable: true })
|
||||
attributes?: Record<string, any> | null;
|
||||
|
||||
|
||||
@@ -78,6 +78,27 @@ describe('ETradeService business selection', () => {
|
||||
expect(data.businesses?.[0].activity).toBe('Export trade in minerals');
|
||||
});
|
||||
|
||||
it("takes the selected licence's trade name as the company name", () => {
|
||||
const { service } = build();
|
||||
const data = service.extractRegistrationData(
|
||||
{
|
||||
LicenceNumber: 'MT/AA/14/670/128936/2007',
|
||||
TradeName: 'Pave Freight Forwarding',
|
||||
} as ETradeBusinessInfo,
|
||||
companyInfo(),
|
||||
);
|
||||
expect(data.companyName).toBe('Pave Freight Forwarding');
|
||||
});
|
||||
|
||||
it('falls back to the registered name when the licence has no trade name', () => {
|
||||
const { service } = build();
|
||||
const data = service.extractRegistrationData(
|
||||
{ LicenceNumber: 'x', TradeName: ' ' } as ETradeBusinessInfo,
|
||||
companyInfo(),
|
||||
);
|
||||
expect(data.companyName).toBe('PAVE LOGISTICS AND TRADING P L C');
|
||||
});
|
||||
|
||||
it('lists every licence for the picker, code prefixes stripped', () => {
|
||||
const { service } = build();
|
||||
const data = service.extractRegistrationData(
|
||||
|
||||
@@ -5,6 +5,7 @@ import { firstValueFrom } from "rxjs";
|
||||
import {
|
||||
ETradeCompanyInfo,
|
||||
ETradeBusinessInfo,
|
||||
ETradeBusinessOption,
|
||||
CompanyRegistrationData,
|
||||
normalizeRegion,
|
||||
} from "@edr/types";
|
||||
@@ -102,10 +103,16 @@ export class ETradeService {
|
||||
}
|
||||
|
||||
/**
|
||||
* `companyInfo` carries the registered organization name (`BusinessName`);
|
||||
* `businessInfo` only carries the licence's `TradeName`. Pass both so the
|
||||
* company name resolves to the legal entity rather than the trade name — and
|
||||
* never to `ManagerNameEng`, which is the manager's personal name.
|
||||
* `businessInfo` carries the selected licence's `TradeName`; `companyInfo`
|
||||
* carries the registered organization name (`BusinessName`). The company name
|
||||
* resolves to the trade name of the licence the customer picked — a TIN
|
||||
* routinely trades under a name that is not its registered one, and the
|
||||
* business they selected is the one they operate as here. `BusinessName` is
|
||||
* the fallback, because eTrade leaves `TradeName` blank on plenty of licences.
|
||||
* Never `ManagerNameEng`, which is the manager's personal name.
|
||||
*
|
||||
* Callers that need the legal entity (tax filings, EIMS seller details) must
|
||||
* read `companyInfo.BusinessName` themselves — it is not this field.
|
||||
*/
|
||||
extractRegistrationData(
|
||||
businessInfo: ETradeBusinessInfo,
|
||||
@@ -115,7 +122,7 @@ export class ETradeService {
|
||||
|
||||
return {
|
||||
companyName:
|
||||
companyInfo?.BusinessName?.trim() || businessInfo.TradeName?.trim() || "",
|
||||
businessInfo.TradeName?.trim() || companyInfo?.BusinessName?.trim() || "",
|
||||
licenceNumber: businessInfo.LicenceNumber,
|
||||
statusDescription: businessInfo.StatusDescription,
|
||||
dateRegistered: businessInfo.DateRegistered,
|
||||
@@ -137,17 +144,57 @@ export class ETradeService {
|
||||
regularPhone: businessInfo.AddressInfo?.RegularPhone || "",
|
||||
managerName: primaryManager?.ManagerNameEng || "",
|
||||
managerPhone: primaryManager?.RegularPhone || "",
|
||||
businesses: (companyInfo?.Businesses ?? []).map((b) => ({
|
||||
licenceNumber: b.LicenceNumber,
|
||||
tradeName: b.TradesName?.trim() || "",
|
||||
activity: (b.SubGroups ?? [])
|
||||
// Some descriptions repeat the code inline ("(65611)Import trade …").
|
||||
// eTrade also puts null entries in this array, so every hop is optional.
|
||||
.map((g) => g?.Description?.replace(/^\(\d+\)\s*/, "").trim())
|
||||
.filter(Boolean)
|
||||
.join(", "),
|
||||
renewedTo: b.RenewedTo || "",
|
||||
})),
|
||||
businesses: (companyInfo?.Businesses ?? []).map(toBusinessOption),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Every business licence held under a TIN, as the customer picks them.
|
||||
*
|
||||
* Split out from {@link extractRegistrationData} because attaching a business
|
||||
* to a company profile needs the list alone — no licence detail fetch, so one
|
||||
* eTrade call instead of two.
|
||||
*/
|
||||
async listBusinessOptions(tin: string): Promise<ETradeBusinessOption[]> {
|
||||
const companyInfo = await this.getCompanyInfoByTin(tin);
|
||||
return (companyInfo.Businesses ?? []).map(toBusinessOption);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve one of the TIN's licences, or throw if eTrade does not list it.
|
||||
*
|
||||
* This is the trust boundary for a client-supplied licence number: a profile
|
||||
* may only ever be attached to a business eTrade actually holds under that
|
||||
* TIN, so the snapshot that gets stored is eTrade's own data, never the
|
||||
* client's.
|
||||
*/
|
||||
async findBusinessOption(
|
||||
tin: string,
|
||||
licenceNumber: string,
|
||||
): Promise<ETradeBusinessOption> {
|
||||
const options = await this.listBusinessOptions(tin);
|
||||
const match = options.find((b) => b.licenceNumber === licenceNumber);
|
||||
if (!match) {
|
||||
throw new BadRequestException(
|
||||
`eTrade lists no business licence "${licenceNumber}" under TIN ${tin}.`,
|
||||
);
|
||||
}
|
||||
return match;
|
||||
}
|
||||
}
|
||||
|
||||
function toBusinessOption(
|
||||
b: ETradeCompanyInfo["Businesses"][number],
|
||||
): ETradeBusinessOption {
|
||||
return {
|
||||
licenceNumber: b.LicenceNumber,
|
||||
tradeName: b.TradesName?.trim() || "",
|
||||
activity: (b.SubGroups ?? [])
|
||||
// Some descriptions repeat the code inline ("(65611)Import trade …").
|
||||
// eTrade also puts null entries in this array, so every hop is optional.
|
||||
.map((g) => g?.Description?.replace(/^\(\d+\)\s*/, "").trim())
|
||||
.filter(Boolean)
|
||||
.join(", "),
|
||||
renewedTo: b.RenewedTo || "",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -34,15 +34,27 @@ describe('bulkTemplateCode', () => {
|
||||
expect(bulkTemplateCode('STEEL', 'INTERCITY', null)).toBe('BULK_INTERCITY_STEEL');
|
||||
});
|
||||
|
||||
it('produces 5 distinct codes per cargo type', () => {
|
||||
it('gives the Ethiopian-customs-only variant its own suffix', () => {
|
||||
expect(bulkTemplateCode('STEEL', 'IMPORT', true, true)).toBe(
|
||||
'BULK_IMPORT_STEEL_ETHIOPIAN_CUSTOMS',
|
||||
);
|
||||
// The flag is meaningless without customs clearing.
|
||||
expect(bulkTemplateCode('STEEL', 'IMPORT', false, true)).toBe(
|
||||
'BULK_IMPORT_STEEL_NO_CUSTOMS',
|
||||
);
|
||||
});
|
||||
|
||||
it('produces 7 distinct codes per cargo type', () => {
|
||||
const codes = [
|
||||
bulkTemplateCode('STEEL', 'IMPORT', true),
|
||||
bulkTemplateCode('STEEL', 'IMPORT', true, true),
|
||||
bulkTemplateCode('STEEL', 'IMPORT', false),
|
||||
bulkTemplateCode('STEEL', 'EXPORT', true),
|
||||
bulkTemplateCode('STEEL', 'EXPORT', true, true),
|
||||
bulkTemplateCode('STEEL', 'EXPORT', false),
|
||||
bulkTemplateCode('STEEL', 'INTERCITY', null),
|
||||
];
|
||||
expect(new Set(codes).size).toBe(5);
|
||||
expect(new Set(codes).size).toBe(7);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -112,6 +124,33 @@ describe('ContractTemplatesService bulk create/resolve', () => {
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('creates the Ethiopian-customs-only variant alongside the full-customs one', async () => {
|
||||
const { service } = build();
|
||||
const created = await service.create({
|
||||
cargoTypeId: 'cargo-1',
|
||||
tradeDirection: 'IMPORT',
|
||||
withCustoms: true,
|
||||
ethiopianCustomsOnly: true,
|
||||
});
|
||||
expect(created.code).toBe('BULK_IMPORT_STEEL_ETHIOPIAN_CUSTOMS');
|
||||
expect(created.ethiopianCustomsOnly).toBe(true);
|
||||
expect(created.documentTitle).toBe(
|
||||
'Steel Transportation and Ethiopian Customs Clearance Services',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects Ethiopian-customs-only without customs clearing', async () => {
|
||||
const { service } = build();
|
||||
await expect(
|
||||
service.create({
|
||||
cargoTypeId: 'cargo-1',
|
||||
tradeDirection: 'IMPORT',
|
||||
withCustoms: false,
|
||||
ethiopianCustomsOnly: true,
|
||||
}),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('resolves a domestic bulk contract to the intercity template, ignoring its customs flag', async () => {
|
||||
const { repository, service } = build();
|
||||
await service.findActiveForContract('DOMESTIC', 'BULK', true, 'cargo-1');
|
||||
@@ -119,6 +158,7 @@ describe('ContractTemplatesService bulk create/resolve', () => {
|
||||
'cargo-1',
|
||||
'INTERCITY',
|
||||
null,
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -129,6 +169,18 @@ describe('ContractTemplatesService bulk create/resolve', () => {
|
||||
'cargo-1',
|
||||
'IMPORT',
|
||||
false,
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('resolves an Ethiopian-customs-only contract to the Ethiopian variant', async () => {
|
||||
const { repository, service } = build();
|
||||
await service.findActiveForContract('IMPORT', 'BULK', true, 'cargo-1', true);
|
||||
expect(repository.findActiveBulkTemplate).toHaveBeenCalledWith(
|
||||
'cargo-1',
|
||||
'IMPORT',
|
||||
true,
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,6 +16,22 @@ describe('contractTemplateCodeFor', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('resolves the Ethiopian variant only when customs clearing is enabled', () => {
|
||||
expect(contractTemplateCodeFor('IMPORT', 'CONTAINER', true, true)).toBe(
|
||||
'IMPORT_CONTAINER_ETHIOPIAN_CUSTOMS',
|
||||
);
|
||||
expect(contractTemplateCodeFor('EXPORT', 'BULK', true, true)).toBe(
|
||||
'EXPORT_BULK_ETHIOPIAN_CUSTOMS',
|
||||
);
|
||||
// Without customs clearing the Ethiopian flag is meaningless.
|
||||
expect(contractTemplateCodeFor('IMPORT', 'CONTAINER', false, true)).toBe(
|
||||
'IMPORT_CONTAINER_NO_CUSTOMS',
|
||||
);
|
||||
expect(contractTemplateCodeFor('DOMESTIC', 'CONTAINER', true, true)).toBe(
|
||||
'INTERCITY_CONTAINER',
|
||||
);
|
||||
});
|
||||
|
||||
it('never gives intercity a customs variant — it crosses no border', () => {
|
||||
for (const flag of [true, false, null, undefined]) {
|
||||
expect(contractTemplateCodeFor('DOMESTIC', 'BULK', flag)).toBe('INTERCITY_BULK');
|
||||
@@ -40,7 +56,11 @@ describe('contractTemplateCodeFor', () => {
|
||||
for (const d of directions) {
|
||||
for (const f of freights) {
|
||||
for (const c of [true, false]) {
|
||||
expect(CONTRACT_TEMPLATE_CODES).toContain(contractTemplateCodeFor(d, f, c));
|
||||
for (const e of [true, false, undefined]) {
|
||||
expect(CONTRACT_TEMPLATE_CODES).toContain(
|
||||
contractTemplateCodeFor(d, f, c, e),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -48,9 +68,9 @@ describe('contractTemplateCodeFor', () => {
|
||||
});
|
||||
|
||||
describe('CONTRACT_TEMPLATE_DEFAULTS', () => {
|
||||
it('seeds exactly the ten declared codes, once each', () => {
|
||||
it('seeds exactly the fourteen declared codes, once each', () => {
|
||||
const seeded = CONTRACT_TEMPLATE_DEFAULTS.map((t) => t.code).sort();
|
||||
expect(seeded).toHaveLength(10);
|
||||
expect(seeded).toHaveLength(14);
|
||||
expect(seeded).toEqual([...CONTRACT_TEMPLATE_CODES].sort());
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { BaseRepository } from "@edr/api-common";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { InjectRepository } from "@nestjs/typeorm";
|
||||
import { IsNull, Repository } from "typeorm";
|
||||
import { Repository } from "typeorm";
|
||||
|
||||
import { CargoType } from "../rule-engine/entities/cargo-type.entity";
|
||||
import {
|
||||
@@ -33,10 +33,22 @@ export class ContractTemplatesRepository extends BaseRepository<ContractTemplate
|
||||
cargoTypeId: string,
|
||||
tradeDirection: BulkTemplateDirection,
|
||||
withCustoms: boolean | null,
|
||||
ethiopianCustomsOnly = false,
|
||||
): Promise<ContractTemplate | null> {
|
||||
return this.repository.findOne({
|
||||
where: { cargoTypeId, tradeDirection, withCustoms: withCustoms ?? IsNull() },
|
||||
});
|
||||
return this.repository
|
||||
.createQueryBuilder("t")
|
||||
.where("t.cargo_type_id = :cargoTypeId", { cargoTypeId })
|
||||
.andWhere("t.trade_direction = :tradeDirection", { tradeDirection })
|
||||
.andWhere(
|
||||
withCustoms === null
|
||||
? "t.with_customs IS NULL"
|
||||
: "t.with_customs = :withCustoms",
|
||||
withCustoms === null ? {} : { withCustoms },
|
||||
)
|
||||
.andWhere("COALESCE(t.ethiopian_customs_only, false) = :ethiopianCustomsOnly", {
|
||||
ethiopianCustomsOnly,
|
||||
})
|
||||
.getOne();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -49,6 +61,7 @@ export class ContractTemplatesRepository extends BaseRepository<ContractTemplate
|
||||
cargoTypeId: string,
|
||||
tradeDirection: BulkTemplateDirection,
|
||||
withCustoms: boolean | null,
|
||||
ethiopianCustomsOnly = false,
|
||||
): Promise<ContractTemplate | null> {
|
||||
return this.repository
|
||||
.createQueryBuilder("t")
|
||||
@@ -60,6 +73,9 @@ export class ContractTemplatesRepository extends BaseRepository<ContractTemplate
|
||||
: "t.with_customs = :withCustoms",
|
||||
withCustoms === null ? {} : { withCustoms },
|
||||
)
|
||||
.andWhere("COALESCE(t.ethiopian_customs_only, false) = :ethiopianCustomsOnly", {
|
||||
ethiopianCustomsOnly,
|
||||
})
|
||||
.andWhere(
|
||||
`(t.cargo_type_id = :cargoTypeId OR t.cargo_type_id = (
|
||||
SELECT c.parent_group_id FROM freight.cargo_types c
|
||||
|
||||
@@ -41,13 +41,17 @@ import {
|
||||
*/
|
||||
const PREVIEW_TEMPLATE_KEYS: Record<ContractTemplateCode, string> = {
|
||||
IMPORT_BULK_CUSTOMS: "IMP_BULK_USD_FORWARDING",
|
||||
IMPORT_BULK_ETHIOPIAN_CUSTOMS: "IMP_BULK_USD_FORWARDING",
|
||||
IMPORT_BULK_NO_CUSTOMS: "IMP_BULK_USD_TRANSPORT_ONLY",
|
||||
EXPORT_BULK_CUSTOMS: "EXP_BULK_USD_FORWARDING",
|
||||
EXPORT_BULK_ETHIOPIAN_CUSTOMS: "EXP_BULK_USD_FORWARDING",
|
||||
EXPORT_BULK_NO_CUSTOMS: "EXP_BULK_USD_TRANSPORT_ONLY",
|
||||
INTERCITY_BULK: "DOM_BULK_USD_TRANSPORT_ONLY",
|
||||
IMPORT_CONTAINER_CUSTOMS: "IMP_CON_USD_FORWARDING",
|
||||
IMPORT_CONTAINER_ETHIOPIAN_CUSTOMS: "IMP_CON_USD_FORWARDING",
|
||||
IMPORT_CONTAINER_NO_CUSTOMS: "IMP_CON_USD_TRANSPORT_ONLY",
|
||||
EXPORT_CONTAINER_CUSTOMS: "EXP_CON_USD_FORWARDING",
|
||||
EXPORT_CONTAINER_ETHIOPIAN_CUSTOMS: "EXP_CON_USD_FORWARDING",
|
||||
EXPORT_CONTAINER_NO_CUSTOMS: "EXP_CON_USD_TRANSPORT_ONLY",
|
||||
INTERCITY_CONTAINER: "DOM_CON_USD_TRANSPORT_ONLY",
|
||||
};
|
||||
@@ -101,7 +105,7 @@ export class ContractTemplatesService {
|
||||
|
||||
const direction = dto.tradeDirection;
|
||||
const intercity = direction === "INTERCITY";
|
||||
if (intercity && dto.withCustoms !== undefined) {
|
||||
if (intercity && (dto.withCustoms !== undefined || dto.ethiopianCustomsOnly)) {
|
||||
throw new BadRequestException(
|
||||
"Intercity contracts are domestic and cross no border — they have no customs clearing variant",
|
||||
);
|
||||
@@ -112,12 +116,24 @@ export class ContractTemplatesService {
|
||||
);
|
||||
}
|
||||
const withCustoms = intercity ? null : Boolean(dto.withCustoms);
|
||||
const ethiopianOnly = Boolean(dto.ethiopianCustomsOnly) && !intercity;
|
||||
if (ethiopianOnly && !withCustoms) {
|
||||
throw new BadRequestException(
|
||||
"Ethiopian-customs-only is a customs clearing variant — it requires withCustoms to be true",
|
||||
);
|
||||
}
|
||||
|
||||
const label = this.comboLabel(cargoType.cargoTypeName, direction, withCustoms);
|
||||
const label = this.comboLabel(
|
||||
cargoType.cargoTypeName,
|
||||
direction,
|
||||
withCustoms,
|
||||
ethiopianOnly,
|
||||
);
|
||||
const existing = await this.repository.findByCargoCombo(
|
||||
dto.cargoTypeId,
|
||||
direction,
|
||||
withCustoms,
|
||||
ethiopianOnly,
|
||||
);
|
||||
if (existing) {
|
||||
throw new ConflictException(
|
||||
@@ -126,11 +142,13 @@ export class ContractTemplatesService {
|
||||
}
|
||||
|
||||
const template = new ContractTemplate();
|
||||
template.code = bulkTemplateCode(cargoType.code, direction, withCustoms);
|
||||
template.code = bulkTemplateCode(cargoType.code, direction, withCustoms, ethiopianOnly);
|
||||
template.name = dto.name ?? label;
|
||||
template.description = dto.description ?? null;
|
||||
template.documentTitle = withCustoms
|
||||
? `${cargoType.cargoTypeName} Transportation and Customs Clearance Services`
|
||||
? ethiopianOnly
|
||||
? `${cargoType.cargoTypeName} Transportation and Ethiopian Customs Clearance Services`
|
||||
: `${cargoType.cargoTypeName} Transportation and Customs Clearance Services`
|
||||
: `${cargoType.cargoTypeName} Transportation Services`;
|
||||
template.whereasClauses = [];
|
||||
template.articles = [];
|
||||
@@ -138,6 +156,7 @@ export class ContractTemplatesService {
|
||||
template.cargoTypeId = cargoType.id;
|
||||
template.tradeDirection = direction;
|
||||
template.withCustoms = withCustoms;
|
||||
template.ethiopianCustomsOnly = intercity ? null : ethiopianOnly;
|
||||
template.isSystem = false;
|
||||
try {
|
||||
return await this.repository.saveTemplate(template);
|
||||
@@ -157,18 +176,21 @@ export class ContractTemplatesService {
|
||||
cargoTypeName: string,
|
||||
direction: BulkTemplateDirection,
|
||||
withCustoms: boolean | null,
|
||||
ethiopianCustomsOnly = false,
|
||||
): string {
|
||||
const dir = direction.charAt(0) + direction.slice(1).toLowerCase();
|
||||
const customs =
|
||||
withCustoms === null
|
||||
? ""
|
||||
: withCustoms
|
||||
? ", with customs clearing"
|
||||
? ethiopianCustomsOnly
|
||||
? ", with Ethiopian customs clearing only"
|
||||
: ", with customs clearing"
|
||||
: ", without customs clearing";
|
||||
return `${cargoTypeName} Bulk Contract (${dir}${customs})`;
|
||||
}
|
||||
|
||||
/** Bulk templates only — the five seeded container templates are permanent. */
|
||||
/** Bulk templates only — the seeded container templates are permanent. */
|
||||
async remove(code: string): Promise<void> {
|
||||
const template = await this.getByCode(code);
|
||||
if (template.isSystem) {
|
||||
@@ -193,6 +215,7 @@ export class ContractTemplatesService {
|
||||
freightType?: string | null,
|
||||
customsClearingEnabled?: boolean | null,
|
||||
cargoTypeId?: string | null,
|
||||
ethiopianCustomsOnly?: boolean | null,
|
||||
): Promise<ContractTemplate | null> {
|
||||
const isBulk = (freightType ?? "").toUpperCase().includes("BULK");
|
||||
if (isBulk) {
|
||||
@@ -202,12 +225,16 @@ export class ContractTemplatesService {
|
||||
cargoTypeId,
|
||||
direction,
|
||||
direction === "INTERCITY" ? null : Boolean(customsClearingEnabled),
|
||||
direction === "INTERCITY"
|
||||
? false
|
||||
: Boolean(customsClearingEnabled && ethiopianCustomsOnly),
|
||||
);
|
||||
}
|
||||
const code = contractTemplateCodeFor(
|
||||
tradeDirection,
|
||||
freightType,
|
||||
customsClearingEnabled,
|
||||
ethiopianCustomsOnly,
|
||||
);
|
||||
const template = await this.repository.findByCode(code);
|
||||
return template?.isActive ? template : null;
|
||||
|
||||
@@ -43,6 +43,14 @@ export class CreateContractTemplateDto {
|
||||
@IsBoolean()
|
||||
withCustoms?: boolean;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
"Restricts the with-customs variant to Ethiopian-side clearing only (Djibouti stays with the Client). Requires withCustoms=true; rejected for INTERCITY",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
ethiopianCustomsOnly?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ description: "Display name (derived from the cargo type when omitted)" })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
|
||||
@@ -4,9 +4,10 @@ import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
|
||||
import { CargoType } from "../../rule-engine/entities/cargo-type.entity";
|
||||
|
||||
/**
|
||||
* The five seeded container templates (import/export split by customs
|
||||
* clearing; intercity is domestic, crosses no border, so it has a single
|
||||
* template). These are system rows: always present, never deletable.
|
||||
* The seeded container templates (import/export split by customs-clearing
|
||||
* option — full, Ethiopian-only, none; intercity is domestic, crosses no
|
||||
* border, so it has a single template). These are system rows: always
|
||||
* present, never deletable.
|
||||
*
|
||||
* Bulk templates are NOT seeded — staff create them per bulk cargo type
|
||||
* (`cargoTypeId`), trade direction (`tradeDirection`) and customs option
|
||||
@@ -20,18 +21,24 @@ import { CargoType } from "../../rule-engine/entities/cargo-type.entity";
|
||||
*
|
||||
* The `_CUSTOMS` variant is issued when the contract has customs clearing
|
||||
* enabled (the Service Provider clears in Djibouti/Ethiopia on the Client's
|
||||
* behalf); `_NO_CUSTOMS` is the transport-only paper, where the Client handles
|
||||
* behalf); `_ETHIOPIAN_CUSTOMS` when the service type is Ethiopian-customs-only
|
||||
* (the Service Provider clears the Ethiopian side only, Djibouti stays with the
|
||||
* Client); `_NO_CUSTOMS` is the transport-only paper, where the Client handles
|
||||
* its own declarations.
|
||||
*/
|
||||
export const CONTRACT_TEMPLATE_CODES = [
|
||||
"IMPORT_BULK_CUSTOMS",
|
||||
"IMPORT_BULK_ETHIOPIAN_CUSTOMS",
|
||||
"IMPORT_BULK_NO_CUSTOMS",
|
||||
"EXPORT_BULK_CUSTOMS",
|
||||
"EXPORT_BULK_ETHIOPIAN_CUSTOMS",
|
||||
"EXPORT_BULK_NO_CUSTOMS",
|
||||
"INTERCITY_BULK",
|
||||
"IMPORT_CONTAINER_CUSTOMS",
|
||||
"IMPORT_CONTAINER_ETHIOPIAN_CUSTOMS",
|
||||
"IMPORT_CONTAINER_NO_CUSTOMS",
|
||||
"EXPORT_CONTAINER_CUSTOMS",
|
||||
"EXPORT_CONTAINER_ETHIOPIAN_CUSTOMS",
|
||||
"EXPORT_CONTAINER_NO_CUSTOMS",
|
||||
"INTERCITY_CONTAINER",
|
||||
] as const;
|
||||
@@ -66,6 +73,7 @@ export function contractTemplateCodeFor(
|
||||
tradeDirection?: string | null,
|
||||
freightType?: string | null,
|
||||
customsClearingEnabled?: boolean | null,
|
||||
ethiopianCustomsOnly?: boolean | null,
|
||||
): ContractTemplateCode {
|
||||
const direction =
|
||||
tradeDirection === "IMPORT"
|
||||
@@ -78,7 +86,11 @@ export function contractTemplateCodeFor(
|
||||
if (direction === "INTERCITY") {
|
||||
return `INTERCITY_${freight}` as ContractTemplateCode;
|
||||
}
|
||||
const customs = customsClearingEnabled ? "CUSTOMS" : "NO_CUSTOMS";
|
||||
const customs = customsClearingEnabled
|
||||
? ethiopianCustomsOnly
|
||||
? "ETHIOPIAN_CUSTOMS"
|
||||
: "CUSTOMS"
|
||||
: "NO_CUSTOMS";
|
||||
return `${direction}_${freight}_${customs}` as ContractTemplateCode;
|
||||
}
|
||||
|
||||
@@ -107,9 +119,16 @@ export function bulkTemplateCode(
|
||||
cargoCode: string,
|
||||
direction: BulkTemplateDirection,
|
||||
withCustoms: boolean | null,
|
||||
ethiopianCustomsOnly = false,
|
||||
): string {
|
||||
const suffix =
|
||||
direction === "INTERCITY" ? "" : withCustoms ? "_CUSTOMS" : "_NO_CUSTOMS";
|
||||
direction === "INTERCITY"
|
||||
? ""
|
||||
: withCustoms
|
||||
? ethiopianCustomsOnly
|
||||
? "_ETHIOPIAN_CUSTOMS"
|
||||
: "_CUSTOMS"
|
||||
: "_NO_CUSTOMS";
|
||||
return `BULK_${direction}_${cargoCode}${suffix}`.toUpperCase();
|
||||
}
|
||||
|
||||
@@ -162,7 +181,15 @@ export class ContractTemplate extends BaseEntity {
|
||||
@Column({ name: "with_customs", type: "boolean", nullable: true })
|
||||
withCustoms?: boolean | null;
|
||||
|
||||
/** The five seeded container templates — cannot be deleted. */
|
||||
/**
|
||||
* Bulk templates only: the with-customs variant restricted to Ethiopian-side
|
||||
* clearing (Djibouti stays with the Client). Only meaningful when
|
||||
* `withCustoms` is true; null/false otherwise.
|
||||
*/
|
||||
@Column({ name: "ethiopian_customs_only", type: "boolean", nullable: true })
|
||||
ethiopianCustomsOnly?: boolean | null;
|
||||
|
||||
/** The seeded container templates — cannot be deleted. */
|
||||
@Column({ name: "is_system", type: "boolean", default: false })
|
||||
isSystem!: boolean;
|
||||
}
|
||||
|
||||
@@ -227,3 +227,85 @@ describe('ContractBookingService — quantity-cap completion', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The customer's shipment request is the order: GL may not change its container
|
||||
* sizes/quantities or billing currency at completion — only per-unit details.
|
||||
*/
|
||||
describe('ContractBookingService — shipment-request lock at completion', () => {
|
||||
type WithAssert = {
|
||||
assertMatchesShipmentRequest(
|
||||
bookingId: string,
|
||||
dto: {
|
||||
paymentCurrency?: string;
|
||||
containers?: Array<{ containerSize: string; quantity: number }>;
|
||||
bulkLines?: Array<{ cargoWeightTons?: number }>;
|
||||
},
|
||||
): Promise<void>;
|
||||
};
|
||||
|
||||
const serviceWithRequest = (request: unknown): WithAssert => {
|
||||
const svc = Object.create(ContractBookingService.prototype) as WithAssert & {
|
||||
dataSource: unknown;
|
||||
};
|
||||
svc.dataSource = {
|
||||
getRepository: () => ({ findOne: async () => request }),
|
||||
};
|
||||
return svc;
|
||||
};
|
||||
|
||||
const request = {
|
||||
paymentCurrency: 'USD',
|
||||
requestedLines: {
|
||||
containers: [
|
||||
{ containerSize: '20ft', quantity: 2 },
|
||||
{ containerSize: '40ft', quantity: 1 },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
it('accepts the exact requested quantities and currency', async () => {
|
||||
await expect(
|
||||
serviceWithRequest(request).assertMatchesShipmentRequest('b1', {
|
||||
paymentCurrency: 'USD',
|
||||
containers: [
|
||||
{ containerSize: '40ft', quantity: 1 },
|
||||
{ containerSize: '20ft', quantity: 2 },
|
||||
],
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects changed quantities', async () => {
|
||||
await expect(
|
||||
serviceWithRequest(request).assertMatchesShipmentRequest('b1', {
|
||||
paymentCurrency: 'USD',
|
||||
containers: [
|
||||
{ containerSize: '20ft', quantity: 4 },
|
||||
{ containerSize: '40ft', quantity: 1 },
|
||||
],
|
||||
}),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('rejects a changed billing currency', async () => {
|
||||
await expect(
|
||||
serviceWithRequest(request).assertMatchesShipmentRequest('b1', {
|
||||
paymentCurrency: 'ETB',
|
||||
containers: [
|
||||
{ containerSize: '20ft', quantity: 2 },
|
||||
{ containerSize: '40ft', quantity: 1 },
|
||||
],
|
||||
}),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('is a no-op without a linked request', async () => {
|
||||
await expect(
|
||||
serviceWithRequest(null).assertMatchesShipmentRequest('b1', {
|
||||
paymentCurrency: 'ETB',
|
||||
containers: [{ containerSize: '20ft', quantity: 9 }],
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import { DataSource } from 'typeorm';
|
||||
import { OnEvent } from '@nestjs/event-emitter';
|
||||
import { insertWithGeneratedReference } from '@edr/api-common';
|
||||
import { CargoUnitOfMeasure } from '@edr/types';
|
||||
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
import { BookingContainer } from '../bookings/entities/booking-container.entity';
|
||||
@@ -28,6 +29,7 @@ import { TrainSchedulingGlobalRules } from '../train-scheduling/entities/train-s
|
||||
import { TrainSchedulingService } from '../train-scheduling/services/train-scheduling.service';
|
||||
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
|
||||
import { eatDay } from '../train-scheduling/batch-window.util';
|
||||
import { bulkTonsPerWagon } from '../train-scheduling/train-capacity.util';
|
||||
import { wagonsPerUnitForSize } from '../rule-engine/container-type.util';
|
||||
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
|
||||
import { RuleEngineService } from '../rule-engine/rule-engine.service';
|
||||
@@ -36,6 +38,7 @@ import { CargoType } from '../rule-engine/entities/cargo-type.entity';
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
import { hasFreightPermission } from '../../common/freight-permission.util';
|
||||
|
||||
import { BookingRequest } from './entities/booking-request.entity';
|
||||
import { Contract } from './entities/contract.entity';
|
||||
import { ContractRoute } from './entities/contract-route.entity';
|
||||
import {
|
||||
@@ -272,6 +275,8 @@ export class ContractBookingService {
|
||||
});
|
||||
}
|
||||
|
||||
const bulkFields = await this.resolveBulkCargoFields(contract, dto);
|
||||
|
||||
// Denormalize route/direction/freight onto the booking for the scheduling engine.
|
||||
// Retry past a concurrent insert that grabbed the same BK sequence number.
|
||||
const booking = await insertWithGeneratedReference(
|
||||
@@ -305,8 +310,7 @@ export class ContractBookingService {
|
||||
cargoFreeText: dto.cargoFreeText?.trim() || null,
|
||||
isHazardous: this.resolveShipmentHandlingFlag(contract, dto, 'hazardousQuantity'),
|
||||
isReefer: this.resolveShipmentHandlingFlag(contract, dto, 'reeferQuantity'),
|
||||
cargoTotalWeightVgm: this.resolveBulkTons(dto),
|
||||
bulkTotalWeightTons: this.resolveBulkWeightTons(dto),
|
||||
...bulkFields,
|
||||
firstMilePickupAddress: contract.firstMilePickupAddress ?? null,
|
||||
firstMilePickupLat: contract.firstMilePickupLat ?? null,
|
||||
firstMilePickupLng: contract.firstMilePickupLng ?? null,
|
||||
@@ -395,6 +399,10 @@ export class ContractBookingService {
|
||||
if (
|
||||
withContainers &&
|
||||
freightType === 'CONTAINER' &&
|
||||
// A rebooked cancellation credit carries `skipAutoConsolidation`: its
|
||||
// shared-wagon partner is picked by GL in the rebook flow, so nothing may
|
||||
// auto-claim (or park) it here behind GL's back.
|
||||
!dto.skipAutoConsolidation &&
|
||||
(await this.consolidationService.needsConsolidationFromBooking(
|
||||
withContainers,
|
||||
))
|
||||
@@ -848,6 +856,35 @@ export class ContractBookingService {
|
||||
if (!dto.scheduledDate) {
|
||||
throw new BadRequestException('A binding shipment day is required');
|
||||
}
|
||||
// Without-customs import/export: the customer's own clearing agent (name,
|
||||
// email, phone) is captured per booking at completion. A resubmit may omit
|
||||
// the fields and keep what the booking already stored. Customs contracts
|
||||
// (GL clears) and intercity (no border) never collect an agent.
|
||||
if (
|
||||
!contract.customsClearingEnabled &&
|
||||
contract.tradeDirection !== 'DOMESTIC'
|
||||
) {
|
||||
const agentName =
|
||||
dto.customsClearingAgent?.trim() || booking.customsClearingAgent || null;
|
||||
const agentEmail =
|
||||
dto.customsClearingAgentEmail?.trim() ||
|
||||
booking.customsClearingAgentEmail ||
|
||||
null;
|
||||
const agentPhone =
|
||||
dto.customsClearingAgentPhone?.trim() ||
|
||||
booking.customsClearingAgentPhone ||
|
||||
null;
|
||||
if (!agentName || !agentEmail || !agentPhone) {
|
||||
throw new BadRequestException(
|
||||
'Customs clearing agent name, email and phone are required to complete this booking.',
|
||||
);
|
||||
}
|
||||
await this.bookingsRepository.update(booking.id, {
|
||||
customsClearingAgent: agentName,
|
||||
customsClearingAgentEmail: agentEmail,
|
||||
customsClearingAgentPhone: agentPhone,
|
||||
} as never);
|
||||
}
|
||||
// No expiry gate here on purpose: this booking was already initiated
|
||||
// before the contract lapsed (createUnderContract/initiateUnderContract
|
||||
// already checked expiry at start). Finishing an in-flight booking must
|
||||
@@ -863,6 +900,12 @@ export class ContractBookingService {
|
||||
direction: contract.tradeDirection ?? null,
|
||||
});
|
||||
|
||||
// The customer's shipment request is the order: sizes, quantities and
|
||||
// billing currency are theirs — GL enters everything else. Both halves of a
|
||||
// consolidated pair pass through here, so each is checked against its OWN
|
||||
// request.
|
||||
await this.assertMatchesShipmentRequest(booking.id, dto);
|
||||
|
||||
const freightType = contract.freightType;
|
||||
let hasCargo =
|
||||
(booking.bookingContainers?.length ?? 0) > 0 ||
|
||||
@@ -944,8 +987,7 @@ export class ContractBookingService {
|
||||
await this.bookingsRepository.update(booking.id, {
|
||||
cargoTypeId: this.resolveCargoTypeId(contract, dto),
|
||||
cargoFreeText: dto.cargoFreeText?.trim() || null,
|
||||
cargoTotalWeightVgm: this.resolveBulkTons(dto),
|
||||
bulkTotalWeightTons: this.resolveBulkWeightTons(dto),
|
||||
...(await this.resolveBulkCargoFields(contract, dto)),
|
||||
equipmentReturn: this.resolveShipmentEquipmentReturn(contract, dto),
|
||||
// Completion is where the cargo — and therefore the price — is fixed, so
|
||||
// it is also where the billing currency is chosen. A bare instance was
|
||||
@@ -1052,6 +1094,72 @@ export class ContractBookingService {
|
||||
return { booking: completed, warnings };
|
||||
}
|
||||
|
||||
/**
|
||||
* The linked shipment request (customs Path B) is the customer's order:
|
||||
* container sizes + quantities and the billing currency are the customer's
|
||||
* choices, and GL may not change them at completion — only per-unit details
|
||||
* (numbers, seals, VGM, handling) are GL's to enter. No linked request, or a
|
||||
* legacy request without lines/currency ⇒ nothing to enforce. Container lines
|
||||
* are checked only when the payload restates cargo (a day-only resubmit keeps
|
||||
* the already-validated persisted cargo).
|
||||
*/
|
||||
private async assertMatchesShipmentRequest(
|
||||
bookingId: string,
|
||||
dto: CreateBookingUnderContractDto,
|
||||
): Promise<void> {
|
||||
const request = await this.dataSource.getRepository(BookingRequest).findOne({
|
||||
where: { createdBookingId: bookingId },
|
||||
});
|
||||
if (!request) return;
|
||||
const lines = request.requestedLines ?? {};
|
||||
|
||||
if (request.paymentCurrency) {
|
||||
if (dto.paymentCurrency && dto.paymentCurrency !== request.paymentCurrency) {
|
||||
throw new BadRequestException(
|
||||
`The customer chose ${request.paymentCurrency} on the shipment request — the billing currency cannot be changed.`,
|
||||
);
|
||||
}
|
||||
dto.paymentCurrency = request.paymentCurrency;
|
||||
}
|
||||
|
||||
if (dto.containers?.length && lines.containers?.length) {
|
||||
// Compare per size in ft ("20ft" vs "20FT"/"20" spellings must not differ).
|
||||
const byFt = (rows: Array<{ containerSize: string; quantity: number }>) => {
|
||||
const map = new Map<number, number>();
|
||||
for (const row of rows) {
|
||||
const ft = parseInt(String(row.containerSize), 10);
|
||||
map.set(ft, (map.get(ft) ?? 0) + Number(row.quantity || 0));
|
||||
}
|
||||
return map;
|
||||
};
|
||||
const requested = byFt(lines.containers);
|
||||
const given = byFt(dto.containers);
|
||||
const same =
|
||||
requested.size === given.size &&
|
||||
[...requested].every(([ft, qty]) => given.get(ft) === qty);
|
||||
if (!same) {
|
||||
const summary = [...requested]
|
||||
.map(([ft, qty]) => `${qty} × ${ft}ft`)
|
||||
.join(', ');
|
||||
throw new BadRequestException(
|
||||
`The customer requested exactly ${summary} — container sizes and quantities cannot be changed at completion.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (dto.bulkLines?.length && lines.bulk?.cargoWeightTons != null) {
|
||||
const givenTons = dto.bulkLines.reduce(
|
||||
(sum, l) => sum + Number(l.cargoWeightTons || 0),
|
||||
0,
|
||||
);
|
||||
if (givenTons !== Number(lines.bulk.cargoWeightTons)) {
|
||||
throw new BadRequestException(
|
||||
`The customer requested ${lines.bulk.cargoWeightTons} tons on the shipment request — the bulk quantity cannot be changed at completion.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for a complementary partner for a parked-eligible drawdown, pair it or
|
||||
* park it in PENDING_CONSOLIDATION with the resume status it should return to.
|
||||
@@ -1456,8 +1564,10 @@ export class ContractBookingService {
|
||||
return probe;
|
||||
}
|
||||
|
||||
probe.cargoTotalWeightVgm = this.resolveBulkTons(dto);
|
||||
probe.bulkTotalWeightTons = this.resolveBulkWeightTons(dto);
|
||||
const bulkFields = await this.resolveBulkCargoFields(contract, dto);
|
||||
probe.cargoTotalWeightVgm = bulkFields.cargoTotalWeightVgm;
|
||||
probe.bulkTotalWeightTons = bulkFields.bulkTotalWeightTons;
|
||||
probe.bulkRequestedWagons = bulkFields.bulkRequestedWagons;
|
||||
const cargoTypeId = this.resolveCargoTypeId(contract, dto);
|
||||
probe.cargoTypeId = cargoTypeId;
|
||||
if (cargoTypeId) {
|
||||
@@ -1861,6 +1971,107 @@ export class ContractBookingService {
|
||||
return tons > 0 ? tons : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk cargo columns for the booking row, resolved against the commodity's
|
||||
* unit of measure:
|
||||
*
|
||||
* - PER_TON: `cargoTotalWeightVgm` = tons (legacy behaviour).
|
||||
* - PER_ITEM: `cargoTotalWeightVgm` = item count, real tonnage in
|
||||
* `bulkTotalWeightTons` (legacy behaviour).
|
||||
* - NUMBER_OF_WAGONS: `cargoTotalWeightVgm` = tons, and the payload must fix
|
||||
* the wagon count (customer on the portal, GL in the backoffice). The
|
||||
* count is validated so each wagon's even share (tons ÷ wagons) fits what
|
||||
* one wagon of this cargo may carry; the optional item count is stored as
|
||||
* information only and never prices or sizes anything.
|
||||
*
|
||||
* Container contracts (and payloads without bulk lines) pass through with
|
||||
* the legacy zero/null values.
|
||||
*/
|
||||
private async resolveBulkCargoFields(
|
||||
contract: Contract,
|
||||
dto: CreateBookingUnderContractDto,
|
||||
): Promise<{
|
||||
cargoTotalWeightVgm: number;
|
||||
bulkTotalWeightTons: number | null;
|
||||
bulkRequestedWagons: number | null;
|
||||
bulkItemCount: number | null;
|
||||
}> {
|
||||
const legacy = {
|
||||
cargoTotalWeightVgm: this.resolveBulkTons(dto),
|
||||
bulkTotalWeightTons: this.resolveBulkWeightTons(dto),
|
||||
bulkRequestedWagons: null as number | null,
|
||||
bulkItemCount: null as number | null,
|
||||
};
|
||||
if (contract.freightType === 'CONTAINER' || !dto.bulkLines?.length) {
|
||||
return legacy;
|
||||
}
|
||||
const cargoTypeId = this.resolveCargoTypeId(contract, dto);
|
||||
if (!cargoTypeId) return legacy;
|
||||
const cargoType = await this.dataSource.getRepository(CargoType).findOne({
|
||||
where: { id: cargoTypeId },
|
||||
relations: { wagonTypes: true },
|
||||
});
|
||||
if (cargoType?.unitOfMeasure !== CargoUnitOfMeasure.NumberOfWagons) {
|
||||
return legacy;
|
||||
}
|
||||
|
||||
const tons = dto.bulkLines.reduce(
|
||||
(sum, l) => sum + Number(l.cargoWeightTons ?? 0),
|
||||
0,
|
||||
);
|
||||
const items = dto.bulkLines.reduce(
|
||||
(sum, l) => sum + Number(l.itemCount ?? 0),
|
||||
0,
|
||||
);
|
||||
const wagons = Math.floor(Number(dto.requestedWagons ?? 0));
|
||||
if (!(wagons >= 1)) {
|
||||
throw new BadRequestException(
|
||||
`${cargoType.cargoTypeName} is booked by wagons — enter the number of wagons needed.`,
|
||||
);
|
||||
}
|
||||
if (!(tons > 0)) {
|
||||
throw new BadRequestException('Cargo weight in tons is required.');
|
||||
}
|
||||
this.assertWagonShareFits(cargoType, tons, wagons);
|
||||
return {
|
||||
cargoTotalWeightVgm: tons,
|
||||
bulkTotalWeightTons: null,
|
||||
bulkRequestedWagons: wagons,
|
||||
bulkItemCount: items > 0 ? Math.floor(items) : null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* NUMBER_OF_WAGONS: block the booking outright when the even per-wagon share
|
||||
* (tons ÷ requested wagons) is heavier than what ANY of the cargo's allowed
|
||||
* wagon types may carry — 100T on 2 wagons is 50T each and fine on a 60T
|
||||
* wagon, but 100T on 1 wagon can never ride. Cargo types with no wagon types
|
||||
* configured skip the check (allocation falls back to the default rating).
|
||||
*/
|
||||
private assertWagonShareFits(
|
||||
cargoType: CargoType,
|
||||
tons: number,
|
||||
wagons: number,
|
||||
): void {
|
||||
const allowed = (cargoType.wagonTypes ?? []).filter(
|
||||
(wt) => Number(wt.capacityTons) > 0,
|
||||
);
|
||||
if (!allowed.length) return;
|
||||
const maxPerWagon = Math.max(
|
||||
...allowed.map((wt) =>
|
||||
bulkTonsPerWagon(cargoType, wt.id, Number(wt.capacityTons)),
|
||||
),
|
||||
);
|
||||
const share = tons / wagons;
|
||||
if (share > maxPerWagon) {
|
||||
throw new BadRequestException(
|
||||
`${tons} tons across ${wagons} wagon(s) loads ${round3(share)}T per wagon, ` +
|
||||
`but a wagon of this cargo carries at most ${round3(maxPerWagon)}T — ` +
|
||||
`request at least ${Math.ceil(tons / maxPerWagon)} wagons.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-line handling counts. Each physical container carries its own hazardous
|
||||
* / reefer / return switch (entered next to its VGM), so the count is however
|
||||
@@ -2184,8 +2395,7 @@ export class ContractBookingService {
|
||||
contractRouteId: route?.id ?? null,
|
||||
originYardId: route?.originYardId ?? null,
|
||||
destinationYardId: route?.destinationYardId ?? null,
|
||||
cargoTotalWeightVgm: this.resolveBulkTons(dto),
|
||||
bulkTotalWeightTons: this.resolveBulkWeightTons(dto),
|
||||
...(await this.resolveBulkCargoFields(contract, dto)),
|
||||
firstMilePickupAddress: contract.firstMilePickupAddress ?? null,
|
||||
lastMileDeliveryAddress: contract.lastMileDeliveryAddress ?? null,
|
||||
bookingContainers: resolved.map(({ line, ct, totalVgmTons }) =>
|
||||
|
||||
@@ -428,6 +428,8 @@ export class ContractTransitionService {
|
||||
contract.customsClearingEnabled,
|
||||
// Bulk templates are keyed by the contract's cargo type.
|
||||
(contract.cargoScope ?? []).find((c) => c.cargoTypeId)?.cargoTypeId,
|
||||
// Ethiopian-customs-only service types resolve to the Ethiopian variant.
|
||||
contract.serviceType?.includesEthiopianCustomsOnly,
|
||||
);
|
||||
if (!active) return null;
|
||||
return {
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsDateString,
|
||||
IsEmail,
|
||||
IsIn,
|
||||
IsInt,
|
||||
IsNumber,
|
||||
@@ -11,6 +12,7 @@ import {
|
||||
IsString,
|
||||
IsUUID,
|
||||
Matches,
|
||||
MaxLength,
|
||||
Min,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
@@ -208,6 +210,19 @@ export class CreateBookingUnderContractDto {
|
||||
@Type(() => CreateBulkLineDto)
|
||||
bulkLines?: CreateBulkLineDto[];
|
||||
|
||||
@ApiPropertyOptional({
|
||||
minimum: 1,
|
||||
description:
|
||||
'NUMBER_OF_WAGONS bulk cargo only: how many wagons the shipment needs. ' +
|
||||
'The weight spreads evenly across them; a PER_WAGON rate bills this count. ' +
|
||||
'Required when the cargo type is measured by wagons, ignored otherwise.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Transform(({ value }) => (value == null || value === '' ? undefined : Number(value)))
|
||||
requestedWagons?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'What the containers carry — captured per booking (container freight).',
|
||||
})
|
||||
@@ -215,6 +230,29 @@ export class CreateBookingUnderContractDto {
|
||||
@IsString()
|
||||
cargoFreeText?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
maxLength: 200,
|
||||
description:
|
||||
'Customs clearing agent name. Required at completion of a without-customs ' +
|
||||
'import/export booking (the service enforces it); ignored on customs contracts.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
customsClearingAgent?: string;
|
||||
|
||||
@ApiPropertyOptional({ maxLength: 200, description: 'Customs clearing agent email.' })
|
||||
@IsOptional()
|
||||
@IsEmail()
|
||||
@MaxLength(200)
|
||||
customsClearingAgentEmail?: string;
|
||||
|
||||
@ApiPropertyOptional({ maxLength: 50, description: 'Customs clearing agent phone number.' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(50)
|
||||
customsClearingAgentPhone?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
|
||||
@@ -63,6 +63,12 @@ describe('shipment preview / created booking parity', () => {
|
||||
resolveShipmentEquipmentReturn: () => c.equipmentReturn,
|
||||
resolveBulkTons: () => 0,
|
||||
resolveBulkWeightTons: () => 0,
|
||||
resolveBulkCargoFields: async () => ({
|
||||
cargoTotalWeightVgm: 0,
|
||||
bulkTotalWeightTons: null,
|
||||
bulkRequestedWagons: null,
|
||||
bulkItemCount: null,
|
||||
}),
|
||||
resolveContainerTypeForSize: async () => ({ id: 'ct40', sizeFt: 40 }),
|
||||
handlingCounts: () => ({
|
||||
hazardousQuantity: 0,
|
||||
|
||||
@@ -118,7 +118,11 @@ export class EimsSellerCacheService implements OnModuleInit {
|
||||
woreda: data.woreda,
|
||||
});
|
||||
this.cached = {
|
||||
LegalName: data.companyName || undefined,
|
||||
// The *legal* entity name, not the licence's trade name that
|
||||
// `data.companyName` now carries — an EIMS seller is filed under its
|
||||
// registered name.
|
||||
LegalName:
|
||||
companyInfo?.BusinessName?.trim() || data.companyName || undefined,
|
||||
Phone: data.mobilePhone || data.regularPhone || undefined,
|
||||
Region: geo?.Region,
|
||||
Wereda: geo?.Wereda,
|
||||
|
||||
@@ -3,7 +3,7 @@ import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { CurrentUser } from '@edr/api-common';
|
||||
import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard';
|
||||
import { FreightJwtGuard } from '../../common/freight-jwt.guard';
|
||||
import type { Response } from 'express';
|
||||
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
||||
|
||||
@@ -57,7 +57,7 @@ const toCatalogEntry = (dataset: ExportDataset): ExportCatalogEntry => ({
|
||||
@ApiTags('Exports')
|
||||
@ApiBearerAuth()
|
||||
@Controller('exports')
|
||||
@UseGuards(JwtGuard)
|
||||
@UseGuards(FreightJwtGuard)
|
||||
export class ExportsController {
|
||||
constructor(
|
||||
private readonly runner: ExportRunnerService,
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
import { JwtGuard } from "@tria-plc/api-common/modules/auth/services/jwt.guard";
|
||||
import { FreightJwtGuard } from "../../common/freight-jwt.guard";
|
||||
|
||||
import {
|
||||
AuthUserPayload,
|
||||
@@ -21,7 +21,7 @@ import { NotificationInboxService } from "./notification-inbox.service";
|
||||
|
||||
@ApiTags("notifications")
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtGuard)
|
||||
@UseGuards(FreightJwtGuard)
|
||||
@Controller("notifications")
|
||||
export class NotificationInboxController {
|
||||
constructor(private readonly service: NotificationInboxService) {}
|
||||
|
||||
@@ -34,10 +34,6 @@ import {
|
||||
directionScopeSql,
|
||||
} from "../user-trade-access/trade-scope.util";
|
||||
|
||||
/** Bookings carry a contract_kind column; GENERAL = umbrella contract row, not a shipment. */
|
||||
const EXCLUDE_GENERAL_CONTRACT_BOOKINGS =
|
||||
"(booking.contract_kind IS NULL OR booking.contract_kind <> 'GENERAL')";
|
||||
|
||||
export type OverviewBookingKpisRow = {
|
||||
total: number;
|
||||
totalActive: number;
|
||||
@@ -147,7 +143,6 @@ export class OverviewRepository {
|
||||
"submittedToday",
|
||||
)
|
||||
.where("booking.deleted_at IS NULL")
|
||||
.andWhere(EXCLUDE_GENERAL_CONTRACT_BOOKINGS)
|
||||
.andWhere(scope.sql, scope.params)
|
||||
.setParameters({
|
||||
closedStatuses: [...OVERVIEW_CLOSED_STATUSES],
|
||||
@@ -337,7 +332,6 @@ export class OverviewRepository {
|
||||
.select(`to_char(booking.created_at::date, 'YYYY-MM-DD')`, "date")
|
||||
.addSelect("COUNT(*)::int", "count")
|
||||
.where("booking.deleted_at IS NULL")
|
||||
.andWhere(EXCLUDE_GENERAL_CONTRACT_BOOKINGS)
|
||||
.andWhere(scope.sql, scope.params)
|
||||
.andWhere(`booking.created_at >= CURRENT_DATE - :days::int + 1`, { days })
|
||||
.groupBy("booking.created_at::date")
|
||||
@@ -357,7 +351,6 @@ export class OverviewRepository {
|
||||
.select("booking.status", "status")
|
||||
.addSelect("COUNT(*)::int", "count")
|
||||
.where("booking.deleted_at IS NULL")
|
||||
.andWhere(EXCLUDE_GENERAL_CONTRACT_BOOKINGS)
|
||||
.andWhere(scope.sql, scope.params)
|
||||
.groupBy("booking.status")
|
||||
.getRawMany<{ status: string; count: string }>();
|
||||
@@ -427,7 +420,6 @@ export class OverviewRepository {
|
||||
.addSelect("booking.payment_currency", "paymentCurrency")
|
||||
.addSelect("booking.created_at", "createdAt")
|
||||
.where("booking.deleted_at IS NULL")
|
||||
.andWhere(EXCLUDE_GENERAL_CONTRACT_BOOKINGS)
|
||||
.andWhere(scope.sql, scope.params)
|
||||
.orderBy("booking.created_at", "DESC")
|
||||
.limit(limit)
|
||||
@@ -463,7 +455,6 @@ export class OverviewRepository {
|
||||
.select("booking.freight_type", "label")
|
||||
.addSelect("COUNT(*)::int", "count")
|
||||
.where("booking.deleted_at IS NULL")
|
||||
.andWhere(EXCLUDE_GENERAL_CONTRACT_BOOKINGS)
|
||||
.andWhere("booking.status != 'DRAFT'")
|
||||
.andWhere(scope.sql, scope.params)
|
||||
.groupBy("booking.freight_type")
|
||||
@@ -485,7 +476,6 @@ export class OverviewRepository {
|
||||
.select("booking.payment_currency", "label")
|
||||
.addSelect("COUNT(*)::int", "count")
|
||||
.where("booking.deleted_at IS NULL")
|
||||
.andWhere(EXCLUDE_GENERAL_CONTRACT_BOOKINGS)
|
||||
.andWhere("booking.status != 'DRAFT'")
|
||||
.andWhere(scope.sql, scope.params)
|
||||
.groupBy("booking.payment_currency")
|
||||
@@ -602,7 +592,6 @@ export class OverviewRepository {
|
||||
this.bookingRepository
|
||||
.createQueryBuilder("booking")
|
||||
.where("booking.deleted_at IS NULL")
|
||||
.andWhere(EXCLUDE_GENERAL_CONTRACT_BOOKINGS)
|
||||
.andWhere(bookingScope.sql, bookingScope.params)
|
||||
.andWhere(windowSql("booking.created_at"), { days, offsetDays })
|
||||
.getCount(),
|
||||
@@ -802,7 +791,6 @@ export class OverviewRepository {
|
||||
.addSelect("FLOOR(EXTRACT(HOUR FROM booking.created_at) / 3)::int", "block")
|
||||
.addSelect("COUNT(*)::int", "count")
|
||||
.where("booking.deleted_at IS NULL")
|
||||
.andWhere(EXCLUDE_GENERAL_CONTRACT_BOOKINGS)
|
||||
.andWhere(scope.sql, scope.params)
|
||||
.andWhere(`booking.created_at >= CURRENT_DATE - :days::int + 1`, { days })
|
||||
.groupBy("EXTRACT(ISODOW FROM booking.created_at)::int")
|
||||
@@ -1457,7 +1445,6 @@ export class OverviewRepository {
|
||||
ON y.id = CASE WHEN b.trade_direction = 'EXPORT'
|
||||
THEN b.destination_yard_id ELSE b.origin_yard_id END
|
||||
WHERE b.deleted_at IS NULL
|
||||
AND (b.contract_kind IS NULL OR b.contract_kind <> 'GENERAL')
|
||||
AND b.created_at >= NOW() - make_interval(days => $1::int)
|
||||
GROUP BY 1
|
||||
ORDER BY count DESC
|
||||
@@ -1475,7 +1462,6 @@ export class OverviewRepository {
|
||||
ON y.id = CASE WHEN b.trade_direction = 'EXPORT'
|
||||
THEN b.destination_yard_id ELSE b.origin_yard_id END
|
||||
WHERE b.deleted_at IS NULL
|
||||
AND (b.contract_kind IS NULL OR b.contract_kind <> 'GENERAL')
|
||||
AND b.created_at >= NOW() - make_interval(days => $1::int)
|
||||
GROUP BY 1, 2
|
||||
ORDER BY 1, 2
|
||||
|
||||
@@ -2,8 +2,10 @@ import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
|
||||
|
||||
import { Company } from '../../companies/entities/company.entity';
|
||||
import { Invoice } from '../../billing/entities/invoice.entity';
|
||||
import { ShippingLineCompany } from '../../shipping-lines/entities/shipping-line-company.entity';
|
||||
import { applyBookingRefDirectionScope } from '../../user-trade-access/trade-scope.util';
|
||||
import { ReportContext, ReportDefinition } from '../report.types';
|
||||
import { CURRENCY_FILTER, PAYER_EXPR, currencyOf } from '../revenue-classification';
|
||||
|
||||
const OPEN_STATUSES = ['ISSUED', 'PENDING', 'PARTIALLY_PAID', 'OVERDUE'];
|
||||
|
||||
@@ -13,13 +15,22 @@ function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
|
||||
// to now() in SQL when the filter is unset (see the COALESCE below).
|
||||
const asOf = (params.asOf as string | null) ?? null;
|
||||
|
||||
// Both payer joins are LEFT: an invoice billed to a shipping line carries no
|
||||
// company, and an INNER join on `companies` silently drops its balance out of
|
||||
// the arrears total.
|
||||
const qb = ctx.ds
|
||||
.createQueryBuilder()
|
||||
.from(Invoice, 'i')
|
||||
.innerJoin(Company, 'c', 'c.id = i.company_id')
|
||||
.leftJoin(Company, 'c', 'c.id = i.company_id')
|
||||
.leftJoin(ShippingLineCompany, 'slc', 'slc.id = i.shipping_line_company_id')
|
||||
.where('i.deleted_at IS NULL')
|
||||
.andWhere('i.status IN (:...openStatuses)', { openStatuses: OPEN_STATUSES })
|
||||
.andWhere('i.balance_amount > 0')
|
||||
// Stored casing has drifted ("usd" rows exist), and one arrears figure
|
||||
// cannot span two currencies.
|
||||
.andWhere('UPPER(i.currency) = :currency', {
|
||||
currency: currencyOf(params).toUpperCase(),
|
||||
})
|
||||
.setParameter('asOf', asOf);
|
||||
|
||||
// ACL: invoices.source_id is a varchar pointer at the originating booking.
|
||||
@@ -32,9 +43,15 @@ export const agingReceivablesReport: ReportDefinition = {
|
||||
title: 'Aging Receivables',
|
||||
description: 'Outstanding customer balances bucketed by days overdue',
|
||||
group: 'Finance',
|
||||
filters: [{ key: 'asOf', label: 'As of', type: 'date' }],
|
||||
filters: [{ key: 'asOf', label: 'As of', type: 'date' }, CURRENCY_FILTER],
|
||||
columns: [
|
||||
{ key: 'customer', label: 'Customer', type: 'string', sortable: true, sortExpr: 'c.name' },
|
||||
{
|
||||
key: 'customer',
|
||||
label: 'Customer',
|
||||
type: 'string',
|
||||
sortable: true,
|
||||
sortExpr: PAYER_EXPR,
|
||||
},
|
||||
{ key: 'invoices', label: 'Invoices', type: 'number' },
|
||||
{ key: 'outstanding', label: 'Outstanding', type: 'money', sortable: true },
|
||||
{ key: 'current', label: 'Current', type: 'money' },
|
||||
@@ -46,7 +63,7 @@ export const agingReceivablesReport: ReportDefinition = {
|
||||
defaultSort: { key: 'outstanding', dir: 'DESC' },
|
||||
query(ctx) {
|
||||
return baseQuery(ctx)
|
||||
.select('c.name', 'customer')
|
||||
.select(PAYER_EXPR, 'customer')
|
||||
.addSelect('COUNT(*)::int', 'invoices')
|
||||
.addSelect('ROUND(SUM(i.balance_amount))::float8', 'outstanding')
|
||||
.addSelect(
|
||||
@@ -72,15 +89,19 @@ export const agingReceivablesReport: ReportDefinition = {
|
||||
`ROUND(COALESCE(SUM(i.balance_amount) FILTER (WHERE i.due_at < COALESCE(:asOf::timestamptz, now()) - interval '90 days'), 0))::float8`,
|
||||
'overdue90plus',
|
||||
)
|
||||
.groupBy('c.name');
|
||||
.groupBy(PAYER_EXPR);
|
||||
},
|
||||
async summary(ctx) {
|
||||
const row = await baseQuery(ctx)
|
||||
.select('ROUND(COALESCE(SUM(i.balance_amount), 0))::float8', 'outstanding')
|
||||
.addSelect('COUNT(DISTINCT c.id)::int', 'customers')
|
||||
.addSelect(`COUNT(DISTINCT ${PAYER_EXPR})::int`, 'customers')
|
||||
.getRawOne();
|
||||
return [
|
||||
{ label: 'Outstanding', value: Number(row?.outstanding ?? 0), unit: 'ETB' },
|
||||
{
|
||||
label: 'Outstanding',
|
||||
value: Number(row?.outstanding ?? 0),
|
||||
unit: currencyOf(ctx.params),
|
||||
},
|
||||
{ label: 'Customers with balance', value: Number(row?.customers ?? 0) },
|
||||
];
|
||||
},
|
||||
|
||||
@@ -4,19 +4,20 @@ import { Yard } from '../../rule-engine/entities/yard.entity';
|
||||
import { ReportContext, ReportDefinition } from '../report.types';
|
||||
import {
|
||||
ACTUAL_TONS_EXPR,
|
||||
CARGO_CATEGORY_EXPR,
|
||||
CARGO_CATEGORY_FILTER,
|
||||
CARGO_CATEGORY_LABEL_EXPR,
|
||||
ALLOC_CONTAINERS_20,
|
||||
ALLOC_CONTAINERS_40,
|
||||
CHARGED_TONS_EXPR,
|
||||
LOADED_WAGONS_EXPR,
|
||||
OPERATIONS_FILTERS,
|
||||
SCHEDULE_EMPTY_WAGONS,
|
||||
REVENUE_CARGO_CATEGORY_EXPR,
|
||||
REVENUE_CARGO_FILTER,
|
||||
SCHEDULE_KM_EXPR,
|
||||
TEU_EXPR,
|
||||
allocationLedgerQb,
|
||||
applyCategoryFilter,
|
||||
distanceKmBetween,
|
||||
} from '../operations-classification';
|
||||
import { CATEGORY_LABEL_OF } from '../revenue-classification';
|
||||
|
||||
/**
|
||||
* A leg is one station-to-station move the train actually made: two consecutive
|
||||
@@ -47,13 +48,34 @@ const LEG_FROM = 'COALESCE(leg.from_yard_id, ts.origin_station_id)';
|
||||
const LEG_TO = 'COALESCE(leg.to_yard_id, ts.destination_station_id)';
|
||||
|
||||
/**
|
||||
* Distance and empty-wagon count are constant within a group that includes
|
||||
* `ts.id` and the leg — MAX() satisfies Postgres without dragging a scalar
|
||||
* subselect through the GROUP BY.
|
||||
* Distance is constant within a group that includes `ts.id` and the leg —
|
||||
* MAX() satisfies Postgres without dragging a scalar subselect through the
|
||||
* GROUP BY.
|
||||
*/
|
||||
const LEG_KM = `MAX(${distanceKmBetween(LEG_FROM, LEG_TO)})`;
|
||||
const EMPTY_WAGONS = `MAX(${SCHEDULE_EMPTY_WAGONS})`;
|
||||
const TOTAL_WAGONS = `(COUNT(DISTINCT tsw.id) + ${EMPTY_WAGONS})::int`;
|
||||
|
||||
/**
|
||||
* The ledger carries the empty wagons as rows of their own, so both counts are
|
||||
* plain aggregates over the group: an empty-wagon row has no loaded wagons and
|
||||
* a cargo row has no empty ones. Read down a departure's rows and its wagons
|
||||
* add up once, instead of every row repeating the train's empty total.
|
||||
*/
|
||||
const EMPTY_WAGONS = 'COUNT(DISTINCT tsw.id) FILTER (WHERE wba.id IS NULL)';
|
||||
const TOTAL_WAGONS = 'COUNT(DISTINCT tsw.id)::int';
|
||||
|
||||
/**
|
||||
* Cargo in the revenue vocabulary, plus the wagon that carried none.
|
||||
*
|
||||
* The label is built out of the key expression rather than beside it: Postgres
|
||||
* only accepts an aggregate-query column inside a GROUP BY expression it can
|
||||
* match verbatim, so a second `wba.id IS NULL` test of its own would demand
|
||||
* `wba.id` in the GROUP BY — which would split the grain down to one row per
|
||||
* allocation.
|
||||
*/
|
||||
const CATEGORY_EXPR = `CASE WHEN wba.id IS NULL THEN 'EMPTY_WAGON'
|
||||
ELSE ${REVENUE_CARGO_CATEGORY_EXPR} END`;
|
||||
const CATEGORY_LABEL_EXPR = `CASE WHEN (${CATEGORY_EXPR}) = 'EMPTY_WAGON' THEN 'Empty wagon'
|
||||
ELSE ${CATEGORY_LABEL_OF(CATEGORY_EXPR)} END`;
|
||||
|
||||
/**
|
||||
* Ton/Km and Vehicle-Km are NULL — not zero — when the yard pair has no
|
||||
@@ -64,8 +86,8 @@ const TON_KM = `ROUND((${CHARGED_TONS_EXPR})::numeric * ${LEG_KM}, 1)::float8`;
|
||||
const VEHICLE_KM = `ROUND(${EMPTY_WAGONS}::numeric * ${LEG_KM}, 1)::float8`;
|
||||
|
||||
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
|
||||
const qb = allocationLedgerQb(ctx);
|
||||
applyCategoryFilter(qb, ctx.params);
|
||||
const qb = allocationLedgerQb(ctx, { includeEmptyWagons: true });
|
||||
applyCategoryFilter(qb, ctx.params, CATEGORY_EXPR);
|
||||
return qb;
|
||||
}
|
||||
|
||||
@@ -89,20 +111,25 @@ export const chargedVsActualVolumeReport: ReportDefinition = {
|
||||
'corridor. Charged volume is the standard weight capacity — 20 and 40 tons per laden ' +
|
||||
'container, 2.24 and 3.88 empty, 70 tons per wagon of steel or fertilizer, 38 for ' +
|
||||
'perishables — all editable in Operating standards. Actual volume is what the ' +
|
||||
'marshalling recorded. Volumes and wagon counts belong to the train, not to the leg, ' +
|
||||
'so they repeat on every leg it ran and across its cargo types rather than being split ' +
|
||||
'between them — the KPIs above count each train once. Ton/Km and Vehicle-Km are the ' +
|
||||
'exception and are the leg’s own, so they add up across legs into the real corridor ' +
|
||||
'figure.',
|
||||
'marshalling recorded. Cargo types are the revenue categories the money side bills ' +
|
||||
'against, so a corridor’s tonnage and its revenue read in the same buckets; wagons ' +
|
||||
'that carried nothing are their own “Empty wagon” line. Volumes and wagon counts ' +
|
||||
'belong to the train, not to the leg, so they repeat on every leg it ran rather than ' +
|
||||
'being split between them — the KPIs above count each train once. Ton/Km and ' +
|
||||
'Vehicle-Km are the exception and are the leg’s own, so they add up across legs into ' +
|
||||
'the real corridor figure.',
|
||||
group: 'Operations',
|
||||
filters: [...OPERATIONS_FILTERS, CARGO_CATEGORY_FILTER],
|
||||
filters: [...OPERATIONS_FILTERS, REVENUE_CARGO_FILTER],
|
||||
columns: [
|
||||
{ key: 'trainNumber', label: 'Train No.', type: 'string', sortable: true, sortExpr: 'ts.train_number' },
|
||||
{ key: 'departedAt', label: 'Departure', type: 'date', sortable: true, sortExpr: 'ts.scheduled_departure_date' },
|
||||
{ key: 'leg', label: 'Leg', type: 'string' },
|
||||
{ key: 'category', label: 'Cargo type', type: 'string', sortable: true, sortExpr: CARGO_CATEGORY_EXPR },
|
||||
{ key: 'legFrom', label: 'From', type: 'string', sortable: true, sortExpr: 'COALESCE(lfy.label, lfy.code)' },
|
||||
{ key: 'legTo', label: 'To', type: 'string', sortable: true, sortExpr: 'COALESCE(lty.label, lty.code)' },
|
||||
{ key: 'category', label: 'Cargo type', type: 'string', sortable: true, sortExpr: CATEGORY_EXPR },
|
||||
{ key: 'chargedTons', label: 'Charged volume', type: 'tons', sortable: true },
|
||||
{ key: 'actualTons', label: 'Actual volume', type: 'tons', sortable: true },
|
||||
{ key: 'containers20', label: '20ft', type: 'number', sortable: true },
|
||||
{ key: 'containers40', label: '40ft', type: 'number', sortable: true },
|
||||
{ key: 'teu', label: 'TEU', type: 'number' },
|
||||
{ key: 'wagons', label: 'Loaded wagons', type: 'number' },
|
||||
{ key: 'emptyWagons', label: 'Empty wagons', type: 'number' },
|
||||
@@ -116,10 +143,13 @@ export const chargedVsActualVolumeReport: ReportDefinition = {
|
||||
return legQuery(ctx)
|
||||
.select("COALESCE(ts.train_number, '—')", 'trainNumber')
|
||||
.addSelect(`to_char(COALESCE(ts.actual_departure_at, ts.scheduled_departure_date), 'YYYY-MM-DD HH24:MI')`, 'departedAt')
|
||||
.addSelect("COALESCE(lfy.label, lfy.code, '?') || ' → ' || COALESCE(lty.label, lty.code, '?')", 'leg')
|
||||
.addSelect(CARGO_CATEGORY_LABEL_EXPR, 'category')
|
||||
.addSelect("COALESCE(lfy.label, lfy.code, '?')", 'legFrom')
|
||||
.addSelect("COALESCE(lty.label, lty.code, '?')", 'legTo')
|
||||
.addSelect(CATEGORY_LABEL_EXPR, 'category')
|
||||
.addSelect(`ROUND((${CHARGED_TONS_EXPR})::numeric, 2)::float8`, 'chargedTons')
|
||||
.addSelect(`ROUND((${ACTUAL_TONS_EXPR})::numeric, 2)::float8`, 'actualTons')
|
||||
.addSelect(`COALESCE(SUM(${ALLOC_CONTAINERS_20}), 0)::int`, 'containers20')
|
||||
.addSelect(`COALESCE(SUM(${ALLOC_CONTAINERS_40}), 0)::int`, 'containers40')
|
||||
.addSelect(TEU_EXPR, 'teu')
|
||||
.addSelect(LOADED_WAGONS_EXPR, 'wagons')
|
||||
.addSelect(`${EMPTY_WAGONS}::int`, 'emptyWagons')
|
||||
@@ -137,7 +167,7 @@ export const chargedVsActualVolumeReport: ReportDefinition = {
|
||||
.addGroupBy('lfy.code')
|
||||
.addGroupBy('lty.label')
|
||||
.addGroupBy('lty.code')
|
||||
.addGroupBy(CARGO_CATEGORY_EXPR);
|
||||
.addGroupBy(CATEGORY_EXPR);
|
||||
},
|
||||
async summary(ctx) {
|
||||
const row = await baseQuery(ctx)
|
||||
|
||||
@@ -2,19 +2,34 @@ import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
|
||||
|
||||
import { Freight } from '@edr/types';
|
||||
import { Invoice } from '../../billing/entities/invoice.entity';
|
||||
import { applyBookingRefDirectionScope } from '../../user-trade-access/trade-scope.util';
|
||||
import { ReportContext, ReportDefinition } from '../report.types';
|
||||
import { CURRENCY_FILTER, currencyOf } from '../revenue-classification';
|
||||
|
||||
const STATUS_OPTIONS = Object.values(Freight.InvoiceStatus).map((v) => ({ value: v, label: v }));
|
||||
const STATUS_OPTIONS = Object.values(Freight.InvoiceStatus).map((v) => ({
|
||||
value: v,
|
||||
label: v,
|
||||
}));
|
||||
|
||||
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
|
||||
const { params } = ctx;
|
||||
const qb = ctx.ds.createQueryBuilder().from(Invoice, 'i').where('i.deleted_at IS NULL');
|
||||
const { params, directions } = ctx;
|
||||
const qb = ctx.ds
|
||||
.createQueryBuilder()
|
||||
.from(Invoice, 'i')
|
||||
.where('i.deleted_at IS NULL')
|
||||
// Both currencies live in this table; one money column cannot hold both.
|
||||
.andWhere('UPPER(i.currency) = :currency', {
|
||||
currency: currencyOf(params).toUpperCase(),
|
||||
});
|
||||
|
||||
if (params.dateFrom) qb.andWhere('i.created_at >= :dateFrom', { dateFrom: params.dateFrom });
|
||||
if (params.dateTo) qb.andWhere('i.created_at < :dateTo', { dateTo: params.dateTo });
|
||||
const statuses = params.statuses as string[] | null;
|
||||
if (statuses) qb.andWhere('i.status IN (:...statuses)', { statuses });
|
||||
return qb;
|
||||
|
||||
// Every other Finance report scopes by the caller's trade directions; without
|
||||
// it this one reports the value of invoices its reader may not see.
|
||||
return applyBookingRefDirectionScope(qb, 'i.source_id', directions);
|
||||
}
|
||||
|
||||
export const invoicingPipelineReport: ReportDefinition = {
|
||||
@@ -24,7 +39,13 @@ export const invoicingPipelineReport: ReportDefinition = {
|
||||
group: 'Finance',
|
||||
filters: [
|
||||
{ key: 'date', label: 'Created', type: 'daterange' },
|
||||
{ key: 'statuses', label: 'Status', type: 'multiselect', options: STATUS_OPTIONS },
|
||||
CURRENCY_FILTER,
|
||||
{
|
||||
key: 'statuses',
|
||||
label: 'Status',
|
||||
type: 'multiselect',
|
||||
options: STATUS_OPTIONS,
|
||||
},
|
||||
],
|
||||
columns: [
|
||||
{ key: 'type', label: 'Type', type: 'string', sortable: true },
|
||||
@@ -52,8 +73,16 @@ export const invoicingPipelineReport: ReportDefinition = {
|
||||
.getRawOne();
|
||||
return [
|
||||
{ label: 'Invoices', value: Number(row?.invoices ?? 0) },
|
||||
{ label: 'Total value', value: Number(row?.totalAmount ?? 0), unit: 'ETB' },
|
||||
{ label: 'Outstanding', value: Number(row?.balance ?? 0), unit: 'ETB' },
|
||||
{
|
||||
label: 'Total value',
|
||||
value: Number(row?.totalAmount ?? 0),
|
||||
unit: currencyOf(ctx.params),
|
||||
},
|
||||
{
|
||||
label: 'Outstanding',
|
||||
value: Number(row?.balance ?? 0),
|
||||
unit: currencyOf(ctx.params),
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { visibleColumns } from '../report-runner.service';
|
||||
import { loadingUnloadingReport as def } from './loading-unloading.report';
|
||||
|
||||
/**
|
||||
* The two grains select different columns — per train the stop's own times,
|
||||
* per station the averages over it. A column shown under a grain its query
|
||||
* doesn't select is a blank column; a column SORTED under one is a 42703.
|
||||
*/
|
||||
describe('loading-unloading', () => {
|
||||
const keys = (grain: string) => visibleColumns(def, { grain }).map((c) => c.key);
|
||||
|
||||
it('shows the stop times per train and the averages per station, never both', () => {
|
||||
expect(keys('train')).toEqual(expect.arrayContaining(['arrivedAt', 'loadUnloadHours']));
|
||||
expect(keys('train')).not.toEqual(expect.arrayContaining(['avgLoadUnloadHours', 'stops']));
|
||||
expect(keys('station')).toEqual(expect.arrayContaining(['avgLoadUnloadHours', 'stops']));
|
||||
expect(keys('station')).not.toEqual(expect.arrayContaining(['arrivedAt', 'trainNumber']));
|
||||
});
|
||||
|
||||
it('sorts by a column both grains select, so the default sort never 42703s', () => {
|
||||
for (const grain of ['train', 'station']) {
|
||||
expect(keys(grain)).toContain(def.defaultSort!.key);
|
||||
}
|
||||
});
|
||||
|
||||
it("defaults the grain, so an unset filter can't show the wrong half", () => {
|
||||
const grain = def.filters.find((f) => f.key === 'grain')!.defaultValue;
|
||||
expect(grain).toBe('train');
|
||||
expect(keys(grain!)).toEqual(keys('train'));
|
||||
});
|
||||
});
|
||||
@@ -6,8 +6,10 @@ import {
|
||||
cycleRateExpr,
|
||||
handlingHours,
|
||||
hoursBetween,
|
||||
loadingEnd,
|
||||
loadingHours,
|
||||
loadingSource,
|
||||
loadingStart,
|
||||
otherActivityHours,
|
||||
stationStaysQb,
|
||||
unloadingHours,
|
||||
@@ -18,9 +20,12 @@ import { PERIOD_FILTER, periodExprOn, periodTruncExprOn } from '../revenue-class
|
||||
* Loading and unloading per train — the spec's own report format: train number,
|
||||
* total loading and unloading time, other activity, station staying time.
|
||||
*
|
||||
* The staying-time report publishes one row per individual stop; this one rolls
|
||||
* a train's stops up into the chosen period, which is what "for week report,
|
||||
* calculate average in the week" asks for. The station stays in the grain
|
||||
* Two shapes, one definition. Per train the row is the stop itself: the logged
|
||||
* arrival, departure, unloading and loading times and that stop's own
|
||||
* durations, because the train number is what makes a specific stop worth
|
||||
* naming. Per station it rolls up into the chosen period — one row per station,
|
||||
* averaged over every train that called there, which is what "for week report,
|
||||
* calculate average in the week" asks for. The station stays in both grains
|
||||
* because a train works both ends of the corridor and the standard it is judged
|
||||
* against differs by side (10h Ethiopia, 13h Djibouti) — averaging a train's
|
||||
* Nagad and Gelan stops together would compare that mixture to one standard.
|
||||
@@ -56,12 +61,20 @@ const GRAIN_FILTER: ReportFilterDef = {
|
||||
key: 'grain',
|
||||
label: 'Group by',
|
||||
type: 'select',
|
||||
defaultValue: 'train',
|
||||
options: [
|
||||
{ value: 'train', label: 'Train' },
|
||||
{ value: 'station', label: 'Station' },
|
||||
],
|
||||
};
|
||||
|
||||
/** Per train the rows are stops, so they carry times; per station, averages. */
|
||||
const TRAIN_ONLY = { grain: 'station' };
|
||||
const STATION_ONLY = { grain: 'train' };
|
||||
|
||||
/** Same display as the staying-time report, so a stop reads alike in both. */
|
||||
const at = (expr: string): string => `to_char(${expr}, 'YYYY-MM-DD HH24:MI')`;
|
||||
|
||||
/** Whitelisted here, so the user's value never reaches SQL. */
|
||||
const byStation = (ctx: ReportContext): boolean => ctx.params.grain === 'station';
|
||||
|
||||
@@ -69,9 +82,10 @@ export const loadingUnloadingReport: ReportDefinition = {
|
||||
key: 'loading-unloading',
|
||||
title: 'Loading & Unloading',
|
||||
description:
|
||||
'Loading and unloading per train, at the granularity you choose — one row per train per ' +
|
||||
'station per period, which at week or month grain is that train’s average over its stops ' +
|
||||
'in the period, the way the OCC report publishes it. Total loading and unloading is ' +
|
||||
'Loading and unloading, at the granularity you choose. Grouped by Train the row is one ' +
|
||||
'stop — its logged arrival, departure, unloading and loading times and that stop’s own ' +
|
||||
'durations. Grouped by Station it is one row per station per period, averaged over every ' +
|
||||
'train that called there, the way the OCC report publishes it. Total loading and unloading is ' +
|
||||
'the stop’s handling window, unloading start to loading end, which is the container ' +
|
||||
'measure; the unloading and loading columns split it for bulk stations that only do ' +
|
||||
'one of the two (Nagad, BCC and DMP on the Djibouti side; Sebeta, GMP, Adama and Modjo ' +
|
||||
@@ -95,74 +109,241 @@ export const loadingUnloadingReport: ReportDefinition = {
|
||||
],
|
||||
columns: [
|
||||
{ key: 'period', label: 'Period', type: 'string', sortable: true },
|
||||
{ key: 'trainNumber', label: 'Train No.', type: 'string', sortable: true },
|
||||
{
|
||||
key: 'trainNumber',
|
||||
label: 'Train No.',
|
||||
type: 'string',
|
||||
sortable: true,
|
||||
hideWhen: TRAIN_ONLY,
|
||||
},
|
||||
{ key: 'station', label: 'Station', type: 'string', sortable: true },
|
||||
{ key: 'country', label: 'Country', type: 'string' },
|
||||
{ key: 'trainType', label: 'Train type', type: 'string' },
|
||||
{ key: 'stops', label: 'Stops', type: 'number', sortable: true },
|
||||
{ key: 'handlingMeasured', label: 'Handling measured', type: 'number' },
|
||||
// Per station this would be a MAX over whatever mix of trains called there.
|
||||
{
|
||||
key: 'trainType',
|
||||
label: 'Train type',
|
||||
type: 'string',
|
||||
hideWhen: TRAIN_ONLY,
|
||||
},
|
||||
{
|
||||
key: 'stops',
|
||||
label: 'Stops',
|
||||
type: 'number',
|
||||
sortable: true,
|
||||
hideWhen: STATION_ONLY,
|
||||
},
|
||||
{
|
||||
key: 'handlingMeasured',
|
||||
label: 'Handling measured',
|
||||
type: 'number',
|
||||
hideWhen: STATION_ONLY,
|
||||
},
|
||||
{ key: 'loadingSource', label: 'Loading from', type: 'string' },
|
||||
{ key: 'avgUnloadingHours', label: 'Avg unloading (hrs)', type: 'number', sortable: true },
|
||||
{ key: 'avgLoadingHours', label: 'Avg loading (hrs)', type: 'number', sortable: true },
|
||||
// Per train: this stop's own clock, not a mean of several.
|
||||
{
|
||||
key: 'arrivedAt',
|
||||
label: 'Arrived',
|
||||
type: 'date',
|
||||
sortable: true,
|
||||
hideWhen: TRAIN_ONLY,
|
||||
},
|
||||
{
|
||||
key: 'departedAt',
|
||||
label: 'Departed',
|
||||
type: 'date',
|
||||
hideWhen: TRAIN_ONLY,
|
||||
},
|
||||
{
|
||||
key: 'unloadingStartedAt',
|
||||
label: 'Unloading start',
|
||||
type: 'date',
|
||||
hideWhen: TRAIN_ONLY,
|
||||
},
|
||||
{
|
||||
key: 'unloadingCompletedAt',
|
||||
label: 'Unloading end',
|
||||
type: 'date',
|
||||
hideWhen: TRAIN_ONLY,
|
||||
},
|
||||
{
|
||||
key: 'loadingStartedAt',
|
||||
label: 'Loading start',
|
||||
type: 'date',
|
||||
hideWhen: TRAIN_ONLY,
|
||||
},
|
||||
{
|
||||
key: 'loadingCompletedAt',
|
||||
label: 'Loading end',
|
||||
type: 'date',
|
||||
hideWhen: TRAIN_ONLY,
|
||||
},
|
||||
{
|
||||
key: 'unloadingHours',
|
||||
label: 'Unloading (hrs)',
|
||||
type: 'number',
|
||||
sortable: true,
|
||||
hideWhen: TRAIN_ONLY,
|
||||
},
|
||||
{
|
||||
key: 'loadingHours',
|
||||
label: 'Loading (hrs)',
|
||||
type: 'number',
|
||||
sortable: true,
|
||||
hideWhen: TRAIN_ONLY,
|
||||
},
|
||||
{
|
||||
key: 'loadUnloadHours',
|
||||
label: 'Loading + unloading (hrs)',
|
||||
type: 'number',
|
||||
sortable: true,
|
||||
hideWhen: TRAIN_ONLY,
|
||||
},
|
||||
{
|
||||
key: 'otherActivityHours',
|
||||
label: 'Other activity (hrs)',
|
||||
type: 'number',
|
||||
hideWhen: TRAIN_ONLY,
|
||||
},
|
||||
{
|
||||
key: 'stayingHours',
|
||||
label: 'Staying (hrs)',
|
||||
type: 'number',
|
||||
sortable: true,
|
||||
hideWhen: TRAIN_ONLY,
|
||||
},
|
||||
{
|
||||
key: 'avgUnloadingHours',
|
||||
label: 'Avg unloading (hrs)',
|
||||
type: 'number',
|
||||
sortable: true,
|
||||
hideWhen: STATION_ONLY,
|
||||
},
|
||||
{
|
||||
key: 'avgLoadingHours',
|
||||
label: 'Avg loading (hrs)',
|
||||
type: 'number',
|
||||
sortable: true,
|
||||
hideWhen: STATION_ONLY,
|
||||
},
|
||||
{
|
||||
key: 'avgLoadUnloadHours',
|
||||
label: 'Avg loading + unloading (hrs)',
|
||||
type: 'number',
|
||||
sortable: true,
|
||||
hideWhen: STATION_ONLY,
|
||||
},
|
||||
{
|
||||
key: 'avgOtherActivityHours',
|
||||
label: 'Avg other activity (hrs)',
|
||||
type: 'number',
|
||||
hideWhen: STATION_ONLY,
|
||||
},
|
||||
{
|
||||
key: 'avgStayingHours',
|
||||
label: 'Avg staying (hrs)',
|
||||
type: 'number',
|
||||
sortable: true,
|
||||
hideWhen: STATION_ONLY,
|
||||
},
|
||||
{
|
||||
key: 'stayStandardHours',
|
||||
label: 'Staying standard (hrs)',
|
||||
type: 'number',
|
||||
},
|
||||
{ key: 'avgOtherActivityHours', label: 'Avg other activity (hrs)', type: 'number' },
|
||||
{ key: 'avgStayingHours', label: 'Avg staying (hrs)', type: 'number', sortable: true },
|
||||
{ key: 'stayStandardHours', label: 'Staying standard (hrs)', type: 'number' },
|
||||
{ key: 'stayVerdict', label: 'Staying verdict', type: 'string' },
|
||||
{ key: 'handlingStandardHours', label: 'Handling standard (hrs)', type: 'number' },
|
||||
{ key: 'handlingRate', label: 'Handling rate', type: 'percent', sortable: true },
|
||||
{
|
||||
key: 'handlingStandardHours',
|
||||
label: 'Handling standard (hrs)',
|
||||
type: 'number',
|
||||
},
|
||||
{
|
||||
key: 'handlingRate',
|
||||
label: 'Handling rate',
|
||||
type: 'percent',
|
||||
sortable: true,
|
||||
},
|
||||
],
|
||||
defaultSort: { key: 'period', dir: 'DESC' },
|
||||
chart: { type: 'bar', x: 'trainNumber', y: ['avgLoadUnloadHours'] },
|
||||
// Only plottable at station grain — per train the rows are individual stops,
|
||||
// and the frontend drops the chart toggle when its columns are hidden.
|
||||
chart: { type: 'bar', x: 'station', y: ['avgLoadUnloadHours'] },
|
||||
query(ctx) {
|
||||
const { params } = ctx;
|
||||
|
||||
// Per train the row IS the stop: its own logged times and its own
|
||||
// durations, since an average of one stop is just the stop with the clock
|
||||
// thrown away. Averaging starts where the grain stops naming the train.
|
||||
if (!byStation(ctx)) {
|
||||
return stationStaysQb(ctx)
|
||||
.select(periodExprOn('s.arrived_at', params), 'period')
|
||||
.addSelect(TRAIN_NUMBER, 'trainNumber')
|
||||
.addSelect('s.station', 'station')
|
||||
.addSelect('s.country', 'country')
|
||||
.addSelect('s.train_type', 'trainType')
|
||||
.addSelect(loadingSource('s'), 'loadingSource')
|
||||
.addSelect(at('s.arrived_at'), 'arrivedAt')
|
||||
.addSelect(at('s.departed_at'), 'departedAt')
|
||||
.addSelect(at('s.unloading_started_at'), 'unloadingStartedAt')
|
||||
.addSelect(at('s.unloading_completed_at'), 'unloadingCompletedAt')
|
||||
.addSelect(at(loadingStart('s')), 'loadingStartedAt')
|
||||
.addSelect(at(loadingEnd('s')), 'loadingCompletedAt')
|
||||
.addSelect(unloadingHours('s'), 'unloadingHours')
|
||||
.addSelect(loadingHours('s'), 'loadingHours')
|
||||
.addSelect(HANDLING_HOURS, 'loadUnloadHours')
|
||||
.addSelect(OTHER_ACTIVITY_HOURS, 'otherActivityHours')
|
||||
.addSelect(STAYING_HOURS, 'stayingHours')
|
||||
.addSelect('s.standard_hours::float8', 'stayStandardHours')
|
||||
.addSelect(
|
||||
`CASE WHEN (${STAYING_HOURS})::numeric <= s.standard_hours
|
||||
THEN 'Encouraging' ELSE 'Needs reason' END`,
|
||||
'stayVerdict',
|
||||
)
|
||||
.addSelect('s.handling_standard_hours::float8', 'handlingStandardHours')
|
||||
.addSelect(
|
||||
cycleRateExpr(`(${HANDLING_HOURS})::numeric`, 's.handling_standard_hours'),
|
||||
'handlingRate',
|
||||
);
|
||||
}
|
||||
|
||||
// Reused verbatim in the GROUP BY, per the trap documented on `periodExpr`.
|
||||
const bucket = periodTruncExprOn('s.arrived_at', params);
|
||||
|
||||
const perStation = byStation(ctx);
|
||||
const qb = stationStaysQb(ctx)
|
||||
.select(periodExprOn('s.arrived_at', params), 'period')
|
||||
.addSelect(perStation ? "'All trains'" : TRAIN_NUMBER, 'trainNumber')
|
||||
.addSelect('s.station', 'station')
|
||||
.addSelect('s.country', 'country')
|
||||
.addSelect('MAX(s.train_type)', 'trainType')
|
||||
.addSelect('COUNT(*)::int', 'stops')
|
||||
.addSelect(`COUNT(${HANDLING_HOURS})::int`, 'handlingMeasured')
|
||||
// Which side of the COALESCE the loading columns came from. A group that
|
||||
// mixes both says so rather than claiming either.
|
||||
.addSelect(
|
||||
`CASE WHEN COUNT(DISTINCT ${loadingSource('s')}) > 1 THEN 'Mixed'
|
||||
return (
|
||||
stationStaysQb(ctx)
|
||||
.select(periodExprOn('s.arrived_at', params), 'period')
|
||||
.addSelect('s.station', 'station')
|
||||
.addSelect('s.country', 'country')
|
||||
.addSelect('COUNT(*)::int', 'stops')
|
||||
.addSelect(`COUNT(${HANDLING_HOURS})::int`, 'handlingMeasured')
|
||||
// Which side of the COALESCE the loading columns came from. A group that
|
||||
// mixes both says so rather than claiming either.
|
||||
.addSelect(
|
||||
`CASE WHEN COUNT(DISTINCT ${loadingSource('s')}) > 1 THEN 'Mixed'
|
||||
ELSE MAX(${loadingSource('s')}) END`,
|
||||
'loadingSource',
|
||||
)
|
||||
.addSelect(avg(unloadingHours('s')), 'avgUnloadingHours')
|
||||
.addSelect(avg(loadingHours('s')), 'avgLoadingHours')
|
||||
.addSelect(avg(HANDLING_HOURS), 'avgLoadUnloadHours')
|
||||
.addSelect(avg(OTHER_ACTIVITY_HOURS), 'avgOtherActivityHours')
|
||||
.addSelect(avg(STAYING_HOURS), 'avgStayingHours')
|
||||
.addSelect('MAX(s.standard_hours)::float8', 'stayStandardHours')
|
||||
.addSelect(
|
||||
`CASE WHEN AVG((${STAYING_HOURS})::numeric) <= MAX(s.standard_hours)
|
||||
'loadingSource',
|
||||
)
|
||||
.addSelect(avg(unloadingHours('s')), 'avgUnloadingHours')
|
||||
.addSelect(avg(loadingHours('s')), 'avgLoadingHours')
|
||||
.addSelect(avg(HANDLING_HOURS), 'avgLoadUnloadHours')
|
||||
.addSelect(avg(OTHER_ACTIVITY_HOURS), 'avgOtherActivityHours')
|
||||
.addSelect(avg(STAYING_HOURS), 'avgStayingHours')
|
||||
.addSelect('MAX(s.standard_hours)::float8', 'stayStandardHours')
|
||||
.addSelect(
|
||||
`CASE WHEN AVG((${STAYING_HOURS})::numeric) <= MAX(s.standard_hours)
|
||||
THEN 'Encouraging' ELSE 'Needs reason' END`,
|
||||
'stayVerdict',
|
||||
)
|
||||
.addSelect(`${HANDLING_STANDARD}::float8`, 'handlingStandardHours')
|
||||
// Same formula the turnaround cycle publishes, so the two read alike.
|
||||
// NULL standard in, NULL rate out — nothing to measure against yet.
|
||||
.addSelect(
|
||||
cycleRateExpr(`AVG((${HANDLING_HOURS})::numeric)`, HANDLING_STANDARD),
|
||||
'handlingRate',
|
||||
)
|
||||
.groupBy(bucket)
|
||||
.addGroupBy('s.station')
|
||||
.addGroupBy('s.country');
|
||||
if (!perStation) qb.addGroupBy(TRAIN_NUMBER);
|
||||
return qb;
|
||||
'stayVerdict',
|
||||
)
|
||||
.addSelect(`${HANDLING_STANDARD}::float8`, 'handlingStandardHours')
|
||||
// Same formula the turnaround cycle publishes, so the two read alike.
|
||||
// NULL standard in, NULL rate out — nothing to measure against yet.
|
||||
.addSelect(
|
||||
cycleRateExpr(`AVG((${HANDLING_HOURS})::numeric)`, HANDLING_STANDARD),
|
||||
'handlingRate',
|
||||
)
|
||||
.groupBy(bucket)
|
||||
.addGroupBy('s.station')
|
||||
.addGroupBy('s.country')
|
||||
);
|
||||
},
|
||||
async summary(ctx) {
|
||||
const row = await stationStaysQb(ctx)
|
||||
@@ -170,13 +351,26 @@ export const loadingUnloadingReport: ReportDefinition = {
|
||||
.addSelect(`COUNT(${HANDLING_HOURS})::int`, 'measured')
|
||||
.addSelect(avg(HANDLING_HOURS), 'avgHandling')
|
||||
.addSelect(avg(OTHER_ACTIVITY_HOURS), 'avgOther')
|
||||
.getRawOne<{ stops: number; measured: number; avgHandling: number; avgOther: number }>();
|
||||
.getRawOne<{
|
||||
stops: number;
|
||||
measured: number;
|
||||
avgHandling: number;
|
||||
avgOther: number;
|
||||
}>();
|
||||
|
||||
return [
|
||||
{ label: 'Stops measured', value: Number(row?.stops ?? 0) },
|
||||
{ label: 'Handling measured', value: Number(row?.measured ?? 0) },
|
||||
{ label: 'Average loading + unloading', value: Number(row?.avgHandling ?? 0), unit: 'h' },
|
||||
{ label: 'Average other activity', value: Number(row?.avgOther ?? 0), unit: 'h' },
|
||||
{
|
||||
label: 'Average loading + unloading',
|
||||
value: Number(row?.avgHandling ?? 0),
|
||||
unit: 'h',
|
||||
},
|
||||
{
|
||||
label: 'Average other activity',
|
||||
value: Number(row?.avgOther ?? 0),
|
||||
unit: 'h',
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
CARGO_CATEGORIES,
|
||||
CARGO_CATEGORY_EXPR,
|
||||
CARGO_CATEGORY_LABEL_EXPR,
|
||||
REVENUE_CARGO_CATEGORY_EXPR,
|
||||
CONTAINER_CLASSES,
|
||||
CONTAINER_CLASS_EXPR,
|
||||
HANDLING_STANDARD_HOURS_EXPR,
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
otherActivityHours,
|
||||
plannedRowsSql,
|
||||
} from './operations-classification';
|
||||
import { REVENUE_CATEGORIES } from './revenue-classification';
|
||||
import { TARGET_DIMENSIONS, TARGET_METRICS } from '../operations-reporting/entities/operations-target.entity';
|
||||
|
||||
/**
|
||||
@@ -69,6 +71,20 @@ describe('operations classification', () => {
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* The volume report groups tonnage by this expression and the finance reports
|
||||
* group birr by `REVENUE_CATEGORY_EXPR`. A key only one side can emit is a
|
||||
* bucket that never reconciles — and it fails silently, as a row that simply
|
||||
* has no counterpart.
|
||||
*/
|
||||
it('classifies cargo into keys the revenue vocabulary offers', () => {
|
||||
const offered = new Set(REVENUE_CATEGORIES.map((o) => o.value));
|
||||
const missing = [...new Set(emittedKeys(REVENUE_CARGO_CATEGORY_EXPR))].filter(
|
||||
(k) => !offered.has(k),
|
||||
);
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
|
||||
it('offers every container class the expression can emit', () => {
|
||||
const offered = new Set(CONTAINER_CLASSES.map((o) => o.value));
|
||||
const missing = [...new Set(emittedKeys(CONTAINER_CLASS_EXPR))].filter((k) => !offered.has(k));
|
||||
|
||||
@@ -11,7 +11,7 @@ import { WagonBookingAllocation } from '../train-schedules/entities/wagon-bookin
|
||||
import { Yard } from '../rule-engine/entities/yard.entity';
|
||||
import { applyDirectionScope } from '../user-trade-access/trade-scope.util';
|
||||
import { ReportContext, ReportFilterDef, ReportFilterOption } from './report.types';
|
||||
import { resolvePeriod, yardOptions } from './revenue-classification';
|
||||
import { REVENUE_CATEGORIES, resolvePeriod, yardOptions } from './revenue-classification';
|
||||
|
||||
/**
|
||||
* The shared vocabulary and SQL behind every operations report — turnaround,
|
||||
@@ -115,6 +115,39 @@ export const CARGO_CATEGORY_EXPR = `CASE
|
||||
ELSE 'UNCLASSIFIED'
|
||||
END`;
|
||||
|
||||
/**
|
||||
* The same cargo, classified into the REVENUE vocabulary — the categories
|
||||
* `revenue-classification.ts` bills against, minus its charge-only buckets
|
||||
* (incidental, first/last mile, customs), which no physical wagon can be.
|
||||
*
|
||||
* Mirrors the cargo arms of `REVENUE_CATEGORY_EXPR` in that expression's own
|
||||
* order, so a ton and the birr charged for it land in the same bucket: empty
|
||||
* re-export before domestic, domestic before anything about what is in the box.
|
||||
* Reports that must reconcile tonnage against revenue group by this one; the
|
||||
* operational vocabulary above keeps sand and bulk apart, which no invoice does.
|
||||
*/
|
||||
export const REVENUE_CARGO_CATEGORY_EXPR = `CASE
|
||||
WHEN ${IS_EMPTY_CONTAINER} THEN 'EMPTY_CONTAINER_REEXPORT'
|
||||
WHEN oy.country IS NOT NULL AND oy.country = dy.country THEN 'DOMESTIC'
|
||||
WHEN ${IS_CONTAINER} AND b.trade_direction = 'EXPORT' THEN 'CONTAINER_EXPORT'
|
||||
WHEN ${IS_CONTAINER} AND ${IS_MULTIMODAL} THEN 'CONTAINER_IMPORT_MULTIMODAL'
|
||||
WHEN ${IS_CONTAINER} THEN 'CONTAINER_IMPORT_UNIMODAL'
|
||||
WHEN ct.code IN (${quote(FERTILIZER_CODES)}) THEN 'FERTILIZER'
|
||||
WHEN ct.code IN (${quote(BREAK_BULK_CODES)}) THEN 'BREAK_BULK'
|
||||
WHEN ct.code IN (${quote(RORO_CODES)}) THEN 'RORO'
|
||||
WHEN b.trade_direction = 'EXPORT' THEN 'OTHER_EXPORT_CARGO'
|
||||
WHEN b.trade_direction = 'IMPORT' THEN 'OTHER_IMPORT_BULK'
|
||||
ELSE 'UNCLASSIFIED'
|
||||
END`;
|
||||
|
||||
/** The revenue vocabulary as a filter, plus the wagon that carries no cargo. */
|
||||
export const REVENUE_CARGO_FILTER: ReportFilterDef = {
|
||||
key: 'categories',
|
||||
label: 'Cargo type',
|
||||
type: 'multiselect',
|
||||
options: [...REVENUE_CATEGORIES, { value: 'EMPTY_WAGON', label: 'Empty wagon' }],
|
||||
};
|
||||
|
||||
export const CONTAINER_CLASS_EXPR = `CASE
|
||||
WHEN ${IS_EMPTY_CONTAINER} THEN 'EMPTY_CONTAINER_RETURN'
|
||||
WHEN b.trade_direction = 'EXPORT' THEN 'CONTAINER_EXPORT'
|
||||
@@ -330,23 +363,13 @@ export const CHARGED_TONS_EXPR = `(
|
||||
* ${stdAgg('charged_tons_per_wagon_general', 70)}
|
||||
)::float8`;
|
||||
|
||||
/** Wagons actually carrying cargo in the grouped set. */
|
||||
export const LOADED_WAGONS_EXPR = 'COUNT(DISTINCT tsw.id)::int';
|
||||
|
||||
/**
|
||||
* Wagons on the departure with nothing allocated to them — the Vehicle-Km base.
|
||||
*
|
||||
* A train-level figure: it belongs to the departure, not to any one cargo type
|
||||
* riding on it, so a report grouped finer than the schedule repeats it rather
|
||||
* than splitting it. Callers that need a total must de-duplicate by schedule.
|
||||
* Wagons actually carrying cargo in the grouped set. The FILTER only bites on a
|
||||
* query built with `includeEmptyWagons` — every row of an allocation-grain
|
||||
* query has an allocation, so it is a no-op there.
|
||||
*/
|
||||
export const SCHEDULE_EMPTY_WAGONS = `(
|
||||
SELECT COUNT(*) FROM freight.train_set_wagons tw
|
||||
WHERE tw.train_set_id = ts.train_set_id AND tw.deleted_at IS NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM freight.wagon_booking_allocations a
|
||||
WHERE a.train_set_wagon_id = tw.id AND a.deleted_at IS NULL)
|
||||
)`;
|
||||
export const LOADED_WAGONS_EXPR =
|
||||
'COUNT(DISTINCT tsw.id) FILTER (WHERE wba.id IS NOT NULL)::int';
|
||||
|
||||
/**
|
||||
* Trainsets operated: wagons loaded divided by a full trainset for this cargo.
|
||||
@@ -440,21 +463,41 @@ const DEAD_SCHEDULE_STATUSES = ['DRAFT', 'CANCELLED'];
|
||||
*
|
||||
* The booking is LEFT joined — a wagon can be allocated before its booking data
|
||||
* is complete, and dropping those rows would understate wagon usage.
|
||||
*
|
||||
* `includeEmptyWagons` turns the ledger around to start from the wagon instead:
|
||||
* every wagon of the departure is a row, and one that carried nothing has a
|
||||
* NULL `wba`. Only the volume report wants that — it reports the empty wagons
|
||||
* as their own line — and it costs the other reports a row grain they would
|
||||
* have to filter back out.
|
||||
*/
|
||||
export function allocationLedgerQb(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
|
||||
export function allocationLedgerQb(
|
||||
ctx: ReportContext,
|
||||
opts: { includeEmptyWagons?: boolean } = {},
|
||||
): SelectQueryBuilder<ObjectLiteral> {
|
||||
const { params, directions } = ctx;
|
||||
|
||||
const qb = ctx.ds
|
||||
.createQueryBuilder()
|
||||
.from(WagonBookingAllocation, 'wba')
|
||||
.innerJoin(TrainSetWagon, 'tsw', 'tsw.id = wba.train_set_wagon_id AND tsw.deleted_at IS NULL')
|
||||
.innerJoin(TrainSchedule, 'ts', 'ts.train_set_id = tsw.train_set_id AND ts.deleted_at IS NULL')
|
||||
const qb = ctx.ds.createQueryBuilder();
|
||||
|
||||
if (opts.includeEmptyWagons) {
|
||||
qb.from(TrainSetWagon, 'tsw')
|
||||
.leftJoin(
|
||||
WagonBookingAllocation,
|
||||
'wba',
|
||||
'wba.train_set_wagon_id = tsw.id AND wba.deleted_at IS NULL',
|
||||
)
|
||||
.where('tsw.deleted_at IS NULL');
|
||||
} else {
|
||||
qb.from(WagonBookingAllocation, 'wba')
|
||||
.innerJoin(TrainSetWagon, 'tsw', 'tsw.id = wba.train_set_wagon_id AND tsw.deleted_at IS NULL')
|
||||
.where('wba.deleted_at IS NULL');
|
||||
}
|
||||
|
||||
qb.innerJoin(TrainSchedule, 'ts', 'ts.train_set_id = tsw.train_set_id AND ts.deleted_at IS NULL')
|
||||
.leftJoin(Booking, 'b', 'b.id = wba.booking_id AND b.deleted_at IS NULL')
|
||||
.leftJoin(CargoType, 'ct', 'ct.id = b.cargo_type_id')
|
||||
.leftJoin(Yard, 'oy', 'oy.id = ts.origin_station_id')
|
||||
.leftJoin(Yard, 'dy', 'dy.id = ts.destination_station_id')
|
||||
.leftJoin(OperationsStandard, 'std', STANDARDS_JOIN)
|
||||
.where('wba.deleted_at IS NULL')
|
||||
.andWhere('ts.status NOT IN (:...deadScheduleStatuses)', {
|
||||
deadScheduleStatuses: DEAD_SCHEDULE_STATUSES,
|
||||
});
|
||||
@@ -715,10 +758,11 @@ export function applyOperationsFilters(
|
||||
export function applyCategoryFilter(
|
||||
qb: SelectQueryBuilder<ObjectLiteral>,
|
||||
params: Record<string, unknown>,
|
||||
categoryExpr: string = CARGO_CATEGORY_EXPR,
|
||||
): void {
|
||||
const categories = params.categories as string[] | null;
|
||||
if (categories?.length) {
|
||||
qb.andWhere(`${CARGO_CATEGORY_EXPR} IN (:...categories)`, { categories });
|
||||
qb.andWhere(`${categoryExpr} IN (:...categories)`, { categories });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
normalizePagination,
|
||||
} from '../../common/utils/pagination.util';
|
||||
import { applyBookingRefDirectionScope } from '../user-trade-access/trade-scope.util';
|
||||
import { ReportDefinition, ReportRunResult } from './report.types';
|
||||
import { ReportColumn, ReportDefinition, ReportRunResult } from './report.types';
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
@@ -38,7 +38,7 @@ function coerceParams(
|
||||
const items = csv?.split(',').map((s) => s.trim()).filter(Boolean) ?? [];
|
||||
params[filter.key] = items.length ? items : null;
|
||||
} else {
|
||||
params[filter.key] = raw[filter.key]?.trim() || null;
|
||||
params[filter.key] = raw[filter.key]?.trim() || filter.defaultValue || null;
|
||||
}
|
||||
}
|
||||
// idKey, when the report declares one, is a plain string param.
|
||||
@@ -57,19 +57,34 @@ function coerceParams(
|
||||
*/
|
||||
const aliasSortExpr = (key: string): string => `"${key.replace(/"/g, '""')}"`;
|
||||
|
||||
/**
|
||||
* Columns the current filter values don't hide. A hidden column is not in the
|
||||
* SELECT list of the shape those filters produce, so sorting by one would be a
|
||||
* 42703 — the sort falls back to the default instead.
|
||||
*/
|
||||
export const visibleColumns = (
|
||||
def: ReportDefinition,
|
||||
params: Record<string, unknown>,
|
||||
): ReportColumn[] =>
|
||||
def.columns.filter((c) =>
|
||||
Object.entries(c.hideWhen ?? {}).every(([key, value]) => params[key] !== value),
|
||||
);
|
||||
|
||||
/** Resolve a client-requested sort column against the report's own whitelist. */
|
||||
function resolveSort(
|
||||
def: ReportDefinition,
|
||||
params: Record<string, unknown>,
|
||||
sortBy?: string,
|
||||
sortOrder?: string,
|
||||
): { key: string; expr: string; dir: 'ASC' | 'DESC' } | null {
|
||||
const dir = sortOrder?.toUpperCase() === 'DESC' ? 'DESC' : 'ASC';
|
||||
const requested = sortBy && def.columns.find((c) => c.key === sortBy && c.sortable);
|
||||
const columns = visibleColumns(def, params);
|
||||
const requested = sortBy && columns.find((c) => c.key === sortBy && c.sortable);
|
||||
if (requested) {
|
||||
return { key: requested.key, expr: requested.sortExpr ?? aliasSortExpr(requested.key), dir };
|
||||
}
|
||||
if (!def.defaultSort) return null;
|
||||
const fallback = def.columns.find((c) => c.key === def.defaultSort!.key);
|
||||
const fallback = columns.find((c) => c.key === def.defaultSort!.key);
|
||||
if (!fallback) return null;
|
||||
return {
|
||||
key: fallback.key,
|
||||
@@ -91,7 +106,7 @@ export class ReportRunnerService {
|
||||
const ctx = { ds: this.ds, params, directions };
|
||||
|
||||
const qb = def.query(ctx);
|
||||
const sort = resolveSort(def, raw.sortBy, raw.sortOrder);
|
||||
const sort = resolveSort(def, params, raw.sortBy, raw.sortOrder);
|
||||
if (sort) qb.orderBy(sort.expr, sort.dir);
|
||||
|
||||
const { page: pageNum, pageSize, skip, take } = normalizePagination({
|
||||
@@ -141,7 +156,7 @@ export class ReportRunnerService {
|
||||
const qb = def.query(ctx);
|
||||
// Same sort the on-screen table is using, not always the default — an
|
||||
// export is supposed to match what the user is looking at.
|
||||
const sort = resolveSort(def, raw.sortBy, raw.sortOrder);
|
||||
const sort = resolveSort(def, params, raw.sortBy, raw.sortOrder);
|
||||
if (sort) qb.orderBy(sort.expr, sort.dir);
|
||||
|
||||
const ceiling = limit ?? cap;
|
||||
|
||||
@@ -19,6 +19,12 @@ export interface ReportColumn {
|
||||
sortable?: boolean;
|
||||
/** SQL to ORDER BY when this column is sorted, if different from `key`. */
|
||||
sortExpr?: string;
|
||||
/**
|
||||
* Hide the column while a filter holds a given value — how one report serves
|
||||
* two group-by grains without two column lists. Display only: the value is
|
||||
* still selected, exported and sortable, it just isn't shown.
|
||||
*/
|
||||
hideWhen?: Record<string, string>;
|
||||
}
|
||||
|
||||
export type ReportFilterType = 'daterange' | 'date' | 'select' | 'multiselect' | 'text';
|
||||
@@ -34,6 +40,12 @@ export interface ReportFilterDef {
|
||||
type: ReportFilterType;
|
||||
/** Static option list for select/multiselect. */
|
||||
options?: ReportFilterOption[];
|
||||
/**
|
||||
* Value the filter takes when the client sends nothing — so a report whose
|
||||
* shape depends on a filter (see `ReportColumn.hideWhen`) never has to guess
|
||||
* what "unset" meant.
|
||||
*/
|
||||
defaultValue?: string;
|
||||
/**
|
||||
* Resolves the option list from the database instead of declaring it inline —
|
||||
* for filters whose choices are reference data (stations, cargo types).
|
||||
|
||||
@@ -28,8 +28,14 @@ import { ReportContext, ReportFilterDef, ReportFilterOption } from './report.typ
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const REVENUE_CATEGORIES: ReportFilterOption[] = [
|
||||
{ value: 'CONTAINER_IMPORT_MULTIMODAL', label: 'Full Container Import — Multimodal' },
|
||||
{ value: 'CONTAINER_IMPORT_UNIMODAL', label: 'Full Container Import — Unimodal' },
|
||||
{
|
||||
value: 'CONTAINER_IMPORT_MULTIMODAL',
|
||||
label: 'Full Container Import — Multimodal',
|
||||
},
|
||||
{
|
||||
value: 'CONTAINER_IMPORT_UNIMODAL',
|
||||
label: 'Full Container Import — Unimodal',
|
||||
},
|
||||
{ value: 'CONTAINER_EXPORT', label: 'Full Container Export' },
|
||||
{ value: 'EMPTY_CONTAINER_REEXPORT', label: 'Empty Container Re-export' },
|
||||
{ value: 'FERTILIZER', label: 'Fertilizer Transportation' },
|
||||
@@ -332,7 +338,10 @@ export const PERIOD_FILTER: ReportFilterDef = {
|
||||
key: 'period',
|
||||
label: 'Granularity',
|
||||
type: 'select',
|
||||
options: Object.entries(PERIOD_UNITS).map(([value, u]) => ({ value, label: u.label })),
|
||||
options: Object.entries(PERIOD_UNITS).map(([value, u]) => ({
|
||||
value,
|
||||
label: u.label,
|
||||
})),
|
||||
};
|
||||
|
||||
/** The timestamp every revenue report buckets and filters on. */
|
||||
@@ -478,7 +487,12 @@ export const REVENUE_FILTERS: ReportFilterDef[] = [
|
||||
options: REVENUE_CATEGORIES,
|
||||
},
|
||||
{ key: 'origin', label: 'Origin', type: 'select', optionsQuery: yardOptions },
|
||||
{ key: 'destination', label: 'Destination', type: 'select', optionsQuery: yardOptions },
|
||||
{
|
||||
key: 'destination',
|
||||
label: 'Destination',
|
||||
type: 'select',
|
||||
optionsQuery: yardOptions,
|
||||
},
|
||||
{ key: 'customer', label: 'Customer / booking ref', type: 'text' },
|
||||
{
|
||||
key: 'methods',
|
||||
@@ -537,9 +551,6 @@ export function revenueLedgerQb(ctx: ReportContext): SelectQueryBuilder<ObjectLi
|
||||
deadInvoiceStatuses: DEAD_INVOICE_STATUSES,
|
||||
})
|
||||
.andWhere("i.source <> 'eims_self_test'")
|
||||
// An umbrella general contract is paid once and drawn down by many orders;
|
||||
// counting both double-counts its value.
|
||||
.andWhere("(b.id IS NULL OR b.contract_kind IS NULL OR b.contract_kind <> 'GENERAL')")
|
||||
// Mixing ETB and USD into one SUM produces a meaningless number.
|
||||
.andWhere('il.currency = :currency', { currency: currencyOf(params) });
|
||||
|
||||
@@ -611,13 +622,13 @@ export function invoiceLedgerQb(ctx: ReportContext): SelectQueryBuilder<ObjectLi
|
||||
deadInvoiceStatuses: DEAD_INVOICE_STATUSES,
|
||||
})
|
||||
.andWhere("i.source <> 'eims_self_test'")
|
||||
.andWhere("(b.id IS NULL OR b.contract_kind IS NULL OR b.contract_kind <> 'GENERAL')")
|
||||
.andWhere('i.currency = :currency', { currency: currencyOf(params) });
|
||||
|
||||
if (params.dateFrom) qb.andWhere(`${REVENUE_DATE} >= :dateFrom`, { dateFrom: params.dateFrom });
|
||||
if (params.dateTo) qb.andWhere(`${REVENUE_DATE} < :dateTo`, { dateTo: params.dateTo });
|
||||
if (params.origin) qb.andWhere('oy.code = :origin', { origin: params.origin });
|
||||
if (params.destination) qb.andWhere('dy.code = :destination', { destination: params.destination });
|
||||
if (params.destination)
|
||||
qb.andWhere('dy.code = :destination', { destination: params.destination });
|
||||
if (params.customer) {
|
||||
qb.andWhere(
|
||||
'(co.name ILIKE :customer OR slc.name ILIKE :customer OR b.reference ILIKE :customer)',
|
||||
@@ -630,14 +641,37 @@ export function invoiceLedgerQb(ctx: ReportContext): SelectQueryBuilder<ObjectLi
|
||||
}
|
||||
|
||||
/**
|
||||
* What the payment gateway actually recorded against this invoice, summed.
|
||||
* `invoices.payment_id` points at a payment-api intent id rather than a
|
||||
* `freight.payments` row, so the reliable link is the booking id both sides
|
||||
* carry.
|
||||
* What the payment gateway actually recorded against this invoice.
|
||||
*
|
||||
* `freight.payments` is keyed by the booking, not the invoice — `ref_id` holds
|
||||
* the booking id and there is no invoice column — while one booking routinely
|
||||
* carries several invoices (25 booking ids here back 58 of them). Reading the
|
||||
* booking's gateway total straight off each invoice therefore hands the same
|
||||
* money to every sibling: 113M of gateway receipts claimed against 44M of
|
||||
* recorded settlement, which surfaced as ~89M of variance that does not exist.
|
||||
*
|
||||
* So the booking's receipts are apportioned across its invoices by their share
|
||||
* of what was recorded as settled — the same device as {@link PAID_SHARE}, and
|
||||
* the only split that makes the report's gateway column sum to the payments
|
||||
* table. A booking whose invoices record no settlement at all cannot be split
|
||||
* that way; it falls back to the billed share, so gateway money nobody booked
|
||||
* still shows up as variance instead of vanishing.
|
||||
*
|
||||
* `invoices.payment_id` does resolve to a `freight.payments` row, but only 72
|
||||
* of 85 successful payments are pointed at by one, so keying on it drops real
|
||||
* receipts.
|
||||
*/
|
||||
export const GATEWAY_PAID = `(
|
||||
SELECT COALESCE(SUM(p.amount), 0) FROM freight.payments p
|
||||
WHERE p.ref_id = i.source_id AND p.status = 'success'
|
||||
(SELECT COALESCE(SUM(p.amount), 0) FROM freight.payments p
|
||||
WHERE p.ref_id = i.source_id AND p.status = 'success')
|
||||
* COALESCE(
|
||||
i.paid_amount / NULLIF((SELECT SUM(i2.paid_amount) FROM freight.invoices i2
|
||||
WHERE i2.source_id = i.source_id AND i2.deleted_at IS NULL
|
||||
AND i2.status NOT IN ('DRAFT', 'CANCELLED')), 0),
|
||||
i.total_amount / NULLIF((SELECT SUM(i2.total_amount) FROM freight.invoices i2
|
||||
WHERE i2.source_id = i.source_id AND i2.deleted_at IS NULL
|
||||
AND i2.status NOT IN ('DRAFT', 'CANCELLED')), 0),
|
||||
0)
|
||||
)`;
|
||||
|
||||
/** The payer, whichever of the two mutually exclusive payer columns is set. */
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import type { RateAppliesTo, RateTrigger, RateUnit } from './rate.entity';
|
||||
|
||||
/** How the bulk commodity a rate is scoped to is counted (cargo_types.unit_of_measure). */
|
||||
export type CargoUom = 'PER_TON' | 'PER_ITEM' | null | undefined;
|
||||
/**
|
||||
* How the bulk commodity a rate is scoped to is counted
|
||||
* (cargo_types.unit_of_measure). NUMBER_OF_WAGONS cargo is weighed in tons and
|
||||
* offers the same PER_TON / PER_WAGON units as PER_TON cargo — only the
|
||||
* booking form (which also asks for a wagon count) treats it differently.
|
||||
*/
|
||||
export type CargoUom = 'PER_TON' | 'PER_ITEM' | 'NUMBER_OF_WAGONS' | null | undefined;
|
||||
|
||||
/**
|
||||
* Units billed against a booking's bulk quantity. That quantity is recorded in
|
||||
|
||||
@@ -21,6 +21,19 @@ export const TRAIN_SCHEDULE_STATUSES = [
|
||||
|
||||
export type TrainScheduleStatus = (typeof TRAIN_SCHEDULE_STATUSES)[number];
|
||||
|
||||
/** One clicked loading or unloading window at a yard (ISO timestamps). */
|
||||
export interface StationWorkPhaseLog {
|
||||
startedAt?: string | null;
|
||||
endedAt?: string | null;
|
||||
startedByUserId?: string | null;
|
||||
endedByUserId?: string | null;
|
||||
}
|
||||
|
||||
export interface StationWorkLog {
|
||||
loading?: StationWorkPhaseLog;
|
||||
unloading?: StationWorkPhaseLog;
|
||||
}
|
||||
|
||||
@Entity({ schema: 'freight', name: 'train_schedules' })
|
||||
@Index(['scheduledDepartureDate'])
|
||||
@Index(['status'])
|
||||
@@ -157,6 +170,15 @@ export class TrainSchedule extends BaseEntity {
|
||||
@Column({ name: 'planned_wagon_real_cuts', type: 'jsonb', nullable: true })
|
||||
plannedWagonRealCuts?: string[] | null;
|
||||
|
||||
/**
|
||||
* Per-station loading/unloading time windows, clicked by yard operators:
|
||||
* `{ [yardId]: { loading?: {...}, unloading?: {...} } }`. Booking load/unload
|
||||
* is gated on the matching window having been STARTED at that yard; end is
|
||||
* informational (elapsed time reporting). ISO strings, editable after the fact.
|
||||
*/
|
||||
@Column({ name: 'station_work_logs', type: 'jsonb', nullable: true })
|
||||
stationWorkLogs?: Record<string, StationWorkLog> | null;
|
||||
|
||||
/** OPEN = accepting/holding bookings; FULL = train filled; CLOSED = manually closed. Orthogonal to `status`. */
|
||||
@Column({ name: 'booking_window_status', type: 'varchar', length: 10, default: 'OPEN' })
|
||||
bookingWindowStatus!: string;
|
||||
|
||||
@@ -241,6 +241,45 @@ describe('BookingBatchService — PAID reconcile', () => {
|
||||
).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ensurePaidBookingAllocated never re-places a MANUAL_ONLY booking (removed from a train by staff)', async () => {
|
||||
dataSource.getRepository().findOne.mockResolvedValue({
|
||||
...paidBooking,
|
||||
trainScheduleId: null,
|
||||
schedulingStatus: 'MANUAL_ONLY',
|
||||
} as unknown as Booking);
|
||||
|
||||
await service.ensurePaidBookingAllocated(bookingId);
|
||||
|
||||
expect(trainScheduleBookingsRepository.createMany).not.toHaveBeenCalled();
|
||||
expect(trainSchedulingService.tryAutoWagonAllocation).not.toHaveBeenCalled();
|
||||
expect(dataSource.getRepository().update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ensurePaidBookingAllocated skips a MANUAL_ONLY booking even when still pinned to a schedule', async () => {
|
||||
dataSource.getRepository().findOne.mockResolvedValue({
|
||||
...paidBooking,
|
||||
schedulingStatus: 'MANUAL_ONLY',
|
||||
} as unknown as Booking);
|
||||
|
||||
await service.ensurePaidBookingAllocated(bookingId);
|
||||
|
||||
expect(trainScheduleBookingsRepository.createMany).not.toHaveBeenCalled();
|
||||
expect(trainSchedulingService.tryAutoWagonAllocation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reconcilePaidUnlinked leaves MANUAL_ONLY bookings alone', async () => {
|
||||
bookingsRepository.findPaidUnlinkedForSchedule.mockResolvedValue([
|
||||
{ ...paidBooking, schedulingStatus: 'MANUAL_ONLY' },
|
||||
]);
|
||||
|
||||
await service.reconcilePaidUnlinked(scheduleId);
|
||||
|
||||
expect(trainScheduleBookingsRepository.createMany).not.toHaveBeenCalled();
|
||||
expect(
|
||||
trainSchedulingService.previewPaidBookingWagonShortage,
|
||||
).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('processSchedule reconciles PAID-unlinked before wagon allocation', async () => {
|
||||
const fillSpy = jest.spyOn(service, 'fillSchedule').mockResolvedValue(0);
|
||||
const settleSpy = jest.spyOn(service, 'settleDueReservations').mockResolvedValue(undefined);
|
||||
|
||||
@@ -76,7 +76,7 @@ import {
|
||||
bookingCargoTons,
|
||||
bulkItemsFitFor,
|
||||
bulkItemWagonsRequired,
|
||||
bulkTonsPerWagon,
|
||||
bulkTonsPerWagonFor,
|
||||
bookingGrossWeightTons,
|
||||
deriveTrainCapacityFromLocomotive,
|
||||
sizePartialOfferWagons,
|
||||
@@ -577,6 +577,11 @@ export class BookingBatchService implements OnModuleInit {
|
||||
// train 30s after being cancelled. Never resurrect a dead booking.
|
||||
if (["CANCELLED", "EXPIRED", "REJECTED", "COMPLETED"].includes(booking.status))
|
||||
return;
|
||||
// Staff removed this booking from a train (dispatch left-behind / manual
|
||||
// unassign) — every auto-allocation rescue below must leave it alone, or
|
||||
// the next document review / sweep silently retakes the space it was
|
||||
// pulled from. Only a manual staff assignment may re-place it.
|
||||
if (booking.schedulingStatus === "MANUAL_ONLY") return;
|
||||
if (!booking.trainScheduleId) {
|
||||
// A paid booking with no train is money taken and nothing boarding. The
|
||||
// hold was expired before the payment landed (webhook lag beat the
|
||||
@@ -1553,6 +1558,8 @@ export class BookingBatchService implements OnModuleInit {
|
||||
for (const booking of unlinked) {
|
||||
// Held on purpose (paid, no wagon free) — the cron must not undo it.
|
||||
if (booking.schedulingStatus === "WAITING_FOR_WAGON") continue;
|
||||
// Removed from a train by staff — manual re-assignment only.
|
||||
if (booking.schedulingStatus === "MANUAL_ONLY") continue;
|
||||
if (await this.holdIfWagonShort(scheduleId, booking)) continue;
|
||||
await this.allocate(scheduleId, booking, "paid");
|
||||
this.logger.log(
|
||||
@@ -2884,7 +2891,8 @@ export class BookingBatchService implements OnModuleInit {
|
||||
.map((o) => ({
|
||||
...o,
|
||||
free: c.stock?.availableFor([o.wagonTypeId], leg) ?? 0,
|
||||
takePerWagon: bulkTonsPerWagon(
|
||||
takePerWagon: bulkTonsPerWagonFor(
|
||||
booking,
|
||||
booking.cargoType,
|
||||
o.wagonTypeId,
|
||||
o.dims.capacityTons,
|
||||
@@ -4722,7 +4730,8 @@ export class BookingBatchService implements OnModuleInit {
|
||||
const cargoTons = bookingCargoTons(booking);
|
||||
// PER_TON cargo may cap tons per wagon below the rating (sugar 50T on a 70T
|
||||
// wagon), so divide by the cap where one is configured for this type.
|
||||
const tonsPerWagon = bulkTonsPerWagon(
|
||||
const tonsPerWagon = bulkTonsPerWagonFor(
|
||||
booking,
|
||||
booking.cargoType,
|
||||
booking.cargoType?.wagonTypes?.[0]?.id,
|
||||
capacityTons,
|
||||
@@ -4798,7 +4807,8 @@ export class BookingBatchService implements OnModuleInit {
|
||||
const wagonTypeId = o.wagonTypeId as string;
|
||||
// Each type sized on its OWN per-wagon tonnage cap, not just its rating
|
||||
// — a type capped lower swallows less per wagon.
|
||||
const tonsPerWagon = bulkTonsPerWagon(
|
||||
const tonsPerWagon = bulkTonsPerWagonFor(
|
||||
booking,
|
||||
booking.cargoType,
|
||||
wagonTypeId,
|
||||
o.dims.capacityTons,
|
||||
@@ -5210,7 +5220,8 @@ export class BookingBatchService implements OnModuleInit {
|
||||
.map((o) => ({
|
||||
...o,
|
||||
free: stock.availableFor([o.wagonTypeId], leg),
|
||||
takePerWagon: bulkTonsPerWagon(
|
||||
takePerWagon: bulkTonsPerWagonFor(
|
||||
booking,
|
||||
booking.cargoType,
|
||||
o.wagonTypeId,
|
||||
o.dims.capacityTons,
|
||||
|
||||
@@ -18,7 +18,11 @@ import { WagonAllocationContainerItem } from '../train-schedules/entities/wagon-
|
||||
import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service';
|
||||
import { Yard } from '../rule-engine/entities/yard.entity';
|
||||
import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity';
|
||||
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
||||
import {
|
||||
StationWorkLog,
|
||||
StationWorkPhaseLog,
|
||||
TrainSchedule,
|
||||
} from '../train-schedules/entities/train-schedule.entity';
|
||||
import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity';
|
||||
import { WagonBookingAllocation } from '../train-schedules/entities/wagon-booking-allocation.entity';
|
||||
import { Wagon } from '../wagons/entities/wagon.entity';
|
||||
@@ -77,6 +81,7 @@ export class BookingJourneyService {
|
||||
);
|
||||
}
|
||||
await this.assertTrainAtYard(schedule, booking.originYardId, 'origin');
|
||||
this.assertStationWorkStarted(schedule, booking.originYardId, 'loading');
|
||||
await this.assertYardCanHandleCargo(booking, booking.originYardId, 'origin');
|
||||
// Export cargo must be in the warehouse with a GRN before it can be loaded,
|
||||
// however it arrived and whatever it is allocated to.
|
||||
@@ -166,6 +171,7 @@ export class BookingJourneyService {
|
||||
);
|
||||
}
|
||||
await this.assertTrainAtYard(schedule, booking.destinationYardId, 'destination');
|
||||
this.assertStationWorkStarted(schedule, booking.destinationYardId, 'unloading');
|
||||
await this.assertYardCanHandleCargo(booking, booking.destinationYardId, 'destination');
|
||||
|
||||
// Intercity has no clearance/delivery tail — unloading completes it. Import/
|
||||
@@ -294,6 +300,9 @@ export class BookingJourneyService {
|
||||
// dispatch — assertTrainAtYard allows origin loading in that state, so
|
||||
// the UI position must agree or origin Load buttons grey out wrongly.
|
||||
trainAtYardId: latest?.yardId ?? schedule.originStationId,
|
||||
// Per-yard loading/unloading time windows — the UI derives its
|
||||
// start/end buttons and the load/unload gating from these.
|
||||
stationWorkLogs: schedule.stationWorkLogs ?? {},
|
||||
yards: [...byYard.values()],
|
||||
};
|
||||
}
|
||||
@@ -414,6 +423,62 @@ export class BookingJourneyService {
|
||||
return rows.map((r) => r.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a station's loading/unloading time-window click (or edit it — an
|
||||
* explicit `at` on an already-set edge overwrites the timestamp under the
|
||||
* same permission that set it). Rules: end needs start, start ≤ end, no
|
||||
* future times. Stored as ISO strings in train_schedules.station_work_logs.
|
||||
* ponytail: read-modify-write on the jsonb — two operators clicking the same
|
||||
* schedule in the same instant can clobber one edge; move to jsonb_set if
|
||||
* that ever bites.
|
||||
*/
|
||||
async recordStationWork(
|
||||
scheduleId: string,
|
||||
yardId: string,
|
||||
phase: 'loading' | 'unloading',
|
||||
edge: 'start' | 'end',
|
||||
at?: string,
|
||||
userId?: string | null,
|
||||
) {
|
||||
const schedule = await this.getSchedule(scheduleId);
|
||||
const when = at ? new Date(at) : new Date();
|
||||
if (Number.isNaN(when.getTime())) {
|
||||
throw new BadRequestException('Invalid timestamp');
|
||||
}
|
||||
if (when.getTime() > Date.now() + 60_000) {
|
||||
throw new BadRequestException(`${phase} ${edge} time cannot be in the future`);
|
||||
}
|
||||
|
||||
const logs: Record<string, StationWorkLog> = schedule.stationWorkLogs ?? {};
|
||||
const entry: StationWorkLog = logs[yardId] ?? {};
|
||||
const ph: StationWorkPhaseLog = entry[phase] ?? {};
|
||||
|
||||
if (edge === 'end') {
|
||||
if (!ph.startedAt) {
|
||||
throw new BadRequestException(`Start ${phase} at this station first`);
|
||||
}
|
||||
if (when.getTime() < new Date(ph.startedAt).getTime()) {
|
||||
throw new BadRequestException(`${phase} end cannot be before its start`);
|
||||
}
|
||||
ph.endedAt = when.toISOString();
|
||||
ph.endedByUserId = userId ?? null;
|
||||
} else {
|
||||
if (ph.endedAt && when.getTime() > new Date(ph.endedAt).getTime()) {
|
||||
throw new BadRequestException(`${phase} start cannot be after its end`);
|
||||
}
|
||||
ph.startedAt = when.toISOString();
|
||||
ph.startedByUserId = userId ?? null;
|
||||
}
|
||||
|
||||
entry[phase] = ph;
|
||||
logs[yardId] = entry;
|
||||
await this.dataSource
|
||||
.getRepository(TrainSchedule)
|
||||
.update(scheduleId, { stationWorkLogs: logs });
|
||||
|
||||
return { scheduleId, yardId, phase, ...ph };
|
||||
}
|
||||
|
||||
// ---- helpers ---------------------------------------------------------------
|
||||
|
||||
private async getSchedule(scheduleId: string): Promise<TrainSchedule> {
|
||||
@@ -481,6 +546,27 @@ export class BookingJourneyService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loading/unloading a booking is only allowed inside a started work window
|
||||
* at that yard — the operator must click "Start loading"/"Start unloading"
|
||||
* (recordStationWork) before touching cargo. The window's END is not checked:
|
||||
* a straggler booking can still be confirmed after the end click, and the
|
||||
* operator can push the end time later (it's editable) if that matters.
|
||||
* Lives here (not the controller) so the checkpoint-driven autoUnloadAtYard
|
||||
* path is gated too — the user wants unloading fully manual.
|
||||
*/
|
||||
private assertStationWorkStarted(
|
||||
schedule: TrainSchedule,
|
||||
yardId: string,
|
||||
phase: 'loading' | 'unloading',
|
||||
): void {
|
||||
if (!schedule.stationWorkLogs?.[yardId]?.[phase]?.startedAt) {
|
||||
throw new BadRequestException(
|
||||
`Start ${phase} at this station first — the ${phase} time window has not been started`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The train is "at" a yard when the latest recorded checkpoint is that yard,
|
||||
* or — for a booking boarding at the train's own origin — when the train has
|
||||
|
||||
@@ -18,14 +18,19 @@ import {
|
||||
TrainSchedulingCreate,
|
||||
TrainSchedulingEditTrainNumber,
|
||||
TrainSchedulingLoad,
|
||||
TrainSchedulingLoadingEnd,
|
||||
TrainSchedulingLoadingStart,
|
||||
TrainSchedulingReschedule,
|
||||
TrainSchedulingUnload,
|
||||
TrainSchedulingUnloadingEnd,
|
||||
TrainSchedulingUnloadingStart,
|
||||
TrainSchedulingRulesManage,
|
||||
TrainSchedulingUpdate,
|
||||
TrainSchedulingView,
|
||||
} from "../../../common/booking-guards";
|
||||
import { FREIGHT_PERMS } from "../../../seed/freight-permissions.registry";
|
||||
import { AcceptIntercityBookingsDto } from "../dto/accept-intercity-bookings.dto";
|
||||
import { StationWorkDto } from "../dto/station-work.dto";
|
||||
import { AssignBookingsDto } from "../dto/assign-bookings.dto";
|
||||
import { AssignUnassignedBookingDto } from "../dto/assign-unassigned-booking.dto";
|
||||
import { SwitchGovernmentBookingDto } from "../dto/switch-government-booking.dto";
|
||||
@@ -566,8 +571,9 @@ export class TrainSchedulingController {
|
||||
dispatchSchedule(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() dto: DispatchScheduleDto,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
return this.trainSchedulingService.dispatchSchedule(id, dto);
|
||||
return this.trainSchedulingService.dispatchSchedule(id, dto, resolveAuthUserId(user));
|
||||
}
|
||||
|
||||
@Get("intercity/bookings")
|
||||
@@ -613,6 +619,68 @@ export class TrainSchedulingController {
|
||||
return this.bookingJourneyService.listYardWork(id);
|
||||
}
|
||||
|
||||
@Post("schedules/:id/stations/:yardId/loading/start")
|
||||
@TrainSchedulingLoadingStart()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Start (or correct, via `at`) this station's loading time window — required before bookings can be loaded there",
|
||||
})
|
||||
startStationLoading(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Param("yardId", ParseUUIDPipe) yardId: string,
|
||||
@Body() dto: StationWorkDto,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
return this.bookingJourneyService.recordStationWork(
|
||||
id, yardId, "loading", "start", dto.at, resolveAuthUserId(user),
|
||||
);
|
||||
}
|
||||
|
||||
@Post("schedules/:id/stations/:yardId/loading/end")
|
||||
@TrainSchedulingLoadingEnd()
|
||||
@ApiOperation({ summary: "End (or correct, via `at`) this station's loading time window" })
|
||||
endStationLoading(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Param("yardId", ParseUUIDPipe) yardId: string,
|
||||
@Body() dto: StationWorkDto,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
return this.bookingJourneyService.recordStationWork(
|
||||
id, yardId, "loading", "end", dto.at, resolveAuthUserId(user),
|
||||
);
|
||||
}
|
||||
|
||||
@Post("schedules/:id/stations/:yardId/unloading/start")
|
||||
@TrainSchedulingUnloadingStart()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Start (or correct, via `at`) this station's unloading time window — required before bookings can be unloaded there",
|
||||
})
|
||||
startStationUnloading(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Param("yardId", ParseUUIDPipe) yardId: string,
|
||||
@Body() dto: StationWorkDto,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
return this.bookingJourneyService.recordStationWork(
|
||||
id, yardId, "unloading", "start", dto.at, resolveAuthUserId(user),
|
||||
);
|
||||
}
|
||||
|
||||
@Post("schedules/:id/stations/:yardId/unloading/end")
|
||||
@TrainSchedulingUnloadingEnd()
|
||||
@ApiOperation({ summary: "End (or correct, via `at`) this station's unloading time window" })
|
||||
endStationUnloading(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Param("yardId", ParseUUIDPipe) yardId: string,
|
||||
@Body() dto: StationWorkDto,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
return this.bookingJourneyService.recordStationWork(
|
||||
id, yardId, "unloading", "end", dto.at, resolveAuthUserId(user),
|
||||
);
|
||||
}
|
||||
|
||||
@Post("schedules/:id/bookings/:bookingId/load")
|
||||
@TrainSchedulingLoad()
|
||||
@ApiOperation({
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { TrainCheckpointKind } from '@edr/types';
|
||||
import {
|
||||
IsArray,
|
||||
IsEnum,
|
||||
IsInt,
|
||||
IsISO8601,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
MaxLength,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
@@ -113,4 +115,20 @@ export class DispatchScheduleDto {
|
||||
@IsOptional()
|
||||
@IsISO8601()
|
||||
actualDepartureAt?: string;
|
||||
|
||||
/**
|
||||
* Loading is a manual staff decision. When present, only these bookings are
|
||||
* auto-loaded at the origin; every other unloaded origin boarder is left
|
||||
* behind — deallocated from its wagon and returned to the booking pool.
|
||||
* Absent (older clients) = load every origin boarder, the historic behavior.
|
||||
*/
|
||||
@ApiProperty({
|
||||
required: false,
|
||||
description:
|
||||
'Origin-yard bookings confirmed loaded; the rest are unassigned back to the pool. Omit to auto-load all.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsUUID('4', { each: true })
|
||||
loadedBookingIds?: string[];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsISO8601, IsOptional } from 'class-validator';
|
||||
|
||||
/**
|
||||
* A station loading/unloading window click. `at` omitted = "now" (the button
|
||||
* click); `at` given = record or correct the timestamp after the fact — same
|
||||
* endpoint, same permission.
|
||||
*/
|
||||
export class StationWorkDto {
|
||||
@ApiPropertyOptional({ description: 'ISO timestamp; omitted = now. Never in the future.' })
|
||||
@IsOptional()
|
||||
@IsISO8601()
|
||||
at?: string;
|
||||
}
|
||||
@@ -170,7 +170,7 @@ describe('TrainSchedulingService', () => {
|
||||
wagonAllocationContainerItemsRepository as never,
|
||||
wagonAllocationBulkLoadsRepository as never,
|
||||
trainCheckpointEventsRepository as never,
|
||||
{} as never, // trainCompositionRemovalLogRepository
|
||||
{ create: jest.fn() } as never, // trainCompositionRemovalLogRepository
|
||||
{
|
||||
autoUnloadArrivedBookings: jest.fn(),
|
||||
autoUnloadExportAtDjibouti: jest.fn(),
|
||||
@@ -182,7 +182,7 @@ describe('TrainSchedulingService', () => {
|
||||
{
|
||||
autoArriveAtFinalYard: jest.fn().mockResolvedValue([]),
|
||||
} as never, // bookingJourneyService
|
||||
{ dispatched: jest.fn(), arrived: jest.fn() } as never, // bookingNotifier
|
||||
{ dispatched: jest.fn(), arrived: jest.fn(), removedFromTrain: jest.fn() } as never, // bookingNotifier
|
||||
{ getLogoImageUrl: jest.fn().mockResolvedValue(null) } as never, // logoSettings
|
||||
);
|
||||
|
||||
@@ -1134,6 +1134,107 @@ describe('TrainSchedulingService', () => {
|
||||
expect(html).toContain('2 (1 empty)');
|
||||
});
|
||||
|
||||
it('marks a leg slot on the import document as TO BE LOADED and keeps it out of the loaded tallies', () => {
|
||||
const loadList = {
|
||||
generatedAt: '2026-07-17T08:00:00.000Z',
|
||||
trainScheduleId: 'schedule-1',
|
||||
trainNumber: '7002',
|
||||
route: 'DCT/SGTD → GMP',
|
||||
origin: 'DCT/SGTD',
|
||||
destination: 'GMP',
|
||||
totalBookings: 2,
|
||||
wagons: [
|
||||
{
|
||||
sequenceNo: 1,
|
||||
wagonNumber: 'W-ICY',
|
||||
boardYard: 'Dire Dawa Port',
|
||||
alightYard: null,
|
||||
allocations: [
|
||||
{
|
||||
...loadedAllocation,
|
||||
containerItems: [{ containerNumber: 'ICY-001' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
sequenceNo: 2,
|
||||
wagonNumber: 'W-IMP',
|
||||
boardYard: null,
|
||||
alightYard: null,
|
||||
allocations: [
|
||||
{
|
||||
...loadedAllocation,
|
||||
containerItems: [{ containerNumber: 'CONT-001', containerType: { sizeFt: 20 } }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
operation: { status: {} },
|
||||
};
|
||||
|
||||
const html = (service as never as {
|
||||
buildImportLoadListHtml: (l: unknown) => string;
|
||||
}).buildImportLoadListHtml(loadList);
|
||||
|
||||
expect(html).toContain('TO BE LOADED AT DIRE DAWA PORT');
|
||||
// Departure station of the leg slot is its board yard, not the origin.
|
||||
expect(html).toContain('<td>Dire Dawa Port</td>');
|
||||
// Only the origin-loaded container counts; the leg slot's tallies separately.
|
||||
expect(html).toContain('<span>Total containers</span><strong>1</strong>');
|
||||
expect(html).toContain('<span>To load en route</span><strong>1 containers</strong>');
|
||||
});
|
||||
|
||||
it('marks a leg slot on the export document as TO LOAD AT its board yard and keeps it out of the tallies', () => {
|
||||
const sizedAllocation = {
|
||||
...loadedAllocation,
|
||||
containerItems: [{ containerNumber: 'CONT-001', containerType: { sizeFt: 20 } }],
|
||||
};
|
||||
const legWagon = { ...makeWagon(2, 'W-LEG', [sizedAllocation]), id: 'slot-leg' };
|
||||
const schedule = {
|
||||
id: 'schedule-1',
|
||||
trainNumber: '8302',
|
||||
direction: 'EXPORT',
|
||||
trainSet: { wagons: [{ ...makeWagon(1, 'W-001', [sizedAllocation]), id: 'slot-1' }, legWagon] },
|
||||
scheduleBookings: [],
|
||||
};
|
||||
|
||||
const html = (service as never as {
|
||||
buildExportLoadListHtml: (s: unknown, o?: unknown) => string;
|
||||
}).buildExportLoadListHtml(schedule, {
|
||||
pendingBoardYardLabelBySlot: new Map([['slot-leg', 'Dire Dawa Port']]),
|
||||
});
|
||||
|
||||
expect(html).toContain('TO LOAD AT DIRE DAWA PORT');
|
||||
expect(html).toContain('<span>Total containers</span><strong>1</strong>');
|
||||
expect(html).toContain('<span>To load en route</span><strong>1 containers</strong>');
|
||||
});
|
||||
|
||||
it('prints coupled/switched wagons logged at this stop, and omits the box when there are none', () => {
|
||||
const schedule = {
|
||||
id: 'schedule-1',
|
||||
trainNumber: '8302',
|
||||
direction: 'EXPORT',
|
||||
trainSet: { wagons: [{ ...makeWagon(1, 'W-001', [loadedAllocation]), id: 'slot-1' }] },
|
||||
scheduleBookings: [],
|
||||
};
|
||||
const build = (service as never as {
|
||||
buildExportLoadListHtml: (s: unknown, o?: unknown) => string;
|
||||
}).buildExportLoadListHtml.bind(service);
|
||||
|
||||
const withChanges = build(schedule, {
|
||||
consistChangesAtStop: [
|
||||
{ action: 'ADD', wagonNumber: 'W-1002' },
|
||||
{ action: 'SWITCH', wagonNumber: 'W-0501 → W-1003' },
|
||||
],
|
||||
});
|
||||
expect(withChanges).toContain('Consist changed at this stop');
|
||||
expect(withChanges).toContain('Coupled: W-1002');
|
||||
expect(withChanges).toContain('Uncoupled — replaced: W-0501 → W-1003');
|
||||
|
||||
const withoutChanges = build(schedule, {});
|
||||
expect(withoutChanges).not.toContain('Consist changed at this stop');
|
||||
});
|
||||
|
||||
it('lists loaded empty containers by number and states they are empty', () => {
|
||||
const schedule = {
|
||||
id: 'schedule-1',
|
||||
@@ -1264,6 +1365,25 @@ describe('TrainSchedulingService', () => {
|
||||
expect(html).toContain('2 (1 empty)');
|
||||
});
|
||||
|
||||
it('keeps whole-route cargo whose allocation never left PLANNED (import flow) on board', () => {
|
||||
// The import flow confirms loading at schedule level and never flips the
|
||||
// allocation to LOADED — the cargo is still on the train until DEPARTED.
|
||||
const schedule = {
|
||||
trainSet: {
|
||||
wagons: [
|
||||
{ ...makeWagon(1, 'W-IMP', [allocWith({ status: 'PLANNED' })]), status: 'RESERVED' },
|
||||
{ ...makeWagon(2, 'W-ICY', [allocWith({ status: 'LOADED', bookingId: 'booking-2' })]), status: 'RESERVED', boardYardId: 'yard-mid' },
|
||||
],
|
||||
},
|
||||
scheduleBookings: [],
|
||||
};
|
||||
|
||||
const { wagons } = onBoardView(schedule);
|
||||
const byNumber = wagons as Array<{ physicalWagon: { wagonNumber: string }; allocations: unknown[] }>;
|
||||
expect(byNumber.map((w) => w.physicalWagon.wagonNumber)).toEqual(['W-IMP', 'W-ICY']);
|
||||
expect(byNumber[0].allocations).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('hides a leg slot (boardYardId set) until it has confirmed LOADED cargo', () => {
|
||||
const legWagonEmpty = { ...makeWagon(2, 'W-LEG', [allocWith({ status: 'RESERVED' })]), status: 'RESERVED', boardYardId: 'yard-mid' };
|
||||
const legWagonLoaded = { ...makeWagon(3, 'W-LEG2', [allocWith({ status: 'LOADED' })]), status: 'RESERVED', boardYardId: 'yard-mid' };
|
||||
@@ -1640,6 +1760,85 @@ describe('TrainSchedulingService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('unassignBooking — MANUAL_ONLY status', () => {
|
||||
const scheduleId = 'sched-rm-1';
|
||||
const removed = makeBooking('bk-rm', 'BKG-RM', 100, 5, '20FT', 5, undefined, undefined, undefined, {
|
||||
status: 'PAID',
|
||||
wagonsRequired: 5,
|
||||
});
|
||||
|
||||
const graph = {
|
||||
id: scheduleId,
|
||||
status: 'DRAFT',
|
||||
originStationId: 'yard-origin',
|
||||
destinationStationId: 'yard-destination',
|
||||
trainSetId: 'ts-rm',
|
||||
trainSet: {
|
||||
id: 'ts-rm',
|
||||
locomotive,
|
||||
trainId: null,
|
||||
wagons: [{ id: 'tsw-rm-1', allocations: [{ id: 'alloc-rm-1', bookingId: 'bk-rm' }] }],
|
||||
},
|
||||
scheduleBookings: [{ bookingId: 'bk-rm' }],
|
||||
};
|
||||
|
||||
const txManager = {
|
||||
getRepository: jest.fn(() => ({
|
||||
find: jest.fn().mockResolvedValue([]),
|
||||
findOne: jest.fn().mockResolvedValue(null),
|
||||
update: jest.fn().mockResolvedValue(undefined),
|
||||
delete: jest.fn().mockResolvedValue(undefined),
|
||||
save: jest.fn().mockResolvedValue(undefined),
|
||||
create: jest.fn((x: unknown) => x),
|
||||
})),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue(graph);
|
||||
bookingsRepository.findById = jest.fn().mockResolvedValue(removed);
|
||||
bookingsRepository.updateSchedulingFields.mockResolvedValue(undefined);
|
||||
dataSource.transaction.mockImplementation(
|
||||
async (fn: (m: unknown) => Promise<void>) => fn(txManager),
|
||||
);
|
||||
jest
|
||||
.spyOn(
|
||||
service as never as { getTrainScheduleById: (id: string) => Promise<unknown> },
|
||||
'getTrainScheduleById' as never,
|
||||
)
|
||||
.mockResolvedValue({ id: scheduleId } as never);
|
||||
});
|
||||
|
||||
it('marks a staff-removed paid booking MANUAL_ONLY and fully detaches it', async () => {
|
||||
await service.unassignBooking(scheduleId, 'bk-rm', 'user-1');
|
||||
|
||||
expect(bookingsRepository.updateSchedulingFields).toHaveBeenCalledWith(
|
||||
'bk-rm',
|
||||
expect.objectContaining({
|
||||
schedulingStatus: 'MANUAL_ONLY',
|
||||
trainScheduleId: null,
|
||||
wagonsRequired: null,
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
expect(trainScheduleBookingsRepository.deleteByScheduleAndBooking).toHaveBeenCalledWith(
|
||||
scheduleId,
|
||||
'bk-rm',
|
||||
expect.anything(),
|
||||
);
|
||||
expect(wagonAllocationContainerItemsRepository.deleteByAllocationIds).toHaveBeenCalledWith(
|
||||
['alloc-rm-1'],
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('never marks ELIGIBLE — a removed booking must not rejoin the auto pool', async () => {
|
||||
await service.unassignBooking(scheduleId, 'bk-rm', 'user-1');
|
||||
|
||||
const updates = bookingsRepository.updateSchedulingFields.mock.calls.map((c) => c[1]);
|
||||
expect(updates.some((u) => u.schedulingStatus === 'ELIGIBLE')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateCheckpoint — leg time correction', () => {
|
||||
const t = (h: number) => new Date(Date.UTC(2026, 0, 1, h));
|
||||
const schedule = {
|
||||
|
||||
@@ -55,7 +55,10 @@ import { TrainSetWagon } from '../../train-sets/entities/train-set-wagon.entity'
|
||||
import { TrainSet } from '../../train-sets/entities/train-set.entity';
|
||||
import { ScheduleWagonAdjustmentLog } from '../../train-schedules/entities/schedule-wagon-adjustment-log.entity';
|
||||
import { TrainScheduleBooking } from '../../train-schedules/entities/train-schedule-booking.entity';
|
||||
import { TrainSchedule } from '../../train-schedules/entities/train-schedule.entity';
|
||||
import {
|
||||
TrainSchedule,
|
||||
type StationWorkPhaseLog,
|
||||
} from '../../train-schedules/entities/train-schedule.entity';
|
||||
import { WagonAllocationContainerItem } from '../../train-schedules/entities/wagon-allocation-container-item.entity';
|
||||
import { WagonBookingAllocation } from '../../train-schedules/entities/wagon-booking-allocation.entity';
|
||||
import { TrainScheduleBookingsRepository } from '../../train-schedules/train-schedule-bookings.repository';
|
||||
@@ -156,7 +159,7 @@ import {
|
||||
bookingCargoTons,
|
||||
bulkItemsFitFor,
|
||||
bulkItemWagonsRequired,
|
||||
bulkTonsPerWagon,
|
||||
bulkTonsPerWagonFor,
|
||||
consistViolations,
|
||||
deriveTrainCapacityFromLocomotive,
|
||||
combinedLocomotiveLimits,
|
||||
@@ -2370,16 +2373,22 @@ export class TrainSchedulingService {
|
||||
if (!schedule) {
|
||||
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
||||
}
|
||||
if (!['DRAFT', 'SCHEDULED'].includes(schedule.status)) {
|
||||
throw new BadRequestException('Cannot unassign from a finalized or dispatched schedule');
|
||||
}
|
||||
|
||||
const link = schedule.scheduleBookings?.find((sb) => sb.bookingId === bookingId);
|
||||
if (!link) {
|
||||
throw new NotFoundException(`Booking ${bookingId} is not assigned to this schedule`);
|
||||
}
|
||||
|
||||
const booking = await this.bookingsRepository.findById(bookingId);
|
||||
// A dispatched train may still shed a booking staff left behind at its
|
||||
// boarding yard (dispatch dialog / log-pass "leave") — but never one whose
|
||||
// cargo is actually on the train.
|
||||
const leftBehindWhileDispatched =
|
||||
schedule.status === 'DISPATCHED' &&
|
||||
!booking?.loadedAt &&
|
||||
booking?.status !== 'IN_TRANSIT';
|
||||
if (!['DRAFT', 'SCHEDULED'].includes(schedule.status) && !leftBehindWhileDispatched) {
|
||||
throw new BadRequestException('Cannot unassign from a finalized or dispatched schedule');
|
||||
}
|
||||
if (booking?.isGovernment) {
|
||||
throw new BadRequestException(
|
||||
'Government bookings cannot be removed from a train. They can only be switched onto another allocation.',
|
||||
@@ -2409,7 +2418,12 @@ export class TrainSchedulingService {
|
||||
);
|
||||
|
||||
const booking = await this.bookingsRepository.findById(bookingId);
|
||||
const schedulingStatus = this.resolvePostUnassignStatus(booking);
|
||||
// Removed from a train by staff → MANUAL_ONLY: the paid booking must not
|
||||
// be auto re-placed by any allocation sweep (it would retake the space it
|
||||
// was just pulled from). Staff re-assign it manually; assign resets the
|
||||
// status to SCHEDULED. Schedule *cancellation* keeps the old behaviour
|
||||
// (resolvePostUnassignStatus) — there the train died, not the booking.
|
||||
const schedulingStatus = SchedulingStatus.ManualOnly;
|
||||
// Clear the schedule pointer too: unassign fully detaches the booking from
|
||||
// this train. Leaving trainScheduleId set glued the booking to a schedule
|
||||
// that may then be dispatched/cancelled/deleted, orphaning it — the
|
||||
@@ -2447,6 +2461,21 @@ export class TrainSchedulingService {
|
||||
for (const slot of survivingSlots) {
|
||||
const slotAllocations = slot.allocations ?? [];
|
||||
if (slotAllocations.length === 0) {
|
||||
// A dispatched train pinned its wagons (ASSIGNED + schedule id) at
|
||||
// departure — freeing the slot must also free the physical wagon, or
|
||||
// the checkpoint position-fix keeps dragging it along the corridor.
|
||||
if (schedule.status === 'DISPATCHED' && slot.physicalWagonId) {
|
||||
const wagon = await manager
|
||||
.getRepository(Wagon)
|
||||
.findOne({ where: { id: slot.physicalWagonId } });
|
||||
if (wagon && wagon.currentTrainScheduleId === scheduleId) {
|
||||
await manager.getRepository(Wagon).update(wagon.id, {
|
||||
currentTrainScheduleId: null,
|
||||
trainSetWagonId: null,
|
||||
status: wagon.trainId ? WagonStatus.Assigned : WagonStatus.Available,
|
||||
});
|
||||
}
|
||||
}
|
||||
await manager.getRepository(TrainSetWagon).delete(slot.id);
|
||||
continue;
|
||||
}
|
||||
@@ -2476,8 +2505,10 @@ export class TrainSchedulingService {
|
||||
|
||||
// Freed wagons may un-full the train — re-derive the window status (this
|
||||
// also revives a DONE window pre-departure so the freed space is bookable
|
||||
// again for import/export).
|
||||
await this.bookingBatchService?.refreshWindowStatus(scheduleId);
|
||||
// again for import/export). A dispatched train's window stays CLOSED.
|
||||
if (schedule.status !== 'DISPATCHED') {
|
||||
await this.bookingBatchService?.refreshWindowStatus(scheduleId);
|
||||
}
|
||||
|
||||
await this.trainCompositionRemovalLogRepository.create({
|
||||
scheduleId,
|
||||
@@ -2842,14 +2873,50 @@ export class TrainSchedulingService {
|
||||
return this.getTrainScheduleById(scheduleId);
|
||||
}
|
||||
|
||||
async dispatchSchedule(scheduleId: string, dto: DispatchScheduleDto = {}) {
|
||||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||||
async dispatchSchedule(scheduleId: string, dto: DispatchScheduleDto = {}, userId?: string) {
|
||||
let schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||||
if (!schedule) {
|
||||
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
||||
}
|
||||
if (schedule.status !== TrainScheduleStatusEnum.Scheduled) {
|
||||
throw new BadRequestException('Only SCHEDULED trains can be dispatched');
|
||||
}
|
||||
// Loading is a manual staff decision: when the dispatch dialog sends the
|
||||
// checked list, every other unloaded origin boarder is left behind —
|
||||
// deallocated from its wagon and returned to the booking pool — so the
|
||||
// origin auto-load below only ever touches confirmed cargo. Government
|
||||
// bookings cannot be unassigned and keep the historic auto-load.
|
||||
if (dto.loadedBookingIds) {
|
||||
const keep = new Set(dto.loadedBookingIds);
|
||||
const candidates = await this.unloadedOriginBoarderIds(scheduleId, schedule.originStationId);
|
||||
const leftBehind = candidates.filter((id) => !keep.has(id));
|
||||
for (const bookingId of leftBehind) {
|
||||
await this.unassignBooking(scheduleId, bookingId, userId);
|
||||
}
|
||||
if (leftBehind.length) {
|
||||
// Unassign deleted allocations and slots — reload the graph dispatch works on.
|
||||
const reloaded = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||||
if (!reloaded) {
|
||||
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
||||
}
|
||||
schedule = reloaded;
|
||||
}
|
||||
}
|
||||
// Dispatch requires the origin's loading window to be COMPLETE: started
|
||||
// and ended. Not started or still open both block — a train departs only
|
||||
// after loading was formally opened and closed.
|
||||
const originLoadingLog =
|
||||
schedule.stationWorkLogs?.[schedule.originStationId]?.loading;
|
||||
if (!originLoadingLog?.startedAt) {
|
||||
throw new BadRequestException(
|
||||
'Start (and end) the loading window at the origin station before dispatching',
|
||||
);
|
||||
}
|
||||
if (!originLoadingLog?.endedAt) {
|
||||
throw new BadRequestException(
|
||||
'End the loading window at the origin station before dispatching',
|
||||
);
|
||||
}
|
||||
// Staff may record the departure after the fact — past is fine, future is not.
|
||||
const now = dto.actualDepartureAt ? new Date(dto.actualDepartureAt) : new Date();
|
||||
this.assertNotFuture(now, 'Departure time');
|
||||
@@ -2895,7 +2962,7 @@ export class TrainSchedulingService {
|
||||
actualDepartureAt: now,
|
||||
trainNumber,
|
||||
// Freeze the wagon plan the moment the train leaves the editable phase.
|
||||
wagonAllocationSnapshot: this.buildWagonAllocationSnapshot(
|
||||
wagonAllocationSnapshot: await this.buildWagonAllocationSnapshot(
|
||||
schedule,
|
||||
TrainScheduleStatusEnum.Dispatched,
|
||||
now,
|
||||
@@ -3071,6 +3138,34 @@ export class TrainSchedulingService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Origin boarders the dispatch dialog decides over: unloaded (no journey
|
||||
* load, no workspace LOADED flag), boardable, non-government. Boardable is
|
||||
* PAID — or FULLY_EXECUTED for shipping-line bookings, which never prepay
|
||||
* (their charge sits on the credit ledger) yet ride from accept.
|
||||
*/
|
||||
private async unloadedOriginBoarderIds(
|
||||
scheduleId: string,
|
||||
originYardId: string,
|
||||
): Promise<string[]> {
|
||||
const rows: Array<{ id: string }> = await this.dataSource.query(
|
||||
`SELECT b.id
|
||||
FROM freight.bookings b
|
||||
JOIN freight.train_schedule_bookings tsb ON tsb.booking_id = b.id
|
||||
WHERE tsb.train_schedule_id = $1
|
||||
AND tsb.deleted_at IS NULL
|
||||
AND b.deleted_at IS NULL
|
||||
AND b.origin_yard_id = $2
|
||||
AND b.loaded_at IS NULL
|
||||
AND COALESCE(tsb.loading_status, 'UNLOADED') <> 'LOADED'
|
||||
AND b.is_government = false
|
||||
AND (b.status = 'PAID'
|
||||
OR (b.shipping_line_company_id IS NOT NULL AND b.status = 'FULLY_EXECUTED'))`,
|
||||
[scheduleId, originYardId],
|
||||
);
|
||||
return rows.map((r) => r.id);
|
||||
}
|
||||
|
||||
async getImportDjiboutiOperation(scheduleId: string) {
|
||||
const schedule = await this.getDjiboutiGatepassSchedule(scheduleId);
|
||||
const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId);
|
||||
@@ -3302,6 +3397,11 @@ export class TrainSchedulingService {
|
||||
const schedule = await this.getImportDjiboutiSchedule(scheduleId);
|
||||
const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId);
|
||||
const generatedAt = operation.loadListGeneratedAt ?? new Date();
|
||||
// Leg slots (boardYardId set) couple to the train mid-corridor — this
|
||||
// Djibouti-side document must say where, not list their cargo as loaded here.
|
||||
const slotYardLabels = await this.yardLabelsById(
|
||||
(schedule.trainSet?.wagons ?? []).flatMap((wagon) => [wagon.boardYardId, wagon.alightYardId]),
|
||||
);
|
||||
|
||||
await this.dataSource.getRepository(ImportDjiboutiOperation).update(operation.id, {
|
||||
loadListGeneratedAt: generatedAt,
|
||||
@@ -3328,6 +3428,8 @@ export class TrainSchedulingService {
|
||||
wagonType: wagon.wagonType?.code ?? wagon.wagonType?.name ?? null,
|
||||
tareWeightTons: wagon.wagonType?.tareWeightTons ?? null,
|
||||
equatedLengthM: wagon.wagonType?.equatedLengthM ?? null,
|
||||
boardYard: wagon.boardYardId ? (slotYardLabels.get(wagon.boardYardId) ?? 'en route') : null,
|
||||
alightYard: wagon.alightYardId ? (slotYardLabels.get(wagon.alightYardId) ?? 'en route') : null,
|
||||
allocations: (wagon.allocations ?? []).map((allocation) => ({
|
||||
bookingId: allocation.bookingId,
|
||||
bookingReference: allocation.booking?.reference ?? null,
|
||||
@@ -3368,7 +3470,19 @@ export class TrainSchedulingService {
|
||||
throw new BadRequestException('Export marshalling document applies only to EXPORT schedules');
|
||||
}
|
||||
|
||||
// Leg slots couple mid-corridor — this origin document must say where their
|
||||
// cargo boards instead of listing it as loaded here (see the import list).
|
||||
const slotYardLabels = await this.yardLabelsById(
|
||||
(schedule.trainSet?.wagons ?? []).map((wagon) => wagon.boardYardId),
|
||||
);
|
||||
const pendingBoardYardLabelBySlot = new Map(
|
||||
(schedule.trainSet?.wagons ?? [])
|
||||
.filter((wagon) => wagon.boardYardId)
|
||||
.map((wagon) => [wagon.id, slotYardLabels.get(wagon.boardYardId!) ?? 'en route']),
|
||||
);
|
||||
|
||||
const html = this.buildExportLoadListHtml(schedule, {
|
||||
pendingBoardYardLabelBySlot,
|
||||
emptyContainers: await this.loadedEmptyContainers(scheduleId),
|
||||
logoImageUrl: await this.logoSettings.getLogoImageUrl(),
|
||||
});
|
||||
@@ -3386,8 +3500,11 @@ export class TrainSchedulingService {
|
||||
* intercity marshalling ("Marshalling 2") document printed after mid-corridor
|
||||
* station work. A wagon slot is on the train iff it has not DEPARTED and
|
||||
* either rides the whole corridor (no boardYardId) or has confirmed LOADED
|
||||
* cargo. Kept wagons carry only their LOADED allocations (DEPARTED =
|
||||
* unloaded, PLANNED/RESERVED = not on board yet).
|
||||
* cargo. Whole-route cargo counts as on board unless DEPARTED (unloaded) —
|
||||
* the import flow confirms loading at schedule level and never flips the
|
||||
* allocation to LOADED, so requiring LOADED here rendered every import wagon
|
||||
* as EMPTY. Leg slots (boardYardId set, coupled mid-corridor) still require
|
||||
* confirmed LOADED cargo before they appear.
|
||||
* ponytail: boardYardId presence is the "boarded yet?" heuristic; upgrade
|
||||
* path is comparing the board yard against the latest checkpoint sequence.
|
||||
*/
|
||||
@@ -3403,7 +3520,9 @@ export class TrainSchedulingService {
|
||||
})
|
||||
.map((wagon) => ({
|
||||
...wagon,
|
||||
allocations: (wagon.allocations ?? []).filter((a) => a.status === 'LOADED'),
|
||||
allocations: (wagon.allocations ?? []).filter((a) =>
|
||||
wagon.boardYardId == null ? a.status !== 'DEPARTED' : a.status === 'LOADED',
|
||||
),
|
||||
})) as TrainSetWagon[];
|
||||
|
||||
const onBoardBookingIds = new Set(
|
||||
@@ -3438,6 +3557,16 @@ export class TrainSchedulingService {
|
||||
: `At ${schedule.originStation?.label ?? schedule.originStation?.code ?? 'origin'} — no checkpoint recorded`;
|
||||
|
||||
const { wagons, unassignedBookings } = this.intercityOnBoardView(schedule);
|
||||
// Couples/switches logged AT THIS STOP — what staff standing here actually
|
||||
// just did to the consist. Bare trims (REMOVE, no replacement) are left
|
||||
// out: nothing new to point staff at for those. Origin adjustments (a
|
||||
// different yard) don't show up on this stop's document.
|
||||
const consistChangesAtStop = last
|
||||
? await this.dataSource.getRepository(ScheduleWagonAdjustmentLog).find({
|
||||
where: { trainScheduleId: scheduleId, yardId: last.yardId, action: In(['ADD', 'SWITCH']) },
|
||||
order: { occurredAt: 'DESC' },
|
||||
})
|
||||
: [];
|
||||
const html = this.buildExportLoadListHtml(schedule, {
|
||||
title: 'Intercity Marshalling Document / Load List (Marshalling 2)',
|
||||
positionLabel,
|
||||
@@ -3445,6 +3574,7 @@ export class TrainSchedulingService {
|
||||
unassignedBookings,
|
||||
emptyContainers: await this.loadedEmptyContainers(scheduleId),
|
||||
logoImageUrl: await this.logoSettings.getLogoImageUrl(),
|
||||
consistChangesAtStop,
|
||||
});
|
||||
// Styled table-aware fallback (marshalling grid) — see importLoadListDocument.
|
||||
const buffer = await this.pdfDocuments.renderTabularDocument(html, 'Intercity marshalling / load list');
|
||||
@@ -3493,6 +3623,15 @@ export class TrainSchedulingService {
|
||||
.find({ where: { trainScheduleId: scheduleId } });
|
||||
}
|
||||
|
||||
private async yardLabelsById(
|
||||
ids: Array<string | null | undefined>,
|
||||
): Promise<Map<string, string>> {
|
||||
const unique = [...new Set(ids.filter((id): id is string => Boolean(id)))];
|
||||
if (!unique.length) return new Map();
|
||||
const yards = await this.dataSource.getRepository(Yard).find({ where: { id: In(unique) } });
|
||||
return new Map(yards.map((yard) => [yard.id, yard.label || yard.code]));
|
||||
}
|
||||
|
||||
private buildExportLoadListHtml(
|
||||
schedule: TrainSchedule,
|
||||
opts?: {
|
||||
@@ -3502,6 +3641,13 @@ export class TrainSchedulingService {
|
||||
unassignedBookings?: Booking[];
|
||||
emptyContainers?: EmptyContainerReturn[];
|
||||
logoImageUrl?: string | null;
|
||||
// Slots that couple to the train downstream (slot id → board yard label).
|
||||
// Their cargo renders as TO LOAD AT and stays out of the loaded tallies.
|
||||
pendingBoardYardLabelBySlot?: Map<string, string>;
|
||||
// Intercity (Marshalling 2) only: couples/switches logged at the stop
|
||||
// this document is printed at (see ScheduleWagonAdjustmentLog). Origin
|
||||
// import/export docs never pass this, so they render no such box.
|
||||
consistChangesAtStop?: ScheduleWagonAdjustmentLog[];
|
||||
},
|
||||
): string {
|
||||
const esc = (value: unknown) =>
|
||||
@@ -3565,6 +3711,7 @@ export class TrainSchedulingService {
|
||||
</tr>`,
|
||||
];
|
||||
}
|
||||
const pendingAt = opts?.pendingBoardYardLabelBySlot?.get(wagon.id);
|
||||
return allocations.map((allocation) => {
|
||||
const booking = allocation.booking ?? bookingById.get(allocation.bookingId);
|
||||
const cargoType = (booking as unknown as { cargoType?: { name?: string; code?: string } } | undefined)?.cargoType;
|
||||
@@ -3576,7 +3723,7 @@ export class TrainSchedulingService {
|
||||
const chassisNumbers = containerItems.map((item) => item.chassisNumber).filter(Boolean).join(', ');
|
||||
return `<tr>
|
||||
${wagonCells}
|
||||
<td>${esc(cargoType?.name ?? cargoType?.code ?? allocation.loadType)}</td>
|
||||
<td>${pendingAt ? `TO LOAD AT ${esc(pendingAt).toUpperCase()} — ` : ''}${esc(cargoType?.name ?? cargoType?.code ?? allocation.loadType)}</td>
|
||||
<td>${esc(companyName)}</td>
|
||||
<td>${esc(containerNumbers || firstContainer?.containerNumber)}</td>
|
||||
<td>${esc(chassisNumbers)}</td>
|
||||
@@ -3613,18 +3760,27 @@ export class TrainSchedulingService {
|
||||
(wagon.allocations ?? []).length === 0 &&
|
||||
!emptiesByWagon.get(Number(wagon.sequenceNo))?.length,
|
||||
).length;
|
||||
const loadsHere = (wagon: TrainSetWagon) => !opts?.pendingBoardYardLabelBySlot?.get(wagon.id);
|
||||
const totalWeight = wagons.reduce(
|
||||
(sum, wagon) =>
|
||||
sum + (wagon.allocations ?? []).reduce((wagonSum, allocation) => wagonSum + Number(allocation.allocatedWeightTons || 0), 0),
|
||||
sum +
|
||||
(loadsHere(wagon)
|
||||
? (wagon.allocations ?? []).reduce((wagonSum, allocation) => wagonSum + Number(allocation.allocatedWeightTons || 0), 0)
|
||||
: 0),
|
||||
0,
|
||||
);
|
||||
|
||||
// Container count summary (40ft, 20ft) — empties returning to Djibouti are
|
||||
// physically on the train, so they count, and are called out on their own tile.
|
||||
let count40ft = 0, count20ft = 0;
|
||||
// Cargo boarding downstream is not on this train yet — it tallies separately.
|
||||
let count40ft = 0, count20ft = 0, pendingContainers = 0;
|
||||
wagons.forEach((wagon) => {
|
||||
(wagon.allocations ?? []).forEach((allocation) => {
|
||||
(allocation.containerItems ?? []).forEach((item) => {
|
||||
if (!loadsHere(wagon)) {
|
||||
pendingContainers++;
|
||||
return;
|
||||
}
|
||||
const size = this.resolveContainerItemSize(item);
|
||||
if (size === 40) count40ft++;
|
||||
else if (size === 20) count20ft++;
|
||||
@@ -3691,6 +3847,7 @@ export class TrainSchedulingService {
|
||||
<div class="tile"><span>Containers 40ft</span><strong>${esc(count40ft)}</strong></div>
|
||||
<div class="tile"><span>Containers 20ft</span><strong>${esc(count20ft)}</strong></div>
|
||||
<div class="tile"><span>Total containers</span><strong>${esc(count40ft + count20ft)}</strong></div>
|
||||
${pendingContainers ? `<div class="tile"><span>To load en route</span><strong>${esc(pendingContainers)} containers</strong></div>` : ''}
|
||||
${emptyContainers.length ? `<div class="tile"><span>Empty containers</span><strong>${esc(emptyContainers.length)}</strong></div>` : ''}
|
||||
<div class="tile"><span>Prepared person</span><strong>${esc(schedule.preparedByUserId)}</strong></div>
|
||||
<div class="tile"><span>Check person</span><strong>${esc(schedule.checkedByUserId)}</strong></div>
|
||||
@@ -3701,6 +3858,24 @@ export class TrainSchedulingService {
|
||||
${opts?.positionLabel ? `<div class="tile"><span>Current position</span><strong>${esc(opts.positionLabel)}</strong></div>` : ''}
|
||||
</div>
|
||||
|
||||
${
|
||||
opts?.consistChangesAtStop?.length
|
||||
? `<div class="notice">
|
||||
<b>Consist changed at this stop:</b>
|
||||
${(() => {
|
||||
const coupled = opts.consistChangesAtStop.filter((row) => row.action === 'ADD');
|
||||
const switched = opts.consistChangesAtStop.filter((row) => row.action === 'SWITCH');
|
||||
return [
|
||||
coupled.length ? `Coupled: ${esc(coupled.map((row) => row.wagonNumber).join(', '))}` : '',
|
||||
switched.length ? `Uncoupled — replaced: ${esc(switched.map((row) => row.wagonNumber).join(', '))}` : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' | ');
|
||||
})()}
|
||||
</div>`
|
||||
: ''
|
||||
}
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -3864,19 +4039,30 @@ export class TrainSchedulingService {
|
||||
.replace(/'/g, ''');
|
||||
const date = (value: unknown) => (value ? new Date(value as string | Date).toLocaleString('en-GB') : '-');
|
||||
const status = loadList.operation.status;
|
||||
// A leg slot (boardYard set) couples mid-corridor — its cargo is NOT on the
|
||||
// physical train this Djibouti-side document is checked against, so it must
|
||||
// stay out of the loaded tallies or the gate count stops matching.
|
||||
const loadsHere = (wagon: (typeof loadList.wagons)[number]) => !wagon.boardYard;
|
||||
const totalAllocations = loadList.wagons.reduce((sum, wagon) => sum + wagon.allocations.length, 0);
|
||||
const totalWeight = loadList.wagons.reduce(
|
||||
(sum, wagon) =>
|
||||
sum + wagon.allocations.reduce((wagonSum, allocation) => wagonSum + Number(allocation.allocatedWeightTons || 0), 0),
|
||||
sum +
|
||||
(loadsHere(wagon)
|
||||
? wagon.allocations.reduce((wagonSum, allocation) => wagonSum + Number(allocation.allocatedWeightTons || 0), 0)
|
||||
: 0),
|
||||
0,
|
||||
);
|
||||
const emptyWagons = loadList.wagons.filter((wagon) => wagon.allocations.length === 0).length;
|
||||
|
||||
// Container count summary (40ft, 20ft)
|
||||
let count40ft = 0, count20ft = 0;
|
||||
// Container count summary (40ft, 20ft) — loaded at origin vs. en route
|
||||
let count40ft = 0, count20ft = 0, pendingContainers = 0;
|
||||
loadList.wagons.forEach((wagon) => {
|
||||
wagon.allocations.forEach((allocation) => {
|
||||
(allocation.containerItems ?? []).forEach((item) => {
|
||||
if (!loadsHere(wagon)) {
|
||||
pendingContainers++;
|
||||
return;
|
||||
}
|
||||
const size = this.resolveContainerItemSize(item);
|
||||
if (size === 40) count40ft++;
|
||||
else if (size === 20) count20ft++;
|
||||
@@ -3891,8 +4077,8 @@ export class TrainSchedulingService {
|
||||
<td>${esc(wagon.wagonType)}</td>
|
||||
<td class="num">${wagon.tareWeightTons == null ? '-' : esc(Number(wagon.tareWeightTons).toFixed(2))}</td>
|
||||
<td class="num">${wagon.equatedLengthM == null ? '-' : esc(Number(wagon.equatedLengthM).toFixed(3))}</td>
|
||||
<td>${esc(loadList.origin)}</td>
|
||||
<td>${esc(loadList.destination)}</td>`;
|
||||
<td>${esc(wagon.boardYard ?? loadList.origin)}</td>
|
||||
<td>${esc(wagon.alightYard ?? loadList.destination)}</td>`;
|
||||
// An empty wagon still runs in the consist, so it still gets a line — see
|
||||
// buildExportLoadListHtml.
|
||||
if (wagon.allocations.length === 0) {
|
||||
@@ -3917,7 +4103,7 @@ export class TrainSchedulingService {
|
||||
<td>${esc(allocation.loadType)}</td>
|
||||
<td>${esc(allocation.containerNumbers.length ? allocation.containerNumbers.join(', ') : '-')}</td>
|
||||
<td>${esc(sealNumbers || '-')}</td>
|
||||
<td></td>
|
||||
<td>${wagon.boardYard ? `TO BE LOADED AT ${esc(wagon.boardYard).toUpperCase()}` : ''}</td>
|
||||
<td class="num">${esc(Number(allocation.allocatedWeightTons || 0).toFixed(3))}</td>
|
||||
</tr>`;
|
||||
},
|
||||
@@ -3990,6 +4176,7 @@ export class TrainSchedulingService {
|
||||
<div class="tile"><span>Containers 40ft</span><strong>${esc(count40ft)}</strong></div>
|
||||
<div class="tile"><span>Containers 20ft</span><strong>${esc(count20ft)}</strong></div>
|
||||
<div class="tile"><span>Total containers</span><strong>${esc(count40ft + count20ft)}</strong></div>
|
||||
${pendingContainers ? `<div class="tile"><span>To load en route</span><strong>${esc(pendingContainers)} containers</strong></div>` : ''}
|
||||
<div class="tile"><span>Gatepass granted</span><strong>${esc(date(loadList.operation.gatepassGrantedAt))}</strong></div>
|
||||
</div>
|
||||
|
||||
@@ -4334,6 +4521,48 @@ export class TrainSchedulingService {
|
||||
}
|
||||
|
||||
/** Track payload for a schedule: ordered stations, logged checkpoints, current position. */
|
||||
/** Attach `startedByName` / `endedByName` to each work-window phase (one iam lookup). */
|
||||
private async stationWorkLogsWithNames(
|
||||
logs: TrainSchedule['stationWorkLogs'],
|
||||
): Promise<Record<string, unknown>> {
|
||||
const workLogs = logs ?? {};
|
||||
const userIds = [
|
||||
...new Set(
|
||||
Object.values(workLogs)
|
||||
.flatMap((log) => [
|
||||
log.loading?.startedByUserId,
|
||||
log.loading?.endedByUserId,
|
||||
log.unloading?.startedByUserId,
|
||||
log.unloading?.endedByUserId,
|
||||
])
|
||||
.filter((id): id is string => Boolean(id)),
|
||||
),
|
||||
];
|
||||
const rows: Array<{ id: string; name: string | null }> = userIds.length
|
||||
? await this.dataSource.query(
|
||||
`SELECT id, COALESCE(username, email) AS name FROM iam.users WHERE id = ANY($1::uuid[])`,
|
||||
[userIds],
|
||||
)
|
||||
: [];
|
||||
const nameById = new Map(rows.map((r) => [r.id, r.name]));
|
||||
const withNames = (phase?: StationWorkPhaseLog) =>
|
||||
phase
|
||||
? {
|
||||
...phase,
|
||||
startedByName: phase.startedByUserId
|
||||
? nameById.get(phase.startedByUserId) ?? null
|
||||
: null,
|
||||
endedByName: phase.endedByUserId ? nameById.get(phase.endedByUserId) ?? null : null,
|
||||
}
|
||||
: undefined;
|
||||
return Object.fromEntries(
|
||||
Object.entries(workLogs).map(([yardId, log]) => [
|
||||
yardId,
|
||||
{ loading: withNames(log.loading), unloading: withNames(log.unloading) },
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
async getScheduleCheckpoints(scheduleId: string) {
|
||||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||||
if (!schedule) {
|
||||
@@ -4374,6 +4603,10 @@ export class TrainSchedulingService {
|
||||
origin: stations[0]?.label ?? null,
|
||||
destination: stations[stations.length - 1]?.label ?? null,
|
||||
stations,
|
||||
// Per-yard loading/unloading time windows for the track page's
|
||||
// start/end buttons and elapsed-time display — with the recorder's
|
||||
// display name resolved so staff see WHO started/ended each window.
|
||||
stationWorkLogs: await this.stationWorkLogsWithNames(schedule.stationWorkLogs),
|
||||
currentSequenceNo,
|
||||
checkpoints: events.map((e) => ({
|
||||
id: e.id,
|
||||
@@ -4848,6 +5081,15 @@ export class TrainSchedulingService {
|
||||
if (schedule.status !== TrainScheduleStatusEnum.Dispatched) {
|
||||
throw new BadRequestException('Only DISPATCHED trains can arrive');
|
||||
}
|
||||
// Arrival happens BEFORE unloading: the train is marked arrived whenever
|
||||
// it physically gets there, and the destination's unloading window opens
|
||||
// afterwards. The bulk booking sweep (autoArriveAtFinalYard) only runs
|
||||
// when that window is already open — otherwise final-yard bookings stay
|
||||
// IN_TRANSIT and are unloaded per booking once staff start unloading
|
||||
// (the per-booking endpoint enforces the window itself).
|
||||
const destinationUnloadingStarted = Boolean(
|
||||
schedule.stationWorkLogs?.[schedule.destinationStationId]?.unloading?.startedAt,
|
||||
);
|
||||
|
||||
// The arrival clock: the operator's entered time when arriving via the final
|
||||
// checkpoint (already order/future-checked there), else now.
|
||||
@@ -4860,7 +5102,7 @@ export class TrainSchedulingService {
|
||||
{
|
||||
actualArrivalAt: now,
|
||||
// Freeze the plan before the wagons below are released to their yards.
|
||||
wagonAllocationSnapshot: this.buildWagonAllocationSnapshot(
|
||||
wagonAllocationSnapshot: await this.buildWagonAllocationSnapshot(
|
||||
schedule,
|
||||
TrainScheduleStatusEnum.Arrived,
|
||||
now,
|
||||
@@ -4888,7 +5130,12 @@ export class TrainSchedulingService {
|
||||
// operator didn't unload individually get their arrival stamped now as a
|
||||
// bulk fallback. Mid-corridor bookings are NOT touched — their arrival is
|
||||
// their own unload (possibly already done while the train kept rolling).
|
||||
await this.bookingJourneyService.autoArriveAtFinalYard(manager, schedule, now);
|
||||
// Runs only when the destination's unloading window is already open —
|
||||
// otherwise arrival precedes unloading and staff unload per booking
|
||||
// after starting the window.
|
||||
if (destinationUnloadingStarted) {
|
||||
await this.bookingJourneyService.autoArriveAtFinalYard(manager, schedule, now);
|
||||
}
|
||||
|
||||
// Release every locomotive of the set (not just the legacy primary) and move it
|
||||
// to the destination yard where it physically arrived.
|
||||
@@ -5234,7 +5481,7 @@ export class TrainSchedulingService {
|
||||
bookingWindowStatus: 'CLOSED',
|
||||
windowPhase: 'DONE',
|
||||
// Freeze the plan before the wagons below are released back to the yard.
|
||||
wagonAllocationSnapshot: this.buildWagonAllocationSnapshot(
|
||||
wagonAllocationSnapshot: await this.buildWagonAllocationSnapshot(
|
||||
schedule,
|
||||
TrainScheduleStatusEnum.Cancelled,
|
||||
now,
|
||||
@@ -6021,11 +6268,11 @@ export class TrainSchedulingService {
|
||||
* physical wagons, so the historical allocation survives those wagons being
|
||||
* re-pinned onto later trains. `capturedStatus` is the status being applied.
|
||||
*/
|
||||
private buildWagonAllocationSnapshot(
|
||||
private async buildWagonAllocationSnapshot(
|
||||
schedule: TrainSchedule,
|
||||
capturedStatus: TrainScheduleStatusEnum,
|
||||
capturedAt: Date,
|
||||
): WagonAllocationSnapshot {
|
||||
): Promise<WagonAllocationSnapshot> {
|
||||
const slots = [...(schedule.trainSet?.wagons ?? [])]
|
||||
.sort((a, b) => a.sequenceNo - b.sequenceNo)
|
||||
.map((wagon) => ({
|
||||
@@ -6049,10 +6296,41 @@ export class TrainSchedulingService {
|
||||
})),
|
||||
}));
|
||||
|
||||
// A built train hauls EVERY coupled wagon, empties included. After this
|
||||
// transition the physical wagons are released and re-pinned to later
|
||||
// trains, so capture the empty consist here — it is the only durable
|
||||
// record of which empties rode this departure (history + yard tracking).
|
||||
const coveredPhysicalIds = new Set(
|
||||
slots.map((slot) => slot.physicalWagonId).filter(Boolean),
|
||||
);
|
||||
const trainWagons = schedule.trainSet?.trainId
|
||||
? await this.dataSource.getRepository(Wagon).find({
|
||||
where: { trainId: schedule.trainSet.trainId },
|
||||
relations: { wagonType: true },
|
||||
order: { sequenceNumber: 'ASC' },
|
||||
})
|
||||
: [];
|
||||
const emptyConsistWagons = trainWagons
|
||||
.filter((wagon) => !coveredPhysicalIds.has(wagon.id))
|
||||
.map((wagon, index) => ({
|
||||
physicalWagonId: wagon.id,
|
||||
physicalWagonNumber: wagon.wagonNumber ?? null,
|
||||
sequenceNo: wagon.sequenceNumber ?? slots.length + index + 1,
|
||||
wagonTypeId: wagon.wagonTypeId ?? null,
|
||||
wagonTypeCode: wagon.wagonType?.code ?? null,
|
||||
wagonTypeName: wagon.wagonType?.name ?? null,
|
||||
capacityTons: Number(wagon.wagonType?.capacityTons ?? 0),
|
||||
tareWeightTons: wagon.wagonType
|
||||
? Number(wagon.wagonType.tareWeightTons)
|
||||
: null,
|
||||
lengthMeters: Number(wagon.wagonType?.lengthMeters ?? 0),
|
||||
}));
|
||||
|
||||
return {
|
||||
capturedStatus,
|
||||
capturedAt: capturedAt.toISOString(),
|
||||
slots,
|
||||
emptyConsistWagons,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -9358,6 +9636,8 @@ export class TrainSchedulingService {
|
||||
Booking,
|
||||
| 'freightType'
|
||||
| 'cargoTotalWeightVgm'
|
||||
| 'bulkTotalWeightTons'
|
||||
| 'bulkRequestedWagons'
|
||||
| 'wagonsRequired'
|
||||
| 'bookingContainers'
|
||||
| 'cargoType'
|
||||
@@ -9388,7 +9668,7 @@ export class TrainSchedulingService {
|
||||
const byLength = containerWagonsForLines(booking.bookingContainers ?? []);
|
||||
// PER_TON cargo may cap tons per wagon below the rating (sugar 50T on a 70T
|
||||
// wagon) — more wagons for the same cargo, so more tare to pull.
|
||||
const tonsPerWagon = bulkTonsPerWagon(booking.cargoType, wagonTypeId, dims.capacityTons);
|
||||
const tonsPerWagon = bulkTonsPerWagonFor(booking, booking.cargoType, wagonTypeId, dims.capacityTons);
|
||||
const byWeight = cargo > 0 && tonsPerWagon > 0 ? Math.ceil(cargo / tonsPerWagon) : 0;
|
||||
// Break-bulk (PER_ITEM): indivisible items occupy more wagons than raw
|
||||
// tonnage suggests — their tare must be pulled too (batch dimsFor parity).
|
||||
@@ -9520,7 +9800,36 @@ export class TrainSchedulingService {
|
||||
0,
|
||||
...(schedule.trainSet?.wagons ?? []).map((w) => w.sequenceNo),
|
||||
);
|
||||
const emptyConsistWagons = rawConsistWagons
|
||||
// Frozen schedules: the live wagon↔train joins no longer describe this
|
||||
// departure, so the empty consist is read from the snapshot captured at
|
||||
// dispatch/arrival — that keeps "which wagons ran empty" in the history
|
||||
// views. Snapshots from before empties were recorded simply have none.
|
||||
const frozenEmptyConsistWagons = (snapshot?.emptyConsistWagons ?? []).map(
|
||||
(wagon) => ({
|
||||
id: wagon.physicalWagonId,
|
||||
sequenceNo: wagon.sequenceNo,
|
||||
capacityTons: roundTons(wagon.capacityTons),
|
||||
lengthMeters: roundTons(wagon.lengthMeters),
|
||||
assignedWeightTons: 0,
|
||||
tareWeightTons:
|
||||
wagon.tareWeightTons != null ? roundTons(wagon.tareWeightTons) : null,
|
||||
status: 'EMPTY',
|
||||
boardYardId: null,
|
||||
alightYardId: null,
|
||||
physicalWagonId: wagon.physicalWagonId,
|
||||
physicalWagonNumber: wagon.physicalWagonNumber,
|
||||
wagonType: wagon.wagonTypeId
|
||||
? {
|
||||
id: wagon.wagonTypeId,
|
||||
code: wagon.wagonTypeCode ?? '',
|
||||
name: wagon.wagonTypeName ?? '',
|
||||
}
|
||||
: null,
|
||||
allocations: [],
|
||||
consistOnly: true,
|
||||
}),
|
||||
);
|
||||
const liveEmptyConsistWagons = rawConsistWagons
|
||||
.filter((wagon) => !coveredPhysicalIds.has(wagon.id))
|
||||
.map((wagon, index) => ({
|
||||
// Physical wagon id — there is no TrainSetWagon slot behind this
|
||||
@@ -9553,6 +9862,9 @@ export class TrainSchedulingService {
|
||||
allocations: [],
|
||||
consistOnly: true,
|
||||
}));
|
||||
const emptyConsistWagons = isWagonAllocationFrozen
|
||||
? frozenEmptyConsistWagons
|
||||
: liveEmptyConsistWagons;
|
||||
|
||||
// The consist is DRAWN in the built train's real coupling order (rawConsistWagons
|
||||
// is already ASC/DESC per reverseWagonOrder), not in slot order — see
|
||||
@@ -9866,10 +10178,17 @@ export class TrainSchedulingService {
|
||||
loadingStatus: sb.loadingStatus ?? LoadingStatus.Unloaded,
|
||||
wagonAssigned: allocatedBookingIds.has(sb.booking?.id ?? sb.bookingId),
|
||||
isGovernment: Boolean(sb.booking?.isGovernment),
|
||||
// Shipping-line bookings never prepay (credit ledger) — the dispatch
|
||||
// dialog needs this to know FULLY_EXECUTED means boardable for them.
|
||||
shippingLineCompanyId: sb.booking?.shippingLineCompanyId ?? null,
|
||||
})) ?? [],
|
||||
// Ordered corridor stops (route milestones; falls back to the two
|
||||
// endpoints) — lets the UI draw per-segment occupancy and label legs.
|
||||
stops: this.mapScheduleStops(schedule),
|
||||
// Per-yard loading/unloading time windows (start/end clicks) — the
|
||||
// detail page shows the origin's loading window; dispatch requires it
|
||||
// started when cargo boards there.
|
||||
stationWorkLogs: schedule.stationWorkLogs ?? {},
|
||||
// Gross ceiling the validator holds each leg to: the set's weakest
|
||||
// locomotive pull limit plus its overage tolerance. Booking weightTons
|
||||
// above are gross too, so the strip can sum them per leg against this.
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
bulkItemWagonsForAllowedTypes,
|
||||
bulkItemWagonsRequired,
|
||||
bulkTonsPerWagon,
|
||||
bulkTonsPerWagonFor,
|
||||
bulkTonWagonsForAllowedTypes,
|
||||
bulkTonWagonsRequired,
|
||||
bulkWagonsForAllowedTypes,
|
||||
@@ -177,6 +178,19 @@ describe('train-capacity.util', () => {
|
||||
expect(bulkTonWagonsForAllowedTypes(bulk(200), cargoType, 70)).toBe(3);
|
||||
});
|
||||
|
||||
it('NUMBER_OF_WAGONS: a requested count wins over the tonnage-derived one', () => {
|
||||
const req = { ...bulk(100), bulkRequestedWagons: 40 };
|
||||
// 100T on 70T wagons is 2 by tonnage — the customer asked for 40.
|
||||
expect(bulkTonWagonsRequired(req, null, 'nw5', 70)).toBe(40);
|
||||
expect(bulkWagonsForAllowedTypes(req, { wagonTypes: [{ id: 'nw5', capacityTons: 70 }] }, 70)).toBe(40);
|
||||
// Each wagon then carries the even share, not rated capacity.
|
||||
expect(bulkTonsPerWagonFor(req, null, 'nw5', 70)).toBe(2.5);
|
||||
// ceil(tons / evenShare) must land exactly on the requested count.
|
||||
const awkward = { ...bulk(100), bulkRequestedWagons: 3 };
|
||||
const share = bulkTonsPerWagonFor(awkward, null, 'nw5', 70);
|
||||
expect(Math.ceil(100 / share)).toBe(3);
|
||||
});
|
||||
|
||||
it('routes PER_ITEM and PER_TON through one call', () => {
|
||||
expect(bulkWagonsForAllowedTypes(bulk(200), sugar, 70)).toBe(4);
|
||||
// PER_ITEM still wins where an item count is present.
|
||||
|
||||
@@ -114,6 +114,43 @@ export function bookingCargoTons(booking: {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Customer-requested wagon count of a NUMBER_OF_WAGONS bulk booking; 0 when
|
||||
* the booking carries none (every other cargo unit). The request was validated
|
||||
* against wagon capacity at booking creation, so sizing code honours it
|
||||
* verbatim instead of deriving a count from tonnage.
|
||||
*/
|
||||
export function requestedBulkWagons(booking: {
|
||||
bulkRequestedWagons?: number | string | null;
|
||||
}): number {
|
||||
const n = Math.floor(num(booking.bulkRequestedWagons));
|
||||
return n > 0 ? n : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Booking-aware {@link bulkTonsPerWagon}: a NUMBER_OF_WAGONS booking fixed its
|
||||
* wagon count, so each wagon carries tons ÷ requested (the even spread the
|
||||
* customer asked for), never more. Rounded UP to 3 decimals so
|
||||
* ceil(tons / perWagon) lands exactly on the requested count instead of one
|
||||
* over on float error. Other bookings get the cargo-type figure unchanged.
|
||||
*/
|
||||
export function bulkTonsPerWagonFor(
|
||||
booking: Parameters<typeof bookingCargoTons>[0] & {
|
||||
bulkRequestedWagons?: number | string | null;
|
||||
},
|
||||
cargoType: ItemFitCargoType | undefined,
|
||||
wagonTypeId: string | null | undefined,
|
||||
capacityTons: number | string | null | undefined,
|
||||
): number {
|
||||
const base = bulkTonsPerWagon(cargoType, wagonTypeId, capacityTons);
|
||||
const requested = requestedBulkWagons(booking);
|
||||
if (!requested) return base;
|
||||
const tons = bookingCargoTons(booking);
|
||||
if (!(tons > 0)) return base;
|
||||
const evenShare = Math.ceil((tons / requested) * 1000) / 1000;
|
||||
return base > 0 ? Math.min(base, evenShare) : evenShare;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wagons a break-bulk (PER_ITEM) bulk booking needs. Items are indivisible, so
|
||||
* floor how many whole items fit one wagon, then ceil the wagon count:
|
||||
@@ -184,11 +221,16 @@ export function bulkTonsPerWagon(
|
||||
* usable per-wagon figure, so callers can fall back as before.
|
||||
*/
|
||||
export function bulkTonWagonsRequired(
|
||||
booking: Parameters<typeof bookingCargoTons>[0],
|
||||
booking: Parameters<typeof bookingCargoTons>[0] & {
|
||||
bulkRequestedWagons?: number | string | null;
|
||||
},
|
||||
cargoType: ItemFitCargoType | undefined,
|
||||
wagonTypeId: string | null | undefined,
|
||||
capacityTons: number | string | null | undefined,
|
||||
): number {
|
||||
// NUMBER_OF_WAGONS: the customer fixed the count — honour it verbatim.
|
||||
const requested = requestedBulkWagons(booking);
|
||||
if (requested) return requested;
|
||||
const perWagon = bulkTonsPerWagon(cargoType, wagonTypeId, capacityTons);
|
||||
const tons = bookingCargoTons(booking);
|
||||
if (!(perWagon > 0) || !(tons > 0)) return 0;
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
bookingCargoTons,
|
||||
bulkItemsFitFor,
|
||||
bulkItemWagonsRequired,
|
||||
bulkTonsPerWagon,
|
||||
bulkTonsPerWagonFor,
|
||||
bulkTonWagonsRequired,
|
||||
consistViolations,
|
||||
} from '../train-capacity.util';
|
||||
@@ -193,8 +193,11 @@ export function buildBulkWagonPlan(
|
||||
// pool with uncapped tonnage either: its wagons stop at the cap, so 200T needs
|
||||
// 4 wagons and pooling it at 70T would plan 3. Capped bookings are sized on
|
||||
// their own cap; only genuinely uncapped tonnage pools at rated capacity.
|
||||
// A NUMBER_OF_WAGONS booking is "capped" at its even share (tons ÷ requested),
|
||||
// so it plans exactly the requested count.
|
||||
const cappedTonSlotsByBooking = bookings.map((b, i) =>
|
||||
itemSlotsByBooking[i] > 0 || bulkTonsPerWagon(b.cargoType, wagonType.id, capacity) >= capacity
|
||||
itemSlotsByBooking[i] > 0 ||
|
||||
bulkTonsPerWagonFor(b, b.cargoType, wagonType.id, capacity) >= capacity
|
||||
? 0
|
||||
: bulkTonWagonsRequired(b, b.cargoType, wagonType.id, capacity),
|
||||
);
|
||||
@@ -330,6 +333,7 @@ function allocateBookingsToSlots(
|
||||
// bookings that column is an item COUNT, not tons.
|
||||
remainingWeightTons: roundTons(bookingCargoTons(booking)),
|
||||
cargoType: booking.cargoType,
|
||||
booking,
|
||||
}));
|
||||
|
||||
let bookingIndex = 0;
|
||||
@@ -343,10 +347,17 @@ function allocateBookingsToSlots(
|
||||
const booking = remaining[bookingIndex];
|
||||
// A PER_TON loading cap (sugar 50T on a 70T wagon) binds the FILL as well
|
||||
// as the wagon count — the plan reserved a wagon per capped chunk, so
|
||||
// pouring rated capacity into it would leave the last wagon empty.
|
||||
// pouring rated capacity into it would leave the last wagon empty. A
|
||||
// NUMBER_OF_WAGONS booking fills each wagon its even share (tons ÷
|
||||
// requested) for the same reason.
|
||||
const takeCap = Math.min(
|
||||
wagonRemaining,
|
||||
bulkTonsPerWagon(booking.cargoType, slot.wagonTypeId, slot.capacityTons),
|
||||
bulkTonsPerWagonFor(
|
||||
booking.booking,
|
||||
booking.cargoType,
|
||||
slot.wagonTypeId,
|
||||
slot.capacityTons,
|
||||
),
|
||||
);
|
||||
const allocatedWeightTons = roundTons(
|
||||
Math.min(takeCap, booking.remainingWeightTons),
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
bookingCargoTons,
|
||||
bulkItemsFitFor,
|
||||
bulkTonsPerWagon,
|
||||
bulkTonsPerWagonFor,
|
||||
bulkWagonsForAllowedTypes,
|
||||
} from './train-capacity.util';
|
||||
import {
|
||||
@@ -179,7 +180,7 @@ const shortageFor = (
|
||||
let seatable = 0;
|
||||
let usedWagons = 0;
|
||||
for (const { wt, free } of freeByType) {
|
||||
const perWagon = bulkTonsPerWagon(booking.cargoType, wt.id, Number(wt.capacityTons));
|
||||
const perWagon = bulkTonsPerWagonFor(booking, booking.cargoType, wt.id, Number(wt.capacityTons));
|
||||
if (!(perWagon > 0) || free <= 0) continue;
|
||||
seatable += free * perWagon;
|
||||
usedWagons += free;
|
||||
@@ -188,7 +189,7 @@ const shortageFor = (
|
||||
const bestPerWagon = Math.max(
|
||||
1,
|
||||
...candidates.map((wt) =>
|
||||
bulkTonsPerWagon(booking.cargoType, wt.id, Number(wt.capacityTons)),
|
||||
bulkTonsPerWagonFor(booking, booking.cargoType, wt.id, Number(wt.capacityTons)),
|
||||
),
|
||||
);
|
||||
return {
|
||||
@@ -583,7 +584,8 @@ export function planWagonsWithStock(params: {
|
||||
if (perItem ? remainingItems <= 0 : remainingWeight <= 0) break;
|
||||
const wagonType = candidates.find((wt) => wt.id === open.slot.wagonTypeId);
|
||||
if (!wagonType) continue;
|
||||
const room = bulkTonsPerWagon(
|
||||
const room = bulkTonsPerWagonFor(
|
||||
booking,
|
||||
booking.cargoType,
|
||||
open.slot.wagonTypeId,
|
||||
Number(open.slot.capacityTons),
|
||||
@@ -640,7 +642,20 @@ export function planWagonsWithStock(params: {
|
||||
openedSlot.freeItems = itemBudgetOf(openedSlot) - takeItems;
|
||||
remainingItems -= takeItems;
|
||||
} else {
|
||||
take = roundTons(Math.min(openedSlot.freeCapacityTons, remainingWeight));
|
||||
// NUMBER_OF_WAGONS: each wagon takes the even share (tons / requested),
|
||||
// not the full per-wagon cap — the loop then opens exactly that count.
|
||||
take = roundTons(
|
||||
Math.min(
|
||||
openedSlot.freeCapacityTons,
|
||||
bulkTonsPerWagonFor(
|
||||
booking,
|
||||
booking.cargoType,
|
||||
openedSlot.slot.wagonTypeId,
|
||||
openedSlot.slot.capacityTons,
|
||||
),
|
||||
remainingWeight,
|
||||
),
|
||||
);
|
||||
}
|
||||
addAllocation(
|
||||
openedSlot.slot,
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsEnum, IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator';
|
||||
|
||||
import { WagonDetachRequestAction } from '../entities/wagon-detach-request.entity';
|
||||
|
||||
export class CreateWagonDetachRequestDto {
|
||||
@ApiProperty({
|
||||
enum: WagonDetachRequestAction,
|
||||
description: 'What approval is being asked for: a plain detach, or detach + MAINTENANCE.',
|
||||
})
|
||||
@IsEnum(WagonDetachRequestAction)
|
||||
action!: WagonDetachRequestAction;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Why the wagon must leave the scheduled consist. Shown to the approver.',
|
||||
maxLength: 500,
|
||||
})
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(500)
|
||||
reason!: string;
|
||||
}
|
||||
|
||||
export class DecideWagonDetachRequestDto {
|
||||
@ApiPropertyOptional({
|
||||
description: 'Decision note — required when rejecting, optional when approving.',
|
||||
maxLength: 500,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
note?: string;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index } from 'typeorm';
|
||||
|
||||
export enum WagonDetachRequestAction {
|
||||
Detach = 'DETACH',
|
||||
Maintenance = 'MAINTENANCE',
|
||||
}
|
||||
|
||||
export enum WagonDetachRequestStatus {
|
||||
Pending = 'PENDING',
|
||||
Approved = 'APPROVED',
|
||||
Rejected = 'REJECTED',
|
||||
}
|
||||
|
||||
/**
|
||||
* Approval gate for detaching a wagon (or sending it to maintenance) from a
|
||||
* train that is on a SCHEDULED run.
|
||||
*
|
||||
* A draft-schedule or unscheduled train is edited freely; once the run is
|
||||
* SCHEDULED, pulling a wagon out changes a departure customers already booked
|
||||
* against, so it becomes a two-person action: one staffer requests with a
|
||||
* reason, another (holding trains:approve_wagon_detach) approves — approval
|
||||
* executes the detach immediately. Rows are never deleted: decided rows are
|
||||
* the audit trail of who asked, who decided, and why.
|
||||
*/
|
||||
@Entity({ schema: 'freight', name: 'wagon_detach_requests' })
|
||||
@Index(['trainId'])
|
||||
@Index(['trainId', 'status'])
|
||||
export class WagonDetachRequest extends BaseEntity {
|
||||
@Column({ name: 'train_id', type: 'uuid' })
|
||||
trainId!: string;
|
||||
|
||||
@Column({ name: 'wagon_id', type: 'uuid' })
|
||||
wagonId!: string;
|
||||
|
||||
/** Snapshot — the audit trail must read correctly if the wagon is renumbered or deleted. */
|
||||
@Column({ name: 'wagon_number', type: 'varchar', length: 50 })
|
||||
wagonNumber!: string;
|
||||
|
||||
@Column({ name: 'action', type: 'varchar', length: 20 })
|
||||
action!: WagonDetachRequestAction;
|
||||
|
||||
@Column({ name: 'reason', type: 'varchar', length: 500 })
|
||||
reason!: string;
|
||||
|
||||
@Column({
|
||||
name: 'status',
|
||||
type: 'enum',
|
||||
enum: WagonDetachRequestStatus,
|
||||
enumName: 'wagon_detach_requests_status_enum',
|
||||
default: WagonDetachRequestStatus.Pending,
|
||||
})
|
||||
status!: WagonDetachRequestStatus;
|
||||
|
||||
/** IAM user id of the requester. The approver must be a different person. */
|
||||
@Column({ name: 'requested_by', type: 'uuid', nullable: true })
|
||||
requestedBy?: string | null;
|
||||
|
||||
/** IAM user id of the approver/rejecter; null while pending. */
|
||||
@Column({ name: 'decided_by', type: 'uuid', nullable: true })
|
||||
decidedBy?: string | null;
|
||||
|
||||
@Column({ name: 'decided_at', type: 'timestamptz', nullable: true })
|
||||
decidedAt?: Date | null;
|
||||
|
||||
/** Required on reject, optional on approve. */
|
||||
@Column({ name: 'decision_note', type: 'varchar', length: 500, nullable: true })
|
||||
decisionNote?: string | null;
|
||||
}
|
||||
@@ -25,6 +25,10 @@ import { BuildTrainDto } from './dto/build-train.dto';
|
||||
import { ListBuiltTrainsQueryDto } from './dto/list-built-trains-query.dto';
|
||||
import { ReorderTrainWagonsDto } from './dto/reorder-train-wagons.dto';
|
||||
import { SendWagonToMaintenanceDto } from './dto/send-wagon-to-maintenance.dto';
|
||||
import {
|
||||
CreateWagonDetachRequestDto,
|
||||
DecideWagonDetachRequestDto,
|
||||
} from './dto/wagon-detach-request.dto';
|
||||
import { UpdateTrainDetailsDto } from './dto/update-train-details.dto';
|
||||
import { UpdateTrainLocomotivesDto } from './dto/update-train-locomotives.dto';
|
||||
import { UpdateTrainYardDto } from './dto/update-train-yard.dto';
|
||||
@@ -47,6 +51,7 @@ import { TrainBuilderService } from './train-builder.service';
|
||||
FREIGHT_PERMS.trains.changeWagonYard,
|
||||
FREIGHT_PERMS.trains.toggleActive,
|
||||
FREIGHT_PERMS.trains.disband,
|
||||
FREIGHT_PERMS.trains.approveWagonDetach,
|
||||
])
|
||||
export class TrainBuilderController {
|
||||
constructor(private readonly trainBuilderService: TrainBuilderService) {}
|
||||
@@ -206,6 +211,74 @@ export class TrainBuilderController {
|
||||
);
|
||||
}
|
||||
|
||||
@Get(':id/detach-requests')
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Detach/maintenance approval requests of this train, newest first — pending and decided alike (the audit trail)',
|
||||
})
|
||||
detachRequests(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.trainBuilderService.listDetachRequests(id);
|
||||
}
|
||||
|
||||
@Post(':id/wagons/:wagonId/detach-requests')
|
||||
@FleetManage(FREIGHT_PERMS.trains.assignWagons)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Request approval to detach a wagon (or send it to maintenance) while the train is on a SCHEDULED run',
|
||||
})
|
||||
createDetachRequest(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param('wagonId', ParseUUIDPipe) wagonId: string,
|
||||
@Body() dto: CreateWagonDetachRequestDto,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
return this.trainBuilderService.createDetachRequest(
|
||||
id,
|
||||
wagonId,
|
||||
dto,
|
||||
resolveAuthUserId(user),
|
||||
);
|
||||
}
|
||||
|
||||
@Post(':id/detach-requests/:requestId/approve')
|
||||
@FleetManage(FREIGHT_PERMS.trains.approveWagonDetach)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Approve a detach/maintenance request — the detach executes immediately; the approver must not be the requester',
|
||||
})
|
||||
approveDetachRequest(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param('requestId', ParseUUIDPipe) requestId: string,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
@Body() dto?: DecideWagonDetachRequestDto,
|
||||
) {
|
||||
return this.trainBuilderService.decideDetachRequest(
|
||||
id,
|
||||
requestId,
|
||||
'APPROVE',
|
||||
resolveAuthUserId(user),
|
||||
dto?.note,
|
||||
);
|
||||
}
|
||||
|
||||
@Post(':id/detach-requests/:requestId/reject')
|
||||
@FleetManage(FREIGHT_PERMS.trains.approveWagonDetach)
|
||||
@ApiOperation({ summary: 'Reject a detach/maintenance request — a note explaining why is required' })
|
||||
rejectDetachRequest(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param('requestId', ParseUUIDPipe) requestId: string,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
@Body() dto: DecideWagonDetachRequestDto,
|
||||
) {
|
||||
return this.trainBuilderService.decideDetachRequest(
|
||||
id,
|
||||
requestId,
|
||||
'REJECT',
|
||||
resolveAuthUserId(user),
|
||||
dto.note,
|
||||
);
|
||||
}
|
||||
|
||||
@Post(':id/reorder-wagons')
|
||||
@FleetManage(FREIGHT_PERMS.trains.assignWagons)
|
||||
@ApiOperation({ summary: 'Persist a drag-reorder of the full consist' })
|
||||
|
||||
@@ -30,8 +30,14 @@ import { ListBuiltTrainsQueryDto } from './dto/list-built-trains-query.dto';
|
||||
import { ReorderTrainWagonsDto } from './dto/reorder-train-wagons.dto';
|
||||
import { UpdateTrainDetailsDto } from './dto/update-train-details.dto';
|
||||
import { UpdateTrainLocomotivesDto } from './dto/update-train-locomotives.dto';
|
||||
import { CreateWagonDetachRequestDto } from './dto/wagon-detach-request.dto';
|
||||
import { TrainLocomotive } from './entities/train-locomotive.entity';
|
||||
import { Train } from './entities/train.entity';
|
||||
import {
|
||||
WagonDetachRequest,
|
||||
WagonDetachRequestAction,
|
||||
WagonDetachRequestStatus,
|
||||
} from './entities/wagon-detach-request.entity';
|
||||
import {
|
||||
buildPaginationMeta,
|
||||
normalizePagination,
|
||||
@@ -751,32 +757,43 @@ export class TrainBuilderService {
|
||||
/** Detach one wagon and close the sequence gap it leaves. */
|
||||
async removeWagon(id: string, wagonId: string, userId?: string | null) {
|
||||
const pending = await this.dataSource.transaction(async (manager) => {
|
||||
const train = await this.getEditableTrain(manager, id);
|
||||
const wagon = await manager.getRepository(Wagon).findOne({ where: { id: wagonId } });
|
||||
if (!wagon || wagon.trainId !== train.id) {
|
||||
throw new NotFoundException(`Wagon ${wagonId} is not part of this train`);
|
||||
}
|
||||
await this.assertDetachableAndReleaseStaleSlots(manager, wagon);
|
||||
await manager.getRepository(Wagon).update(wagon.id, {
|
||||
trainId: null,
|
||||
sequenceNumber: null,
|
||||
status: WagonStatus.Available,
|
||||
importTrainNumber: null,
|
||||
exportTrainNumber: null,
|
||||
});
|
||||
await this.resequenceWagons(manager, train.id);
|
||||
return this.syncLiveScheduleAfterConsistChange(
|
||||
manager,
|
||||
train.id,
|
||||
[{ action: 'REMOVE', wagonId: wagon.id, wagonNumber: wagon.wagonNumber }],
|
||||
userId ?? null,
|
||||
wagon.currentYardId ?? train.currentYardId ?? null,
|
||||
);
|
||||
await this.assertDetachNeedsNoApproval(manager, id);
|
||||
return this.removeWagonCore(manager, id, wagonId, userId);
|
||||
});
|
||||
await this.reconcileWindowAfterConsistChange(pending);
|
||||
return this.getComposition(id);
|
||||
}
|
||||
|
||||
/** Transactional body of removeWagon — also runs under an approved detach request. */
|
||||
private async removeWagonCore(
|
||||
manager: EntityManager,
|
||||
id: string,
|
||||
wagonId: string,
|
||||
userId?: string | null,
|
||||
): Promise<PendingWindowCheck | null> {
|
||||
const train = await this.getEditableTrain(manager, id);
|
||||
const wagon = await manager.getRepository(Wagon).findOne({ where: { id: wagonId } });
|
||||
if (!wagon || wagon.trainId !== train.id) {
|
||||
throw new NotFoundException(`Wagon ${wagonId} is not part of this train`);
|
||||
}
|
||||
await this.assertDetachableAndReleaseStaleSlots(manager, wagon);
|
||||
await manager.getRepository(Wagon).update(wagon.id, {
|
||||
trainId: null,
|
||||
sequenceNumber: null,
|
||||
status: WagonStatus.Available,
|
||||
importTrainNumber: null,
|
||||
exportTrainNumber: null,
|
||||
});
|
||||
await this.resequenceWagons(manager, train.id);
|
||||
return this.syncLiveScheduleAfterConsistChange(
|
||||
manager,
|
||||
train.id,
|
||||
[{ action: 'REMOVE', wagonId: wagon.id, wagonNumber: wagon.wagonNumber }],
|
||||
userId ?? null,
|
||||
wagon.currentYardId ?? train.currentYardId ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detach one wagon AND flag it for maintenance: it leaves the consist and
|
||||
* moves to MAINTENANCE status (not AVAILABLE), so it is not re-coupled until
|
||||
@@ -789,6 +806,22 @@ export class TrainBuilderService {
|
||||
note?: string | null,
|
||||
) {
|
||||
const pending = await this.dataSource.transaction(async (manager) => {
|
||||
await this.assertDetachNeedsNoApproval(manager, id);
|
||||
return this.sendWagonToMaintenanceCore(manager, id, wagonId, userId, note);
|
||||
});
|
||||
await this.reconcileWindowAfterConsistChange(pending);
|
||||
return this.getComposition(id);
|
||||
}
|
||||
|
||||
/** Transactional body of sendWagonToMaintenance — also runs under an approved request. */
|
||||
private async sendWagonToMaintenanceCore(
|
||||
manager: EntityManager,
|
||||
id: string,
|
||||
wagonId: string,
|
||||
userId?: string | null,
|
||||
note?: string | null,
|
||||
): Promise<PendingWindowCheck | null> {
|
||||
{
|
||||
const train = await this.getEditableTrain(manager, id);
|
||||
const wagon = await manager.getRepository(Wagon).findOne({ where: { id: wagonId } });
|
||||
if (!wagon || wagon.trainId !== train.id) {
|
||||
@@ -851,6 +884,191 @@ export class TrainBuilderService {
|
||||
userId ?? null,
|
||||
yardId,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Direct-detach guard: while this train carries a live SCHEDULED run,
|
||||
* removing a wagon changes a departure customers already booked against, so
|
||||
* it is a two-person action — refuse here and point at the request flow.
|
||||
* DRAFT stays freely editable; DISPATCHED is already frozen by
|
||||
* getEditableTrain (the train is IN_SERVICE).
|
||||
*/
|
||||
private async assertDetachNeedsNoApproval(
|
||||
manager: EntityManager,
|
||||
trainId: string,
|
||||
): Promise<void> {
|
||||
const scheduled = await this.findScheduledRun(manager, trainId);
|
||||
if (scheduled) {
|
||||
throw new ConflictException(
|
||||
`Train is on scheduled run ${scheduled.reference ?? scheduled.id} — detaching a wagon needs an approved detach request`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async findScheduledRun(
|
||||
manager: EntityManager,
|
||||
trainId: string,
|
||||
): Promise<{ id: string; reference: string | null } | null> {
|
||||
const rows: { id: string; reference: string | null }[] = await manager.query(
|
||||
`SELECT ts.id, ts.reference
|
||||
FROM freight.train_schedules ts
|
||||
JOIN freight.train_sets tset ON tset.id = ts.train_set_id
|
||||
WHERE tset.train_id = $1
|
||||
AND ts.status = 'SCHEDULED'
|
||||
AND ts.deleted_at IS NULL
|
||||
LIMIT 1`,
|
||||
[trainId],
|
||||
);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* File a detach/maintenance approval request for a wagon on a SCHEDULED
|
||||
* train. The request carries the reason; a different staffer with
|
||||
* trains:approve_wagon_detach decides it (approval executes the detach).
|
||||
*/
|
||||
async createDetachRequest(
|
||||
id: string,
|
||||
wagonId: string,
|
||||
dto: CreateWagonDetachRequestDto,
|
||||
userId?: string | null,
|
||||
) {
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
const train = await this.getEditableTrain(manager, id);
|
||||
const wagon = await manager.getRepository(Wagon).findOne({ where: { id: wagonId } });
|
||||
if (!wagon || wagon.trainId !== train.id) {
|
||||
throw new NotFoundException(`Wagon ${wagonId} is not part of this train`);
|
||||
}
|
||||
const scheduled = await this.findScheduledRun(manager, train.id);
|
||||
if (!scheduled) {
|
||||
throw new ConflictException(
|
||||
'This train has no SCHEDULED run — detach the wagon directly, no approval needed',
|
||||
);
|
||||
}
|
||||
// Refuse up front what an approval could never execute (booked
|
||||
// allocations pin the wagon) — but release nothing yet: slots are only
|
||||
// touched when the approved detach actually runs.
|
||||
await this.assertDetachableAndReleaseStaleSlots(manager, wagon, { checkOnly: true });
|
||||
const repo = manager.getRepository(WagonDetachRequest);
|
||||
const open = await repo.findOne({
|
||||
where: { trainId: train.id, wagonId: wagon.id, status: WagonDetachRequestStatus.Pending },
|
||||
});
|
||||
if (open) {
|
||||
throw new ConflictException(
|
||||
`Wagon ${wagon.wagonNumber} already has a pending detach request`,
|
||||
);
|
||||
}
|
||||
return repo.save(
|
||||
repo.create({
|
||||
trainId: train.id,
|
||||
wagonId: wagon.id,
|
||||
wagonNumber: wagon.wagonNumber,
|
||||
action: dto.action,
|
||||
reason: dto.reason.trim(),
|
||||
requestedBy: userId ?? null,
|
||||
}),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/** All detach/maintenance requests of this train, newest first — the approval audit trail. */
|
||||
async listDetachRequests(trainId: string) {
|
||||
const rows: Array<{
|
||||
id: string;
|
||||
wagonId: string;
|
||||
wagonNumber: string;
|
||||
action: string;
|
||||
reason: string;
|
||||
status: string;
|
||||
requestedById: string | null;
|
||||
requestedBy: string | null;
|
||||
requestedAt: Date;
|
||||
decidedBy: string | null;
|
||||
decidedAt: Date | null;
|
||||
decisionNote: string | null;
|
||||
}> = await this.dataSource.query(
|
||||
`SELECT r.id,
|
||||
r.wagon_id AS "wagonId",
|
||||
r.wagon_number AS "wagonNumber",
|
||||
r.action,
|
||||
r.reason,
|
||||
r.status,
|
||||
r.requested_by AS "requestedById",
|
||||
COALESCE(ru.username, ru.email) AS "requestedBy",
|
||||
r.created_at AS "requestedAt",
|
||||
COALESCE(du.username, du.email) AS "decidedBy",
|
||||
r.decided_at AS "decidedAt",
|
||||
r.decision_note AS "decisionNote"
|
||||
FROM freight.wagon_detach_requests r
|
||||
LEFT JOIN iam.users ru ON ru.id = r.requested_by
|
||||
LEFT JOIN iam.users du ON du.id = r.decided_by
|
||||
WHERE r.train_id = $1
|
||||
AND r.deleted_at IS NULL
|
||||
ORDER BY r.created_at DESC
|
||||
LIMIT 100`,
|
||||
[trainId],
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide a pending request. Approve executes the detach (or maintenance
|
||||
* move) in the same transaction that stamps the decision, so an approved row
|
||||
* can never exist without its detach having happened. The requester cannot
|
||||
* approve their own request; a rejection must carry a note.
|
||||
*/
|
||||
async decideDetachRequest(
|
||||
id: string,
|
||||
requestId: string,
|
||||
decision: 'APPROVE' | 'REJECT',
|
||||
userId?: string | null,
|
||||
note?: string | null,
|
||||
) {
|
||||
const pending = await this.dataSource.transaction(async (manager) => {
|
||||
const repo = manager.getRepository(WagonDetachRequest);
|
||||
const request = await repo.findOne({
|
||||
where: { id: requestId, trainId: id },
|
||||
lock: { mode: 'pessimistic_write' },
|
||||
});
|
||||
if (!request) {
|
||||
throw new NotFoundException(`Detach request ${requestId} not found on this train`);
|
||||
}
|
||||
if (request.status !== WagonDetachRequestStatus.Pending) {
|
||||
throw new ConflictException(
|
||||
`This request was already ${request.status.toLowerCase()}`,
|
||||
);
|
||||
}
|
||||
const decisionNote = note?.trim() || null;
|
||||
if (decision === 'REJECT') {
|
||||
if (!decisionNote) {
|
||||
throw new BadRequestException('A note explaining the rejection is required');
|
||||
}
|
||||
await repo.update(request.id, {
|
||||
status: WagonDetachRequestStatus.Rejected,
|
||||
decidedBy: userId ?? null,
|
||||
decidedAt: new Date(),
|
||||
decisionNote,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
// The 4-eyes point of the gate: requester and approver are different people.
|
||||
if (request.requestedBy && userId && request.requestedBy === userId) {
|
||||
throw new ConflictException(
|
||||
'You filed this request — a different staff member must approve it',
|
||||
);
|
||||
}
|
||||
const pendingCheck =
|
||||
request.action === WagonDetachRequestAction.Maintenance
|
||||
? await this.sendWagonToMaintenanceCore(manager, id, request.wagonId, userId, request.reason)
|
||||
: await this.removeWagonCore(manager, id, request.wagonId, userId);
|
||||
await repo.update(request.id, {
|
||||
status: WagonDetachRequestStatus.Approved,
|
||||
decidedBy: userId ?? null,
|
||||
decidedAt: new Date(),
|
||||
decisionNote,
|
||||
});
|
||||
return pendingCheck;
|
||||
});
|
||||
await this.reconcileWindowAfterConsistChange(pending);
|
||||
return this.getComposition(id);
|
||||
@@ -893,6 +1111,7 @@ export class TrainBuilderService {
|
||||
private async assertDetachableAndReleaseStaleSlots(
|
||||
manager: EntityManager,
|
||||
wagon: Wagon,
|
||||
opts: { checkOnly?: boolean } = {},
|
||||
): Promise<void> {
|
||||
const rows: { id: string; train_set_id: string; status: string; allocs: string }[] =
|
||||
await manager.query(
|
||||
@@ -915,6 +1134,7 @@ export class TrainBuilderService {
|
||||
`Wagon ${wagon.wagonNumber} is pinned to an active schedule and cannot be removed`,
|
||||
);
|
||||
}
|
||||
if (opts.checkOnly) return;
|
||||
await manager.getRepository(TrainSetWagon).delete(rows.map((r) => r.id));
|
||||
for (const trainSetId of [...new Set(rows.map((r) => r.train_set_id))]) {
|
||||
const remaining = await manager.getRepository(TrainSetWagon).find({
|
||||
|
||||
@@ -4,13 +4,17 @@ import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module';
|
||||
import { TrainLocomotive } from './entities/train-locomotive.entity';
|
||||
import { Train } from './entities/train.entity';
|
||||
import { WagonDetachRequest } from './entities/wagon-detach-request.entity';
|
||||
import { TrainBuilderController } from './train-builder.controller';
|
||||
import { TrainBuilderService } from './train-builder.service';
|
||||
import { TrainsController } from './trains.controller';
|
||||
import { TrainsService } from './trains.service';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Train, TrainLocomotive]), TrainSchedulingModule],
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([Train, TrainLocomotive, WagonDetachRequest]),
|
||||
TrainSchedulingModule,
|
||||
],
|
||||
controllers: [TrainsController, TrainBuilderController],
|
||||
providers: [TrainsService, TrainBuilderService],
|
||||
exports: [TrainsService, TrainBuilderService],
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
} from '@nestjs/swagger';
|
||||
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
||||
import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator';
|
||||
import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard';
|
||||
import { FreightJwtGuard } from '../../common/freight-jwt.guard';
|
||||
import { OptionalJwtGuard } from './optional-jwt.guard';
|
||||
import {
|
||||
CompleteVerificationResultDto,
|
||||
@@ -91,7 +91,7 @@ export class VerifaydaController {
|
||||
}
|
||||
|
||||
@Get('status')
|
||||
@UseGuards(JwtGuard)
|
||||
@UseGuards(FreightJwtGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({
|
||||
summary: "Get the current user's Fayda verification status",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user