Merge pull request #1454 from Tria-plc/dev

dev
This commit is contained in:
marshal
2026-08-29 10:42:48 +03:00
committed by GitHub
223 changed files with 18098 additions and 1377 deletions

View File

@@ -18,6 +18,7 @@
"type-check": "tsc --noEmit",
"seed:demo-scheduling": "ts-node -r tsconfig-paths/register src/scripts/seed-demo-scheduling.ts",
"seed:freight-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-freight-demo.ts",
"seed:warehouse-layout": "ts-node -r tsconfig-paths/register src/scripts/seed-warehouse-layout.ts",
"seed:warehouse-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-warehouse-demo.ts",
"seed:warehouse-export-receive-ready": "ts-node -r tsconfig-paths/register src/scripts/seed-warehouse-export-receive-ready.ts",
"seed:export-djibouti-interchange-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-export-djibouti-interchange-demo.ts",

View File

@@ -35,6 +35,7 @@ import { ConsignmentsModule } from "./modules/consignments/consignments.module";
import { LocomotivesModule } from "./modules/locomotives/locomotives.module";
import { TruckTypesModule } from "./modules/truck-types/truck-types.module";
import { TransitAgentsModule } from "./modules/transit-agents/transit-agents.module";
import { TransitAssignmentsModule } from "./modules/transit-assignments/transit-assignments.module";
import { WagonTypesModule } from "./modules/wagon-types/wagon-types.module";
import { TrainSetsModule } from "./modules/train-sets/train-sets.module";
import { TrainSchedulesModule } from "./modules/train-schedules/train-schedules.module";
@@ -205,6 +206,7 @@ if (!process.env.APPLICATION_NAME) {
LocomotivesModule,
TruckTypesModule,
TransitAgentsModule,
TransitAssignmentsModule,
WagonTypesModule,
TrainSetsModule,
TrainSchedulesModule,

View File

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

View File

@@ -0,0 +1,28 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Why a train schedule was cancelled, captured at cancel time. Staff pick a
* reason in the cancel dialog and every view of the cancelled schedule reads it
* back — a cancelled train on the board used to say nothing about why it died.
*/
export class ScheduleCancellationReason3780000000000 implements MigrationInterface {
name = 'ScheduleCancellationReason3780000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE "freight"."train_schedules"
ADD COLUMN IF NOT EXISTS "cancellation_reason" varchar(500),
ADD COLUMN IF NOT EXISTS "cancelled_at" timestamptz,
ADD COLUMN IF NOT EXISTS "cancelled_by_user_id" uuid
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE "freight"."train_schedules"
DROP COLUMN IF EXISTS "cancellation_reason",
DROP COLUMN IF EXISTS "cancelled_at",
DROP COLUMN IF EXISTS "cancelled_by_user_id"
`);
}
}

View File

@@ -0,0 +1,24 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Empties backfilled into the yard belong to a company that may not be a
* registered customer yet, so `customer_id` cannot hold it. `company_name` is
* the typed fallback, and the display label when the customer IS registered.
*/
export class EmptyContainerReturnCompanyName3790000000000 implements MigrationInterface {
name = 'EmptyContainerReturnCompanyName3790000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.empty_container_returns
ADD COLUMN IF NOT EXISTS company_name varchar(200)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.empty_container_returns
DROP COLUMN IF EXISTS company_name
`);
}
}

View File

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

View File

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

View File

@@ -0,0 +1,34 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Backlog registration of full containers that were already sitting in a yard
* before the system knew about them. Such a row carries a true, backdated
* `arrived_at` for the record but accrues NO storage or demurrage — the
* operator decided these are not billable retroactively — so the flag exists
* to keep the fee engine off them.
*
* `company_id` / `company_name` carry the owner, since a backlog row has no
* booking to inherit one from. The name is free text for a company that is not
* a registered customer yet.
*/
export class WarehouseInventoryBacklogRegistration3800000000000 implements MigrationInterface {
name = 'WarehouseInventoryBacklogRegistration3800000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.warehouse_inventory
ADD COLUMN IF NOT EXISTS backlog_registration boolean NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS company_id uuid,
ADD COLUMN IF NOT EXISTS company_name varchar(200)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.warehouse_inventory
DROP COLUMN IF EXISTS backlog_registration,
DROP COLUMN IF EXISTS company_id,
DROP COLUMN IF EXISTS company_name
`);
}
}

View File

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

View File

@@ -0,0 +1,55 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Seed `edr_freight_app:warehouse_zones:delete` — the zone counterpart of the
* warehouse and yard delete permissions, which already exist.
*
* `ROLE_PERMISSION_PRESETS` spreads `Object.values(FREIGHT_PERMS.warehouseZones)`
* into the warehouse positions, so the moment the key is added to the registry
* `FreightPositionsSeeder.loadPermissionIds` resolves it against `iam.permissions`
* at boot — and throws `missing_permissions:<key>` if the row is absent. The
* catalog is otherwise written by `EdrOrgSeeder`, which skips itself unless
* `SEED_EDR_ORG` is set, so a migration is the only path that runs everywhere.
*
* Idempotent on `key`; keeps the registry's fixed uuid so every environment
* lands on the same id. Skips silently when the freight application row is
* absent, since there is nothing to attach to.
*/
export class WarehouseZoneDeletePermission3810000000000 implements MigrationInterface {
private static readonly KEY = 'edr_freight_app:warehouse_zones:delete';
private static readonly ID = 'f1c00001-0001-4000-8000-000000000004';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`INSERT INTO iam.permissions (id, key, name, application_id)
SELECT $2::uuid,
$1::varchar,
'{"am": "Delete warehouse zone", "en": "Delete warehouse zone"}'::jsonb,
a.id
FROM iam.application a
WHERE a.key = 'edr_freight_app'
AND NOT EXISTS (SELECT 1 FROM iam.permissions p WHERE p.key = $1::varchar)`,
[WarehouseZoneDeletePermission3810000000000.KEY, WarehouseZoneDeletePermission3810000000000.ID],
);
}
/**
* Grants go first, or the delete trips the position/role permission foreign
* keys — a half-removed permission is worse than one left in place.
*/
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DELETE FROM iam.position_permissions
WHERE permission_id IN (SELECT id FROM iam.permissions WHERE key = $1)`,
[WarehouseZoneDeletePermission3810000000000.KEY],
);
await queryRunner.query(
`DELETE FROM iam.role_permissions
WHERE permission_id IN (SELECT id FROM iam.permissions WHERE key = $1)`,
[WarehouseZoneDeletePermission3810000000000.KEY],
);
await queryRunner.query(`DELETE FROM iam.permissions WHERE key = $1`, [
WarehouseZoneDeletePermission3810000000000.KEY,
]);
}
}

View File

@@ -0,0 +1,21 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* `freight.warehouses.freight_type` — CONTAINER or BULK, or null for a site
* that takes both.
*
* Nullable with no backfill on purpose: every existing warehouse predates the
* field and is unrestricted today, so writing a value would narrow live
* allocation behind the operator's back.
*/
export class WarehouseFreightType3820000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.warehouses ADD COLUMN IF NOT EXISTS freight_type varchar(16)`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE freight.warehouses DROP COLUMN IF EXISTS freight_type`);
}
}

View File

@@ -0,0 +1,128 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Physical container positions below the zone: a stack is the ground footprint,
* a slot is one level in it. Adds `stack_id` / `slot_id` to warehouse inventory.
*
* Everything is additive and nullable. Existing inventory keeps warehouse /
* yard / zone as its only location and stays valid — nothing is backfilled,
* because no one can know where a box already in the yard is actually stacked.
*
* Occupancy is not stored on the slot. `uq_warehouse_inventory_active_slot`
* makes the inventory row the single source of truth: one live placement per
* slot, enforced by Postgres. Its status list must stay in step with
* `SLOT_OCCUPYING_STATUSES` in warehouse-inventory.entity.ts.
*/
export class WarehouseZoneStacksSlots3830000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.warehouse_zone_stacks (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
zone_id uuid NOT NULL REFERENCES freight.warehouse_zones(id) ON DELETE CASCADE,
code varchar(40) NOT NULL,
name varchar(160),
"row" varchar(20),
bay varchar(20),
"position" varchar(20),
max_stack_height int NOT NULL DEFAULT 3,
status varchar(16) NOT NULL DEFAULT 'ACTIVE',
is_active boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz,
CONSTRAINT chk_warehouse_zone_stacks_height CHECK (max_stack_height >= 1)
)
`);
await queryRunner.query(
`CREATE INDEX IF NOT EXISTS idx_warehouse_zone_stacks_zone ON freight.warehouse_zone_stacks (zone_id)`,
);
await queryRunner.query(
`CREATE INDEX IF NOT EXISTS idx_warehouse_zone_stacks_status ON freight.warehouse_zone_stacks (status)`,
);
// Partial: a soft-deleted stack must not block reusing its code.
await queryRunner.query(
`CREATE UNIQUE INDEX IF NOT EXISTS uq_warehouse_zone_stacks_zone_code
ON freight.warehouse_zone_stacks (zone_id, code) WHERE deleted_at IS NULL`,
);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.warehouse_zone_slots (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
stack_id uuid NOT NULL REFERENCES freight.warehouse_zone_stacks(id) ON DELETE CASCADE,
level int NOT NULL,
status varchar(16) NOT NULL DEFAULT 'AVAILABLE',
is_active boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz,
CONSTRAINT chk_warehouse_zone_slots_level CHECK (level >= 1)
)
`);
await queryRunner.query(
`CREATE INDEX IF NOT EXISTS idx_warehouse_zone_slots_stack ON freight.warehouse_zone_slots (stack_id, level)`,
);
await queryRunner.query(
`CREATE UNIQUE INDEX IF NOT EXISTS uq_warehouse_zone_slots_stack_level
ON freight.warehouse_zone_slots (stack_id, level) WHERE deleted_at IS NULL`,
);
await queryRunner.query(
`ALTER TABLE freight.warehouse_inventory ADD COLUMN IF NOT EXISTS stack_id uuid`,
);
await queryRunner.query(
`ALTER TABLE freight.warehouse_inventory ADD COLUMN IF NOT EXISTS slot_id uuid`,
);
// Named FKs added defensively — ADD CONSTRAINT has no IF NOT EXISTS.
await queryRunner.query(`
DO $$ BEGIN
ALTER TABLE freight.warehouse_inventory
ADD CONSTRAINT fk_warehouse_inventory_stack
FOREIGN KEY (stack_id) REFERENCES freight.warehouse_zone_stacks(id);
EXCEPTION WHEN duplicate_object THEN NULL; END $$
`);
await queryRunner.query(`
DO $$ BEGIN
ALTER TABLE freight.warehouse_inventory
ADD CONSTRAINT fk_warehouse_inventory_slot
FOREIGN KEY (slot_id) REFERENCES freight.warehouse_zone_slots(id);
EXCEPTION WHEN duplicate_object THEN NULL; END $$
`);
await queryRunner.query(
`CREATE INDEX IF NOT EXISTS idx_warehouse_inventory_stack ON freight.warehouse_inventory (stack_id)`,
);
await queryRunner.query(
`CREATE INDEX IF NOT EXISTS idx_warehouse_inventory_slot ON freight.warehouse_inventory (slot_id)`,
);
// One live container per slot. Statuses past the yard gate (LOADED,
// DISPATCHED, DELIVERED, UNLOADED_AT_DJIBOUTI_PORT) free the position
// without any exit path having to clear the column.
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS uq_warehouse_inventory_active_slot
ON freight.warehouse_inventory (slot_id)
WHERE deleted_at IS NULL
AND slot_id IS NOT NULL
AND status IN ('UNLOADED','RECEIVED','STORED','RESERVED','READY_FOR_LOADING','READY_FOR_PICKUP')
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX IF EXISTS freight.uq_warehouse_inventory_active_slot`);
await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_warehouse_inventory_slot`);
await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_warehouse_inventory_stack`);
await queryRunner.query(
`ALTER TABLE freight.warehouse_inventory DROP CONSTRAINT IF EXISTS fk_warehouse_inventory_slot`,
);
await queryRunner.query(
`ALTER TABLE freight.warehouse_inventory DROP CONSTRAINT IF EXISTS fk_warehouse_inventory_stack`,
);
await queryRunner.query(`ALTER TABLE freight.warehouse_inventory DROP COLUMN IF EXISTS slot_id`);
await queryRunner.query(`ALTER TABLE freight.warehouse_inventory DROP COLUMN IF EXISTS stack_id`);
await queryRunner.query(`DROP TABLE IF EXISTS freight.warehouse_zone_slots`);
await queryRunner.query(`DROP TABLE IF EXISTS freight.warehouse_zone_stacks`);
}
}

View File

@@ -116,6 +116,8 @@ export interface InvoiceListFilters {
status?: Freight.InvoiceStatus;
statuses?: Freight.InvoiceStatus[];
sources?: string[];
/** What the invoice bills for (`PREPAID`, `DEMURRAGE`, …) — free-form per source. */
types?: string[];
eimsStatuses?: string[];
/** Settled payment method, normalised UPPER_SNAKE — see `invoicePaymentMethodExpr`. */
paymentMethods?: string[];
@@ -306,6 +308,9 @@ export class BillingService {
sources: filter.sources,
});
}
if (filter.types?.length) {
qb.andWhere("invoice.type IN (:...types)", { types: filter.types });
}
if (filter.eimsStatuses?.length) {
qb.andWhere("invoice.eimsStatus IN (:...eimsStatuses)", {
eimsStatuses: filter.eimsStatuses,

View File

@@ -22,6 +22,7 @@ describe("FilterInvoiceDto", () => {
search: "INV-2026",
statuses: "PENDING,OVERDUE",
sources: "booking,warehouse",
types: "PREPAID,WAGON_CANCEL_FEE",
eimsStatuses: "NOT_SUBMITTED",
currency: "etb",
issuedFrom: "2026-08-01T00:00:00.000Z",
@@ -39,6 +40,7 @@ describe("FilterInvoiceDto", () => {
expect(errors).toEqual([]);
expect(dto.statuses).toEqual(["PENDING", "OVERDUE"]);
expect(dto.sources).toEqual(["booking", "warehouse"]);
expect(dto.types).toEqual(["PREPAID", "WAGON_CANCEL_FEE"]);
expect(dto.currency).toBe("ETB");
expect(dto.minAmount).toBe(100);
expect(dto.hasBalance).toBe(true);

View File

@@ -89,6 +89,18 @@ export class FilterInvoiceDto {
@IsIn(Object.values(Freight.InvoiceSource), { each: true })
sources?: Freight.InvoiceSource[];
/**
* What the invoice bills for (`?types=PREPAID,WAGON_CANCEL_FEE`). Free-form
* like `paymentMethods`: every billing source mints its own `type` string, so
* an `IsIn` here would silently drop a real value.
*/
@ApiPropertyOptional({ isArray: true, example: ["PREPAID"] })
@IsOptional()
@Transform(csv)
@IsArray()
@IsString({ each: true })
types?: string[];
/** MoR filing state — Finance's "what still needs registering" cut. */
@ApiPropertyOptional({ isArray: true, enum: EimsInvoiceStatus })
@IsOptional()

View File

@@ -0,0 +1,146 @@
import {
CARGO_TYPE_SUBTREE_SQL,
bookingContainerCountSql,
bookingContainerVgmSql,
bookingContentMatchSql,
bookingContentSql,
bookingHasContainerTypeSql,
bookingRequestedCargoSql,
bookingRequestedContainerCountSql,
} from './booking-content.sql';
describe('bookingContentSql', () => {
const sql = bookingContentSql('b');
it('prefers the container lines, since container bookings carry no description', () => {
expect(sql.indexOf('freight.booking_container')).toBeLessThan(
sql.indexOf('freight.cargo_types'),
);
expect(sql).toContain('freight.container_types');
expect(sql).toContain('bc.deleted_at IS NULL');
});
it('falls back to commodity, then to the free-text description', () => {
expect(sql.indexOf('cgt.cargo_type_name')).toBeLessThan(
sql.indexOf('b.cargo_free_text'),
);
});
// An empty string is not a missing value to COALESCE — without NULLIF a blank
// description would win over the commodity behind it.
it('treats an empty string as absent at every level', () => {
expect(sql.match(/NULLIF/g)).toHaveLength(3);
});
it('rewrites every reference when embedded under another alias', () => {
expect(bookingContentSql('bk')).not.toMatch(/\bb\.(cargo|id)/);
});
});
describe('CARGO_TYPE_SUBTREE_SQL', () => {
// The filter offers groups, not just leaves, so picking "Bulk" has to reach
// commodities at any depth beneath it — two levels today, more tomorrow.
it('walks the tree recursively rather than one level of children', () => {
expect(CARGO_TYPE_SUBTREE_SQL).toContain('WITH RECURSIVE');
expect(CARGO_TYPE_SUBTREE_SQL).toContain('c.parent_group_id = sub.id');
});
it('includes the picked node itself, so a leaf still matches exactly', () => {
expect(CARGO_TYPE_SUBTREE_SQL).toContain('WHERE id = :cargoTypeId');
});
});
describe('bookingContentMatchSql', () => {
const sql = bookingContentMatchSql('b');
it('searches all three places content can live', () => {
expect(sql).toContain('b.cargo_free_text ILIKE :cargoText');
expect(sql).toContain('cgt.cargo_type_name ILIKE :cargoText');
expect(sql).toContain('cnt.code ILIKE :cargoText');
});
// Anything but OR would make the text box match nothing for whole freight
// types — a container booking has no commodity, a bulk one has no container.
it('ORs them, and stays one parenthesised term for andWhere', () => {
expect(sql).not.toContain(' AND :cargoText');
expect(sql.startsWith('(')).toBe(true);
expect(sql.trimEnd().endsWith(')')).toBe(true);
});
});
describe('bookingContainerCountSql', () => {
// booking_container is one row per LINE carrying a quantity, so counting rows
// would report a 54-container booking as 1.
it('sums the line quantities rather than counting lines', () => {
expect(bookingContainerCountSql('b')).toContain('SUM(bc.quantity)');
expect(bookingContainerCountSql('b')).not.toContain('COUNT(');
});
it('counts every type by default and one type when scoped', () => {
expect(bookingContainerCountSql('b')).not.toContain('container_type_id');
expect(bookingContainerCountSql('b', true)).toContain(
'bc.container_type_id = :containerTypeId',
);
});
it('is 0, never NULL, so a bound comparison still decides', () => {
expect(bookingContainerCountSql('b')).toContain('COALESCE(SUM(bc.quantity), 0)');
});
it('ignores soft-deleted lines', () => {
expect(bookingContainerCountSql('b')).toContain('bc.deleted_at IS NULL');
expect(bookingHasContainerTypeSql('b')).toContain('bc.deleted_at IS NULL');
});
it('rewrites the booking reference under another alias', () => {
expect(bookingContainerCountSql('bk')).toContain('bc.booking_id = bk.id');
expect(bookingHasContainerTypeSql('bk')).toContain('bc.booking_id = bk.id');
});
});
describe('bookingContainerVgmSql', () => {
// The whole point: b.cargo_total_weight_vgm is 0 for portal container
// bookings, so the weight has to come off the lines.
it('reads the lines, never the booking-level column', () => {
const sql = bookingContainerVgmSql('b');
expect(sql).toContain('SUM(bc.total_vgm_tons)');
expect(sql).not.toContain('cargo_total_weight_vgm');
expect(sql).toContain('bc.deleted_at IS NULL');
});
});
describe('requested (shipment-request) cargo', () => {
const cargo = bookingRequestedCargoSql('b');
const count = bookingRequestedContainerCountSql('b');
it('reads the request, never the booking or its container lines', () => {
for (const sql of [cargo, count]) {
expect(sql).toContain('freight.booking_requests br');
expect(sql).toContain('br.created_booking_id = b.id');
expect(sql).not.toContain('freight.booking_container');
}
});
// requested_lines is a free-form jsonb column; jsonb_array_elements throws on
// a non-array, which would 500 the whole list for one malformed row.
it('survives a requested_lines with no container array', () => {
for (const sql of [cargo, count]) {
expect(sql).toContain("jsonb_typeof(br.requested_lines->'containers') = 'array'");
expect(sql).toContain("ELSE '[]'::jsonb");
}
});
it('renders the bulk shape too, not only containers', () => {
expect(cargo).toContain("'bulk'->>'cargoWeightTons'");
expect(cargo).toContain("'bulk'->>'itemCount'");
});
it('counts 0 rather than NULL when no request exists', () => {
expect(count).toContain("COALESCE(SUM((l->>'quantity')::int), 0)");
});
it('ignores soft-deleted requests', () => {
expect(cargo).toContain('br.deleted_at IS NULL');
expect(count).toContain('br.deleted_at IS NULL');
});
});

View File

@@ -0,0 +1,148 @@
/**
* What the customer said is IN the booking, per freight type — the list
* filter, the summary and the export all read this one expression so the
* column, the pill and the sheet can never disagree.
*
* BULK the commodity picked from the cargo tree (`cargo_types`), falling
* back to the free-text description for a bare group or a legacy row
* that has no commodity.
* CONTAINER the wizard asks for no description at all — VGM and contents are
* captured later in operations — so the closest thing to the
* customer's own words is the container lines they entered:
* "2 × 40FT, 1 × 20FT".
*
* Containers are checked FIRST: a container booking has no `cargo_type_id`
* (the API rejects one), so the order only matters for a mixed legacy row,
* where the physical lines are the better answer.
*/
export function bookingContentSql(alias = 'b'): string {
return `COALESCE(
NULLIF((SELECT string_agg(bc.quantity || ' × ' || COALESCE(cnt.label, cnt.code), ', '
ORDER BY cnt.size_ft DESC NULLS LAST, cnt.code)
FROM freight.booking_container bc
JOIN freight.container_types cnt ON cnt.id = bc.container_type_id
WHERE bc.booking_id = ${alias}.id AND bc.deleted_at IS NULL), ''),
NULLIF((SELECT cgt.cargo_type_name FROM freight.cargo_types cgt
WHERE cgt.id = ${alias}.cargo_type_id), ''),
NULLIF(${alias}.cargo_free_text, ''))`;
}
/**
* Cargo types at or under `:cargoTypeId`, so picking a GROUP in the filter
* matches every commodity beneath it — the same group→commodity drill-down the
* booking wizard offers, read back. Recursive because `cargo_types` is an
* arbitrary-depth tree (Bulk → Steel Billet → S1 → …), not two levels.
*/
export const CARGO_TYPE_SUBTREE_SQL = `(
WITH RECURSIVE sub AS (
SELECT id FROM freight.cargo_types WHERE id = :cargoTypeId
UNION ALL
SELECT c.id FROM freight.cargo_types c JOIN sub ON c.parent_group_id = sub.id
)
SELECT id FROM sub)`;
/**
* Contains-match over every part of the content a customer can type or pick:
* their own description, the commodity's name, and the container types on the
* booking. Bind `:cargoText` already wrapped in `%`.
*/
export function bookingContentMatchSql(alias = 'b'): string {
return `(${alias}.cargo_free_text ILIKE :cargoText
OR EXISTS (SELECT 1 FROM freight.cargo_types cgt
WHERE cgt.id = ${alias}.cargo_type_id
AND cgt.cargo_type_name ILIKE :cargoText)
OR EXISTS (SELECT 1 FROM freight.booking_container bc
JOIN freight.container_types cnt ON cnt.id = bc.container_type_id
WHERE bc.booking_id = ${alias}.id AND bc.deleted_at IS NULL
AND (cnt.label ILIKE :cargoText OR cnt.code ILIKE :cargoText)))`;
}
/**
* Containers on a booking, as a count of physical boxes — `booking_container`
* is one row PER LINE with a `quantity`, not one row per box, so this sums the
* quantity rather than counting rows.
*
* `scopedToType` narrows the sum to `:containerTypeId`, which is what makes one
* number filter answer both "10 containers in total" and "10 forty-footers":
* the count filter reads the container-type filter when one is set, and counts
* every type when it is not.
*/
export function bookingContainerCountSql(alias = 'b', scopedToType = false): string {
return `(SELECT COALESCE(SUM(bc.quantity), 0)
FROM freight.booking_container bc
WHERE bc.booking_id = ${alias}.id
AND bc.deleted_at IS NULL${
scopedToType ? '\n AND bc.container_type_id = :containerTypeId' : ''
})`;
}
/** Bookings carrying at least one line of `:containerTypeId`. */
export function bookingHasContainerTypeSql(alias = 'b'): string {
return `EXISTS (SELECT 1 FROM freight.booking_container bc
WHERE bc.booking_id = ${alias}.id
AND bc.deleted_at IS NULL
AND bc.container_type_id = :containerTypeId)`;
}
/**
* Container VGM on a booking, in tons — the sum of the per-line totals.
*
* NOT `bookings.cargo_total_weight_vgm`: the portal wizard leaves that at 0 for
* container freight (VGM is captured per container, later, in operations), so
* reading the booking-level column showed every portal container booking as
* weighing nothing. Same reason `bookingTonsSql` falls through to these lines.
*/
export function bookingContainerVgmSql(alias = 'b'): string {
return `(SELECT COALESCE(SUM(bc.total_vgm_tons), 0)
FROM freight.booking_container bc
WHERE bc.booking_id = ${alias}.id
AND bc.deleted_at IS NULL)`;
}
/**
* Cargo the customer declared on the SHIPMENT REQUEST behind a booking, which
* is not the same fact as cargo on the booking itself.
*
* On a GENERAL + customs contract the customer cannot book directly: they
* submit a request (day + quantities), and `initiateForShipmentRequest` opens a
* BARE instance from it — "the request itself carries the quantities; the
* instance carries none". So between initiation and `completeUnderContract` the
* booking legitimately holds no cargo while the customer's declared quantities
* sit on `booking_requests.requested_lines`.
*
* Kept in its own column rather than folded into the real container count: a
* declared 2 × 20FT is a request, not two boxes on a booking, and merging the
* two would overstate operational totals.
*/
const REQUESTED_CONTAINER_LINES = `jsonb_array_elements(
CASE WHEN jsonb_typeof(br.requested_lines->'containers') = 'array'
THEN br.requested_lines->'containers'
ELSE '[]'::jsonb END)`;
/** Human-readable declared cargo: "2 × 20FT", "12 t", "40 items". */
export function bookingRequestedCargoSql(alias = 'b'): string {
return `(SELECT COALESCE(
(SELECT string_agg((l->>'quantity') || ' × ' || upper(l->>'containerSize'), ', '
ORDER BY l->>'containerSize')
FROM ${REQUESTED_CONTAINER_LINES} AS l),
NULLIF(br.requested_lines->'bulk'->>'cargoWeightTons', '') || ' t',
NULLIF(br.requested_lines->'bulk'->>'itemCount', '') || ' items')
FROM freight.booking_requests br
WHERE br.created_booking_id = ${alias}.id
AND br.deleted_at IS NULL
ORDER BY br.created_at DESC
LIMIT 1)`;
}
/**
* Boxes declared on the shipment request. Pairs with the real container count:
* `Containers = 0` AND `Requested containers >= 1` is exactly the set awaiting
* completion.
*/
export function bookingRequestedContainerCountSql(alias = 'b'): string {
return `(SELECT COALESCE(SUM((l->>'quantity')::int), 0)
FROM freight.booking_requests br
CROSS JOIN LATERAL ${REQUESTED_CONTAINER_LINES} AS l
WHERE br.created_booking_id = ${alias}.id
AND br.deleted_at IS NULL)`;
}

View File

@@ -0,0 +1,30 @@
import { bookingTonsSql } from './booking-tons.sql';
describe('bookingTonsSql', () => {
const sql = bookingTonsSql('b');
// The regression this exists for: a plain COALESCE stops at the portal's
// literal 0 for container bookings and reports them as weighing nothing.
it('treats a stored 0 as "no figure" on both booking-level columns', () => {
expect(sql).toContain('NULLIF(b.bulk_total_weight_tons, 0)');
expect(sql).toContain('NULLIF(b.cargo_total_weight_vgm, 0)');
});
it('falls back to the per-line container VGM, excluding soft-deleted lines', () => {
expect(sql).toContain('SUM(bc.total_vgm_tons)');
expect(sql).toContain('freight.booking_container bc');
expect(sql).toContain('bc.booking_id = b.id');
expect(sql).toContain('bc.deleted_at IS NULL');
});
it('never returns NULL, so callers may SUM it directly', () => {
expect(sql.trimEnd().endsWith('0)')).toBe(true);
});
it('rewrites every reference when embedded under another alias', () => {
const aliased = bookingTonsSql('bk');
expect(aliased).not.toMatch(/\bb\./);
expect(aliased).toContain('bk.cargo_total_weight_vgm');
expect(aliased).toContain('bc.booking_id = bk.id');
});
});

View File

@@ -0,0 +1,26 @@
/**
* SQL mirror of `bookingCargoTons()` (train-scheduling/train-capacity.util.ts).
*
* Three storage conventions share `bookings.cargo_total_weight_vgm`:
* - BULK PER_TON — the column holds tons.
* - BULK PER_ITEM — the column holds an ITEM COUNT; the tons are in
* `bulk_total_weight_tons`.
* - CONTAINER — the portal wizard captures VGM per line, not per booking,
* and sends 0 (portal NewBookingPage: "containers carry NO weight at the
* wizard"). The tons live in `booking_container.total_vgm_tons`. The
* backoffice wizard does store a booking-level total, so both shapes exist
* in the same table.
*
* Hence NULLIF on both columns: a plain
* `COALESCE(bulk_total_weight_tons, cargo_total_weight_vgm)` stops at the
* portal's 0 — COALESCE falls through on NULL, never on 0 — and every
* portal-created container booking reads as 0 tons in exports and reports.
*/
export function bookingTonsSql(alias = 'b'): string {
return `COALESCE(
NULLIF(${alias}.bulk_total_weight_tons, 0),
NULLIF(${alias}.cargo_total_weight_vgm, 0),
(SELECT SUM(bc.total_vgm_tons) FROM freight.booking_container bc
WHERE bc.booking_id = ${alias}.id AND bc.deleted_at IS NULL),
0)`;
}

View File

@@ -17,6 +17,7 @@ describe('BookingWagonCancellationService.resolveRequestedCut (bulk)', () => {
wagons: number;
weightTons: number;
quantities: { bulkTons?: number };
totalWagons: number;
}>;
};
const booking = {
@@ -29,7 +30,39 @@ describe('BookingWagonCancellationService.resolveRequestedCut (bulk)', () => {
it('cancels every wagon with the exact total tonnage', async () => {
const cut = await svc.resolveRequestedCut(booking, { wagons: 4 });
expect(cut).toEqual({ wagons: 4, weightTons: 250.5, quantities: { bulkTons: 250.5 } });
expect(cut).toEqual({
wagons: 4,
weightTons: 250.5,
quantities: { bulkTons: 250.5 },
totalWagons: 4,
});
});
/**
* Unassigning a paid booking from a train clears `wagonsRequired` to NULL, so
* cancellation used to reject it outright ("no wagon requirement to cancel
* from"). The pinned `cancellationWagons`, stamped at first allocation, keeps
* the footprint through the unassign.
*/
it('falls back to the pinned cancellation footprint when wagonsRequired is cleared', async () => {
const unassigned = { ...booking, wagonsRequired: null, cancellationWagons: 4 };
const cut = await svc.resolveRequestedCut(unassigned, { wagons: 4 });
expect(cut.wagons).toBe(4);
expect(cut.totalWagons).toBe(4);
expect(cut.weightTons).toBe(250.5);
});
/** NUMBER_OF_WAGONS bulk never allocated: the customer's pinned count sizes it. */
it('sizes a never-allocated NUMBER_OF_WAGONS booking from bulkRequestedWagons', async () => {
const fresh = {
...booking,
wagonsRequired: null,
cancellationWagons: null,
bulkRequestedWagons: 3,
};
const cut = await svc.resolveRequestedCut(fresh, { wagons: 3 });
expect(cut.totalWagons).toBe(3);
expect(cut.weightTons).toBe(250.5);
});
it('rejects more wagons than the booking has', async () => {

View File

@@ -23,6 +23,8 @@ import { wagonsPerUnitForSize } from '../rule-engine/container-type.util';
import { ContainerType } from '../rule-engine/entities/container-type.entity';
import { Rate } from '../rule-engine/entities/rate.entity';
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
import { requestedBulkWagons } from '../train-scheduling/train-capacity.util';
import { wagonsRequiredForBooking } from '../train-scheduling/utils/fleet-plan.util';
import { TrainSchedulingService } from '../train-scheduling/services/train-scheduling.service';
import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity';
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
@@ -74,6 +76,8 @@ interface RequestedCut {
wagons: number;
weightTons: number;
quantities: CancelledQuantities;
/** The booking's whole wagon footprint the cut came out of — credit divides by it. */
totalWagons: number;
}
/** The priced fee for a cut: total, currency and the rate(s) it came from. */
@@ -165,7 +169,7 @@ export class BookingWagonCancellationService {
feePerWagon: fee.perWagon,
feeAmount: fee.amount,
feeCurrency: fee.currency,
creditAmount: this.creditFor(booking, Number(booking.wagonsRequired ?? 0)),
creditAmount: round2(Number(booking.totalAmount ?? 0)),
};
}
this.assertCutSparesSharedWagon(cut);
@@ -177,7 +181,7 @@ export class BookingWagonCancellationService {
feePerWagon: fee.perWagon,
feeAmount: fee.amount,
feeCurrency: fee.currency,
creditAmount: this.creditFor(booking, cut.wagons),
creditAmount: this.creditFor(booking, cut.wagons, cut.totalWagons),
};
}
@@ -218,7 +222,7 @@ export class BookingWagonCancellationService {
: await this.resolveRequestedCut(booking, dto);
const fee = await this.priceFee(booking, cut);
const feeAmount = fee.amount;
const creditAmount = this.creditFor(booking, cut.wagons);
const creditAmount = this.creditFor(booking, cut.wagons, cut.totalWagons);
const row = await this.repo.create({
bookingId,
@@ -318,7 +322,7 @@ export class BookingWagonCancellationService {
const rows = await this.dataSource.getRepository(WagonBookingAllocation).count({
where: { bookingId: row.bookingId },
});
if (rows < Math.round(Number(booking.wagonsRequired ?? 0))) {
if (rows < Math.round(await this.wagonFootprint(booking))) {
throw new ConflictException(
'The train has no free wagon space left to restore the cancelled wagons — the request cannot be withdrawn. Pay the cancellation fee and rebook the credit on another day instead.',
);
@@ -363,7 +367,7 @@ export class BookingWagonCancellationService {
const row = await this.openConsolidationBreak(
booking,
'ceil',
this.creditFor(booking, Number(booking.wagonsRequired ?? 0)),
round2(Number(booking.totalAmount ?? 0)),
reason ?? 'Consolidated pair cancelled',
userId,
);
@@ -371,7 +375,7 @@ export class BookingWagonCancellationService {
await this.openConsolidationBreak(
partner,
'floor',
this.creditFor(partner, Number(partner.wagonsRequired ?? 0)),
round2(Number(partner.totalAmount ?? 0)),
`Cancelled with its consolidation partner ${booking.reference}`,
userId,
);
@@ -540,7 +544,8 @@ export class BookingWagonCancellationService {
} as RequestWagonCancellationDto);
}
return this.resolveRequestedCut(booking, {
wagons: Number(booking.wagonsRequired ?? 0),
// Footprint, not the live wagonsRequired: unassign clears that to NULL.
wagons: await this.wagonFootprint(booking),
} as RequestWagonCancellationDto);
}
@@ -568,7 +573,7 @@ export class BookingWagonCancellationService {
const row = await this.openConsolidationBreak(
booking,
'ceil',
this.creditFor(booking, Number(booking.wagonsRequired ?? 0)),
round2(Number(booking.totalAmount ?? 0)),
'Consolidation partner lapsed unpaid — paired booking cancelled, cancellation fee applies',
);
await this.dataSource.getRepository(Booking).update(booking.id, {
@@ -729,9 +734,10 @@ export class BookingWagonCancellationService {
// Whole-booking cut: nothing is left to ship, so the booking ends
// CANCELLED (frees the contract slot/cap for the rebook) and drops off its
// train. The credit row still points at it for T3.
const wagonsLeft = round2(
Number(booking.wagonsRequired ?? 0) - Number(row.wagonsCancelled),
);
// Off the pinned footprint, not the live wagonsRequired — unassign
// clears that to NULL, which read as a full cut on any partial cancel.
const footprint = await this.wagonFootprint(booking);
const wagonsLeft = round2(footprint - Number(row.wagonsCancelled));
const isFull = wagonsLeft <= 0;
// NUMBER_OF_WAGONS bookings pin their count in bulkRequestedWagons, which
// bulkTonWagonsRequired honours verbatim. Left stale it re-inflates the
@@ -745,6 +751,9 @@ export class BookingWagonCancellationService {
: null;
await manager.getRepository(Booking).update(booking.id, {
wagonsRequired: Math.max(0, wagonsLeft),
// Keep the cancellation footprint in step, so a second partial cancel
// prices against what is actually left, not the original booking.
cancellationWagons: Math.max(0, wagonsLeft),
...(requestedWagonsLeft !== null
? { bulkRequestedWagons: requestedWagonsLeft }
: {}),
@@ -836,15 +845,15 @@ export class BookingWagonCancellationService {
)
.where('alloc.booking_id = :bookingId', { bookingId })
.getMany();
const loaded = allocations.filter(
(a) => a.status === 'LOADED' || a.status === 'DEPARTED',
);
const remaining = allocations.filter(
(a) => a.status !== 'LOADED' && a.status !== 'DEPARTED',
);
if (!loaded.length) {
// A booking whose cargo never showed up at all (0 loaded) is cancelled the
// same way — the gate that holds the train does not care whether loading
// started, only that nothing is left unresolved.
if (!allocations.length) {
throw new BadRequestException(
'Loading has not started for this booking — use the normal wagon cancellation flow.',
'This booking has no wagons on this schedule — use the normal wagon cancellation flow.',
);
}
if (!remaining.length) {
@@ -853,14 +862,37 @@ export class BookingWagonCancellationService {
);
}
// Staff may cut a SUBSET of the never-loaded wagons (picked in the loading
// modal) instead of the whole remainder. Anything already LOADED is
// rejected rather than silently dropped: the operator believes they are
// cancelling that wagon, and it is on the train.
let target = remaining;
if (dto.wagonAllocationIds?.length) {
const wanted = new Set(dto.wagonAllocationIds);
const known = new Set(allocations.map((a) => a.id));
const unknown = dto.wagonAllocationIds.filter((id) => !known.has(id));
if (unknown.length) {
throw new BadRequestException(
'Some selected wagons are not allocated to this booking on this schedule.',
);
}
const loaded = allocations.filter((a) => wanted.has(a.id) && !remaining.includes(a));
if (loaded.length) {
throw new BadRequestException(
`${loaded.length} selected wagon(s) are already loaded and cannot be cancelled.`,
);
}
target = remaining.filter((a) => wanted.has(a.id));
}
const cut = await this.resolveRequestedCut(booking, {
wagonAllocationIds: remaining.map((r) => r.id),
wagonAllocationIds: target.map((r) => r.id),
} as RequestWagonCancellationDto);
if (booking.consolidationPartnerId) this.assertCutSparesSharedWagon(cut);
const edrFault = !!dto.edrFault;
const fee = edrFault ? null : await this.priceFee(booking, cut);
const creditAmount = this.creditFor(booking, cut.wagons);
const creditAmount = this.creditFor(booking, cut.wagons, cut.totalWagons);
const row = await this.repo.create({
bookingId,
@@ -1253,7 +1285,7 @@ export class BookingWagonCancellationService {
booking: Booking,
dto: RequestWagonCancellationDto,
): Promise<RequestedCut> {
const totalWagons = Number(booking.wagonsRequired ?? 0);
const totalWagons = await this.wagonFootprint(booking);
if (totalWagons <= 0) {
throw new BadRequestException('This booking has no wagon requirement to cancel from.');
}
@@ -1335,6 +1367,7 @@ export class BookingWagonCancellationService {
weightTons: weightShare,
// Bookings without unit records fall back to the T2 LIFO trim.
quantities: { bySize, ...(units.length === requested ? { units } : {}) },
totalWagons,
};
}
@@ -1360,7 +1393,7 @@ export class BookingWagonCancellationService {
if (tons <= 0) {
throw new BadRequestException('The requested cut is too small to release cargo.');
}
return { wagons, weightTons: tons, quantities: { bulkTons: tons } };
return { wagons, weightTons: tons, quantities: { bulkTons: tons }, totalWagons };
}
/**
@@ -1416,6 +1449,7 @@ export class BookingWagonCancellationService {
wagons,
weightTons: tons,
quantities: { bulkTons: tons, allocationIds },
totalWagons,
};
}
@@ -1460,12 +1494,54 @@ export class BookingWagonCancellationService {
wagons,
weightTons: round3(units.reduce((s, u) => s + Number(u.vgmTons || 0), 0)),
quantities: { bySize, units, allocationIds },
totalWagons,
};
}
/**
* The booking's wagon footprint for cancellation pricing.
*
* `wagonsRequired` is a LIVE scheduling field: unassign clears it to NULL, so
* a paid booking pulled off a train read 0 wagons and could not be cancelled
* at all. `cancellationWagons` is stamped once at first allocation and never
* cleared — read it first. A booking never allocated has neither, so size it
* from the cargo the same way the scheduler would: TEU geometry for
* containers, the customer's pinned count for NUMBER_OF_WAGONS bulk, tonnage
* ÷ wagon capacity for PER_TON bulk.
*/
private async wagonFootprint(booking: Booking): Promise<number> {
const pinned = Number(booking.cancellationWagons ?? 0);
if (pinned > 0) return round2(pinned);
const stored = Number(booking.wagonsRequired ?? 0);
if (stored > 0) return round2(stored);
const requested = requestedBulkWagons(booking);
if (requested > 0) return requested;
// Cargo relations drive the sizing — reload when the caller passed a bare
// booking (findById does not always hydrate them).
const full =
booking.bookingContainers || booking.cargoType
? booking
: ((await this.dataSource.getRepository(Booking).findOne({
where: { id: booking.id },
relations: {
bookingContainers: { containerType: true },
cargoType: { wagonTypes: true },
},
})) ?? booking);
const capacities = (full.cargoType?.wagonTypes ?? [])
.map((wt) => Number(wt.capacityTons))
.filter((c) => c > 0);
const bulkCapacity =
full.freightType === 'BULK' && capacities.length
? Math.max(...capacities)
: undefined;
return round2(wagonsRequiredForBooking(full, bulkCapacity));
}
/** Credit = the cancelled share of the ORIGINAL price (old-price rebooking). */
private creditFor(booking: Booking, wagons: number): number {
const totalWagons = Number(booking.wagonsRequired ?? 0);
private creditFor(booking: Booking, wagons: number, totalWagons: number): number {
if (totalWagons <= 0) return 0;
return round2(Number(booking.totalAmount) * (wagons / totalWagons));
}
@@ -1875,9 +1951,12 @@ export class BookingWagonCancellationService {
// the same cargo); number/seal/VGM come from the override when given.
units: sized.map((u, i) => ({
containerNumber: replacement?.[i]?.containerNumber ?? u.containerNumber,
// A credit snapshot taken before seals were mandatory can carry
// none; the booking service normalizes the blank back to null
// rather than blocking the rebook of already-paid cargo.
sealNumber: replacement
? (replacement[i]?.sealNumber ?? undefined)
: (u.sealNumber ?? undefined),
? (replacement[i]?.sealNumber ?? '')
: (u.sealNumber ?? ''),
vgmTons: replacement?.[i]?.vgmTons ?? u.vgmTons,
isHazardous: u.isHazardous,
isReefer: u.isReefer,

View File

@@ -21,6 +21,13 @@ import { ShippingLineCompany } from '../shipping-lines/entities/shipping-line-co
import { ContractRateSnapshot } from '../contracts/entities/contract-rate-snapshot.entity';
import { ContractRoute } from '../contracts/entities/contract-route.entity';
import { applyDirectionScope } from '../user-trade-access/trade-scope.util';
import {
CARGO_TYPE_SUBTREE_SQL,
bookingContainerCountSql,
bookingContentMatchSql,
bookingHasContainerTypeSql,
bookingRequestedContainerCountSql,
} from './booking-content.sql';
import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity';
import {
BookingDocumentReview,
@@ -65,7 +72,17 @@ export interface BookingListFilterOptions {
contractId?: string;
contractType?: string;
serviceTypeId?: string;
/** Cargo type OR cargo group — a group matches every commodity beneath it. */
cargoTypeId?: string;
/** Contains-search over content: description, commodity name, container types. */
cargoText?: string;
/** Bookings carrying this container type; also scopes the container count. */
containerTypeId?: string;
containersMin?: number;
containersMax?: number;
/** Bounds on containers declared on the shipment request behind the booking. */
requestedContainersMin?: number;
requestedContainersMax?: number;
freightType?: string;
bookingType?: string;
tradeDirection?: string;
@@ -1176,11 +1193,55 @@ export class BookingsRepository extends BaseRepository<Booking> {
serviceTypeId: options.serviceTypeId,
});
}
// A group is selectable in the filter, not just a leaf commodity, so this
// matches the whole subtree — picking "Bulk" must return every commodity
// under it, the same drill-down the booking wizard offers, read back.
if (options.cargoTypeId) {
qb.andWhere('booking.cargo_type_id = :cargoTypeId', {
qb.andWhere(`booking.cargo_type_id IN ${CARGO_TYPE_SUBTREE_SQL}`, {
cargoTypeId: options.cargoTypeId,
});
}
if (options.cargoText) {
qb.andWhere(bookingContentMatchSql('booking'), {
cargoText: `%${options.cargoText}%`,
});
}
if (options.containerTypeId) {
qb.andWhere(bookingHasContainerTypeSql('booking'), {
containerTypeId: options.containerTypeId,
});
}
// One count filter, two questions: with a container type picked it counts
// that type, without one it counts every box on the booking.
if (options.containersMin != null || options.containersMax != null) {
const count = bookingContainerCountSql(
'booking',
Boolean(options.containerTypeId),
);
if (options.containersMin != null) {
qb.andWhere(`${count} >= :containersMin`, {
containersMin: options.containersMin,
});
}
if (options.containersMax != null) {
qb.andWhere(`${count} <= :containersMax`, {
containersMax: options.containersMax,
});
}
}
// Declared on the shipment request, not on the booking. Pairs with the
// count above: containers 0..0 AND requested >= 1 is the set awaiting
// completion after clearance.
if (options.requestedContainersMin != null) {
qb.andWhere(`${bookingRequestedContainerCountSql('booking')} >= :requestedContainersMin`, {
requestedContainersMin: options.requestedContainersMin,
});
}
if (options.requestedContainersMax != null) {
qb.andWhere(`${bookingRequestedContainerCountSql('booking')} <= :requestedContainersMax`, {
requestedContainersMax: options.requestedContainersMax,
});
}
if (omit !== 'freightType' && options.freightType) {
qb.andWhere('booking.freight_type = :freightType', {
freightType: options.freightType,
@@ -1725,6 +1786,7 @@ export class BookingsRepository extends BaseRepository<Booking> {
Booking,
| 'schedulingStatus'
| 'wagonsRequired'
| 'cancellationWagons'
| 'scheduledAt'
| 'holdStartedAt'
| 'holdExpiresAt'

View File

@@ -1845,6 +1845,12 @@ export class BookingsService {
contractType: filter.contractType,
serviceTypeId: filter.serviceTypeId,
cargoTypeId: filter.cargoTypeId,
cargoText: filter.cargoText,
containerTypeId: filter.containerTypeId,
containersMin: filter.containersMin,
containersMax: filter.containersMax,
requestedContainersMin: filter.requestedContainersMin,
requestedContainersMax: filter.requestedContainersMax,
freightType: filter.freightType,
bookingType: filter.bookingType,
tradeDirection: filter.tradeDirection,
@@ -2072,6 +2078,12 @@ export class BookingsService {
contractType: filter.contractType,
serviceTypeId: filter.serviceTypeId,
cargoTypeId: filter.cargoTypeId,
cargoText: filter.cargoText,
containerTypeId: filter.containerTypeId,
containersMin: filter.containersMin,
containersMax: filter.containersMax,
requestedContainersMin: filter.requestedContainersMin,
requestedContainersMax: filter.requestedContainersMax,
freightType: filter.freightType,
bookingType: filter.bookingType,
tradeDirection: filter.tradeDirection,

View File

@@ -62,11 +62,60 @@ export class FilterBookingDto {
@IsUUID()
serviceTypeId?: string;
@ApiPropertyOptional({ format: 'uuid' })
@ApiPropertyOptional({
format: 'uuid',
description:
'Cargo type OR cargo group — a group matches every commodity beneath it',
})
@IsOptional()
@IsUUID()
cargoTypeId?: string;
@ApiPropertyOptional({
description:
'Contains-search over booking content: cargo description, commodity name, container types',
})
@IsOptional()
@Transform(({ value }) =>
typeof value === 'string' && value.trim() ? value.trim() : undefined,
)
cargoText?: string;
@ApiPropertyOptional({
format: 'uuid',
description:
'Bookings carrying this container type. Also scopes containersMin/Max to it.',
})
@IsOptional()
@IsUUID()
containerTypeId?: string;
@ApiPropertyOptional({
description:
'Minimum container count — of containerTypeId when set, else of all types',
})
@IsOptional()
@Transform(({ value }) => (value === '' || value == null ? undefined : Number(value)))
containersMin?: number;
@ApiPropertyOptional({ description: 'Maximum container count — see containersMin' })
@IsOptional()
@Transform(({ value }) => (value === '' || value == null ? undefined : Number(value)))
containersMax?: number;
@ApiPropertyOptional({
description:
'Minimum containers declared on the shipment request behind the booking',
})
@IsOptional()
@Transform(({ value }) => (value === '' || value == null ? undefined : Number(value)))
requestedContainersMin?: number;
@ApiPropertyOptional({ description: 'Maximum requested containers — see requestedContainersMin' })
@IsOptional()
@Transform(({ value }) => (value === '' || value == null ? undefined : Number(value)))
requestedContainersMax?: number;
@ApiPropertyOptional({ enum: FREIGHT_TYPES })
@IsOptional()
@IsIn([...FREIGHT_TYPES])

View File

@@ -183,6 +183,19 @@ export class CancelRemainingWagonsDto {
@IsUUID('4')
scheduleId!: string;
@ApiPropertyOptional({
description:
'Cancel only THESE never-loaded wagons (wagon_booking_allocation ids from ' +
'GET /bookings/:id/wagons). Omit to cancel the whole unloaded remainder. ' +
'Already-loaded wagons are rejected — they are riding.',
type: [String],
})
@IsOptional()
@IsArray()
@ArrayNotEmpty()
@IsUUID('4', { each: true })
wagonAllocationIds?: string[];
@ApiProperty({ description: 'Why the remaining wagons are not riding' })
@IsString()
@IsNotEmpty()

View File

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

View File

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

View File

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

View File

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

View File

@@ -2250,7 +2250,9 @@ export class ContractBookingService {
unitRepo.create({
bookingContainerId: containerRow.id,
containerNumber: unit.containerNumber,
sealNumber: unit.sealNumber ?? null,
// Legacy units recovered by the remainder placement can still
// arrive sealless — keep those null rather than empty-string.
sealNumber: unit.sealNumber?.trim() || null,
vgmTons: unit.vgmTons,
isHazardous: unit.isHazardous ?? false,
isReefer: unit.isReefer ?? false,

View File

@@ -7,6 +7,7 @@ import {
IsEmail,
IsIn,
IsInt,
IsNotEmpty,
IsNumber,
IsOptional,
IsString,
@@ -32,10 +33,12 @@ export class CreateContainerUnitDto {
})
containerNumber!: string;
@ApiPropertyOptional()
@IsOptional()
@ApiProperty({ description: 'Seal number — required on every container, import and export alike.' })
@IsString()
sealNumber?: string;
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
@IsNotEmpty({ message: 'sealNumber is required' })
@MaxLength(64)
sealNumber!: string;
@ApiProperty({ description: 'VGM in tons', minimum: 0 })
@IsNumber()

View File

@@ -1,4 +1,17 @@
import { DataSource } from 'typeorm';
import { FREIGHT_PERMS } from '../../../seed/freight-permissions.registry';
import {
CARGO_TYPE_SUBTREE_SQL,
bookingContainerCountSql,
bookingContentMatchSql,
bookingContainerVgmSql,
bookingContentSql,
bookingHasContainerTypeSql,
bookingRequestedCargoSql,
bookingRequestedContainerCountSql,
} from '../../bookings/booking-content.sql';
import { bookingTonsSql } from '../../bookings/booking-tons.sql';
import { Booking } from '../../bookings/entities/booking.entity';
import { Company } from '../../companies/entities/company.entity';
import { CompanyProfile } from '../../companies/entities/company-profile.entity';
@@ -10,23 +23,104 @@ import { Yard } from '../../rule-engine/entities/yard.entity';
import { ShippingLineCompany } from '../../shipping-lines/entities/shipping-line-company.entity';
import { Train } from '../../trains/entities/train.entity';
import { applyDirectionScope } from '../../user-trade-access/trade-scope.util';
import { ExportDataset } from '../export.types';
import { ExportFilterOption } from '../export-filter.util';
import { ExportDataset, ExportField } from '../export.types';
/**
* Domain semantics that the retired `bookings-list` report used to share.
* Kept identical on purpose — for PER_ITEM bulk bookings `cargo_total_weight_vgm`
* holds an item COUNT, not tonnage, and `adjusted_total_amount` silently
* overrides `total_amount`. Getting either wrong misreports money or weight.
* Tonnage is `bookingTonsSql` — the one resolver for the three ways a booking
* stores its weight. `adjusted_total_amount` silently overrides `total_amount`.
* Getting either wrong misreports money or weight.
*/
const TONS = 'COALESCE(b.bulk_total_weight_tons, b.cargo_total_weight_vgm)';
const TONS = bookingTonsSql('b');
const REVENUE = 'COALESCE(b.adjusted_total_amount, b.total_amount)';
/** What the customer described as the booking's contents — see the helper. */
const CONTENT = bookingContentSql('b');
const CONTAINER_COUNT = bookingContainerCountSql('b');
const REQUESTED_COUNT = bookingRequestedContainerCountSql('b');
const STATUS_OPTIONS = [
'DRAFT', 'SUBMITTED', 'UNDER_REVIEW', 'APPROVED', 'REJECTED',
'CANCELLED', 'EXPIRED', 'SCHEDULED', 'LOADED', 'IN_TRANSIT',
'ARRIVED', 'DELIVERED', 'COMPLETED',
].map((v) => ({ value: v, label: v.replace(/_/g, ' ') }));
/**
* Cargo tree flattened for a single select: groups and every commodity beneath
* them, each labelled by its full path ("Bulk → Wheat") the way the booking
* wizard shows a deep leaf. Picking a group row filters its whole subtree.
*
* Recursive because `cargo_types` is arbitrary-depth, not two levels.
*/
async function cargoTypeOptions(ds: DataSource): Promise<ExportFilterOption[]> {
return ds.query(`
WITH RECURSIVE t AS (
SELECT id, display_order, 0 AS depth,
ARRAY[display_order]::int[] AS ord,
ARRAY[cargo_type_name]::text[] AS path
FROM freight.cargo_types
WHERE parent_group_id IS NULL AND deleted_at IS NULL AND is_active
UNION ALL
SELECT c.id, c.display_order, t.depth + 1,
t.ord || c.display_order,
t.path || c.cargo_type_name
FROM freight.cargo_types c
JOIN t ON c.parent_group_id = t.id
WHERE c.deleted_at IS NULL AND c.is_active
)
SELECT id AS value, array_to_string(path, ' → ') AS label
FROM t ORDER BY ord, path
`) as Promise<ExportFilterOption[]>;
}
/** Container types are 2 rows that change about never. */
async function containerTypeOptions(ds: DataSource): Promise<ExportFilterOption[]> {
return ds.query(`
SELECT id AS value, COALESCE(label, code) AS label
FROM freight.container_types
WHERE deleted_at IS NULL AND is_active
ORDER BY display_order, code
`) as Promise<ExportFilterOption[]>;
}
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
/**
* One column per container type ("20FT", "40FT", …), each the box count of
* that type on the booking. Resolved from `container_types` rather than
* hardcoded, so adding a 45ft adds its column without a deploy of this file.
*
* The type id is INTERPOLATED, not bound — `ExportField.select` is a raw SQL
* string with no parameter bag — so ids that are not uuids are dropped rather
* than spliced. They come from our own table; the guard is for the day someone
* changes that column's type.
*/
async function containerTypeFields(ds: DataSource): Promise<ExportField[]> {
const rows: Array<{ id: string; code: string; label: string | null }> = await ds.query(`
SELECT id, code, label
FROM freight.container_types
WHERE deleted_at IS NULL AND is_active
ORDER BY display_order, code
`);
return rows
.filter((r) => UUID_RE.test(r.id))
.map((r) => {
const name = r.label || r.code;
return {
key: `containers${r.code.replace(/[^A-Za-z0-9]/g, '')}`,
label: `${name} containers`,
type: 'number' as const,
group: 'cargo',
select: `(SELECT COALESCE(SUM(bc.quantity), 0)
FROM freight.booking_container bc
WHERE bc.booking_id = b.id
AND bc.deleted_at IS NULL
AND bc.container_type_id = '${r.id}')::int`,
};
});
}
export const bookingsDataset: ExportDataset = {
key: 'bookings',
title: 'Bookings',
@@ -112,10 +206,21 @@ export const bookingsDataset: ExportDataset = {
{ key: 'serviceType', label: 'Service type', type: 'string', group: 'route', requires: ['st'], select: 'st.service_name' },
// ---- Cargo -----------------------------------------------------------
{ key: 'cargo', label: 'Cargo', type: 'string', group: 'cargo', default: true, requires: ['cty'], select: 'COALESCE(cty.cargo_type_name, b.cargo_free_text)' },
// What the customer said is in the booking. `cargo` below is the narrower
// commodity-only view, kept for saved presets that already tick it.
{ key: 'content', label: 'Content', type: 'string', group: 'cargo', default: true, select: CONTENT, sortExpr: CONTENT },
{ key: 'cargo', label: 'Cargo (commodity)', type: 'string', group: 'cargo', requires: ['cty'], select: 'COALESCE(cty.cargo_type_name, b.cargo_free_text)' },
{ key: 'cargoDescription', label: 'Cargo description', type: 'string', group: 'cargo', select: 'b.cargo_free_text' },
// Boxes, not lines: booking_container is one row per LINE with a quantity.
{ key: 'containerCount', label: 'Containers', type: 'number', group: 'cargo', default: true, select: `${CONTAINER_COUNT}::int`, sortExpr: CONTAINER_COUNT },
// Declared on the shipment request, not yet on the booking — see the helper.
{ key: 'requestedCargo', label: 'Requested cargo', type: 'string', group: 'cargo', select: bookingRequestedCargoSql('b') },
{ key: 'requestedContainers', label: 'Requested containers', type: 'number', group: 'cargo', select: `${REQUESTED_COUNT}::int`, sortExpr: REQUESTED_COUNT },
{ key: 'freightType', label: 'Freight type', type: 'string', group: 'cargo', default: true, select: 'b.freight_type' },
{ key: 'tons', label: 'Tonnage', type: 'tons', group: 'cargo', default: true, select: `ROUND(${TONS})::float8`, sortExpr: TONS },
{ key: 'containerWeightVgm', label: 'Container VGM', type: 'number', group: 'cargo', select: 'b.cargo_total_weight_vgm' },
// The per-line sum, NOT b.cargo_total_weight_vgm — the portal leaves that
// column at 0 for container freight, so it read 0 for every such booking.
{ key: 'containerWeightVgm', label: 'Container VGM (t)', type: 'tons', group: 'cargo', select: `${bookingContainerVgmSql('b')}::float8`, sortExpr: bookingContainerVgmSql('b') },
{ key: 'bulkWeightTons', label: 'Bulk weight (t)', type: 'tons', group: 'cargo', select: 'b.bulk_total_weight_tons' },
{ key: 'isHazardous', label: 'Hazardous', type: 'boolean', group: 'cargo', select: 'b.is_hazardous' },
{ key: 'isReefer', label: 'Reefer', type: 'boolean', group: 'cargo', select: 'b.is_reefer' },
@@ -172,6 +277,8 @@ export const bookingsDataset: ExportDataset = {
{ key: 'doubleHandling', label: 'Double handling', type: 'boolean', group: 'clearance', select: 'b.double_handling' },
],
dynamicFields: containerTypeFields,
filters: [
{ key: 'created', label: 'Created', type: 'daterange' },
{ key: 'statuses', label: 'Status', type: 'multiselect', options: STATUS_OPTIONS },
@@ -190,6 +297,13 @@ export const bookingsDataset: ExportDataset = {
{ value: 'PAID', label: 'Paid' },
{ value: 'FAILED', label: 'Failed' },
] },
{ key: 'cargoTypeId', label: 'Content (cargo type)', type: 'select', optionsQuery: cargoTypeOptions },
{ key: 'cargoText', label: 'Content contains', type: 'text' },
{ key: 'containerTypeId', label: 'Container type', type: 'select', optionsQuery: containerTypeOptions },
{ key: 'containersMin', label: 'Containers (min)', type: 'text' },
{ key: 'containersMax', label: 'Containers (max)', type: 'text' },
{ key: 'requestedContainersMin', label: 'Requested containers (min)', type: 'text' },
{ key: 'requestedContainersMax', label: 'Requested containers (max)', type: 'text' },
{ key: 'companyId', label: 'Customer', type: 'text' },
{ key: 'search', label: 'Search reference or customer', type: 'text' },
],
@@ -210,6 +324,23 @@ export const bookingsDataset: ExportDataset = {
if (params.tradeDirection) qb.andWhere('b.trade_direction = :tradeDirection', { tradeDirection: params.tradeDirection });
if (params.freightType) qb.andWhere('b.freight_type = :freightType', { freightType: params.freightType });
// Group or leaf — a group matches its whole subtree (see CARGO_TYPE_SUBTREE_SQL).
if (params.cargoTypeId) qb.andWhere(`b.cargo_type_id IN ${CARGO_TYPE_SUBTREE_SQL}`, { cargoTypeId: params.cargoTypeId });
if (params.cargoText) qb.andWhere(bookingContentMatchSql('b'), { cargoText: `%${params.cargoText as string}%` });
if (params.containerTypeId) qb.andWhere(bookingHasContainerTypeSql('b'), { containerTypeId: params.containerTypeId });
// With a container type picked the count is of THAT type, else of every box.
const containerCount = bookingContainerCountSql('b', Boolean(params.containerTypeId));
// coerceFilterParams yields null (not undefined) for an unset filter, and
// Number(null) is 0 — which would silently apply ">= 0" to every export.
const num = (v: unknown) => (v == null || v === '' ? NaN : Number(v));
const min = num(params.containersMin);
const max = num(params.containersMax);
if (Number.isFinite(min)) qb.andWhere(`${containerCount} >= :containersMin`, { containersMin: min });
if (Number.isFinite(max)) qb.andWhere(`${containerCount} <= :containersMax`, { containersMax: max });
const reqMin = num(params.requestedContainersMin);
const reqMax = num(params.requestedContainersMax);
if (Number.isFinite(reqMin)) qb.andWhere(`${REQUESTED_COUNT} >= :requestedContainersMin`, { requestedContainersMin: reqMin });
if (Number.isFinite(reqMax)) qb.andWhere(`${REQUESTED_COUNT} <= :requestedContainersMax`, { requestedContainersMax: reqMax });
if (params.paymentStatus) qb.andWhere('b.payment_status = :paymentStatus', { paymentStatus: params.paymentStatus });
if (params.companyId) qb.andWhere('b.company_id = :companyId', { companyId: params.companyId });
if (params.search) {

View File

@@ -123,6 +123,7 @@ export const invoicesDataset: ExportDataset = {
// on-screen filter actually carries into the export.
{ key: 'status', label: 'Status (single)', type: 'text' },
{ key: 'sources', label: 'Source', type: 'multiselect' },
{ key: 'types', label: 'Type', type: 'multiselect' },
{ key: 'eimsStatuses', label: 'EIMS status', type: 'multiselect' },
{ key: 'paymentMethods', label: 'Payment method', type: 'multiselect' },
{ key: 'currency', label: 'Currency', type: 'select', options: [
@@ -151,6 +152,8 @@ export const invoicesDataset: ExportDataset = {
if (params.status) qb.andWhere('i.status = :status', { status: params.status });
const sources = params.sources as string[] | null;
if (sources?.length) qb.andWhere('i.source IN (:...sources)', { sources });
const types = params.types as string[] | null;
if (types?.length) qb.andWhere('i.type IN (:...types)', { types });
const eimsStatuses = params.eimsStatuses as string[] | null;
if (eimsStatuses?.length) qb.andWhere('i.eims_status IN (:...eimsStatuses)', { eimsStatuses });
const paymentMethods = params.paymentMethods as string[] | null;

View File

@@ -1,4 +1,5 @@
import { FREIGHT_PERMS } from '../../../seed/freight-permissions.registry';
import { bookingTonsSql } from '../../bookings/booking-tons.sql';
import { Route } from '../../routes/entities/route.entity';
import { Yard } from '../../rule-engine/entities/yard.entity';
import { ShippingLineCompany } from '../../shipping-lines/entities/shipping-line-company.entity';
@@ -86,7 +87,7 @@ export const trainSchedulesDataset: ExportDataset = {
},
{
key: 'totalWeightTons', label: 'Total weight (t)', type: 'tons', group: 'load', default: true,
select: `(SELECT ROUND(COALESCE(SUM(COALESCE(b.bulk_total_weight_tons, b.cargo_total_weight_vgm)), 0))::float8
select: `(SELECT ROUND(COALESCE(SUM(${bookingTonsSql('b')}), 0))::float8
FROM freight.bookings b
WHERE b.train_schedule_id = sch.id AND b.deleted_at IS NULL)`,
},

