Merge branch 'dev' of https://github.com/Tria-plc/edr-platform into feature/group-booking

This commit is contained in:
Roba Boru
2026-08-27 22:55:42 +03:00
212 changed files with 10522 additions and 1589 deletions

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 legs 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 corridors 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 legs 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)

View File

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

View File

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

View File

@@ -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 trains 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 stops 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 stops 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',
},
];
},
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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(' &nbsp;|&nbsp; ');
})()}
</div>`
: ''
}
<table>
<thead>
<tr>
@@ -3864,19 +4039,30 @@ export class TrainSchedulingService {
.replace(/'/g, '&#39;');
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.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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