View File

@@ -1,5 +1,7 @@
import { DataSource } from 'typeorm';
import type { ExportField } from './export.types';
const DAY_MS = 24 * 60 * 60 * 1000;
export type ExportFilterType = 'daterange' | 'date' | 'select' | 'multiselect' | 'text';
@@ -64,6 +66,26 @@ export function coerceFilterParams(
*/
const optionsCache = new Map<string, ExportFilterOption[]>();
/** Process-lifetime cache for `dynamicFields`, keyed by dataset. */
const fieldsCache = new Map<string, ExportField[]>();
/**
* A dataset's full field list: its static fields plus whatever `dynamicFields`
* resolves from the DB. Every read of `dataset.fields` goes through this, so
* the catalog and the download agree on which keys exist.
*/
export async function resolveDatasetFields(
dataset: { key: string; fields: ExportField[]; dynamicFields?: (ds: DataSource) => Promise<ExportField[]> },
ds: DataSource,
): Promise<ExportField[]> {
if (!dataset.dynamicFields) return dataset.fields;
const cached = fieldsCache.get(dataset.key);
if (cached) return cached;
const resolved = [...dataset.fields, ...(await dataset.dynamicFields(ds))];
fieldsCache.set(dataset.key, resolved);
return resolved;
}
export async function resolveFilterOptions(
filters: ExportFilterDef[],
ds: DataSource,

View File

@@ -103,6 +103,15 @@ export interface ExportDataset {
alwaysJoin?: string[];
groups: ExportGroup[];
fields: ExportField[];
/**
* Extra fields resolved from reference data and appended to `fields` — one
* column per row of some small, rarely-changing table (a column per container
* type, say). Cached for the process, like `ExportFilterDef.optionsQuery`.
*
* The SQL these build is interpolated, not bound, so a resolver MUST validate
* anything it splices in; see `bookingsDataset` for the uuid guard.
*/
dynamicFields?: (ds: DataSource) => Promise<ExportField[]>;
filters: ExportFilterDef[];
/** Must name a field whose `sortExpr` references only the base alias. */
defaultSort?: { key: string; dir: 'ASC' | 'DESC' };

View File

@@ -9,7 +9,7 @@ import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/curre
import { assertFreightPermission, hasFreightPermission } from '../../common/freight-permission.util';
import { UserTradeAccessService } from '../user-trade-access/user-trade-access.service';
import { resolveFilterOptions } from './export-filter.util';
import { resolveDatasetFields, resolveFilterOptions } from './export-filter.util';
import {
EXPORT_MIME,
formatRowCap,
@@ -31,13 +31,16 @@ const CAPS = { csv: CSV_ROW_CAP, xlsx: XLSX_ROW_CAP, pdf: PDF_ROW_CAP };
* Metadata only. `select` / `requires` / `sortExpr` are raw SQL and a map of
* the schema — they never leave the server.
*/
const toCatalogEntry = (dataset: ExportDataset): ExportCatalogEntry => ({
const toCatalogEntry = (
dataset: ExportDataset,
fields: ExportField[],
): ExportCatalogEntry => ({
key: dataset.key,
title: dataset.title,
description: dataset.description,
group: dataset.group,
groups: dataset.groups,
fields: dataset.fields.map(({ key, label, type, group, default: isDefault }) => ({
fields: fields.map(({ key, label, type, group, default: isDefault }) => ({
key,
label,
type,
@@ -72,7 +75,7 @@ export class ExportsController {
const allowed = DATASETS.filter((d) => hasFreightPermission(user, d.permission));
return Promise.all(
allowed.map(async (d) => ({
...toCatalogEntry(d),
...toCatalogEntry(d, await resolveDatasetFields(d, this.dataSource)),
filters: await resolveFilterOptions(d.filters, this.dataSource),
})),
);
@@ -102,7 +105,10 @@ export class ExportsController {
const dataset = this.resolve(key, user);
const directions = await this.userTradeAccessService.resolveAllowedDirections(user);
const format = resolveExportFormat(query.format);
const fields = this.resolveFields(dataset, query.fields);
const fields = ExportsController.pickFields(
await resolveDatasetFields(dataset, this.dataSource),
query.fields,
);
const rows = await this.runner.run(dataset, fields, query, directions, {
cap: formatRowCap(format),
@@ -134,15 +140,15 @@ export class ExportsController {
* DEFAULT set, not everything — a booking export has ~70 fields and dumping
* all of them on an unparameterised call is nobody's intent.
*/
private resolveFields(dataset: ExportDataset, raw: string | undefined): ExportField[] {
private static pickFields(all: ExportField[], raw: string | undefined): ExportField[] {
if (raw?.trim()) {
const picked = pickByKey(dataset.fields, raw);
const picked = pickByKey(all, raw);
// pickByKey falls back to everything when nothing matched; for a dataset
// the safer read of "all keys unknown" is still the default set.
if (picked.length !== dataset.fields.length) return picked;
if (picked.length !== all.length) return picked;
}
const defaults = dataset.fields.filter((f) => f.default);
return defaults.length ? defaults : dataset.fields;
const defaults = all.filter((f) => f.default);
return defaults.length ? defaults : all;
}
private resolve(key: string, user: TCurrentUser): ExportDataset {

View File

@@ -1,6 +1,8 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
ArrayMaxSize,
ArrayMinSize,
ArrayNotEmpty,
IsArray,
IsDateString,
@@ -9,6 +11,7 @@ import {
IsOptional,
IsString,
IsUUID,
MaxLength,
Min,
ValidateNested,
} from 'class-validator';
@@ -150,6 +153,14 @@ export class CreateEmptyContainerReturnDto {
@IsUUID()
customerId?: string;
@ApiPropertyOptional({
description: 'Owning company name — free text when the company is not a registered customer.',
})
@IsOptional()
@IsString()
@MaxLength(200)
companyName?: string;
@ApiPropertyOptional()
@IsOptional()
@IsDateString()
@@ -196,6 +207,16 @@ export class CreateEmptyContainerReturnDto {
returnedBy?: 'EDR' | 'CUSTOMER';
}
export class BulkCreateEmptyContainerReturnsDto {
@ApiProperty({ type: [CreateEmptyContainerReturnDto] })
@IsArray()
@ArrayMinSize(1)
@ArrayMaxSize(1000)
@ValidateNested({ each: true })
@Type(() => CreateEmptyContainerReturnDto)
returns!: CreateEmptyContainerReturnDto[];
}
export class LoadEmptyContainerItemDto {
@ApiProperty({ format: 'uuid' })
@IsUUID()

View File

@@ -27,6 +27,15 @@ export class EmptyContainerReturn extends BaseEntity {
@Column({ name: 'customer_id', type: 'uuid', nullable: true })
customerId?: string | null;
/**
* Owning company as text. Set when the box was backfilled for a company that
* is not (yet) a registered customer, so `customer_id` cannot carry it. When
* a registered company IS picked, both are set — the name is the label the
* list renders without a join.
*/
@Column({ name: 'company_name', type: 'varchar', length: 200, nullable: true })
companyName?: string | null;
@Column({ name: 'return_date', type: 'timestamptz' })
returnDate!: Date;
@@ -75,3 +84,14 @@ export class EmptyContainerReturn extends BaseEntity {
performedBy: string | null;
}>;
}
/**
* A row of the returns list: the entity's own columns plus the booking
* reference and owning company joined in. Standalone returns leave
* `bookingId`/`bookingReference` null.
*/
export interface EmptyContainerReturnListItem
extends Omit<EmptyContainerReturn, 'createdAt' | 'updatedAt' | 'deletedAt'> {
bookingReference: string | null;
createdAt: Date;
}

View File

@@ -11,6 +11,7 @@ import { BookingsService } from '../bookings/bookings.service';
import {
AssignCustomsRiskDto,
CreateDjiboutiIncidentDto,
BulkCreateEmptyContainerReturnsDto,
CreateEmptyContainerReturnDto,
ImportOperationActionDto,
LoadEmptyContainersOnTrainDto,
@@ -125,6 +126,15 @@ export class ImportOperationsController {
return this.service.createEmptyReturn(dto);
}
@Post('empty-container-returns/bulk')
@BookingStaff(FREIGHT_PERMS.bookings.operations)
@ApiOperation({
summary: 'Bulk-record empties already in the yard but never entered in the system',
})
bulkCreateEmptyReturns(@Body() dto: BulkCreateEmptyContainerReturnsDto) {
return this.service.bulkCreateEmptyReturns(dto);
}
@Post('empty-container-returns/load-on-train')
@BookingStaff(FREIGHT_PERMS.bookings.operations)
@ApiOperation({

View File

@@ -1,6 +1,6 @@
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { In, Repository } from 'typeorm';
import { In, Not, Repository } from 'typeorm';
import { NotificationAudience, NotificationType } from '@edr/types';
import { LogoSettingsService } from '../logo-settings/logo-settings.service';
@@ -10,6 +10,7 @@ import { NotificationsService } from '../notifications/notifications.service';
import { sendCompanyChannels } from '../notifications/notify-company.util';
import { WarehouseReleaseDocumentService } from '../warehouses/warehouse-release-document.service';
import {
BulkCreateEmptyContainerReturnsDto,
CreateDjiboutiIncidentDto,
CreateEmptyContainerReturnDto,
ImportOperationActionDto,
@@ -24,7 +25,11 @@ import {
type DjiboutiIncidentType,
} from './entities/djibouti-incident.entity';
import { assertWagonLoad } from './empty-container-wagon.util';
import { EmptyContainerReturn } from './entities/empty-container-return.entity';
import {
EmptyContainerReturn,
type EmptyContainerReturnListItem,
type EmptyContainerReturnStatus,
} from './entities/empty-container-return.entity';
import {
ImportCustomsFinalization,
type ImportCustomsDocumentType,
@@ -159,8 +164,43 @@ export class ImportOperationsService {
return this.getCustoms(bookingId);
}
listEmptyReturns() {
return this.emptyReturns.find({ order: { createdAt: 'DESC' } as never });
/**
* Every empty return, booking-linked and standalone alike, in one list. The
* booking reference and the owning company are joined in so the table can
* show which booking a box came back on without a second round trip — a
* standalone row simply has neither, and falls back to the typed
* `company_name`.
*/
listEmptyReturns(): Promise<EmptyContainerReturnListItem[]> {
return this.emptyReturns.manager.query(`
SELECT
r.id,
r.container_number AS "containerNumber",
r.booking_id AS "bookingId",
b.reference AS "bookingReference",
r.customer_id AS "customerId",
COALESCE(r.company_name, c.name) AS "companyName",
r.return_date AS "returnDate",
r.facility,
r.yard,
r.zone,
r.condition,
r.handover_note AS "handoverNote",
r.status,
r.wagon_allocation_reference AS "wagonAllocationReference",
r.container_size AS "containerSize",
r.train_schedule_id AS "trainScheduleId",
r.wagon_sequence_no AS "wagonSequenceNo",
r.performed_by AS "performedBy",
r.returned_by AS "returnedBy",
r.status_history AS "statusHistory",
r.created_at AS "createdAt"
FROM freight.empty_container_returns r
LEFT JOIN freight.bookings b ON b.id = r.booking_id
LEFT JOIN freight.companies c ON c.id = b.company_id
WHERE r.deleted_at IS NULL
ORDER BY r.created_at DESC
`);
}
listEmptyReturnsForBooking(bookingId: string) {
@@ -174,6 +214,7 @@ export class ImportOperationsService {
containerNumber: dto.containerNumber,
bookingId: dto.bookingId ?? null,
customerId: dto.customerId ?? null,
companyName: dto.companyName ?? null,
returnDate,
containerSize: dto.containerSize ?? null,
facility: dto.facility ?? null,
@@ -199,6 +240,66 @@ export class ImportOperationsService {
return saved;
}
/**
* Bulk backfill of empties already sitting in a yard but never recorded.
* All-or-nothing: if any container number already has an open (not COMPLETED)
* return, nothing is written — re-uploading the same sheet must not duplicate
* boxes. No interchange notification is sent; these are historical rows, not
* a live handover.
*/
async bulkCreateEmptyReturns(dto: BulkCreateEmptyContainerReturnsDto) {
const numbers = dto.returns.map((r) => r.containerNumber.trim().toUpperCase());
const seen = new Set<string>();
const dupInFile = numbers.filter((n) => (seen.has(n) ? true : (seen.add(n), false)));
if (dupInFile.length > 0) {
throw new BadRequestException(
`Container number(s) repeated in the upload: ${[...new Set(dupInFile)].join(', ')}`,
);
}
const existing = await this.emptyReturns.find({
where: {
containerNumber: In(numbers),
status: Not('COMPLETED' as EmptyContainerReturnStatus),
},
select: { containerNumber: true },
});
if (existing.length > 0) {
throw new BadRequestException(
`Already recorded as returned: ${existing.map((r) => r.containerNumber).join(', ')}`,
);
}
const rows = dto.returns.map((r, i) => {
const returnDate = r.returnDate ? new Date(r.returnDate) : new Date();
return this.emptyReturns.create({
containerNumber: numbers[i],
bookingId: r.bookingId ?? null,
customerId: r.customerId ?? null,
companyName: r.companyName ?? null,
returnDate,
containerSize: r.containerSize ?? null,
facility: r.facility ?? null,
yard: r.yard ?? null,
zone: r.zone ?? null,
condition: r.condition ?? null,
handoverNote: r.handoverNote ?? null,
performedBy: r.performedBy ?? null,
returnedBy: r.returnedBy ?? null,
statusHistory: [
{
status: 'RETURNED' as const,
changedAt: returnDate.toISOString(),
performedBy: r.performedBy ?? null,
},
],
});
});
return this.emptyReturns.save(rows);
}
/**
* Load returned empties onto an export departure. A wagon takes ONE 40ft or
* TWO 20ft — never a mix, never three. Empties already sitting on a wagon of

View File

@@ -35,9 +35,24 @@ describe("isDomesticPhone", () => {
(phone) => expect(isDomesticPhone(phone)).toBe(true),
);
it.each(["+14155550123", "+447911123456", "0712345678", "+2519866", "12345"])(
"rejects non-domestic or malformed %s",
(phone) => expect(isDomesticPhone(phone)).toBe(false),
// Djibouti is the line's other end: the gateway reaches its 77x mobiles.
it.each(["+25377123456", "25377123456", "77123456"])(
"accepts Djibouti mobile form %s",
(phone) => expect(isDomesticPhone(phone)).toBe(true),
);
it.each([
"+14155550123",
"+447911123456",
"0712345678",
"+2519866",
"12345",
// Djibouti fixed line (2x) — valid number, not a mobile the gateway serves.
"+25321350000",
// Right length, wrong Djibouti prefix.
"+25366123456",
])("rejects unreachable or malformed %s", (phone) =>
expect(isDomesticPhone(phone)).toBe(false),
);
});

View File

@@ -42,20 +42,42 @@ function normalizePhone(rawPhone: string): string {
if (digits.startsWith("+")) return digits;
const bare = digits.replace(/^0+/, "");
if (/^251\d{9}$/.test(digits)) return `+${digits}`;
if (/^253\d{8}$/.test(digits)) return `+${digits}`;
if (/^9\d{8}$|^7\d{8}$/.test(bare)) return `+251${bare}`;
// Djibouti mobiles are 8 digits starting 77 and have no trunk prefix, so a
// bare "77…" is unambiguous — it cannot be an Ethiopian local number, which
// is always 9 digits after the trunk zero.
if (/^77\d{6}$/.test(bare)) return `+253${bare}`;
// Unknown shape (foreign number, already-clean intl without +) — prefix + if
// it looks like a full international number, else leave as typed.
return digits.length >= 11 ? `+${digits}` : raw;
}
/**
* Whether a phone is an Ethiopian mobile the SMS gateway can actually reach —
* the carrier integration is domestic-only, so a send to anything else is
* queued and silently lost. Callers use this to fall back to email instead of
* pretending an SMS is on its way.
* Mobile ranges the SMS gateway is contracted to reach, as E.164 patterns.
*
* The gateway itself is opaque from here — `SmsClientService` publishes to
* RabbitMQ and the carrier sits several hops downstream — so this list is a
* policy statement, not a capability probe: a number outside it is treated as
* unreachable and callers fall back to email rather than promising an SMS that
* would be queued and silently dropped.
*
* - Ethiopia: `+2519…` mobiles only. `+2517…` is deliberately absent; it parses
* as a valid ET number but is not a range this gateway delivers to.
* - Djibouti: `+25377…`, the country's only mobile range (2x is fixed-line).
*/
const REACHABLE_MOBILE_PATTERNS = [/^\+2519\d{8}$/, /^\+25377\d{6}$/];
/**
* Whether a phone sits in a mobile range the SMS gateway can actually reach.
*
* Named "domestic" for the Ethiopian-only era this predates; it now covers both
* countries the railway runs through. Callers use it to fall back to email
* instead of pretending an SMS is on its way.
*/
export function isDomesticPhone(rawPhone: string): boolean {
return /^\+2519\d{8}$/.test(normalizePhone(rawPhone));
const normalized = normalizePhone(rawPhone);
return REACHABLE_MOBILE_PATTERNS.some((p) => p.test(normalized));
}
/**
@@ -99,7 +121,7 @@ export class OtpService {
private readonly otpRepository: OtpRepository,
private readonly notifications: NotificationsService,
private readonly emailClient: EmailClientService,
) { }
) {}
// ---------------------------------------------------------------------------
// Generate OTP
@@ -197,8 +219,10 @@ export class OtpService {
for (const outcome of outcomes) {
this.logger.log(
`otp.dispatch channel=${outcome.channel} target=${label} queued=${outcome.queued
} latencyMs=${Date.now() - startedAt}${outcome.error ? ` error=${outcome.error}` : ""
`otp.dispatch channel=${outcome.channel} target=${label} queued=${
outcome.queued
} latencyMs=${Date.now() - startedAt}${
outcome.error ? ` error=${outcome.error}` : ""
}`,
);
}
@@ -222,7 +246,8 @@ export class OtpService {
// user who never receives a code — indistinguishable from carrier loss,
// and the misleading success response makes it look like our side worked.
this.logger.error(
`otp.dispatch.dropped channels=${channels.join("+")} target=${label} rabbitmqEnabled=${process.env.RABBITMQ_ENABLED ?? "unset"
`otp.dispatch.dropped channels=${channels.join("+")} target=${label} rabbitmqEnabled=${
process.env.RABBITMQ_ENABLED ?? "unset"
} — no transport reported hand-off; no code will arrive for this send`,
);
}
@@ -247,7 +272,8 @@ export class OtpService {
// Log the real cause (DB/SMS/email failure) with its stack so a deployed
// "Failed to send OTP" 400 is diagnosable from the API logs, not opaque.
this.logger.error(
`otp.dispatch.failed channels=${channels.join("+")} target=${label} latencyMs=${Date.now() - startedAt
`otp.dispatch.failed channels=${channels.join("+")} target=${label} latencyMs=${
Date.now() - startedAt
}: ${error instanceof Error ? error.message : String(error)}`,
error instanceof Error ? error.stack : undefined,
);
@@ -330,8 +356,9 @@ export class OtpService {
) {
const line = `otp.verify channels=${channelsOf(target).join(
"+",
)} target=${this.targetLabel(target)} mode=${mode} result=${result}${detail ? ` ${detail}` : ""
}`;
)} target=${this.targetLabel(target)} mode=${mode} result=${result}${
detail ? ` ${detail}` : ""
}`;
if (result === "ok") this.logger.log(line);
else this.logger.warn(line);

View File

@@ -1,6 +1,7 @@
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
import { BookingStatus } from '@edr/types';
import { bookingTonsSql } from '../../bookings/booking-tons.sql';
import { Booking } from '../../bookings/entities/booking.entity';
import { Yard } from '../../rule-engine/entities/yard.entity';
import { CargoType } from '../../rule-engine/entities/cargo-type.entity';
@@ -9,7 +10,7 @@ import { ReportContext, ReportDefinition } from '../report.types';
// One resolver behind "Booking per status, per port/train/date/cargo/contract
// type" — the same breakdown Operation, Marketing, Global Logistics and the
// Operation Report each ask for verbatim. Embed once, reuse everywhere.
const TONS = 'COALESCE(b.bulk_total_weight_tons, b.cargo_total_weight_vgm)';
const TONS = bookingTonsSql('b');
const REVENUE = 'COALESCE(b.adjusted_total_amount, b.total_amount)';
const STATUS_OPTIONS = [...new Set(Object.values(BookingStatus))].map((v) => ({

View File

@@ -1,9 +1,10 @@
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
import { bookingTonsSql } from '../../bookings/booking-tons.sql';
import { Booking } from '../../bookings/entities/booking.entity';
import { ReportContext, ReportDefinition } from '../report.types';
const TONS = 'COALESCE(b.bulk_total_weight_tons, b.cargo_total_weight_vgm)';
const TONS = bookingTonsSql('b');
const NOT_UMBRELLA = "(b.contract_kind IS NULL OR b.contract_kind <> 'GENERAL')";
const DEAD_STATUSES = ['DRAFT', 'CANCELLED', 'REJECTED', 'EXPIRED'];

View File

@@ -1,10 +1,11 @@
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
import { bookingTonsSql } from '../../bookings/booking-tons.sql';
import { Company } from '../../companies/entities/company.entity';
import { Contract } from '../../contracts/entities/contract.entity';
import { ReportContext, ReportDefinition } from '../report.types';
const TONS = 'COALESCE(b.bulk_total_weight_tons, b.cargo_total_weight_vgm)';
const TONS = bookingTonsSql('b');
const DEAD_STATUSES = ['DRAFT', 'CANCELLED', 'REJECTED', 'EXPIRED'];
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {

View File

@@ -273,6 +273,20 @@ export class TrainSchedule extends BaseEntity {
@Column({ name: 'wagon_allocation_snapshot', type: 'jsonb', nullable: true })
wagonAllocationSnapshot?: WagonAllocationSnapshot | null;
/**
* Why this schedule was cancelled — required at cancel time and shown on every
* view of the cancelled train. NULL on live schedules and on rows cancelled
* before the reason was captured.
*/
@Column({ name: 'cancellation_reason', type: 'varchar', length: 500, nullable: true })
cancellationReason?: string | null;
@Column({ name: 'cancelled_at', type: 'timestamptz', nullable: true })
cancelledAt?: Date | null;
@Column({ name: 'cancelled_by_user_id', type: 'uuid', nullable: true })
cancelledByUserId?: string | null;
@OneToMany(() => TrainScheduleBooking, (scheduleBooking) => scheduleBooking.trainSchedule)
scheduleBookings?: TrainScheduleBooking[];
}

View File

@@ -1379,6 +1379,8 @@ describe('BookingBatchService — built-train wagon capacity', () => {
maxWagons?: number;
routeStops?: string[];
yardCountries?: Record<string, string>;
maxPullWeightTons?: number;
maxTrainLengthMeters?: number;
}) => {
const schedule = {
id: scheduleId,
@@ -1390,8 +1392,11 @@ describe('BookingBatchService — built-train wagon capacity', () => {
scheduleBookings: [],
trainSet: {
locomotive: {
maxPullWeightTons: 1,
maxTrainLengthMeters: 1,
// Roomy on purpose: these cases exercise the SLOT axis, so the pull
// budget must not be what closes the train. Weight-bound behaviour
// has its own cases below.
maxPullWeightTons: opts.maxPullWeightTons ?? 100000,
maxTrainLengthMeters: opts.maxTrainLengthMeters ?? 100000,
overageToleranceTons: 0,
overageToleranceMeters: 0,
},
@@ -1459,15 +1464,28 @@ describe('BookingBatchService — built-train wagon capacity', () => {
await expect(service.isScheduleFull(scheduleId)).resolves.toBe(true);
});
it('is NOT full while physical wagons remain, ignoring weight/length limits', async () => {
it('is NOT full while physical wagons remain and the loco can still haul them', async () => {
const { service } = buildService({
physicalWagons: 3,
reserved: [reservedBooking('b1'), reservedBooking('b2')],
});
// 1T pull cap would have been exhausted long ago under the old math.
await expect(service.isScheduleFull(scheduleId)).resolves.toBe(false);
});
it('is FULL when the locomotive cannot pull another wagon, though slots are free', async () => {
// The consist has a spare slot, but every wagon spends its TARE out of the
// same pull limit the cargo needs — so a slot-free train can still be
// weight-full. This is what let a 44-wagon booking plan 4065T gross onto a
// 3500T train while the board advertised free wagons.
const { service } = buildService({
physicalWagons: 3,
reserved: [reservedBooking('b1'), reservedBooking('b2')],
maxPullWeightTons: 1,
maxTrainLengthMeters: 1,
});
await expect(service.isScheduleFull(scheduleId)).resolves.toBe(true);
});
it('is NOT full when only a middle leg is sold and other edges run free (domestic route)', async () => {
// Leg-aware allocation (planWagonsWithStock legs) made mid-leg wagons real
// capacity on the edges they don't ride: a domestic corridor with cargo

View File

@@ -658,8 +658,36 @@ export class BookingBatchService implements OnModuleInit {
}
}
const linked =
let linked =
await this.trainScheduleBookingsRepository.existsForBooking(bookingId);
// A link row can outlive the booking's own pointer (cleared on one path
// while the row survives on another). The booking then looks "linked" here,
// so the branch below calls tryAutoWagonAllocation(null) and the paid
// booking silently never gets wagons — no error, just no allocation.
if (linked && !booking.trainScheduleId) {
// The link row still names the train it belongs to — restore the pointer
// from it rather than dropping the link, so the booking keeps the train
// it was placed on and the allocation below has a schedule to run against.
const [link] = await this.trainScheduleBookingsRepository.findByBookingIds([
bookingId,
]);
if (link?.trainScheduleId) {
await this.dataSource
.getRepository(Booking)
.update(bookingId, { trainScheduleId: link.trainScheduleId } as never);
booking.trainScheduleId = link.trainScheduleId;
this.logger.warn(
`[BATCH] ${booking.reference ?? bookingId} was linked to schedule ${link.trainScheduleId} ` +
`with no train_schedule_id of its own — pointer restored so it can allocate`,
);
} else {
await this.trainScheduleBookingsRepository.deleteByScheduleAndBooking(
link?.trainScheduleId ?? '',
bookingId,
);
linked = false;
}
}
// Intercity is allocated MANUALLY: payment secures the ride, staff then
// place it on whichever same-route train suits (intercity panel). Unpin
// from the train it reserved against — that train may be the wrong one by
@@ -3939,6 +3967,11 @@ export class BookingBatchService implements OnModuleInit {
schedulingStatus: "SCHEDULED",
scheduledAt: new Date(),
wagonsRequired,
// Pinned for cancellation pricing: unassign clears wagonsRequired, this
// stays. Written once — a later re-allocation keeps the first stamp.
...(Number(booking.cancellationWagons ?? 0) > 0
? {}
: { cancellationWagons: wagonsRequired }),
paymentDeadline: null,
selectedForBatchAt: null,
} as never);
@@ -5376,10 +5409,13 @@ export class BookingBatchService implements OnModuleInit {
* Dire→Djibouti leaves the Addis→Dire edges untouched.
*
* Two capacity regimes, decided by the schedule's train:
* - Built train (Train Builder consist with physical wagons): the consist IS
* the capacity. Wagon slots = physical wagon count; weight and length are
* NOT re-checked here — the builder and adjust-consist already enforced the
* locomotive's pull/length limits when the consist was assembled.
* - Built train (Train Builder consist with physical wagons): wagon slots =
* physical wagon count, but the locomotive's weight/length budgets STILL
* apply. The builder only proves the EMPTY consist can be pulled; every
* wagon then spends its tare out of the same pull limit the cargo needs, so
* a 54-wagon consist can be slot-free and still weight-full. Treating the
* consist as unlimited tonnage is what let a 44-wagon booking plan 4065T
* gross onto a 3500T train.
* - No built train (legacy schedules): the locomotive's length-derived slot
* count plus its weight/length budgets, as before — yard staff attach the
* missing wagons manually before wagon assignment.
@@ -5392,13 +5428,16 @@ export class BookingBatchService implements OnModuleInit {
): Promise<CorridorBudget> {
const physicalWagons = await this.builtTrainWagonCount(schedule);
if (physicalWagons != null) {
// The consist fixes the SLOT count (never the locomotive's length-derived
// estimate), but weight and length stay on the locomotive's real budget —
// including its overage tolerance, which `fits` may spend on a whole unit.
limits = {
base: {
wagons: physicalWagons,
weightTons: Number.POSITIVE_INFINITY,
lengthMeters: Number.POSITIVE_INFINITY,
weightTons: limits.base.weightTons,
lengthMeters: limits.base.lengthMeters,
},
tolerance: { weightTons: 0, lengthMeters: 0 },
tolerance: limits.tolerance,
};
}
// Built trains keep the leg-aware multi-edge corridor too: the wagon
@@ -5631,19 +5670,23 @@ export class BookingBatchService implements OnModuleInit {
const wagonDims = await this.loadWagonDims();
const physicalWagons = await this.builtTrainWagonCount(schedule);
let limits: TrainLimits;
const locomotive = trainSetLocomotiveLimits(schedule.trainSet);
if (physicalWagons != null) {
// The consist is the capacity; weight/length were settled at build time.
// remainingBudget swaps in the physical wagon count per edge itself.
limits = {
base: {
wagons: physicalWagons,
weightTons: Number.POSITIVE_INFINITY,
lengthMeters: Number.POSITIVE_INFINITY,
},
tolerance: { weightTons: 0, lengthMeters: 0 },
};
// The consist fixes the slot count, but the locomotive's pull/length
// budget still binds: 54 empty slots are worthless once the tare of the
// wagons already loaded has spent the pull limit. Without a locomotive
// there is nothing to weigh against, so the slot axis is all that is left.
limits = locomotive
? await this.capacityLimits(locomotive)
: {
base: {
wagons: physicalWagons,
weightTons: Number.POSITIVE_INFINITY,
lengthMeters: Number.POSITIVE_INFINITY,
},
tolerance: { weightTons: 0, lengthMeters: 0 },
};
} else {
const locomotive = trainSetLocomotiveLimits(schedule.trainSet);
// No loco, no built train: only the slot axis exists to bind against.
if (!locomotive) return (await this.remainingWagons(schedule)) <= 0;
limits = await this.capacityLimits(locomotive);

View File

@@ -39,15 +39,30 @@ export class BookingNotifierService {
try {
const s = await this.trainSchedules.findByIdWithStations(scheduleId);
if (!s) return fallback;
const ref = s.reference ?? s.trainNumber ?? null;
const route =
// Customers know the train by its operating number (8001), not the
// schedule reference — lead with it and keep S-… as the secondary id.
const parts = [
s.reference,
s.originStation?.label && s.destinationStation?.label
? ` (${s.originStation.label}${s.destinationStation.label})`
: '';
? `${s.originStation.label}${s.destinationStation.label}`
: null,
].filter(Boolean);
const detail = parts.length ? ` (${parts.join(', ')})` : '';
const departure = s.scheduledDepartureDate
? `, departing ${new Date(s.scheduledDepartureDate).toLocaleDateString('en-GB', { timeZone: BATCH_TIMEZONE })}`
? `, departing ${new Date(s.scheduledDepartureDate).toLocaleString('en-GB', {
timeZone: BATCH_TIMEZONE,
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
hour12: false,
})} EAT`
: '';
return ref ? `train ${ref}${route}${departure}` : `${fallback}${route}${departure}`;
const number = s.trainNumber ?? s.reference ?? null;
return number
? `train ${number}${number === s.reference ? '' : detail}${departure}`
: `${fallback}${detail}${departure}`;
} catch (err) {
this.logger.warn(
`scheduleLabel(${scheduleId}) failed: ${(err as Error).message}`,

View File

@@ -32,6 +32,7 @@ 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 { CancelTrainScheduleDto } from "../dto/cancel-train-schedule.dto";
import { AssignUnassignedBookingDto } from "../dto/assign-unassigned-booking.dto";
import { SwitchGovernmentBookingDto } from "../dto/switch-government-booking.dto";
import { CreateContainerTrainScheduleDto } from "../dto/create-container-train-schedule.dto";
@@ -894,6 +895,28 @@ export class TrainSchedulingController {
return res.send(buffer);
}
@Get("schedules/:id/marshalling/stops")
@TrainSchedulingView()
@ApiOperation({ summary: "Corridor stops with a logged consist change, in order (Marshalling 2, 3, 4…)" })
marshallingStops(@Param("id", ParseUUIDPipe) id: string) {
return this.trainSchedulingService.marshallingStops(id);
}
@Get("schedules/:id/marshalling/document/:stopIndex")
@TrainSchedulingView()
@ApiOperation({ summary: "Download the numbered marshalling PDF for one corridor stop" })
async marshallingDocumentAt(
@Param("id", ParseUUIDPipe) id: string,
@Param("stopIndex", ParseIntPipe) stopIndex: number,
@Res() res: Response,
) {
const { filename, buffer } = await this.trainSchedulingService.marshallingDocumentAt(id, stopIndex);
res.setHeader("Content-Type", "application/pdf");
res.setHeader("Content-Disposition", `inline; filename="${filename}"`);
res.setHeader("Content-Length", buffer.length);
return res.send(buffer);
}
// ---- batch / booking-window staff actions ----
@Post("schedules/:id/run-batch")
@@ -1184,14 +1207,22 @@ export class TrainSchedulingController {
@Post("container/schedules/:id/cancel")
@TrainSchedulingCancel()
@ApiOperation({ summary: "Cancel container train schedule" })
cancelTrainSchedule(@Param("id", ParseUUIDPipe) id: string) {
return this.trainSchedulingService.cancelTrainSchedule(id);
cancelTrainSchedule(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: CancelTrainScheduleDto,
@CurrentUser() user: AuthUserPayload,
) {
return this.trainSchedulingService.cancelTrainSchedule(id, dto, user?.id);
}
@Post('bulk/schedules/:id/cancel')
@TrainSchedulingCancel()
@ApiOperation({ summary: "Cancel bulk train schedule" })
cancelBulkTrainSchedule(@Param("id", ParseUUIDPipe) id: string) {
return this.trainSchedulingService.cancelTrainSchedule(id);
cancelBulkTrainSchedule(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: CancelTrainScheduleDto,
@CurrentUser() user: AuthUserPayload,
) {
return this.trainSchedulingService.cancelTrainSchedule(id, dto, user?.id);
}
}

View File

@@ -0,0 +1,145 @@
import { BadRequestException } from '@nestjs/common';
import { TrainSchedulingService } from './services/train-scheduling.service';
/**
* Per-wagon loading dispatch gate. A booking half-loaded at the DEPARTURE yard
* blocks the train; a booking that boards further down the corridor
* (A→B→C→D carrying a B→C load) never does — its wagons are not due until its
* own yard, so the SQL is scoped by `b.origin_yard_id = <schedule origin>`.
* The scoping lives in the query, so this checks the parameters that carry it
* plus the throw/pass decision on the rows it returns.
*/
describe('TrainSchedulingService.assertNoPartiallyLoadedBookings', () => {
const ORIGIN = 'yard-a';
const SET = 'set-1';
const makeService = (rows: Array<{ reference: string; loaded: string; total: string }>) => {
const calls: Array<{ sql: string; params: unknown[] }> = [];
const svc = Object.create(TrainSchedulingService.prototype) as {
dataSource: { query: (sql: string, params: unknown[]) => Promise<unknown> };
assertNoPartiallyLoadedBookings(
schedule: unknown,
boardingYardId: string,
context: { action: string; yardLabel?: string },
): Promise<void>;
assertPassedYardsFullyLoaded(
schedule: unknown,
stations: Array<{ sequenceNo: number; yardId: string; label: string }>,
sequenceNo: number,
): Promise<void>;
};
svc.dataSource = {
query: async (sql: string, params: unknown[]) => {
calls.push({ sql, params });
return rows;
},
};
return { svc, calls };
};
const schedule = { trainSetId: SET, originStationId: ORIGIN };
it('scopes the scan to bookings boarding at this departure yard', async () => {
const { svc, calls } = makeService([]);
await svc.assertNoPartiallyLoadedBookings(schedule, ORIGIN, { action: 'dispatch' });
expect(calls).toHaveLength(1);
// The origin filter is what keeps a mid-corridor booking from holding the
// train — without it, one early-loaded B→C wagon blocks dispatch at A.
expect(calls[0].sql).toContain('b.origin_yard_id = $2');
expect(calls[0].params).toEqual([SET, ORIGIN]);
});
it('lets the train go when nothing at this yard is half-loaded', async () => {
const { svc } = makeService([]);
await expect(
svc.assertNoPartiallyLoadedBookings(schedule, ORIGIN, { action: 'dispatch' }),
).resolves.toBeUndefined();
});
it('blocks a booking half-loaded at this yard, naming its progress', async () => {
const { svc } = makeService([{ reference: 'BK-2026-000220', loaded: '4', total: '5' }]);
await expect(
svc.assertNoPartiallyLoadedBookings(schedule, ORIGIN, { action: 'dispatch' }),
).rejects.toThrow(/BK-2026-000220 \(4\/5 wagons loaded\)/);
await expect(
svc.assertNoPartiallyLoadedBookings(schedule, ORIGIN, { action: 'dispatch' }),
).rejects.toBeInstanceOf(BadRequestException);
});
it('skips the scan entirely for a schedule with no train set', async () => {
const { svc, calls } = makeService([{ reference: 'X', loaded: '1', total: '2' }]);
await expect(
svc.assertNoPartiallyLoadedBookings({ trainSetId: null }, ORIGIN, {
action: 'dispatch',
}),
).resolves.toBeUndefined();
expect(calls).toHaveLength(0);
});
});
/**
* Mid-corridor twin: logging a checkpoint at station N means the train left
* every earlier stop, so each of those yards is checked for its OWN
* half-loaded bookings. The origin is excluded (dispatch gated it) and the
* yard being arrived at is excluded (its loading has not happened yet).
*/
describe('TrainSchedulingService.assertPassedYardsFullyLoaded', () => {
const STATIONS = [
{ sequenceNo: 0, yardId: 'mojo', label: 'Mojo' },
{ sequenceNo: 1, yardId: 'adama', label: 'Adama' },
{ sequenceNo: 2, yardId: 'dire', label: 'Dire Dawa' },
{ sequenceNo: 3, yardId: 'djibouti', label: 'Djibouti' },
];
const makeService = (rowsByYard: Record<string, Array<Record<string, string>>>) => {
const scanned: string[] = [];
const svc = Object.create(TrainSchedulingService.prototype) as {
dataSource: { query: (sql: string, params: unknown[]) => Promise<unknown> };
assertPassedYardsFullyLoaded(
schedule: unknown,
stations: typeof STATIONS,
sequenceNo: number,
): Promise<void>;
};
svc.dataSource = {
query: async (_sql: string, params: unknown[]) => {
const yardId = params[1] as string;
scanned.push(yardId);
return rowsByYard[yardId] ?? [];
},
};
return { svc, scanned };
};
const schedule = { trainSetId: 'set-1', originStationId: 'mojo' };
it('checks the stops already departed, never the origin or the yard being reached', async () => {
const { svc, scanned } = makeService({});
await svc.assertPassedYardsFullyLoaded(schedule, STATIONS, 3);
// Mojo is dispatch's job; Djibouti has not been loaded at yet.
expect(scanned).toEqual(['adama', 'dire']);
});
it('blocks the checkpoint when a passed yard left a booking half-loaded', async () => {
const { svc } = makeService({
adama: [{ reference: 'BK-200', loaded: '5', total: '8' }],
});
await expect(svc.assertPassedYardsFullyLoaded(schedule, STATIONS, 2)).rejects.toThrow(
/Adama.*BK-200 \(5\/8 wagons loaded\)/s,
);
});
it('names the resolution the operator has: load the rest, or cancel it', async () => {
const { svc } = makeService({
adama: [{ reference: 'BK-200', loaded: '5', total: '8' }],
});
await expect(svc.assertPassedYardsFullyLoaded(schedule, STATIONS, 2)).rejects.toThrow(
/customer fault: cancellation fee; EDR fault: no fee, rebookable/,
);
});
it('scans nothing at the first checkpoint after the origin', async () => {
const { svc, scanned } = makeService({});
await svc.assertPassedYardsFullyLoaded(schedule, STATIONS, 1);
expect(scanned).toEqual([]);
});
});

View File

@@ -0,0 +1,14 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString, MaxLength } from 'class-validator';
export class CancelTrainScheduleDto {
@ApiProperty({
description:
'Why this train is being cancelled. Shown on the schedule from then on, and to the staff who have to re-place its bookings.',
maxLength: 500,
})
@IsString()
@IsNotEmpty()
@MaxLength(500)
reason!: string;
}

View File

@@ -7,6 +7,7 @@ import {
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { bookingTonsSql } from '../bookings/booking-tons.sql';
import { Booking } from '../bookings/entities/booking.entity';
import { RouteMilestone } from '../routes/entities/route-milestone.entity';
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
@@ -58,7 +59,7 @@ export class IntercityService {
b.reference AS "reference",
b.status AS "status",
b.freight_type AS "freightType",
b.cargo_total_weight_vgm AS "weightTons",
${bookingTonsSql('b')} AS "weightTons",
b.loaded_at AS "loadedAt",
b.arrived_at AS "arrivedAt",
company.name AS "customer",

View File

@@ -333,7 +333,9 @@ export class RemainderPlacementService {
return deferred.map((u) => ({
containerNumber: u.containerNumber,
sealNumber: u.sealNumber ?? undefined,
// Deferred units predate the seal requirement; the booking service
// normalizes the blank back to null rather than rejecting the re-book.
sealNumber: u.sealNumber ?? '',
vgmTons: Number(u.vgmTons),
isHazardous: u.isHazardous,
isReefer: u.isReefer,

View File

@@ -1209,7 +1209,7 @@ describe('TrainSchedulingService', () => {
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', () => {
it('prints the consist-changes table for this stop, and omits it when there are none', () => {
const schedule = {
id: 'schedule-1',
trainNumber: '8302',
@@ -1223,16 +1223,21 @@ describe('TrainSchedulingService', () => {
const withChanges = build(schedule, {
consistChangesAtStop: [
{ action: 'ADD', wagonNumber: 'W-1002' },
{ action: 'SWITCH', wagonNumber: 'W-0501 → W-1003' },
{ wagonNumber: 'W-1002', event: 'Coupled', containerNumbers: 'EMPTY WAGON' },
{ wagonNumber: 'W-1005', event: 'Coupled', containerNumbers: 'CONT-004, CONT-005' },
{ wagonNumber: 'W-0501 → W-1003', event: 'Switched', containerNumbers: 'CONT-011' },
],
});
expect(withChanges).toContain('Consist changed at this stop');
expect(withChanges).toContain('Coupled: W-1002');
expect(withChanges).toContain('Uncoupled — replaced: W-0501 → W-1003');
expect(withChanges).toContain('Consist Changed At This Stop');
expect(withChanges).toContain('<td>W-1002</td>');
expect(withChanges).toContain('<td>Coupled</td>');
expect(withChanges).toContain('<td>EMPTY WAGON</td>');
expect(withChanges).toContain('<td>CONT-004, CONT-005</td>');
expect(withChanges).toContain('<td>W-0501 → W-1003</td>');
expect(withChanges).toContain('<td>Switched</td>');
const withoutChanges = build(schedule, {});
expect(withoutChanges).not.toContain('Consist changed at this stop');
expect(withoutChanges).not.toContain('Consist Changed At This Stop');
});
it('lists loaded empty containers by number and states they are empty', () => {

View File

@@ -2320,12 +2320,18 @@ export class TrainSchedulingService {
// batch fill, which unlinks it and frees its wagons on the next window cycle.
const scheduledAt = new Date();
for (const booking of bookings) {
const wagonsRequired = sumWagonsRequired(booking, wagonPlan);
await this.bookingsRepository.updateSchedulingFields(
booking.id,
{
schedulingStatus: SchedulingStatus.Scheduled,
scheduledAt,
wagonsRequired: sumWagonsRequired(booking, wagonPlan),
wagonsRequired,
// Pinned for cancellation pricing: unassign clears wagonsRequired,
// this stays. Written once — re-allocation keeps the first stamp.
...(Number(booking.cancellationWagons ?? 0) > 0
? {}
: { cancellationWagons: wagonsRequired }),
},
manager,
);
@@ -2949,7 +2955,9 @@ export class TrainSchedulingService {
// Per-wagon loading: a booking mid-load is neither ridable nor removable —
// every wagon must be LOADED, or the never-loaded remainder cancelled
// (at-loading cancellation), before the train departs.
await this.assertNoPartiallyLoadedBookings(schedule);
await this.assertNoPartiallyLoadedBookings(schedule, schedule.originStationId, {
action: 'dispatch',
});
await this.dataSource.transaction(async (manager) => {
const trainNumber = await this.assignTrainNumber(manager, schedule);
@@ -3149,13 +3157,21 @@ export class TrainSchedulingService {
* (their charge sits on the credit ledger) yet ride from accept.
*/
/**
* Per-wagon loading dispatch gate: a booking with SOME wagons LOADED and
* SOME still PLANNED/RESERVED must resolve before departure — load the rest
* or cancel it (which shrinks the booking to its loaded wagons). Blocking
* here beats silently unassigning: unassign would delete LOADED allocations
* and strand cargo that is physically on the train.
* Per-wagon loading gate: a booking with SOME wagons LOADED and SOME still
* PLANNED/RESERVED must resolve before the train leaves the yard it boards
* at — load the rest, or cancel the remainder (which shrinks the booking to
* its loaded wagons). Blocking beats silently unassigning: unassign would
* delete LOADED allocations and strand cargo physically on the train.
*
* Scoped to bookings BOARDING AT `boardingYardId`, so each yard answers only
* for its own cargo: a mid-corridor booking (A→B→C→D carrying a B→C load) is
* not due at A and must never hold the train there.
*/
private async assertNoPartiallyLoadedBookings(schedule: TrainSchedule): Promise<void> {
private async assertNoPartiallyLoadedBookings(
schedule: TrainSchedule,
boardingYardId: string,
context: { action: string; yardLabel?: string },
): Promise<void> {
if (!schedule.trainSetId) return;
const rows: Array<{ reference: string; loaded: string; total: string }> =
await this.dataSource.query(
@@ -3166,24 +3182,51 @@ export class TrainSchedulingService {
JOIN freight.train_set_wagons tsw ON tsw.id = a.train_set_wagon_id
JOIN freight.bookings b ON b.id = a.booking_id
WHERE tsw.train_set_id = $1
AND b.origin_yard_id = $2
AND a.deleted_at IS NULL
AND tsw.deleted_at IS NULL
AND b.deleted_at IS NULL
GROUP BY b.id, b.reference
HAVING COUNT(*) FILTER (WHERE a.status IN ('LOADED', 'DEPARTED')) > 0
AND COUNT(*) FILTER (WHERE a.status NOT IN ('LOADED', 'DEPARTED')) > 0`,
[schedule.trainSetId],
[schedule.trainSetId, boardingYardId],
);
if (rows.length) {
const detail = rows
.map((r) => `${r.reference} (${r.loaded}/${r.total} wagons loaded)`)
.join(', ');
const where = context.yardLabel ? ` at ${context.yardLabel}` : '';
throw new BadRequestException(
`Cannot dispatch: booking(s) partially loaded — load every wagon or cancel the remainder first: ${detail}`,
`Cannot ${context.action}: booking(s) partially loaded${where} — load every wagon ` +
`or cancel the remainder (customer fault: cancellation fee; EDR fault: no fee, ` +
`rebookable) first: ${detail}`,
);
}
}
/**
* Mid-corridor twin of the dispatch gate. Logging a checkpoint at station N
* asserts the train has left every earlier stop, so each of those yards must
* have no half-loaded booking of its own left behind. The origin (seq 0) is
* skipped — dispatch already gated it — and the final station is included:
* arriving there still means the train left the stop before it.
*/
private async assertPassedYardsFullyLoaded(
schedule: TrainSchedule,
stations: Array<{ sequenceNo: number; yardId: string; label: string }>,
sequenceNo: number,
): Promise<void> {
const departed = stations.filter(
(st) => st.sequenceNo > 0 && st.sequenceNo < sequenceNo,
);
for (const st of departed) {
await this.assertNoPartiallyLoadedBookings(schedule, st.yardId, {
action: 'record this checkpoint',
yardLabel: st.label,
});
}
}
private async unloadedOriginBoarderIds(
scheduleId: string,
originYardId: string,
@@ -3580,7 +3623,83 @@ export class TrainSchedulingService {
return { wagons, unassignedBookings };
}
async intercityMarshallingDocument(scheduleId: string): Promise<{ filename: string; buffer: Buffer }> {
/**
* Every corridor stop where the consist actually changed for this schedule
* (coupled, uncoupled, or switched — any flavor), in the order the train
* reached them. Origin is never in this list — it's always its own doc (the
* plain import/export load list), so numbering here starts at 2. A stop with
* only a routine checkpoint and no consist change never gets a row, which is
* the point: "Marshalling 2, 3, 4…" tracks events, not raw stop count.
*/
async marshallingStops(
scheduleId: string,
): Promise<Array<{ stopIndex: number; yardId: string; yardLabel: string; firstOccurredAt: string }>> {
const rows = await this.dataSource.getRepository(ScheduleWagonAdjustmentLog).find({
where: { trainScheduleId: scheduleId },
order: { occurredAt: 'ASC' },
});
const firstSeenAt = new Map<string, Date>();
for (const row of rows) {
if (!row.yardId || firstSeenAt.has(row.yardId)) continue;
firstSeenAt.set(row.yardId, row.occurredAt);
}
const orderedYardIds = [...firstSeenAt.entries()]
.sort((a, b) => a[1].getTime() - b[1].getTime())
.map(([yardId]) => yardId);
const labels = await this.yardLabelsById(orderedYardIds);
return orderedYardIds.map((yardId, i) => ({
stopIndex: i + 2,
yardId,
yardLabel: labels.get(yardId) ?? yardId,
firstOccurredAt: firstSeenAt.get(yardId)!.toISOString(),
}));
}
/**
* The coupled/uncoupled/switched rows for one stop, in the locked table
* shape (wagon, event, containers). "EMPTY WAGON" replaces the container
* list rather than a blank cell — the column always exists so a loaded and
* an empty coupling read as the same table, not two different layouts.
* Cargo for ADD/REMOVE rows is read off the schedule's OWN slot allocations
* for that physical wagon: an ADD is a leg slot boarding already loaded (see
* stampSlotLegs) or an empty couple (plannedWagonCouples) with none; a
* REMOVE is a slot alighting with its cargo, or an empty trim. A SWITCH row
* carries the incoming wagon's id — the slot's cargo already rides it.
*/
private consistChangesAt(
schedule: TrainSchedule,
logRows: ScheduleWagonAdjustmentLog[],
): Array<{ wagonNumber: string; event: 'Coupled' | 'Uncoupled' | 'Switched'; containerNumbers: string }> {
const slotByPhysicalWagonId = new Map(
(schedule.trainSet?.wagons ?? [])
.filter((wagon) => wagon.physicalWagonId)
.map((wagon) => [wagon.physicalWagonId as string, wagon]),
);
return logRows.map((row) => {
const slot = slotByPhysicalWagonId.get(row.wagonId);
const containerNumbers = (slot?.allocations ?? [])
.flatMap((allocation) => allocation.containerItems ?? [])
.map((item) => item.containerNumber)
.filter(Boolean)
.join(', ');
return {
wagonNumber: row.wagonNumber,
event: row.action === 'ADD' ? 'Coupled' : row.action === 'REMOVE' ? 'Uncoupled' : 'Switched',
containerNumbers: containerNumbers || 'EMPTY WAGON',
};
});
}
/**
* The numbered marshalling document for one corridor stop (see
* marshallingStops — stopIndex 2+, origin is its own separate doc).
* ponytail: the wagon table always shows the CURRENT on-board state, not a
* point-in-time reconstruction of what stood on the train at that past
* stop — a full historical snapshot is a much bigger feature nobody has
* asked for. What's stop-specific is the consist-changes table below it,
* which IS scoped to that stop's own logged events.
*/
async marshallingDocumentAt(scheduleId: string, stopIndex: number): Promise<{ filename: string; buffer: Buffer }> {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule) {
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
@@ -3590,24 +3709,66 @@ export class TrainSchedulingService {
'Intercity marshalling document applies only to dispatched or arrived trains',
);
}
const stops = await this.marshallingStops(scheduleId);
const stop = stops.find((s) => s.stopIndex === stopIndex);
if (!stop) {
throw new NotFoundException(
`No marshalling document at stop ${stopIndex} for this schedule — nothing coupled/uncoupled there, or the stop doesn't exist`,
);
}
const { wagons, unassignedBookings } = this.intercityOnBoardView(schedule);
const logRows = await this.dataSource.getRepository(ScheduleWagonAdjustmentLog).find({
where: { trainScheduleId: scheduleId, yardId: stop.yardId },
order: { occurredAt: 'ASC' },
});
const html = this.buildExportLoadListHtml(schedule, {
title: `Intercity Marshalling Document / Load List (Marshalling ${stopIndex})`,
positionLabel: `At ${stop.yardLabel}`,
wagons,
unassignedBookings,
emptyContainers: await this.loadedEmptyContainers(scheduleId),
logoImageUrl: await this.logoSettings.getLogoImageUrl(),
consistChangesAtStop: this.consistChangesAt(schedule, logRows),
});
// Styled table-aware fallback (marshalling grid) — see importLoadListDocument.
const buffer = await this.pdfDocuments.renderTabularDocument(html, `Marshalling ${stopIndex}`);
const reference = schedule.trainNumber ?? schedule.id;
return {
filename: `marshalling-${stopIndex}-${this.safeDocumentName(reference)}.pdf`,
buffer,
};
}
/**
* Back-compat alias: the single "current" intercity doc (Marshalling 2) the
* old one-document-per-schedule UI calls. Resolves to the LATEST stop with
* a logged consist change; falls back to the current-position doc with no
* changes table when nothing has coupled/uncoupled yet (e.g. right after
* dispatch, before any mid-corridor stop).
*/
async intercityMarshallingDocument(scheduleId: string): Promise<{ filename: string; buffer: Buffer }> {
const stops = await this.marshallingStops(scheduleId);
const latest = stops[stops.length - 1];
if (latest) {
return this.marshallingDocumentAt(scheduleId, latest.stopIndex);
}
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule) {
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
}
if (schedule.status !== 'DISPATCHED' && schedule.status !== 'ARRIVED') {
throw new BadRequestException(
'Intercity marshalling document applies only to dispatched or arrived trains',
);
}
const checkpoints = await this.trainCheckpointEventsRepository.findBySchedule(scheduleId);
const last = checkpoints[checkpoints.length - 1];
const positionLabel = last
? `After ${last.yard?.label ?? last.yard?.code ?? 'checkpoint'}`
: `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,
@@ -3615,7 +3776,6 @@ 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');
@@ -3685,10 +3845,14 @@ export class TrainSchedulingService {
// 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[];
// Numbered marshalling docs only (see marshallingDocumentAt /
// consistChangesAt) — couples/uncouples/switches logged at THIS stop.
// Origin import/export docs never pass this, so they render no such box.
consistChangesAtStop?: Array<{
wagonNumber: string;
event: 'Coupled' | 'Uncoupled' | 'Switched';
containerNumbers: string;
}>;
},
): string {
const esc = (value: unknown) =>
@@ -3853,6 +4017,7 @@ export class TrainSchedulingService {
.tile span { display: block; color: #64748b; font-size: 9px; text-transform: uppercase; letter-spacing: .05em; margin-bottom: 4px; }
.tile strong { font-size: 11px; }
${logoImageCss()}
h2 { margin: 16px 0 6px; font-size: 12px; color: #0f766e; text-transform: uppercase; letter-spacing: .05em; }
table { width: 100%; border-collapse: collapse; }
th { background: #f8fafc; color: #475569; text-align: left; }
th, td { border: 1px solid #cbd5e1; padding: 5px 6px; font-size: 9.5px; vertical-align: top; }
@@ -3901,19 +4066,27 @@ export class TrainSchedulingService {
${
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>`
? `<h2>Consist Changed At This Stop</h2>
<table>
<thead>
<tr>
<th>Wagon No</th>
<th>Event</th>
<th>Container No</th>
</tr>
</thead>
<tbody>
${opts.consistChangesAtStop
.map(
(row) => `<tr>
<td>${esc(row.wagonNumber)}</td>
<td>${esc(row.event)}</td>
<td>${esc(row.containerNumbers)}</td>
</tr>`,
)
.join('')}
</tbody>
</table>`
: ''
}
@@ -4744,6 +4917,12 @@ export class TrainSchedulingService {
: TrainCheckpointKind.Passed);
const occurredAt = dto.occurredAt ? new Date(dto.occurredAt) : new Date();
await this.assertCheckpointTime(schedule, stations, dto.sequenceNo, occurredAt);
// Per-wagon loading, mid-corridor: recording THIS station means the train
// left the previous one, so every booking that boarded back there must be
// fully loaded or its remainder cancelled. The origin is covered by
// dispatch; here we answer for the stops between it and this one, so a
// skipped checkpoint log cannot smuggle an unresolved yard past the gate.
await this.assertPassedYardsFullyLoaded(schedule, stations, dto.sequenceNo);
// Upsert by (scheduleId, sequenceNo) so re-logging a station updates rather than duplicates.
const [existing] = await this.trainCheckpointEventsRepository.findAll({
@@ -4964,6 +5143,76 @@ export class TrainSchedulingService {
await manager.getRepository(ScheduleWagonAdjustmentLog).save(coupleLogRows);
}
}
// Leg slots (booking legs boarding/alighting mid-corridor — see
// stampSlotLegs) reaching their board/alight yard here: logged same as
// planned couples/cuts above, so the marshalling document can show
// what coupled ALREADY LOADED / uncoupled WITH cargo at this stop.
// Purely observational — their physical wagon was already pinned to
// the slot at schedule-build time (assignPhysicalWagonsToSlots), so
// nothing here changes wagon state, only the log. Dedupe against
// existing rows (not a wagon-state flag, unlike the couple/cut blocks
// above) since passedYardIds re-includes earlier stops on every call.
const legSlotsHere = (schedule.trainSet?.wagons ?? []).filter(
(slot) =>
slot.physicalWagonId &&
((slot.boardYardId && passedYardIds.includes(slot.boardYardId)) ||
(slot.alightYardId && passedYardIds.includes(slot.alightYardId))),
);
if (legSlotsHere.length && builtTrainId) {
const legWagonIds = [
...new Set(legSlotsHere.map((slot) => slot.physicalWagonId!)),
];
const legWagonById = new Map(
(
await manager.getRepository(Wagon).find({ where: { id: In(legWagonIds) } })
).map((w) => [w.id, w]),
);
const alreadyLogged = new Set(
(
await manager.getRepository(ScheduleWagonAdjustmentLog).find({
where: {
trainScheduleId: scheduleId,
wagonId: In(legWagonIds),
action: In(['ADD', 'REMOVE']),
},
})
).map((row) => `${row.wagonId}:${row.action}:${row.yardId}`),
);
const legLogRows: ScheduleWagonAdjustmentLog[] = [];
for (const slot of legSlotsHere) {
const wagon = legWagonById.get(slot.physicalWagonId!);
if (!wagon) continue;
const events: Array<{ action: 'ADD' | 'REMOVE'; yardId: string }> = [];
if (slot.boardYardId && passedYardIds.includes(slot.boardYardId)) {
events.push({ action: 'ADD', yardId: slot.boardYardId });
}
if (slot.alightYardId && passedYardIds.includes(slot.alightYardId)) {
events.push({ action: 'REMOVE', yardId: slot.alightYardId });
}
for (const { action, yardId } of events) {
const key = `${wagon.id}:${action}:${yardId}`;
if (alreadyLogged.has(key)) continue;
alreadyLogged.add(key);
legLogRows.push(
manager.getRepository(ScheduleWagonAdjustmentLog).create({
trainScheduleId: scheduleId,
trainId: builtTrainId,
action,
wagonId: wagon.id,
wagonNumber: wagon.wagonNumber,
adjustedByUserId: null,
yardId,
occurredAt,
}),
);
}
}
if (legLogRows.length) {
await manager.getRepository(ScheduleWagonAdjustmentLog).save(legLogRows);
}
}
await manager
.getRepository(Wagon)
.createQueryBuilder()
@@ -5496,7 +5745,11 @@ export class TrainSchedulingService {
return this.getTrainScheduleById(id);
}
async cancelTrainSchedule(id: string) {
async cancelTrainSchedule(
id: string,
dto?: { reason?: string },
userId?: string,
) {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(id);
if (!schedule) {
throw new NotFoundException(`Train schedule ${id} not found`);
@@ -5527,6 +5780,11 @@ export class TrainSchedulingService {
TrainScheduleStatusEnum.Cancelled,
now,
),
// Why the train died — read back by every view of the cancelled
// schedule, and by the staff who have to re-place its bookings.
cancellationReason: dto?.reason?.trim() || null,
cancelledAt: now,
cancelledByUserId: userId ?? null,
},
manager,
);
@@ -8020,6 +8278,8 @@ export class TrainSchedulingService {
freightType: this.resolveScheduleFreightType(schedule),
status: schedule.status,
bookingWindowStatus: schedule.bookingWindowStatus ?? 'OPEN',
cancellationReason: schedule.cancellationReason ?? null,
cancelledAt: schedule.cancelledAt ?? null,
maxWagons: schedule.maxWagons ?? 0,
remainingWagons: Math.max(
0,
@@ -9978,6 +10238,8 @@ export class TrainSchedulingService {
id: schedule.id,
reference: schedule.reference ?? null,
status: schedule.status,
cancellationReason: schedule.cancellationReason ?? null,
cancelledAt: schedule.cancelledAt ?? null,
freightType: this.resolveScheduleFreightType(schedule),
trainNumber: schedule.trainNumber ?? null,
voyageNumber: schedule.voyageNumber ?? null,

View File

@@ -1,25 +1,34 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsBoolean, IsDateString, IsOptional, IsString, MaxLength } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { Transform } from "class-transformer";
import {
IsBoolean,
IsDateString,
IsEmail,
IsOptional,
IsString,
MaxLength,
} from "class-validator";
import { IsValidPhone } from "../../../common/validators/is-phone-number.validator";
const toBoolean = ({ value }: { value: unknown }) => {
if (typeof value === 'boolean') return value;
if (value === 'true') return true;
if (value === 'false') return false;
if (typeof value === "boolean") return value;
if (value === "true") return true;
if (value === "false") return false;
return value;
};
export class CreateTransitAgentDto {
@ApiProperty({ maxLength: 150, example: 'Ahmed Bourhan' })
@ApiProperty({ maxLength: 150, example: "Ahmed Bourhan" })
@IsString()
@MaxLength(150)
name!: string;
@ApiProperty({ example: '2026-01-01' })
@ApiProperty({ example: "2026-01-01" })
@IsDateString()
validFrom!: string;
@ApiProperty({ example: '2026-12-31' })
@ApiProperty({ example: "2026-12-31" })
@IsDateString()
validTo!: string;
@@ -28,4 +37,33 @@ export class CreateTransitAgentDto {
@Transform(toBoolean)
@IsBoolean()
isActive?: boolean;
/**
* Becomes the IAM account's email and is where the activation link is sent.
* Optional: an agent may be created as a GL-assignable roster entry only, and
* invited later. Supplying it creates the portal account right away.
*/
@ApiPropertyOptional({ example: "a.bourhan@transit.dj" })
@IsOptional()
@IsEmail()
@MaxLength(150)
email?: string;
@ApiPropertyOptional({
example: "+25377834567",
description:
"E.164. Djiboutian (+253 77…) and Ethiopian (+251 9…) mobiles also receive the activation link by SMS.",
})
@IsOptional()
@IsString()
@MaxLength(30)
@IsValidPhone()
phoneNumber?: string;
/** Login name. Defaults to the email, which is what the agent tries first. */
@ApiPropertyOptional({ example: "a-bourhan" })
@IsOptional()
@IsString()
@MaxLength(100)
username?: string;
}

View File

@@ -0,0 +1,36 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { IsEmail, IsOptional, IsString, MaxLength } from "class-validator";
import { IsValidPhone } from "../../../common/validators/is-phone-number.validator";
/**
* Give an EXISTING roster-only transit agent a portal login.
*
* Email is required here even though it is optional on the agent itself: this
* endpoint's whole job is to send the activation link, and email is the only
* channel guaranteed to reach a Djibouti-registered officer. Omitting a field
* keeps whatever the agent already has.
*/
export class InviteTransitAgentDto {
@ApiProperty({ example: "a.bourhan@transit.dj" })
@IsEmail()
@MaxLength(150)
email!: string;
@ApiPropertyOptional({
example: "+25377834567",
description:
"E.164. Djiboutian (+253 77…) and Ethiopian (+251 9…) mobiles also receive the activation link by SMS.",
})
@IsOptional()
@IsString()
@MaxLength(30)
@IsValidPhone()
phoneNumber?: string;
@ApiPropertyOptional({ example: "a-bourhan" })
@IsOptional()
@IsString()
@MaxLength(100)
username?: string;
}

View File

@@ -1,5 +1,5 @@
import { PartialType } from '@nestjs/mapped-types';
import { PartialType } from "@nestjs/mapped-types";
import { CreateTransitAgentDto } from './create-transit-agent.dto';
import { CreateTransitAgentDto } from "./create-transit-agent.dto";
export class UpdateTransitAgentDto extends PartialType(CreateTransitAgentDto) {}

View File

@@ -1,5 +1,5 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index } from 'typeorm';
import { BaseEntity } from "@edr/api-common";
import { Column, Entity, Index } from "typeorm";
/**
* Djibouti transit officer GL Djibouti may assign against a shipment's
@@ -7,18 +7,43 @@ import { Column, Entity, Index } from 'typeorm';
* validity window arrive without a code change; `isActive` is the manual
* suspend/reactivate switch, independent of the validity window.
*/
@Entity({ schema: 'freight', name: 'transit_agents' })
@Index(['isActive'])
@Entity({ schema: "freight", name: "transit_agents" })
@Index(["isActive"])
export class TransitAgent extends BaseEntity {
@Column({ name: 'name', type: 'varchar', length: 150 })
@Column({ name: "name", type: "varchar", length: 150 })
name!: string;
@Column({ name: 'valid_from', type: 'date' })
@Column({ name: "valid_from", type: "date" })
validFrom!: string;
@Column({ name: 'valid_to', type: 'date' })
@Column({ name: "valid_to", type: "date" })
validTo!: string;
@Column({ name: 'is_active', type: 'boolean', default: true })
@Column({ name: "is_active", type: "boolean", default: true })
isActive!: boolean;
/**
* The IAM account (`iam.users`, userType `individual`) that signs in to the
* portal as this agent. No FK: `iam` is a separate schema owned by the IAM
* service, and the rest of the codebase reaches it by query rather than by
* relation.
*
* NULL for every agent that exists only as a GL-assignable roster entry —
* which is all of them before this feature, and stays legal afterwards. An
* agent gains an account when staff invite it, so `userId !== null` IS the
* "has a portal login" predicate; nothing else needs to track it.
*/
@Column({ name: "user_id", type: "uuid", nullable: true })
userId?: string | null;
/**
* Mirrors the IAM account's email; the activation link is sent here. Nullable
* because a roster-only agent has never needed one — but an invite cannot be
* sent without it, so {@link TransitAgentsService.invite} requires it.
*/
@Column({ name: "email", type: "varchar", length: 150, nullable: true })
email?: string | null;
@Column({ name: "phone_number", type: "varchar", length: 30, nullable: true })
phoneNumber?: string | null;
}

View File

@@ -10,36 +10,38 @@ import {
Patch,
Post,
Query,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
} from "@nestjs/common";
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
import {
RuleEngineCreate,
RuleEngineDelete,
RuleEngineUpdate,
RuleEngineView,
} from '../../common/rule-engine-guards';
} from "../../common/rule-engine-guards";
import { CreateTransitAgentDto } from './dto/create-transit-agent.dto';
import { UpdateTransitAgentDto } from './dto/update-transit-agent.dto';
import { TransitAgentsService } from './transit-agents.service';
import { BackofficeResetPasswordDto } from "../auth/dto/forgot-password.dto";
import { CreateTransitAgentDto } from "./dto/create-transit-agent.dto";
import { InviteTransitAgentDto } from "./dto/invite-transit-agent.dto";
import { UpdateTransitAgentDto } from "./dto/update-transit-agent.dto";
import { TransitAgentsService } from "./transit-agents.service";
@ApiTags('transit-agents')
@Controller('transit-agents')
@ApiTags("transit-agents")
@Controller("transit-agents")
@ApiBearerAuth()
export class TransitAgentsController {
constructor(private readonly transitAgentsService: TransitAgentsService) {}
@Get()
@RuleEngineView('transit-agents')
@ApiOperation({ summary: 'List transit agents' })
@RuleEngineView("transit-agents")
@ApiOperation({ summary: "List transit agents" })
findAll(@Query() query: Record<string, string | undefined>) {
return this.transitAgentsService.findAll({
isActive:
query.isActive === 'all'
query.isActive === "all"
? undefined
: query.isActive !== undefined
? query.isActive === 'true'
? query.isActive === "true"
: undefined,
page: query.page ? parseInt(query.page, 10) : undefined,
pageSize: query.pageSize ? parseInt(query.pageSize, 10) : undefined,
@@ -49,39 +51,77 @@ export class TransitAgentsController {
}
/** Active + currently valid officers — the transit-assignee assignment dropdown. */
@Get('assignable')
@RuleEngineView('transit-agents')
@ApiOperation({ summary: 'List transit agents assignable right now (active and in-window)' })
@Get("assignable")
@RuleEngineView("transit-agents")
@ApiOperation({
summary: "List transit agents assignable right now (active and in-window)",
})
findAssignable() {
return this.transitAgentsService.findAssignable();
}
@Get(':id')
@RuleEngineView('transit-agents')
@ApiOperation({ summary: 'Get a transit agent by ID' })
findOne(@Param('id', ParseUUIDPipe) id: string) {
@Get(":id")
@RuleEngineView("transit-agents")
@ApiOperation({ summary: "Get a transit agent by ID" })
findOne(@Param("id", ParseUUIDPipe) id: string) {
return this.transitAgentsService.findById(id);
}
@Post()
@RuleEngineCreate('transit-agents')
@ApiOperation({ summary: 'Create a transit agent' })
@RuleEngineCreate("transit-agents")
@ApiOperation({
summary:
"Create a transit agent; with an email, also creates its portal account and sends the activation link",
})
create(@Body() dto: CreateTransitAgentDto) {
return this.transitAgentsService.create(dto);
return this.transitAgentsService.createWithInvite(dto);
}
@Patch(':id')
@RuleEngineUpdate('transit-agents')
@ApiOperation({ summary: 'Update a transit agent' })
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateTransitAgentDto) {
/**
* The path for the roster entries already in production: they were created
* before transit agents had logins, so they get their account here rather
* than at create time.
*/
@Post(":id/invite")
@RuleEngineUpdate("transit-agents")
@ApiOperation({
summary:
"Create a portal account for an existing transit agent and send the activation link",
})
invite(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: InviteTransitAgentDto,
) {
return this.transitAgentsService.invite(id, dto);
}
@Post(":id/resend-activation")
@RuleEngineUpdate("transit-agents")
@ApiOperation({
summary: "Resend a transit agent's activation / password-reset link",
})
resendActivation(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: BackofficeResetPasswordDto,
) {
return this.transitAgentsService.resendActivation(id, dto.channel);
}
@Patch(":id")
@RuleEngineUpdate("transit-agents")
@ApiOperation({ summary: "Update a transit agent" })
update(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: UpdateTransitAgentDto,
) {
return this.transitAgentsService.update(id, dto);
}
@Delete(':id')
@RuleEngineDelete('transit-agents')
@Delete(":id")
@RuleEngineDelete("transit-agents")
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Soft-delete a transit agent' })
remove(@Param('id', ParseUUIDPipe) id: string) {
@ApiOperation({ summary: "Soft-delete a transit agent" })
remove(@Param("id", ParseUUIDPipe) id: string) {
return this.transitAgentsService.remove(id);
}
}

View File

@@ -1,13 +1,24 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import { TransitAgent } from './entities/transit-agent.entity';
import { TransitAgentsController } from './transit-agents.controller';
import { TransitAgentsRepository } from './transit-agents.repository';
import { TransitAgentsService } from './transit-agents.service';
import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity";
import { FreightAuthModule } from "../auth/freight-auth.module";
import { OtpModule } from "../otp/otp.module";
import { TransitAgent } from "./entities/transit-agent.entity";
import { TransitAgentsController } from "./transit-agents.controller";
import { TransitAgentsRepository } from "./transit-agents.repository";
import { TransitAgentsService } from "./transit-agents.service";
@Module({
imports: [TypeOrmModule.forFeature([TransitAgent])],
imports: [
// `User` is registered here so this module can create the IAM account that
// backs an invited transit agent, in the same transaction as the agent row.
TypeOrmModule.forFeature([TransitAgent, User]),
// CustomerResetService — activation links reuse the staff-triggered reset path.
FreightAuthModule,
OtpModule,
],
controllers: [TransitAgentsController],
providers: [TransitAgentsRepository, TransitAgentsService],
exports: [TransitAgentsRepository, TransitAgentsService],

View File

@@ -1,9 +1,14 @@
import { BaseRepository } from '@edr/api-common';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { LessThanOrEqual, MoreThanOrEqual, Repository } from 'typeorm';
import { BaseRepository } from "@edr/api-common";
import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import {
EntityManager,
LessThanOrEqual,
MoreThanOrEqual,
Repository,
} from "typeorm";
import { TransitAgent } from './entities/transit-agent.entity';
import { TransitAgent } from "./entities/transit-agent.entity";
@Injectable()
export class TransitAgentsRepository extends BaseRepository<TransitAgent> {
@@ -22,7 +27,48 @@ export class TransitAgentsRepository extends BaseRepository<TransitAgent> {
validFrom: LessThanOrEqual(today),
validTo: MoreThanOrEqual(today),
},
order: { name: 'ASC' },
order: { name: "ASC" },
});
}
/** The transit agent signed in as `userId`, or null for any other account. */
findByUserId(userId: string): Promise<TransitAgent | null> {
return this.repository.findOne({ where: { userId } });
}
/**
* Case-insensitive, matching the `lower(email)` unique index. `exceptId` lets
* an update re-save its own address without colliding with itself.
*/
async existsByEmail(email: string, exceptId?: string): Promise<boolean> {
const qb = this.repository
.createQueryBuilder("ta")
.where("lower(ta.email) = lower(:email)", { email });
if (exceptId) qb.andWhere("ta.id != :exceptId", { exceptId });
return (await qb.getCount()) > 0;
}
/**
* Insert inside a caller-supplied transaction, so the agent row and the IAM
* user it points at commit together — a row referencing a user that was
* rolled back (or vice versa) is an account nobody can sign in to.
*/
createInTransaction(
manager: EntityManager,
data: Partial<TransitAgent>,
): Promise<TransitAgent> {
const repo = manager.getRepository(TransitAgent);
return repo.save(repo.create(data));
}
/** Attach an IAM account to an existing agent, inside the caller's transaction. */
async linkAccountInTransaction(
manager: EntityManager,
id: string,
data: Pick<TransitAgent, "userId" | "email" | "phoneNumber">,
): Promise<TransitAgent> {
const repo = manager.getRepository(TransitAgent);
await repo.update(id, data);
return repo.findOneOrFail({ where: { id } });
}
}

View File

@@ -0,0 +1,346 @@
import { BadRequestException, ConflictException } from "@nestjs/common";
import {
EUserStatus,
EUserType,
} from "@tria-plc/api-common/utils/enums/user.enum";
import { ResetChannel } from "../auth/dto/forgot-password.dto";
import { TransitAgentsService } from "./transit-agents.service";
/**
* The account half of a transit agent. The roster half (validity window,
* assignability) predates this and is untouched — what these lock is that
* adding a login did not make an account MANDATORY, since production is full of
* roster-only agents that must keep working.
*/
describe("TransitAgentsService accounts", () => {
const savedUser = { id: "user-1" };
let repo: {
existsByEmail: jest.Mock;
createInTransaction: jest.Mock;
linkAccountInTransaction: jest.Mock;
findById: jest.Mock;
findByUserId: jest.Mock;
create: jest.Mock;
update: jest.Mock;
};
let userRepository: { findOne: jest.Mock; update: jest.Mock };
let customerResetService: {
sendResetLinkToUser: jest.Mock;
sendResetLinkToUserOnChannels: jest.Mock;
};
let dataSource: { transaction: jest.Mock };
let userRepoInTx: { create: jest.Mock; save: jest.Mock };
let service: TransitAgentsService;
const base = {
name: "Ahmed Bourhan",
validFrom: "2026-01-01",
validTo: "2026-12-31",
};
beforeEach(() => {
userRepoInTx = {
create: jest.fn((v) => v),
save: jest.fn().mockResolvedValue(savedUser),
};
repo = {
existsByEmail: jest.fn().mockResolvedValue(false),
createInTransaction: jest.fn(async (_m, data) => ({
id: "ta-1",
...data,
})),
linkAccountInTransaction: jest.fn(async (_m, id, data) => ({
id,
...base,
isActive: true,
...data,
})),
findById: jest.fn(),
findByUserId: jest.fn(),
create: jest.fn(async (data) => ({ id: "ta-1", ...data })),
// `BaseRepository.update` re-reads the row via `findById`, so the result
// carries columns the caller never passed — `userId` above all, which is
// what decides whether IAM gets synced.
update: jest.fn(async (id, data) => ({
...(await repo.findById(id)),
id,
...data,
})),
};
userRepository = {
findOne: jest.fn().mockResolvedValue(null),
update: jest.fn(),
};
customerResetService = {
sendResetLinkToUser: jest
.fn()
.mockResolvedValue({
maskedTarget: "a**@transit.dj",
channel: ResetChannel.Email,
}),
sendResetLinkToUserOnChannels: jest
.fn()
.mockResolvedValue([
{ maskedTarget: "a**@transit.dj", channel: ResetChannel.Email },
]),
};
dataSource = {
transaction: jest.fn(async (cb) =>
cb({ getRepository: () => userRepoInTx } as never),
),
};
service = new TransitAgentsService(
repo as never,
userRepository as never,
customerResetService as never,
dataSource as never,
);
});
describe("create", () => {
it("creates a roster-only agent with no account when no email is given", async () => {
const { agent, activationSentTo } = await service.createWithInvite(base);
expect(dataSource.transaction).not.toHaveBeenCalled();
expect(
customerResetService.sendResetLinkToUserOnChannels,
).not.toHaveBeenCalled();
expect(agent.hasAccount).toBe(false);
expect(activationSentTo).toBeNull();
});
it("creates the IAM account with no password set when an email is given", async () => {
await service.createWithInvite({
...base,
email: "A.Bourhan@Transit.DJ",
});
expect(userRepoInTx.save).toHaveBeenCalledWith(
expect.objectContaining({
email: "a.bourhan@transit.dj",
username: "a.bourhan@transit.dj",
userType: EUserType.INDIVIDUAL,
hasSetPassword: false,
status: EUserStatus.ACCEPTED,
}),
);
});
it("sends the activation link only after the transaction commits", async () => {
const order: string[] = [];
dataSource.transaction.mockImplementation(
async (cb: (m: unknown) => unknown) => {
const result = await cb({ getRepository: () => userRepoInTx });
order.push("commit");
return result;
},
);
customerResetService.sendResetLinkToUserOnChannels.mockImplementation(
async () => {
order.push("send");
return [
{ maskedTarget: "a**@transit.dj", channel: ResetChannel.Email },
];
},
);
await service.createWithInvite({ ...base, email: "a@transit.dj" });
expect(order).toEqual(["commit", "send"]);
});
});
describe("invite", () => {
it("attaches an account to an existing roster-only agent and sends the link", async () => {
repo.findById.mockResolvedValue({
id: "ta-1",
...base,
isActive: true,
userId: null,
});
const { agent, activationSentTo } = await service.invite("ta-1", {
email: "a@transit.dj",
});
expect(repo.linkAccountInTransaction).toHaveBeenCalledWith(
expect.anything(),
"ta-1",
expect.objectContaining({ userId: "user-1", email: "a@transit.dj" }),
);
expect(agent.hasAccount).toBe(true);
expect(activationSentTo).toBe("a**@transit.dj");
});
it("refuses to mint a second account for an agent that already has one", async () => {
repo.findById.mockResolvedValue({
id: "ta-1",
...base,
isActive: true,
userId: "user-9",
});
await expect(
service.invite("ta-1", { email: "a@transit.dj" }),
).rejects.toThrow(ConflictException);
expect(dataSource.transaction).not.toHaveBeenCalled();
});
it("refuses credentials that already belong to another account", async () => {
repo.findById.mockResolvedValue({
id: "ta-1",
...base,
isActive: true,
userId: null,
});
userRepository.findOne.mockResolvedValue({ id: "someone-else" });
await expect(
service.invite("ta-1", { email: "a@transit.dj" }),
).rejects.toThrow(ConflictException);
});
it("texts the link as well when the number is domestic", async () => {
repo.findById.mockResolvedValue({
id: "ta-1",
...base,
isActive: true,
userId: null,
});
await service.invite("ta-1", {
email: "a@transit.dj",
phoneNumber: "+251911223344",
});
expect(
customerResetService.sendResetLinkToUserOnChannels,
).toHaveBeenCalledWith(
"user-1",
[ResetChannel.Email, ResetChannel.Phone],
expect.objectContaining({ allowWithoutCredential: true }),
);
});
it("emails only when the number is foreign — the SMS gateway is domestic-only", async () => {
repo.findById.mockResolvedValue({
id: "ta-1",
...base,
isActive: true,
userId: null,
});
await service.invite("ta-1", {
email: "a@transit.dj",
phoneNumber: "+33612345678",
});
expect(
customerResetService.sendResetLinkToUserOnChannels,
).toHaveBeenCalledWith("user-1", [ResetChannel.Email], expect.anything());
});
});
describe("update", () => {
it("mirrors an edited email onto the linked IAM account", async () => {
repo.findById.mockResolvedValue({
id: "ta-1",
...base,
isActive: true,
userId: "user-1",
});
await service.update("ta-1", { email: "New@Transit.DJ" });
expect(repo.update).toHaveBeenCalledWith(
"ta-1",
expect.objectContaining({ email: "new@transit.dj" }),
);
expect(userRepository.update).toHaveBeenCalledWith(
"user-1",
expect.objectContaining({ email: "new@transit.dj" }),
);
});
it("never writes username — it names an IAM account, not a column on this table", async () => {
repo.findById.mockResolvedValue({
id: "ta-1",
...base,
isActive: true,
userId: null,
});
await service.update("ta-1", { username: "nope" } as never);
expect(repo.update).toHaveBeenCalledWith(
"ta-1",
expect.not.objectContaining({ username: expect.anything() }),
);
});
it("leaves IAM alone for a roster-only agent", async () => {
repo.findById.mockResolvedValue({
id: "ta-1",
...base,
isActive: true,
userId: null,
});
await service.update("ta-1", { email: "a@transit.dj" });
expect(userRepository.update).not.toHaveBeenCalled();
});
});
describe("resendActivation", () => {
it("refuses for an agent that has no account yet", async () => {
repo.findById.mockResolvedValue({
id: "ta-1",
...base,
isActive: true,
userId: null,
});
await expect(
service.resendActivation("ta-1", ResetChannel.Email),
).rejects.toThrow(BadRequestException);
});
it("refuses an SMS resend to a foreign number", async () => {
repo.findById.mockResolvedValue({
id: "ta-1",
...base,
isActive: true,
userId: "user-1",
phoneNumber: "+33612345678",
});
await expect(
service.resendActivation("ta-1", ResetChannel.Phone),
).rejects.toThrow(BadRequestException);
});
it("reuses the existing account rather than minting a new one", async () => {
repo.findById.mockResolvedValue({
id: "ta-1",
...base,
isActive: true,
userId: "user-1",
email: "a@transit.dj",
});
await service.resendActivation("ta-1", ResetChannel.Email);
expect(customerResetService.sendResetLinkToUser).toHaveBeenCalledWith(
"user-1",
ResetChannel.Email,
expect.objectContaining({ allowWithoutCredential: true }),
);
expect(dataSource.transaction).not.toHaveBeenCalled();
});
});
});

View File

@@ -1,17 +1,49 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { FindOptionsOrder } from 'typeorm';
import {
BadRequestException,
ConflictException,
Injectable,
Logger,
NotFoundException,
} from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import {
EUserStatus,
EUserType,
} from "@tria-plc/api-common/utils/enums/user.enum";
// Subpath import (not the package root) so ts-jest can resolve it when this
// file lands in a spec's compile graph — same reason as backoffice.service.ts.
import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity";
import {
DataSource,
EntityManager,
FindOptionsOrder,
Repository,
} from "typeorm";
import { CreateTransitAgentDto } from './dto/create-transit-agent.dto';
import { UpdateTransitAgentDto } from './dto/update-transit-agent.dto';
import { TransitAgent } from './entities/transit-agent.entity';
import { TransitAgentsRepository } from './transit-agents.repository';
import { CustomerResetService } from "../auth/customer-reset.service";
import { ResetChannel } from "../auth/dto/forgot-password.dto";
import { isDomesticPhone } from "../otp/otp.service";
import { CreateTransitAgentDto } from "./dto/create-transit-agent.dto";
import { InviteTransitAgentDto } from "./dto/invite-transit-agent.dto";
import { UpdateTransitAgentDto } from "./dto/update-transit-agent.dto";
import { TransitAgent } from "./entities/transit-agent.entity";
import { TransitAgentsRepository } from "./transit-agents.repository";
export type TransitAgentValidityStatus = 'VALID' | 'NOT_STARTED' | 'EXPIRED';
export type TransitAgentValidityStatus = "VALID" | "NOT_STARTED" | "EXPIRED";
export type TransitAgentView = TransitAgent & {
validityStatus: TransitAgentValidityStatus;
/** True once an IAM account backs this agent — i.e. it can sign in. */
hasAccount: boolean;
};
export interface InvitedTransitAgent {
agent: TransitAgentView;
/** Masked destination of the activation link, or null if none was sent. */
activationSentTo: string | null;
activationChannel: ResetChannel | null;
}
type TransitAgentListFilter = {
isActive?: boolean;
page?: number;
@@ -25,20 +57,34 @@ function todayISODate(): string {
return new Date().toISOString().slice(0, 10);
}
function validityStatus(agent: Pick<TransitAgent, 'validFrom' | 'validTo'>): TransitAgentValidityStatus {
function validityStatus(
agent: Pick<TransitAgent, "validFrom" | "validTo">,
): TransitAgentValidityStatus {
const today = todayISODate();
if (today < agent.validFrom) return 'NOT_STARTED';
if (today > agent.validTo) return 'EXPIRED';
return 'VALID';
if (today < agent.validFrom) return "NOT_STARTED";
if (today > agent.validTo) return "EXPIRED";
return "VALID";
}
function withValidityStatus(agent: TransitAgent): TransitAgentView {
return { ...agent, validityStatus: validityStatus(agent) };
return {
...agent,
validityStatus: validityStatus(agent),
hasAccount: Boolean(agent.userId),
};
}
@Injectable()
export class TransitAgentsService {
constructor(private readonly transitAgentsRepository: TransitAgentsRepository) {}
private readonly logger = new Logger(TransitAgentsService.name);
constructor(
private readonly transitAgentsRepository: TransitAgentsRepository,
@InjectRepository(User)
private readonly userRepository: Repository<User>,
private readonly customerResetService: CustomerResetService,
private readonly dataSource: DataSource,
) {}
async findAll(filter: TransitAgentListFilter = {}): Promise<{
data: TransitAgentView[];
@@ -46,10 +92,13 @@ export class TransitAgentsService {
}> {
const page = filter.page ?? 1;
const pageSize = filter.pageSize ?? 500;
const sortBy = ['name', 'validFrom', 'validTo', 'isActive'].includes(filter.sortBy ?? '')
const sortBy = ["name", "validFrom", "validTo", "isActive"].includes(
filter.sortBy ?? "",
)
? (filter.sortBy as keyof TransitAgent)
: 'name';
const sortOrder = filter.sortOrder?.toUpperCase() === 'DESC' ? 'DESC' : 'ASC';
: "name";
const sortOrder =
filter.sortOrder?.toUpperCase() === "DESC" ? "DESC" : "ASC";
const [data, total] = await this.transitAgentsRepository.findAndCount({
where: filter.isActive === undefined ? {} : { isActive: filter.isActive },
@@ -86,12 +135,14 @@ export class TransitAgentsService {
async getAssignable(id: string): Promise<TransitAgent> {
const agent = await this.transitAgentsRepository.findById(id);
if (!agent) {
throw new BadRequestException('Selected transit officer was not found.');
throw new BadRequestException("Selected transit officer was not found.");
}
if (!agent.isActive) {
throw new BadRequestException(`${agent.name} is suspended — pick another transit officer.`);
throw new BadRequestException(
`${agent.name} is suspended — pick another transit officer.`,
);
}
if (validityStatus(agent) !== 'VALID') {
if (validityStatus(agent) !== "VALID") {
throw new BadRequestException(
`${agent.name}'s validity window has expired — pick another transit officer or extend their dates.`,
);
@@ -99,38 +150,364 @@ export class TransitAgentsService {
return agent;
}
async create(dto: CreateTransitAgentDto): Promise<TransitAgentView> {
if (dto.validTo < dto.validFrom) {
throw new BadRequestException('Valid-to date must be on or after valid-from date.');
/**
* Create an IAM account for a transit agent, inside the caller's transaction.
*
* Follows `ShippingLineCompaniesService.register` — same entities, same shape
* — including its one deliberate difference from employee creation: no
* `UserCredential` row is written and `hasSetPassword` stays false, so the
* agent must come through the activation link. Staff never handle a password.
*/
private async createIamAccount(
manager: EntityManager,
args: {
name: string;
email: string;
username: string;
phoneNumber?: string;
},
): Promise<string> {
const userRepo = manager.getRepository(User);
const user = await userRepo.save(
userRepo.create({
email: args.email,
username: args.username,
phoneNumber: args.phoneNumber,
name: { en: args.name },
userType: EUserType.INDIVIDUAL,
isActive: true,
// No credential row: the account has no password until the activation
// link is used. `hasSetPassword` must stay false or the portal treats
// the account as ready to sign in with a password that does not exist.
hasSetPassword: false,
status: EUserStatus.ACCEPTED,
}),
);
return user.id as string;
}
/**
* Normalize and validate the account fields shared by create and invite, and
* refuse credentials that already belong to somebody.
*/
private async prepareAccountFields(
dto: { email: string; phoneNumber?: string; username?: string },
exceptAgentId?: string,
) {
const email = dto.email.trim().toLowerCase();
const username = (dto.username?.trim() || email).toLowerCase();
const phoneNumber = dto.phoneNumber?.trim() || undefined;
if (
await this.transitAgentsRepository.existsByEmail(email, exceptAgentId)
) {
throw new ConflictException(
`A transit agent with email ${email} already exists`,
);
}
const agent = await this.transitAgentsRepository.create({
// An existing IAM account means these credentials already belong to a
// customer, a shipping line or an employee. Reusing it would let one login
// resolve to two different account kinds, so this is refused rather than
// merged.
const existingUser = await this.userRepository.findOne({
where: [{ email }, { username }],
select: { id: true },
});
if (existingUser) {
throw new ConflictException("email_or_username_already_in_use");
}
return { email, username, phoneNumber };
}
/**
* Create a transit agent.
*
* With no `email` this is the pre-existing behaviour: a GL-assignable roster
* entry with no login, which is what production is full of. With an `email`
* the IAM account and the agent row are created in one transaction and the
* activation link goes out.
*/
async create(dto: CreateTransitAgentDto): Promise<TransitAgentView> {
return (await this.createWithInvite(dto)).agent;
}
/** {@link create}, also reporting where the activation link went. */
async createWithInvite(
dto: CreateTransitAgentDto,
): Promise<InvitedTransitAgent> {
if (dto.validTo < dto.validFrom) {
throw new BadRequestException(
"Valid-to date must be on or after valid-from date.",
);
}
const base = {
name: dto.name.trim(),
validFrom: dto.validFrom,
validTo: dto.validTo,
isActive: dto.isActive ?? true,
};
if (!dto.email) {
// Roster-only agent — no account, nothing to send.
const agent = await this.transitAgentsRepository.create(base);
return {
agent: withValidityStatus(agent),
activationSentTo: null,
activationChannel: null,
};
}
const { email, username, phoneNumber } = await this.prepareAccountFields({
email: dto.email,
phoneNumber: dto.phoneNumber,
username: dto.username,
});
return withValidityStatus(agent);
const agent = await this.dataSource.transaction(async (manager) => {
const userId = await this.createIamAccount(manager, {
name: base.name,
email,
username,
phoneNumber,
});
return this.transitAgentsRepository.createInTransaction(manager, {
...base,
userId,
email,
phoneNumber: phoneNumber ?? null,
});
});
// Outside the transaction on purpose: a delivery failure must not roll back
// a registered agent. The link is resendable, and the account is already
// valid without it.
const activation = await this.sendActivationLink(agent);
return {
agent: withValidityStatus(agent),
activationSentTo: activation?.maskedTarget ?? null,
activationChannel: activation?.channel ?? null,
};
}
async update(id: string, dto: UpdateTransitAgentDto): Promise<TransitAgentView> {
/**
* Give an EXISTING agent a portal login — the path for the roster entries
* already in production. Creates the IAM account, attaches it, and sends the
* activation link.
*/
async invite(
id: string,
dto: InviteTransitAgentDto,
): Promise<InvitedTransitAgent> {
const current = await this.transitAgentsRepository.findById(id);
if (!current) {
throw new NotFoundException(`Transit agent ${id} not found`);
}
if (current.userId) {
// Already has an account — resending is `resendActivation`, which reuses
// the existing user instead of minting a second one for the same person.
throw new ConflictException(
"This transit agent already has a portal account — resend the activation link instead.",
);
}
const { email, username, phoneNumber } = await this.prepareAccountFields(
dto,
id,
);
const agent = await this.dataSource.transaction(async (manager) => {
const userId = await this.createIamAccount(manager, {
name: current.name,
email,
username,
phoneNumber,
});
return this.transitAgentsRepository.linkAccountInTransaction(
manager,
id,
{
userId,
email,
phoneNumber: phoneNumber ?? null,
},
);
});
const activation = await this.sendActivationLink(agent);
return {
agent: withValidityStatus(agent),
activationSentTo: activation?.maskedTarget ?? null,
activationChannel: activation?.channel ?? null,
};
}
/**
* Send the activation link.
*
* Email always goes out — it is the only channel guaranteed to reach a
* foreign-registered officer. SMS is sent in addition when the number is
* domestic, since the gateway silently drops anything else. Both carry the
* SAME single-use ticket: minting retires earlier tickets, so two mints would
* kill the email link the moment the SMS went out.
*
* Reports the email send, as that is the one that is always attempted.
*/
async sendActivationLink(agent: TransitAgent) {
if (!agent.userId) return null;
const scope = `transit agent ${agent.id}`;
const channels = [ResetChannel.Email];
if (agent.phoneNumber && isDomesticPhone(agent.phoneNumber)) {
channels.push(ResetChannel.Phone);
}
const sent = await this.customerResetService.sendResetLinkToUserOnChannels(
agent.userId,
channels,
{ scope, allowWithoutCredential: true },
);
const emailed = sent.find((s) => s.channel === ResetChannel.Email) ?? null;
if (!emailed) {
this.logger.error(
`Activation email not sent for transit agent ${agent.id} — no reachable address`,
);
}
if (
channels.includes(ResetChannel.Phone) &&
!sent.some((s) => s.channel === ResetChannel.Phone)
) {
this.logger.warn(`Activation SMS not sent for transit agent ${agent.id}`);
}
return emailed;
}
async resendActivation(id: string, channel: ResetChannel) {
const agent = await this.transitAgentsRepository.findById(id);
if (!agent) {
throw new NotFoundException("Transit agent not found");
}
if (!agent.userId) {
throw new BadRequestException(
"This transit agent has no portal account yet — invite them first.",
);
}
if (
channel === ResetChannel.Phone &&
(!agent.phoneNumber || !isDomesticPhone(agent.phoneNumber))
) {
throw new BadRequestException(
"This transit agent has no domestic phone number — the SMS gateway cannot reach it",
);
}
const sent = await this.customerResetService.sendResetLinkToUser(
agent.userId,
channel,
{
scope: `transit agent ${agent.id}`,
allowWithoutCredential: true,
},
);
if (!sent) {
throw new NotFoundException(
`No active account with ${
channel === ResetChannel.Email ? "an email address" : "a phone number"
} for this transit agent`,
);
}
return sent;
}
/** The transit agent signed in as `userId`, or null for any other account. */
findByUserId(userId: string): Promise<TransitAgent | null> {
return this.transitAgentsRepository.findByUserId(userId);
}
async update(
id: string,
dto: UpdateTransitAgentDto,
): Promise<TransitAgentView> {
const current = await this.findById(id);
const nextValidFrom = dto.validFrom ?? current.validFrom;
const nextValidTo = dto.validTo ?? current.validTo;
if (nextValidTo < nextValidFrom) {
throw new BadRequestException('Valid-to date must be on or after valid-from date.');
throw new BadRequestException(
"Valid-to date must be on or after valid-from date.",
);
}
// `username` only ever names an IAM account, and it is chosen once at
// account creation. Accepting it here (PartialType inherits it from the
// create DTO) would write a column that does not exist on this table.
const { username: _ignoredUsername, email, phoneNumber, ...rest } = dto;
const contact: Partial<TransitAgent> = {};
if (email !== undefined) {
const normalized = email.trim().toLowerCase();
if (await this.transitAgentsRepository.existsByEmail(normalized, id)) {
throw new ConflictException(
`A transit agent with email ${normalized} already exists`,
);
}
contact.email = normalized;
}
if (phoneNumber !== undefined) {
contact.phoneNumber = phoneNumber.trim() || null;
}
const updated = await this.transitAgentsRepository.update(id, {
...dto,
...rest,
...contact,
...(dto.name ? { name: dto.name.trim() } : {}),
});
if (!updated) {
throw new NotFoundException(`Transit agent ${id} not found`);
}
// Keep the IAM account in step. Without this, an agent whose address was
// corrected here would still receive its activation link at the old one —
// the reset service reads the address off `iam.users`, not off this row.
if (
updated.userId &&
(contact.email !== undefined || contact.phoneNumber !== undefined)
) {
await this.syncIamContact(updated);
}
return withValidityStatus(updated);
}
/**
* Mirror an edited email/phone onto the linked IAM account.
*
* Best-effort: a failure here must not fail the agent edit that already
* committed, but it does mean the two are out of step, so it is logged loudly
* rather than swallowed. Re-running the edit retries it.
*/
private async syncIamContact(agent: TransitAgent): Promise<void> {
if (!agent.userId) return;
try {
await this.userRepository.update(agent.userId, {
...(agent.email ? { email: agent.email } : {}),
phoneNumber: agent.phoneNumber ?? undefined,
});
} catch (error) {
this.logger.error(
`Transit agent ${agent.id} contact updated but IAM user ${agent.userId} was not — ` +
`activation links will still go to the old address: ${String(error)}`,
);
}
}
async remove(id: string): Promise<void> {
await this.findById(id);
await this.transitAgentsRepository.softDelete(id);

View File

@@ -0,0 +1,36 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import {
IsEnum,
IsOptional,
IsString,
IsUUID,
MaxLength,
} from "class-validator";
import { TransitAssignmentStatus } from "../entities/transit-assignment.entity";
export class CreateTransitAssignmentDto {
@ApiProperty({ format: "uuid" })
@IsUUID()
bookingId!: string;
@ApiProperty({ format: "uuid" })
@IsUUID()
transitAgentId!: string;
@ApiPropertyOptional({
enum: TransitAssignmentStatus,
default: TransitAssignmentStatus.NotStarted,
description:
"Assignments normally start NOT_STARTED; pass one only to record work already under way.",
})
@IsOptional()
@IsEnum(TransitAssignmentStatus)
status?: TransitAssignmentStatus;
@ApiPropertyOptional({ maxLength: 2000 })
@IsOptional()
@IsString()
@MaxLength(2000)
note?: string;
}

View File

@@ -0,0 +1,43 @@
import { ApiPropertyOptional } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { IsEnum, IsInt, IsOptional, IsString, Max, Min } from "class-validator";
import { TransitAssignmentStatus } from "../entities/transit-assignment.entity";
/** Filters for the transit agent's own booking list. */
export class MyAssignmentsQueryDto {
/** Free text over the booking reference and the customer's company name. */
@ApiPropertyOptional()
@IsOptional()
@IsString()
search?: string;
@ApiPropertyOptional({ enum: TransitAssignmentStatus })
@IsOptional()
@IsEnum(TransitAssignmentStatus)
status?: TransitAssignmentStatus;
@ApiPropertyOptional({
example: "DISPATCHED",
description: "The booking's scheduling state.",
})
@IsOptional()
@IsString()
schedulingStatus?: string;
@ApiPropertyOptional({ default: 1 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number;
@ApiPropertyOptional({ default: 20 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
// Bounded so a hand-edited query string cannot ask for the whole table.
@Max(100)
pageSize?: number;
}

View File

@@ -0,0 +1,23 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { Transform } from "class-transformer";
import { IsBoolean, IsOptional, IsString, MaxLength } from "class-validator";
/** The portal's Save / Finish action on the agent's own assignment. */
export class SubmitTransitAssignmentDto {
@ApiProperty({
description:
"true finishes the assignment, which also locks its documents. false saves progress and leaves it open.",
})
// Arrives as a string when posted as multipart alongside files.
@Transform(({ value }) =>
value === "true" ? true : value === "false" ? false : value,
)
@IsBoolean()
finish!: boolean;
@ApiPropertyOptional({ maxLength: 2000 })
@IsOptional()
@IsString()
@MaxLength(2000)
note?: string;
}

View File

@@ -0,0 +1,36 @@
import { ApiPropertyOptional } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { IsEnum, IsInt, IsOptional, IsUUID, Min } from "class-validator";
import { TransitAssignmentStatus } from "../entities/transit-assignment.entity";
export class TransitAssignmentQueryDto {
@ApiPropertyOptional({ format: "uuid" })
@IsOptional()
@IsUUID()
bookingId?: string;
@ApiPropertyOptional({ format: "uuid" })
@IsOptional()
@IsUUID()
transitAgentId?: string;
@ApiPropertyOptional({ enum: TransitAssignmentStatus })
@IsOptional()
@IsEnum(TransitAssignmentStatus)
status?: TransitAssignmentStatus;
@ApiPropertyOptional({ default: 1 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number;
@ApiPropertyOptional({ default: 20 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
pageSize?: number;
}

View File

@@ -0,0 +1,22 @@
import { ApiPropertyOptional } from "@nestjs/swagger";
import { IsEnum, IsOptional, IsString, MaxLength } from "class-validator";
import { TransitAssignmentStatus } from "../entities/transit-assignment.entity";
/**
* `bookingId` and `transitAgentId` are absent on purpose: repointing an
* assignment at a different booking or agent would silently reattribute the
* work and the documents already filed under it. Delete and re-create instead.
*/
export class UpdateTransitAssignmentDto {
@ApiPropertyOptional({ enum: TransitAssignmentStatus })
@IsOptional()
@IsEnum(TransitAssignmentStatus)
status?: TransitAssignmentStatus;
@ApiPropertyOptional({ maxLength: 2000 })
@IsOptional()
@IsString()
@MaxLength(2000)
note?: string;
}

View File

@@ -0,0 +1,79 @@
import { BaseEntity } from "@edr/api-common";
import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
import { Booking } from "../../bookings/entities/booking.entity";
import { TransitAgent } from "../../transit-agents/entities/transit-agent.entity";
/** Where the agent's work on this booking currently stands. */
export enum TransitAssignmentStatus {
NotStarted = "NOT_STARTED",
InProgress = "IN_PROGRESS",
Finished = "FINISHED",
}
/**
* One transit agent's work on one booking. An agent handles many bookings, so
* this is the join between the two, carrying the work's own state: when it
* started, when it finished, and the documents produced along the way.
*
* Deliberately separate from the transit-assignee handshake on the booking
* (`/bookings/:id/clearance/transit-assignee/...`), which is a pre-declaration
* agreement between GL Ethiopia and GL Djibouti about WHO will handle customs.
* Nothing here reads or writes that flow.
*
* There is no stored duration. "Time after the train arrives" is
* `finishedAt booking.arrivedAt`; both halves already exist, and storing the
* difference would be a third source of truth that goes stale the moment either
* timestamp is corrected. It is computed on read — see
* `TransitAssignmentsService.toView`.
*
* Documents live in `freight.files` under
* {@link TRANSIT_ASSIGNMENT_FILE_RESOURCE}, which already carries the MinIO
* object, the upload time, the uploader and the supersede history.
*/
@Entity({ schema: "freight", name: "transit_assignments" })
@Index(["bookingId"])
@Index(["transitAgentId", "status"])
export class TransitAssignment extends BaseEntity {
@Column({ name: "booking_id", type: "uuid" })
bookingId!: string;
@ManyToOne(() => Booking)
@JoinColumn({ name: "booking_id" })
booking?: Booking;
@Column({ name: "transit_agent_id", type: "uuid" })
transitAgentId!: string;
@ManyToOne(() => TransitAgent)
@JoinColumn({ name: "transit_agent_id" })
transitAgent?: TransitAgent;
@Column({
name: "status",
type: "varchar",
length: 32,
default: TransitAssignmentStatus.NotStarted,
})
status!: TransitAssignmentStatus;
/** Stamped on the first move to IN_PROGRESS; never overwritten afterwards. */
@Column({ name: "started_at", type: "timestamptz", nullable: true })
startedAt?: Date | null;
/** Stamped on the move to FINISHED. Cleared if the work is reopened. */
@Column({ name: "finished_at", type: "timestamptz", nullable: true })
finishedAt?: Date | null;
@Column({ name: "assigned_by_user_id", type: "uuid", nullable: true })
assignedByUserId?: string | null;
@Column({ name: "assigned_at", type: "timestamptz", default: () => "now()" })
assignedAt!: Date;
@Column({ name: "note", type: "text", nullable: true })
note?: string | null;
}
/** `files.resource` value for documents attached to a transit assignment. */
export const TRANSIT_ASSIGNMENT_FILE_RESOURCE = "transit_assignments";

View File

@@ -0,0 +1,255 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
ParseUUIDPipe,
Patch,
Post,
Query,
UploadedFiles,
UseInterceptors,
} from "@nestjs/common";
import { AnyFilesInterceptor } from "@nestjs/platform-express";
import {
ApiBearerAuth,
ApiConsumes,
ApiOperation,
ApiTags,
} from "@nestjs/swagger";
import { CurrentUser } from "@edr/api-common";
import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type";
import { BookingStaff, PortalCustomer } from "../../common/booking-guards";
import { documentUploadMulterOptions } from "../../common/document-upload.options";
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
import { CreateTransitAssignmentDto } from "./dto/create-transit-assignment.dto";
import { MyAssignmentsQueryDto } from "./dto/my-assignments-query.dto";
import { SubmitTransitAssignmentDto } from "./dto/submit-transit-assignment.dto";
import { TransitAssignmentQueryDto } from "./dto/transit-assignment-query.dto";
import { UpdateTransitAssignmentDto } from "./dto/update-transit-assignment.dto";
import { TransitAssignmentsService } from "./transit-assignments.service";
/**
* Transit assignments — one transit agent's work on one booking.
*
* Distinct from the transit-assignee handshake under
* `/bookings/:id/clearance/transit-assignee/...`, which decides WHO will handle
* a shipment's customs. This is the work record that follows: status, timings
* and documents.
*/
@ApiTags("transit-assignments")
@Controller("transit-assignments")
@ApiBearerAuth()
export class TransitAssignmentsController {
constructor(
private readonly transitAssignmentsService: TransitAssignmentsService,
) {}
// ── Portal — the signed-in transit agent's own work ───────────────────────
// Declared first so the literal `my` segment is matched before `:id`.
// Every route resolves the agent from the session; none accepts an agent id.
@Get("my/stats")
@PortalCustomer()
@ApiOperation({
summary: "Dashboard figures for the signed-in transit agent's own work",
})
myStats(@CurrentUser() user: TCurrentUser) {
return this.transitAssignmentsService.myStats(user.id);
}
@Get("my")
@PortalCustomer()
@ApiOperation({
summary:
"The signed-in transit agent's assigned bookings (paginated, filterable)",
})
findMine(
@CurrentUser() user: TCurrentUser,
@Query() query: MyAssignmentsQueryDto,
) {
return this.transitAssignmentsService.findMine(user.id, query);
}
@Get("my/:id")
@PortalCustomer()
@ApiOperation({ summary: "One of my assignments, with its documents" })
findMineById(
@CurrentUser() user: TCurrentUser,
@Param("id", ParseUUIDPipe) id: string,
) {
return this.transitAssignmentsService.findMineById(user.id, id);
}
@Post("my/:id/files")
@PortalCustomer()
@ApiConsumes("multipart/form-data")
@UseInterceptors(AnyFilesInterceptor(documentUploadMulterOptions))
@ApiOperation({
summary:
"Upload documents to my assignment. Allowed only while the booking is DISPATCHED and the assignment is not finished.",
})
uploadMyFiles(
@CurrentUser() user: TCurrentUser,
@Param("id", ParseUUIDPipe) id: string,
@UploadedFiles() files: Express.Multer.File[],
// One `titles` part per file, in the same order. A single-file upload posts
// one part, which multipart parsing hands back as a bare string rather than
// an array — normalised here so the service always sees a positional list.
@Body("titles") titles?: string | string[],
) {
return this.transitAssignmentsService.uploadMyFiles(
user.id,
id,
files,
{ userId: user.id, name: user.name?.en ?? undefined },
titles === undefined ? undefined : ([] as string[]).concat(titles),
);
}
@Delete("my/:id/files/:fileId")
@PortalCustomer()
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: "Remove a document from my assignment" })
removeMyFile(
@CurrentUser() user: TCurrentUser,
@Param("id", ParseUUIDPipe) id: string,
@Param("fileId", ParseUUIDPipe) fileId: string,
) {
return this.transitAssignmentsService.removeMyFile(user.id, id, fileId);
}
@Post("my/:id/submit")
@PortalCustomer()
@ApiOperation({
summary:
"Save progress, or finish the assignment (which locks its documents)",
})
submitMine(
@CurrentUser() user: TCurrentUser,
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: SubmitTransitAssignmentDto,
) {
return this.transitAssignmentsService.submitMine(user.id, id, dto);
}
// ── Backoffice ────────────────────────────────────────────────────────────
@Get()
@BookingStaff(FREIGHT_PERMS.transitAssignments.view)
@ApiOperation({
summary:
"List transit assignments (paginated, filterable by booking / agent / status)",
})
findAll(@Query() query: TransitAssignmentQueryDto) {
return this.transitAssignmentsService.findAll(query);
}
/**
* Declared before `:id` — Nest matches routes in order, so a literal segment
* registered after a parameter would be swallowed by it.
*/
@Get("by-agent/:transitAgentId")
@BookingStaff(FREIGHT_PERMS.transitAssignments.view)
@ApiOperation({ summary: "Every assignment handed to one transit agent" })
findByTransitAgent(
@Param("transitAgentId", ParseUUIDPipe) transitAgentId: string,
) {
return this.transitAssignmentsService.findByTransitAgent(transitAgentId);
}
@Get("by-booking/:bookingId")
@BookingStaff(FREIGHT_PERMS.transitAssignments.view)
@ApiOperation({ summary: "Every transit agent assigned to one booking" })
findByBooking(@Param("bookingId", ParseUUIDPipe) bookingId: string) {
return this.transitAssignmentsService.findByBooking(bookingId);
}
@Get(":id")
@BookingStaff(FREIGHT_PERMS.transitAssignments.view)
@ApiOperation({
summary:
"One assignment, with its attached documents and computed duration",
})
findOne(@Param("id", ParseUUIDPipe) id: string) {
return this.transitAssignmentsService.findById(id);
}
@Post()
@BookingStaff(FREIGHT_PERMS.transitAssignments.create)
@ApiOperation({ summary: "Assign a transit agent to a booking" })
create(
@Body() dto: CreateTransitAssignmentDto,
@CurrentUser() user: TCurrentUser,
) {
return this.transitAssignmentsService.create(dto, user?.id);
}
@Patch(":id")
@BookingStaff(FREIGHT_PERMS.transitAssignments.update)
@ApiOperation({
summary:
"Update status or note — status changes stamp the start/finish clocks",
})
update(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: UpdateTransitAssignmentDto,
) {
return this.transitAssignmentsService.update(id, dto);
}
@Delete(":id")
@BookingStaff(FREIGHT_PERMS.transitAssignments.delete)
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: "Soft-delete an assignment" })
remove(@Param("id", ParseUUIDPipe) id: string) {
return this.transitAssignmentsService.remove(id);
}
// ── Documents ─────────────────────────────────────────────────────────────
@Get(":id/files")
@BookingStaff(FREIGHT_PERMS.transitAssignments.view)
@ApiOperation({ summary: "An assignment's uploaded documents" })
listFiles(@Param("id", ParseUUIDPipe) id: string) {
return this.transitAssignmentsService.listFiles(id);
}
@Post(":id/files")
@BookingStaff(FREIGHT_PERMS.transitAssignments.update)
@ApiConsumes("multipart/form-data")
@UseInterceptors(AnyFilesInterceptor(documentUploadMulterOptions))
@ApiOperation({
summary:
"Upload one or more documents; re-uploading adds a version, it does not overwrite",
})
uploadFiles(
@Param("id", ParseUUIDPipe) id: string,
@UploadedFiles() files: Express.Multer.File[],
@CurrentUser() user: TCurrentUser,
@Body("titles") titles?: string | string[],
) {
return this.transitAssignmentsService.uploadFiles(
id,
files,
{ userId: user?.id, name: user?.name?.en ?? undefined },
titles === undefined ? undefined : ([] as string[]).concat(titles),
);
}
@Delete(":id/files/:fileId")
@BookingStaff(FREIGHT_PERMS.transitAssignments.update)
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: "Remove one document from an assignment" })
removeFile(
@Param("id", ParseUUIDPipe) id: string,
@Param("fileId", ParseUUIDPipe) fileId: string,
) {
return this.transitAssignmentsService.removeFile(id, fileId);
}
}

View File

@@ -0,0 +1,25 @@
import { Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import { Booking } from "../bookings/entities/booking.entity";
import { FilesModule } from "../files/files.module";
import { TransitAgentsModule } from "../transit-agents/transit-agents.module";
import { TransitAssignment } from "./entities/transit-assignment.entity";
import { TransitAssignmentsController } from "./transit-assignments.controller";
import { TransitAssignmentsRepository } from "./transit-assignments.repository";
import { TransitAssignmentsService } from "./transit-assignments.service";
@Module({
imports: [
// `Booking` is registered as an ENTITY rather than importing BookingsModule:
// this module only confirms a booking id exists, and that module would drag
// its whole graph (billing, contracts, scheduling, first/last mile) along.
TypeOrmModule.forFeature([TransitAssignment, Booking]),
FilesModule,
TransitAgentsModule,
],
controllers: [TransitAssignmentsController],
providers: [TransitAssignmentsService, TransitAssignmentsRepository],
exports: [TransitAssignmentsService, TransitAssignmentsRepository],
})
export class TransitAssignmentsModule {}

View File

@@ -0,0 +1,139 @@
import { BaseRepository } from "@edr/api-common";
import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm";
import {
TransitAssignment,
TransitAssignmentStatus,
} from "./entities/transit-assignment.entity";
export interface TransitAssignmentFilter {
bookingId?: string;
transitAgentId?: string;
status?: TransitAssignmentStatus;
/** The booking's scheduling state (DISPATCHED / SCHEDULED / …). */
schedulingStatus?: string;
/** Free text over the booking reference and the customer's company name. */
search?: string;
}
@Injectable()
export class TransitAssignmentsRepository extends BaseRepository<TransitAssignment> {
constructor(
@InjectRepository(TransitAssignment)
private readonly assignmentsRepo: Repository<TransitAssignment>,
) {
super(assignmentsRepo);
}
/**
* The booking is joined rather than lazily loaded because every read needs
* its `arrivedAt` — that is the other half of the computed
* "time after the train arrives", so a list without it would be N+1 queries
* or a column of nulls. The customer's company rides along for the same
* reason: the agent's list is read by reference AND by whose cargo it is.
*/
private baseQuery() {
return this.assignmentsRepo
.createQueryBuilder("ta")
.leftJoinAndSelect("ta.booking", "booking")
.leftJoinAndSelect("booking.company", "company")
.leftJoinAndSelect("ta.transitAgent", "agent")
.where("ta.deletedAt IS NULL");
}
/** Shared filter application, so a list and its count can never diverge. */
private applyFilters(
qb: ReturnType<TransitAssignmentsRepository["baseQuery"]>,
filter: TransitAssignmentFilter,
) {
if (filter.bookingId) {
qb.andWhere("ta.bookingId = :bookingId", { bookingId: filter.bookingId });
}
if (filter.transitAgentId) {
qb.andWhere("ta.transitAgentId = :transitAgentId", {
transitAgentId: filter.transitAgentId,
});
}
if (filter.status) {
qb.andWhere("ta.status = :status", { status: filter.status });
}
if (filter.schedulingStatus) {
qb.andWhere("booking.schedulingStatus = :schedulingStatus", {
schedulingStatus: filter.schedulingStatus,
});
}
if (filter.search?.trim()) {
qb.andWhere(
"(booking.reference ILIKE :search OR company.name ILIKE :search)",
{ search: `%${filter.search.trim()}%` },
);
}
return qb;
}
async findPaginated(
filter: TransitAssignmentFilter,
skip: number,
take: number,
): Promise<[TransitAssignment[], number]> {
return this.applyFilters(this.baseQuery(), filter)
.orderBy("ta.assignedAt", "DESC")
.skip(skip)
.take(take)
.getManyAndCount();
}
/**
* One agent's own list, filtered and paginated. Differs from
* {@link findPaginated} only in that the agent is pinned by the caller from
* the session, so it can never be widened by a query parameter.
*/
async findByTransitAgentPaginated(
transitAgentId: string,
filter: Omit<TransitAssignmentFilter, "transitAgentId">,
skip: number,
take: number,
): Promise<[TransitAssignment[], number]> {
return this.applyFilters(this.baseQuery(), { ...filter, transitAgentId })
.orderBy("ta.assignedAt", "DESC")
.skip(skip)
.take(take)
.getManyAndCount();
}
findOneWithRelations(id: string): Promise<TransitAssignment | null> {
return this.baseQuery().andWhere("ta.id = :id", { id }).getOne();
}
/** Every live assignment for one agent — the agent's own workload list. */
findByTransitAgent(transitAgentId: string): Promise<TransitAssignment[]> {
return this.baseQuery()
.andWhere("ta.transitAgentId = :transitAgentId", { transitAgentId })
.orderBy("ta.assignedAt", "DESC")
.getMany();
}
/** Every live assignment on one booking. */
findByBooking(bookingId: string): Promise<TransitAssignment[]> {
return this.baseQuery()
.andWhere("ta.bookingId = :bookingId", { bookingId })
.orderBy("ta.assignedAt", "DESC")
.getMany();
}
/** Guards the unique (booking, agent) pair before an insert 23505s. */
async existsForPair(
bookingId: string,
transitAgentId: string,
): Promise<boolean> {
const count = await this.assignmentsRepo
.createQueryBuilder("ta")
.where("ta.bookingId = :bookingId", { bookingId })
.andWhere("ta.transitAgentId = :transitAgentId", { transitAgentId })
.andWhere("ta.deletedAt IS NULL")
.getCount();
return count > 0;
}
}

View File

@@ -0,0 +1,515 @@
import {
ConflictException,
ForbiddenException,
NotFoundException,
} from "@nestjs/common";
import {
TransitAssignment,
TransitAssignmentStatus,
} from "./entities/transit-assignment.entity";
import { TransitAssignmentsService } from "./transit-assignments.service";
/**
* The two things this module gets wrong quietly: the status transitions that
* stamp the clocks, and the duration computed from them. Both are invisible
* until a report reads a null or a negative number months later.
*/
describe("TransitAssignmentsService", () => {
const ARRIVED = new Date("2026-08-28T09:00:00Z");
let assignments: {
findPaginated: jest.Mock;
findOneWithRelations: jest.Mock;
findByTransitAgent: jest.Mock;
findByTransitAgentPaginated: jest.Mock;
findByBooking: jest.Mock;
existsForPair: jest.Mock;
create: jest.Mock;
update: jest.Mock;
softDelete: jest.Mock;
};
let agents: { findById: jest.Mock; findByUserId: jest.Mock };
let bookings: { findOne: jest.Mock };
let files: {
findByResource: jest.Mock;
findByResourceIdsGrouped: jest.Mock;
upload: jest.Mock;
remove: jest.Mock;
};
let service: TransitAssignmentsService;
const row = (over: Partial<TransitAssignment> = {}) =>
({
id: "ta-1",
bookingId: "bk-1",
transitAgentId: "ag-1",
status: TransitAssignmentStatus.NotStarted,
startedAt: null,
finishedAt: null,
// DISPATCHED by default: uploads are gated on it, so a fixture without it
// would fail every document test for the wrong reason.
booking: {
id: "bk-1",
arrivedAt: ARRIVED,
schedulingStatus: "DISPATCHED",
},
...over,
}) as TransitAssignment;
beforeEach(() => {
assignments = {
findPaginated: jest.fn(),
findOneWithRelations: jest.fn().mockResolvedValue(row()),
findByTransitAgent: jest.fn().mockResolvedValue([]),
findByTransitAgentPaginated: jest.fn().mockResolvedValue([[], 0]),
findByBooking: jest.fn().mockResolvedValue([]),
existsForPair: jest.fn().mockResolvedValue(false),
create: jest.fn(async (data) => ({ id: "ta-1", ...data })),
update: jest.fn(async (id, data) => ({ id, ...data })),
softDelete: jest.fn(),
};
agents = {
findById: jest.fn().mockResolvedValue({ id: "ag-1", name: "Ahmed" }),
findByUserId: jest.fn().mockResolvedValue({ id: "ag-1", name: "Ahmed" }),
};
bookings = { findOne: jest.fn().mockResolvedValue({ id: "bk-1" }) };
files = {
findByResource: jest.fn().mockResolvedValue([]),
findByResourceIdsGrouped: jest.fn().mockResolvedValue(new Map()),
upload: jest.fn(),
remove: jest.fn(),
};
service = new TransitAssignmentsService(
assignments as never,
agents as never,
bookings as never,
files as never,
);
});
describe("timeAfterTrainArrives", () => {
it("reports whole minutes between arrival and finish", async () => {
assignments.findOneWithRelations.mockResolvedValue(
row({
status: TransitAssignmentStatus.Finished,
finishedAt: new Date("2026-08-28T14:30:00Z"),
}),
);
const view = await service.findById("ta-1");
expect(view.timeAfterTrainArrives).toBe(330);
});
it("is null while the work is unfinished", async () => {
assignments.findOneWithRelations.mockResolvedValue(
row({ status: TransitAssignmentStatus.InProgress, startedAt: ARRIVED }),
);
expect((await service.findById("ta-1")).timeAfterTrainArrives).toBeNull();
});
it("is null when the booking never recorded an arrival", async () => {
assignments.findOneWithRelations.mockResolvedValue(
row({
status: TransitAssignmentStatus.Finished,
finishedAt: new Date("2026-08-28T14:30:00Z"),
booking: {
id: "bk-1",
arrivedAt: null,
schedulingStatus: "DISPATCHED",
} as never,
}),
);
expect((await service.findById("ta-1")).timeAfterTrainArrives).toBeNull();
});
});
describe("status transitions", () => {
it("stamps startedAt on the move to IN_PROGRESS", async () => {
await service.update("ta-1", {
status: TransitAssignmentStatus.InProgress,
});
const patch = assignments.update.mock.calls[0][1];
expect(patch.startedAt).toBeInstanceOf(Date);
expect(patch.finishedAt).toBeNull();
});
it("keeps the ORIGINAL startedAt when finished work is reopened", async () => {
const original = new Date("2026-08-28T10:00:00Z");
assignments.findOneWithRelations.mockResolvedValue(
row({
status: TransitAssignmentStatus.Finished,
startedAt: original,
finishedAt: new Date("2026-08-28T12:00:00Z"),
}),
);
await service.update("ta-1", {
status: TransitAssignmentStatus.InProgress,
});
const patch = assignments.update.mock.calls[0][1];
// Reopening must not restart the clock, or the elapsed time would only
// cover the second attempt rather than the whole job.
expect(patch.startedAt).toBe(original);
expect(patch.finishedAt).toBeNull();
});
it("stamps both clocks when finishing work that was never started", async () => {
await service.update("ta-1", {
status: TransitAssignmentStatus.Finished,
});
const patch = assignments.update.mock.calls[0][1];
expect(patch.startedAt).toBeInstanceOf(Date);
expect(patch.finishedAt).toBeInstanceOf(Date);
});
it("clears both clocks on a reset to NOT_STARTED", async () => {
assignments.findOneWithRelations.mockResolvedValue(
row({
status: TransitAssignmentStatus.Finished,
startedAt: ARRIVED,
finishedAt: new Date(),
}),
);
await service.update("ta-1", {
status: TransitAssignmentStatus.NotStarted,
});
const patch = assignments.update.mock.calls[0][1];
expect(patch.startedAt).toBeNull();
expect(patch.finishedAt).toBeNull();
});
});
describe("create", () => {
it("refuses to assign the same agent to one booking twice", async () => {
assignments.existsForPair.mockResolvedValue(true);
await expect(
service.create({ bookingId: "bk-1", transitAgentId: "ag-1" }),
).rejects.toThrow(ConflictException);
expect(assignments.create).not.toHaveBeenCalled();
});
it("rejects an unknown booking", async () => {
bookings.findOne.mockResolvedValue(null);
await expect(
service.create({ bookingId: "nope", transitAgentId: "ag-1" }),
).rejects.toThrow(NotFoundException);
});
});
describe("files", () => {
it("refuses to delete a file belonging to another assignment", async () => {
files.findByResource.mockResolvedValue([{ id: "file-1" }]);
await expect(service.removeFile("ta-1", "file-2")).rejects.toThrow(
NotFoundException,
);
expect(files.remove).not.toHaveBeenCalled();
});
it("names each uploaded file from its positional title", async () => {
await service.uploadFiles(
"ta-1",
[
{ originalname: "a.pdf" } as Express.Multer.File,
{ originalname: "b.pdf" } as Express.Multer.File,
{ originalname: "c.pdf" } as Express.Multer.File,
],
{},
["Bill of lading", " ", "Packing list"],
);
const titles = files.upload.mock.calls.map((call) => call[0].title);
// Index N names file N; a blank entry falls back to null so the record
// shows its original filename rather than an empty label.
expect(titles).toEqual(["Bill of lading", null, "Packing list"]);
});
it("stores no title when none were sent", async () => {
await service.uploadFiles(
"ta-1",
[{ originalname: "a.pdf" } as Express.Multer.File],
{},
);
expect(files.upload.mock.calls[0][0].title).toBeNull();
});
it("refuses an upload before the booking is dispatched", async () => {
assignments.findOneWithRelations.mockResolvedValue(
row({
booking: {
id: "bk-1",
arrivedAt: null,
schedulingStatus: "SCHEDULED",
} as never,
}),
);
await expect(
service.uploadFiles("ta-1", [{} as Express.Multer.File], {}),
).rejects.toThrow(ForbiddenException);
expect(files.upload).not.toHaveBeenCalled();
});
it("refuses an upload once the assignment is finished", async () => {
assignments.findOneWithRelations.mockResolvedValue(
row({
status: TransitAssignmentStatus.Finished,
finishedAt: new Date(),
}),
);
await expect(
service.uploadFiles("ta-1", [{} as Express.Multer.File], {}),
).rejects.toThrow(ForbiddenException);
});
it("refuses to remove a document once the assignment is finished", async () => {
assignments.findOneWithRelations.mockResolvedValue(
row({
status: TransitAssignmentStatus.Finished,
finishedAt: new Date(),
}),
);
files.findByResource.mockResolvedValue([{ id: "file-1" }]);
await expect(service.removeFile("ta-1", "file-1")).rejects.toThrow(
ForbiddenException,
);
expect(files.remove).not.toHaveBeenCalled();
});
});
describe("myStats", () => {
const at = (iso: string) => new Date(iso);
const withRows = (rows: Record<string, unknown>[]) => {
assignments.findByTransitAgent.mockResolvedValue(
rows.map((r, i) => row({ id: `ta-${i}`, ...r } as never)),
);
files.findByResourceIdsGrouped.mockResolvedValue(new Map());
};
it("uses the median, so one reopened assignment cannot skew the headline", async () => {
withRows([
{
status: TransitAssignmentStatus.Finished,
finishedAt: at("2026-08-28T10:35:00Z"),
},
{
status: TransitAssignmentStatus.Finished,
finishedAt: at("2026-08-28T12:10:00Z"),
},
{
status: TransitAssignmentStatus.Finished,
finishedAt: at("2026-08-28T13:45:00Z"),
},
// 47h outlier: a mean would report ~12h, which describes nobody.
{
status: TransitAssignmentStatus.Finished,
finishedAt: at("2026-08-30T08:00:00Z"),
},
]);
const stats = await service.myStats("user-1");
// 95/190/285/2820 -> even count, so the median averages the middle two.
// A mean would be 848 minutes, describing none of the four.
expect(stats.performance.medianClearanceMinutes).toBe(238);
expect(stats.performance.slowestClearanceMinutes).toBe(2820);
});
it("bands clearance times into the SLA buckets", async () => {
withRows([
{
status: TransitAssignmentStatus.Finished,
finishedAt: at("2026-08-28T10:30:00Z"),
},
{
status: TransitAssignmentStatus.Finished,
finishedAt: at("2026-08-28T13:00:00Z"),
},
{
status: TransitAssignmentStatus.Finished,
finishedAt: at("2026-08-29T09:00:00Z"),
},
]);
const stats = await service.myStats("user-1");
expect(stats.sla).toEqual({ under2h: 1, under6h: 1, over6h: 1 });
expect(stats.performance.onTimeRate).toBe(67);
});
it("counts coverage only over dispatched bookings", async () => {
withRows([
{ booking: { arrivedAt: null, schedulingStatus: "DISPATCHED" } },
{ booking: { arrivedAt: null, schedulingStatus: "DISPATCHED" } },
// Scheduled bookings cannot receive documents yet, so counting them
// would report a failure the agent could not have avoided.
{ booking: { arrivedAt: null, schedulingStatus: "SCHEDULED" } },
]);
const stats = await service.myStats("user-1");
expect(stats.coverage.dispatched).toBe(2);
expect(stats.coverage.withDocuments).toBe(0);
});
it("reports nulls rather than zero when nothing has been measured", async () => {
withRows([{ status: TransitAssignmentStatus.NotStarted }]);
const stats = await service.myStats("user-1");
expect(stats.performance.medianClearanceMinutes).toBeNull();
expect(stats.performance.onTimeRate).toBeNull();
expect(stats.totals.open).toBe(1);
});
});
describe("customerName", () => {
it("flattens the booking's company name", async () => {
assignments.findOneWithRelations.mockResolvedValue(
row({
booking: {
id: "bk-1",
arrivedAt: ARRIVED,
schedulingStatus: "DISPATCHED",
company: { name: "SHAFICI PHARMACEUTICAL" },
} as never,
}),
);
expect((await service.findById("ta-1")).customerName).toBe(
"SHAFICI PHARMACEUTICAL",
);
});
it("is null when the booking has no company", async () => {
expect((await service.findById("ta-1")).customerName).toBeNull();
});
});
describe("canUploadDocuments", () => {
it("is true for an open assignment on a dispatched booking", async () => {
expect((await service.findById("ta-1")).canUploadDocuments).toBe(true);
});
it("is false before dispatch", async () => {
assignments.findOneWithRelations.mockResolvedValue(
row({
booking: {
id: "bk-1",
arrivedAt: null,
schedulingStatus: "SCHEDULED",
} as never,
}),
);
expect((await service.findById("ta-1")).canUploadDocuments).toBe(false);
});
it("is false once finished", async () => {
assignments.findOneWithRelations.mockResolvedValue(
row({
status: TransitAssignmentStatus.Finished,
finishedAt: new Date(),
}),
);
expect((await service.findById("ta-1")).canUploadDocuments).toBe(false);
});
});
describe("portal scoping", () => {
it("hides another agent's assignment behind a NotFound", async () => {
assignments.findOneWithRelations.mockResolvedValue(
row({ transitAgentId: "someone-else" }),
);
await expect(service.findMineById("user-1", "ta-1")).rejects.toThrow(
NotFoundException,
);
});
it("rejects an account that is not a transit agent", async () => {
agents.findByUserId.mockResolvedValue(null);
await expect(service.findMine("user-1")).rejects.toThrow(
ForbiddenException,
);
});
it("pins the query to the session's agent and passes the filters through", async () => {
await service.findMine("user-1", {
search: "BK-2026",
status: TransitAssignmentStatus.InProgress,
schedulingStatus: "DISPATCHED",
page: 2,
pageSize: 10,
});
const [agentId, filter, skip, take] =
assignments.findByTransitAgentPaginated.mock.calls[0];
// The agent id comes from the session, never from the query — otherwise
// one agent could page through another agent's work.
expect(agentId).toBe("ag-1");
expect(filter).toMatchObject({
search: "BK-2026",
status: TransitAssignmentStatus.InProgress,
schedulingStatus: "DISPATCHED",
});
expect(skip).toBe(10);
expect(take).toBe(10);
});
it("reports pagination meta", async () => {
assignments.findByTransitAgentPaginated.mockResolvedValue([[], 45]);
const result = await service.findMine("user-1", { pageSize: 20 });
expect(result.meta).toEqual({
total: 45,
page: 1,
pageSize: 20,
totalPages: 3,
});
});
it("save moves the assignment to IN_PROGRESS, finish closes it", async () => {
await service.submitMine("user-1", "ta-1", { finish: false });
expect(assignments.update.mock.calls[0][1].status).toBe(
TransitAssignmentStatus.InProgress,
);
assignments.update.mockClear();
await service.submitMine("user-1", "ta-1", { finish: true });
expect(assignments.update.mock.calls[0][1].status).toBe(
TransitAssignmentStatus.Finished,
);
});
it("refuses to re-submit an already finished assignment", async () => {
assignments.findOneWithRelations.mockResolvedValue(
row({
status: TransitAssignmentStatus.Finished,
finishedAt: new Date(),
}),
);
await expect(
service.submitMine("user-1", "ta-1", { finish: true }),
).rejects.toThrow(ForbiddenException);
});
});
});

View File

@@ -0,0 +1,596 @@
import {
BadRequestException,
ConflictException,
ForbiddenException,
Injectable,
NotFoundException,
} from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm";
import { Booking } from "../bookings/entities/booking.entity";
import { FilesService } from "../files/files.service";
import { TransitAgentsRepository } from "../transit-agents/transit-agents.repository";
import { FileRecord } from "../files/entities/file.entity";
import { CreateTransitAssignmentDto } from "./dto/create-transit-assignment.dto";
import { MyAssignmentsQueryDto } from "./dto/my-assignments-query.dto";
import { TransitAssignmentQueryDto } from "./dto/transit-assignment-query.dto";
import { UpdateTransitAssignmentDto } from "./dto/update-transit-assignment.dto";
import {
TRANSIT_ASSIGNMENT_FILE_RESOURCE,
TransitAssignment,
TransitAssignmentStatus,
} from "./entities/transit-assignment.entity";
import { TransitAssignmentsRepository } from "./transit-assignments.repository";
/** One attached document, flattened for the API. */
export interface TransitAssignmentFileView {
id: string;
name: string;
title: string | null;
url: string;
size: number;
mimeType: string;
/** When the file was first uploaded. */
uploadedAt: string;
/** When its metadata was last edited — equal to `uploadedAt` if never. */
updatedAt: string;
uploadedByUserId: string | null;
uploadedByName: string | null;
}
export type TransitAssignmentView = TransitAssignment & {
/**
* Minutes between the train arriving and the transit work finishing —
* `finishedAt booking.arrivedAt`, floored to whole minutes.
*
* Null until BOTH exist: an unfinished assignment has no end, and a booking
* whose arrival was never stamped has no start. Computed rather than stored
* so a corrected timestamp cannot leave a stale number behind.
*/
timeAfterTrainArrives: number | null;
/**
* Whether documents may still be added or removed right now. Mirrors
* `assertUploadAllowed` so the portal can disable its controls instead of
* letting the agent discover the rule through a 403.
*/
canUploadDocuments: boolean;
/**
* Whose cargo this is. Flattened off the joined company so the portal grid
* does not have to reach through `booking.company` — and so a booking with no
* company (shipping-line bookings carry none) renders as a blank rather than
* throwing.
*/
customerName: string | null;
files?: TransitAssignmentFileView[];
};
@Injectable()
export class TransitAssignmentsService {
constructor(
private readonly assignmentsRepository: TransitAssignmentsRepository,
private readonly transitAgentsRepository: TransitAgentsRepository,
// The Booking ENTITY, not BookingsModule: this only needs to confirm a
// booking id exists, and importing that module would pull its whole graph
// (billing, contracts, scheduling, first/last mile) in behind it.
@InjectRepository(Booking)
private readonly bookingsRepository: Repository<Booking>,
private readonly filesService: FilesService,
) {}
private static minutesBetween(
from?: Date | null,
to?: Date | null,
): number | null {
if (!from || !to) return null;
return Math.floor((to.getTime() - from.getTime()) / 60_000);
}
private toView(assignment: TransitAssignment): TransitAssignmentView {
return {
...assignment,
timeAfterTrainArrives: TransitAssignmentsService.minutesBetween(
assignment.booking?.arrivedAt,
assignment.finishedAt,
),
canUploadDocuments:
assignment.status !== TransitAssignmentStatus.Finished &&
assignment.booking?.schedulingStatus === "DISPATCHED",
customerName: assignment.booking?.company?.name ?? null,
};
}
async findAll(query: TransitAssignmentQueryDto) {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20;
const [items, total] = await this.assignmentsRepository.findPaginated(
{
bookingId: query.bookingId,
transitAgentId: query.transitAgentId,
status: query.status,
},
(page - 1) * pageSize,
pageSize,
);
return {
items: items.map((item) => this.toView(item)),
meta: {
total,
page,
pageSize,
totalPages: Math.max(1, Math.ceil(total / pageSize)),
},
};
}
/** Detail read — the only one that carries the attached documents. */
async findById(id: string): Promise<TransitAssignmentView> {
const assignment =
await this.assignmentsRepository.findOneWithRelations(id);
if (!assignment) {
throw new NotFoundException(`Transit assignment ${id} not found`);
}
return { ...this.toView(assignment), files: await this.listFiles(id) };
}
/** Every assignment handed to one transit agent — their workload list. */
async findByTransitAgent(
transitAgentId: string,
): Promise<TransitAssignmentView[]> {
const agent = await this.transitAgentsRepository.findById(transitAgentId);
if (!agent) {
throw new NotFoundException(`Transit agent ${transitAgentId} not found`);
}
const rows =
await this.assignmentsRepository.findByTransitAgent(transitAgentId);
return rows.map((row) => this.toView(row));
}
/** Every agent assigned to one booking. */
async findByBooking(bookingId: string): Promise<TransitAssignmentView[]> {
const rows = await this.assignmentsRepository.findByBooking(bookingId);
return rows.map((row) => this.toView(row));
}
// ── Portal (the signed-in transit agent's own work) ───────────────────────
// Every one of these resolves the agent from the SESSION and never from a
// client-supplied id: an agent must not be able to read or edit another
// agent's assignments by guessing one.
/** The transit agent this portal user signs in as. */
private async requireAgentForUser(userId: string) {
const agent = await this.transitAgentsRepository.findByUserId(userId);
if (!agent) {
throw new ForbiddenException("This account is not a transit agent");
}
return agent;
}
/**
* Dashboard figures for the signed-in agent's own work.
*
* Every interval is derived from timestamps that already exist — nothing is
* stored, so a corrected arrival or finish time changes these on the next
* read rather than leaving a stale metric behind.
*
* The median is used rather than the mean on purpose: one assignment
* reopened days later drags an average far enough to make the whole panel
* lie about typical performance.
*/
async myStats(userId: string) {
const agent = await this.requireAgentForUser(userId);
const rows = await this.assignmentsRepository.findByTransitAgent(agent.id);
const docCounts = rows.length
? await this.filesService.findByResourceIdsGrouped(
rows.map((r) => r.id),
TRANSIT_ASSIGNMENT_FILE_RESOURCE,
)
: new Map<string, unknown[]>();
const minutes = (from?: Date | null, to?: Date | null) =>
from && to ? Math.floor((to.getTime() - from.getTime()) / 60_000) : null;
const items = rows.map((row) => {
const arrivedAt = row.booking?.arrivedAt ?? null;
return {
id: row.id,
reference: row.booking?.reference ?? null,
customerName: row.booking?.company?.name ?? null,
status: row.status,
schedulingStatus: row.booking?.schedulingStatus ?? null,
/** Dispatch (cargo loaded) to the train arriving. */
transitMinutes: minutes(row.booking?.loadedAt, arrivedAt),
/** Arrival to the agent picking the work up. */
pickupMinutes: minutes(arrivedAt, row.startedAt),
/** Arrival to the work being finished — the headline metric. */
clearanceMinutes: minutes(arrivedAt, row.finishedAt),
documentCount: (docCounts.get(row.id) ?? []).length,
};
});
const median = (values: number[]): number | null => {
if (!values.length) return null;
const sorted = [...values].sort((a, b) => a - b);
const mid = Math.floor(sorted.length / 2);
return sorted.length % 2
? sorted[mid]
: Math.round((sorted[mid - 1] + sorted[mid]) / 2);
};
const cleared = items
.map((i) => i.clearanceMinutes)
.filter((v): v is number => v !== null);
const pickups = items
.map((i) => i.pickupMinutes)
.filter((v): v is number => v !== null);
// SLA bands, in minutes: inside 2h, inside 6h, beyond.
const sla = {
under2h: cleared.filter((v) => v <= 120).length,
under6h: cleared.filter((v) => v > 120 && v <= 360).length,
over6h: cleared.filter((v) => v > 360).length,
};
// Coverage counts only bookings that COULD have documents — uploads are
// gated on dispatch, so counting scheduled ones would invent a failure.
const dispatched = items.filter((i) => i.schedulingStatus === "DISPATCHED");
const withDocs = dispatched.filter((i) => i.documentCount > 0).length;
return {
totals: {
assignments: items.length,
open: items.filter((i) => i.status !== TransitAssignmentStatus.Finished)
.length,
finished: items.filter(
(i) => i.status === TransitAssignmentStatus.Finished,
).length,
readyForDocuments: items.filter(
(i) =>
i.schedulingStatus === "DISPATCHED" &&
i.status !== TransitAssignmentStatus.Finished,
).length,
documents: items.reduce((sum, i) => sum + i.documentCount, 0),
},
performance: {
medianClearanceMinutes: median(cleared),
medianPickupMinutes: median(pickups),
fastestClearanceMinutes: cleared.length ? Math.min(...cleared) : null,
slowestClearanceMinutes: cleared.length ? Math.max(...cleared) : null,
onTimeRate: cleared.length
? Math.round(((sla.under2h + sla.under6h) / cleared.length) * 100)
: null,
measured: cleared.length,
},
sla,
coverage: {
dispatched: dispatched.length,
withDocuments: withDocs,
},
/** Newest first, for the timeline and the recent-activity list. */
items: items.slice(0, 12),
};
}
async findMine(userId: string, query: MyAssignmentsQueryDto = {}) {
const agent = await this.requireAgentForUser(userId);
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20;
const [rows, total] =
await this.assignmentsRepository.findByTransitAgentPaginated(
agent.id,
{
status: query.status,
schedulingStatus: query.schedulingStatus,
search: query.search,
},
(page - 1) * pageSize,
pageSize,
);
// Documents come back with the list so the grid can show a per-row count.
// Batched deliberately: one lookup for the page, not one per assignment.
const grouped = rows.length
? await this.filesService.findByResourceIdsGrouped(
rows.map((row) => row.id),
TRANSIT_ASSIGNMENT_FILE_RESOURCE,
)
: new Map();
return {
items: rows.map((row) => ({
...this.toView(row),
files: (grouped.get(row.id) ?? []).map((record: FileRecord) => ({
id: record.id,
name: record.name,
title: record.title,
url: record.url,
size: record.size,
mimeType: record.mimeType,
uploadedAt: record.createdAt.toISOString(),
updatedAt: record.updatedAt.toISOString(),
uploadedByUserId: record.uploadedByUserId,
uploadedByName: record.uploadedByName,
})),
})),
meta: {
total,
page,
pageSize,
totalPages: Math.max(1, Math.ceil(total / pageSize)),
},
};
}
/**
* One of the signed-in agent's own assignments, with its documents.
* Ownership is asserted rather than filtered: a mismatch is hidden behind a
* NotFound so assignment ids cannot be probed.
*/
async findMineById(
userId: string,
id: string,
): Promise<TransitAssignmentView> {
const agent = await this.requireAgentForUser(userId);
const assignment =
await this.assignmentsRepository.findOneWithRelations(id);
if (!assignment || assignment.transitAgentId !== agent.id) {
throw new NotFoundException(`Transit assignment ${id} not found`);
}
return { ...this.toView(assignment), files: await this.listFiles(id) };
}
/** Assert the assignment is this user's before any write reaches it. */
private async assertMine(userId: string, id: string): Promise<void> {
await this.findMineById(userId, id);
}
async uploadMyFiles(
userId: string,
id: string,
files: Express.Multer.File[],
uploader: { userId?: string; name?: string },
titles?: string[],
): Promise<TransitAssignmentFileView[]> {
await this.assertMine(userId, id);
return this.uploadFiles(id, files, uploader, titles);
}
async removeMyFile(
userId: string,
id: string,
fileId: string,
): Promise<void> {
await this.assertMine(userId, id);
return this.removeFile(id, fileId);
}
/**
* The portal's Save / Finish action.
*
* Save keeps the assignment open (moving it to IN_PROGRESS so the work reads
* as under way); Finish closes it, which also locks its documents — see
* `assertUploadAllowed`.
*/
async submitMine(
userId: string,
id: string,
input: { finish: boolean; note?: string },
): Promise<TransitAssignmentView> {
const current = await this.findMineById(userId, id);
if (current.status === TransitAssignmentStatus.Finished) {
throw new ForbiddenException("This assignment is already finished.");
}
await this.update(id, {
status: input.finish
? TransitAssignmentStatus.Finished
: TransitAssignmentStatus.InProgress,
note: input.note,
});
return this.findMineById(userId, id);
}
async create(
dto: CreateTransitAssignmentDto,
assignedByUserId?: string,
): Promise<TransitAssignmentView> {
const booking = await this.bookingsRepository.findOne({
where: { id: dto.bookingId },
select: { id: true },
});
if (!booking) {
throw new NotFoundException(`Booking ${dto.bookingId} not found`);
}
const agent = await this.transitAgentsRepository.findById(
dto.transitAgentId,
);
if (!agent) {
throw new NotFoundException(
`Transit agent ${dto.transitAgentId} not found`,
);
}
if (
await this.assignmentsRepository.existsForPair(
dto.bookingId,
dto.transitAgentId,
)
) {
throw new ConflictException(
`${agent.name} is already assigned to this booking`,
);
}
const status = dto.status ?? TransitAssignmentStatus.NotStarted;
const created = await this.assignmentsRepository.create({
bookingId: dto.bookingId,
transitAgentId: dto.transitAgentId,
status,
// Creating straight into a working state still has to stamp its clock, or
// the assignment would report no start.
startedAt:
status === TransitAssignmentStatus.NotStarted ? null : new Date(),
finishedAt:
status === TransitAssignmentStatus.Finished ? new Date() : null,
assignedByUserId: assignedByUserId ?? null,
note: dto.note?.trim() || null,
});
return this.findById(created.id);
}
async update(
id: string,
dto: UpdateTransitAssignmentDto,
): Promise<TransitAssignmentView> {
const current = await this.assignmentsRepository.findOneWithRelations(id);
if (!current) {
throw new NotFoundException(`Transit assignment ${id} not found`);
}
const patch: Partial<TransitAssignment> = {};
if (dto.note !== undefined) patch.note = dto.note.trim() || null;
if (dto.status && dto.status !== current.status) {
patch.status = dto.status;
if (dto.status === TransitAssignmentStatus.InProgress) {
// Only the FIRST start is recorded — reopening finished work keeps the
// original start, so the elapsed time still spans the whole job.
patch.startedAt = current.startedAt ?? new Date();
patch.finishedAt = null;
} else if (dto.status === TransitAssignmentStatus.Finished) {
patch.startedAt = current.startedAt ?? new Date();
patch.finishedAt = new Date();
} else {
// Back to NOT_STARTED — the work is being reset, so both clocks clear
// rather than leaving a duration for work that no longer happened.
patch.startedAt = null;
patch.finishedAt = null;
}
}
const updated = await this.assignmentsRepository.update(id, patch);
if (!updated) {
throw new NotFoundException(`Transit assignment ${id} not found`);
}
return this.findById(id);
}
async remove(id: string): Promise<void> {
await this.findById(id);
await this.assignmentsRepository.softDelete(id);
}
// ── Documents ─────────────────────────────────────────────────────────────
// Stored in `freight.files` under TRANSIT_ASSIGNMENT_FILE_RESOURCE rather
// than a table of their own: that one already carries the MinIO object, the
// upload time, the uploader and the supersede history.
/**
* Whether an assignment may still receive documents.
*
* Two gates, both business rules rather than UI conveniences:
* - the booking must actually be on its way (`DISPATCHED`), since there is
* nothing to clear before the train leaves;
* - the assignment must not be FINISHED — filing closes with the work, so a
* finished record cannot grow new paperwork afterwards.
*/
private assertUploadAllowed(assignment: TransitAssignment): void {
if (assignment.status === TransitAssignmentStatus.Finished) {
throw new ForbiddenException(
"This assignment is finished — its documents can no longer be changed.",
);
}
if (assignment.booking?.schedulingStatus !== "DISPATCHED") {
throw new ForbiddenException(
"Documents can only be uploaded once the booking has been dispatched.",
);
}
}
async listFiles(id: string): Promise<TransitAssignmentFileView[]> {
const records = await this.filesService.findByResource(
id,
TRANSIT_ASSIGNMENT_FILE_RESOURCE,
);
return records.map((record) => ({
id: record.id,
name: record.name,
title: record.title,
url: record.url,
size: record.size,
mimeType: record.mimeType,
uploadedAt: record.createdAt.toISOString(),
updatedAt: record.updatedAt.toISOString(),
uploadedByUserId: record.uploadedByUserId,
uploadedByName: record.uploadedByName,
}));
}
async uploadFiles(
id: string,
files: Express.Multer.File[],
uploader: { userId?: string; name?: string },
/**
* A display name per file, positionally matched to `files`. Multer preserves
* the multipart part order, and the client appends one `titles` entry per
* file in the same order, so index N names file N. A missing or blank entry
* falls back to the original filename.
*/
titles?: string[],
): Promise<TransitAssignmentFileView[]> {
if (!files?.length) {
throw new BadRequestException("No files were uploaded");
}
// Asserts the assignment exists before anything reaches MinIO — an upload
// keyed to a missing row would be unreachable storage nobody ever lists.
const assignment =
await this.assignmentsRepository.findOneWithRelations(id);
if (!assignment) {
throw new NotFoundException(`Transit assignment ${id} not found`);
}
this.assertUploadAllowed(assignment);
await Promise.all(
files.map((file, index) =>
this.filesService.upload({
resourceId: id,
resource: TRANSIT_ASSIGNMENT_FILE_RESOURCE,
code: file.fieldname || "document",
file,
title: titles?.[index]?.trim() || null,
uploadedByUserId: uploader.userId ?? null,
uploadedByName: uploader.name ?? null,
}),
),
);
return this.listFiles(id);
}
async removeFile(id: string, fileId: string): Promise<void> {
const assignment =
await this.assignmentsRepository.findOneWithRelations(id);
if (!assignment) {
throw new NotFoundException(`Transit assignment ${id} not found`);
}
// Same gate as upload: a finished assignment's paperwork is fixed, and
// removal is as much a change as adding.
this.assertUploadAllowed(assignment);
const files = await this.filesService.findByResource(
id,
TRANSIT_ASSIGNMENT_FILE_RESOURCE,
);
// Scoped to this assignment's own documents: a bare file id would let one
// assignment delete another's paperwork.
if (!files.some((file) => file.id === fileId)) {
throw new NotFoundException(
`File ${fileId} not found on this assignment`,
);
}
await this.filesService.remove(fileId);
}
}

View File

@@ -0,0 +1,222 @@
import { BadRequestException, ConflictException } from '@nestjs/common';
import { WarehousePlacementService } from './warehouse-placement.service';
import { WarehouseZoneStacksService } from './warehouse-zone-stacks.service';
/**
* The physical rules a yard operator would recognise: nothing floats above an
* empty level, a slot holds one box, and the ids a client sends are only
* believed after the whole chain has been resolved server-side.
*/
const CHAIN = {
slotId: 'slot-2',
slotStatus: 'AVAILABLE',
slotIsActive: true,
level: 2,
stackId: 'stack-1',
stackCode: 'ZA-001',
stackStatus: 'ACTIVE',
stackIsActive: true,
maxStackHeight: 3,
zoneId: 'zone-1',
zoneCode: 'L1-O-A-ZA',
zoneType: 'CONTAINER_ZONE',
zoneStatus: 'ACTIVE',
zoneIsActive: true,
yardId: 'yard-1',
yardCode: 'L1-O-A',
yardType: 'CONTAINER_YARD',
yardDirection: null,
yardStatus: 'ACTIVE',
yardIsActive: true,
warehouseId: 'wh-1',
warehouseCode: 'L1-OPEN',
warehouseStatus: 'ACTIVE',
warehouseIsActive: true,
};
/** A placement service whose slot chain and stack occupancy are dictated by the test. */
function makePlacement(chain: Partial<typeof CHAIN>, occupiedLevels: number[], slotTakenBy: string | null = null) {
const service = Object.create(WarehousePlacementService.prototype) as Record<string, unknown>;
service.resolveSlot = jest.fn().mockResolvedValue({ ...CHAIN, ...chain });
service.occupiedLevels = jest.fn().mockResolvedValue(occupiedLevels);
service.em = () => ({ query: jest.fn().mockResolvedValue(slotTakenBy ? [{ id: slotTakenBy }] : []) });
return service as unknown as WarehousePlacementService;
}
const placementInput = {
slotId: 'slot-2',
warehouseId: 'wh-1',
yardId: 'yard-1',
zoneId: 'zone-1',
quantity: 1,
};
describe('WarehousePlacementService.assertStackable', () => {
const service = Object.create(WarehousePlacementService.prototype) as WarehousePlacementService;
it('always allows the ground level', () => {
expect(() => service.assertStackable({ level: 1, stackCode: 'ZA-001' }, [])).not.toThrow();
});
it('allows level 2 once level 1 is filled', () => {
expect(() => service.assertStackable({ level: 2, stackCode: 'ZA-001' }, [1])).not.toThrow();
});
it('allows level 3 once levels 1 and 2 are filled', () => {
expect(() => service.assertStackable({ level: 3, stackCode: 'ZA-001' }, [1, 2])).not.toThrow();
});
it('refuses level 2 over an empty ground level', () => {
expect(() => service.assertStackable({ level: 2, stackCode: 'ZA-001' }, [])).toThrow(
/level 2 cannot be filled while level\(s\) 1 are empty/,
);
});
it('refuses level 3 when level 2 is empty', () => {
expect(() => service.assertStackable({ level: 3, stackCode: 'ZA-001' }, [1])).toThrow(
/level\(s\) 2 are empty/,
);
});
});
describe('WarehousePlacementService.validateSlotForInventory', () => {
it('accepts a consistent hierarchy with the level below filled', async () => {
const service = makePlacement({}, [1]);
await expect(service.validateSlotForInventory(placementInput)).resolves.toMatchObject({
stackId: 'stack-1',
level: 2,
});
});
it('refuses a slot belonging to another zone', async () => {
const service = makePlacement({ zoneId: 'other-zone' }, [1]);
await expect(service.validateSlotForInventory(placementInput)).rejects.toBeInstanceOf(BadRequestException);
});
it('refuses a zone whose yard is not the one given', async () => {
const service = makePlacement({ yardId: 'other-yard' }, [1]);
await expect(service.validateSlotForInventory(placementInput)).rejects.toThrow(/does not belong|not the yard given/);
});
it('refuses a yard whose warehouse is not the one given', async () => {
const service = makePlacement({ warehouseId: 'other-wh' }, [1]);
await expect(service.validateSlotForInventory(placementInput)).rejects.toThrow(/not the warehouse given/);
});
it('refuses an inactive stack', async () => {
const service = makePlacement({ stackStatus: 'INACTIVE', stackIsActive: false }, [1]);
await expect(service.validateSlotForInventory(placementInput)).rejects.toThrow(/Stack ZA-001 is not active/);
});
it('refuses a blocked slot', async () => {
const service = makePlacement({ slotStatus: 'BLOCKED' }, [1]);
await expect(service.validateSlotForInventory(placementInput)).rejects.toThrow(/is BLOCKED/);
});
it('accepts a slot reserved for the box now arriving', async () => {
const service = makePlacement({ slotStatus: 'RESERVED' }, [1]);
await expect(service.validateSlotForInventory(placementInput)).resolves.toMatchObject({ level: 2 });
});
it('refuses a slot another container already stands in', async () => {
const service = makePlacement({}, [1], 'other-inventory');
await expect(service.validateSlotForInventory(placementInput)).rejects.toBeInstanceOf(ConflictException);
});
it('refuses a level above the stack height', async () => {
const service = makePlacement({ level: 4, slotId: 'slot-4' }, [1, 2, 3]);
await expect(
service.validateSlotForInventory({ ...placementInput, slotId: 'slot-4' }),
).rejects.toThrow(/above stack ZA-001's maximum height of 3/);
});
it('refuses a row that still covers several containers', async () => {
const service = makePlacement({}, [1]);
await expect(service.validateSlotForInventory({ ...placementInput, quantity: 5 })).rejects.toThrow(
/covers 5 containers/,
);
});
it('skips container stacking rules for a bulk yard', async () => {
// Level 2 over an empty level 1 would be refused in a container yard;
// a bulk yard has no vertical semantics to enforce.
const service = makePlacement({ yardType: 'BULK_YARD' }, []);
await expect(service.validateSlotForInventory({ ...placementInput, quantity: 12 })).resolves.toMatchObject({
yardType: 'BULK_YARD',
});
});
});
describe('WarehousePlacementService.getContainerAccessibility', () => {
function makeAccessibility(placed: unknown, blocking: unknown[]) {
const service = Object.create(WarehousePlacementService.prototype) as Record<string, unknown>;
const query = jest
.fn()
.mockResolvedValueOnce(placed ? [placed] : [])
.mockResolvedValueOnce(blocking);
service.em = () => ({ query });
return service as unknown as WarehousePlacementService;
}
it('reports a ground container buried under two others', async () => {
const service = makeAccessibility(
{ inventoryId: 'inv-1', level: 1, stackId: 'stack-1', stackCode: 'ZA-001' },
[
{ inventoryId: 'inv-3', level: 3, status: 'STORED', containerNumber: 'CONT-003' },
{ inventoryId: 'inv-2', level: 2, status: 'STORED', containerNumber: 'CONT-002' },
],
);
await expect(service.getContainerAccessibility('inv-1')).resolves.toEqual({
accessible: false,
inventoryId: 'inv-1',
stackCode: 'ZA-001',
level: 1,
blockingContainers: [
{ inventoryId: 'inv-3', level: 3, status: 'STORED', containerNumber: 'CONT-003' },
{ inventoryId: 'inv-2', level: 2, status: 'STORED', containerNumber: 'CONT-002' },
],
});
});
it('reports the top container as reachable', async () => {
const service = makeAccessibility({ inventoryId: 'inv-3', level: 3, stackId: 'stack-1', stackCode: 'ZA-001' }, []);
await expect(service.getContainerAccessibility('inv-3')).resolves.toMatchObject({ accessible: true });
});
it('treats an item with no slot as reachable', async () => {
const service = makeAccessibility({ inventoryId: 'inv-9', level: null, stackId: null, stackCode: null }, []);
await expect(service.getContainerAccessibility('inv-9')).resolves.toEqual({
accessible: true,
inventoryId: 'inv-9',
stackCode: null,
level: null,
blockingContainers: [],
});
});
});
describe('WarehouseZoneStacksService guards', () => {
function makeStacksService(occupied: number[]) {
const service = Object.create(WarehouseZoneStacksService.prototype) as Record<string, unknown>;
service.placement = { occupiedLevels: jest.fn().mockResolvedValue(occupied) };
service.stacksRepository = {
findById: jest.fn().mockResolvedValue({ id: 'stack-1', code: 'ZA-001', zoneId: 'zone-1', slots: [] }),
};
service.dataSource = { transaction: jest.fn() };
return service as unknown as WarehouseZoneStacksService;
}
it('refuses to delete a stack that still holds containers', async () => {
await expect(makeStacksService([1, 2]).remove('stack-1')).rejects.toThrow(
/still holds 2 container\(s\) at level\(s\) 1, 2/,
);
});
it('deletes an empty stack', async () => {
const service = makeStacksService([]);
await expect(service.remove('stack-1')).resolves.toEqual({ id: 'stack-1', deleted: true });
});
});

View File

@@ -0,0 +1,46 @@
import { ConflictException, NotFoundException } from '@nestjs/common';
import { WarehousesService } from './warehouses.service';
/**
* Deleting a warehouse that still holds yards would orphan every zone and the
* inventory sitting in them, so remove() refuses instead of cascading.
*/
function makeService(warehouse: unknown) {
const warehousesRepository = {
findById: jest.fn().mockResolvedValue(warehouse),
softDelete: jest.fn().mockResolvedValue(undefined),
};
const service = Object.create(WarehousesService.prototype) as Record<string, unknown>;
service.warehousesRepository = warehousesRepository;
return { service: service as unknown as WarehousesService, warehousesRepository };
}
describe('WarehousesService.remove', () => {
it('soft-deletes a warehouse with no yards', async () => {
const { service, warehousesRepository } = makeService({ id: 'w1', code: 'GMP', yards: [] });
await expect(service.remove('w1')).resolves.toEqual({ id: 'w1', deleted: true });
expect(warehousesRepository.softDelete).toHaveBeenCalledWith('w1');
});
it('refuses while yards remain', async () => {
const { service, warehousesRepository } = makeService({
id: 'w1',
code: 'GMP',
yards: [{ id: 'y1' }],
});
await expect(service.remove('w1')).rejects.toBeInstanceOf(ConflictException);
expect(warehousesRepository.softDelete).not.toHaveBeenCalled();
});
it('404s on an unknown warehouse', async () => {
const { service, warehousesRepository } = makeService(null);
await expect(service.remove('nope')).rejects.toBeInstanceOf(NotFoundException);
expect(warehousesRepository.softDelete).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,88 @@
import { ConflictException, NotFoundException } from '@nestjs/common';
import { WarehouseYardsService } from './warehouse-yards.service';
import { WarehouseZonesService } from './warehouse-zones.service';
/**
* Soft-deleting a parent would leave its children pointing at a row every
* joining query drops, so both removes refuse while children exist.
*/
function makeYardsService(yard: unknown) {
const yardsRepository = {
findById: jest.fn().mockResolvedValue(yard),
softDelete: jest.fn().mockResolvedValue(undefined),
};
const service = Object.create(WarehouseYardsService.prototype) as Record<string, unknown>;
service.yardsRepository = yardsRepository;
return { service: service as unknown as WarehouseYardsService, yardsRepository };
}
function makeZonesService(zone: unknown, heldInventory: number, configuredStacks = 0) {
const zonesRepository = {
findById: jest.fn().mockResolvedValue(zone),
softDelete: jest.fn().mockResolvedValue(undefined),
};
const inventoryRepository = {
findAndCount: jest.fn().mockResolvedValue([[], heldInventory]),
};
const service = Object.create(WarehouseZonesService.prototype) as Record<string, unknown>;
service.zonesRepository = zonesRepository;
service.inventoryRepository = inventoryRepository;
service.dataSource = { query: jest.fn().mockResolvedValue([{ count: configuredStacks }]) };
return { service: service as unknown as WarehouseZonesService, zonesRepository };
}
describe('WarehouseYardsService.remove', () => {
it('soft-deletes a yard with no zones', async () => {
const { service, yardsRepository } = makeYardsService({ id: 'y1', code: 'CY-A', zones: [] });
await expect(service.remove('y1')).resolves.toEqual({ id: 'y1', deleted: true });
expect(yardsRepository.softDelete).toHaveBeenCalledWith('y1');
});
it('refuses while zones remain', async () => {
const { service, yardsRepository } = makeYardsService({
id: 'y1',
code: 'CY-A',
zones: [{ id: 'z1' }],
});
await expect(service.remove('y1')).rejects.toBeInstanceOf(ConflictException);
expect(yardsRepository.softDelete).not.toHaveBeenCalled();
});
it('404s on an unknown yard', async () => {
const { service } = makeYardsService(null);
await expect(service.remove('nope')).rejects.toBeInstanceOf(NotFoundException);
});
});
describe('WarehouseZonesService.remove', () => {
it('soft-deletes an empty zone', async () => {
const { service, zonesRepository } = makeZonesService({ id: 'z1', code: 'ZA' }, 0);
await expect(service.remove('z1')).resolves.toEqual({ id: 'z1', deleted: true });
expect(zonesRepository.softDelete).toHaveBeenCalledWith('z1');
});
it('refuses while inventory sits in it', async () => {
const { service, zonesRepository } = makeZonesService({ id: 'z1', code: 'ZA' }, 16);
await expect(service.remove('z1')).rejects.toBeInstanceOf(ConflictException);
expect(zonesRepository.softDelete).not.toHaveBeenCalled();
});
it('refuses while ground stacks are still configured in it', async () => {
const { service, zonesRepository } = makeZonesService({ id: 'z1', code: 'ZA' }, 0, 20);
await expect(service.remove('z1')).rejects.toThrow(/still has 20 configured stack\(s\)/);
expect(zonesRepository.softDelete).not.toHaveBeenCalled();
});
it('404s on an unknown zone', async () => {
const { service } = makeZonesService(null, 0);
await expect(service.remove('nope')).rejects.toBeInstanceOf(NotFoundException);
});
});

View File

@@ -1,7 +1,14 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsEnum, IsNumber, IsOptional, IsString, IsUUID, Matches, MaxLength, Min } from 'class-validator';
import { WAREHOUSE_STATUSES, WAREHOUSE_TYPES, WarehouseStatus, WarehouseType } from '../entities/warehouse.entity';
import {
FREIGHT_TYPES,
FreightType,
WAREHOUSE_STATUSES,
WAREHOUSE_TYPES,
WarehouseStatus,
WarehouseType,
} from '../entities/warehouse.entity';
export class CreateWarehouseDto {
@ApiProperty()
@@ -19,6 +26,11 @@ export class CreateWarehouseDto {
@IsEnum(WAREHOUSE_TYPES)
type!: WarehouseType;
@ApiPropertyOptional({ enum: FREIGHT_TYPES, description: 'Omit for a warehouse that takes both.' })
@IsOptional()
@IsEnum(FREIGHT_TYPES)
freightType?: FreightType;
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID()

View File

@@ -14,6 +14,14 @@ export class MoveInventoryDto {
@IsUUID()
zoneId!: string;
@ApiPropertyOptional({
format: 'uuid',
description: 'Exact physical slot in the destination zone. Container yards only.',
})
@IsOptional()
@IsUUID()
slotId?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()

View File

@@ -0,0 +1,33 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsIn, IsOptional, IsUUID } from 'class-validator';
export class FindAvailableSlotDto {
@ApiProperty({ format: 'uuid' })
@IsUUID()
yardId!: string;
@ApiPropertyOptional({ format: 'uuid', description: 'Narrow the search to one zone.' })
@IsOptional()
@IsUUID()
zoneId?: string;
@ApiPropertyOptional({ enum: ['IMPORT', 'EXPORT', 'BOTH'], description: 'Null/BOTH matches any yard direction.' })
@IsOptional()
@IsIn(['IMPORT', 'EXPORT', 'BOTH'])
direction?: string;
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID()
cargoTypeId?: string;
}
export class AssignSlotDto {
@ApiPropertyOptional({
format: 'uuid',
description: 'Target slot. Omit to let the placement engine pick the lowest free level.',
})
@IsOptional()
@IsUUID()
slotId?: string;
}

View File

@@ -0,0 +1,114 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
ArrayMaxSize,
ArrayMinSize,
IsArray,
IsDateString,
IsNumber,
IsOptional,
IsString,
IsUUID,
MaxLength,
Min,
ValidateNested,
} from 'class-validator';
/**
* A loaded container that was already sitting in a yard before the system knew
* about it. It has no booking, so the owner is carried as a company reference
* or free text, and `arrivedAt` is the true historical arrival rather than now.
*/
export class RegisterBacklogContainerDto {
@ApiProperty({ description: 'ISO 6346 container number' })
@IsString()
@MaxLength(20)
containerNumber!: string;
@ApiProperty({ format: 'uuid' })
@IsUUID()
containerTypeId!: string;
@ApiProperty({ format: 'uuid' })
@IsUUID()
warehouseId!: string;
@ApiProperty({ format: 'uuid' })
@IsUUID()
yardId!: string;
@ApiProperty({ format: 'uuid' })
@IsUUID()
zoneId!: string;
@ApiProperty({ description: 'True historical arrival date — drives nothing billable.' })
@IsDateString()
arrivedAt!: string;
@ApiPropertyOptional({ format: 'uuid', description: 'Registered customer, when the owner is one.' })
@IsOptional()
@IsUUID()
companyId?: string;
@ApiPropertyOptional({ description: 'Owner name — free text when the company is not a customer yet.' })
@IsOptional()
@IsString()
@MaxLength(200)
companyName?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
@MaxLength(100)
sealNumber?: string;
@ApiPropertyOptional({ description: 'Net weight in the unit the warehouse records (tonnes).' })
@IsOptional()
@IsNumber()
@Min(0)
weight?: number;
@ApiPropertyOptional()
@IsOptional()
@IsNumber()
@Min(0)
volume?: number;
/**
* ponytail: defaults to 0 when unknown, which is the honest value for a box
* nobody weighed. `containers.max_gross_weight` is a ceiling in
* cargoes.service, so set real figures here before this box is ever used for
* a new cargo assignment.
*/
@ApiPropertyOptional()
@IsOptional()
@IsNumber()
@Min(0)
tareWeight?: number;
@ApiPropertyOptional()
@IsOptional()
@IsNumber()
@Min(0)
maxGrossWeight?: number;
@ApiPropertyOptional()
@IsOptional()
@IsString()
notes?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
performedBy?: string;
}
export class BulkRegisterBacklogDto {
@ApiProperty({ type: [RegisterBacklogContainerDto] })
@IsArray()
@ArrayMinSize(1)
@ArrayMaxSize(1000)
@ValidateNested({ each: true })
@Type(() => RegisterBacklogContainerDto)
containers!: RegisterBacklogContainerDto[];
}

View File

@@ -22,6 +22,15 @@ export class StoreInventoryDto {
@IsUUID()
zoneId?: string;
@ApiPropertyOptional({
format: 'uuid',
description:
'Exact physical slot. Container yards only; omit to let the placement engine pick the lowest free level.',
})
@IsOptional()
@IsUUID()
slotId?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()

View File

@@ -0,0 +1,120 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsBoolean, IsEnum, IsInt, IsOptional, IsString, IsUUID, Max, MaxLength, Min } from 'class-validator';
import {
DEFAULT_MAX_STACK_HEIGHT,
WAREHOUSE_ZONE_STACK_STATUSES,
WarehouseZoneStackStatus,
} from '../entities/warehouse-zone-stack.entity';
import {
WAREHOUSE_ZONE_SLOT_STATUSES,
WarehouseZoneSlotStatus,
} from '../entities/warehouse-zone-slot.entity';
/** Nobody stacks boxes this high; the cap is here to catch a typo'd 30. */
const MAX_SUPPORTED_STACK_HEIGHT = 10;
export class CreateWarehouseZoneStackDto {
@ApiPropertyOptional({ format: 'uuid', description: 'Optional — taken from the route param when omitted' })
@IsOptional()
@IsUUID()
zoneId?: string;
@ApiProperty({ example: 'ZA-001' })
@IsString()
@MaxLength(40)
code!: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
@MaxLength(160)
name?: string;
@ApiPropertyOptional({ description: 'Physical row label' })
@IsOptional()
@IsString()
@MaxLength(20)
row?: string;
@ApiPropertyOptional({ description: 'Physical bay label' })
@IsOptional()
@IsString()
@MaxLength(20)
bay?: string;
@ApiPropertyOptional({ description: 'Physical position label' })
@IsOptional()
@IsString()
@MaxLength(20)
position?: string;
@ApiPropertyOptional({
default: DEFAULT_MAX_STACK_HEIGHT,
description: 'One slot is generated per level, 1 to this height.',
})
@IsOptional()
@IsInt()
@Min(1)
@Max(MAX_SUPPORTED_STACK_HEIGHT)
maxStackHeight?: number;
}
export class UpdateWarehouseZoneStackDto {
@ApiPropertyOptional()
@IsOptional()
@IsString()
@MaxLength(40)
code?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
@MaxLength(160)
name?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
@MaxLength(20)
row?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
@MaxLength(20)
bay?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
@MaxLength(20)
position?: string;
@ApiPropertyOptional({ description: 'Raising it adds slots; lowering it removes the empty top levels.' })
@IsOptional()
@IsInt()
@Min(1)
@Max(MAX_SUPPORTED_STACK_HEIGHT)
maxStackHeight?: number;
@ApiPropertyOptional({ enum: WAREHOUSE_ZONE_STACK_STATUSES })
@IsOptional()
@IsEnum(WAREHOUSE_ZONE_STACK_STATUSES)
status?: WarehouseZoneStackStatus;
}
export class UpdateWarehouseZoneSlotDto {
@ApiPropertyOptional({
enum: WAREHOUSE_ZONE_SLOT_STATUSES,
description: 'Operator intent only. OCCUPIED is derived from inventory and cannot be set here.',
})
@IsOptional()
@IsEnum(WAREHOUSE_ZONE_SLOT_STATUSES)
status?: WarehouseZoneSlotStatus;
@ApiPropertyOptional()
@IsOptional()
@IsBoolean()
isActive?: boolean;
}

View File

@@ -7,6 +7,8 @@ import { Container } from '../../container-management/entities/container.entity'
import { Warehouse } from './warehouse.entity';
import { WarehouseYard } from './warehouse-yard.entity';
import { WarehouseZone } from './warehouse-zone.entity';
import { WarehouseZoneSlot } from './warehouse-zone-slot.entity';
import { WarehouseZoneStack } from './warehouse-zone-stack.entity';
// Lifecycle. Supersedes the Batch 1 set
// (ARRIVED_AT_WAREHOUSE / UNDER_INSPECTION / READY_FOR_LOADING) — migrated in place.
@@ -50,6 +52,23 @@ export const WAREHOUSE_INVENTORY_TRANSITIONS: Record<WarehouseInventoryStatus, W
DELIVERED: [],
};
/**
* Statuses in which an inventory row is still physically standing in its slot.
* The moment it is LOADED onto a train, dispatched, or handed over, the ground
* is free again — so occupancy is read from this list rather than written to
* the slot row. The partial unique index in
* `WarehouseZoneStacksSlots3830000000000` uses exactly the same list; change
* one and you must change the other.
*/
export const SLOT_OCCUPYING_STATUSES: readonly WarehouseInventoryStatus[] = [
'UNLOADED',
'RECEIVED',
'STORED',
'RESERVED',
'READY_FOR_LOADING',
'READY_FOR_PICKUP',
];
@Entity({ schema: 'freight', name: 'warehouse_inventory' })
@Index(['warehouseId'])
@Index(['yardId'])
@@ -58,6 +77,8 @@ export const WAREHOUSE_INVENTORY_TRANSITIONS: Record<WarehouseInventoryStatus, W
@Index(['cargoId'])
@Index(['containerId'])
@Index(['goodsId'])
@Index(['stackId'])
@Index(['slotId'])
@Index(['status'])
export class WarehouseInventory extends BaseEntity {
@Column({ name: 'warehouse_id', type: 'uuid' })
@@ -81,6 +102,25 @@ export class WarehouseInventory extends BaseEntity {
@JoinColumn({ name: 'zone_id' })
zone?: WarehouseZone;
/**
* Exact physical position inside the zone. Nullable and additive: every row
* that predates the stack/slot model, and every non-container yard, keeps
* working with zone-level placement alone.
*/
@Column({ name: 'stack_id', type: 'uuid', nullable: true })
stackId?: string | null;
@ManyToOne(() => WarehouseZoneStack, { nullable: true })
@JoinColumn({ name: 'stack_id' })
stack?: WarehouseZoneStack | null;
@Column({ name: 'slot_id', type: 'uuid', nullable: true })
slotId?: string | null;
@ManyToOne(() => WarehouseZoneSlot, { nullable: true })
@JoinColumn({ name: 'slot_id' })
slot?: WarehouseZoneSlot | null;
@Column({ name: 'booking_id', type: 'uuid', nullable: true })
bookingId?: string | null;
@@ -105,6 +145,22 @@ export class WarehouseInventory extends BaseEntity {
@Column({ name: 'goods_id', type: 'uuid', nullable: true })
goodsId?: string | null;
/**
* Registered as backlog: the box was already in the yard before the system
* knew about it. `arrivedAt` is the true, backdated arrival, but no storage
* or demurrage accrues — see WarehouseFeeService.previewForInventory.
*/
@Column({ name: 'backlog_registration', type: 'boolean', default: false })
backlogRegistration!: boolean;
/** Owner of a row with no booking to inherit one from. */
@Column({ name: 'company_id', type: 'uuid', nullable: true })
companyId?: string | null;
/** Owner as text — a company that is not a registered customer yet. */
@Column({ name: 'company_name', type: 'varchar', length: 200, nullable: true })
companyName?: string | null;
@Column({ name: 'quantity', type: 'numeric', precision: 12, scale: 3, default: 0 })
quantity!: number;

View File

@@ -0,0 +1,39 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { WarehouseZoneStack } from './warehouse-zone-stack.entity';
/**
* Stored slot status is *operator intent* only. Occupancy is never written
* here: it is derived from `warehouse_inventory.slot_id` plus the row's
* lifecycle status, so the two can never drift apart and no exit path
* (load / dispatch / deliver) has to remember to free a slot. The computed
* OCCUPIED value is what the API returns — see `SLOT_EFFECTIVE_STATUSES`.
*/
export const WAREHOUSE_ZONE_SLOT_STATUSES = ['AVAILABLE', 'BLOCKED', 'RESERVED', 'INACTIVE'] as const;
export type WarehouseZoneSlotStatus = (typeof WAREHOUSE_ZONE_SLOT_STATUSES)[number];
export const SLOT_EFFECTIVE_STATUSES = [...WAREHOUSE_ZONE_SLOT_STATUSES, 'OCCUPIED'] as const;
export type SlotEffectiveStatus = (typeof SLOT_EFFECTIVE_STATUSES)[number];
@Entity({ schema: 'freight', name: 'warehouse_zone_slots' })
@Index(['stackId'])
@Index(['status'])
export class WarehouseZoneSlot extends BaseEntity {
@Column({ name: 'stack_id', type: 'uuid' })
stackId!: string;
@ManyToOne(() => WarehouseZoneStack, (stack) => stack.slots, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'stack_id' })
stack?: WarehouseZoneStack;
/** 1 = on the ground. Capped by the parent stack's maxStackHeight. */
@Column({ name: 'level', type: 'int' })
level!: number;
@Column({ name: 'status', type: 'varchar', length: 16, default: 'AVAILABLE' })
status!: WarehouseZoneSlotStatus;
@Column({ name: 'is_active', type: 'boolean', default: true })
isActive!: boolean;
}

View File

@@ -0,0 +1,60 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm';
import { WarehouseZone } from './warehouse-zone.entity';
import { WarehouseZoneSlot } from './warehouse-zone-slot.entity';
export const WAREHOUSE_ZONE_STACK_STATUSES = ['ACTIVE', 'INACTIVE'] as const;
export type WarehouseZoneStackStatus = (typeof WAREHOUSE_ZONE_STACK_STATUSES)[number];
/** Default vertical height of a container stack — three boxes, EDR's reach-stacker limit. */
export const DEFAULT_MAX_STACK_HEIGHT = 3;
/**
* One ground footprint inside a zone: the patch of concrete a container is put
* down on, and the levels above it. The zone is where allocation stops; this is
* where a box physically sits.
*
* Generic on purpose — a bulk or general-cargo zone may divide itself into
* stacks too — but the vertical stacking rules only run for CONTAINER_YARD.
*/
@Entity({ schema: 'freight', name: 'warehouse_zone_stacks' })
@Index(['zoneId'])
@Index(['status'])
export class WarehouseZoneStack extends BaseEntity {
@Column({ name: 'zone_id', type: 'uuid' })
zoneId!: string;
@ManyToOne(() => WarehouseZone, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'zone_id' })
zone?: WarehouseZone;
/** Unique within the zone, e.g. ZA-001. */
@Column({ name: 'code', type: 'varchar', length: 40 })
code!: string;
@Column({ name: 'name', type: 'varchar', length: 160, nullable: true })
name?: string | null;
/** Free-form physical coordinates. Labels, not numbers — yards mix A/B/C with 1/2/3. */
@Column({ name: 'row', type: 'varchar', length: 20, nullable: true })
row?: string | null;
@Column({ name: 'bay', type: 'varchar', length: 20, nullable: true })
bay?: string | null;
@Column({ name: 'position', type: 'varchar', length: 20, nullable: true })
position?: string | null;
@Column({ name: 'max_stack_height', type: 'int', default: DEFAULT_MAX_STACK_HEIGHT })
maxStackHeight!: number;
@Column({ name: 'status', type: 'varchar', length: 16, default: 'ACTIVE' })
status!: WarehouseZoneStackStatus;
@Column({ name: 'is_active', type: 'boolean', default: true })
isActive!: boolean;
@OneToMany(() => WarehouseZoneSlot, (slot) => slot.stack)
slots?: WarehouseZoneSlot[];
}

View File

@@ -1,12 +1,16 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm';
import { FREIGHT_TYPES, FreightType } from '../../bookings/entities/booking.entity';
import { Facility } from '../../facilities/entities/facility.entity';
import { WarehouseYard } from './warehouse-yard.entity';
export const WAREHOUSE_TYPES = ['OPEN_WAREHOUSE', 'CLOSED_WAREHOUSE'] as const;
export type WarehouseType = (typeof WAREHOUSE_TYPES)[number];
export { FREIGHT_TYPES };
export type { FreightType };
export const WAREHOUSE_STATUSES = ['ACTIVE', 'INACTIVE'] as const;
export type WarehouseStatus = (typeof WAREHOUSE_STATUSES)[number];
@@ -25,6 +29,14 @@ export class Warehouse extends BaseEntity {
@Column({ name: 'type', type: 'varchar', length: 32 })
type!: WarehouseType;
/**
* What the warehouse handles. Null means unrestricted — the pre-existing
* behaviour for every warehouse created before this field existed, so it
* never narrows an already-configured site.
*/
@Column({ name: 'freight_type', type: 'varchar', length: 16, nullable: true })
freightType?: FreightType | null;
@Column({ name: 'station_id', type: 'uuid', nullable: true })
stationId?: string | null;

View File

@@ -12,6 +12,8 @@ import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
interface ItemAttributes {
arrivedAt: Date | null;
/** Backlog-registered box: real arrival on the record, but never billed. */
backlogRegistration: boolean;
gateClearedAt: Date | null;
releaseDate: Date | null;
freightType: string | null;
@@ -260,6 +262,7 @@ export class WarehouseFeeService {
private async loadItem(inventoryId: string): Promise<ItemAttributes> {
const [row] = await this.dataSource.query(
`SELECT inv.arrived_at AS "arrivedAt",
inv.backlog_registration AS "backlogRegistration",
inv.gate_cleared_at AS "gateClearedAt",
inv.release_date AS "releaseDate",
inv.quantity AS "inventoryQuantity",
@@ -777,6 +780,13 @@ export class WarehouseFeeService {
/** Preview demurrage + storage fees for an inventory item using the most specific active rules. */
async previewForInventory(inventoryId: string, billingCurrency = 'USD'): Promise<FeePreview[]> {
const item = await this.loadItem(inventoryId);
// A backlog registration carries a backdated arrival so the record is
// honest about how long the box has sat, but it was never booked through
// EDR and is not billed for that history. No rule applies, so no preview —
// which also keeps it off the invoice, since invoicing reads this same list.
if (item.backlogRegistration) return [];
const rules = await this.feeRuleRepository.findAll({ where: { isActive: true } });
const now = new Date();
@@ -894,6 +904,8 @@ export class WarehouseFeeService {
trucks.map(async (t) => {
const item: ItemAttributes = {
arrivedAt: null,
// Truck detention is a per-truck charge, never a warehouse backlog row.
backlogRegistration: false,
gateClearedAt: null,
releaseDate: null,
freightType: leg.freightType ?? null,

View File

@@ -14,8 +14,13 @@ import { FilterWarehouseInventoryDto } from './dto/filter-inventory.dto';
import { InquiryWarehouseInventoryDto } from './dto/inquiry-inventory.dto';
import { LoadInventoryDto } from './dto/load-inventory.dto';
import { MoveInventoryDto } from './dto/move-inventory.dto';
import { AssignSlotDto, FindAvailableSlotDto } from './dto/placement.dto';
import { StoreInventoryDto } from './dto/store-inventory.dto';
import { ReceiveWarehouseInventoryDto } from './dto/receive-inventory.dto';
import {
BulkRegisterBacklogDto,
RegisterBacklogContainerDto,
} from './dto/register-backlog.dto';
import { ApproveDeliveryDto } from './dto/approve-delivery.dto';
import { SetDoubleHandlingDto } from './dto/double-handling.dto';
import { ReleaseOrderDto } from './dto/release-order.dto';
@@ -143,6 +148,25 @@ export class WarehouseInventoryController {
return this.inventoryService.eligibleBookings(dir);
}
@Post('register-backlog')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.receive)
@ApiOperation({
summary: 'Register one loaded container already in the yard but never entered in the system',
})
registerBacklog(@Body() dto: RegisterBacklogContainerDto, @CurrentUser() user: TCurrentUser) {
dto.performedBy = actorLabel(user) ?? dto.performedBy;
return this.inventoryService.registerBacklogContainer(dto);
}
@Post('register-backlog-bulk')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.receive)
@ApiOperation({ summary: 'Bulk-register loaded containers already in the yard (Excel backlog)' })
registerBacklogBulk(@Body() dto: BulkRegisterBacklogDto, @CurrentUser() user: TCurrentUser) {
const performedBy = actorLabel(user);
dto.containers.forEach((c) => (c.performedBy = performedBy ?? c.performedBy));
return this.inventoryService.bulkRegisterBacklogContainers(dto);
}
@Post('receive-bulk')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.receive)
@ApiOperation({ summary: 'Bulk-receive selected eligible PAID bookings into a location' })
@@ -369,6 +393,50 @@ export class WarehouseInventoryController {
return this.inventoryService.move(id, dto);
}
@Post('placement/find-slot')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.move)
@ApiOperation({
summary: 'Lowest free stack level for a container yard',
description: 'Read-only preview of where the placement engine would put the next container.',
})
findAvailableSlot(@Body() dto: FindAvailableSlotDto) {
return this.inventoryService.findAvailableSlot(dto);
}
@Post(':id/assign-slot')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.move)
@ApiOperation({
summary: 'Place inventory at an exact stack level',
description: 'Omit slotId to take the lowest free level in the item\'s current zone.',
})
assignSlot(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: AssignSlotDto,
@CurrentUser() user: TCurrentUser,
) {
return this.inventoryService.assignSlot(id, dto.slotId, actorLabel(user));
}
@Post(':id/release-slot')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.move)
@ApiOperation({
summary: 'Take inventory off its stack level',
description: 'Refused while other containers are stacked on top of it.',
})
releaseSlot(@Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: TCurrentUser) {
return this.inventoryService.releaseSlot(id, actorLabel(user));
}
@Get(':id/accessibility')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
@ApiOperation({
summary: 'Can this container be lifted out',
description: 'Lists the containers stacked above it. Nothing is moved.',
})
accessibility(@Param('id', ParseUUIDPipe) id: string) {
return this.inventoryService.getContainerAccessibility(id);
}
@Post(':id/store')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.move)
@ApiOperation({ summary: 'Mark received inventory as STORED (optional explicit warehouse/yard/zone)' })

View File

@@ -50,6 +50,10 @@ import { InquiryWarehouseInventoryDto } from './dto/inquiry-inventory.dto';
import { LoadInventoryDto } from './dto/load-inventory.dto';
import { MoveInventoryDto } from './dto/move-inventory.dto';
import { ReceiveWarehouseInventoryDto } from './dto/receive-inventory.dto';
import {
BulkRegisterBacklogDto,
RegisterBacklogContainerDto,
} from './dto/register-backlog.dto';
import { ReleaseOrderDto } from './dto/release-order.dto';
import { ReserveInventoryDto } from './dto/reserve-inventory.dto';
import { UnloadBookingDto } from './dto/unload-booking.dto';
@@ -70,6 +74,7 @@ import { Warehouse } from './entities/warehouse.entity';
import { SchedulingReadFacade } from './scheduling-read.facade';
import { WarehouseActivityLogService } from './warehouse-activity-log.service';
import { WarehouseInventoryRepository } from './warehouse-inventory.repository';
import { WarehousePlacementService } from './warehouse-placement.service';
import { WarehouseLoadingRepository } from './warehouse-loading.repository';
import { WarehouseReleaseDocumentService } from './warehouse-release-document.service';
import { HandoverService } from './handover.service';
@@ -412,6 +417,7 @@ export class WarehouseInventoryService {
private readonly activityLog: WarehouseActivityLogService,
private readonly scheduling: SchedulingReadFacade,
private readonly allocation: WarehouseAllocationService,
private readonly placement: WarehousePlacementService,
private readonly invoices: WarehouseInvoiceService,
private readonly inspectionService: WarehouseInspectionService,
private readonly releaseDocuments: WarehouseReleaseDocumentService,
@@ -2863,6 +2869,136 @@ export class WarehouseInventoryService {
await this.lastMileService.acceptBooking(booking.reference);
}
/**
* Register a loaded container that is already physically in a yard but was
* never entered in the system. Unlike receive(), there is no booking, no
* truck entrance to record (nobody remembers the driver of a box that has sat
* for months) and the arrival is backdated to when it actually turned up.
*
* The row is flagged `backlogRegistration`, which keeps the fee engine off it
* entirely — see WarehouseFeeService.previewForInventory. Capacity is still
* charged, because the box does occupy the yard.
*/
async registerBacklogContainer(dto: RegisterBacklogContainerDto): Promise<WarehouseInventory> {
const id = await this.dataSource.transaction((manager) => this.saveBacklogContainer(manager, dto));
const saved = await this.inventoryRepository.findById(id);
if (!saved) throw new NotFoundException(`Inventory ${id} not found after registration`);
return saved;
}
/** The write itself, so single and bulk share one transaction each. */
private async saveBacklogContainer(
manager: EntityManager,
dto: RegisterBacklogContainerDto,
): Promise<string> {
const containerNumber = dto.containerNumber.trim().toUpperCase();
const arrivedAt = new Date(dto.arrivedAt);
if (Number.isNaN(arrivedAt.getTime())) {
throw new BadRequestException(`Arrival date "${dto.arrivedAt}" is not a valid date`);
}
if (arrivedAt.getTime() > Date.now()) {
throw new BadRequestException('Arrival date cannot be in the future');
}
{
const { warehouse, yard, zone } = await this.validateLocation(manager, dto);
const containerType = await manager.query(
`SELECT id FROM freight.container_types WHERE id = $1 AND deleted_at IS NULL`,
[dto.containerTypeId],
);
if (containerType.length === 0) {
throw new NotFoundException(`Container type ${dto.containerTypeId} not found`);
}
// container_number is UNIQUE — reuse the existing record rather than
// colliding, so a box seen before keeps one identity.
const containers = manager.getRepository(Container);
let container = await containers.findOne({ where: { containerNumber } });
if (container) {
const alreadyHeld = await manager.getRepository(WarehouseInventory).findOne({
where: { containerId: container.id, status: In(['RECEIVED', 'STORED', 'READY_FOR_PICKUP']) },
});
if (alreadyHeld) {
throw new BadRequestException(
`Container ${containerNumber} is already in the warehouse (status ${alreadyHeld.status})`,
);
}
} else {
container = await containers.save(
containers.create({
containerNumber,
containerTypeId: dto.containerTypeId,
sealNumber: dto.sealNumber?.trim() || null,
tareWeight: dto.tareWeight ?? 0,
maxGrossWeight: dto.maxGrossWeight ?? 0,
status: 'LOADED',
bookingId: null,
}),
);
}
const weight = Number(dto.weight) || 0;
const volume = Number(dto.volume) || 0;
this.assertCapacity('Warehouse', warehouse, weight, volume, 1);
this.assertCapacity('Yard', yard, weight, volume, 1);
this.assertCapacity('Zone', zone, weight, volume, 1);
const owner = dto.companyName?.trim() || null;
const grnNumber = this.generateGrnNumber('WH', 'BACKLOG', arrivedAt, owner);
const saved = await manager.getRepository(WarehouseInventory).save(
manager.getRepository(WarehouseInventory).create({
warehouseId: dto.warehouseId,
yardId: dto.yardId,
zoneId: dto.zoneId,
bookingId: null,
containerId: container.id,
companyId: dto.companyId ?? null,
companyName: owner,
quantity: 1,
weight,
volume: dto.volume ?? null,
grnNumber,
status: 'RECEIVED',
arrivedAt,
backlogRegistration: true,
notes: this.buildReceiveNote({
grnNumber,
notes:
dto.notes?.trim() ||
`Backlog registration — already in yard, arrived ${arrivedAt.toISOString().slice(0, 10)}`,
}),
}),
);
await this.applyCapacityDelta(manager, dto, weight, volume, 1);
return saved.id;
}
}
/**
* Bulk backlog registration. All-or-nothing: one bad row rejects the sheet,
* so a half-registered yard can never happen.
*/
async bulkRegisterBacklogContainers(dto: BulkRegisterBacklogDto): Promise<WarehouseInventory[]> {
const numbers = dto.containers.map((c) => c.containerNumber.trim().toUpperCase());
const seen = new Set<string>();
const repeated = [...new Set(numbers.filter((n) => (seen.has(n) ? true : (seen.add(n), false))))];
if (repeated.length > 0) {
throw new BadRequestException(`Container number(s) repeated in the upload: ${repeated.join(', ')}`);
}
const ids = await this.dataSource.transaction(async (manager) => {
const written: string[] = [];
for (const container of dto.containers) {
written.push(await this.saveBacklogContainer(manager, container));
}
return written;
});
return this.inventoryRepository.findAll({ where: { id: In(ids) } });
}
async receive(dto: ReceiveWarehouseInventoryDto): Promise<WarehouseInventory> {
const bookingDirection = dto.bookingId ? await this.getBookingDirection(dto.bookingId) : null;
await this.assertExportBookingPaid(dto.bookingId, bookingDirection);
@@ -2996,12 +3132,37 @@ export class WarehouseInventoryService {
if (
item.warehouseId === dto.warehouseId &&
item.yardId === dto.yardId &&
item.zoneId === dto.zoneId
item.zoneId === dto.zoneId &&
(item.slotId ?? null) === (dto.slotId ?? null)
) {
throw new BadRequestException('Destination location is the same as current location');
}
const { warehouse, yard, zone } = await this.validateLocation(manager, dto);
// Taking a box out of a stack is physically impossible while others stand
// on top of it — the same rule the release path enforces. Moving is one of
// those exits, so it is checked here rather than only at release.
if (item.slotId) {
await this.placement.assertAccessible(item.id, manager);
}
// A move that names a slot is validated against the hierarchy it claims;
// one that does not clears the old slot, because the box has left it.
if (dto.slotId) {
await this.placement.validateSlotForInventory(
{
slotId: dto.slotId,
warehouseId: dto.warehouseId,
yardId: dto.yardId,
zoneId: dto.zoneId,
inventoryId: item.id,
quantity: Number(item.quantity) || 0,
},
manager,
);
}
const weight = Number(item.weight) || 0;
const containerCount = item.containerId ? Math.round(Number(item.quantity) || 0) : 0;
@@ -3011,7 +3172,12 @@ export class WarehouseInventoryService {
if (item.yardId !== dto.yardId) {
this.assertCapacity('Yard', yard, weight, Number(item.volume) || 0, containerCount);
}
this.assertCapacity('Zone', zone, weight, Number(item.volume) || 0, containerCount);
// Skipped when the zone is unchanged: a slot-to-slot reshuffle inside one
// zone adds nothing to it, and a full zone would otherwise refuse to let
// its own containers be restacked.
if (item.zoneId !== dto.zoneId) {
this.assertCapacity('Zone', zone, weight, Number(item.volume) || 0, containerCount);
}
await this.applyCapacityDelta(
manager,
@@ -3030,6 +3196,14 @@ export class WarehouseInventoryService {
item.warehouseId = dto.warehouseId;
item.yardId = dto.yardId;
item.zoneId = dto.zoneId;
if (dto.slotId) {
const slot = await this.placement.resolveSlot(dto.slotId, manager);
item.stackId = slot.stackId;
item.slotId = slot.slotId;
} else {
item.stackId = null;
item.slotId = null;
}
if (dto.remarks?.trim()) {
const existingNotes = item.notes?.trim();
item.notes = existingNotes
@@ -3044,12 +3218,171 @@ export class WarehouseInventoryService {
return this.findById(movedId);
}
// ── Physical slot placement ──────────────────────────────────────────────
/**
* Pin an inventory item to an exact stack level, or let the placement engine
* pick the lowest free one. Transactional and locked: the row cannot be moved
* out from under the placement between validation and write.
*/
async assignSlot(id: string, slotId?: string, performedBy?: string): Promise<WarehouseInventory> {
await this.dataSource.transaction(async (manager) => {
const item = await manager.getRepository(WarehouseInventory).findOne({
where: { id },
lock: { mode: 'pessimistic_write' },
});
if (!item) throw new NotFoundException(`Inventory item ${id} not found`);
const criteria = await this.getInventoryAllocationCriteria(item);
const chosenSlotId =
slotId ??
(
await this.placement.findAvailableContainerSlot(
{ yardId: item.yardId, zoneId: item.zoneId, direction: criteria.tradeDirection },
manager,
)
)?.slotId;
if (!chosenSlotId) {
throw new BadRequestException('No free stack level is available in this zone');
}
const slot = await this.placement.validateSlotForInventory(
{
slotId: chosenSlotId,
warehouseId: item.warehouseId,
yardId: item.yardId,
zoneId: item.zoneId,
inventoryId: item.id,
quantity: Number(item.quantity) || 0,
},
manager,
);
await manager.getRepository(WarehouseInventory).update(id, {
stackId: slot.stackId,
slotId: slot.slotId,
notes: this.appendNote(item.notes, `Placed at ${slot.stackCode} level ${slot.level}`),
});
await this.activityLog.record(
{
activityType: 'INVENTORY_MOVED',
inventoryId: id,
warehouseId: item.warehouseId,
description: `Placed at ${slot.stackCode} level ${slot.level}`,
performedBy,
},
manager,
);
});
return this.findById(id);
}
/** Take the item off its stack level without moving it out of the zone. */
async releaseSlot(id: string, performedBy?: string): Promise<WarehouseInventory> {
await this.dataSource.transaction(async (manager) => {
const item = await manager.getRepository(WarehouseInventory).findOne({
where: { id },
lock: { mode: 'pessimistic_write' },
});
if (!item) throw new NotFoundException(`Inventory item ${id} not found`);
if (!item.slotId) return;
// Nothing may be standing on top of it — freeing a buried box would leave
// the containers above it floating over an empty level.
await this.placement.assertAccessible(id, manager);
await manager.getRepository(WarehouseInventory).update(id, { stackId: null, slotId: null });
await this.activityLog.record(
{
activityType: 'INVENTORY_MOVED',
inventoryId: id,
warehouseId: item.warehouseId,
description: 'Released from its stack level',
performedBy,
},
manager,
);
});
return this.findById(id);
}
/** Whether the box can be lifted out, and what is stacked on top of it if not. */
getContainerAccessibility(id: string) {
return this.placement.getContainerAccessibility(id);
}
/** Lowest free stack level for the given yard/zone, without assigning it. */
findAvailableSlot(input: {
yardId: string;
zoneId?: string;
direction?: string;
cargoTypeId?: string;
}) {
return this.placement.findAvailableContainerSlot(input);
}
/**
* The physical position an item should take when it is stored.
*
* An explicitly chosen slot is validated and any failure is surfaced — the
* operator asked for that exact level. The automatic path is best-effort:
* a yard with no stacks configured yet, or one that is full, falls back to
* plain zone-level storage rather than blocking a store that worked before
* this model existed.
*/
private async resolveStoragePlacement(
manager: EntityManager,
item: WarehouseInventory,
location: LocationRef,
options: { slotId?: string; direction?: string | null },
): Promise<{ stackId: string; slotId: string; label: string } | null> {
const yard = await manager.getRepository(WarehouseYard).findOne({ where: { id: location.yardId } });
if (yard?.type !== 'CONTAINER_YARD') return null;
if (options.slotId) {
const slot = await this.placement.validateSlotForInventory(
{
slotId: options.slotId,
warehouseId: location.warehouseId,
yardId: location.yardId,
zoneId: location.zoneId,
inventoryId: item.id,
quantity: Number(item.quantity) || 0,
},
manager,
);
return { stackId: slot.stackId, slotId: slot.slotId, label: `${slot.stackCode} level ${slot.level}` };
}
// A row still covering several containers has no single position to take.
if ((Number(item.quantity) || 0) > 1) return null;
try {
const found = await this.placement.findAvailableContainerSlot(
{ yardId: location.yardId, zoneId: location.zoneId, direction: options.direction },
manager,
);
return found
? { stackId: found.stackId, slotId: found.slotId, label: `${found.stackCode} level ${found.level}` }
: null;
} catch (error) {
this.logger.debug(
`Automatic slot placement skipped for inventory ${item.id}: ${(error as Error).message}`,
);
return null;
}
}
// ── Lifecycle transitions ────────────────────────────────────────────────
async store(
id: string,
performedBy?: string,
chosen?: { warehouseId?: string; yardId?: string; zoneId?: string },
chosen?: { warehouseId?: string; yardId?: string; zoneId?: string; slotId?: string },
): Promise<WarehouseInventory> {
const item = await this.findById(id);
this.assertTransition(item.status, 'STORED');
@@ -3113,11 +3446,17 @@ export class WarehouseInventoryService {
await this.applyCapacityDelta(manager, location, weight, volume, containerCount);
}
const storedReason = manualLocation
const placed = await this.resolveStoragePlacement(manager, locked, location, {
slotId: chosen?.slotId,
direction: criteria.tradeDirection,
});
const baseReason = manualLocation
? `Stored at operator-selected location -> ${location.path ?? 'chosen yard/zone'}`
: ruleLocation?.rule
? `Stored by allocation rule "${ruleLocation.rule.name}" -> ${ruleLocation.path}`
: `Stored by capacity-balanced allocation -> ${location.path ?? 'assigned yard/zone'}`;
const storedReason = placed ? `${baseReason} @ ${placed.label}` : baseReason;
await manager.getRepository(WarehouseInventory).update(id, {
status: 'STORED',
@@ -3125,6 +3464,8 @@ export class WarehouseInventoryService {
warehouseId: location.warehouseId,
yardId: location.yardId,
zoneId: location.zoneId,
stackId: placed?.stackId ?? null,
slotId: placed?.slotId ?? null,
notes: this.appendNote(locked.notes, storedReason),
});
@@ -6029,6 +6370,18 @@ export class WarehouseInventoryService {
if (!yard) throw new NotFoundException(`Yard ${dto.yardId} not found`);
const zone = await manager.getRepository(WarehouseZone).findOne({ where: { id: dto.zoneId } });
if (!zone) throw new NotFoundException(`Zone ${dto.zoneId} not found`);
// The three ids arrive independently from the client, so they have to be
// checked against each other: a zone belonging to another yard would send
// the item to a location that does not exist on the ground, and every
// capacity counter above it would be adjusted on the wrong row.
if (zone.yardId !== yard.id) {
throw new BadRequestException(`Zone ${zone.code} does not belong to yard ${yard.code}`);
}
if (yard.warehouseId !== warehouse.id) {
throw new BadRequestException(`Yard ${yard.code} does not belong to warehouse ${warehouse.code}`);
}
return { warehouse, yard, zone };
}

View File

@@ -0,0 +1,644 @@
import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource, EntityManager } from 'typeorm';
import {
SLOT_OCCUPYING_STATUSES,
WarehouseInventory,
} from './entities/warehouse-inventory.entity';
import { SlotEffectiveStatus } from './entities/warehouse-zone-slot.entity';
/** The whole chain above one slot, resolved server-side in a single join. */
export interface SlotHierarchy {
slotId: string;
slotStatus: string;
slotIsActive: boolean;
level: number;
stackId: string;
stackCode: string;
stackStatus: string;
stackIsActive: boolean;
maxStackHeight: number;
zoneId: string;
zoneCode: string;
zoneType: string;
zoneStatus: string;
zoneIsActive: boolean;
yardId: string;
yardCode: string;
yardType: string;
yardDirection: string | null;
yardStatus: string;
yardIsActive: boolean;
warehouseId: string;
warehouseCode: string;
warehouseStatus: string;
warehouseIsActive: boolean;
}
export interface AvailableSlot {
slotId: string;
stackId: string;
stackCode: string;
level: number;
zoneId: string;
zoneCode: string;
}
export interface FindSlotInput {
yardId: string;
zoneId?: string | null;
/** IMPORT | EXPORT — matched against the yard's direction (null = BOTH). */
direction?: string | null;
cargoTypeId?: string | null;
}
export interface BlockingContainer {
inventoryId: string;
containerNumber: string | null;
level: number;
status: string;
}
export interface ContainerAccessibility {
accessible: boolean;
inventoryId: string;
stackCode: string | null;
level: number | null;
blockingContainers: BlockingContainer[];
}
export interface SlotSummary {
configuredCapacity: number | null;
physicalSlotCount: number;
occupiedSlotCount: number;
reservedSlotCount: number;
blockedSlotCount: number;
inactiveSlotCount: number;
availableSlotCount: number;
/** True when more physical slots are built than the configured capacity allows. */
inconsistent: boolean;
}
export interface ZoneLayoutSlot {
slotId: string;
level: number;
effectiveStatus: SlotEffectiveStatus;
inventoryId: string | null;
containerNumber: string | null;
}
export interface ZoneLayoutStack {
stackId: string;
code: string;
name: string | null;
maxStackHeight: number;
status: string;
isActive: boolean;
slots: ZoneLayoutSlot[];
}
export interface ZoneLayout {
zoneId: string;
zoneCode: string;
zoneName: string;
stacks: ZoneLayoutStack[];
summary: SlotSummary;
}
/**
* Container identity has two sources and neither covers the other: a backlog
* registration points `warehouse_inventory.container_id` at a `containers` row,
* while booked cargo carries its numbers on `booking_container_units`. Scalar
* subselects rather than joins, so one slot can never fan out into many rows.
* A booking whose units were never split into one inventory row each shows the
* first unit number — placement refuses such rows anyway (see assertSingleUnit).
*/
const CONTAINER_NUMBER_EXPR = `COALESCE(
(SELECT c.container_number FROM freight.containers c
WHERE c.id = i.container_id AND c.deleted_at IS NULL),
(SELECT bcu.container_number FROM freight.booking_container_units bcu
JOIN freight.booking_container bc ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
WHERE bc.booking_id = i.booking_id AND bcu.deleted_at IS NULL
ORDER BY bcu.container_number
LIMIT 1)
)`;
/** A row is "standing in its slot" only in these statuses — same list as the DB's partial unique index. */
const OCCUPYING = SLOT_OCCUPYING_STATUSES as unknown as string[];
/**
* Physical container placement: the stage after allocation. Allocation picks a
* yard (and maybe a zone) from configured rules; this picks the exact stack and
* level, enforces the stacking rules, and answers whether a box can be reached.
*
* Nothing here is called for a non-container yard — bulk, general cargo,
* hazardous and cold storage keep zone-level placement.
*/
@Injectable()
export class WarehousePlacementService {
constructor(@InjectDataSource() private readonly dataSource: DataSource) {}
private em(manager?: EntityManager): EntityManager | DataSource {
return manager ?? this.dataSource;
}
// ── Hierarchy ─────────────────────────────────────────────────────────────
/**
* Resolve a slot's full chain up to the warehouse. Ids arriving from a client
* are never trusted against one another — this is the one place the chain is
* established, and every caller compares against what comes back here.
*/
async resolveSlot(slotId: string, manager?: EntityManager): Promise<SlotHierarchy> {
const [row] = await this.em(manager).query(
`SELECT sl.id AS "slotId", sl.status AS "slotStatus", sl.is_active AS "slotIsActive",
sl.level AS "level",
s.id AS "stackId", s.code AS "stackCode", s.status AS "stackStatus",
s.is_active AS "stackIsActive", s.max_stack_height AS "maxStackHeight",
z.id AS "zoneId", z.code AS "zoneCode", z.type AS "zoneType",
z.status AS "zoneStatus", z.is_active AS "zoneIsActive",
y.id AS "yardId", y.code AS "yardCode", y.type AS "yardType",
y.direction AS "yardDirection", y.status AS "yardStatus", y.is_active AS "yardIsActive",
w.id AS "warehouseId", w.code AS "warehouseCode",
w.status AS "warehouseStatus", w.is_active AS "warehouseIsActive"
FROM freight.warehouse_zone_slots sl
JOIN freight.warehouse_zone_stacks s ON s.id = sl.stack_id AND s.deleted_at IS NULL
JOIN freight.warehouse_zones z ON z.id = s.zone_id AND z.deleted_at IS NULL
JOIN freight.warehouse_yards y ON y.id = z.yard_id AND y.deleted_at IS NULL
JOIN freight.warehouses w ON w.id = y.warehouse_id AND w.deleted_at IS NULL
WHERE sl.id = $1 AND sl.deleted_at IS NULL`,
[slotId],
);
if (!row) throw new NotFoundException(`Slot ${slotId} not found`);
return row as SlotHierarchy;
}
/** Levels in a stack currently holding a container, lowest first. */
async occupiedLevels(
stackId: string,
excludeInventoryId?: string | null,
manager?: EntityManager,
): Promise<number[]> {
const rows: Array<{ level: number }> = await this.em(manager).query(
`SELECT sl.level AS "level"
FROM freight.warehouse_zone_slots sl
JOIN freight.warehouse_inventory i
ON i.slot_id = sl.id AND i.deleted_at IS NULL AND i.status = ANY($2)
WHERE sl.stack_id = $1 AND sl.deleted_at IS NULL
AND ($3::uuid IS NULL OR i.id <> $3::uuid)
ORDER BY sl.level`,
[stackId, OCCUPYING, excludeInventoryId ?? null],
);
return rows.map((r) => Number(r.level));
}
// ── Placement validation ──────────────────────────────────────────────────
/**
* Every check that must pass before a container may stand in a slot, in the
* order a yard operator would hit them. Returns the resolved hierarchy so the
* caller writes ids it did not invent.
*/
async validateSlotForInventory(
input: {
slotId: string;
warehouseId: string;
yardId: string;
zoneId: string;
/** Excluded from occupancy checks — the row being moved is allowed to leave its own slot. */
inventoryId?: string | null;
quantity?: number | null;
},
manager?: EntityManager,
): Promise<SlotHierarchy> {
const slot = await this.resolveSlot(input.slotId, manager);
// 1. Hierarchy — the client may not staple a slot onto an unrelated zone/yard/warehouse.
if (slot.zoneId !== input.zoneId) {
throw new BadRequestException(
`Slot ${slot.stackCode}/L${slot.level} belongs to zone ${slot.zoneCode}, not the zone given`,
);
}
if (slot.yardId !== input.yardId) {
throw new BadRequestException(`Zone ${slot.zoneCode} belongs to yard ${slot.yardCode}, not the yard given`);
}
if (slot.warehouseId !== input.warehouseId) {
throw new BadRequestException(
`Yard ${slot.yardCode} belongs to warehouse ${slot.warehouseCode}, not the warehouse given`,
);
}
// 2. Every level of the chain has to be operationally open.
this.assertOperational('Warehouse', slot.warehouseCode, slot.warehouseStatus, slot.warehouseIsActive);
this.assertOperational('Yard', slot.yardCode, slot.yardStatus, slot.yardIsActive);
this.assertOperational('Zone', slot.zoneCode, slot.zoneStatus, slot.zoneIsActive);
this.assertOperational('Stack', slot.stackCode, slot.stackStatus, slot.stackIsActive);
if (!slot.slotIsActive) {
throw new BadRequestException(`Slot ${slot.stackCode}/L${slot.level} is inactive`);
}
// RESERVED is accepted: a slot is reserved *for* the box now arriving.
if (slot.slotStatus !== 'AVAILABLE' && slot.slotStatus !== 'RESERVED') {
throw new BadRequestException(`Slot ${slot.stackCode}/L${slot.level} is ${slot.slotStatus}`);
}
// 3. One box per slot. The DB's partial unique index is the backstop; this
// is the readable error the operator actually gets.
const [taken] = await this.em(manager).query(
`SELECT i.id FROM freight.warehouse_inventory i
WHERE i.slot_id = $1 AND i.deleted_at IS NULL AND i.status = ANY($2)
AND ($3::uuid IS NULL OR i.id <> $3::uuid)
LIMIT 1`,
[input.slotId, OCCUPYING, input.inventoryId ?? null],
);
if (taken) {
throw new ConflictException(`Slot ${slot.stackCode}/L${slot.level} is already occupied`);
}
if (slot.level > slot.maxStackHeight) {
throw new BadRequestException(
`Level ${slot.level} is above stack ${slot.stackCode}'s maximum height of ${slot.maxStackHeight}`,
);
}
// 4. Container yards only: no box may float above an empty level, and a row
// covering several containers has no single physical position.
if (slot.yardType === 'CONTAINER_YARD') {
this.assertSingleUnit(input.quantity);
const occupied = await this.occupiedLevels(slot.stackId, input.inventoryId ?? null, manager);
this.assertStackable(slot, occupied);
}
return slot;
}
private assertOperational(label: string, code: string, status: string, isActive: boolean): void {
if (status !== 'ACTIVE' || !isActive) {
throw new BadRequestException(`${label} ${code} is not active`);
}
}
/**
* A slot is one container. A row that still carries several boxes has no
* single position — split it before placing it, rather than silently pinning
* five containers to one level.
*/
private assertSingleUnit(quantity?: number | null): void {
const qty = Number(quantity ?? 1);
if (qty > 1) {
throw new BadRequestException(
`This inventory row covers ${qty} containers. Split it into one row per container before assigning a slot.`,
);
}
}
/** Level N needs every level below it filled — nothing hovers. */
assertStackable(slot: Pick<SlotHierarchy, 'level' | 'stackCode'>, occupiedLevels: number[]): void {
if (slot.level === 1) return;
const missing: number[] = [];
for (let level = 1; level < slot.level; level += 1) {
if (!occupiedLevels.includes(level)) missing.push(level);
}
if (missing.length > 0) {
throw new BadRequestException(
`Stack ${slot.stackCode}: level ${slot.level} cannot be filled while level(s) ${missing.join(', ')} are empty`,
);
}
}
// ── Finding a slot ────────────────────────────────────────────────────────
/**
* Lowest valid free level, deterministic: zone code, then stack code, then
* level. Bottom-up by construction — a stack's candidate level is always one
* above its current top, so level 2 can never be picked before level 1.
*
* Isolated on purpose: a smarter strategy (weight, direction, dwell time)
* swaps in here without touching any caller.
*/
async findAvailableContainerSlot(input: FindSlotInput, manager?: EntityManager): Promise<AvailableSlot | null> {
const yard = await this.loadYardForPlacement(input, manager);
const zoneIds = await this.candidateZoneIds(yard.id, input.zoneId ?? null, manager);
if (zoneIds.length === 0) return null;
const [slot] = await this.em(manager).query(
`SELECT sl.id AS "slotId", s.id AS "stackId", s.code AS "stackCode",
sl.level AS "level", z.id AS "zoneId", z.code AS "zoneCode"
FROM freight.warehouse_zone_stacks s
JOIN freight.warehouse_zones z ON z.id = s.zone_id AND z.deleted_at IS NULL
CROSS JOIN LATERAL (
SELECT COALESCE(MAX(sl2.level), 0) AS top
FROM freight.warehouse_zone_slots sl2
JOIN freight.warehouse_inventory i2
ON i2.slot_id = sl2.id AND i2.deleted_at IS NULL AND i2.status = ANY($2)
WHERE sl2.stack_id = s.id AND sl2.deleted_at IS NULL
) occ
JOIN freight.warehouse_zone_slots sl
ON sl.stack_id = s.id AND sl.deleted_at IS NULL
AND sl.level = occ.top + 1
AND sl.status = 'AVAILABLE' AND sl.is_active = true
WHERE s.zone_id = ANY($1::uuid[])
AND s.deleted_at IS NULL AND s.status = 'ACTIVE' AND s.is_active = true
AND occ.top < s.max_stack_height
ORDER BY z.code, s.code, sl.level
LIMIT 1`,
[zoneIds, OCCUPYING],
);
return (slot as AvailableSlot) ?? null;
}
/** Yard gates: active, a container yard, right direction, right cargo type. */
private async loadYardForPlacement(
input: FindSlotInput,
manager?: EntityManager,
): Promise<{ id: string; code: string }> {
const [yard] = await this.em(manager).query(
`SELECT y.id, y.code, y.type, y.direction, y.status, y.is_active AS "isActive",
y.capacity_containers AS "capacityContainers", y.current_containers AS "currentContainers",
w.status AS "warehouseStatus", w.is_active AS "warehouseIsActive", w.code AS "warehouseCode"
FROM freight.warehouse_yards y
JOIN freight.warehouses w ON w.id = y.warehouse_id AND w.deleted_at IS NULL
WHERE y.id = $1 AND y.deleted_at IS NULL`,
[input.yardId],
);
if (!yard) throw new NotFoundException(`Yard ${input.yardId} not found`);
this.assertOperational('Warehouse', yard.warehouseCode, yard.warehouseStatus, yard.warehouseIsActive);
this.assertOperational('Yard', yard.code, yard.status, yard.isActive);
if (yard.type !== 'CONTAINER_YARD') {
throw new BadRequestException(`Yard ${yard.code} is a ${yard.type}; container stacking does not apply`);
}
// Null direction has always meant "takes both" — never treat it as invalid.
const yardDirection = yard.direction ?? 'BOTH';
const wanted = input.direction ?? 'BOTH';
if (yardDirection !== 'BOTH' && wanted !== 'BOTH' && yardDirection !== wanted) {
throw new BadRequestException(`Yard ${yard.code} serves ${yardDirection} traffic, not ${wanted}`);
}
// Empty cargo-type relation = open to any cargo. Preserved deliberately.
if (input.cargoTypeId) {
const [{ allowed }] = await this.em(manager).query(
`SELECT (NOT EXISTS (SELECT 1 FROM freight.warehouse_yard_cargo_types t WHERE t.yard_id = $1)
OR EXISTS (SELECT 1 FROM freight.warehouse_yard_cargo_types t
WHERE t.yard_id = $1 AND t.cargo_type_id = $2)) AS allowed`,
[yard.id, input.cargoTypeId],
);
if (!allowed) {
throw new BadRequestException(`Yard ${yard.code} does not accept this cargo type`);
}
}
if (yard.capacityContainers != null && Number(yard.currentContainers) >= Number(yard.capacityContainers)) {
throw new BadRequestException(
`Yard ${yard.code} is at its configured capacity (${yard.currentContainers}/${yard.capacityContainers})`,
);
}
return { id: yard.id, code: yard.code };
}
/** Active container zones in the yard with configured capacity left, in code order. */
private async candidateZoneIds(
yardId: string,
zoneId: string | null,
manager?: EntityManager,
): Promise<string[]> {
const rows: Array<{ id: string }> = await this.em(manager).query(
`SELECT z.id
FROM freight.warehouse_zones z
WHERE z.yard_id = $1 AND z.deleted_at IS NULL
AND z.status = 'ACTIVE' AND z.is_active = true
AND z.type = 'CONTAINER_ZONE'
AND (z.capacity_containers IS NULL OR z.current_containers < z.capacity_containers)
AND ($2::uuid IS NULL OR z.id = $2::uuid)
ORDER BY z.code`,
[yardId, zoneId],
);
return rows.map((r) => r.id);
}
// ── Accessibility ─────────────────────────────────────────────────────────
/**
* Whether a box can be taken out without touching anything else. Containers
* standing above it block it; nothing is moved to clear the way — a
* relocation is an operator decision, not a side effect of a read.
*/
async getContainerAccessibility(inventoryId: string, manager?: EntityManager): Promise<ContainerAccessibility> {
const [placed] = await this.em(manager).query(
`SELECT i.id AS "inventoryId", sl.level AS "level", s.id AS "stackId", s.code AS "stackCode"
FROM freight.warehouse_inventory i
LEFT JOIN freight.warehouse_zone_slots sl ON sl.id = i.slot_id AND sl.deleted_at IS NULL
LEFT JOIN freight.warehouse_zone_stacks s ON s.id = sl.stack_id AND s.deleted_at IS NULL
WHERE i.id = $1 AND i.deleted_at IS NULL`,
[inventoryId],
);
if (!placed) throw new NotFoundException(`Inventory item ${inventoryId} not found`);
// No slot = zone-level placement (bulk, or an item that predates the model):
// nothing is stacked on it, so it is reachable.
if (!placed.stackId) {
return { accessible: true, inventoryId, stackCode: null, level: null, blockingContainers: [] };
}
const blocking: BlockingContainer[] = await this.em(manager).query(
`SELECT i.id AS "inventoryId", sl.level AS "level", i.status AS "status",
${CONTAINER_NUMBER_EXPR} AS "containerNumber"
FROM freight.warehouse_zone_slots sl
JOIN freight.warehouse_inventory i
ON i.slot_id = sl.id AND i.deleted_at IS NULL AND i.status = ANY($3)
WHERE sl.stack_id = $1 AND sl.deleted_at IS NULL AND sl.level > $2
ORDER BY sl.level DESC`,
[placed.stackId, Number(placed.level), OCCUPYING],
);
return {
accessible: blocking.length === 0,
inventoryId,
stackCode: placed.stackCode,
level: Number(placed.level),
blockingContainers: blocking.map((b) => ({ ...b, level: Number(b.level) })),
};
}
/** Refuse to hand out a box that is buried — used by the exit/delivery paths. */
async assertAccessible(inventoryId: string, manager?: EntityManager): Promise<void> {
const access = await this.getContainerAccessibility(inventoryId, manager);
if (!access.accessible) {
const above = access.blockingContainers
.map((b) => `${b.containerNumber ?? b.inventoryId} (L${b.level})`)
.join(', ');
throw new ConflictException(
`Container is at ${access.stackCode}/L${access.level} with ${above} stacked above it. Relocate those first.`,
);
}
}
// ── Reads ─────────────────────────────────────────────────────────────────
/** Physical layout of one zone: every stack, every level, what stands there. */
async zoneLayout(zoneId: string, manager?: EntityManager): Promise<ZoneLayout> {
const [zone] = await this.em(manager).query(
`SELECT z.id, z.code, z.name, z.capacity_containers AS "capacityContainers"
FROM freight.warehouse_zones z WHERE z.id = $1 AND z.deleted_at IS NULL`,
[zoneId],
);
if (!zone) throw new NotFoundException(`Warehouse zone ${zoneId} not found`);
const rows: Array<{
stackId: string;
code: string;
name: string | null;
maxStackHeight: number;
stackStatus: string;
stackIsActive: boolean;
slotId: string | null;
level: number | null;
slotStatus: string | null;
slotIsActive: boolean | null;
inventoryId: string | null;
containerNumber: string | null;
}> = await this.em(manager).query(
`SELECT s.id AS "stackId", s.code AS "code", s.name AS "name",
s.max_stack_height AS "maxStackHeight", s.status AS "stackStatus",
s.is_active AS "stackIsActive",
sl.id AS "slotId", sl.level AS "level", sl.status AS "slotStatus",
sl.is_active AS "slotIsActive",
i.id AS "inventoryId",
CASE WHEN i.id IS NULL THEN NULL ELSE ${CONTAINER_NUMBER_EXPR} END AS "containerNumber"
FROM freight.warehouse_zone_stacks s
LEFT JOIN freight.warehouse_zone_slots sl ON sl.stack_id = s.id AND sl.deleted_at IS NULL
LEFT JOIN freight.warehouse_inventory i
ON i.slot_id = sl.id AND i.deleted_at IS NULL AND i.status = ANY($2)
WHERE s.zone_id = $1 AND s.deleted_at IS NULL
ORDER BY s.code, sl.level DESC`,
[zoneId, OCCUPYING],
);
const stacks = new Map<string, ZoneLayoutStack>();
for (const row of rows) {
let stack = stacks.get(row.stackId);
if (!stack) {
stack = {
stackId: row.stackId,
code: row.code,
name: row.name,
maxStackHeight: Number(row.maxStackHeight),
status: row.stackStatus,
isActive: row.stackIsActive,
slots: [],
};
stacks.set(row.stackId, stack);
}
if (row.slotId) {
stack.slots.push({
slotId: row.slotId,
level: Number(row.level),
effectiveStatus: this.effectiveStatus(row.slotStatus, row.slotIsActive, row.inventoryId),
inventoryId: row.inventoryId,
containerNumber: row.containerNumber,
});
}
}
return {
zoneId: zone.id,
zoneCode: zone.code,
zoneName: zone.name,
stacks: [...stacks.values()],
summary: await this.slotSummary({ zoneId }, manager),
};
}
private effectiveStatus(
status: string | null,
isActive: boolean | null,
inventoryId: string | null,
): SlotEffectiveStatus {
if (inventoryId) return 'OCCUPIED';
if (isActive === false) return 'INACTIVE';
return (status as SlotEffectiveStatus) ?? 'AVAILABLE';
}
/**
* The three numbers that are routinely confused: what was configured, what is
* physically built, and what is actually full. Configured capacity is never
* overwritten from the slot count — a mismatch is reported, not corrected.
*/
async slotSummary(
scope: { zoneId?: string; yardId?: string },
manager?: EntityManager,
): Promise<SlotSummary> {
if (!scope.zoneId && !scope.yardId) {
throw new BadRequestException('A zone or yard is required');
}
const [row] = await this.em(manager).query(
`SELECT
(SELECT SUM(z.capacity_containers)
FROM freight.warehouse_zones z
WHERE z.deleted_at IS NULL
AND ($1::uuid IS NULL OR z.id = $1::uuid)
AND ($2::uuid IS NULL OR z.yard_id = $2::uuid)) AS "configuredCapacity",
COUNT(sl.id) AS "physicalSlotCount",
COUNT(i.id) AS "occupiedSlotCount",
COUNT(*) FILTER (WHERE i.id IS NULL AND sl.is_active AND sl.status = 'RESERVED') AS "reservedSlotCount",
COUNT(*) FILTER (WHERE i.id IS NULL AND sl.is_active AND sl.status = 'BLOCKED') AS "blockedSlotCount",
COUNT(*) FILTER (WHERE sl.id IS NOT NULL AND (NOT sl.is_active OR sl.status = 'INACTIVE'))
AS "inactiveSlotCount",
COUNT(*) FILTER (WHERE i.id IS NULL AND sl.is_active AND sl.status = 'AVAILABLE'
AND s.status = 'ACTIVE' AND s.is_active) AS "availableSlotCount"
FROM freight.warehouse_zones z
JOIN freight.warehouse_zone_stacks s ON s.zone_id = z.id AND s.deleted_at IS NULL
LEFT JOIN freight.warehouse_zone_slots sl ON sl.stack_id = s.id AND sl.deleted_at IS NULL
LEFT JOIN freight.warehouse_inventory i
ON i.slot_id = sl.id AND i.deleted_at IS NULL AND i.status = ANY($3)
WHERE z.deleted_at IS NULL
AND ($1::uuid IS NULL OR z.id = $1::uuid)
AND ($2::uuid IS NULL OR z.yard_id = $2::uuid)`,
[scope.zoneId ?? null, scope.yardId ?? null, OCCUPYING],
);
const configuredCapacity = row?.configuredCapacity == null ? null : Number(row.configuredCapacity);
const physicalSlotCount = Number(row?.physicalSlotCount ?? 0);
return {
configuredCapacity,
physicalSlotCount,
occupiedSlotCount: Number(row?.occupiedSlotCount ?? 0),
reservedSlotCount: Number(row?.reservedSlotCount ?? 0),
blockedSlotCount: Number(row?.blockedSlotCount ?? 0),
inactiveSlotCount: Number(row?.inactiveSlotCount ?? 0),
availableSlotCount: Number(row?.availableSlotCount ?? 0),
inconsistent: configuredCapacity != null && physicalSlotCount > configuredCapacity,
};
}
/** Free a slot explicitly. Exit paths don't need this — status alone frees it. */
async releaseSlot(inventoryId: string, manager?: EntityManager): Promise<void> {
await this.em(manager).query(
`UPDATE freight.warehouse_inventory
SET stack_id = NULL, slot_id = NULL, updated_at = now()
WHERE id = $1 AND deleted_at IS NULL`,
[inventoryId],
);
}
/** Write a validated placement onto an inventory row inside the caller's transaction. */
async applyPlacement(
manager: EntityManager,
inventoryId: string,
placement: { stackId: string; slotId: string } | null,
): Promise<void> {
await manager.getRepository(WarehouseInventory).update(inventoryId, {
stackId: placement?.stackId ?? null,
slotId: placement?.slotId ?? null,
});
}
}

View File

@@ -1,4 +1,4 @@
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post } from '@nestjs/common';
import { Body, Controller, Delete, Get, Param, ParseUUIDPipe, Patch, Post } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { BookingStaff } from '../../common/booking-guards';
@@ -40,6 +40,16 @@ export class WarehouseYardsController {
return this.yardsService.update(id, dto);
}
@Delete(':id')
@BookingStaff(FREIGHT_PERMS.warehouseYards.delete)
@ApiOperation({
summary: 'Delete warehouse yard',
description: 'Soft-deletes the yard. Refused while it still has zones.',
})
remove(@Param('id', ParseUUIDPipe) id: string) {
return this.yardsService.remove(id);
}
@Get(':yardId/zones')
@BookingStaff(FREIGHT_PERMS.warehouseZones.view)
@ApiOperation({ summary: 'List zones within a yard' })

View File

@@ -107,6 +107,24 @@ export class WarehouseYardsService {
return this.findById(id);
}
/**
* Soft-delete a yard. Zones (and the inventory sitting in them) are left
* alone — a yard still holding zones is refused rather than orphaning stock.
*/
async remove(id: string): Promise<{ id: string; deleted: true }> {
const existing = await this.findById(id);
if (existing.zones?.length) {
throw new ConflictException(
`Yard ${existing.code} still has ${existing.zones.length} zone(s). Delete them first.`,
);
}
await this.yardsRepository.softDelete(id);
return { id, deleted: true };
}
private async assertCodeUnique(warehouseId: string, code: string, ignoreId?: string): Promise<void> {
const [existing] = await this.yardsRepository.findAll({ where: { warehouseId, code } });

View File

@@ -0,0 +1,13 @@
import { BaseRepository } from '@edr/api-common';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { WarehouseZoneSlot } from './entities/warehouse-zone-slot.entity';
@Injectable()
export class WarehouseZoneSlotsRepository extends BaseRepository<WarehouseZoneSlot> {
constructor(@InjectRepository(WarehouseZoneSlot) repository: Repository<WarehouseZoneSlot>) {
super(repository);
}
}

View File

@@ -0,0 +1,102 @@
import {
BadRequestException,
Body,
Controller,
Delete,
Get,
Param,
ParseUUIDPipe,
Patch,
Post,
Query,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { BookingStaff } from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import {
CreateWarehouseZoneStackDto,
UpdateWarehouseZoneSlotDto,
UpdateWarehouseZoneStackDto,
} from './dto/warehouse-zone-stack.dto';
import { WarehouseZoneStacksService } from './warehouse-zone-stacks.service';
/**
* Stacks and slots are zone configuration, so they ride the warehouse-zone
* permissions rather than introducing new keys — a new key needs a matching
* `iam.permissions` row in every environment or boot fails.
*/
@ApiTags('warehouse-zone-stacks')
@ApiBearerAuth()
@Controller('warehouse-zone-stacks')
// Class gate lists every key its routes use: Nest runs class AND method guards.
@BookingStaff([
FREIGHT_PERMS.warehouseZones.view,
FREIGHT_PERMS.warehouseInventory.view,
FREIGHT_PERMS.warehouseZones.create,
FREIGHT_PERMS.warehouseZones.update,
FREIGHT_PERMS.warehouseZones.delete,
])
export class WarehouseZoneStacksController {
constructor(private readonly stacksService: WarehouseZoneStacksService) {}
@Get()
@ApiOperation({ summary: 'List the ground stacks configured in a zone' })
findByZone(@Query('zoneId', ParseUUIDPipe) zoneId: string) {
return this.stacksService.findByZone(zoneId);
}
@Post()
@BookingStaff(FREIGHT_PERMS.warehouseZones.create)
@ApiOperation({
summary: 'Create a ground stack',
description: 'One slot per level is generated automatically, from 1 to maxStackHeight (default 3).',
})
create(@Body() dto: CreateWarehouseZoneStackDto, @Query('zoneId') zoneIdQuery?: string) {
const zoneId = dto.zoneId ?? zoneIdQuery;
if (!zoneId) {
throw new BadRequestException('zoneId is required');
}
return this.stacksService.create(zoneId, dto);
}
// Declared before ':id' so 'slots' is never swallowed as a stack id.
@Patch('slots/:slotId')
@BookingStaff(FREIGHT_PERMS.warehouseZones.update)
@ApiOperation({
summary: 'Block, reserve, or reactivate one slot',
description: 'Occupancy is derived from inventory and cannot be set here.',
})
updateSlot(@Param('slotId', ParseUUIDPipe) slotId: string, @Body() dto: UpdateWarehouseZoneSlotDto) {
return this.stacksService.updateSlot(slotId, dto);
}
@Get(':id')
@ApiOperation({ summary: 'Get one stack with its slots' })
findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.stacksService.findById(id);
}
@Get(':id/occupancy')
@ApiOperation({ summary: 'Level-by-level occupancy of one stack' })
occupancy(@Param('id', ParseUUIDPipe) id: string) {
return this.stacksService.slotOccupancy(id);
}
@Patch(':id')
@BookingStaff(FREIGHT_PERMS.warehouseZones.update)
@ApiOperation({
summary: 'Update a stack',
description: 'Raising maxStackHeight adds slots; lowering it trims the empty top levels.',
})
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateWarehouseZoneStackDto) {
return this.stacksService.update(id, dto);
}
@Delete(':id')
@BookingStaff(FREIGHT_PERMS.warehouseZones.delete)
@ApiOperation({ summary: 'Delete a stack', description: 'Refused while containers still stand in it.' })
remove(@Param('id', ParseUUIDPipe) id: string) {
return this.stacksService.remove(id);
}
}

View File

@@ -0,0 +1,13 @@
import { BaseRepository } from '@edr/api-common';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { WarehouseZoneStack } from './entities/warehouse-zone-stack.entity';
@Injectable()
export class WarehouseZoneStacksRepository extends BaseRepository<WarehouseZoneStack> {
constructor(@InjectRepository(WarehouseZoneStack) repository: Repository<WarehouseZoneStack>) {
super(repository);
}
}

View File

@@ -0,0 +1,245 @@
import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource, EntityManager, In, IsNull } from 'typeorm';
import {
CreateWarehouseZoneStackDto,
UpdateWarehouseZoneSlotDto,
UpdateWarehouseZoneStackDto,
} from './dto/warehouse-zone-stack.dto';
import {
DEFAULT_MAX_STACK_HEIGHT,
WarehouseZoneStack,
} from './entities/warehouse-zone-stack.entity';
import { WarehouseZoneSlot } from './entities/warehouse-zone-slot.entity';
import { WarehousePlacementService } from './warehouse-placement.service';
import { WarehouseZoneSlotsRepository } from './warehouse-zone-slots.repository';
import { WarehouseZoneStacksRepository } from './warehouse-zone-stacks.repository';
import { WarehouseZonesService } from './warehouse-zones.service';
/**
* Ground stacks and their vertical slots — the physical layout of a zone.
*
* Slots are never created by hand: a stack of height 3 is three slots, so they
* are generated with the stack and kept in step with its height. That is the
* only way the placement engine can trust `level` to mean what it says.
*/
@Injectable()
export class WarehouseZoneStacksService {
constructor(
private readonly stacksRepository: WarehouseZoneStacksRepository,
private readonly slotsRepository: WarehouseZoneSlotsRepository,
private readonly zonesService: WarehouseZonesService,
private readonly placement: WarehousePlacementService,
@InjectDataSource() private readonly dataSource: DataSource,
) {}
findByZone(zoneId: string): Promise<WarehouseZoneStack[]> {
return this.stacksRepository.findAll({
where: { zoneId },
relations: { slots: true },
order: { code: 'ASC' },
});
}
async findById(id: string): Promise<WarehouseZoneStack> {
const stack = await this.stacksRepository.findById(id, { relations: { slots: true, zone: true } });
if (!stack) throw new NotFoundException(`Warehouse zone stack ${id} not found`);
stack.slots?.sort((a, b) => a.level - b.level);
return stack;
}
/** Create the stack and its slots together — a stack with no slots holds nothing. */
async create(zoneId: string, dto: CreateWarehouseZoneStackDto): Promise<WarehouseZoneStack> {
await this.zonesService.findById(zoneId);
const code = dto.code.trim();
await this.assertCodeUnique(zoneId, code);
const maxStackHeight = dto.maxStackHeight ?? DEFAULT_MAX_STACK_HEIGHT;
const id = await this.dataSource.transaction(async (manager) => {
const stack = await manager.getRepository(WarehouseZoneStack).save(
manager.getRepository(WarehouseZoneStack).create({
zoneId,
code,
name: dto.name?.trim() ?? null,
row: dto.row?.trim() ?? null,
bay: dto.bay?.trim() ?? null,
position: dto.position?.trim() ?? null,
maxStackHeight,
status: 'ACTIVE',
isActive: true,
}),
);
await this.generateSlots(manager, stack.id, 1, maxStackHeight);
return stack.id;
});
return this.findById(id);
}
async update(id: string, dto: UpdateWarehouseZoneStackDto): Promise<WarehouseZoneStack> {
const existing = await this.findById(id);
const code = dto.code?.trim() ?? existing.code;
if (code !== existing.code) {
await this.assertCodeUnique(existing.zoneId, code, id);
}
const newHeight = dto.maxStackHeight ?? existing.maxStackHeight;
const status = dto.status ?? existing.status;
if (status === 'INACTIVE' && existing.status !== 'INACTIVE') {
await this.assertStackEmpty(id, 'deactivated');
}
await this.dataSource.transaction(async (manager) => {
if (newHeight > existing.maxStackHeight) {
await this.generateSlots(manager, id, existing.maxStackHeight + 1, newHeight);
} else if (newHeight < existing.maxStackHeight) {
await this.removeSlotsAbove(manager, id, newHeight, existing.code);
}
await manager.getRepository(WarehouseZoneStack).update(id, {
code,
name: dto.name?.trim() ?? existing.name,
row: dto.row?.trim() ?? existing.row,
bay: dto.bay?.trim() ?? existing.bay,
position: dto.position?.trim() ?? existing.position,
maxStackHeight: newHeight,
status,
isActive: status === 'ACTIVE',
});
});
return this.findById(id);
}
/**
* Soft-delete a stack. Refused while anything stands in it — the boxes would
* be left pointing at a position every layout query drops.
*/
async remove(id: string): Promise<{ id: string; deleted: true }> {
const existing = await this.findById(id);
await this.assertStackEmpty(id, 'deleted');
await this.dataSource.transaction(async (manager) => {
await manager.getRepository(WarehouseZoneSlot).softDelete({ stackId: id });
await manager.getRepository(WarehouseZoneStack).softDelete(id);
});
return { id: existing.id, deleted: true };
}
/**
* Set operator intent on one slot. OCCUPIED is not settable — it is derived
* from the inventory sitting there — and a slot holding a box cannot be
* blocked or switched off underneath it.
*/
async updateSlot(slotId: string, dto: UpdateWarehouseZoneSlotDto): Promise<WarehouseZoneSlot> {
const slot = await this.slotsRepository.findById(slotId);
if (!slot) throw new NotFoundException(`Warehouse zone slot ${slotId} not found`);
const status = dto.status ?? slot.status;
const isActive = dto.isActive ?? (dto.status ? dto.status !== 'INACTIVE' : slot.isActive);
const closingOff = status === 'BLOCKED' || status === 'INACTIVE' || isActive === false;
if (closingOff) {
const [held] = await this.dataSource.query(
`SELECT i.id FROM freight.warehouse_inventory i
WHERE i.slot_id = $1 AND i.deleted_at IS NULL
AND i.status IN ('UNLOADED','RECEIVED','STORED','RESERVED','READY_FOR_LOADING','READY_FOR_PICKUP')
LIMIT 1`,
[slotId],
);
if (held) {
throw new ConflictException('Slot still holds a container. Move it out first.');
}
}
const updated = await this.slotsRepository.update(slotId, { status, isActive });
if (!updated) throw new NotFoundException(`Warehouse zone slot ${slotId} not found`);
return updated;
}
/** Occupancy of one stack, level by level. */
async slotOccupancy(stackId: string): Promise<
Array<{ slotId: string; level: number; effectiveStatus: string; inventoryId: string | null }>
> {
const stack = await this.findById(stackId);
const layout = await this.placement.zoneLayout(stack.zoneId);
const found = layout.stacks.find((s) => s.stackId === stackId);
return (found?.slots ?? []).map((s) => ({
slotId: s.slotId,
level: s.level,
effectiveStatus: s.effectiveStatus,
inventoryId: s.inventoryId,
}));
}
// ── internals ─────────────────────────────────────────────────────────────
/** Idempotent: a level that already exists (e.g. after a height cut and re-raise) is skipped. */
private async generateSlots(
manager: EntityManager,
stackId: string,
fromLevel: number,
toLevel: number,
): Promise<void> {
const repository = manager.getRepository(WarehouseZoneSlot);
const existing = await repository.find({ where: { stackId }, withDeleted: true });
const byLevel = new Map(existing.map((slot) => [slot.level, slot]));
for (let level = fromLevel; level <= toLevel; level += 1) {
const found = byLevel.get(level);
if (found?.deletedAt) {
// Bring a previously trimmed level back rather than colliding with the
// (stack_id, level) unique index.
await repository.restore(found.id);
await repository.update(found.id, { status: 'AVAILABLE', isActive: true });
} else if (!found) {
await repository.save(repository.create({ stackId, level, status: 'AVAILABLE', isActive: true }));
}
}
}
private async removeSlotsAbove(
manager: EntityManager,
stackId: string,
newHeight: number,
stackCode: string,
): Promise<void> {
const occupied = await this.placement.occupiedLevels(stackId, null, manager);
const stillUsed = occupied.filter((level) => level > newHeight);
if (stillUsed.length > 0) {
throw new BadRequestException(
`Stack ${stackCode}: level(s) ${stillUsed.join(', ')} still hold containers — cannot lower the height to ${newHeight}`,
);
}
const doomed = await manager.getRepository(WarehouseZoneSlot).find({
where: { stackId, deletedAt: IsNull() },
});
const ids = doomed.filter((slot) => slot.level > newHeight).map((slot) => slot.id);
if (ids.length > 0) {
await manager.getRepository(WarehouseZoneSlot).softDelete({ id: In(ids) });
}
}
private async assertStackEmpty(stackId: string, action: string): Promise<void> {
const occupied = await this.placement.occupiedLevels(stackId);
if (occupied.length > 0) {
throw new ConflictException(
`Stack still holds ${occupied.length} container(s) at level(s) ${occupied.join(', ')}. Move them out before it can be ${action}.`,
);
}
}
private async assertCodeUnique(zoneId: string, code: string, ignoreId?: string): Promise<void> {
const [existing] = await this.stacksRepository.findAll({ where: { zoneId, code } });
if (existing && existing.id !== ignoreId) {
throw new ConflictException(`Stack code ${code} already exists in this zone`);
}
}
}

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