mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-09 10:58:14 +00:00
@@ -18,6 +18,7 @@
|
|||||||
"type-check": "tsc --noEmit",
|
"type-check": "tsc --noEmit",
|
||||||
"seed:demo-scheduling": "ts-node -r tsconfig-paths/register src/scripts/seed-demo-scheduling.ts",
|
"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: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-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: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",
|
"seed:export-djibouti-interchange-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-export-djibouti-interchange-demo.ts",
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import { ConsignmentsModule } from "./modules/consignments/consignments.module";
|
|||||||
import { LocomotivesModule } from "./modules/locomotives/locomotives.module";
|
import { LocomotivesModule } from "./modules/locomotives/locomotives.module";
|
||||||
import { TruckTypesModule } from "./modules/truck-types/truck-types.module";
|
import { TruckTypesModule } from "./modules/truck-types/truck-types.module";
|
||||||
import { TransitAgentsModule } from "./modules/transit-agents/transit-agents.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 { WagonTypesModule } from "./modules/wagon-types/wagon-types.module";
|
||||||
import { TrainSetsModule } from "./modules/train-sets/train-sets.module";
|
import { TrainSetsModule } from "./modules/train-sets/train-sets.module";
|
||||||
import { TrainSchedulesModule } from "./modules/train-schedules/train-schedules.module";
|
import { TrainSchedulesModule } from "./modules/train-schedules/train-schedules.module";
|
||||||
@@ -205,6 +206,7 @@ if (!process.env.APPLICATION_NAME) {
|
|||||||
LocomotivesModule,
|
LocomotivesModule,
|
||||||
TruckTypesModule,
|
TruckTypesModule,
|
||||||
TransitAgentsModule,
|
TransitAgentsModule,
|
||||||
|
TransitAssignmentsModule,
|
||||||
WagonTypesModule,
|
WagonTypesModule,
|
||||||
TrainSetsModule,
|
TrainSetsModule,
|
||||||
TrainSchedulesModule,
|
TrainSchedulesModule,
|
||||||
|
|||||||
@@ -4,25 +4,29 @@ import {
|
|||||||
ValidationOptions,
|
ValidationOptions,
|
||||||
ValidatorConstraint,
|
ValidatorConstraint,
|
||||||
ValidatorConstraintInterface,
|
ValidatorConstraintInterface,
|
||||||
} from 'class-validator';
|
} from "class-validator";
|
||||||
import { isValidPhoneNumber, parsePhoneNumberFromString } from 'libphonenumber-js';
|
import {
|
||||||
|
isValidPhoneNumber,
|
||||||
|
parsePhoneNumberFromString,
|
||||||
|
} from "libphonenumber-js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Country-aware phone validation. The value is expected as a full international
|
* 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
|
* number (E.164, e.g. "+25377834567" for Djibouti or "+251911223344" for
|
||||||
* value itself — no separate country field needed.
|
* 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 {
|
export class IsValidPhoneConstraint implements ValidatorConstraintInterface {
|
||||||
validate(value: unknown): boolean {
|
validate(value: unknown): boolean {
|
||||||
// Empty is allowed here; pair with @IsOptional / @IsNotEmpty as needed.
|
// Empty is allowed here; pair with @IsOptional / @IsNotEmpty as needed.
|
||||||
if (value === undefined || value === null || value === '') return true;
|
if (value === undefined || value === null || value === "") return true;
|
||||||
if (typeof value !== 'string') return false;
|
if (typeof value !== "string") return false;
|
||||||
return isValidPhoneNumber(value);
|
return isValidPhoneNumber(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
defaultMessage(args: ValidationArguments): string {
|
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(
|
export function normalizeE164(
|
||||||
value: string | null | undefined,
|
value: string | null | undefined,
|
||||||
): string | null | undefined {
|
): string | null | undefined {
|
||||||
if (value === undefined || value === null || value === '') return value;
|
if (value === undefined || value === null || value === "") return value;
|
||||||
const parsed = parsePhoneNumberFromString(value, 'ET');
|
const parsed = parsePhoneNumberFromString(value, "ET");
|
||||||
return parsed?.isValid() ? parsed.number : value.trim();
|
return parsed?.isValid() ? parsed.number : value.trim();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Give a transit agent a portal login.
|
||||||
|
*
|
||||||
|
* Every column is NULLABLE and nothing is backfilled: production already holds
|
||||||
|
* transit agents that exist only as a GL-assignable roster entry, and they must
|
||||||
|
* keep working untouched. An agent gains an account when staff invite it — at
|
||||||
|
* which point `user_id` is filled in — so "has a login" is exactly
|
||||||
|
* `user_id IS NOT NULL`, and the assignment flow never has to care.
|
||||||
|
*
|
||||||
|
* The unique indexes are partial (`WHERE ... IS NOT NULL`) because Postgres
|
||||||
|
* treats NULLs as distinct in a plain unique index only per-row; being explicit
|
||||||
|
* documents that many account-less agents are expected to coexist.
|
||||||
|
*/
|
||||||
|
export class TransitAgentAccount3790000000000 implements MigrationInterface {
|
||||||
|
name = "TransitAgentAccount3790000000000";
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE freight.transit_agents
|
||||||
|
ADD COLUMN IF NOT EXISTS user_id uuid,
|
||||||
|
ADD COLUMN IF NOT EXISTS email varchar(150),
|
||||||
|
ADD COLUMN IF NOT EXISTS phone_number varchar(30)`,
|
||||||
|
);
|
||||||
|
// One IAM account can back at most one transit agent — otherwise a single
|
||||||
|
// login would resolve to two agents in `findByUserId`.
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE UNIQUE INDEX IF NOT EXISTS ux_transit_agents_user_id
|
||||||
|
ON freight.transit_agents (user_id)
|
||||||
|
WHERE user_id IS NOT NULL AND deleted_at IS NULL`,
|
||||||
|
);
|
||||||
|
// Case-insensitive, matching how the repository checks for duplicates.
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE UNIQUE INDEX IF NOT EXISTS ux_transit_agents_email
|
||||||
|
ON freight.transit_agents (lower(email))
|
||||||
|
WHERE email IS NOT NULL AND deleted_at IS NULL`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`DROP INDEX IF EXISTS freight.ux_transit_agents_email`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`DROP INDEX IF EXISTS freight.ux_transit_agents_user_id`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE freight.transit_agents
|
||||||
|
DROP COLUMN IF EXISTS phone_number,
|
||||||
|
DROP COLUMN IF EXISTS email,
|
||||||
|
DROP COLUMN IF EXISTS user_id`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wagon footprint pinned for cancellation pricing. `wagons_required` is a LIVE
|
||||||
|
* scheduling field — unassign clears it to NULL — so a paid booking pulled off
|
||||||
|
* a train had nothing left to price a cancellation fee or credit against
|
||||||
|
* ("This booking has no wagon requirement to cancel from."). This column is
|
||||||
|
* stamped once, at first allocation, and never cleared: cancellation reads it
|
||||||
|
* (falling back to a computed count for bookings never allocated).
|
||||||
|
*/
|
||||||
|
export class BookingCancellationWagons3800000000000 implements MigrationInterface {
|
||||||
|
name = 'BookingCancellationWagons3800000000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.bookings
|
||||||
|
ADD COLUMN IF NOT EXISTS cancellation_wagons numeric(6,2)
|
||||||
|
`);
|
||||||
|
// Backfill the bookings that still carry a live stamp.
|
||||||
|
await queryRunner.query(`
|
||||||
|
UPDATE freight.bookings
|
||||||
|
SET cancellation_wagons = wagons_required
|
||||||
|
WHERE cancellation_wagons IS NULL
|
||||||
|
AND wagons_required IS NOT NULL
|
||||||
|
AND wagons_required > 0
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.bookings
|
||||||
|
DROP COLUMN IF EXISTS cancellation_wagons
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,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
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transit assignments — one row per (booking × transit agent), so an agent
|
||||||
|
* handles many bookings.
|
||||||
|
*
|
||||||
|
* Deliberately NOT the existing transit-assignee handshake on bookings
|
||||||
|
* (`/bookings/:id/clearance/transit-assignee/...`, which stores its answer on
|
||||||
|
* the booking itself): that is a pre-declaration agreement between GL Ethiopia
|
||||||
|
* and GL Djibouti about WHO will handle customs. This is the work record —
|
||||||
|
* status, timings and documents — and nothing here reads or writes that flow.
|
||||||
|
*
|
||||||
|
* There is no duration column on purpose. The time taken after the train
|
||||||
|
* arrives is `finished_at − bookings.arrived_at`, and both halves already
|
||||||
|
* exist; storing the difference would be a third source of truth that goes
|
||||||
|
* stale the moment either timestamp is corrected. It is computed on read.
|
||||||
|
*
|
||||||
|
* Documents hang off `freight.files` with `resource = 'transit_assignments'`
|
||||||
|
* and `resource_id = transit_assignments.id`. That table already carries the
|
||||||
|
* MinIO object, the upload time (`created_at`), the uploader, the edit time
|
||||||
|
* (`updated_at`) and the supersede history, so no file table is added here.
|
||||||
|
*/
|
||||||
|
export class TransitAssignments3810000000000 implements MigrationInterface {
|
||||||
|
name = "TransitAssignments3810000000000";
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS freight.transit_assignments (
|
||||||
|
id uuid NOT NULL DEFAULT gen_random_uuid(),
|
||||||
|
booking_id uuid NOT NULL,
|
||||||
|
transit_agent_id uuid NOT NULL,
|
||||||
|
status varchar(32) NOT NULL DEFAULT 'NOT_STARTED',
|
||||||
|
started_at timestamptz,
|
||||||
|
finished_at timestamptz,
|
||||||
|
assigned_by_user_id uuid,
|
||||||
|
assigned_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
note text,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
deleted_at timestamptz,
|
||||||
|
CONSTRAINT pk_transit_assignments PRIMARY KEY (id),
|
||||||
|
CONSTRAINT fk_transit_assignments_booking
|
||||||
|
FOREIGN KEY (booking_id) REFERENCES freight.bookings (id),
|
||||||
|
CONSTRAINT fk_transit_assignments_agent
|
||||||
|
FOREIGN KEY (transit_agent_id) REFERENCES freight.transit_agents (id)
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// One live assignment per (booking, agent). Partial so a soft-deleted row
|
||||||
|
// never blocks re-assigning the same agent to the same booking later.
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS ux_transit_assignments_booking_agent
|
||||||
|
ON freight.transit_assignments (booking_id, transit_agent_id)
|
||||||
|
WHERE deleted_at IS NULL
|
||||||
|
`);
|
||||||
|
|
||||||
|
// The two list directions: a booking's assignments, and an agent's workload.
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_transit_assignments_booking
|
||||||
|
ON freight.transit_assignments (booking_id) WHERE deleted_at IS NULL
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_transit_assignments_agent_status
|
||||||
|
ON freight.transit_assignments (transit_agent_id, status)
|
||||||
|
WHERE deleted_at IS NULL
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`DROP TABLE IF EXISTS freight.transit_assignments`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ describe('BookingWagonCancellationService.resolveRequestedCut (bulk)', () => {
|
|||||||
wagons: number;
|
wagons: number;
|
||||||
weightTons: number;
|
weightTons: number;
|
||||||
quantities: { bulkTons?: number };
|
quantities: { bulkTons?: number };
|
||||||
|
totalWagons: number;
|
||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
const booking = {
|
const booking = {
|
||||||
@@ -29,7 +30,39 @@ describe('BookingWagonCancellationService.resolveRequestedCut (bulk)', () => {
|
|||||||
|
|
||||||
it('cancels every wagon with the exact total tonnage', async () => {
|
it('cancels every wagon with the exact total tonnage', async () => {
|
||||||
const cut = await svc.resolveRequestedCut(booking, { wagons: 4 });
|
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 () => {
|
it('rejects more wagons than the booking has', async () => {
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ import { wagonsPerUnitForSize } from '../rule-engine/container-type.util';
|
|||||||
import { ContainerType } from '../rule-engine/entities/container-type.entity';
|
import { ContainerType } from '../rule-engine/entities/container-type.entity';
|
||||||
import { Rate } from '../rule-engine/entities/rate.entity';
|
import { Rate } from '../rule-engine/entities/rate.entity';
|
||||||
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
|
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 { TrainSchedulingService } from '../train-scheduling/services/train-scheduling.service';
|
||||||
import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity';
|
import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity';
|
||||||
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
||||||
@@ -74,6 +76,8 @@ interface RequestedCut {
|
|||||||
wagons: number;
|
wagons: number;
|
||||||
weightTons: number;
|
weightTons: number;
|
||||||
quantities: CancelledQuantities;
|
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. */
|
/** 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,
|
feePerWagon: fee.perWagon,
|
||||||
feeAmount: fee.amount,
|
feeAmount: fee.amount,
|
||||||
feeCurrency: fee.currency,
|
feeCurrency: fee.currency,
|
||||||
creditAmount: this.creditFor(booking, Number(booking.wagonsRequired ?? 0)),
|
creditAmount: round2(Number(booking.totalAmount ?? 0)),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
this.assertCutSparesSharedWagon(cut);
|
this.assertCutSparesSharedWagon(cut);
|
||||||
@@ -177,7 +181,7 @@ export class BookingWagonCancellationService {
|
|||||||
feePerWagon: fee.perWagon,
|
feePerWagon: fee.perWagon,
|
||||||
feeAmount: fee.amount,
|
feeAmount: fee.amount,
|
||||||
feeCurrency: fee.currency,
|
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);
|
: await this.resolveRequestedCut(booking, dto);
|
||||||
const fee = await this.priceFee(booking, cut);
|
const fee = await this.priceFee(booking, cut);
|
||||||
const feeAmount = fee.amount;
|
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({
|
const row = await this.repo.create({
|
||||||
bookingId,
|
bookingId,
|
||||||
@@ -318,7 +322,7 @@ export class BookingWagonCancellationService {
|
|||||||
const rows = await this.dataSource.getRepository(WagonBookingAllocation).count({
|
const rows = await this.dataSource.getRepository(WagonBookingAllocation).count({
|
||||||
where: { bookingId: row.bookingId },
|
where: { bookingId: row.bookingId },
|
||||||
});
|
});
|
||||||
if (rows < Math.round(Number(booking.wagonsRequired ?? 0))) {
|
if (rows < Math.round(await this.wagonFootprint(booking))) {
|
||||||
throw new ConflictException(
|
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.',
|
'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(
|
const row = await this.openConsolidationBreak(
|
||||||
booking,
|
booking,
|
||||||
'ceil',
|
'ceil',
|
||||||
this.creditFor(booking, Number(booking.wagonsRequired ?? 0)),
|
round2(Number(booking.totalAmount ?? 0)),
|
||||||
reason ?? 'Consolidated pair cancelled',
|
reason ?? 'Consolidated pair cancelled',
|
||||||
userId,
|
userId,
|
||||||
);
|
);
|
||||||
@@ -371,7 +375,7 @@ export class BookingWagonCancellationService {
|
|||||||
await this.openConsolidationBreak(
|
await this.openConsolidationBreak(
|
||||||
partner,
|
partner,
|
||||||
'floor',
|
'floor',
|
||||||
this.creditFor(partner, Number(partner.wagonsRequired ?? 0)),
|
round2(Number(partner.totalAmount ?? 0)),
|
||||||
`Cancelled with its consolidation partner ${booking.reference}`,
|
`Cancelled with its consolidation partner ${booking.reference}`,
|
||||||
userId,
|
userId,
|
||||||
);
|
);
|
||||||
@@ -540,7 +544,8 @@ export class BookingWagonCancellationService {
|
|||||||
} as RequestWagonCancellationDto);
|
} as RequestWagonCancellationDto);
|
||||||
}
|
}
|
||||||
return this.resolveRequestedCut(booking, {
|
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);
|
} as RequestWagonCancellationDto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -568,7 +573,7 @@ export class BookingWagonCancellationService {
|
|||||||
const row = await this.openConsolidationBreak(
|
const row = await this.openConsolidationBreak(
|
||||||
booking,
|
booking,
|
||||||
'ceil',
|
'ceil',
|
||||||
this.creditFor(booking, Number(booking.wagonsRequired ?? 0)),
|
round2(Number(booking.totalAmount ?? 0)),
|
||||||
'Consolidation partner lapsed unpaid — paired booking cancelled, cancellation fee applies',
|
'Consolidation partner lapsed unpaid — paired booking cancelled, cancellation fee applies',
|
||||||
);
|
);
|
||||||
await this.dataSource.getRepository(Booking).update(booking.id, {
|
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
|
// 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
|
// CANCELLED (frees the contract slot/cap for the rebook) and drops off its
|
||||||
// train. The credit row still points at it for T3.
|
// train. The credit row still points at it for T3.
|
||||||
const wagonsLeft = round2(
|
// Off the pinned footprint, not the live wagonsRequired — unassign
|
||||||
Number(booking.wagonsRequired ?? 0) - Number(row.wagonsCancelled),
|
// 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;
|
const isFull = wagonsLeft <= 0;
|
||||||
// NUMBER_OF_WAGONS bookings pin their count in bulkRequestedWagons, which
|
// NUMBER_OF_WAGONS bookings pin their count in bulkRequestedWagons, which
|
||||||
// bulkTonWagonsRequired honours verbatim. Left stale it re-inflates the
|
// bulkTonWagonsRequired honours verbatim. Left stale it re-inflates the
|
||||||
@@ -745,6 +751,9 @@ export class BookingWagonCancellationService {
|
|||||||
: null;
|
: null;
|
||||||
await manager.getRepository(Booking).update(booking.id, {
|
await manager.getRepository(Booking).update(booking.id, {
|
||||||
wagonsRequired: Math.max(0, wagonsLeft),
|
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
|
...(requestedWagonsLeft !== null
|
||||||
? { bulkRequestedWagons: requestedWagonsLeft }
|
? { bulkRequestedWagons: requestedWagonsLeft }
|
||||||
: {}),
|
: {}),
|
||||||
@@ -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, {
|
const cut = await this.resolveRequestedCut(booking, {
|
||||||
wagonAllocationIds: remaining.map((r) => r.id),
|
wagonAllocationIds: target.map((r) => r.id),
|
||||||
} as RequestWagonCancellationDto);
|
} as RequestWagonCancellationDto);
|
||||||
if (booking.consolidationPartnerId) this.assertCutSparesSharedWagon(cut);
|
if (booking.consolidationPartnerId) this.assertCutSparesSharedWagon(cut);
|
||||||
|
|
||||||
const edrFault = !!dto.edrFault;
|
const edrFault = !!dto.edrFault;
|
||||||
const fee = edrFault ? null : await this.priceFee(booking, cut);
|
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({
|
const row = await this.repo.create({
|
||||||
bookingId,
|
bookingId,
|
||||||
@@ -1253,7 +1285,7 @@ export class BookingWagonCancellationService {
|
|||||||
booking: Booking,
|
booking: Booking,
|
||||||
dto: RequestWagonCancellationDto,
|
dto: RequestWagonCancellationDto,
|
||||||
): Promise<RequestedCut> {
|
): Promise<RequestedCut> {
|
||||||
const totalWagons = Number(booking.wagonsRequired ?? 0);
|
const totalWagons = await this.wagonFootprint(booking);
|
||||||
if (totalWagons <= 0) {
|
if (totalWagons <= 0) {
|
||||||
throw new BadRequestException('This booking has no wagon requirement to cancel from.');
|
throw new BadRequestException('This booking has no wagon requirement to cancel from.');
|
||||||
}
|
}
|
||||||
@@ -1335,6 +1367,7 @@ export class BookingWagonCancellationService {
|
|||||||
weightTons: weightShare,
|
weightTons: weightShare,
|
||||||
// Bookings without unit records fall back to the T2 LIFO trim.
|
// Bookings without unit records fall back to the T2 LIFO trim.
|
||||||
quantities: { bySize, ...(units.length === requested ? { units } : {}) },
|
quantities: { bySize, ...(units.length === requested ? { units } : {}) },
|
||||||
|
totalWagons,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1360,7 +1393,7 @@ export class BookingWagonCancellationService {
|
|||||||
if (tons <= 0) {
|
if (tons <= 0) {
|
||||||
throw new BadRequestException('The requested cut is too small to release cargo.');
|
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,
|
wagons,
|
||||||
weightTons: tons,
|
weightTons: tons,
|
||||||
quantities: { bulkTons: tons, allocationIds },
|
quantities: { bulkTons: tons, allocationIds },
|
||||||
|
totalWagons,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1460,12 +1494,54 @@ export class BookingWagonCancellationService {
|
|||||||
wagons,
|
wagons,
|
||||||
weightTons: round3(units.reduce((s, u) => s + Number(u.vgmTons || 0), 0)),
|
weightTons: round3(units.reduce((s, u) => s + Number(u.vgmTons || 0), 0)),
|
||||||
quantities: { bySize, units, allocationIds },
|
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). */
|
/** Credit = the cancelled share of the ORIGINAL price (old-price rebooking). */
|
||||||
private creditFor(booking: Booking, wagons: number): number {
|
private creditFor(booking: Booking, wagons: number, totalWagons: number): number {
|
||||||
const totalWagons = Number(booking.wagonsRequired ?? 0);
|
|
||||||
if (totalWagons <= 0) return 0;
|
if (totalWagons <= 0) return 0;
|
||||||
return round2(Number(booking.totalAmount) * (wagons / totalWagons));
|
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.
|
// the same cargo); number/seal/VGM come from the override when given.
|
||||||
units: sized.map((u, i) => ({
|
units: sized.map((u, i) => ({
|
||||||
containerNumber: replacement?.[i]?.containerNumber ?? u.containerNumber,
|
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
|
sealNumber: replacement
|
||||||
? (replacement[i]?.sealNumber ?? undefined)
|
? (replacement[i]?.sealNumber ?? '')
|
||||||
: (u.sealNumber ?? undefined),
|
: (u.sealNumber ?? ''),
|
||||||
vgmTons: replacement?.[i]?.vgmTons ?? u.vgmTons,
|
vgmTons: replacement?.[i]?.vgmTons ?? u.vgmTons,
|
||||||
isHazardous: u.isHazardous,
|
isHazardous: u.isHazardous,
|
||||||
isReefer: u.isReefer,
|
isReefer: u.isReefer,
|
||||||
|
|||||||
@@ -1786,6 +1786,7 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
|||||||
Booking,
|
Booking,
|
||||||
| 'schedulingStatus'
|
| 'schedulingStatus'
|
||||||
| 'wagonsRequired'
|
| 'wagonsRequired'
|
||||||
|
| 'cancellationWagons'
|
||||||
| 'scheduledAt'
|
| 'scheduledAt'
|
||||||
| 'holdStartedAt'
|
| 'holdStartedAt'
|
||||||
| 'holdExpiresAt'
|
| 'holdExpiresAt'
|
||||||
|
|||||||
@@ -114,6 +114,8 @@ interface CarriageAcceptanceWagonRow {
|
|||||||
arrivalAt: string | null;
|
arrivalAt: string | null;
|
||||||
containerNumbers: string | null;
|
containerNumbers: string | null;
|
||||||
sealNumbers: string | null;
|
sealNumbers: string | null;
|
||||||
|
/** Allocation status — LOADED/DEPARTED means EDR has the cargo. */
|
||||||
|
status: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A received-but-not-yet-marshalled export line, standing in for a wagon row. */
|
/** A received-but-not-yet-marshalled export line, standing in for a wagon row. */
|
||||||
@@ -263,9 +265,13 @@ export class BookingsService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Carriage acceptance sheet — one per booking, listing every wagon the booking
|
* Carriage acceptance sheet — one per booking, listing every wagon the booking
|
||||||
* occupies. Handed to the customer when EDR accepts the cargo (export) and when
|
* occupies. A booking is routinely loaded in parts (some containers go, the
|
||||||
* the wagons are allocated before marshalling (import), so it is only available
|
* rest wait for the next train), so each row carries a Status of Loaded or
|
||||||
* once the booking has wagon allocations.
|
* Not loaded and the totals count only the loaded ones: the customer sees the
|
||||||
|
* whole plan on one page without the sheet overstating what EDR has taken.
|
||||||
|
*
|
||||||
|
* Handed to the customer when EDR accepts the cargo (export) and when the
|
||||||
|
* wagons are allocated before marshalling (import).
|
||||||
*/
|
*/
|
||||||
async carriageAcceptanceSheet(bookingId: string): Promise<{ filename: string; buffer: Buffer }> {
|
async carriageAcceptanceSheet(bookingId: string): Promise<{ filename: string; buffer: Buffer }> {
|
||||||
const booking = await this.findById(bookingId);
|
const booking = await this.findById(bookingId);
|
||||||
@@ -285,6 +291,7 @@ export class BookingsService {
|
|||||||
s.scheduled_departure_date AS "departureAt",
|
s.scheduled_departure_date AS "departureAt",
|
||||||
so.label AS "marshalledAt",
|
so.label AS "marshalledAt",
|
||||||
sd.label AS "arrivalAt",
|
sd.label AS "arrivalAt",
|
||||||
|
a.status AS "status",
|
||||||
string_agg(DISTINCT ci.container_number, ', ') AS "containerNumbers",
|
string_agg(DISTINCT ci.container_number, ', ') AS "containerNumbers",
|
||||||
string_agg(DISTINCT ci.seal_number, ', ') AS "sealNumbers"
|
string_agg(DISTINCT ci.seal_number, ', ') AS "sealNumbers"
|
||||||
FROM freight.wagon_booking_allocations a
|
FROM freight.wagon_booking_allocations a
|
||||||
@@ -299,7 +306,7 @@ export class BookingsService {
|
|||||||
LEFT JOIN freight.wagon_allocation_container_items ci
|
LEFT JOIN freight.wagon_allocation_container_items ci
|
||||||
ON ci.wagon_booking_allocation_id = a.id AND ci.deleted_at IS NULL
|
ON ci.wagon_booking_allocation_id = a.id AND ci.deleted_at IS NULL
|
||||||
WHERE a.booking_id = $1 AND a.deleted_at IS NULL
|
WHERE a.booking_id = $1 AND a.deleted_at IS NULL
|
||||||
GROUP BY tsw.id, a.id, wt.code, wt.name, w.wagon_number, wt.tare_weight_tons,
|
GROUP BY tsw.id, a.id, a.status, wt.code, wt.name, w.wagon_number, wt.tare_weight_tons,
|
||||||
s.train_number, s.scheduled_departure_date, so.label, sd.label
|
s.train_number, s.scheduled_departure_date, so.label, sd.label
|
||||||
ORDER BY tsw.sequence_no`,
|
ORDER BY tsw.sequence_no`,
|
||||||
[bookingId],
|
[bookingId],
|
||||||
@@ -383,6 +390,8 @@ export class BookingsService {
|
|||||||
arrivalAt: null,
|
arrivalAt: null,
|
||||||
containerNumbers: row.containerNumbers,
|
containerNumbers: row.containerNumbers,
|
||||||
sealNumbers: row.sealNumbers ?? null,
|
sealNumbers: row.sealNumbers ?? null,
|
||||||
|
// A received line has no allocation; it is cargo EDR already holds.
|
||||||
|
status: null,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -497,7 +506,17 @@ export class BookingsService {
|
|||||||
const header = wagons[0];
|
const header = wagons[0];
|
||||||
const sheetDate = header.departureAt ? new Date(header.departureAt) : new Date();
|
const sheetDate = header.departureAt ? new Date(header.departureAt) : new Date();
|
||||||
|
|
||||||
const totals = wagons.reduce(
|
// Loaded = EDR has the cargo. A booking is routinely loaded in parts, so the
|
||||||
|
// totals count only those: the sheet shows the whole plan, but must never
|
||||||
|
// total up cargo still sitting in the yard. A received-line sheet
|
||||||
|
// (pendingWagons) has no allocation status, and every line on it is cargo
|
||||||
|
// already accepted, so it counts in full.
|
||||||
|
const isLoaded = (w: CarriageAcceptanceWagonRow) =>
|
||||||
|
pendingWagons || w.status === 'LOADED' || w.status === 'DEPARTED';
|
||||||
|
const loadedWagons = wagons.filter(isLoaded);
|
||||||
|
const notLoadedCount = wagons.length - loadedWagons.length;
|
||||||
|
|
||||||
|
const totals = loadedWagons.reduce(
|
||||||
(acc, w) => ({
|
(acc, w) => ({
|
||||||
tare: acc.tare + (Number(w.tareWeightTons) || 0),
|
tare: acc.tare + (Number(w.tareWeightTons) || 0),
|
||||||
capacity: acc.capacity + (Number(w.loadCapacityTons) || 0),
|
capacity: acc.capacity + (Number(w.loadCapacityTons) || 0),
|
||||||
@@ -507,7 +526,7 @@ export class BookingsService {
|
|||||||
{ tare: 0, capacity: 0, load: 0, length: 0 },
|
{ tare: 0, capacity: 0, load: 0, length: 0 },
|
||||||
);
|
);
|
||||||
// A wagon carrying no weight and no container is running empty under this booking.
|
// A wagon carrying no weight and no container is running empty under this booking.
|
||||||
const fullWagons = wagons.filter(
|
const fullWagons = loadedWagons.filter(
|
||||||
(w) => (Number(w.allocatedWeightTons) || 0) > 0 || Boolean(w.containerNumbers),
|
(w) => (Number(w.allocatedWeightTons) || 0) > 0 || Boolean(w.containerNumbers),
|
||||||
).length;
|
).length;
|
||||||
|
|
||||||
@@ -525,6 +544,9 @@ export class BookingsService {
|
|||||||
<td>${esc(departureStation)}</td>
|
<td>${esc(departureStation)}</td>
|
||||||
<td>${esc(w.containerNumbers)}</td>
|
<td>${esc(w.containerNumbers)}</td>
|
||||||
<td>${esc(w.sealNumbers)}</td>
|
<td>${esc(w.sealNumbers)}</td>
|
||||||
|
<td class="${isLoaded(w) ? 'loaded' : 'pending'}">${
|
||||||
|
pendingWagons ? 'Accepted' : isLoaded(w) ? 'Loaded' : 'Not loaded'
|
||||||
|
}</td>
|
||||||
<td class="num">${money(prices[i])}</td>
|
<td class="num">${money(prices[i])}</td>
|
||||||
</tr>`,
|
</tr>`,
|
||||||
)
|
)
|
||||||
@@ -535,11 +557,11 @@ export class BookingsService {
|
|||||||
// figure from the printed sheet.
|
// figure from the printed sheet.
|
||||||
const totalsRow = `<tr class="totals">
|
const totalsRow = `<tr class="totals">
|
||||||
<td>TOT</td>
|
<td>TOT</td>
|
||||||
<td>${wagons.length} ${pendingWagons ? 'received lines' : 'wagons'}</td>
|
<td>${loadedWagons.length} ${pendingWagons ? 'received lines' : 'wagons loaded'}</td>
|
||||||
<td>${
|
<td>${
|
||||||
pendingWagons
|
pendingWagons
|
||||||
? 'pending marshalling'
|
? 'pending marshalling'
|
||||||
: `full ${fullWagons} / empty ${wagons.length - fullWagons}`
|
: `full ${fullWagons} / empty ${loadedWagons.length - fullWagons}`
|
||||||
}</td>
|
}</td>
|
||||||
<td class="num">${num(totals.tare, 2)}</td>
|
<td class="num">${num(totals.tare, 2)}</td>
|
||||||
<td class="num">${num(totals.length)}</td>
|
<td class="num">${num(totals.length)}</td>
|
||||||
@@ -549,6 +571,7 @@ export class BookingsService {
|
|||||||
<td></td>
|
<td></td>
|
||||||
<td></td>
|
<td></td>
|
||||||
<td></td>
|
<td></td>
|
||||||
|
<td>${notLoadedCount > 0 ? `loaded only (${notLoadedCount} not loaded)` : ''}</td>
|
||||||
<td class="num">${money(totalAmount)}</td>
|
<td class="num">${money(totalAmount)}</td>
|
||||||
</tr>`;
|
</tr>`;
|
||||||
|
|
||||||
@@ -575,6 +598,8 @@ export class BookingsService {
|
|||||||
th { background: #f8fafc; color: #475569; text-align: left; }
|
th { background: #f8fafc; color: #475569; text-align: left; }
|
||||||
th, td { border: 1px solid #cbd5e1; padding: 5px 6px; font-size: 9.5px; vertical-align: top; }
|
th, td { border: 1px solid #cbd5e1; padding: 5px 6px; font-size: 9.5px; vertical-align: top; }
|
||||||
.num { text-align: right; }
|
.num { text-align: right; }
|
||||||
|
.loaded { color: #0f766e; font-weight: 700; }
|
||||||
|
.pending { color: #b45309; font-weight: 700; }
|
||||||
tr.totals td { background: #f8fafc; font-weight: 700; }
|
tr.totals td { background: #f8fafc; font-weight: 700; }
|
||||||
.notice { margin-top: 10px; border-left: 4px solid #0f766e; background: #f0fdfa; padding: 8px 10px; font-size: 10px; color: #134e4a; }
|
.notice { margin-top: 10px; border-left: 4px solid #0f766e; background: #f0fdfa; padding: 8px 10px; font-size: 10px; color: #134e4a; }
|
||||||
.signatures { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; margin-top: 34px; }
|
.signatures { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; margin-top: 34px; }
|
||||||
@@ -618,6 +643,7 @@ export class BookingsService {
|
|||||||
<th>Departure Station</th>
|
<th>Departure Station</th>
|
||||||
<th>Container No.</th>
|
<th>Container No.</th>
|
||||||
<th>Seal No.</th>
|
<th>Seal No.</th>
|
||||||
|
<th>Status</th>
|
||||||
<th class="num">Price (${esc(currency)})</th>
|
<th class="num">Price (${esc(currency)})</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
|||||||
@@ -183,6 +183,19 @@ export class CancelRemainingWagonsDto {
|
|||||||
@IsUUID('4')
|
@IsUUID('4')
|
||||||
scheduleId!: string;
|
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' })
|
@ApiProperty({ description: 'Why the remaining wagons are not riding' })
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
|
|||||||
@@ -543,6 +543,13 @@ export class Booking extends BaseEntity {
|
|||||||
@Column({ name: 'wagons_required', type: 'numeric', precision: 6, scale: 2, nullable: true })
|
@Column({ name: 'wagons_required', type: 'numeric', precision: 6, scale: 2, nullable: true })
|
||||||
wagonsRequired?: number | null;
|
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' })
|
@Column({ name: 'scheduling_status', type: 'varchar', length: 30, default: 'NOT_SCHEDULED' })
|
||||||
schedulingStatus!: string;
|
schedulingStatus!: string;
|
||||||
|
|
||||||
|
|||||||
@@ -57,8 +57,10 @@ import { CompanyInfoResponseDto } from "./dto/company-info-response.dto";
|
|||||||
import {
|
import {
|
||||||
AccountInfoResponse,
|
AccountInfoResponse,
|
||||||
ShippingLineInfoResponseDto,
|
ShippingLineInfoResponseDto,
|
||||||
|
TransitAgentInfoResponseDto,
|
||||||
} from "./dto/account-info-response.dto";
|
} from "./dto/account-info-response.dto";
|
||||||
import { ShippingLineCompaniesService } from "../shipping-lines/shipping-line-companies.service";
|
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 { UpdateProfileDto } from "./dto/update-profile.dto";
|
||||||
import { ProfileResponseDto } from "./dto/profile-response.dto";
|
import { ProfileResponseDto } from "./dto/profile-response.dto";
|
||||||
import { DashboardSummaryResponseDto } from "./dto/dashboard-summary-response.dto";
|
import { DashboardSummaryResponseDto } from "./dto/dashboard-summary-response.dto";
|
||||||
@@ -104,6 +106,7 @@ export class CompaniesController {
|
|||||||
private readonly companiesService: CompaniesService,
|
private readonly companiesService: CompaniesService,
|
||||||
private readonly filesService: FilesService,
|
private readonly filesService: FilesService,
|
||||||
private readonly shippingLineCompaniesService: ShippingLineCompaniesService,
|
private readonly shippingLineCompaniesService: ShippingLineCompaniesService,
|
||||||
|
private readonly transitAgentsService: TransitAgentsService,
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -133,10 +136,10 @@ export class CompaniesController {
|
|||||||
async getInfo(
|
async getInfo(
|
||||||
@CurrentUser() user: CurrentIamUser,
|
@CurrentUser() user: CurrentIamUser,
|
||||||
): Promise<AccountInfoResponse> {
|
): Promise<AccountInfoResponse> {
|
||||||
// A shipping line has no company and no external profile, so the customer
|
// Neither a shipping line nor a transit agent has a company or an external
|
||||||
// lookup below would 404. Checked first, and reported with an explicit
|
// profile, so the customer lookup below would 404 for both. Checked first,
|
||||||
// `accountKind` so the portal can skip onboarding for shipping lines
|
// and reported with an explicit `accountKind` so the portal can skip
|
||||||
// without inferring it from a missing company.
|
// onboarding for them without inferring it from a missing company.
|
||||||
const shippingLine = await this.shippingLineCompaniesService.findByUserId(
|
const shippingLine = await this.shippingLineCompaniesService.findByUserId(
|
||||||
user.id,
|
user.id,
|
||||||
);
|
);
|
||||||
@@ -144,6 +147,11 @@ export class CompaniesController {
|
|||||||
return new ShippingLineInfoResponseDto(shippingLine);
|
return new ShippingLineInfoResponseDto(shippingLine);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const transitAgent = await this.transitAgentsService.findByUserId(user.id);
|
||||||
|
if (transitAgent) {
|
||||||
|
return new TransitAgentInfoResponseDto(transitAgent);
|
||||||
|
}
|
||||||
|
|
||||||
const { profile, company } =
|
const { profile, company } =
|
||||||
await this.companiesService.getCompanyInfoByUserId(user.id);
|
await this.companiesService.getCompanyInfoByUserId(user.id);
|
||||||
const review = await this.companiesService.getOpenChangeRequestForCompany(
|
const review = await this.companiesService.getOpenChangeRequestForCompany(
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { CompanyChangeRequest } from "./entities/company-change-request.entity";
|
|||||||
import { CompanyRevision } from "./entities/company-revision.entity";
|
import { CompanyRevision } from "./entities/company-revision.entity";
|
||||||
import { Booking } from "../bookings/entities/booking.entity";
|
import { Booking } from "../bookings/entities/booking.entity";
|
||||||
import { ShippingLineCompaniesModule } from "../shipping-lines/shipping-line-companies.module";
|
import { ShippingLineCompaniesModule } from "../shipping-lines/shipping-line-companies.module";
|
||||||
|
import { TransitAgentsModule } from "../transit-agents/transit-agents.module";
|
||||||
import { CompanyProfileRepository } from "./company-profile.repository";
|
import { CompanyProfileRepository } from "./company-profile.repository";
|
||||||
import { CompanyChangeRequestRepository } from "./company-change-request.repository";
|
import { CompanyChangeRequestRepository } from "./company-change-request.repository";
|
||||||
import { CompanyRevisionRepository } from "./company-revision.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
|
// shipping-line session, which has no company row to look up. forwardRef
|
||||||
// because that module imports BillingModule, which imports this one.
|
// because that module imports BillingModule, which imports this one.
|
||||||
forwardRef(() => ShippingLineCompaniesModule),
|
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],
|
controllers: [CompaniesController],
|
||||||
providers: [
|
providers: [
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||||
|
|
||||||
import { ShippingLineCompany } from "../../shipping-lines/entities/shipping-line-company.entity";
|
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";
|
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`
|
* 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
|
* missing?": a failed or slow company fetch also leaves `company` empty, and
|
||||||
* treating that as "no onboarding needed" would let customers skip onboarding
|
* treating that as "no onboarding needed" would let customers skip onboarding
|
||||||
* whenever the request failed. A shipping line is identified positively, and
|
* whenever the request failed. A shipping line and a transit agent are each
|
||||||
* anything else defaults to `customer`.
|
* 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. */
|
/** The signed-in shipping line. No company, no profile, no onboarding. */
|
||||||
export class ShippingLineInfoResponseDto {
|
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 =
|
export type AccountInfoResponse =
|
||||||
| (CompanyInfoResponseDto & { accountKind: "customer" })
|
| (CompanyInfoResponseDto & { accountKind: "customer" })
|
||||||
| ShippingLineInfoResponseDto;
|
| ShippingLineInfoResponseDto
|
||||||
|
| TransitAgentInfoResponseDto;
|
||||||
|
|||||||
@@ -2250,7 +2250,9 @@ export class ContractBookingService {
|
|||||||
unitRepo.create({
|
unitRepo.create({
|
||||||
bookingContainerId: containerRow.id,
|
bookingContainerId: containerRow.id,
|
||||||
containerNumber: unit.containerNumber,
|
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,
|
vgmTons: unit.vgmTons,
|
||||||
isHazardous: unit.isHazardous ?? false,
|
isHazardous: unit.isHazardous ?? false,
|
||||||
isReefer: unit.isReefer ?? false,
|
isReefer: unit.isReefer ?? false,
|
||||||
|
|||||||
@@ -176,6 +176,18 @@ export class ContractNotifierService {
|
|||||||
this.inApp(c, 'Contract suspension lifted', msg);
|
this.inApp(c, 'Contract suspension lifted', msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backoffice cancelled the contract. Terminal — the customer is told they may
|
||||||
|
* submit a new contract with the same details if they still need the service.
|
||||||
|
*/
|
||||||
|
cancelledByStaff(c: Contract, reason: string): void {
|
||||||
|
const msg =
|
||||||
|
`Your contract ${c.reference} has been cancelled. Reason: ${reason}. ` +
|
||||||
|
`If you still need this service you can submit a new contract request with the same details.`;
|
||||||
|
void this.notifyContact(c, msg, 'CANCELLED');
|
||||||
|
this.inApp(c, 'Contract cancelled', msg);
|
||||||
|
}
|
||||||
|
|
||||||
/** Customer cancelled their own contract — staff-side record. */
|
/** Customer cancelled their own contract — staff-side record. */
|
||||||
cancelledByCustomer(c: Contract, reason: string): void {
|
cancelledByCustomer(c: Contract, reason: string): void {
|
||||||
this.inAppStaff(
|
this.inAppStaff(
|
||||||
|
|||||||
@@ -120,3 +120,34 @@ describe('contract base freight is priced on the contract lane only', () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('contract base freight ignores shipping-line rates', () => {
|
||||||
|
it("never prices a customer contract off a line's negotiated rate (CTR-2026-00049)", async () => {
|
||||||
|
// Both LIVE on the contract's own lane: the line rate sorted first and won,
|
||||||
|
// so the contract quoted 32 USD/wagon instead of the standard 1690.
|
||||||
|
const breakdown = await service([
|
||||||
|
rate({
|
||||||
|
containerTypeId: CT20,
|
||||||
|
rateValue: 32,
|
||||||
|
rateUnit: 'PER_WAGON',
|
||||||
|
shippingLineCompanyId: 'line-1',
|
||||||
|
}),
|
||||||
|
rate({ containerTypeId: CT20, rateValue: 1690, rateUnit: 'PER_WAGON' }),
|
||||||
|
]).buildBreakdown(contract({}));
|
||||||
|
expect(breakdown.lineItems).toEqual([
|
||||||
|
expect.objectContaining({ code: 'CONTAINER_20FT', unitPrice: 1690 }),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks when the only rate on the lane belongs to a shipping line', async () => {
|
||||||
|
await expect(
|
||||||
|
service([
|
||||||
|
rate({
|
||||||
|
containerTypeId: CT20,
|
||||||
|
rateValue: 32,
|
||||||
|
shippingLineCompanyId: 'line-1',
|
||||||
|
}),
|
||||||
|
]).buildBreakdown(contract({})),
|
||||||
|
).rejects.toThrow(UnprocessableEntityException);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -85,7 +85,15 @@ export class ContractPricingService {
|
|||||||
* commodity rate) — NO totals or quantities (doc §9.1).
|
* commodity rate) — NO totals or quantities (doc §9.1).
|
||||||
*/
|
*/
|
||||||
async buildBreakdown(contract: Contract): Promise<ContractPricingBreakdown> {
|
async buildBreakdown(contract: Contract): Promise<ContractPricingBreakdown> {
|
||||||
const liveRates = await this.ratesService.findLiveRates();
|
// Contracts belong to a customer company — there is no shipping-line
|
||||||
|
// contract (no shipping_line_company_id on the entity), so a contract may
|
||||||
|
// only ever price off the standard rates. Without this filter a line's
|
||||||
|
// negotiated rate on the same lane matched first and the contract froze it
|
||||||
|
// for a customer: CTR-2026-00049 quoted a line's 32 USD/wagon 20ft and
|
||||||
|
// 23 USD/container 40ft instead of the standard 1690 / 1676.
|
||||||
|
const liveRates = (await this.ratesService.findLiveRates()).filter(
|
||||||
|
(r) => !r.shippingLineCompanyId,
|
||||||
|
);
|
||||||
const currency = contract.paymentCurrency;
|
const currency = contract.paymentCurrency;
|
||||||
const isEtb = currency === 'ETB';
|
const isEtb = currency === 'ETB';
|
||||||
const usdToEtb = isEtb ? await this.exchangeService.getRate('USD', 'ETB') : 1;
|
const usdToEtb = isEtb ? await this.exchangeService.getRate('USD', 'ETB') : 1;
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { ContractTransitionService } from './contract-transition.service';
|
||||||
|
import type { Contract } from './entities/contract.entity';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Staff cancel is terminal, so the rules that matter are: it needs its own
|
||||||
|
* permission (suspend must NOT imply it), it refuses to strand live shipments,
|
||||||
|
* it works on a suspended contract, and it cannot be applied twice.
|
||||||
|
*/
|
||||||
|
describe('ContractTransitionService — staff cancel', () => {
|
||||||
|
const contract = (over: Partial<Contract> = {}): Contract =>
|
||||||
|
({
|
||||||
|
id: 'c-1',
|
||||||
|
reference: 'CTR-2026-00042',
|
||||||
|
companyId: 'co-1',
|
||||||
|
status: 'CONTRACT_ACTIVE',
|
||||||
|
freightType: 'CONTAINER',
|
||||||
|
...over,
|
||||||
|
}) as Contract;
|
||||||
|
|
||||||
|
let current: Contract;
|
||||||
|
let repo: {
|
||||||
|
update: jest.Mock;
|
||||||
|
createReviewNote: jest.Mock;
|
||||||
|
countActiveBookings: jest.Mock;
|
||||||
|
};
|
||||||
|
let notifier: { cancelledByStaff: jest.Mock };
|
||||||
|
let service: ContractTransitionService;
|
||||||
|
|
||||||
|
const staff = {
|
||||||
|
permissions: [{ key: 'edr_freight_app:contracts:cancel' }],
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
current = contract();
|
||||||
|
repo = {
|
||||||
|
update: jest.fn().mockImplementation((_id: string, patch: object) => {
|
||||||
|
current = { ...current, ...patch } as Contract;
|
||||||
|
return Promise.resolve(current);
|
||||||
|
}),
|
||||||
|
createReviewNote: jest.fn().mockResolvedValue(undefined),
|
||||||
|
countActiveBookings: jest.fn().mockResolvedValue(0),
|
||||||
|
};
|
||||||
|
notifier = { cancelledByStaff: jest.fn() };
|
||||||
|
service = Object.create(
|
||||||
|
ContractTransitionService.prototype,
|
||||||
|
) as ContractTransitionService;
|
||||||
|
Object.assign(service, {
|
||||||
|
contractsRepository: repo,
|
||||||
|
contractsService: { findById: () => Promise.resolve(current) },
|
||||||
|
notifier,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cancels, records the reason as a staff note, and notifies the customer', async () => {
|
||||||
|
await service.cancelByStaff('c-1', 'Duplicate request', 'staff-1', staff as never);
|
||||||
|
|
||||||
|
expect(repo.update).toHaveBeenCalledWith('c-1', {
|
||||||
|
status: 'CANCELLED',
|
||||||
|
statusBeforeSuspension: null,
|
||||||
|
});
|
||||||
|
expect(repo.createReviewNote).toHaveBeenCalledWith(
|
||||||
|
'c-1',
|
||||||
|
'Duplicate request',
|
||||||
|
'CANCELLATION',
|
||||||
|
'staff-1',
|
||||||
|
'STAFF',
|
||||||
|
);
|
||||||
|
expect(notifier.cancelledByStaff).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cancels a suspended contract — freezing it is exactly when staff kill it', async () => {
|
||||||
|
current = contract({
|
||||||
|
status: 'SUSPENDED',
|
||||||
|
statusBeforeSuspension: 'CONTRACT_ACTIVE',
|
||||||
|
} as Partial<Contract>);
|
||||||
|
|
||||||
|
await service.cancelByStaff('c-1', 'Customer withdrew', 'staff-1', staff as never);
|
||||||
|
|
||||||
|
expect(repo.update).toHaveBeenCalledWith('c-1', {
|
||||||
|
status: 'CANCELLED',
|
||||||
|
statusBeforeSuspension: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses while a shipment is still running', async () => {
|
||||||
|
repo.countActiveBookings.mockResolvedValue(2);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.cancelByStaff('c-1', 'Change of plan', 'staff-1', staff as never),
|
||||||
|
).rejects.toThrow('2 active shipments');
|
||||||
|
expect(repo.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses to cancel an already-terminal contract', async () => {
|
||||||
|
current = contract({ status: 'CANCELLED' });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.cancelByStaff('c-1', 'Again', 'staff-1', staff as never),
|
||||||
|
).rejects.toThrow(/already cancelled/i);
|
||||||
|
expect(repo.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a user holding only the suspend key — cancel is a separate permission', async () => {
|
||||||
|
const suspender = {
|
||||||
|
permissions: [{ key: 'edr_freight_app:contracts:suspend' }],
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.cancelByStaff('c-1', 'Not allowed', 'staff-1', suspender as never),
|
||||||
|
).rejects.toThrow();
|
||||||
|
expect(repo.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1452,6 +1452,54 @@ export class ContractTransitionService {
|
|||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Staff cancel — terminal, unlike suspend. The contract is dead; a fresh one
|
||||||
|
* with the same parameters can be submitted afterwards (references are minted
|
||||||
|
* per contract, so nothing about the old row blocks the new one).
|
||||||
|
*
|
||||||
|
* Cancellable from ANY non-terminal status, including SUSPENDED: a frozen
|
||||||
|
* contract is exactly the one staff most often need to kill outright.
|
||||||
|
*/
|
||||||
|
async cancelByStaff(
|
||||||
|
contractId: string,
|
||||||
|
reason: string,
|
||||||
|
actorId: string,
|
||||||
|
user?: TCurrentUser | null,
|
||||||
|
): Promise<Contract> {
|
||||||
|
const contract = await this.contractsService.findById(contractId);
|
||||||
|
assertFreightPermission(user, FREIGHT_PERMS.contracts.cancel);
|
||||||
|
if ((TERMINAL_CONTRACT_STATUSES as readonly string[]).includes(contract.status)) {
|
||||||
|
throw new ConflictException(
|
||||||
|
`Contract is already ${contract.status.toLowerCase().replace(/_/g, ' ')}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same guard as the customer path: live shipments must be settled first,
|
||||||
|
// otherwise cancelling the contract orphans cargo already in motion.
|
||||||
|
const active = await this.contractsRepository.countActiveBookings(contractId);
|
||||||
|
if (active > 0) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`This contract has ${active} active shipment${active === 1 ? '' : 's'}. ` +
|
||||||
|
'Cancel or complete them before cancelling the contract.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.contractsRepository.createReviewNote(
|
||||||
|
contractId,
|
||||||
|
reason,
|
||||||
|
'CANCELLATION',
|
||||||
|
actorId,
|
||||||
|
'STAFF',
|
||||||
|
);
|
||||||
|
await this.contractsRepository.update(contractId, {
|
||||||
|
status: 'CANCELLED',
|
||||||
|
statusBeforeSuspension: null,
|
||||||
|
} as never);
|
||||||
|
const updated = await this.contractsService.findById(contractId);
|
||||||
|
this.notifier.cancelledByStaff(updated, reason);
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
async renew(contractId: string, userId?: string): Promise<Contract> {
|
async renew(contractId: string, userId?: string): Promise<Contract> {
|
||||||
const source = await this.contractsService.findById(contractId);
|
const source = await this.contractsService.findById(contractId);
|
||||||
|
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ import {
|
|||||||
RequestChangesDto,
|
RequestChangesDto,
|
||||||
ResumeContractDto,
|
ResumeContractDto,
|
||||||
SuspendContractDto,
|
SuspendContractDto,
|
||||||
|
CancelContractByStaffDto,
|
||||||
} from './dto/approve-step.dto';
|
} from './dto/approve-step.dto';
|
||||||
import { SignContractDto } from './dto/sign-contract.dto';
|
import { SignContractDto } from './dto/sign-contract.dto';
|
||||||
import { ReviewClearanceDocumentDto } from './dto/review-clearance-document.dto';
|
import { ReviewClearanceDocumentDto } from './dto/review-clearance-document.dto';
|
||||||
@@ -552,6 +553,25 @@ export class ContractsController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post(':id/staff/cancel')
|
||||||
|
@BookingStaff(FREIGHT_PERMS.contracts.cancel)
|
||||||
|
@ApiOperation({
|
||||||
|
summary:
|
||||||
|
'Staff cancel a contract (terminal — a new contract with the same details may be submitted after)',
|
||||||
|
})
|
||||||
|
cancelByStaff(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body() dto: CancelContractByStaffDto,
|
||||||
|
@CurrentUser() user: TCurrentUser,
|
||||||
|
) {
|
||||||
|
return this.transitionService.cancelByStaff(
|
||||||
|
id,
|
||||||
|
dto.reason,
|
||||||
|
resolveAuthUserId(user),
|
||||||
|
user,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@Post(':id/approval-steps/:stepId/approve')
|
@Post(':id/approval-steps/:stepId/approve')
|
||||||
@BookingStaff(FREIGHT_PERMS.contracts.view)
|
@BookingStaff(FREIGHT_PERMS.contracts.view)
|
||||||
@ApiOperation({ summary: 'Approve one approval step in sequence' })
|
@ApiOperation({ summary: 'Approve one approval step in sequence' })
|
||||||
|
|||||||
@@ -51,6 +51,14 @@ export class CancelContractDto {
|
|||||||
reason?: string;
|
reason?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Staff cancel is terminal, so the reason is mandatory — it is the audit record. */
|
||||||
|
export class CancelContractByStaffDto {
|
||||||
|
@ApiProperty({ description: 'Why the contract is being cancelled — shown to the customer' })
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
reason!: string;
|
||||||
|
}
|
||||||
|
|
||||||
export class SuspendContractDto {
|
export class SuspendContractDto {
|
||||||
@ApiProperty({ description: 'Why the contract is being frozen — shown to the customer' })
|
@ApiProperty({ description: 'Why the contract is being frozen — shown to the customer' })
|
||||||
@IsString()
|
@IsString()
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
IsEmail,
|
IsEmail,
|
||||||
IsIn,
|
IsIn,
|
||||||
IsInt,
|
IsInt,
|
||||||
|
IsNotEmpty,
|
||||||
IsNumber,
|
IsNumber,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsString,
|
IsString,
|
||||||
@@ -32,10 +33,12 @@ export class CreateContainerUnitDto {
|
|||||||
})
|
})
|
||||||
containerNumber!: string;
|
containerNumber!: string;
|
||||||
|
|
||||||
@ApiPropertyOptional()
|
@ApiProperty({ description: 'Seal number — required on every container, import and export alike.' })
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
@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 })
|
@ApiProperty({ description: 'VGM in tons', minimum: 0 })
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
|
|||||||
@@ -83,3 +83,184 @@ export async function notifyCarriageAcceptanceReady(
|
|||||||
logger.warn(`Carriage acceptance ready notify failed for ${bookingId}: ${(err as Error).message}`);
|
logger.warn(`Carriage acceptance ready notify failed for ${bookingId}: ${(err as Error).message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One container line on the load manifest notice. */
|
||||||
|
interface LoadManifestLists {
|
||||||
|
reference: string;
|
||||||
|
companyId: string | null;
|
||||||
|
trainNumber: string | null;
|
||||||
|
originStation: string | null;
|
||||||
|
destinationStation: string | null;
|
||||||
|
departureAt: Date | null;
|
||||||
|
loaded: string[];
|
||||||
|
leftBehind: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** At most `max` numbers, then "+N more" — an SMS must not carry 44 of them. */
|
||||||
|
function summarizeNumbers(numbers: string[], max = 5): string {
|
||||||
|
if (numbers.length === 0) return 'none';
|
||||||
|
const shown = numbers.slice(0, max).join(', ');
|
||||||
|
const rest = numbers.length - max;
|
||||||
|
return rest > 0 ? `${shown} +${rest} more` : shown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read what actually went on the train and what did not. Left behind = every
|
||||||
|
* container the customer declared minus the ones sitting on a LOADED/DEPARTED
|
||||||
|
* wagon, so a booking loaded in parts reports honestly on both halves.
|
||||||
|
*/
|
||||||
|
export async function loadManifestLists(
|
||||||
|
dataSource: DataSource,
|
||||||
|
bookingId: string,
|
||||||
|
trainScheduleId: string,
|
||||||
|
): Promise<LoadManifestLists | null> {
|
||||||
|
const [booking]: Array<{ reference: string; companyId: string | null }> =
|
||||||
|
await dataSource.query(
|
||||||
|
`SELECT reference, company_id AS "companyId"
|
||||||
|
FROM freight.bookings
|
||||||
|
WHERE id = $1 AND deleted_at IS NULL`,
|
||||||
|
[bookingId],
|
||||||
|
);
|
||||||
|
if (!booking) return null;
|
||||||
|
|
||||||
|
const [train]: Array<{
|
||||||
|
trainNumber: string | null;
|
||||||
|
originStation: string | null;
|
||||||
|
destinationStation: string | null;
|
||||||
|
departureAt: Date | null;
|
||||||
|
}> = await dataSource.query(
|
||||||
|
`SELECT s.train_number AS "trainNumber",
|
||||||
|
so.label AS "originStation",
|
||||||
|
sd.label AS "destinationStation",
|
||||||
|
s.scheduled_departure_date AS "departureAt"
|
||||||
|
FROM freight.train_schedules s
|
||||||
|
LEFT JOIN freight.yards so ON so.id = s.origin_station_id
|
||||||
|
LEFT JOIN freight.yards sd ON sd.id = s.destination_station_id
|
||||||
|
WHERE s.id = $1 AND s.deleted_at IS NULL`,
|
||||||
|
[trainScheduleId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const loadedRows: Array<{ containerNumber: string | null }> = await dataSource.query(
|
||||||
|
`SELECT DISTINCT ci.container_number AS "containerNumber"
|
||||||
|
FROM freight.wagon_allocation_container_items ci
|
||||||
|
JOIN freight.wagon_booking_allocations a
|
||||||
|
ON a.id = ci.wagon_booking_allocation_id AND a.deleted_at IS NULL
|
||||||
|
WHERE a.booking_id = $1
|
||||||
|
AND ci.deleted_at IS NULL
|
||||||
|
AND a.status IN ('LOADED', 'DEPARTED')
|
||||||
|
ORDER BY 1`,
|
||||||
|
[bookingId],
|
||||||
|
);
|
||||||
|
const declaredRows: Array<{ containerNumber: string | null }> = await dataSource.query(
|
||||||
|
`SELECT DISTINCT u.container_number AS "containerNumber"
|
||||||
|
FROM freight.booking_container_units u
|
||||||
|
JOIN freight.booking_container l
|
||||||
|
ON l.id = u.booking_container_id AND l.deleted_at IS NULL
|
||||||
|
WHERE l.booking_id = $1 AND u.deleted_at IS NULL
|
||||||
|
ORDER BY 1`,
|
||||||
|
[bookingId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const loaded = loadedRows.map((r) => r.containerNumber).filter(Boolean) as string[];
|
||||||
|
const loadedSet = new Set(loaded);
|
||||||
|
const leftBehind = (declaredRows.map((r) => r.containerNumber).filter(Boolean) as string[]).filter(
|
||||||
|
(n) => !loadedSet.has(n),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
reference: booking.reference,
|
||||||
|
companyId: booking.companyId,
|
||||||
|
trainNumber: train?.trainNumber ?? null,
|
||||||
|
originStation: train?.originStation ?? null,
|
||||||
|
destinationStation: train?.destinationStation ?? null,
|
||||||
|
departureAt: train?.departureAt ?? null,
|
||||||
|
loaded,
|
||||||
|
leftBehind,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tell the customer what boarded the train and what did not, over in-app + SMS
|
||||||
|
* + email, and raise a warehouse-desk notice for anything left behind so
|
||||||
|
* somebody owns finding it space. A booking is routinely loaded in parts, and
|
||||||
|
* before this the customer learnt about it only by reading the sheet.
|
||||||
|
*
|
||||||
|
* Best-effort throughout: loading must never roll back because a provider is
|
||||||
|
* down.
|
||||||
|
*/
|
||||||
|
export async function notifyLoadManifest(
|
||||||
|
dataSource: DataSource,
|
||||||
|
notifications: NotificationsService,
|
||||||
|
inbox: NotificationInboxService,
|
||||||
|
bookingId: string,
|
||||||
|
trainScheduleId: string,
|
||||||
|
warehouseNotificationPermission: string,
|
||||||
|
logger: Logger,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const m = await loadManifestLists(dataSource, bookingId, trainScheduleId);
|
||||||
|
if (!m) return;
|
||||||
|
|
||||||
|
const route =
|
||||||
|
m.originStation && m.destinationStation
|
||||||
|
? ` ${m.originStation} → ${m.destinationStation}`
|
||||||
|
: '';
|
||||||
|
const departs = m.departureAt
|
||||||
|
? `, departs ${new Date(m.departureAt).toLocaleString('en-GB')}`
|
||||||
|
: '';
|
||||||
|
const train = m.trainNumber ? `train ${m.trainNumber}` : 'the train';
|
||||||
|
|
||||||
|
const headline =
|
||||||
|
`Booking ${m.reference}: ${m.loaded.length} container(s) loaded on ${train}` +
|
||||||
|
`${route}${departs}.`;
|
||||||
|
const loadedLine = m.loaded.length > 0 ? ` Loaded: ${summarizeNumbers(m.loaded)}.` : '';
|
||||||
|
const leftLine =
|
||||||
|
m.leftBehind.length > 0
|
||||||
|
? ` Not loaded (${m.leftBehind.length}): ${summarizeNumbers(m.leftBehind)}.` +
|
||||||
|
' These stay with EDR — once a warehouse is assigned you will receive the GRN.'
|
||||||
|
: '';
|
||||||
|
const body = headline + loadedLine + leftLine;
|
||||||
|
|
||||||
|
if (m.companyId) {
|
||||||
|
await inbox.notify({
|
||||||
|
recipients: { companyId: m.companyId },
|
||||||
|
audience: NotificationAudience.PORTAL,
|
||||||
|
type: NotificationType.BOOKING_STATUS,
|
||||||
|
title: m.leftBehind.length > 0 ? 'Cargo partly loaded' : 'Cargo loaded',
|
||||||
|
// The in-app copy carries every number; SMS and email get the summary.
|
||||||
|
body:
|
||||||
|
headline +
|
||||||
|
(m.loaded.length > 0 ? `\nLoaded: ${m.loaded.join(', ')}` : '') +
|
||||||
|
(m.leftBehind.length > 0
|
||||||
|
? `\nNot loaded: ${m.leftBehind.join(', ')}\nThese stay with EDR — once a warehouse is assigned you will receive the GRN.`
|
||||||
|
: ''),
|
||||||
|
link: `/bookings/${bookingId}`,
|
||||||
|
data: {
|
||||||
|
bookingId,
|
||||||
|
reference: m.reference,
|
||||||
|
trainNumber: m.trainNumber,
|
||||||
|
loaded: m.loaded,
|
||||||
|
leftBehind: m.leftBehind,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await sendCompanyChannels(dataSource, notifications, m.companyId, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nothing left behind is nothing for the warehouse desk to place.
|
||||||
|
if (m.leftBehind.length > 0) {
|
||||||
|
await inbox.notify({
|
||||||
|
recipients: { permissionKeys: [warehouseNotificationPermission] },
|
||||||
|
audience: NotificationAudience.BACKOFFICE,
|
||||||
|
type: NotificationType.REQUEST_SUBMITTED,
|
||||||
|
title: `${m.leftBehind.length} container(s) left behind — ${m.reference}`,
|
||||||
|
body:
|
||||||
|
`${train} departed without ${m.leftBehind.length} container(s) of booking ${m.reference}: ` +
|
||||||
|
`${m.leftBehind.join(', ')}. Assign warehouse space and raise the GRN.`,
|
||||||
|
link: `/dashboard/booking-requests/${bookingId}`,
|
||||||
|
data: { bookingId, reference: m.reference, leftBehind: m.leftBehind },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn(`Load manifest notify failed for ${bookingId}: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -35,9 +35,24 @@ describe("isDomesticPhone", () => {
|
|||||||
(phone) => expect(isDomesticPhone(phone)).toBe(true),
|
(phone) => expect(isDomesticPhone(phone)).toBe(true),
|
||||||
);
|
);
|
||||||
|
|
||||||
it.each(["+14155550123", "+447911123456", "0712345678", "+2519866", "12345"])(
|
// Djibouti is the line's other end: the gateway reaches its 77x mobiles.
|
||||||
"rejects non-domestic or malformed %s",
|
it.each(["+25377123456", "25377123456", "77123456"])(
|
||||||
(phone) => expect(isDomesticPhone(phone)).toBe(false),
|
"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),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -42,20 +42,42 @@ function normalizePhone(rawPhone: string): string {
|
|||||||
if (digits.startsWith("+")) return digits;
|
if (digits.startsWith("+")) return digits;
|
||||||
const bare = digits.replace(/^0+/, "");
|
const bare = digits.replace(/^0+/, "");
|
||||||
if (/^251\d{9}$/.test(digits)) return `+${digits}`;
|
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}`;
|
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
|
// Unknown shape (foreign number, already-clean intl without +) — prefix + if
|
||||||
// it looks like a full international number, else leave as typed.
|
// it looks like a full international number, else leave as typed.
|
||||||
return digits.length >= 11 ? `+${digits}` : raw;
|
return digits.length >= 11 ? `+${digits}` : raw;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether a phone is an Ethiopian mobile the SMS gateway can actually reach —
|
* Mobile ranges the SMS gateway is contracted to reach, as E.164 patterns.
|
||||||
* 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
|
* The gateway itself is opaque from here — `SmsClientService` publishes to
|
||||||
* pretending an SMS is on its way.
|
* 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 {
|
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 otpRepository: OtpRepository,
|
||||||
private readonly notifications: NotificationsService,
|
private readonly notifications: NotificationsService,
|
||||||
private readonly emailClient: EmailClientService,
|
private readonly emailClient: EmailClientService,
|
||||||
) { }
|
) {}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Generate OTP
|
// Generate OTP
|
||||||
@@ -197,8 +219,10 @@ export class OtpService {
|
|||||||
|
|
||||||
for (const outcome of outcomes) {
|
for (const outcome of outcomes) {
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`otp.dispatch channel=${outcome.channel} target=${label} queued=${outcome.queued
|
`otp.dispatch channel=${outcome.channel} target=${label} queued=${
|
||||||
} latencyMs=${Date.now() - startedAt}${outcome.error ? ` error=${outcome.error}` : ""
|
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,
|
// user who never receives a code — indistinguishable from carrier loss,
|
||||||
// and the misleading success response makes it look like our side worked.
|
// and the misleading success response makes it look like our side worked.
|
||||||
this.logger.error(
|
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`,
|
} — 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
|
// 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.
|
// "Failed to send OTP" 400 is diagnosable from the API logs, not opaque.
|
||||||
this.logger.error(
|
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.message : String(error)}`,
|
||||||
error instanceof Error ? error.stack : undefined,
|
error instanceof Error ? error.stack : undefined,
|
||||||
);
|
);
|
||||||
@@ -330,8 +356,9 @@ export class OtpService {
|
|||||||
) {
|
) {
|
||||||
const line = `otp.verify channels=${channelsOf(target).join(
|
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);
|
if (result === "ok") this.logger.log(line);
|
||||||
else this.logger.warn(line);
|
else this.logger.warn(line);
|
||||||
|
|
||||||
|
|||||||
@@ -508,12 +508,12 @@ export class RuleEngineService {
|
|||||||
|
|
||||||
if (input.tradeDirection === 'IMPORT') {
|
if (input.tradeDirection === 'IMPORT') {
|
||||||
appliedModifiers.push(
|
appliedModifiers.push(
|
||||||
...this.derivedImportOverweight(
|
...(await this.derivedImportOverweight(
|
||||||
input,
|
input,
|
||||||
containerWeightResults,
|
containerWeightResults,
|
||||||
lineMaxVgmTons,
|
lineMaxVgmTons,
|
||||||
liveRates,
|
liveRates,
|
||||||
),
|
)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -540,23 +540,37 @@ export class RuleEngineService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Import overweight — derived, never configured. Each overweight container
|
* Import overweight — derived, never configured. Excess tons are billed on a
|
||||||
* line bills its excess tons at (its own base import freight on the booking's
|
* PER-WAGON basis: (the wagon's base import freight on the booking's route)
|
||||||
* route) ÷ (2 × its weight limit): 20ft at 1000 USD with a 20 t limit →
|
* ÷ (2 × the container's weight limit).
|
||||||
* 25 USD per excess ton. Export keeps the configured OVERWEIGHT rate.
|
*
|
||||||
* Note: derives from the LIVE route rate even for frozen-rate contract
|
* The rate is normalised to a wagon before dividing, because a 20ft rate
|
||||||
* bookings — the frozen snapshot has no route-scoped container price to
|
* quoted PER_CONTAINER prices only HALF a wagon — two 20ft ride one wagon —
|
||||||
* divide.
|
* while a 40ft container IS the whole wagon. So a PER_CONTAINER 20ft rate is
|
||||||
|
* doubled first; 40ft (and any rate already quoted PER_WAGON) is taken as is:
|
||||||
|
* - 20ft PER_CONTAINER 845 USD, 20 t limit → (845 × 2) / (2 × 20) = 42.25
|
||||||
|
* - 40ft PER_CONTAINER 1676 USD, 40 t limit → 1676 / (2 × 40) = 20.95
|
||||||
|
* Halving over 2 × the limit keeps the original meaning: filling one wagon's
|
||||||
|
* worth of excess costs one extra wagon of freight.
|
||||||
|
*
|
||||||
|
* Export keeps the configured OVERWEIGHT rate. Note: derives from the LIVE
|
||||||
|
* route rate even for frozen-rate contract bookings — the frozen snapshot has
|
||||||
|
* no route-scoped container price to divide.
|
||||||
*/
|
*/
|
||||||
private derivedImportOverweight(
|
private async derivedImportOverweight(
|
||||||
input: BookingEvaluationInput,
|
input: BookingEvaluationInput,
|
||||||
weightResults: ContainerWeightResult[],
|
weightResults: ContainerWeightResult[],
|
||||||
lineMaxVgmTons: Array<number | null>,
|
lineMaxVgmTons: Array<number | null>,
|
||||||
liveRates: Rate[],
|
liveRates: Rate[],
|
||||||
): AppliedCargoModifier[] {
|
): Promise<AppliedCargoModifier[]> {
|
||||||
const modifiers: AppliedCargoModifier[] = [];
|
const modifiers: AppliedCargoModifier[] = [];
|
||||||
if (!input.originYardId || !input.destinationYardId) return modifiers;
|
if (!input.originYardId || !input.destinationYardId) return modifiers;
|
||||||
|
|
||||||
|
// How many of each container type ride one wagon: a 40ft fills a wagon,
|
||||||
|
// two 20ft share one. Keyed by container type so a PER_CONTAINER rate can
|
||||||
|
// be scaled up to the wagon the overweight formula prices against.
|
||||||
|
const sizeByTypeId = await this.containersPerWagonByTypeId(weightResults);
|
||||||
|
|
||||||
for (let i = 0; i < weightResults.length; i++) {
|
for (let i = 0; i < weightResults.length; i++) {
|
||||||
const wr = weightResults[i];
|
const wr = weightResults[i];
|
||||||
const excess = Number(wr?.overweightExcessTons ?? 0);
|
const excess = Number(wr?.overweightExcessTons ?? 0);
|
||||||
@@ -578,7 +592,16 @@ export class RuleEngineService {
|
|||||||
// No base rate → the base-freight line hard-blocks this booking anyway.
|
// No base rate → the base-freight line hard-blocks this booking anyway.
|
||||||
if (!base) continue;
|
if (!base) continue;
|
||||||
|
|
||||||
const perTon = Number(base.rateValue) / (2 * maxVgm);
|
// Normalise the rate to ONE WAGON before dividing. A PER_CONTAINER 20ft
|
||||||
|
// rate covers half a wagon, so it is scaled by the 2 containers that ride
|
||||||
|
// one; 40ft scales by 1. A rate already quoted PER_WAGON is the wagon
|
||||||
|
// price already — never scale it again.
|
||||||
|
const perWagonRate =
|
||||||
|
base.rateUnit === 'PER_CONTAINER'
|
||||||
|
? Number(base.rateValue) * (sizeByTypeId.get(wr.containerTypeId) ?? 1)
|
||||||
|
: Number(base.rateValue);
|
||||||
|
|
||||||
|
const perTon = perWagonRate / (2 * maxVgm);
|
||||||
const amount = excess * perTon;
|
const amount = excess * perTon;
|
||||||
if (!(amount > 0)) continue;
|
if (!(amount > 0)) continue;
|
||||||
|
|
||||||
@@ -595,6 +618,42 @@ export class RuleEngineService {
|
|||||||
return modifiers;
|
return modifiers;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Containers of each type that ride ONE wagon, derived from the type's
|
||||||
|
* size_ft against a 40ft wagon slot: 20ft → 2, 40ft → 1. Only the types the
|
||||||
|
* caller actually needs are looked up. Unknown or non-positive sizes fall
|
||||||
|
* back to 1, which leaves a PER_CONTAINER rate unscaled — the pre-existing
|
||||||
|
* behaviour, so a missing size can never inflate a bill.
|
||||||
|
*/
|
||||||
|
private async containersPerWagonByTypeId(
|
||||||
|
weightResults: ContainerWeightResult[],
|
||||||
|
): Promise<Map<string, number>> {
|
||||||
|
const perWagon = new Map<string, number>();
|
||||||
|
const ids = [...new Set(weightResults.map((w) => w.containerTypeId).filter(Boolean))];
|
||||||
|
if (ids.length === 0) return perWagon;
|
||||||
|
|
||||||
|
let rows: Array<{ id: string; size_ft: string | number | null }> = [];
|
||||||
|
try {
|
||||||
|
rows = await this.dataSource.query(
|
||||||
|
'SELECT id, size_ft FROM freight.container_types WHERE id = ANY($1)',
|
||||||
|
[ids],
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// Size lookup unavailable — fall back to an unscaled (×1) rate, the
|
||||||
|
// behaviour before per-wagon normalisation. Never fail pricing over it.
|
||||||
|
return perWagon;
|
||||||
|
}
|
||||||
|
const WAGON_SLOT_FT = 40;
|
||||||
|
for (const row of rows) {
|
||||||
|
const sizeFt = Number(row.size_ft ?? 0);
|
||||||
|
perWagon.set(
|
||||||
|
row.id,
|
||||||
|
sizeFt > 0 ? Math.max(1, Math.floor(WAGON_SLOT_FT / sizeFt)) : 1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return perWagon;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Empty-container return — sold per direction + route + container type, like
|
* Empty-container return — sold per direction + route + container type, like
|
||||||
* base freight. Each container line that opted in (returnQuantity, or every
|
* base freight. Each container line that opted in (returnQuantity, or every
|
||||||
|
|||||||
@@ -3967,6 +3967,11 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
schedulingStatus: "SCHEDULED",
|
schedulingStatus: "SCHEDULED",
|
||||||
scheduledAt: new Date(),
|
scheduledAt: new Date(),
|
||||||
wagonsRequired,
|
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,
|
paymentDeadline: null,
|
||||||
selectedForBatchAt: null,
|
selectedForBatchAt: null,
|
||||||
} as never);
|
} as never);
|
||||||
|
|||||||
@@ -31,7 +31,11 @@ import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity';
|
|||||||
import { assertExportReceivedWithGrn, DIRECT_TO_TRAIN } from '../../common/export-received-gate';
|
import { assertExportReceivedWithGrn, DIRECT_TO_TRAIN } from '../../common/export-received-gate';
|
||||||
import { NotificationsService } from '../notifications/notifications.service';
|
import { NotificationsService } from '../notifications/notifications.service';
|
||||||
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
|
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
|
||||||
import { notifyCarriageAcceptanceReady } from '../notifications/notify-company.util';
|
import {
|
||||||
|
notifyCarriageAcceptanceReady,
|
||||||
|
notifyLoadManifest,
|
||||||
|
} from '../notifications/notify-company.util';
|
||||||
|
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Per-booking journey along a train's corridor — for EVERY trade direction.
|
* Per-booking journey along a train's corridor — for EVERY trade direction.
|
||||||
@@ -267,6 +271,20 @@ export class BookingJourneyService {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// What actually boarded, and what did not. A booking is routinely loaded in
|
||||||
|
// parts; the customer is told both halves, and the warehouse desk is told
|
||||||
|
// about the leftovers so somebody owns placing them. After the transaction:
|
||||||
|
// the lists are read back from the allocation statuses it just wrote.
|
||||||
|
void notifyLoadManifest(
|
||||||
|
this.dataSource,
|
||||||
|
this.notifications,
|
||||||
|
this.inbox,
|
||||||
|
bookingId,
|
||||||
|
scheduleId,
|
||||||
|
FREIGHT_PERMS.warehouseInventory.getNotification,
|
||||||
|
this.logger,
|
||||||
|
);
|
||||||
|
|
||||||
// Customer tracking: cargo is on the train — loading milestones plus the
|
// Customer tracking: cargo is on the train — loading milestones plus the
|
||||||
// direction's "departed" handoff. Doc-trigger path no-ops non-customs
|
// direction's "departed" handoff. Doc-trigger path no-ops non-customs
|
||||||
// bookings (intercity) and already-completed codes.
|
// bookings (intercity) and already-completed codes.
|
||||||
|
|||||||
@@ -39,15 +39,30 @@ export class BookingNotifierService {
|
|||||||
try {
|
try {
|
||||||
const s = await this.trainSchedules.findByIdWithStations(scheduleId);
|
const s = await this.trainSchedules.findByIdWithStations(scheduleId);
|
||||||
if (!s) return fallback;
|
if (!s) return fallback;
|
||||||
const ref = s.reference ?? s.trainNumber ?? null;
|
// Customers know the train by its operating number (8001), not the
|
||||||
const route =
|
// 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})`
|
? `${s.originStation.label} → ${s.destinationStation.label}`
|
||||||
: '';
|
: null,
|
||||||
|
].filter(Boolean);
|
||||||
|
const detail = parts.length ? ` (${parts.join(', ')})` : '';
|
||||||
const departure = s.scheduledDepartureDate
|
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) {
|
} catch (err) {
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
`scheduleLabel(${scheduleId}) failed: ${(err as Error).message}`,
|
`scheduleLabel(${scheduleId}) failed: ${(err as Error).message}`,
|
||||||
|
|||||||
@@ -333,7 +333,9 @@ export class RemainderPlacementService {
|
|||||||
|
|
||||||
return deferred.map((u) => ({
|
return deferred.map((u) => ({
|
||||||
containerNumber: u.containerNumber,
|
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),
|
vgmTons: Number(u.vgmTons),
|
||||||
isHazardous: u.isHazardous,
|
isHazardous: u.isHazardous,
|
||||||
isReefer: u.isReefer,
|
isReefer: u.isReefer,
|
||||||
|
|||||||
@@ -1134,7 +1134,7 @@ describe('TrainSchedulingService', () => {
|
|||||||
expect(html).toContain('2 (1 empty)');
|
expect(html).toContain('2 (1 empty)');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('marks a leg slot on the import document as TO BE LOADED and keeps it out of the loaded tallies', () => {
|
it('drops a leg slot entirely from the import document — not part of the departing consist', () => {
|
||||||
const loadList = {
|
const loadList = {
|
||||||
generatedAt: '2026-07-17T08:00:00.000Z',
|
generatedAt: '2026-07-17T08:00:00.000Z',
|
||||||
trainScheduleId: 'schedule-1',
|
trainScheduleId: 'schedule-1',
|
||||||
@@ -1176,15 +1176,16 @@ describe('TrainSchedulingService', () => {
|
|||||||
buildImportLoadListHtml: (l: unknown) => string;
|
buildImportLoadListHtml: (l: unknown) => string;
|
||||||
}).buildImportLoadListHtml(loadList);
|
}).buildImportLoadListHtml(loadList);
|
||||||
|
|
||||||
expect(html).toContain('TO BE LOADED AT DIRE DAWA PORT');
|
// The leg slot (W-ICY, boards later at Dire Dawa) gets no row at all —
|
||||||
// Departure station of the leg slot is its board yard, not the origin.
|
// it isn't on the departing consist. Only W-IMP appears.
|
||||||
expect(html).toContain('<td>Dire Dawa Port</td>');
|
expect(html).not.toContain('W-ICY');
|
||||||
// Only the origin-loaded container counts; the leg slot's tallies separately.
|
expect(html).not.toContain('ICY-001');
|
||||||
|
expect(html).toContain('W-IMP');
|
||||||
|
expect(html).toContain('<span>Wagons</span><strong>1</strong>');
|
||||||
expect(html).toContain('<span>Total containers</span><strong>1</strong>');
|
expect(html).toContain('<span>Total containers</span><strong>1</strong>');
|
||||||
expect(html).toContain('<span>To load en route</span><strong>1 containers</strong>');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('marks a leg slot on the export document as TO LOAD AT its board yard and keeps it out of the tallies', () => {
|
it('drops a leg slot entirely from the export document — not part of the departing consist', () => {
|
||||||
const sizedAllocation = {
|
const sizedAllocation = {
|
||||||
...loadedAllocation,
|
...loadedAllocation,
|
||||||
containerItems: [{ containerNumber: 'CONT-001', containerType: { sizeFt: 20 } }],
|
containerItems: [{ containerNumber: 'CONT-001', containerType: { sizeFt: 20 } }],
|
||||||
@@ -1204,9 +1205,10 @@ describe('TrainSchedulingService', () => {
|
|||||||
pendingBoardYardLabelBySlot: new Map([['slot-leg', 'Dire Dawa Port']]),
|
pendingBoardYardLabelBySlot: new Map([['slot-leg', 'Dire Dawa Port']]),
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(html).toContain('TO LOAD AT DIRE DAWA PORT');
|
// The leg slot (W-LEG, boards later at Dire Dawa) gets no row at all.
|
||||||
|
expect(html).not.toContain('W-LEG');
|
||||||
|
expect(html).toContain('<span>Wagons</span><strong>1</strong>');
|
||||||
expect(html).toContain('<span>Total containers</span><strong>1</strong>');
|
expect(html).toContain('<span>Total containers</span><strong>1</strong>');
|
||||||
expect(html).toContain('<span>To load en route</span><strong>1 containers</strong>');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('prints the consist-changes table for this stop, and omits it when there are none', () => {
|
it('prints the consist-changes table for this stop, and omits it when there are none', () => {
|
||||||
@@ -1240,6 +1242,44 @@ describe('TrainSchedulingService', () => {
|
|||||||
expect(withoutChanges).not.toContain('Consist Changed At This Stop');
|
expect(withoutChanges).not.toContain('Consist Changed At This Stop');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('shows per-row Departure/Arrival Station — schedule endpoints for a whole-route wagon, its own board/alight yard for a leg slot', () => {
|
||||||
|
const wholeRoute = { ...makeWagon(1, 'W-001', [loadedAllocation]), id: 'slot-1' };
|
||||||
|
const legSlot = {
|
||||||
|
...makeWagon(2, 'W-LEG', [loadedAllocation]),
|
||||||
|
id: 'slot-leg',
|
||||||
|
boardYardId: 'yard-dire',
|
||||||
|
alightYardId: 'yard-adama',
|
||||||
|
};
|
||||||
|
const schedule = {
|
||||||
|
id: 'schedule-1',
|
||||||
|
trainNumber: '8302',
|
||||||
|
direction: 'EXPORT',
|
||||||
|
originStation: { label: 'DCT/SGTD' },
|
||||||
|
destinationStation: { label: 'GMP (Gelan Multipurpose Port)' },
|
||||||
|
trainSet: { wagons: [wholeRoute, legSlot] },
|
||||||
|
scheduleBookings: [],
|
||||||
|
};
|
||||||
|
const build = (service as never as {
|
||||||
|
buildExportLoadListHtml: (s: unknown, o?: unknown) => string;
|
||||||
|
}).buildExportLoadListHtml.bind(service);
|
||||||
|
|
||||||
|
const html = build(schedule, {
|
||||||
|
yardLabelById: new Map([
|
||||||
|
['yard-dire', 'Dire Dawa Port'],
|
||||||
|
['yard-adama', 'Adama'],
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(html).toContain('<th>Departure Station</th>');
|
||||||
|
expect(html).toContain('<th>Arrival Station</th>');
|
||||||
|
// Whole-route wagon: schedule's own endpoints.
|
||||||
|
expect(html).toContain('<td>DCT/SGTD</td>');
|
||||||
|
expect(html).toContain('<td>GMP (Gelan Multipurpose Port)</td>');
|
||||||
|
// Leg slot: its own board/alight yard, not the schedule's endpoints.
|
||||||
|
expect(html).toContain('<td>Dire Dawa Port</td>');
|
||||||
|
expect(html).toContain('<td>Adama</td>');
|
||||||
|
});
|
||||||
|
|
||||||
it('lists loaded empty containers by number and states they are empty', () => {
|
it('lists loaded empty containers by number and states they are empty', () => {
|
||||||
const schedule = {
|
const schedule = {
|
||||||
id: 'schedule-1',
|
id: 'schedule-1',
|
||||||
@@ -1404,6 +1444,30 @@ describe('TrainSchedulingService', () => {
|
|||||||
expect(numbers).toEqual(['W-LEG2']);
|
expect(numbers).toEqual(['W-LEG2']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('drops a leg slot LOADED by generation time but not yet coupled as of this stop', () => {
|
||||||
|
// Both W-DIRE (coupled+loaded at Dire Dawa) and W-ADAMA (coupled+loaded
|
||||||
|
// at Adama, a LATER stop) read identically to intercityOnBoardView by
|
||||||
|
// the time this runs — both LOADED right now. Only the adjustment log
|
||||||
|
// knows W-ADAMA hadn't coupled yet as of Dire Dawa's own timestamp.
|
||||||
|
const wholeRoute = makeWagon(1, 'W-001', [allocWith({ status: 'LOADED' })]);
|
||||||
|
const legDireDawa = { ...makeWagon(2, 'W-DIRE', [allocWith({ status: 'LOADED' })]), boardYardId: 'yard-dire' };
|
||||||
|
const legAdama = { ...makeWagon(3, 'W-ADAMA', [allocWith({ status: 'LOADED' })]), boardYardId: 'yard-adama' };
|
||||||
|
const schedule = { trainSet: { wagons: [wholeRoute, legDireDawa, legAdama] }, scheduleBookings: [] };
|
||||||
|
|
||||||
|
const { wagons } = onBoardView(schedule);
|
||||||
|
const boardedByDireDawa = new Set(['W-DIRE']); // logged ADD only up to Dire Dawa's stop
|
||||||
|
const wagonsAsOfStop = (service as never as {
|
||||||
|
wagonsAsOfStop: (w: unknown, s: Set<string>) => Array<{ physicalWagon: { wagonNumber: string } }>;
|
||||||
|
}).wagonsAsOfStop.bind(service);
|
||||||
|
|
||||||
|
const asOfDireDawa = wagonsAsOfStop(wagons, boardedByDireDawa);
|
||||||
|
expect(asOfDireDawa.map((w) => w.physicalWagon.wagonNumber)).toEqual(['W-001', 'W-DIRE']);
|
||||||
|
|
||||||
|
const boardedByAdama = new Set(['W-DIRE', 'W-ADAMA']); // both stops have now happened
|
||||||
|
const asOfAdama = wagonsAsOfStop(wagons, boardedByAdama);
|
||||||
|
expect(asOfAdama.map((w) => w.physicalWagon.wagonNumber)).toEqual(['W-001', 'W-DIRE', 'W-ADAMA']);
|
||||||
|
});
|
||||||
|
|
||||||
it('lists an IN_TRANSIT booking with no wagon allocation in the unassigned section', () => {
|
it('lists an IN_TRANSIT booking with no wagon allocation in the unassigned section', () => {
|
||||||
const rider = {
|
const rider = {
|
||||||
id: 'booking-9',
|
id: 'booking-9',
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
ILike,
|
ILike,
|
||||||
In,
|
In,
|
||||||
IsNull,
|
IsNull,
|
||||||
|
LessThanOrEqual,
|
||||||
Not,
|
Not,
|
||||||
QueryFailedError,
|
QueryFailedError,
|
||||||
Raw,
|
Raw,
|
||||||
@@ -2320,12 +2321,18 @@ export class TrainSchedulingService {
|
|||||||
// batch fill, which unlinks it and frees its wagons on the next window cycle.
|
// batch fill, which unlinks it and frees its wagons on the next window cycle.
|
||||||
const scheduledAt = new Date();
|
const scheduledAt = new Date();
|
||||||
for (const booking of bookings) {
|
for (const booking of bookings) {
|
||||||
|
const wagonsRequired = sumWagonsRequired(booking, wagonPlan);
|
||||||
await this.bookingsRepository.updateSchedulingFields(
|
await this.bookingsRepository.updateSchedulingFields(
|
||||||
booking.id,
|
booking.id,
|
||||||
{
|
{
|
||||||
schedulingStatus: SchedulingStatus.Scheduled,
|
schedulingStatus: SchedulingStatus.Scheduled,
|
||||||
scheduledAt,
|
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,
|
manager,
|
||||||
);
|
);
|
||||||
@@ -3550,17 +3557,19 @@ export class TrainSchedulingService {
|
|||||||
|
|
||||||
// Leg slots couple mid-corridor — this origin document must say where their
|
// Leg slots couple mid-corridor — this origin document must say where their
|
||||||
// cargo boards instead of listing it as loaded here (see the import list).
|
// cargo boards instead of listing it as loaded here (see the import list).
|
||||||
const slotYardLabels = await this.yardLabelsById(
|
// Also doubles as the per-row Departure/Arrival Station lookup below.
|
||||||
(schedule.trainSet?.wagons ?? []).map((wagon) => wagon.boardYardId),
|
const yardLabelById = await this.yardLabelsById(
|
||||||
|
(schedule.trainSet?.wagons ?? []).flatMap((wagon) => [wagon.boardYardId, wagon.alightYardId]),
|
||||||
);
|
);
|
||||||
const pendingBoardYardLabelBySlot = new Map(
|
const pendingBoardYardLabelBySlot = new Map(
|
||||||
(schedule.trainSet?.wagons ?? [])
|
(schedule.trainSet?.wagons ?? [])
|
||||||
.filter((wagon) => wagon.boardYardId)
|
.filter((wagon) => wagon.boardYardId)
|
||||||
.map((wagon) => [wagon.id, slotYardLabels.get(wagon.boardYardId!) ?? 'en route']),
|
.map((wagon) => [wagon.id, yardLabelById.get(wagon.boardYardId!) ?? 'en route']),
|
||||||
);
|
);
|
||||||
|
|
||||||
const html = this.buildExportLoadListHtml(schedule, {
|
const html = this.buildExportLoadListHtml(schedule, {
|
||||||
pendingBoardYardLabelBySlot,
|
pendingBoardYardLabelBySlot,
|
||||||
|
yardLabelById,
|
||||||
emptyContainers: await this.loadedEmptyContainers(scheduleId),
|
emptyContainers: await this.loadedEmptyContainers(scheduleId),
|
||||||
logoImageUrl: await this.logoSettings.getLogoImageUrl(),
|
logoImageUrl: await this.logoSettings.getLogoImageUrl(),
|
||||||
});
|
});
|
||||||
@@ -3617,6 +3626,24 @@ export class TrainSchedulingService {
|
|||||||
return { wagons, unassignedBookings };
|
return { wagons, unassignedBookings };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Corrects intercityOnBoardView's CURRENT-state wagon list against a
|
||||||
|
* specific stop's document. intercityOnBoardView's "boardYardId == null ||
|
||||||
|
* hasLoaded" test reads whatever is true RIGHT NOW — it can't distinguish
|
||||||
|
* "this leg slot coupled at THIS stop" from "it coupled at a LATER stop
|
||||||
|
* that has, by generation time, also already happened" (both look LOADED).
|
||||||
|
* Reprinting an earlier stop's document after a later one has run would
|
||||||
|
* otherwise leak the later stop's wagons in. `boardedWagonNumbers` is the
|
||||||
|
* set of physical wagon numbers with a logged ADD at or before this stop
|
||||||
|
* (see marshallingDocumentAt) — the ground truth a real-time heuristic
|
||||||
|
* can't provide once multiple stops have already happened.
|
||||||
|
*/
|
||||||
|
private wagonsAsOfStop(wagons: TrainSetWagon[], boardedWagonNumbers: Set<string>): TrainSetWagon[] {
|
||||||
|
return wagons.filter(
|
||||||
|
(wagon) => wagon.boardYardId == null || boardedWagonNumbers.has(wagon.physicalWagon?.wagonNumber ?? ''),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Every corridor stop where the consist actually changed for this schedule
|
* Every corridor stop where the consist actually changed for this schedule
|
||||||
* (coupled, uncoupled, or switched — any flavor), in the order the train
|
* (coupled, uncoupled, or switched — any flavor), in the order the train
|
||||||
@@ -3711,11 +3738,37 @@ export class TrainSchedulingService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { wagons, unassignedBookings } = this.intercityOnBoardView(schedule);
|
const { wagons: currentWagons, unassignedBookings } = this.intercityOnBoardView(schedule);
|
||||||
|
// intercityOnBoardView's "boardYardId == null || hasLoaded" test reads
|
||||||
|
// CURRENT state — it can't tell "coupled here" from "coupled at a LATER
|
||||||
|
// stop that has since also happened" (both look LOADED by generation
|
||||||
|
// time once the trip has moved past this stop). Reprinting Marshalling 2
|
||||||
|
// after Marshalling 3's stop already ran would otherwise show Marshalling
|
||||||
|
// 3's coupled wagons too. Correct it against the log: a leg-slot wagon
|
||||||
|
// belongs on THIS stop's document only if it actually has a logged ADD
|
||||||
|
// at or before THIS stop's own timestamp.
|
||||||
|
const boardedByThisStop = new Set(
|
||||||
|
(
|
||||||
|
await this.dataSource.getRepository(ScheduleWagonAdjustmentLog).find({
|
||||||
|
where: {
|
||||||
|
trainScheduleId: scheduleId,
|
||||||
|
action: 'ADD',
|
||||||
|
occurredAt: LessThanOrEqual(new Date(stop.firstOccurredAt)),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).map((row) => row.wagonNumber),
|
||||||
|
);
|
||||||
|
const wagons = this.wagonsAsOfStop(currentWagons, boardedByThisStop);
|
||||||
const logRows = await this.dataSource.getRepository(ScheduleWagonAdjustmentLog).find({
|
const logRows = await this.dataSource.getRepository(ScheduleWagonAdjustmentLog).find({
|
||||||
where: { trainScheduleId: scheduleId, yardId: stop.yardId },
|
where: { trainScheduleId: scheduleId, yardId: stop.yardId },
|
||||||
order: { occurredAt: 'ASC' },
|
order: { occurredAt: 'ASC' },
|
||||||
});
|
});
|
||||||
|
// Per-row Departure/Arrival Station: a whole-route wagon reads the
|
||||||
|
// schedule's own origin/destination, a leg-slot wagon reads where IT
|
||||||
|
// boards/alights instead.
|
||||||
|
const yardLabelById = await this.yardLabelsById(
|
||||||
|
(schedule.trainSet?.wagons ?? []).flatMap((wagon) => [wagon.boardYardId, wagon.alightYardId]),
|
||||||
|
);
|
||||||
const html = this.buildExportLoadListHtml(schedule, {
|
const html = this.buildExportLoadListHtml(schedule, {
|
||||||
title: `Intercity Marshalling Document / Load List (Marshalling ${stopIndex})`,
|
title: `Intercity Marshalling Document / Load List (Marshalling ${stopIndex})`,
|
||||||
positionLabel: `At ${stop.yardLabel}`,
|
positionLabel: `At ${stop.yardLabel}`,
|
||||||
@@ -3724,6 +3777,7 @@ export class TrainSchedulingService {
|
|||||||
emptyContainers: await this.loadedEmptyContainers(scheduleId),
|
emptyContainers: await this.loadedEmptyContainers(scheduleId),
|
||||||
logoImageUrl: await this.logoSettings.getLogoImageUrl(),
|
logoImageUrl: await this.logoSettings.getLogoImageUrl(),
|
||||||
consistChangesAtStop: this.consistChangesAt(schedule, logRows),
|
consistChangesAtStop: this.consistChangesAt(schedule, logRows),
|
||||||
|
yardLabelById,
|
||||||
});
|
});
|
||||||
// Styled table-aware fallback (marshalling grid) — see importLoadListDocument.
|
// Styled table-aware fallback (marshalling grid) — see importLoadListDocument.
|
||||||
const buffer = await this.pdfDocuments.renderTabularDocument(html, `Marshalling ${stopIndex}`);
|
const buffer = await this.pdfDocuments.renderTabularDocument(html, `Marshalling ${stopIndex}`);
|
||||||
@@ -3763,6 +3817,9 @@ export class TrainSchedulingService {
|
|||||||
? `After ${last.yard?.label ?? last.yard?.code ?? 'checkpoint'}`
|
? `After ${last.yard?.label ?? last.yard?.code ?? 'checkpoint'}`
|
||||||
: `At ${schedule.originStation?.label ?? schedule.originStation?.code ?? 'origin'} — no checkpoint recorded`;
|
: `At ${schedule.originStation?.label ?? schedule.originStation?.code ?? 'origin'} — no checkpoint recorded`;
|
||||||
const { wagons, unassignedBookings } = this.intercityOnBoardView(schedule);
|
const { wagons, unassignedBookings } = this.intercityOnBoardView(schedule);
|
||||||
|
const yardLabelById = await this.yardLabelsById(
|
||||||
|
(schedule.trainSet?.wagons ?? []).flatMap((wagon) => [wagon.boardYardId, wagon.alightYardId]),
|
||||||
|
);
|
||||||
const html = this.buildExportLoadListHtml(schedule, {
|
const html = this.buildExportLoadListHtml(schedule, {
|
||||||
title: 'Intercity Marshalling Document / Load List (Marshalling 2)',
|
title: 'Intercity Marshalling Document / Load List (Marshalling 2)',
|
||||||
positionLabel,
|
positionLabel,
|
||||||
@@ -3770,6 +3827,7 @@ export class TrainSchedulingService {
|
|||||||
unassignedBookings,
|
unassignedBookings,
|
||||||
emptyContainers: await this.loadedEmptyContainers(scheduleId),
|
emptyContainers: await this.loadedEmptyContainers(scheduleId),
|
||||||
logoImageUrl: await this.logoSettings.getLogoImageUrl(),
|
logoImageUrl: await this.logoSettings.getLogoImageUrl(),
|
||||||
|
yardLabelById,
|
||||||
});
|
});
|
||||||
// Styled table-aware fallback (marshalling grid) — see importLoadListDocument.
|
// Styled table-aware fallback (marshalling grid) — see importLoadListDocument.
|
||||||
const buffer = await this.pdfDocuments.renderTabularDocument(html, 'Intercity marshalling / load list');
|
const buffer = await this.pdfDocuments.renderTabularDocument(html, 'Intercity marshalling / load list');
|
||||||
@@ -3839,6 +3897,10 @@ export class TrainSchedulingService {
|
|||||||
// Slots that couple to the train downstream (slot id → board yard label).
|
// 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.
|
// Their cargo renders as TO LOAD AT and stays out of the loaded tallies.
|
||||||
pendingBoardYardLabelBySlot?: Map<string, string>;
|
pendingBoardYardLabelBySlot?: Map<string, string>;
|
||||||
|
// yardId → label, for the per-row Departure/Arrival Station columns
|
||||||
|
// (falls back to the schedule's own origin/destination when a wagon's
|
||||||
|
// boardYardId/alightYardId is null — i.e. it rides the whole corridor).
|
||||||
|
yardLabelById?: Map<string, string>;
|
||||||
// Numbered marshalling docs only (see marshallingDocumentAt /
|
// Numbered marshalling docs only (see marshallingDocumentAt /
|
||||||
// consistChangesAt) — couples/uncouples/switches logged at THIS stop.
|
// consistChangesAt) — couples/uncouples/switches logged at THIS stop.
|
||||||
// Origin import/export docs never pass this, so they render no such box.
|
// Origin import/export docs never pass this, so they render no such box.
|
||||||
@@ -3860,10 +3922,15 @@ export class TrainSchedulingService {
|
|||||||
const time = (value: unknown) => (value ? new Date(value as string | Date).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' }) : '-');
|
const time = (value: unknown) => (value ? new Date(value as string | Date).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' }) : '-');
|
||||||
const bookingById = new Map((schedule.scheduleBookings ?? []).map((link) => [link.bookingId, link.booking]));
|
const bookingById = new Map((schedule.scheduleBookings ?? []).map((link) => [link.bookingId, link.booking]));
|
||||||
// The document is checked against the physical train, so it has to run in
|
// The document is checked against the physical train, so it has to run in
|
||||||
// consist order — the relation comes back unordered.
|
// consist order — the relation comes back unordered. Slots planned to
|
||||||
const wagons = [...(opts?.wagons ?? schedule.trainSet?.wagons ?? [])].sort(
|
// couple at a LATER stop (pendingBoardYardLabelBySlot, origin docs only —
|
||||||
(a, b) => Number(a.sequenceNo ?? 0) - Number(b.sequenceNo ?? 0),
|
// intercity calls never pass it, their wagons list is already on-board
|
||||||
);
|
// only) are dropped here, not just tallied around: they are not part of
|
||||||
|
// the departing consist, so they get no row and no count on this document.
|
||||||
|
// Their own coupling shows up on THAT stop's own marshalling document.
|
||||||
|
const wagons = [...(opts?.wagons ?? schedule.trainSet?.wagons ?? [])]
|
||||||
|
.filter((wagon) => !opts?.pendingBoardYardLabelBySlot?.get(wagon.id))
|
||||||
|
.sort((a, b) => Number(a.sequenceNo ?? 0) - Number(b.sequenceNo ?? 0));
|
||||||
// Empties sit on wagons that carry no booking allocation, keyed by the wagon
|
// Empties sit on wagons that carry no booking allocation, keyed by the wagon
|
||||||
// slot recorded when they were loaded.
|
// slot recorded when they were loaded.
|
||||||
const emptiesByWagon = new Map<number, EmptyContainerReturn[]>();
|
const emptiesByWagon = new Map<number, EmptyContainerReturn[]>();
|
||||||
@@ -3874,15 +3941,28 @@ export class TrainSchedulingService {
|
|||||||
empty,
|
empty,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
const originLabel = schedule.originStation?.label ?? schedule.originStation?.code;
|
||||||
|
const destinationLabel = schedule.destinationStation?.label ?? schedule.destinationStation?.code;
|
||||||
const rows = wagons
|
const rows = wagons
|
||||||
.flatMap((wagon) => {
|
.flatMap((wagon) => {
|
||||||
|
// Departure/Arrival Station per row: a leg-slot wagon boards/alights
|
||||||
|
// somewhere other than the schedule's own endpoints; a whole-route
|
||||||
|
// wagon just reads origin/destination.
|
||||||
|
const departureLabel = wagon.boardYardId
|
||||||
|
? (opts?.yardLabelById?.get(wagon.boardYardId) ?? 'en route')
|
||||||
|
: originLabel;
|
||||||
|
const arrivalLabel = wagon.alightYardId
|
||||||
|
? (opts?.yardLabelById?.get(wagon.alightYardId) ?? 'en route')
|
||||||
|
: destinationLabel;
|
||||||
// Wagon identity is the same on every row the wagon produces, loaded or not.
|
// Wagon identity is the same on every row the wagon produces, loaded or not.
|
||||||
const wagonCells = `<td>${esc(wagon.sequenceNo)}</td>
|
const wagonCells = `<td>${esc(wagon.sequenceNo)}</td>
|
||||||
<td>${esc(wagon.physicalWagon?.wagonNumber)}</td>
|
<td>${esc(wagon.physicalWagon?.wagonNumber)}</td>
|
||||||
<td>${esc(wagon.wagonType?.code ?? wagon.wagonType?.name)}</td>
|
<td>${esc(wagon.wagonType?.code ?? wagon.wagonType?.name)}</td>
|
||||||
<td class="num">${esc(Number(wagon.lengthMeters || 0).toFixed(3))}</td>
|
<td class="num">${esc(Number(wagon.lengthMeters || 0).toFixed(3))}</td>
|
||||||
<td class="num">${esc(Number(wagon.wagonType?.tareWeightTons ?? 0).toFixed(2))}</td>
|
<td class="num">${esc(Number(wagon.wagonType?.tareWeightTons ?? 0).toFixed(2))}</td>
|
||||||
<td class="num">${esc(Number(wagon.capacityTons || 0).toFixed(3))}</td>`;
|
<td class="num">${esc(Number(wagon.capacityTons || 0).toFixed(3))}</td>
|
||||||
|
<td>${esc(departureLabel)}</td>
|
||||||
|
<td>${esc(arrivalLabel)}</td>`;
|
||||||
const allocations = wagon.allocations ?? [];
|
const allocations = wagon.allocations ?? [];
|
||||||
// An empty wagon still runs in the consist, so it still gets a line. Staff
|
// An empty wagon still runs in the consist, so it still gets a line. Staff
|
||||||
// check this document against the physical train — a wagon with no row
|
// check this document against the physical train — a wagon with no row
|
||||||
@@ -3906,11 +3986,10 @@ export class TrainSchedulingService {
|
|||||||
return [
|
return [
|
||||||
`<tr class="empty">
|
`<tr class="empty">
|
||||||
${wagonCells}
|
${wagonCells}
|
||||||
<td colspan="4">EMPTY — no cargo allocated</td>
|
<td colspan="5">EMPTY — no cargo allocated</td>
|
||||||
</tr>`,
|
</tr>`,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
const pendingAt = opts?.pendingBoardYardLabelBySlot?.get(wagon.id);
|
|
||||||
return allocations.map((allocation) => {
|
return allocations.map((allocation) => {
|
||||||
const booking = allocation.booking ?? bookingById.get(allocation.bookingId);
|
const booking = allocation.booking ?? bookingById.get(allocation.bookingId);
|
||||||
const cargoType = (booking as unknown as { cargoType?: { name?: string; code?: string } } | undefined)?.cargoType;
|
const cargoType = (booking as unknown as { cargoType?: { name?: string; code?: string } } | undefined)?.cargoType;
|
||||||
@@ -3922,7 +4001,7 @@ export class TrainSchedulingService {
|
|||||||
const chassisNumbers = containerItems.map((item) => item.chassisNumber).filter(Boolean).join(', ');
|
const chassisNumbers = containerItems.map((item) => item.chassisNumber).filter(Boolean).join(', ');
|
||||||
return `<tr>
|
return `<tr>
|
||||||
${wagonCells}
|
${wagonCells}
|
||||||
<td>${pendingAt ? `TO LOAD AT ${esc(pendingAt).toUpperCase()} — ` : ''}${esc(cargoType?.name ?? cargoType?.code ?? allocation.loadType)}</td>
|
<td>${esc(cargoType?.name ?? cargoType?.code ?? allocation.loadType)}</td>
|
||||||
<td>${esc(companyName)}</td>
|
<td>${esc(companyName)}</td>
|
||||||
<td>${esc(containerNumbers || firstContainer?.containerNumber)}</td>
|
<td>${esc(containerNumbers || firstContainer?.containerNumber)}</td>
|
||||||
<td>${esc(chassisNumbers)}</td>
|
<td>${esc(chassisNumbers)}</td>
|
||||||
@@ -3935,7 +4014,7 @@ export class TrainSchedulingService {
|
|||||||
// they are still physically on the train, so they get rows of their own.
|
// they are still physically on the train, so they get rows of their own.
|
||||||
const unassigned = opts?.unassignedBookings ?? [];
|
const unassigned = opts?.unassignedBookings ?? [];
|
||||||
const unassignedRows = unassigned.length
|
const unassignedRows = unassigned.length
|
||||||
? `<tr class="empty"><td colspan="11">ON BOARD — WAGON NOT RECORDED</td></tr>` +
|
? `<tr class="empty"><td colspan="13">ON BOARD — WAGON NOT RECORDED</td></tr>` +
|
||||||
unassigned
|
unassigned
|
||||||
.map((booking) => {
|
.map((booking) => {
|
||||||
const containerNumbers = (booking.bookingContainers ?? [])
|
const containerNumbers = (booking.bookingContainers ?? [])
|
||||||
@@ -3944,7 +4023,7 @@ export class TrainSchedulingService {
|
|||||||
.join(', ');
|
.join(', ');
|
||||||
const leg = `${booking.originYard?.label ?? booking.originYard?.code ?? '-'} → ${booking.destinationYard?.label ?? booking.destinationYard?.code ?? '-'}`;
|
const leg = `${booking.originYard?.label ?? booking.originYard?.code ?? '-'} → ${booking.destinationYard?.label ?? booking.destinationYard?.code ?? '-'}`;
|
||||||
return `<tr>
|
return `<tr>
|
||||||
<td colspan="6">${esc(booking.reference)} — ${esc(leg)}</td>
|
<td colspan="8">${esc(booking.reference)} — ${esc(leg)}</td>
|
||||||
<td>${esc(booking.cargoType?.cargoTypeName ?? booking.cargoType?.code)}</td>
|
<td>${esc(booking.cargoType?.cargoTypeName ?? booking.cargoType?.code)}</td>
|
||||||
<td>${esc(booking.company?.name)}</td>
|
<td>${esc(booking.company?.name)}</td>
|
||||||
<td>${esc(containerNumbers)}</td>
|
<td>${esc(containerNumbers)}</td>
|
||||||
@@ -3959,27 +4038,20 @@ export class TrainSchedulingService {
|
|||||||
(wagon.allocations ?? []).length === 0 &&
|
(wagon.allocations ?? []).length === 0 &&
|
||||||
!emptiesByWagon.get(Number(wagon.sequenceNo))?.length,
|
!emptiesByWagon.get(Number(wagon.sequenceNo))?.length,
|
||||||
).length;
|
).length;
|
||||||
const loadsHere = (wagon: TrainSetWagon) => !opts?.pendingBoardYardLabelBySlot?.get(wagon.id);
|
|
||||||
const totalWeight = wagons.reduce(
|
const totalWeight = wagons.reduce(
|
||||||
(sum, wagon) =>
|
(sum, wagon) =>
|
||||||
sum +
|
sum + (wagon.allocations ?? []).reduce((wagonSum, allocation) => wagonSum + Number(allocation.allocatedWeightTons || 0), 0),
|
||||||
(loadsHere(wagon)
|
|
||||||
? (wagon.allocations ?? []).reduce((wagonSum, allocation) => wagonSum + Number(allocation.allocatedWeightTons || 0), 0)
|
|
||||||
: 0),
|
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Container count summary (40ft, 20ft) — empties returning to Djibouti are
|
// Container count summary (40ft, 20ft) — empties returning to Djibouti are
|
||||||
// physically on the train, so they count, and are called out on their own tile.
|
// physically on the train, so they count, and are called out on their own
|
||||||
// Cargo boarding downstream is not on this train yet — it tallies separately.
|
// tile. Cargo boarding downstream never enters this loop — `wagons` above
|
||||||
let count40ft = 0, count20ft = 0, pendingContainers = 0;
|
// already excludes those slots.
|
||||||
|
let count40ft = 0, count20ft = 0;
|
||||||
wagons.forEach((wagon) => {
|
wagons.forEach((wagon) => {
|
||||||
(wagon.allocations ?? []).forEach((allocation) => {
|
(wagon.allocations ?? []).forEach((allocation) => {
|
||||||
(allocation.containerItems ?? []).forEach((item) => {
|
(allocation.containerItems ?? []).forEach((item) => {
|
||||||
if (!loadsHere(wagon)) {
|
|
||||||
pendingContainers++;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const size = this.resolveContainerItemSize(item);
|
const size = this.resolveContainerItemSize(item);
|
||||||
if (size === 40) count40ft++;
|
if (size === 40) count40ft++;
|
||||||
else if (size === 20) count20ft++;
|
else if (size === 20) count20ft++;
|
||||||
@@ -4047,7 +4119,6 @@ export class TrainSchedulingService {
|
|||||||
<div class="tile"><span>Containers 40ft</span><strong>${esc(count40ft)}</strong></div>
|
<div class="tile"><span>Containers 40ft</span><strong>${esc(count40ft)}</strong></div>
|
||||||
<div class="tile"><span>Containers 20ft</span><strong>${esc(count20ft)}</strong></div>
|
<div class="tile"><span>Containers 20ft</span><strong>${esc(count20ft)}</strong></div>
|
||||||
<div class="tile"><span>Total containers</span><strong>${esc(count40ft + count20ft)}</strong></div>
|
<div class="tile"><span>Total containers</span><strong>${esc(count40ft + count20ft)}</strong></div>
|
||||||
${pendingContainers ? `<div class="tile"><span>To load en route</span><strong>${esc(pendingContainers)} containers</strong></div>` : ''}
|
|
||||||
${emptyContainers.length ? `<div class="tile"><span>Empty containers</span><strong>${esc(emptyContainers.length)}</strong></div>` : ''}
|
${emptyContainers.length ? `<div class="tile"><span>Empty containers</span><strong>${esc(emptyContainers.length)}</strong></div>` : ''}
|
||||||
<div class="tile"><span>Prepared person</span><strong>${esc(schedule.preparedByUserId)}</strong></div>
|
<div class="tile"><span>Prepared person</span><strong>${esc(schedule.preparedByUserId)}</strong></div>
|
||||||
<div class="tile"><span>Check person</span><strong>${esc(schedule.checkedByUserId)}</strong></div>
|
<div class="tile"><span>Check person</span><strong>${esc(schedule.checkedByUserId)}</strong></div>
|
||||||
@@ -4093,6 +4164,8 @@ export class TrainSchedulingService {
|
|||||||
<th class="num">Equated Length</th>
|
<th class="num">Equated Length</th>
|
||||||
<th class="num">Tare Weight</th>
|
<th class="num">Tare Weight</th>
|
||||||
<th class="num">Load Capacity</th>
|
<th class="num">Load Capacity</th>
|
||||||
|
<th>Departure Station</th>
|
||||||
|
<th>Arrival Station</th>
|
||||||
<th>Cargo Type</th>
|
<th>Cargo Type</th>
|
||||||
<th>Company</th>
|
<th>Company</th>
|
||||||
<th>Container No</th>
|
<th>Container No</th>
|
||||||
@@ -4101,7 +4174,7 @@ export class TrainSchedulingService {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
${rows || '<tr><td colspan="10">No wagons on this train set.</td></tr>'}
|
${rows || '<tr><td colspan="13">No wagons on this train set.</td></tr>'}
|
||||||
${unassignedRows}
|
${unassignedRows}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -4247,30 +4320,23 @@ export class TrainSchedulingService {
|
|||||||
.replace(/'/g, ''');
|
.replace(/'/g, ''');
|
||||||
const date = (value: unknown) => (value ? new Date(value as string | Date).toLocaleString('en-GB') : '-');
|
const date = (value: unknown) => (value ? new Date(value as string | Date).toLocaleString('en-GB') : '-');
|
||||||
const status = loadList.operation.status;
|
const status = loadList.operation.status;
|
||||||
// A leg slot (boardYard set) couples mid-corridor — its cargo is NOT on the
|
// A leg slot (boardYard set) couples mid-corridor — it is not part of the
|
||||||
// physical train this Djibouti-side document is checked against, so it must
|
// consist this Djibouti-side document is checked against yet, so it gets
|
||||||
// stay out of the loaded tallies or the gate count stops matching.
|
// no row and no count here at all. Its own coupling shows up on THAT
|
||||||
const loadsHere = (wagon: (typeof loadList.wagons)[number]) => !wagon.boardYard;
|
// stop's own marshalling document once it actually happens.
|
||||||
const totalAllocations = loadList.wagons.reduce((sum, wagon) => sum + wagon.allocations.length, 0);
|
const wagons = loadList.wagons.filter((wagon) => !wagon.boardYard);
|
||||||
const totalWeight = loadList.wagons.reduce(
|
const totalAllocations = wagons.reduce((sum, wagon) => sum + wagon.allocations.length, 0);
|
||||||
(sum, wagon) =>
|
const totalWeight = wagons.reduce(
|
||||||
sum +
|
(sum, wagon) => sum + wagon.allocations.reduce((wagonSum, allocation) => wagonSum + Number(allocation.allocatedWeightTons || 0), 0),
|
||||||
(loadsHere(wagon)
|
|
||||||
? wagon.allocations.reduce((wagonSum, allocation) => wagonSum + Number(allocation.allocatedWeightTons || 0), 0)
|
|
||||||
: 0),
|
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
const emptyWagons = loadList.wagons.filter((wagon) => wagon.allocations.length === 0).length;
|
const emptyWagons = wagons.filter((wagon) => wagon.allocations.length === 0).length;
|
||||||
|
|
||||||
// Container count summary (40ft, 20ft) — loaded at origin vs. en route
|
// Container count summary (40ft, 20ft)
|
||||||
let count40ft = 0, count20ft = 0, pendingContainers = 0;
|
let count40ft = 0, count20ft = 0;
|
||||||
loadList.wagons.forEach((wagon) => {
|
wagons.forEach((wagon) => {
|
||||||
wagon.allocations.forEach((allocation) => {
|
wagon.allocations.forEach((allocation) => {
|
||||||
(allocation.containerItems ?? []).forEach((item) => {
|
(allocation.containerItems ?? []).forEach((item) => {
|
||||||
if (!loadsHere(wagon)) {
|
|
||||||
pendingContainers++;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const size = this.resolveContainerItemSize(item);
|
const size = this.resolveContainerItemSize(item);
|
||||||
if (size === 40) count40ft++;
|
if (size === 40) count40ft++;
|
||||||
else if (size === 20) count20ft++;
|
else if (size === 20) count20ft++;
|
||||||
@@ -4278,7 +4344,7 @@ export class TrainSchedulingService {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const allocationRows = loadList.wagons
|
const allocationRows = wagons
|
||||||
.flatMap((wagon) => {
|
.flatMap((wagon) => {
|
||||||
const wagonCells = `<td>${esc(wagon.sequenceNo)}</td>
|
const wagonCells = `<td>${esc(wagon.sequenceNo)}</td>
|
||||||
<td>${esc(wagon.wagonNumber)}</td>
|
<td>${esc(wagon.wagonNumber)}</td>
|
||||||
@@ -4311,7 +4377,7 @@ export class TrainSchedulingService {
|
|||||||
<td>${esc(allocation.loadType)}</td>
|
<td>${esc(allocation.loadType)}</td>
|
||||||
<td>${esc(allocation.containerNumbers.length ? allocation.containerNumbers.join(', ') : '-')}</td>
|
<td>${esc(allocation.containerNumbers.length ? allocation.containerNumbers.join(', ') : '-')}</td>
|
||||||
<td>${esc(sealNumbers || '-')}</td>
|
<td>${esc(sealNumbers || '-')}</td>
|
||||||
<td>${wagon.boardYard ? `TO BE LOADED AT ${esc(wagon.boardYard).toUpperCase()}` : ''}</td>
|
<td></td>
|
||||||
<td class="num">${esc(Number(allocation.allocatedWeightTons || 0).toFixed(3))}</td>
|
<td class="num">${esc(Number(allocation.allocatedWeightTons || 0).toFixed(3))}</td>
|
||||||
</tr>`;
|
</tr>`;
|
||||||
},
|
},
|
||||||
@@ -4378,13 +4444,12 @@ export class TrainSchedulingService {
|
|||||||
<div class="tile"><span>Origin</span><strong>${esc(loadList.origin)}</strong></div>
|
<div class="tile"><span>Origin</span><strong>${esc(loadList.origin)}</strong></div>
|
||||||
<div class="tile"><span>Destination</span><strong>${esc(loadList.destination)}</strong></div>
|
<div class="tile"><span>Destination</span><strong>${esc(loadList.destination)}</strong></div>
|
||||||
<div class="tile"><span>Total bookings</span><strong>${esc(loadList.totalBookings)}</strong></div>
|
<div class="tile"><span>Total bookings</span><strong>${esc(loadList.totalBookings)}</strong></div>
|
||||||
<div class="tile"><span>Wagons</span><strong>${esc(loadList.wagons.length)}${emptyWagons ? ` (${emptyWagons} empty)` : ''}</strong></div>
|
<div class="tile"><span>Wagons</span><strong>${esc(wagons.length)}${emptyWagons ? ` (${emptyWagons} empty)` : ''}</strong></div>
|
||||||
<div class="tile"><span>Allocations</span><strong>${esc(totalAllocations)}</strong></div>
|
<div class="tile"><span>Allocations</span><strong>${esc(totalAllocations)}</strong></div>
|
||||||
<div class="tile"><span>Total weight</span><strong>${esc(totalWeight.toFixed(3))} T</strong></div>
|
<div class="tile"><span>Total weight</span><strong>${esc(totalWeight.toFixed(3))} T</strong></div>
|
||||||
<div class="tile"><span>Containers 40ft</span><strong>${esc(count40ft)}</strong></div>
|
<div class="tile"><span>Containers 40ft</span><strong>${esc(count40ft)}</strong></div>
|
||||||
<div class="tile"><span>Containers 20ft</span><strong>${esc(count20ft)}</strong></div>
|
<div class="tile"><span>Containers 20ft</span><strong>${esc(count20ft)}</strong></div>
|
||||||
<div class="tile"><span>Total containers</span><strong>${esc(count40ft + count20ft)}</strong></div>
|
<div class="tile"><span>Total containers</span><strong>${esc(count40ft + count20ft)}</strong></div>
|
||||||
${pendingContainers ? `<div class="tile"><span>To load en route</span><strong>${esc(pendingContainers)} containers</strong></div>` : ''}
|
|
||||||
<div class="tile"><span>Gatepass granted</span><strong>${esc(date(loadList.operation.gatepassGrantedAt))}</strong></div>
|
<div class="tile"><span>Gatepass granted</span><strong>${esc(date(loadList.operation.gatepassGrantedAt))}</strong></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { combinedLocomotiveLimits } from '../train-scheduling/train-capacity.uti
|
|||||||
import { ScheduleWagonAdjustmentLog } from '../train-schedules/entities/schedule-wagon-adjustment-log.entity';
|
import { ScheduleWagonAdjustmentLog } from '../train-schedules/entities/schedule-wagon-adjustment-log.entity';
|
||||||
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
||||||
import { TrainSet } from '../train-sets/entities/train-set.entity';
|
import { TrainSet } from '../train-sets/entities/train-set.entity';
|
||||||
|
import { TrainSetLocomotive } from '../train-sets/entities/train-set-locomotive.entity';
|
||||||
import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity';
|
import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity';
|
||||||
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
||||||
import { WagonMovement } from '../wagons/entities/wagon-movement.entity';
|
import { WagonMovement } from '../wagons/entities/wagon-movement.entity';
|
||||||
@@ -504,7 +505,7 @@ export class TrainBuilderService {
|
|||||||
if (locomotiveIds.length < 1) {
|
if (locomotiveIds.length < 1) {
|
||||||
throw new BadRequestException('A train must be pulled by at least one locomotive');
|
throw new BadRequestException('A train must be pulled by at least one locomotive');
|
||||||
}
|
}
|
||||||
await this.dataSource.transaction(async (manager) => {
|
const pending = await this.dataSource.transaction(async (manager) => {
|
||||||
const train = await this.getEditableTrain(manager, id);
|
const train = await this.getEditableTrain(manager, id);
|
||||||
const yard = await manager
|
const yard = await manager
|
||||||
.getRepository(Yard)
|
.getRepository(Yard)
|
||||||
@@ -523,10 +524,76 @@ export class TrainBuilderService {
|
|||||||
await manager
|
await manager
|
||||||
.getRepository(Train)
|
.getRepository(Train)
|
||||||
.update(train.id, { capacityTons: round(limits?.maxPullWeightTons ?? 0) });
|
.update(train.id, { capacityTons: round(limits?.maxPullWeightTons ?? 0) });
|
||||||
|
// Capacity math reads the SET's locomotives, not the train's — push the
|
||||||
|
// new pull weight onto the live runs too, or they keep the old ceiling.
|
||||||
|
return this.syncLiveSchedulesAfterLocomotiveChange(manager, train.id, locomotiveIds);
|
||||||
});
|
});
|
||||||
|
// Re-derive FULL/reopen once committed — a bigger pull weight can free room
|
||||||
|
// on a schedule that had closed as FULL.
|
||||||
|
await this.reconcileWindowsAfterConsistChange(pending);
|
||||||
return this.getComposition(id);
|
return this.getComposition(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mirror a built train's locomotive change onto every LIVE (DRAFT/SCHEDULED)
|
||||||
|
* schedule formed from it. The three capacity axes are derived from
|
||||||
|
* `train_set_locomotives` (see trainSetLocomotiveLimits), which is snapshotted
|
||||||
|
* when the set is built and never re-synced — so adding a second locomotive
|
||||||
|
* raised `trains.capacity_tons` but left every existing schedule pulling on
|
||||||
|
* the old single-loco ceiling, still refusing bookings for want of weight.
|
||||||
|
*
|
||||||
|
* Only DRAFT/SCHEDULED runs follow the live train; DISPATCHED/ARRIVED render
|
||||||
|
* from their frozen snapshot and must not be disturbed (same rule as
|
||||||
|
* syncLiveScheduleAfterConsistChange).
|
||||||
|
*/
|
||||||
|
private async syncLiveSchedulesAfterLocomotiveChange(
|
||||||
|
manager: EntityManager,
|
||||||
|
trainId: string,
|
||||||
|
locomotiveIds: string[],
|
||||||
|
): Promise<PendingWindowCheck[]> {
|
||||||
|
const trainSets = await manager.getRepository(TrainSet).find({ where: { trainId } });
|
||||||
|
if (!trainSets.length) return [];
|
||||||
|
|
||||||
|
const schedules = await manager.getRepository(TrainSchedule).find({
|
||||||
|
where: {
|
||||||
|
trainSetId: In(trainSets.map((s) => s.id)),
|
||||||
|
status: In(['DRAFT', 'SCHEDULED']),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!schedules.length) return [];
|
||||||
|
|
||||||
|
// Only the sets still backing a live run — a set behind an ARRIVED schedule
|
||||||
|
// keeps the locomotives it actually ran with.
|
||||||
|
const liveSetIds = [...new Set(schedules.map((s) => s.trainSetId))];
|
||||||
|
const [primaryId] = locomotiveIds;
|
||||||
|
for (const trainSetId of liveSetIds) {
|
||||||
|
await manager.getRepository(TrainSetLocomotive).delete({ trainSetId });
|
||||||
|
await manager.getRepository(TrainSetLocomotive).save(
|
||||||
|
locomotiveIds.map((locomotiveId, index) =>
|
||||||
|
manager
|
||||||
|
.getRepository(TrainSetLocomotive)
|
||||||
|
.create({ trainSetId, locomotiveId, sequenceNo: index }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
// `locomotiveId` is the primary-locomotive fallback for single-loco reads.
|
||||||
|
await manager.getRepository(TrainSet).update(trainSetId, { locomotiveId: primaryId });
|
||||||
|
}
|
||||||
|
|
||||||
|
return schedules.map((s) => ({
|
||||||
|
scheduleId: s.id,
|
||||||
|
wasFull: s.bookingWindowStatus === 'FULL',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** {@link reconcileWindowAfterConsistChange} over several schedules. */
|
||||||
|
private async reconcileWindowsAfterConsistChange(
|
||||||
|
pending: PendingWindowCheck[],
|
||||||
|
): Promise<void> {
|
||||||
|
for (const check of pending) {
|
||||||
|
await this.reconcileWindowAfterConsistChange(check);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Edit a built train's display identity: name and fixed import/export run
|
* Edit a built train's display identity: name and fixed import/export run
|
||||||
* numbers. Mirrors the build-time number rules — the pair may not collide
|
* numbers. Mirrors the build-time number rules — the pair may not collide
|
||||||
|
|||||||
@@ -1,25 +1,34 @@
|
|||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||||
import { Transform } from 'class-transformer';
|
import { Transform } from "class-transformer";
|
||||||
import { IsBoolean, IsDateString, IsOptional, IsString, MaxLength } from 'class-validator';
|
import {
|
||||||
|
IsBoolean,
|
||||||
|
IsDateString,
|
||||||
|
IsEmail,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
MaxLength,
|
||||||
|
} from "class-validator";
|
||||||
|
|
||||||
|
import { IsValidPhone } from "../../../common/validators/is-phone-number.validator";
|
||||||
|
|
||||||
const toBoolean = ({ value }: { value: unknown }) => {
|
const toBoolean = ({ value }: { value: unknown }) => {
|
||||||
if (typeof value === 'boolean') return value;
|
if (typeof value === "boolean") return value;
|
||||||
if (value === 'true') return true;
|
if (value === "true") return true;
|
||||||
if (value === 'false') return false;
|
if (value === "false") return false;
|
||||||
return value;
|
return value;
|
||||||
};
|
};
|
||||||
|
|
||||||
export class CreateTransitAgentDto {
|
export class CreateTransitAgentDto {
|
||||||
@ApiProperty({ maxLength: 150, example: 'Ahmed Bourhan' })
|
@ApiProperty({ maxLength: 150, example: "Ahmed Bourhan" })
|
||||||
@IsString()
|
@IsString()
|
||||||
@MaxLength(150)
|
@MaxLength(150)
|
||||||
name!: string;
|
name!: string;
|
||||||
|
|
||||||
@ApiProperty({ example: '2026-01-01' })
|
@ApiProperty({ example: "2026-01-01" })
|
||||||
@IsDateString()
|
@IsDateString()
|
||||||
validFrom!: string;
|
validFrom!: string;
|
||||||
|
|
||||||
@ApiProperty({ example: '2026-12-31' })
|
@ApiProperty({ example: "2026-12-31" })
|
||||||
@IsDateString()
|
@IsDateString()
|
||||||
validTo!: string;
|
validTo!: string;
|
||||||
|
|
||||||
@@ -28,4 +37,33 @@ export class CreateTransitAgentDto {
|
|||||||
@Transform(toBoolean)
|
@Transform(toBoolean)
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
isActive?: boolean;
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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) {}
|
export class UpdateTransitAgentDto extends PartialType(CreateTransitAgentDto) {}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { BaseEntity } from '@edr/api-common';
|
import { BaseEntity } from "@edr/api-common";
|
||||||
import { Column, Entity, Index } from 'typeorm';
|
import { Column, Entity, Index } from "typeorm";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Djibouti transit officer GL Djibouti may assign against a shipment's
|
* 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
|
* validity window arrive without a code change; `isActive` is the manual
|
||||||
* suspend/reactivate switch, independent of the validity window.
|
* suspend/reactivate switch, independent of the validity window.
|
||||||
*/
|
*/
|
||||||
@Entity({ schema: 'freight', name: 'transit_agents' })
|
@Entity({ schema: "freight", name: "transit_agents" })
|
||||||
@Index(['isActive'])
|
@Index(["isActive"])
|
||||||
export class TransitAgent extends BaseEntity {
|
export class TransitAgent extends BaseEntity {
|
||||||
@Column({ name: 'name', type: 'varchar', length: 150 })
|
@Column({ name: "name", type: "varchar", length: 150 })
|
||||||
name!: string;
|
name!: string;
|
||||||
|
|
||||||
@Column({ name: 'valid_from', type: 'date' })
|
@Column({ name: "valid_from", type: "date" })
|
||||||
validFrom!: string;
|
validFrom!: string;
|
||||||
|
|
||||||
@Column({ name: 'valid_to', type: 'date' })
|
@Column({ name: "valid_to", type: "date" })
|
||||||
validTo!: string;
|
validTo!: string;
|
||||||
|
|
||||||
@Column({ name: 'is_active', type: 'boolean', default: true })
|
@Column({ name: "is_active", type: "boolean", default: true })
|
||||||
isActive!: boolean;
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,36 +10,38 @@ import {
|
|||||||
Patch,
|
Patch,
|
||||||
Post,
|
Post,
|
||||||
Query,
|
Query,
|
||||||
} from '@nestjs/common';
|
} from "@nestjs/common";
|
||||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
RuleEngineCreate,
|
RuleEngineCreate,
|
||||||
RuleEngineDelete,
|
RuleEngineDelete,
|
||||||
RuleEngineUpdate,
|
RuleEngineUpdate,
|
||||||
RuleEngineView,
|
RuleEngineView,
|
||||||
} from '../../common/rule-engine-guards';
|
} from "../../common/rule-engine-guards";
|
||||||
|
|
||||||
import { CreateTransitAgentDto } from './dto/create-transit-agent.dto';
|
import { BackofficeResetPasswordDto } from "../auth/dto/forgot-password.dto";
|
||||||
import { UpdateTransitAgentDto } from './dto/update-transit-agent.dto';
|
import { CreateTransitAgentDto } from "./dto/create-transit-agent.dto";
|
||||||
import { TransitAgentsService } from './transit-agents.service';
|
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')
|
@ApiTags("transit-agents")
|
||||||
@Controller('transit-agents')
|
@Controller("transit-agents")
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
export class TransitAgentsController {
|
export class TransitAgentsController {
|
||||||
constructor(private readonly transitAgentsService: TransitAgentsService) {}
|
constructor(private readonly transitAgentsService: TransitAgentsService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@RuleEngineView('transit-agents')
|
@RuleEngineView("transit-agents")
|
||||||
@ApiOperation({ summary: 'List transit agents' })
|
@ApiOperation({ summary: "List transit agents" })
|
||||||
findAll(@Query() query: Record<string, string | undefined>) {
|
findAll(@Query() query: Record<string, string | undefined>) {
|
||||||
return this.transitAgentsService.findAll({
|
return this.transitAgentsService.findAll({
|
||||||
isActive:
|
isActive:
|
||||||
query.isActive === 'all'
|
query.isActive === "all"
|
||||||
? undefined
|
? undefined
|
||||||
: query.isActive !== undefined
|
: query.isActive !== undefined
|
||||||
? query.isActive === 'true'
|
? query.isActive === "true"
|
||||||
: undefined,
|
: undefined,
|
||||||
page: query.page ? parseInt(query.page, 10) : undefined,
|
page: query.page ? parseInt(query.page, 10) : undefined,
|
||||||
pageSize: query.pageSize ? parseInt(query.pageSize, 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. */
|
/** Active + currently valid officers — the transit-assignee assignment dropdown. */
|
||||||
@Get('assignable')
|
@Get("assignable")
|
||||||
@RuleEngineView('transit-agents')
|
@RuleEngineView("transit-agents")
|
||||||
@ApiOperation({ summary: 'List transit agents assignable right now (active and in-window)' })
|
@ApiOperation({
|
||||||
|
summary: "List transit agents assignable right now (active and in-window)",
|
||||||
|
})
|
||||||
findAssignable() {
|
findAssignable() {
|
||||||
return this.transitAgentsService.findAssignable();
|
return this.transitAgentsService.findAssignable();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(":id")
|
||||||
@RuleEngineView('transit-agents')
|
@RuleEngineView("transit-agents")
|
||||||
@ApiOperation({ summary: 'Get a transit agent by ID' })
|
@ApiOperation({ summary: "Get a transit agent by ID" })
|
||||||
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
findOne(@Param("id", ParseUUIDPipe) id: string) {
|
||||||
return this.transitAgentsService.findById(id);
|
return this.transitAgentsService.findById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@RuleEngineCreate('transit-agents')
|
@RuleEngineCreate("transit-agents")
|
||||||
@ApiOperation({ summary: 'Create a transit agent' })
|
@ApiOperation({
|
||||||
|
summary:
|
||||||
|
"Create a transit agent; with an email, also creates its portal account and sends the activation link",
|
||||||
|
})
|
||||||
create(@Body() dto: CreateTransitAgentDto) {
|
create(@Body() dto: CreateTransitAgentDto) {
|
||||||
return this.transitAgentsService.create(dto);
|
return this.transitAgentsService.createWithInvite(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id')
|
/**
|
||||||
@RuleEngineUpdate('transit-agents')
|
* The path for the roster entries already in production: they were created
|
||||||
@ApiOperation({ summary: 'Update a transit agent' })
|
* before transit agents had logins, so they get their account here rather
|
||||||
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateTransitAgentDto) {
|
* 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);
|
return this.transitAgentsService.update(id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(":id")
|
||||||
@RuleEngineDelete('transit-agents')
|
@RuleEngineDelete("transit-agents")
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
@ApiOperation({ summary: 'Soft-delete a transit agent' })
|
@ApiOperation({ summary: "Soft-delete a transit agent" })
|
||||||
remove(@Param('id', ParseUUIDPipe) id: string) {
|
remove(@Param("id", ParseUUIDPipe) id: string) {
|
||||||
return this.transitAgentsService.remove(id);
|
return this.transitAgentsService.remove(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,24 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from "@nestjs/common";
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from "@nestjs/typeorm";
|
||||||
|
|
||||||
import { TransitAgent } from './entities/transit-agent.entity';
|
import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity";
|
||||||
import { TransitAgentsController } from './transit-agents.controller';
|
|
||||||
import { TransitAgentsRepository } from './transit-agents.repository';
|
import { FreightAuthModule } from "../auth/freight-auth.module";
|
||||||
import { TransitAgentsService } from './transit-agents.service';
|
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({
|
@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],
|
controllers: [TransitAgentsController],
|
||||||
providers: [TransitAgentsRepository, TransitAgentsService],
|
providers: [TransitAgentsRepository, TransitAgentsService],
|
||||||
exports: [TransitAgentsRepository, TransitAgentsService],
|
exports: [TransitAgentsRepository, TransitAgentsService],
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
import { BaseRepository } from '@edr/api-common';
|
import { BaseRepository } from "@edr/api-common";
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from "@nestjs/common";
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from "@nestjs/typeorm";
|
||||||
import { LessThanOrEqual, MoreThanOrEqual, Repository } from 'typeorm';
|
import {
|
||||||
|
EntityManager,
|
||||||
|
LessThanOrEqual,
|
||||||
|
MoreThanOrEqual,
|
||||||
|
Repository,
|
||||||
|
} from "typeorm";
|
||||||
|
|
||||||
import { TransitAgent } from './entities/transit-agent.entity';
|
import { TransitAgent } from "./entities/transit-agent.entity";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TransitAgentsRepository extends BaseRepository<TransitAgent> {
|
export class TransitAgentsRepository extends BaseRepository<TransitAgent> {
|
||||||
@@ -22,7 +27,48 @@ export class TransitAgentsRepository extends BaseRepository<TransitAgent> {
|
|||||||
validFrom: LessThanOrEqual(today),
|
validFrom: LessThanOrEqual(today),
|
||||||
validTo: MoreThanOrEqual(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 } });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,17 +1,49 @@
|
|||||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
import {
|
||||||
import { FindOptionsOrder } from 'typeorm';
|
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 { CustomerResetService } from "../auth/customer-reset.service";
|
||||||
import { UpdateTransitAgentDto } from './dto/update-transit-agent.dto';
|
import { ResetChannel } from "../auth/dto/forgot-password.dto";
|
||||||
import { TransitAgent } from './entities/transit-agent.entity';
|
import { isDomesticPhone } from "../otp/otp.service";
|
||||||
import { TransitAgentsRepository } from './transit-agents.repository';
|
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 & {
|
export type TransitAgentView = TransitAgent & {
|
||||||
validityStatus: TransitAgentValidityStatus;
|
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 = {
|
type TransitAgentListFilter = {
|
||||||
isActive?: boolean;
|
isActive?: boolean;
|
||||||
page?: number;
|
page?: number;
|
||||||
@@ -25,20 +57,34 @@ function todayISODate(): string {
|
|||||||
return new Date().toISOString().slice(0, 10);
|
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();
|
const today = todayISODate();
|
||||||
if (today < agent.validFrom) return 'NOT_STARTED';
|
if (today < agent.validFrom) return "NOT_STARTED";
|
||||||
if (today > agent.validTo) return 'EXPIRED';
|
if (today > agent.validTo) return "EXPIRED";
|
||||||
return 'VALID';
|
return "VALID";
|
||||||
}
|
}
|
||||||
|
|
||||||
function withValidityStatus(agent: TransitAgent): TransitAgentView {
|
function withValidityStatus(agent: TransitAgent): TransitAgentView {
|
||||||
return { ...agent, validityStatus: validityStatus(agent) };
|
return {
|
||||||
|
...agent,
|
||||||
|
validityStatus: validityStatus(agent),
|
||||||
|
hasAccount: Boolean(agent.userId),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TransitAgentsService {
|
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<{
|
async findAll(filter: TransitAgentListFilter = {}): Promise<{
|
||||||
data: TransitAgentView[];
|
data: TransitAgentView[];
|
||||||
@@ -46,10 +92,13 @@ export class TransitAgentsService {
|
|||||||
}> {
|
}> {
|
||||||
const page = filter.page ?? 1;
|
const page = filter.page ?? 1;
|
||||||
const pageSize = filter.pageSize ?? 500;
|
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)
|
? (filter.sortBy as keyof TransitAgent)
|
||||||
: 'name';
|
: "name";
|
||||||
const sortOrder = filter.sortOrder?.toUpperCase() === 'DESC' ? 'DESC' : 'ASC';
|
const sortOrder =
|
||||||
|
filter.sortOrder?.toUpperCase() === "DESC" ? "DESC" : "ASC";
|
||||||
|
|
||||||
const [data, total] = await this.transitAgentsRepository.findAndCount({
|
const [data, total] = await this.transitAgentsRepository.findAndCount({
|
||||||
where: filter.isActive === undefined ? {} : { isActive: filter.isActive },
|
where: filter.isActive === undefined ? {} : { isActive: filter.isActive },
|
||||||
@@ -86,12 +135,14 @@ export class TransitAgentsService {
|
|||||||
async getAssignable(id: string): Promise<TransitAgent> {
|
async getAssignable(id: string): Promise<TransitAgent> {
|
||||||
const agent = await this.transitAgentsRepository.findById(id);
|
const agent = await this.transitAgentsRepository.findById(id);
|
||||||
if (!agent) {
|
if (!agent) {
|
||||||
throw new BadRequestException('Selected transit officer was not found.');
|
throw new BadRequestException("Selected transit officer was not found.");
|
||||||
}
|
}
|
||||||
if (!agent.isActive) {
|
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(
|
throw new BadRequestException(
|
||||||
`${agent.name}'s validity window has expired — pick another transit officer or extend their dates.`,
|
`${agent.name}'s validity window has expired — pick another transit officer or extend their dates.`,
|
||||||
);
|
);
|
||||||
@@ -99,38 +150,364 @@ export class TransitAgentsService {
|
|||||||
return agent;
|
return agent;
|
||||||
}
|
}
|
||||||
|
|
||||||
async create(dto: CreateTransitAgentDto): Promise<TransitAgentView> {
|
/**
|
||||||
if (dto.validTo < dto.validFrom) {
|
* Create an IAM account for a transit agent, inside the caller's transaction.
|
||||||
throw new BadRequestException('Valid-to date must be on or after valid-from date.');
|
*
|
||||||
|
* 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(),
|
name: dto.name.trim(),
|
||||||
validFrom: dto.validFrom,
|
validFrom: dto.validFrom,
|
||||||
validTo: dto.validTo,
|
validTo: dto.validTo,
|
||||||
isActive: dto.isActive ?? true,
|
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 current = await this.findById(id);
|
||||||
const nextValidFrom = dto.validFrom ?? current.validFrom;
|
const nextValidFrom = dto.validFrom ?? current.validFrom;
|
||||||
const nextValidTo = dto.validTo ?? current.validTo;
|
const nextValidTo = dto.validTo ?? current.validTo;
|
||||||
if (nextValidTo < nextValidFrom) {
|
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, {
|
const updated = await this.transitAgentsRepository.update(id, {
|
||||||
...dto,
|
...rest,
|
||||||
|
...contact,
|
||||||
...(dto.name ? { name: dto.name.trim() } : {}),
|
...(dto.name ? { name: dto.name.trim() } : {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!updated) {
|
if (!updated) {
|
||||||
throw new NotFoundException(`Transit agent ${id} not found`);
|
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);
|
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> {
|
async remove(id: string): Promise<void> {
|
||||||
await this.findById(id);
|
await this.findById(id);
|
||||||
await this.transitAgentsRepository.softDelete(id);
|
await this.transitAgentsRepository.softDelete(id);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,7 +1,14 @@
|
|||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import { IsEnum, IsNumber, IsOptional, IsString, IsUUID, Matches, MaxLength, Min } from 'class-validator';
|
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 {
|
export class CreateWarehouseDto {
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
@@ -19,6 +26,11 @@ export class CreateWarehouseDto {
|
|||||||
@IsEnum(WAREHOUSE_TYPES)
|
@IsEnum(WAREHOUSE_TYPES)
|
||||||
type!: WarehouseType;
|
type!: WarehouseType;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ enum: FREIGHT_TYPES, description: 'Omit for a warehouse that takes both.' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(FREIGHT_TYPES)
|
||||||
|
freightType?: FreightType;
|
||||||
|
|
||||||
@ApiPropertyOptional({ format: 'uuid' })
|
@ApiPropertyOptional({ format: 'uuid' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsUUID()
|
@IsUUID()
|
||||||
|
|||||||
@@ -14,6 +14,14 @@ export class MoveInventoryDto {
|
|||||||
@IsUUID()
|
@IsUUID()
|
||||||
zoneId!: string;
|
zoneId!: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
format: 'uuid',
|
||||||
|
description: 'Exact physical slot in the destination zone. Container yards only.',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
slotId?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional()
|
@ApiPropertyOptional()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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[];
|
||||||
|
}
|
||||||
@@ -22,6 +22,15 @@ export class StoreInventoryDto {
|
|||||||
@IsUUID()
|
@IsUUID()
|
||||||
zoneId?: string;
|
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()
|
@ApiPropertyOptional()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -7,6 +7,8 @@ import { Container } from '../../container-management/entities/container.entity'
|
|||||||
import { Warehouse } from './warehouse.entity';
|
import { Warehouse } from './warehouse.entity';
|
||||||
import { WarehouseYard } from './warehouse-yard.entity';
|
import { WarehouseYard } from './warehouse-yard.entity';
|
||||||
import { WarehouseZone } from './warehouse-zone.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
|
// Lifecycle. Supersedes the Batch 1 set
|
||||||
// (ARRIVED_AT_WAREHOUSE / UNDER_INSPECTION / READY_FOR_LOADING) — migrated in place.
|
// (ARRIVED_AT_WAREHOUSE / UNDER_INSPECTION / READY_FOR_LOADING) — migrated in place.
|
||||||
@@ -50,6 +52,23 @@ export const WAREHOUSE_INVENTORY_TRANSITIONS: Record<WarehouseInventoryStatus, W
|
|||||||
DELIVERED: [],
|
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' })
|
@Entity({ schema: 'freight', name: 'warehouse_inventory' })
|
||||||
@Index(['warehouseId'])
|
@Index(['warehouseId'])
|
||||||
@Index(['yardId'])
|
@Index(['yardId'])
|
||||||
@@ -58,6 +77,8 @@ export const WAREHOUSE_INVENTORY_TRANSITIONS: Record<WarehouseInventoryStatus, W
|
|||||||
@Index(['cargoId'])
|
@Index(['cargoId'])
|
||||||
@Index(['containerId'])
|
@Index(['containerId'])
|
||||||
@Index(['goodsId'])
|
@Index(['goodsId'])
|
||||||
|
@Index(['stackId'])
|
||||||
|
@Index(['slotId'])
|
||||||
@Index(['status'])
|
@Index(['status'])
|
||||||
export class WarehouseInventory extends BaseEntity {
|
export class WarehouseInventory extends BaseEntity {
|
||||||
@Column({ name: 'warehouse_id', type: 'uuid' })
|
@Column({ name: 'warehouse_id', type: 'uuid' })
|
||||||
@@ -81,6 +102,25 @@ export class WarehouseInventory extends BaseEntity {
|
|||||||
@JoinColumn({ name: 'zone_id' })
|
@JoinColumn({ name: 'zone_id' })
|
||||||
zone?: WarehouseZone;
|
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 })
|
@Column({ name: 'booking_id', type: 'uuid', nullable: true })
|
||||||
bookingId?: string | null;
|
bookingId?: string | null;
|
||||||
|
|
||||||
@@ -105,6 +145,22 @@ export class WarehouseInventory extends BaseEntity {
|
|||||||
@Column({ name: 'goods_id', type: 'uuid', nullable: true })
|
@Column({ name: 'goods_id', type: 'uuid', nullable: true })
|
||||||
goodsId?: string | null;
|
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 })
|
@Column({ name: 'quantity', type: 'numeric', precision: 12, scale: 3, default: 0 })
|
||||||
quantity!: number;
|
quantity!: number;
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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[];
|
||||||
|
}
|
||||||
@@ -1,12 +1,16 @@
|
|||||||
import { BaseEntity } from '@edr/api-common';
|
import { BaseEntity } from '@edr/api-common';
|
||||||
import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm';
|
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 { Facility } from '../../facilities/entities/facility.entity';
|
||||||
import { WarehouseYard } from './warehouse-yard.entity';
|
import { WarehouseYard } from './warehouse-yard.entity';
|
||||||
|
|
||||||
export const WAREHOUSE_TYPES = ['OPEN_WAREHOUSE', 'CLOSED_WAREHOUSE'] as const;
|
export const WAREHOUSE_TYPES = ['OPEN_WAREHOUSE', 'CLOSED_WAREHOUSE'] as const;
|
||||||
export type WarehouseType = (typeof WAREHOUSE_TYPES)[number];
|
export type WarehouseType = (typeof WAREHOUSE_TYPES)[number];
|
||||||
|
|
||||||
|
export { FREIGHT_TYPES };
|
||||||
|
export type { FreightType };
|
||||||
|
|
||||||
export const WAREHOUSE_STATUSES = ['ACTIVE', 'INACTIVE'] as const;
|
export const WAREHOUSE_STATUSES = ['ACTIVE', 'INACTIVE'] as const;
|
||||||
export type WarehouseStatus = (typeof WAREHOUSE_STATUSES)[number];
|
export type WarehouseStatus = (typeof WAREHOUSE_STATUSES)[number];
|
||||||
|
|
||||||
@@ -25,6 +29,14 @@ export class Warehouse extends BaseEntity {
|
|||||||
@Column({ name: 'type', type: 'varchar', length: 32 })
|
@Column({ name: 'type', type: 'varchar', length: 32 })
|
||||||
type!: WarehouseType;
|
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 })
|
@Column({ name: 'station_id', type: 'uuid', nullable: true })
|
||||||
stationId?: string | null;
|
stationId?: string | null;
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
|||||||
|
|
||||||
interface ItemAttributes {
|
interface ItemAttributes {
|
||||||
arrivedAt: Date | null;
|
arrivedAt: Date | null;
|
||||||
|
/** Backlog-registered box: real arrival on the record, but never billed. */
|
||||||
|
backlogRegistration: boolean;
|
||||||
gateClearedAt: Date | null;
|
gateClearedAt: Date | null;
|
||||||
releaseDate: Date | null;
|
releaseDate: Date | null;
|
||||||
freightType: string | null;
|
freightType: string | null;
|
||||||
@@ -260,6 +262,7 @@ export class WarehouseFeeService {
|
|||||||
private async loadItem(inventoryId: string): Promise<ItemAttributes> {
|
private async loadItem(inventoryId: string): Promise<ItemAttributes> {
|
||||||
const [row] = await this.dataSource.query(
|
const [row] = await this.dataSource.query(
|
||||||
`SELECT inv.arrived_at AS "arrivedAt",
|
`SELECT inv.arrived_at AS "arrivedAt",
|
||||||
|
inv.backlog_registration AS "backlogRegistration",
|
||||||
inv.gate_cleared_at AS "gateClearedAt",
|
inv.gate_cleared_at AS "gateClearedAt",
|
||||||
inv.release_date AS "releaseDate",
|
inv.release_date AS "releaseDate",
|
||||||
inv.quantity AS "inventoryQuantity",
|
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. */
|
/** Preview demurrage + storage fees for an inventory item using the most specific active rules. */
|
||||||
async previewForInventory(inventoryId: string, billingCurrency = 'USD'): Promise<FeePreview[]> {
|
async previewForInventory(inventoryId: string, billingCurrency = 'USD'): Promise<FeePreview[]> {
|
||||||
const item = await this.loadItem(inventoryId);
|
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 rules = await this.feeRuleRepository.findAll({ where: { isActive: true } });
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
@@ -894,6 +904,8 @@ export class WarehouseFeeService {
|
|||||||
trucks.map(async (t) => {
|
trucks.map(async (t) => {
|
||||||
const item: ItemAttributes = {
|
const item: ItemAttributes = {
|
||||||
arrivedAt: null,
|
arrivedAt: null,
|
||||||
|
// Truck detention is a per-truck charge, never a warehouse backlog row.
|
||||||
|
backlogRegistration: false,
|
||||||
gateClearedAt: null,
|
gateClearedAt: null,
|
||||||
releaseDate: null,
|
releaseDate: null,
|
||||||
freightType: leg.freightType ?? null,
|
freightType: leg.freightType ?? null,
|
||||||
|
|||||||
@@ -14,8 +14,13 @@ import { FilterWarehouseInventoryDto } from './dto/filter-inventory.dto';
|
|||||||
import { InquiryWarehouseInventoryDto } from './dto/inquiry-inventory.dto';
|
import { InquiryWarehouseInventoryDto } from './dto/inquiry-inventory.dto';
|
||||||
import { LoadInventoryDto } from './dto/load-inventory.dto';
|
import { LoadInventoryDto } from './dto/load-inventory.dto';
|
||||||
import { MoveInventoryDto } from './dto/move-inventory.dto';
|
import { MoveInventoryDto } from './dto/move-inventory.dto';
|
||||||
|
import { AssignSlotDto, FindAvailableSlotDto } from './dto/placement.dto';
|
||||||
import { StoreInventoryDto } from './dto/store-inventory.dto';
|
import { StoreInventoryDto } from './dto/store-inventory.dto';
|
||||||
import { ReceiveWarehouseInventoryDto } from './dto/receive-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 { ApproveDeliveryDto } from './dto/approve-delivery.dto';
|
||||||
import { SetDoubleHandlingDto } from './dto/double-handling.dto';
|
import { SetDoubleHandlingDto } from './dto/double-handling.dto';
|
||||||
import { ReleaseOrderDto } from './dto/release-order.dto';
|
import { ReleaseOrderDto } from './dto/release-order.dto';
|
||||||
@@ -143,6 +148,25 @@ export class WarehouseInventoryController {
|
|||||||
return this.inventoryService.eligibleBookings(dir);
|
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')
|
@Post('receive-bulk')
|
||||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.receive)
|
@BookingStaff(FREIGHT_PERMS.warehouseInventory.receive)
|
||||||
@ApiOperation({ summary: 'Bulk-receive selected eligible PAID bookings into a location' })
|
@ApiOperation({ summary: 'Bulk-receive selected eligible PAID bookings into a location' })
|
||||||
@@ -369,6 +393,50 @@ export class WarehouseInventoryController {
|
|||||||
return this.inventoryService.move(id, dto);
|
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')
|
@Post(':id/store')
|
||||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.move)
|
@BookingStaff(FREIGHT_PERMS.warehouseInventory.move)
|
||||||
@ApiOperation({ summary: 'Mark received inventory as STORED (optional explicit warehouse/yard/zone)' })
|
@ApiOperation({ summary: 'Mark received inventory as STORED (optional explicit warehouse/yard/zone)' })
|
||||||
|
|||||||
@@ -50,6 +50,10 @@ import { InquiryWarehouseInventoryDto } from './dto/inquiry-inventory.dto';
|
|||||||
import { LoadInventoryDto } from './dto/load-inventory.dto';
|
import { LoadInventoryDto } from './dto/load-inventory.dto';
|
||||||
import { MoveInventoryDto } from './dto/move-inventory.dto';
|
import { MoveInventoryDto } from './dto/move-inventory.dto';
|
||||||
import { ReceiveWarehouseInventoryDto } from './dto/receive-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 { ReleaseOrderDto } from './dto/release-order.dto';
|
||||||
import { ReserveInventoryDto } from './dto/reserve-inventory.dto';
|
import { ReserveInventoryDto } from './dto/reserve-inventory.dto';
|
||||||
import { UnloadBookingDto } from './dto/unload-booking.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 { SchedulingReadFacade } from './scheduling-read.facade';
|
||||||
import { WarehouseActivityLogService } from './warehouse-activity-log.service';
|
import { WarehouseActivityLogService } from './warehouse-activity-log.service';
|
||||||
import { WarehouseInventoryRepository } from './warehouse-inventory.repository';
|
import { WarehouseInventoryRepository } from './warehouse-inventory.repository';
|
||||||
|
import { WarehousePlacementService } from './warehouse-placement.service';
|
||||||
import { WarehouseLoadingRepository } from './warehouse-loading.repository';
|
import { WarehouseLoadingRepository } from './warehouse-loading.repository';
|
||||||
import { WarehouseReleaseDocumentService } from './warehouse-release-document.service';
|
import { WarehouseReleaseDocumentService } from './warehouse-release-document.service';
|
||||||
import { HandoverService } from './handover.service';
|
import { HandoverService } from './handover.service';
|
||||||
@@ -412,6 +417,7 @@ export class WarehouseInventoryService {
|
|||||||
private readonly activityLog: WarehouseActivityLogService,
|
private readonly activityLog: WarehouseActivityLogService,
|
||||||
private readonly scheduling: SchedulingReadFacade,
|
private readonly scheduling: SchedulingReadFacade,
|
||||||
private readonly allocation: WarehouseAllocationService,
|
private readonly allocation: WarehouseAllocationService,
|
||||||
|
private readonly placement: WarehousePlacementService,
|
||||||
private readonly invoices: WarehouseInvoiceService,
|
private readonly invoices: WarehouseInvoiceService,
|
||||||
private readonly inspectionService: WarehouseInspectionService,
|
private readonly inspectionService: WarehouseInspectionService,
|
||||||
private readonly releaseDocuments: WarehouseReleaseDocumentService,
|
private readonly releaseDocuments: WarehouseReleaseDocumentService,
|
||||||
@@ -1503,6 +1509,7 @@ export class WarehouseInventoryService {
|
|||||||
grnNumber: string;
|
grnNumber: string;
|
||||||
direction?: string | null;
|
direction?: string | null;
|
||||||
warehouseId?: string | null;
|
warehouseId?: string | null;
|
||||||
|
bookingId?: string | null;
|
||||||
};
|
};
|
||||||
booking: {
|
booking: {
|
||||||
companyId?: string | null;
|
companyId?: string | null;
|
||||||
@@ -1724,6 +1731,7 @@ export class WarehouseInventoryService {
|
|||||||
grnNumber,
|
grnNumber,
|
||||||
direction: dto.direction,
|
direction: dto.direction,
|
||||||
warehouseId: dto.warehouseId,
|
warehouseId: dto.warehouseId,
|
||||||
|
bookingId,
|
||||||
},
|
},
|
||||||
booking,
|
booking,
|
||||||
bookingId,
|
bookingId,
|
||||||
@@ -2863,6 +2871,136 @@ export class WarehouseInventoryService {
|
|||||||
await this.lastMileService.acceptBooking(booking.reference);
|
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> {
|
async receive(dto: ReceiveWarehouseInventoryDto): Promise<WarehouseInventory> {
|
||||||
const bookingDirection = dto.bookingId ? await this.getBookingDirection(dto.bookingId) : null;
|
const bookingDirection = dto.bookingId ? await this.getBookingDirection(dto.bookingId) : null;
|
||||||
await this.assertExportBookingPaid(dto.bookingId, bookingDirection);
|
await this.assertExportBookingPaid(dto.bookingId, bookingDirection);
|
||||||
@@ -2971,6 +3109,7 @@ export class WarehouseInventoryService {
|
|||||||
grnNumber,
|
grnNumber,
|
||||||
direction: bookingDirection,
|
direction: bookingDirection,
|
||||||
warehouseId: dto.warehouseId,
|
warehouseId: dto.warehouseId,
|
||||||
|
bookingId: dto.bookingId ?? null,
|
||||||
});
|
});
|
||||||
|
|
||||||
return saved.id;
|
return saved.id;
|
||||||
@@ -2996,12 +3135,37 @@ export class WarehouseInventoryService {
|
|||||||
if (
|
if (
|
||||||
item.warehouseId === dto.warehouseId &&
|
item.warehouseId === dto.warehouseId &&
|
||||||
item.yardId === dto.yardId &&
|
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');
|
throw new BadRequestException('Destination location is the same as current location');
|
||||||
}
|
}
|
||||||
|
|
||||||
const { warehouse, yard, zone } = await this.validateLocation(manager, dto);
|
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 weight = Number(item.weight) || 0;
|
||||||
const containerCount = item.containerId ? Math.round(Number(item.quantity) || 0) : 0;
|
const containerCount = item.containerId ? Math.round(Number(item.quantity) || 0) : 0;
|
||||||
|
|
||||||
@@ -3011,7 +3175,12 @@ export class WarehouseInventoryService {
|
|||||||
if (item.yardId !== dto.yardId) {
|
if (item.yardId !== dto.yardId) {
|
||||||
this.assertCapacity('Yard', yard, weight, Number(item.volume) || 0, containerCount);
|
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(
|
await this.applyCapacityDelta(
|
||||||
manager,
|
manager,
|
||||||
@@ -3030,6 +3199,14 @@ export class WarehouseInventoryService {
|
|||||||
item.warehouseId = dto.warehouseId;
|
item.warehouseId = dto.warehouseId;
|
||||||
item.yardId = dto.yardId;
|
item.yardId = dto.yardId;
|
||||||
item.zoneId = dto.zoneId;
|
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()) {
|
if (dto.remarks?.trim()) {
|
||||||
const existingNotes = item.notes?.trim();
|
const existingNotes = item.notes?.trim();
|
||||||
item.notes = existingNotes
|
item.notes = existingNotes
|
||||||
@@ -3044,12 +3221,171 @@ export class WarehouseInventoryService {
|
|||||||
return this.findById(movedId);
|
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 ────────────────────────────────────────────────
|
// ── Lifecycle transitions ────────────────────────────────────────────────
|
||||||
|
|
||||||
async store(
|
async store(
|
||||||
id: string,
|
id: string,
|
||||||
performedBy?: string,
|
performedBy?: string,
|
||||||
chosen?: { warehouseId?: string; yardId?: string; zoneId?: string },
|
chosen?: { warehouseId?: string; yardId?: string; zoneId?: string; slotId?: string },
|
||||||
): Promise<WarehouseInventory> {
|
): Promise<WarehouseInventory> {
|
||||||
const item = await this.findById(id);
|
const item = await this.findById(id);
|
||||||
this.assertTransition(item.status, 'STORED');
|
this.assertTransition(item.status, 'STORED');
|
||||||
@@ -3113,11 +3449,17 @@ export class WarehouseInventoryService {
|
|||||||
await this.applyCapacityDelta(manager, location, weight, volume, containerCount);
|
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'}`
|
? `Stored at operator-selected location -> ${location.path ?? 'chosen yard/zone'}`
|
||||||
: ruleLocation?.rule
|
: ruleLocation?.rule
|
||||||
? `Stored by allocation rule "${ruleLocation.rule.name}" -> ${ruleLocation.path}`
|
? `Stored by allocation rule "${ruleLocation.rule.name}" -> ${ruleLocation.path}`
|
||||||
: `Stored by capacity-balanced allocation -> ${location.path ?? 'assigned yard/zone'}`;
|
: `Stored by capacity-balanced allocation -> ${location.path ?? 'assigned yard/zone'}`;
|
||||||
|
const storedReason = placed ? `${baseReason} @ ${placed.label}` : baseReason;
|
||||||
|
|
||||||
await manager.getRepository(WarehouseInventory).update(id, {
|
await manager.getRepository(WarehouseInventory).update(id, {
|
||||||
status: 'STORED',
|
status: 'STORED',
|
||||||
@@ -3125,6 +3467,8 @@ export class WarehouseInventoryService {
|
|||||||
warehouseId: location.warehouseId,
|
warehouseId: location.warehouseId,
|
||||||
yardId: location.yardId,
|
yardId: location.yardId,
|
||||||
zoneId: location.zoneId,
|
zoneId: location.zoneId,
|
||||||
|
stackId: placed?.stackId ?? null,
|
||||||
|
slotId: placed?.slotId ?? null,
|
||||||
notes: this.appendNote(locked.notes, storedReason),
|
notes: this.appendNote(locked.notes, storedReason),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -6029,6 +6373,18 @@ export class WarehouseInventoryService {
|
|||||||
if (!yard) throw new NotFoundException(`Yard ${dto.yardId} not found`);
|
if (!yard) throw new NotFoundException(`Yard ${dto.yardId} not found`);
|
||||||
const zone = await manager.getRepository(WarehouseZone).findOne({ where: { id: dto.zoneId } });
|
const zone = await manager.getRepository(WarehouseZone).findOne({ where: { id: dto.zoneId } });
|
||||||
if (!zone) throw new NotFoundException(`Zone ${dto.zoneId} not found`);
|
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 };
|
return { warehouse, yard, zone };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -6261,10 +6617,9 @@ export class WarehouseInventoryService {
|
|||||||
grnNumber: string;
|
grnNumber: string;
|
||||||
direction?: string | null;
|
direction?: string | null;
|
||||||
warehouseId?: string | null;
|
warehouseId?: string | null;
|
||||||
|
/** Resolves the company, which unlocks in-app + email alongside the SMS. */
|
||||||
|
bookingId?: string | null;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const phone = params.phone?.trim();
|
|
||||||
if (!phone) return;
|
|
||||||
|
|
||||||
const ownerName = params.ownerName?.trim() || 'Customer';
|
const ownerName = params.ownerName?.trim() || 'Customer';
|
||||||
const bookingReference = params.bookingReference?.trim();
|
const bookingReference = params.bookingReference?.trim();
|
||||||
const message =
|
const message =
|
||||||
@@ -6274,6 +6629,47 @@ export class WarehouseInventoryService {
|
|||||||
(params.direction ? `Direction: ${params.direction}. ` : '') +
|
(params.direction ? `Direction: ${params.direction}. ` : '') +
|
||||||
`Thank you.`;
|
`Thank you.`;
|
||||||
|
|
||||||
|
// A booking gives us the company, and with it the customer's inbox and
|
||||||
|
// email — not just whatever phone number the gate clerk typed. Without one
|
||||||
|
// (manual or backlog receive) the typed phone is all there is, so the
|
||||||
|
// original SMS-only path stands.
|
||||||
|
let companyId: string | null = null;
|
||||||
|
if (params.bookingId) {
|
||||||
|
try {
|
||||||
|
const [row]: Array<{ companyId: string | null }> = await this.dataSource.query(
|
||||||
|
`SELECT company_id AS "companyId"
|
||||||
|
FROM freight.bookings
|
||||||
|
WHERE id = $1 AND deleted_at IS NULL`,
|
||||||
|
[params.bookingId],
|
||||||
|
);
|
||||||
|
companyId = row?.companyId ?? null;
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn(`GRN ${params.grnNumber}: company lookup failed: ${String(error)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (companyId) {
|
||||||
|
try {
|
||||||
|
await this.inbox.notify({
|
||||||
|
recipients: { companyId },
|
||||||
|
audience: NotificationAudience.PORTAL,
|
||||||
|
type: NotificationType.DOCUMENT_ACTION,
|
||||||
|
title: 'Cargo received — GRN issued',
|
||||||
|
body: message,
|
||||||
|
link: params.bookingId ? `/bookings/${params.bookingId}` : undefined,
|
||||||
|
data: { grnNumber: params.grnNumber, bookingId: params.bookingId ?? null },
|
||||||
|
});
|
||||||
|
// Sends SMS *and* email to the company's own contacts, so the typed
|
||||||
|
// phone below is skipped to avoid texting the customer twice.
|
||||||
|
await sendCompanyChannels(this.dataSource, this.notifications, companyId, message);
|
||||||
|
return;
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Failed to notify company for GRN ${params.grnNumber}: ${String(error)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const phone = params.phone?.trim();
|
||||||
|
if (!phone) return;
|
||||||
try {
|
try {
|
||||||
await this.notifications.directSend('sms', phone, message);
|
await this.notifications.directSend('sms', phone, message);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
|
|
||||||
import { BookingStaff } from '../../common/booking-guards';
|
import { BookingStaff } from '../../common/booking-guards';
|
||||||
@@ -40,6 +40,16 @@ export class WarehouseYardsController {
|
|||||||
return this.yardsService.update(id, dto);
|
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')
|
@Get(':yardId/zones')
|
||||||
@BookingStaff(FREIGHT_PERMS.warehouseZones.view)
|
@BookingStaff(FREIGHT_PERMS.warehouseZones.view)
|
||||||
@ApiOperation({ summary: 'List zones within a yard' })
|
@ApiOperation({ summary: 'List zones within a yard' })
|
||||||
|
|||||||
@@ -107,6 +107,24 @@ export class WarehouseYardsService {
|
|||||||
return this.findById(id);
|
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> {
|
private async assertCodeUnique(warehouseId: string, code: string, ignoreId?: string): Promise<void> {
|
||||||
const [existing] = await this.yardsRepository.findAll({ where: { warehouseId, code } });
|
const [existing] = await this.yardsRepository.findAll({ where: { warehouseId, code } });
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch } from '@nestjs/common';
|
import { Body, Controller, Delete, Get, Param, ParseUUIDPipe, Patch } from '@nestjs/common';
|
||||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
|
|
||||||
import { BookingStaff } from '../../common/booking-guards';
|
import { BookingStaff } from '../../common/booking-guards';
|
||||||
@@ -18,6 +18,7 @@ import { WarehouseZonesService } from './warehouse-zones.service';
|
|||||||
FREIGHT_PERMS.warehouseZones.view,
|
FREIGHT_PERMS.warehouseZones.view,
|
||||||
FREIGHT_PERMS.warehouseInventory.view,
|
FREIGHT_PERMS.warehouseInventory.view,
|
||||||
FREIGHT_PERMS.warehouseZones.update,
|
FREIGHT_PERMS.warehouseZones.update,
|
||||||
|
FREIGHT_PERMS.warehouseZones.delete,
|
||||||
])
|
])
|
||||||
export class WarehouseZonesController {
|
export class WarehouseZonesController {
|
||||||
constructor(private readonly zonesService: WarehouseZonesService) {}
|
constructor(private readonly zonesService: WarehouseZonesService) {}
|
||||||
@@ -40,4 +41,41 @@ export class WarehouseZonesController {
|
|||||||
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateWarehouseZoneDto) {
|
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateWarehouseZoneDto) {
|
||||||
return this.zonesService.update(id, dto);
|
return this.zonesService.update(id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get(':id/contents')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'What is currently stored in a zone',
|
||||||
|
description: 'A row per container — booked units and backlog-registered containers alike.',
|
||||||
|
})
|
||||||
|
contents(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
return this.zonesService.contents(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id/layout')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Physical layout of a zone',
|
||||||
|
description: 'Every ground stack with its levels, what stands on each, and the slot summary.',
|
||||||
|
})
|
||||||
|
layout(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
return this.zonesService.layout(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id/slot-summary')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Configured capacity vs physical slots vs occupancy',
|
||||||
|
description: 'Flags a zone whose built slots exceed its configured container capacity.',
|
||||||
|
})
|
||||||
|
slotSummary(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
return this.zonesService.slotSummary(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
@BookingStaff(FREIGHT_PERMS.warehouseZones.delete)
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Delete warehouse zone',
|
||||||
|
description: 'Soft-deletes the zone. Refused while inventory still sits in it.',
|
||||||
|
})
|
||||||
|
remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
return this.zonesService.remove(id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,35 @@
|
|||||||
import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { InjectDataSource } from '@nestjs/typeorm';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
|
||||||
import { CreateWarehouseZoneDto } from './dto/create-warehouse-zone.dto';
|
import { CreateWarehouseZoneDto } from './dto/create-warehouse-zone.dto';
|
||||||
import { UpdateWarehouseZoneDto } from './dto/update-warehouse-zone.dto';
|
import { UpdateWarehouseZoneDto } from './dto/update-warehouse-zone.dto';
|
||||||
import { WarehouseZone } from './entities/warehouse-zone.entity';
|
import { WarehouseZone } from './entities/warehouse-zone.entity';
|
||||||
|
import { WarehouseInventoryRepository } from './warehouse-inventory.repository';
|
||||||
|
import { WarehousePlacementService } from './warehouse-placement.service';
|
||||||
import { WarehouseYardsService } from './warehouse-yards.service';
|
import { WarehouseYardsService } from './warehouse-yards.service';
|
||||||
import { WarehouseZonesRepository } from './warehouse-zones.repository';
|
import { WarehouseZonesRepository } from './warehouse-zones.repository';
|
||||||
|
|
||||||
|
/** One container (or one bulk lot) currently sitting in a zone. */
|
||||||
|
export interface ZoneContentItem {
|
||||||
|
inventoryId: string;
|
||||||
|
containerNumber: string | null;
|
||||||
|
unloadedAt: string | null;
|
||||||
|
containerType: string | null;
|
||||||
|
direction: 'IMPORT' | 'EXPORT' | null;
|
||||||
|
loadState: string | null;
|
||||||
|
status: string;
|
||||||
|
bookingReference: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class WarehouseZonesService {
|
export class WarehouseZonesService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly zonesRepository: WarehouseZonesRepository,
|
private readonly zonesRepository: WarehouseZonesRepository,
|
||||||
private readonly yardsService: WarehouseYardsService,
|
private readonly yardsService: WarehouseYardsService,
|
||||||
|
private readonly inventoryRepository: WarehouseInventoryRepository,
|
||||||
|
private readonly placement: WarehousePlacementService,
|
||||||
|
@InjectDataSource() private readonly dataSource: DataSource,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
findAll(): Promise<WarehouseZone[]> {
|
findAll(): Promise<WarehouseZone[]> {
|
||||||
@@ -98,6 +117,94 @@ export class WarehouseZonesService {
|
|||||||
return this.findById(id);
|
return this.findById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What is physically sitting in one zone, a row per container.
|
||||||
|
*
|
||||||
|
* Container identity has two sources and neither covers the other: booked
|
||||||
|
* cargo carries its units on `booking_container_units`, while a backlog
|
||||||
|
* registration has no booking and links `warehouse_inventory.container_id`
|
||||||
|
* straight to a `containers` row. Bulk cargo has neither, so it comes back
|
||||||
|
* with a null container number rather than being dropped from its zone.
|
||||||
|
*
|
||||||
|
* Full/empty likewise: `containers.status` when there is a container row,
|
||||||
|
* otherwise a returned unit is the empty one.
|
||||||
|
*/
|
||||||
|
async contents(zoneId: string): Promise<ZoneContentItem[]> {
|
||||||
|
await this.findById(zoneId);
|
||||||
|
|
||||||
|
return this.dataSource.query(
|
||||||
|
`SELECT i.id AS "inventoryId",
|
||||||
|
COALESCE(c.container_number, bcu.container_number) AS "containerNumber",
|
||||||
|
i.unloaded_at AS "unloadedAt",
|
||||||
|
COALESCE(ct_direct.label, ct_booked.label, bc.container_size) AS "containerType",
|
||||||
|
b.trade_direction AS "direction",
|
||||||
|
CASE
|
||||||
|
WHEN c.status IS NOT NULL THEN c.status
|
||||||
|
WHEN bcu.is_return THEN 'EMPTY'
|
||||||
|
WHEN bcu.container_number IS NOT NULL THEN 'FULL'
|
||||||
|
ELSE NULL
|
||||||
|
END AS "loadState",
|
||||||
|
i.status AS "status",
|
||||||
|
b.reference AS "bookingReference"
|
||||||
|
FROM freight.warehouse_inventory i
|
||||||
|
LEFT JOIN freight.containers c ON c.id = i.container_id AND c.deleted_at IS NULL
|
||||||
|
LEFT JOIN freight.container_types ct_direct ON ct_direct.id = c.container_type_id
|
||||||
|
LEFT JOIN freight.bookings b ON b.id = i.booking_id AND b.deleted_at IS NULL
|
||||||
|
LEFT JOIN freight.booking_container bc ON bc.booking_id = b.id AND bc.deleted_at IS NULL
|
||||||
|
LEFT JOIN freight.container_types ct_booked ON ct_booked.id = bc.container_type_id
|
||||||
|
LEFT JOIN freight.booking_container_units bcu
|
||||||
|
ON bcu.booking_container_id = bc.id AND bcu.deleted_at IS NULL
|
||||||
|
WHERE i.zone_id = $1 AND i.deleted_at IS NULL
|
||||||
|
ORDER BY i.unloaded_at DESC NULLS LAST,
|
||||||
|
COALESCE(c.container_number, bcu.container_number)`,
|
||||||
|
[zoneId],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The zone's physical layout: every ground stack, every level, what stands there. */
|
||||||
|
async layout(zoneId: string) {
|
||||||
|
await this.findById(zoneId);
|
||||||
|
return this.placement.zoneLayout(zoneId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Configured capacity vs slots actually built vs slots actually full. */
|
||||||
|
async slotSummary(zoneId: string) {
|
||||||
|
await this.findById(zoneId);
|
||||||
|
return this.placement.slotSummary({ zoneId });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Soft-delete a zone. Inventory points at a zone, so a zone still holding
|
||||||
|
* stock is refused — soft-deleting it would leave those rows pointing at a
|
||||||
|
* location every zone-joining query drops. Configured stacks block it for the
|
||||||
|
* same reason: they would survive their parent and never be reachable again.
|
||||||
|
*/
|
||||||
|
async remove(id: string): Promise<{ id: string; deleted: true }> {
|
||||||
|
const existing = await this.findById(id);
|
||||||
|
const [, held] = await this.inventoryRepository.findAndCount({ where: { zoneId: id } });
|
||||||
|
|
||||||
|
if (held > 0) {
|
||||||
|
throw new ConflictException(
|
||||||
|
`Zone ${existing.code} still holds ${held} inventory item(s). Move them out first.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [stacks] = await this.dataSource.query(
|
||||||
|
`SELECT count(*)::int AS count FROM freight.warehouse_zone_stacks
|
||||||
|
WHERE zone_id = $1 AND deleted_at IS NULL`,
|
||||||
|
[id],
|
||||||
|
);
|
||||||
|
if (Number(stacks?.count ?? 0) > 0) {
|
||||||
|
throw new ConflictException(
|
||||||
|
`Zone ${existing.code} still has ${stacks.count} configured stack(s). Delete them first.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.zonesRepository.softDelete(id);
|
||||||
|
|
||||||
|
return { id, deleted: true };
|
||||||
|
}
|
||||||
|
|
||||||
private async assertCodeUnique(yardId: string, code: string, ignoreId?: string): Promise<void> {
|
private async assertCodeUnique(yardId: string, code: string, ignoreId?: string): Promise<void> {
|
||||||
const [existing] = await this.zonesRepository.findAll({ where: { yardId, code } });
|
const [existing] = await this.zonesRepository.findAll({ where: { yardId, code } });
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common';
|
import { Body, Controller, Delete, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common';
|
||||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
|
|
||||||
import { BookingStaff } from '../../common/booking-guards';
|
import { BookingStaff } from '../../common/booking-guards';
|
||||||
@@ -25,6 +25,7 @@ import { WarehousesService } from './warehouses.service';
|
|||||||
FREIGHT_PERMS.warehouseDashboard.view,
|
FREIGHT_PERMS.warehouseDashboard.view,
|
||||||
FREIGHT_PERMS.warehouses.create,
|
FREIGHT_PERMS.warehouses.create,
|
||||||
FREIGHT_PERMS.warehouses.update,
|
FREIGHT_PERMS.warehouses.update,
|
||||||
|
FREIGHT_PERMS.warehouses.delete,
|
||||||
FREIGHT_PERMS.warehouseYards.view,
|
FREIGHT_PERMS.warehouseYards.view,
|
||||||
FREIGHT_PERMS.warehouseYards.create,
|
FREIGHT_PERMS.warehouseYards.create,
|
||||||
])
|
])
|
||||||
@@ -79,6 +80,16 @@ export class WarehousesController {
|
|||||||
return this.warehousesService.update(id, dto);
|
return this.warehousesService.update(id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
@BookingStaff(FREIGHT_PERMS.warehouses.delete)
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Delete warehouse',
|
||||||
|
description: 'Soft-deletes the warehouse. Refused while it still has yards.',
|
||||||
|
})
|
||||||
|
remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
return this.warehousesService.remove(id);
|
||||||
|
}
|
||||||
|
|
||||||
@Get(':warehouseId/yards')
|
@Get(':warehouseId/yards')
|
||||||
@BookingStaff(FREIGHT_PERMS.warehouseYards.view)
|
@BookingStaff(FREIGHT_PERMS.warehouseYards.view)
|
||||||
@ApiOperation({ summary: 'List yards within a warehouse' })
|
@ApiOperation({ summary: 'List yards within a warehouse' })
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ import { WarehouseInventoryMovement } from './entities/warehouse-inventory-movem
|
|||||||
import { WarehouseLoading } from './entities/warehouse-loading.entity';
|
import { WarehouseLoading } from './entities/warehouse-loading.entity';
|
||||||
import { WarehouseYard } from './entities/warehouse-yard.entity';
|
import { WarehouseYard } from './entities/warehouse-yard.entity';
|
||||||
import { WarehouseZone } from './entities/warehouse-zone.entity';
|
import { WarehouseZone } from './entities/warehouse-zone.entity';
|
||||||
|
import { WarehouseZoneSlot } from './entities/warehouse-zone-slot.entity';
|
||||||
|
import { WarehouseZoneStack } from './entities/warehouse-zone-stack.entity';
|
||||||
import { Warehouse } from './entities/warehouse.entity';
|
import { Warehouse } from './entities/warehouse.entity';
|
||||||
import { SchedulingReadFacade } from './scheduling-read.facade';
|
import { SchedulingReadFacade } from './scheduling-read.facade';
|
||||||
import { WarehouseActivityLogRepository } from './warehouse-activity-log.repository';
|
import { WarehouseActivityLogRepository } from './warehouse-activity-log.repository';
|
||||||
@@ -47,6 +49,11 @@ import { WarehouseSchedulingAdapterService } from './warehouse-scheduling-adapte
|
|||||||
import { WarehouseYardsController } from './warehouse-yards.controller';
|
import { WarehouseYardsController } from './warehouse-yards.controller';
|
||||||
import { WarehouseYardsRepository } from './warehouse-yards.repository';
|
import { WarehouseYardsRepository } from './warehouse-yards.repository';
|
||||||
import { WarehouseYardsService } from './warehouse-yards.service';
|
import { WarehouseYardsService } from './warehouse-yards.service';
|
||||||
|
import { WarehousePlacementService } from './warehouse-placement.service';
|
||||||
|
import { WarehouseZoneSlotsRepository } from './warehouse-zone-slots.repository';
|
||||||
|
import { WarehouseZoneStacksController } from './warehouse-zone-stacks.controller';
|
||||||
|
import { WarehouseZoneStacksRepository } from './warehouse-zone-stacks.repository';
|
||||||
|
import { WarehouseZoneStacksService } from './warehouse-zone-stacks.service';
|
||||||
import { WarehouseZonesController } from './warehouse-zones.controller';
|
import { WarehouseZonesController } from './warehouse-zones.controller';
|
||||||
import { WarehouseZonesRepository } from './warehouse-zones.repository';
|
import { WarehouseZonesRepository } from './warehouse-zones.repository';
|
||||||
import { WarehouseZonesService } from './warehouse-zones.service';
|
import { WarehouseZonesService } from './warehouse-zones.service';
|
||||||
@@ -60,6 +67,8 @@ import { WarehousesService } from './warehouses.service';
|
|||||||
Warehouse,
|
Warehouse,
|
||||||
WarehouseYard,
|
WarehouseYard,
|
||||||
WarehouseZone,
|
WarehouseZone,
|
||||||
|
WarehouseZoneStack,
|
||||||
|
WarehouseZoneSlot,
|
||||||
WarehouseInventory,
|
WarehouseInventory,
|
||||||
WarehouseInventoryMovement,
|
WarehouseInventoryMovement,
|
||||||
WarehouseActivityLog,
|
WarehouseActivityLog,
|
||||||
@@ -83,6 +92,7 @@ import { WarehousesService } from './warehouses.service';
|
|||||||
WarehousesController,
|
WarehousesController,
|
||||||
WarehouseYardsController,
|
WarehouseYardsController,
|
||||||
WarehouseZonesController,
|
WarehouseZonesController,
|
||||||
|
WarehouseZoneStacksController,
|
||||||
WarehouseInventoryController,
|
WarehouseInventoryController,
|
||||||
WarehouseLoadingsController,
|
WarehouseLoadingsController,
|
||||||
WarehouseInspectionController,
|
WarehouseInspectionController,
|
||||||
@@ -93,6 +103,8 @@ import { WarehousesService } from './warehouses.service';
|
|||||||
WarehousesRepository,
|
WarehousesRepository,
|
||||||
WarehouseYardsRepository,
|
WarehouseYardsRepository,
|
||||||
WarehouseZonesRepository,
|
WarehouseZonesRepository,
|
||||||
|
WarehouseZoneStacksRepository,
|
||||||
|
WarehouseZoneSlotsRepository,
|
||||||
WarehouseInventoryRepository,
|
WarehouseInventoryRepository,
|
||||||
WarehouseInventoryMovementRepository,
|
WarehouseInventoryMovementRepository,
|
||||||
WarehouseActivityLogRepository,
|
WarehouseActivityLogRepository,
|
||||||
@@ -103,6 +115,8 @@ import { WarehousesService } from './warehouses.service';
|
|||||||
WarehousesService,
|
WarehousesService,
|
||||||
WarehouseYardsService,
|
WarehouseYardsService,
|
||||||
WarehouseZonesService,
|
WarehouseZonesService,
|
||||||
|
WarehouseZoneStacksService,
|
||||||
|
WarehousePlacementService,
|
||||||
WarehouseInventoryService,
|
WarehouseInventoryService,
|
||||||
WarehouseActivityLogService,
|
WarehouseActivityLogService,
|
||||||
WarehouseDashboardService,
|
WarehouseDashboardService,
|
||||||
@@ -119,6 +133,8 @@ import { WarehousesService } from './warehouses.service';
|
|||||||
WarehousesService,
|
WarehousesService,
|
||||||
WarehouseYardsService,
|
WarehouseYardsService,
|
||||||
WarehouseZonesService,
|
WarehouseZonesService,
|
||||||
|
WarehouseZoneStacksService,
|
||||||
|
WarehousePlacementService,
|
||||||
WarehouseInventoryService,
|
WarehouseInventoryService,
|
||||||
WarehouseAllocationService,
|
WarehouseAllocationService,
|
||||||
WarehouseFeeService,
|
WarehouseFeeService,
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ export class WarehousesService {
|
|||||||
name: dto.name.trim(),
|
name: dto.name.trim(),
|
||||||
code: dto.code.trim(),
|
code: dto.code.trim(),
|
||||||
type: dto.type,
|
type: dto.type,
|
||||||
|
freightType: dto.freightType ?? null,
|
||||||
stationId: dto.stationId ?? null,
|
stationId: dto.stationId ?? null,
|
||||||
facilityId: dto.facilityId ?? null,
|
facilityId: dto.facilityId ?? null,
|
||||||
locationName: dto.locationName?.trim() ?? null,
|
locationName: dto.locationName?.trim() ?? null,
|
||||||
@@ -87,6 +88,7 @@ export class WarehousesService {
|
|||||||
name: dto.name?.trim() ?? existing.name,
|
name: dto.name?.trim() ?? existing.name,
|
||||||
code: dto.code?.trim() ?? existing.code,
|
code: dto.code?.trim() ?? existing.code,
|
||||||
type: dto.type ?? existing.type,
|
type: dto.type ?? existing.type,
|
||||||
|
freightType: dto.freightType ?? existing.freightType,
|
||||||
stationId: dto.stationId ?? existing.stationId,
|
stationId: dto.stationId ?? existing.stationId,
|
||||||
facilityId: dto.facilityId ?? existing.facilityId,
|
facilityId: dto.facilityId ?? existing.facilityId,
|
||||||
locationName: dto.locationName?.trim() ?? existing.locationName,
|
locationName: dto.locationName?.trim() ?? existing.locationName,
|
||||||
@@ -108,6 +110,25 @@ export class WarehousesService {
|
|||||||
return this.findById(id);
|
return this.findById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Soft-delete a warehouse. Yards (and therefore zones and inventory, which
|
||||||
|
* hang off a zone) are left alone — a warehouse holding them is refused
|
||||||
|
* rather than silently orphaning stock.
|
||||||
|
*/
|
||||||
|
async remove(id: string): Promise<{ id: string; deleted: true }> {
|
||||||
|
const existing = await this.findById(id);
|
||||||
|
|
||||||
|
if (existing.yards?.length) {
|
||||||
|
throw new ConflictException(
|
||||||
|
`Warehouse ${existing.code} still has ${existing.yards.length} yard(s). Delete them first.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.warehousesRepository.softDelete(id);
|
||||||
|
|
||||||
|
return { id, deleted: true };
|
||||||
|
}
|
||||||
|
|
||||||
/** Map low-level DB errors (FK / length / etc.) to a clean 400 instead of a 500. */
|
/** Map low-level DB errors (FK / length / etc.) to a clean 400 instead of a 500. */
|
||||||
private mapDbError(error: unknown): never {
|
private mapDbError(error: unknown): never {
|
||||||
if (error instanceof QueryFailedError) {
|
if (error instanceof QueryFailedError) {
|
||||||
|
|||||||
35
apps/edr-freight-api/src/scripts/seed-warehouse-layout.ts
Normal file
35
apps/edr-freight-api/src/scripts/seed-warehouse-layout.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { AppDataSource } from '../data-source';
|
||||||
|
import { WarehouseLayoutSeeder } from '../seed/warehouse-layout.seeder';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lays out the physical warehouse structure described by
|
||||||
|
* `src/seed/warehouse-layout.json` — edit that file, not this script.
|
||||||
|
*
|
||||||
|
* Idempotent: existing warehouses, yards, zones, stacks and slots are left
|
||||||
|
* untouched, so a re-run only fills in what is missing.
|
||||||
|
*/
|
||||||
|
async function seedWarehouseLayout() {
|
||||||
|
await AppDataSource.initialize();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const summary = await new WarehouseLayoutSeeder(AppDataSource).run();
|
||||||
|
console.table([summary]);
|
||||||
|
|
||||||
|
const counts = await AppDataSource.query(`
|
||||||
|
SELECT
|
||||||
|
(SELECT COUNT(*)::int FROM freight.warehouses WHERE deleted_at IS NULL) AS warehouses,
|
||||||
|
(SELECT COUNT(*)::int FROM freight.warehouse_yards WHERE deleted_at IS NULL) AS yards,
|
||||||
|
(SELECT COUNT(*)::int FROM freight.warehouse_zones WHERE deleted_at IS NULL) AS zones,
|
||||||
|
(SELECT COUNT(*)::int FROM freight.warehouse_zone_stacks WHERE deleted_at IS NULL) AS stacks,
|
||||||
|
(SELECT COUNT(*)::int FROM freight.warehouse_zone_slots WHERE deleted_at IS NULL) AS slots
|
||||||
|
`);
|
||||||
|
console.table(counts);
|
||||||
|
} finally {
|
||||||
|
await AppDataSource.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
seedWarehouseLayout().catch((error) => {
|
||||||
|
console.error('Failed to seed the warehouse layout:', error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -465,6 +465,13 @@ export const CONTRACT_PERMISSIONS: FreightPermissionSeed[] = [
|
|||||||
"edr_freight_app:contracts:suspend",
|
"edr_freight_app:contracts:suspend",
|
||||||
"Suspend / resume a signed contract",
|
"Suspend / resume a signed contract",
|
||||||
),
|
),
|
||||||
|
// Terminal kill switch. Unlike suspend this cannot be undone — the customer
|
||||||
|
// re-submits a fresh contract with the same parameters instead.
|
||||||
|
perm(
|
||||||
|
"a3000001-0001-4000-8000-00000000001c",
|
||||||
|
"edr_freight_app:contracts:cancel",
|
||||||
|
"Cancel a contract (terminal)",
|
||||||
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
// Historical ids. EdrOrgSeeder no longer sends them — it upserts on `key` and
|
// Historical ids. EdrOrgSeeder no longer sends them — it upserts on `key` and
|
||||||
@@ -652,6 +659,32 @@ export const SHIPPING_LINE_PERMISSIONS: FreightPermissionSeed[] = [
|
|||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// C3. Transit assignments — a transit agent's work on one booking: status,
|
||||||
|
// timings and documents. Separate from the booking's transit-assignee handshake,
|
||||||
|
// which only decides who will handle customs.
|
||||||
|
export const TRANSIT_ASSIGNMENT_PERMISSIONS: FreightPermissionSeed[] = [
|
||||||
|
perm(
|
||||||
|
"d1a00003-0001-4000-8000-000000000001",
|
||||||
|
"edr_freight_app:transit_assignments:view",
|
||||||
|
"View transit assignments",
|
||||||
|
),
|
||||||
|
perm(
|
||||||
|
"d1a00003-0001-4000-8000-000000000002",
|
||||||
|
"edr_freight_app:transit_assignments:create",
|
||||||
|
"Assign a transit agent to a booking",
|
||||||
|
),
|
||||||
|
perm(
|
||||||
|
"d1a00003-0001-4000-8000-000000000003",
|
||||||
|
"edr_freight_app:transit_assignments:update",
|
||||||
|
"Update a transit assignment and its documents",
|
||||||
|
),
|
||||||
|
perm(
|
||||||
|
"d1a00003-0001-4000-8000-000000000004",
|
||||||
|
"edr_freight_app:transit_assignments:delete",
|
||||||
|
"Remove a transit assignment",
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
// Internal chat (Matrix/Element) — sidebar visibility + manual reconcile trigger.
|
// Internal chat (Matrix/Element) — sidebar visibility + manual reconcile trigger.
|
||||||
export const CHAT_PERMISSIONS: FreightPermissionSeed[] = [
|
export const CHAT_PERMISSIONS: FreightPermissionSeed[] = [
|
||||||
perm(
|
perm(
|
||||||
@@ -1272,6 +1305,11 @@ export const WAREHOUSE_PERMISSIONS: FreightPermissionSeed[] = [
|
|||||||
"edr_freight_app:warehouse_zones:update",
|
"edr_freight_app:warehouse_zones:update",
|
||||||
"Update warehouse zone",
|
"Update warehouse zone",
|
||||||
),
|
),
|
||||||
|
perm(
|
||||||
|
"f1c00001-0001-4000-8000-000000000004",
|
||||||
|
"edr_freight_app:warehouse_zones:delete",
|
||||||
|
"Delete warehouse zone",
|
||||||
|
),
|
||||||
perm(
|
perm(
|
||||||
"f1d00001-0001-4000-8000-000000000001",
|
"f1d00001-0001-4000-8000-000000000001",
|
||||||
"edr_freight_app:warehouse_allocation_rules:view",
|
"edr_freight_app:warehouse_allocation_rules:view",
|
||||||
@@ -1878,6 +1916,11 @@ export const NOTIFICATION_PERMISSIONS: FreightPermissionSeed[] = [
|
|||||||
"edr_freight_app:additional_charges:get_notification",
|
"edr_freight_app:additional_charges:get_notification",
|
||||||
"Receive additional charge notifications",
|
"Receive additional charge notifications",
|
||||||
),
|
),
|
||||||
|
perm(
|
||||||
|
"f3a00001-0001-4000-8000-00000000000a",
|
||||||
|
"edr_freight_app:warehouse_inventory:get_notification",
|
||||||
|
"Receive warehouse desk notifications (containers left behind at loading)",
|
||||||
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
export const ADVANCED_BACKOFFICE_PERMISSIONS: FreightPermissionSeed[] = [
|
export const ADVANCED_BACKOFFICE_PERMISSIONS: FreightPermissionSeed[] = [
|
||||||
@@ -1885,6 +1928,7 @@ export const ADVANCED_BACKOFFICE_PERMISSIONS: FreightPermissionSeed[] = [
|
|||||||
...OVERVIEW_LAYOUT_PERMISSIONS,
|
...OVERVIEW_LAYOUT_PERMISSIONS,
|
||||||
...CUSTOMER_PERMISSIONS,
|
...CUSTOMER_PERMISSIONS,
|
||||||
...SHIPPING_LINE_PERMISSIONS,
|
...SHIPPING_LINE_PERMISSIONS,
|
||||||
|
...TRANSIT_ASSIGNMENT_PERMISSIONS,
|
||||||
...CHAT_PERMISSIONS,
|
...CHAT_PERMISSIONS,
|
||||||
...FINANCE_PERMISSIONS,
|
...FINANCE_PERMISSIONS,
|
||||||
...MILE_PERMISSIONS,
|
...MILE_PERMISSIONS,
|
||||||
@@ -2029,6 +2073,7 @@ export const FREIGHT_PERMS = {
|
|||||||
clearanceDjActions: "edr_freight_app:contracts:clearance_dj_actions",
|
clearanceDjActions: "edr_freight_app:contracts:clearance_dj_actions",
|
||||||
clearanceDutyAdvise: "edr_freight_app:contracts:clearance_duty_advise",
|
clearanceDutyAdvise: "edr_freight_app:contracts:clearance_duty_advise",
|
||||||
suspend: "edr_freight_app:contracts:suspend",
|
suspend: "edr_freight_app:contracts:suspend",
|
||||||
|
cancel: "edr_freight_app:contracts:cancel",
|
||||||
editDocument: "edr_freight_app:contracts:edit_document",
|
editDocument: "edr_freight_app:contracts:edit_document",
|
||||||
finalInvoiceRaise: "edr_freight_app:contracts:final_invoice_raise",
|
finalInvoiceRaise: "edr_freight_app:contracts:final_invoice_raise",
|
||||||
finalInvoiceConfirm: "edr_freight_app:contracts:final_invoice_confirm",
|
finalInvoiceConfirm: "edr_freight_app:contracts:final_invoice_confirm",
|
||||||
@@ -2113,6 +2158,12 @@ export const FREIGHT_PERMS = {
|
|||||||
// Notification selector, not a route guard — see NOTIFICATION_PERMISSIONS.
|
// Notification selector, not a route guard — see NOTIFICATION_PERMISSIONS.
|
||||||
getNotification: "edr_freight_app:customers:get_notification",
|
getNotification: "edr_freight_app:customers:get_notification",
|
||||||
},
|
},
|
||||||
|
transitAssignments: {
|
||||||
|
view: "edr_freight_app:transit_assignments:view",
|
||||||
|
create: "edr_freight_app:transit_assignments:create",
|
||||||
|
update: "edr_freight_app:transit_assignments:update",
|
||||||
|
delete: "edr_freight_app:transit_assignments:delete",
|
||||||
|
},
|
||||||
shippingLines: {
|
shippingLines: {
|
||||||
view: "edr_freight_app:shipping_lines:view",
|
view: "edr_freight_app:shipping_lines:view",
|
||||||
create: "edr_freight_app:shipping_lines:create",
|
create: "edr_freight_app:shipping_lines:create",
|
||||||
@@ -2307,6 +2358,7 @@ export const FREIGHT_PERMS = {
|
|||||||
view: "edr_freight_app:warehouse_zones:view",
|
view: "edr_freight_app:warehouse_zones:view",
|
||||||
create: "edr_freight_app:warehouse_zones:create",
|
create: "edr_freight_app:warehouse_zones:create",
|
||||||
update: "edr_freight_app:warehouse_zones:update",
|
update: "edr_freight_app:warehouse_zones:update",
|
||||||
|
delete: "edr_freight_app:warehouse_zones:delete",
|
||||||
},
|
},
|
||||||
warehouseAllocationRules: {
|
warehouseAllocationRules: {
|
||||||
view: "edr_freight_app:warehouse_allocation_rules:view",
|
view: "edr_freight_app:warehouse_allocation_rules:view",
|
||||||
@@ -2336,6 +2388,12 @@ export const FREIGHT_PERMS = {
|
|||||||
release: "edr_freight_app:warehouse_inventory:release",
|
release: "edr_freight_app:warehouse_inventory:release",
|
||||||
deliver: "edr_freight_app:warehouse_inventory:deliver",
|
deliver: "edr_freight_app:warehouse_inventory:deliver",
|
||||||
inspect: "edr_freight_app:warehouse_inventory:inspect",
|
inspect: "edr_freight_app:warehouse_inventory:inspect",
|
||||||
|
/**
|
||||||
|
* Notification selector, not a route guard — who gets pinged when cargo is
|
||||||
|
* left behind at loading and needs warehouse space. Assign it to whichever
|
||||||
|
* desk owns that; it grants access to nothing.
|
||||||
|
*/
|
||||||
|
getNotification: "edr_freight_app:warehouse_inventory:get_notification",
|
||||||
},
|
},
|
||||||
interchangeDocuments: {
|
interchangeDocuments: {
|
||||||
view: "edr_freight_app:interchange_documents:view",
|
view: "edr_freight_app:interchange_documents:view",
|
||||||
@@ -2826,6 +2884,9 @@ export const ROLE_PERMISSION_PRESETS = {
|
|||||||
FREIGHT_PERMS.contracts.generateContract,
|
FREIGHT_PERMS.contracts.generateContract,
|
||||||
...bothFreightTypes(FREIGHT_PERMS.contracts.signStaff),
|
...bothFreightTypes(FREIGHT_PERMS.contracts.signStaff),
|
||||||
FREIGHT_PERMS.contracts.suspend,
|
FREIGHT_PERMS.contracts.suspend,
|
||||||
|
// Terminal kill switch, granted alongside suspend on the same desk that
|
||||||
|
// already rejects contracts and cancels bookings.
|
||||||
|
FREIGHT_PERMS.contracts.cancel,
|
||||||
FREIGHT_PERMS.contracts.editDocument,
|
FREIGHT_PERMS.contracts.editDocument,
|
||||||
...BOOKING_DESK_NOTIFICATION_KEYS,
|
...BOOKING_DESK_NOTIFICATION_KEYS,
|
||||||
// Marketing follows up with the customer when a reviewer sends profile
|
// Marketing follows up with the customer when a reviewer sends profile
|
||||||
|
|||||||
28
apps/edr-freight-api/src/seed/warehouse-layout.json
Normal file
28
apps/edr-freight-api/src/seed/warehouse-layout.json
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"facility": {
|
||||||
|
"code": "GELAN",
|
||||||
|
"name": "Gelan Dry Port",
|
||||||
|
"facilityType": "DRY_PORT"
|
||||||
|
},
|
||||||
|
"levels": ["L1", "L2", "L3", "L4"],
|
||||||
|
"kinds": [
|
||||||
|
{ "suffix": "OPEN", "letter": "O", "name": "Open Warehouse", "type": "OPEN_WAREHOUSE" },
|
||||||
|
{ "suffix": "CLOSED", "letter": "C", "name": "Closed Warehouse", "type": "CLOSED_WAREHOUSE" }
|
||||||
|
],
|
||||||
|
"yards": [
|
||||||
|
{ "label": "A", "type": "CONTAINER_YARD", "capacityContainers": 150 },
|
||||||
|
{ "label": "B", "type": "CONTAINER_YARD", "capacityContainers": 150 },
|
||||||
|
{ "label": "C", "type": "GENERAL_CARGO_YARD", "capacityContainers": null },
|
||||||
|
{ "label": "D", "type": "BULK_YARD", "capacityContainers": null },
|
||||||
|
{ "label": "E", "type": "HAZARDOUS_YARD", "capacityContainers": null },
|
||||||
|
{ "label": "F", "type": "COLD_STORAGE_YARD", "capacityContainers": null }
|
||||||
|
],
|
||||||
|
"zones": [
|
||||||
|
{ "label": "A", "capacityContainers": 60 },
|
||||||
|
{ "label": "B", "capacityContainers": 45 },
|
||||||
|
{ "label": "C", "capacityContainers": 45 }
|
||||||
|
],
|
||||||
|
"stack": {
|
||||||
|
"maxStackHeight": 3
|
||||||
|
}
|
||||||
|
}
|
||||||
214
apps/edr-freight-api/src/seed/warehouse-layout.seeder.ts
Normal file
214
apps/edr-freight-api/src/seed/warehouse-layout.seeder.ts
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
import { readFileSync } from 'fs';
|
||||||
|
import { join } from 'path';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the physical warehouse layout from `warehouse-layout.json`:
|
||||||
|
* facility → warehouses (L1-OPEN …) → yards (A–F) → zones (A–C) → ground
|
||||||
|
* stacks → slots.
|
||||||
|
*
|
||||||
|
* The shape is configuration, never enums: yard letters, zone letters and
|
||||||
|
* stack heights all come from the JSON, because a physical layout changes and
|
||||||
|
* a deployed enum does not.
|
||||||
|
*
|
||||||
|
* Idempotent on every code. A row that already exists is left exactly as it
|
||||||
|
* is — capacities tuned by hand on a live site must survive a re-run.
|
||||||
|
*/
|
||||||
|
export interface WarehouseLayoutConfig {
|
||||||
|
facility: { code: string; name: string; facilityType: string };
|
||||||
|
levels: string[];
|
||||||
|
kinds: Array<{ suffix: string; letter: string; name: string; type: string }>;
|
||||||
|
yards: Array<{ label: string; type: string; capacityContainers: number | null }>;
|
||||||
|
zones: Array<{ label: string; capacityContainers: number }>;
|
||||||
|
stack: { maxStackHeight: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LayoutSeedSummary {
|
||||||
|
facilityCode: string;
|
||||||
|
warehousesCreated: number;
|
||||||
|
yardsCreated: number;
|
||||||
|
zonesCreated: number;
|
||||||
|
stacksCreated: number;
|
||||||
|
slotsCreated: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class WarehouseLayoutSeeder {
|
||||||
|
constructor(
|
||||||
|
private readonly dataSource: DataSource,
|
||||||
|
private readonly config: WarehouseLayoutConfig = WarehouseLayoutSeeder.loadConfig(),
|
||||||
|
) {}
|
||||||
|
|
||||||
|
static loadConfig(path = join(__dirname, 'warehouse-layout.json')): WarehouseLayoutConfig {
|
||||||
|
return JSON.parse(readFileSync(path, 'utf8')) as WarehouseLayoutConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
async run(): Promise<LayoutSeedSummary> {
|
||||||
|
const summary: LayoutSeedSummary = {
|
||||||
|
facilityCode: this.config.facility.code,
|
||||||
|
warehousesCreated: 0,
|
||||||
|
yardsCreated: 0,
|
||||||
|
zonesCreated: 0,
|
||||||
|
stacksCreated: 0,
|
||||||
|
slotsCreated: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const facilityId = await this.upsertFacility();
|
||||||
|
|
||||||
|
for (const level of this.config.levels) {
|
||||||
|
for (const kind of this.config.kinds) {
|
||||||
|
const warehouseCode = `${level}-${kind.suffix}`;
|
||||||
|
const warehouse = await this.upsertWarehouse(facilityId, warehouseCode, `${level} ${kind.name}`, kind.type);
|
||||||
|
summary.warehousesCreated += warehouse.created ? 1 : 0;
|
||||||
|
|
||||||
|
for (const yardCfg of this.config.yards) {
|
||||||
|
const yardCode = `${level}-${kind.letter}-${yardCfg.label}`;
|
||||||
|
const yard = await this.upsertYard(warehouse.id, yardCode, `Yard ${yardCfg.label}`, yardCfg);
|
||||||
|
summary.yardsCreated += yard.created ? 1 : 0;
|
||||||
|
|
||||||
|
// Zones, stacks and slots are only laid out for container yards —
|
||||||
|
// bulk and general cargo do not stand in numbered positions.
|
||||||
|
if (yardCfg.type !== 'CONTAINER_YARD') continue;
|
||||||
|
|
||||||
|
for (const zoneCfg of this.config.zones) {
|
||||||
|
const zoneCode = `${yardCode}-Z${zoneCfg.label}`;
|
||||||
|
const zone = await this.upsertZone(yard.id, zoneCode, `Zone ${zoneCfg.label}`, zoneCfg.capacityContainers);
|
||||||
|
summary.zonesCreated += zone.created ? 1 : 0;
|
||||||
|
|
||||||
|
const height = this.config.stack.maxStackHeight;
|
||||||
|
// Ground positions, not boxes: a 60-container zone stacked three
|
||||||
|
// high needs 20 patches of concrete.
|
||||||
|
const stackCount = Math.floor(zoneCfg.capacityContainers / height);
|
||||||
|
|
||||||
|
for (let n = 1; n <= stackCount; n += 1) {
|
||||||
|
const stackCode = `Z${zoneCfg.label}-${String(n).padStart(3, '0')}`;
|
||||||
|
const stack = await this.upsertStack(zone.id, stackCode, height);
|
||||||
|
summary.stacksCreated += stack.created ? 1 : 0;
|
||||||
|
summary.slotsCreated += await this.upsertSlots(stack.id, height);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async upsertFacility(): Promise<string> {
|
||||||
|
const { code, name, facilityType } = this.config.facility;
|
||||||
|
const [existing] = await this.dataSource.query(
|
||||||
|
`SELECT id FROM freight.facilities WHERE code = $1 AND deleted_at IS NULL`,
|
||||||
|
[code],
|
||||||
|
);
|
||||||
|
if (existing) return existing.id;
|
||||||
|
|
||||||
|
const [created] = await this.dataSource.query(
|
||||||
|
`INSERT INTO freight.facilities (code, name, facility_type, facility_status, is_active)
|
||||||
|
VALUES ($1, $2, $3, 'ACTIVE', true)
|
||||||
|
RETURNING id`,
|
||||||
|
[code, name, facilityType],
|
||||||
|
);
|
||||||
|
return created.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async upsertWarehouse(
|
||||||
|
facilityId: string,
|
||||||
|
code: string,
|
||||||
|
name: string,
|
||||||
|
type: string,
|
||||||
|
): Promise<{ id: string; created: boolean }> {
|
||||||
|
const [existing] = await this.dataSource.query(
|
||||||
|
`SELECT id FROM freight.warehouses WHERE code = $1 AND deleted_at IS NULL`,
|
||||||
|
[code],
|
||||||
|
);
|
||||||
|
if (existing) return { id: existing.id, created: false };
|
||||||
|
|
||||||
|
const [created] = await this.dataSource.query(
|
||||||
|
`INSERT INTO freight.warehouses (code, name, type, facility_id, status, is_active,
|
||||||
|
current_weight, current_containers, current_volume)
|
||||||
|
VALUES ($1, $2, $3, $4, 'ACTIVE', true, 0, 0, 0)
|
||||||
|
RETURNING id`,
|
||||||
|
[code, name, type, facilityId],
|
||||||
|
);
|
||||||
|
return { id: created.id, created: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async upsertYard(
|
||||||
|
warehouseId: string,
|
||||||
|
code: string,
|
||||||
|
name: string,
|
||||||
|
cfg: { type: string; capacityContainers: number | null },
|
||||||
|
): Promise<{ id: string; created: boolean }> {
|
||||||
|
const [existing] = await this.dataSource.query(
|
||||||
|
`SELECT id FROM freight.warehouse_yards
|
||||||
|
WHERE warehouse_id = $1 AND code = $2 AND deleted_at IS NULL`,
|
||||||
|
[warehouseId, code],
|
||||||
|
);
|
||||||
|
if (existing) return { id: existing.id, created: false };
|
||||||
|
|
||||||
|
const [created] = await this.dataSource.query(
|
||||||
|
`INSERT INTO freight.warehouse_yards (warehouse_id, code, name, type, capacity_containers,
|
||||||
|
status, is_active, current_weight, current_containers, current_volume)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, 'ACTIVE', true, 0, 0, 0)
|
||||||
|
RETURNING id`,
|
||||||
|
[warehouseId, code, name, cfg.type, cfg.capacityContainers],
|
||||||
|
);
|
||||||
|
return { id: created.id, created: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async upsertZone(
|
||||||
|
yardId: string,
|
||||||
|
code: string,
|
||||||
|
name: string,
|
||||||
|
capacityContainers: number,
|
||||||
|
): Promise<{ id: string; created: boolean }> {
|
||||||
|
const [existing] = await this.dataSource.query(
|
||||||
|
`SELECT id FROM freight.warehouse_zones WHERE yard_id = $1 AND code = $2 AND deleted_at IS NULL`,
|
||||||
|
[yardId, code],
|
||||||
|
);
|
||||||
|
if (existing) return { id: existing.id, created: false };
|
||||||
|
|
||||||
|
const [created] = await this.dataSource.query(
|
||||||
|
`INSERT INTO freight.warehouse_zones (yard_id, code, name, type, capacity_containers,
|
||||||
|
status, is_active, current_weight, current_containers, current_volume)
|
||||||
|
VALUES ($1, $2, $3, 'CONTAINER_ZONE', $4, 'ACTIVE', true, 0, 0, 0)
|
||||||
|
RETURNING id`,
|
||||||
|
[yardId, code, name, capacityContainers],
|
||||||
|
);
|
||||||
|
return { id: created.id, created: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async upsertStack(
|
||||||
|
zoneId: string,
|
||||||
|
code: string,
|
||||||
|
maxStackHeight: number,
|
||||||
|
): Promise<{ id: string; created: boolean }> {
|
||||||
|
const [existing] = await this.dataSource.query(
|
||||||
|
`SELECT id FROM freight.warehouse_zone_stacks WHERE zone_id = $1 AND code = $2 AND deleted_at IS NULL`,
|
||||||
|
[zoneId, code],
|
||||||
|
);
|
||||||
|
if (existing) return { id: existing.id, created: false };
|
||||||
|
|
||||||
|
const [created] = await this.dataSource.query(
|
||||||
|
`INSERT INTO freight.warehouse_zone_stacks (zone_id, code, max_stack_height, status, is_active)
|
||||||
|
VALUES ($1, $2, $3, 'ACTIVE', true)
|
||||||
|
RETURNING id`,
|
||||||
|
[zoneId, code, maxStackHeight],
|
||||||
|
);
|
||||||
|
return { id: created.id, created: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async upsertSlots(stackId: string, height: number): Promise<number> {
|
||||||
|
const result = await this.dataSource.query(
|
||||||
|
`INSERT INTO freight.warehouse_zone_slots (stack_id, level, status, is_active)
|
||||||
|
SELECT $1, lvl, 'AVAILABLE', true
|
||||||
|
FROM generate_series(1, $2) AS lvl
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM freight.warehouse_zone_slots s
|
||||||
|
WHERE s.stack_id = $1 AND s.level = lvl AND s.deleted_at IS NULL
|
||||||
|
)
|
||||||
|
RETURNING id`,
|
||||||
|
[stackId, height],
|
||||||
|
);
|
||||||
|
return Array.isArray(result) ? result.length : 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -97,6 +97,7 @@
|
|||||||
"react-markdown": "^9.1.0",
|
"react-markdown": "^9.1.0",
|
||||||
"react-pdf": "^10.4.1",
|
"react-pdf": "^10.4.1",
|
||||||
"react-pdf-html": "^2.1.5",
|
"react-pdf-html": "^2.1.5",
|
||||||
|
"react-phone-number-input": "^3.4.17",
|
||||||
"react-quill-new": "^3.8.3",
|
"react-quill-new": "^3.8.3",
|
||||||
"react-resizable-panels": "^3.0.6",
|
"react-resizable-panels": "^3.0.6",
|
||||||
"react-router-dom": "^6.27.0",
|
"react-router-dom": "^6.27.0",
|
||||||
|
|||||||
@@ -96,6 +96,7 @@ import TrainBuilderDetailPage from "./pages/trainBuilder/TrainBuilderDetailPage"
|
|||||||
import TrainBuilderListPage from "./pages/trainBuilder/TrainBuilderListPage";
|
import TrainBuilderListPage from "./pages/trainBuilder/TrainBuilderListPage";
|
||||||
import ArrivalQueuePage from "./pages/warehouses/ArrivalQueuePage";
|
import ArrivalQueuePage from "./pages/warehouses/ArrivalQueuePage";
|
||||||
import ContainerReturnsPage from "./pages/warehouses/ContainerReturnsPage";
|
import ContainerReturnsPage from "./pages/warehouses/ContainerReturnsPage";
|
||||||
|
import RegisterFullContainersPage from "./pages/warehouses/RegisterFullContainersPage";
|
||||||
import DispatchQueuePage from "./pages/warehouses/DispatchQueuePage";
|
import DispatchQueuePage from "./pages/warehouses/DispatchQueuePage";
|
||||||
import ExportDjiboutiUnloadingQueuePage from "./pages/warehouses/ExportDjiboutiUnloadingQueuePage";
|
import ExportDjiboutiUnloadingQueuePage from "./pages/warehouses/ExportDjiboutiUnloadingQueuePage";
|
||||||
import ExportWarehouseFlowPage from "./pages/warehouses/ExportWarehouseFlowPage";
|
import ExportWarehouseFlowPage from "./pages/warehouses/ExportWarehouseFlowPage";
|
||||||
@@ -708,6 +709,16 @@ const App = () => {
|
|||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="register-full-containers"
|
||||||
|
element={
|
||||||
|
<RequirePermission
|
||||||
|
permission={FREIGHT_PERMS.warehouseInventory.receive}
|
||||||
|
>
|
||||||
|
<RegisterFullContainersPage />
|
||||||
|
</RequirePermission>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="loaded-inventory"
|
path="loaded-inventory"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { isSmsReachable, isValidPhone } from "./PhoneField";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `isSmsReachable` mirrors `isDomesticPhone` in the API's otp.service. The two
|
||||||
|
* must agree: this one greys out the SMS option, that one decides whether the
|
||||||
|
* message is actually sent, and a disagreement means the UI promises a text
|
||||||
|
* nobody sends (or hides one that would have worked). These cases are the same
|
||||||
|
* ones the API spec asserts.
|
||||||
|
*/
|
||||||
|
describe("isSmsReachable", () => {
|
||||||
|
it.each(["+251986680099", "0986680099", "251986680099"])(
|
||||||
|
"accepts Ethiopian mobile form %s",
|
||||||
|
(phone) => expect(isSmsReachable(phone)).toBe(true),
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each(["+25377123456", "25377123456", "77123456"])(
|
||||||
|
"accepts Djibouti mobile form %s",
|
||||||
|
(phone) => expect(isSmsReachable(phone)).toBe(true),
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
"+14155550123",
|
||||||
|
"+447911123456",
|
||||||
|
"0712345678",
|
||||||
|
"+2519866",
|
||||||
|
"12345",
|
||||||
|
// Djibouti fixed line — valid number, not a mobile the gateway serves.
|
||||||
|
"+25321350000",
|
||||||
|
"+25366123456",
|
||||||
|
])("rejects unreachable or malformed %s", (phone) =>
|
||||||
|
expect(isSmsReachable(phone)).toBe(false),
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each([undefined, null, ""])("treats %s as unreachable", (phone) =>
|
||||||
|
expect(isSmsReachable(phone)).toBe(false),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The country-picker input emits a PARTIAL E.164 while the user is still
|
||||||
|
* typing — "+25377" is a non-empty string that will post happily and come back
|
||||||
|
* as a 400 from the API's own IsValidPhone. Forms must treat "non-empty" and
|
||||||
|
* "complete" as different questions, so this is the check they call.
|
||||||
|
*/
|
||||||
|
describe("isValidPhone", () => {
|
||||||
|
it.each(["+25377834567", "+251911223344"])(
|
||||||
|
"accepts the complete number %s",
|
||||||
|
(phone) => expect(isValidPhone(phone)).toBe(true),
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each(["+253", "+25377", "+2537712", "+251", "+2519112"])(
|
||||||
|
"rejects the partial number %s the picker emits mid-typing",
|
||||||
|
(phone) => expect(isValidPhone(phone)).toBe(false),
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each([undefined, null, ""])("treats %s as invalid", (phone) =>
|
||||||
|
expect(isValidPhone(phone)).toBe(false),
|
||||||
|
);
|
||||||
|
});
|
||||||
107
apps/edr-freight-web/backoffice/src/components/PhoneField.tsx
Normal file
107
apps/edr-freight-web/backoffice/src/components/PhoneField.tsx
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import { Input, TextInput } from "@mantine/core";
|
||||||
|
import RPNInput, { isValidPhoneNumber } from "react-phone-number-input";
|
||||||
|
import "react-phone-number-input/style.css";
|
||||||
|
import "./phone-field.css";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The countries the railway operates between, and the only two the SMS gateway
|
||||||
|
* is contracted to reach (see `REACHABLE_MOBILE_PATTERNS` in the API's
|
||||||
|
* otp.service). Restricting the picker to them keeps staff from entering a
|
||||||
|
* number that would validate but could never receive an activation link.
|
||||||
|
*/
|
||||||
|
export const SUPPORTED_PHONE_COUNTRIES = ["DJ", "ET"] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Djibouti — most accounts entered here (transit agents above all) are
|
||||||
|
* Djibouti-side, so it saves the picker interaction on the common case.
|
||||||
|
*/
|
||||||
|
export const DEFAULT_PHONE_COUNTRY = "DJ";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-exported so callers can validate before submitting.
|
||||||
|
*
|
||||||
|
* Needed because the input emits a PARTIAL E.164 while the user is still
|
||||||
|
* typing — "+25377" and "+2537712" are non-empty strings that reach a payload
|
||||||
|
* happily and then come back as a 400 from the API's own `IsValidPhone`. A
|
||||||
|
* caller must treat "non-empty" and "complete" as different questions.
|
||||||
|
*/
|
||||||
|
export const isValidPhone = (value?: string | null): boolean =>
|
||||||
|
!!value && isValidPhoneNumber(value);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the SMS gateway can actually reach this number.
|
||||||
|
*
|
||||||
|
* Mirrors `isDomesticPhone` in the API's otp.service — Ethiopian `+2519…` and
|
||||||
|
* Djiboutian `+25377…` mobiles. Anything else (a landline, another country) is
|
||||||
|
* queued and silently lost, so the UI offers email instead of promising an SMS.
|
||||||
|
*/
|
||||||
|
export function isSmsReachable(rawPhone?: string | null): boolean {
|
||||||
|
if (!rawPhone) return false;
|
||||||
|
const digits = rawPhone.trim().replace(/[^\d+]/g, "");
|
||||||
|
const bare = digits.replace(/^\+/, "").replace(/^0+/, "");
|
||||||
|
const normalized = digits.startsWith("+")
|
||||||
|
? digits
|
||||||
|
: /^251\d{9}$|^253\d{8}$/.test(digits)
|
||||||
|
? `+${digits}`
|
||||||
|
: /^9\d{8}$|^7\d{8}$/.test(bare)
|
||||||
|
? `+251${bare}`
|
||||||
|
: /^77\d{6}$/.test(bare)
|
||||||
|
? `+253${bare}`
|
||||||
|
: digits;
|
||||||
|
return /^\+2519\d{8}$/.test(normalized) || /^\+25377\d{6}$/.test(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PhoneFieldProps {
|
||||||
|
label?: string;
|
||||||
|
value?: string;
|
||||||
|
onChange: (value: string | undefined) => void;
|
||||||
|
error?: string;
|
||||||
|
required?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
placeholder?: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Phone input with a country selector, limited to Ethiopia and Djibouti.
|
||||||
|
* Emits a single E.164 value (e.g. +251912345678, +25377123456) so the API
|
||||||
|
* never has to guess a country from a bare local number.
|
||||||
|
*/
|
||||||
|
export function PhoneField({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
error,
|
||||||
|
required,
|
||||||
|
disabled,
|
||||||
|
placeholder = "77 83 45 67",
|
||||||
|
description,
|
||||||
|
}: PhoneFieldProps) {
|
||||||
|
return (
|
||||||
|
<Input.Wrapper
|
||||||
|
label={label}
|
||||||
|
required={required}
|
||||||
|
error={error}
|
||||||
|
description={description}
|
||||||
|
styles={{ label: { fontWeight: 600, fontSize: 14, color: "#10202F" } }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`edr-phone-wrapper${error ? " edr-phone-wrapper--error" : ""}`}
|
||||||
|
>
|
||||||
|
<RPNInput
|
||||||
|
international
|
||||||
|
defaultCountry={DEFAULT_PHONE_COUNTRY}
|
||||||
|
countries={[...SUPPORTED_PHONE_COUNTRIES]}
|
||||||
|
countryCallingCodeEditable={false}
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
inputComponent={TextInput}
|
||||||
|
disabled={disabled}
|
||||||
|
placeholder={placeholder}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Input.Wrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PhoneField;
|
||||||
@@ -30,6 +30,13 @@ export function BookingCargoCard({ booking }: BookingCargoCardProps) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const isBulk = booking.freightType === "BULK";
|
const isBulk = booking.freightType === "BULK";
|
||||||
|
// NUMBER_OF_WAGONS cargo is booked by a wagon COUNT, not by tonnage — the
|
||||||
|
// count the customer fixed is what allocation and per-wagon pricing use, so
|
||||||
|
// it belongs on the card next to the weight.
|
||||||
|
const requestedWagons =
|
||||||
|
isBulk && booking.cargoType?.unitOfMeasure === "NUMBER_OF_WAGONS"
|
||||||
|
? Number(booking.bulkRequestedWagons ?? 0) || null
|
||||||
|
: null;
|
||||||
// Bulk: the commodity itself (Wheat, Steel…) is the headline. Containers:
|
// Bulk: the commodity itself (Wheat, Steel…) is the headline. Containers:
|
||||||
// the freight kind, with the shipper's own description alongside.
|
// the freight kind, with the shipper's own description alongside.
|
||||||
const cargoHeadline = isBulk
|
const cargoHeadline = isBulk
|
||||||
@@ -46,6 +53,11 @@ export function BookingCargoCard({ booking }: BookingCargoCardProps) {
|
|||||||
<Badge variant="light" color={isBulk ? "orange" : "blue"} radius="sm">
|
<Badge variant="light" color={isBulk ? "orange" : "blue"} radius="sm">
|
||||||
{isBulk ? "Bulk" : "Container"}
|
{isBulk ? "Bulk" : "Container"}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
{requestedWagons != null ? (
|
||||||
|
<Badge variant="light" color="grape" radius="sm">
|
||||||
|
{requestedWagons} wagon{requestedWagons === 1 ? "" : "s"}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
{cargoDescription ? (
|
{cargoDescription ? (
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
— {cargoDescription}
|
— {cargoDescription}
|
||||||
@@ -62,6 +74,9 @@ export function BookingCargoCard({ booking }: BookingCargoCardProps) {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<MetricTile label="Total VGM" value={`${tons} tons`} />
|
<MetricTile label="Total VGM" value={`${tons} tons`} />
|
||||||
|
{requestedWagons != null && (
|
||||||
|
<MetricTile label="Wagons booked" value={`${requestedWagons}`} />
|
||||||
|
)}
|
||||||
{items != null && <MetricTile label="Items" value={`${items}`} />}
|
{items != null && <MetricTile label="Items" value={`${items}`} />}
|
||||||
<MetricTile
|
<MetricTile
|
||||||
label="Hazardous"
|
label="Hazardous"
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useNavigate } from "react-router-dom";
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Button, Group, Modal, Stack, Text, Textarea } from "@mantine/core";
|
import { Button, Group, Modal, Stack, Text, Textarea } from "@mantine/core";
|
||||||
import {
|
import {
|
||||||
|
Ban,
|
||||||
Check,
|
Check,
|
||||||
Eye,
|
Eye,
|
||||||
// FilePen, // ponytail: back with the "Edit contract articles" button
|
// FilePen, // ponytail: back with the "Edit contract articles" button
|
||||||
@@ -81,6 +82,8 @@ export function ContractActionsToolbar({
|
|||||||
const mayReject = hasPermission(user, FREIGHT_PERMS.contracts.reject[arm]);
|
const mayReject = hasPermission(user, FREIGHT_PERMS.contracts.reject[arm]);
|
||||||
// One key both ways — whoever can freeze a contract can unfreeze it.
|
// One key both ways — whoever can freeze a contract can unfreeze it.
|
||||||
const maySuspend = hasPermission(user, FREIGHT_PERMS.contracts.suspend);
|
const maySuspend = hasPermission(user, FREIGHT_PERMS.contracts.suspend);
|
||||||
|
// Cancel is its own key — it is terminal, so it is NOT implied by suspend.
|
||||||
|
const mayCancel = hasPermission(user, FREIGHT_PERMS.contracts.cancel);
|
||||||
|
|
||||||
const [editorOpen, setEditorOpen] = useState(false);
|
const [editorOpen, setEditorOpen] = useState(false);
|
||||||
const [editorMode, setEditorMode] = useState<"accept" | "edit">("accept");
|
const [editorMode, setEditorMode] = useState<"accept" | "edit">("accept");
|
||||||
@@ -93,6 +96,67 @@ export function ContractActionsToolbar({
|
|||||||
const [suspendReason, setSuspendReason] = useState("");
|
const [suspendReason, setSuspendReason] = useState("");
|
||||||
const [resumeOpen, setResumeOpen] = useState(false);
|
const [resumeOpen, setResumeOpen] = useState(false);
|
||||||
const [resumeNote, setResumeNote] = useState("");
|
const [resumeNote, setResumeNote] = useState("");
|
||||||
|
const [cancelOpen, setCancelOpen] = useState(false);
|
||||||
|
const [cancelReason, setCancelReason] = useState("");
|
||||||
|
|
||||||
|
// Shared by the suspended branch and the normal toolbar — both can cancel.
|
||||||
|
const cancelModal = (
|
||||||
|
<Modal
|
||||||
|
opened={cancelOpen}
|
||||||
|
onClose={() => setCancelOpen(false)}
|
||||||
|
title="Cancel this contract?"
|
||||||
|
centered
|
||||||
|
>
|
||||||
|
<Stack gap="md">
|
||||||
|
<Text size="sm">
|
||||||
|
Contract <b>{contract.reference}</b> will be cancelled permanently.
|
||||||
|
This cannot be undone — there is no way to reactivate it. A new
|
||||||
|
contract with the same details can be submitted afterwards. The
|
||||||
|
customer is notified.
|
||||||
|
</Text>
|
||||||
|
<Textarea
|
||||||
|
label="Reason for cancellation"
|
||||||
|
placeholder="Explain why this contract is being cancelled…"
|
||||||
|
autosize
|
||||||
|
minRows={3}
|
||||||
|
value={cancelReason}
|
||||||
|
onChange={(e) => setCancelReason(e.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
<Group justify="flex-end" gap="sm">
|
||||||
|
<Button variant="default" onClick={() => setCancelOpen(false)}>
|
||||||
|
Keep contract
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="red"
|
||||||
|
disabled={!cancelReason.trim()}
|
||||||
|
loading={mutations.cancelByStaff.isPending}
|
||||||
|
onClick={() =>
|
||||||
|
mutations.cancelByStaff.mutate(cancelReason, {
|
||||||
|
onSuccess: () => {
|
||||||
|
setCancelOpen(false);
|
||||||
|
setCancelReason("");
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Cancel contract
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
|
||||||
|
const cancelButton = mayCancel ? (
|
||||||
|
<Button
|
||||||
|
fullWidth
|
||||||
|
variant="light"
|
||||||
|
color="red"
|
||||||
|
leftSection={<Ban size={16} />}
|
||||||
|
onClick={() => setCancelOpen(true)}
|
||||||
|
>
|
||||||
|
Cancel contract
|
||||||
|
</Button>
|
||||||
|
) : null;
|
||||||
|
|
||||||
// Whether the document is editable depends on WHO is viewing — only the
|
// Whether the document is editable depends on WHO is viewing — only the
|
||||||
// approver whose turn it is may edit — so the server decides, not the client.
|
// approver whose turn it is may edit — so the server decides, not the client.
|
||||||
@@ -126,9 +190,13 @@ export function ContractActionsToolbar({
|
|||||||
if (status === "CHANGES_REQUESTED") {
|
if (status === "CHANGES_REQUESTED") {
|
||||||
return (
|
return (
|
||||||
<SectionCard icon={Zap} title="Awaiting customer">
|
<SectionCard icon={Zap} title="Awaiting customer">
|
||||||
<Text size="sm" c="dimmed">
|
<Stack gap="sm">
|
||||||
No staff actions until the customer resubmits the contract.
|
<Text size="sm" c="dimmed">
|
||||||
</Text>
|
No staff actions until the customer resubmits the contract.
|
||||||
|
</Text>
|
||||||
|
{cancelButton}
|
||||||
|
</Stack>
|
||||||
|
{cancelModal}
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -166,6 +234,7 @@ export function ContractActionsToolbar({
|
|||||||
You do not have permission to lift a suspension.
|
You do not have permission to lift a suspension.
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
{cancelButton}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
@@ -209,6 +278,8 @@ export function ContractActionsToolbar({
|
|||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
{cancelModal}
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -362,11 +433,16 @@ export function ContractActionsToolbar({
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Available at every non-terminal status — the early returns above
|
||||||
|
already cover the statuses where cancelling makes no sense. */}
|
||||||
|
{cancelButton}
|
||||||
|
|
||||||
{!canAccept &&
|
{!canAccept &&
|
||||||
!inApproval &&
|
!inApproval &&
|
||||||
!canViewContract &&
|
!canViewContract &&
|
||||||
!canReviewClearance &&
|
!canReviewClearance &&
|
||||||
!canSuspend && (
|
!canSuspend &&
|
||||||
|
!mayCancel && (
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
No staff actions available for this status. Monitor until the
|
No staff actions available for this status. Monitor until the
|
||||||
workflow advances.
|
workflow advances.
|
||||||
@@ -515,6 +591,8 @@ export function ContractActionsToolbar({
|
|||||||
</Button>
|
</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
{cancelModal}
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -118,6 +118,7 @@ const blockNegative = (event: KeyboardEvent<HTMLInputElement>) => {
|
|||||||
|
|
||||||
interface UnitErrors {
|
interface UnitErrors {
|
||||||
containerNumber?: string;
|
containerNumber?: string;
|
||||||
|
sealNumber?: string;
|
||||||
vgmTons?: string;
|
vgmTons?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -886,6 +887,9 @@ export default function GlCreateBookingForm() {
|
|||||||
} else if ((numberCounts.get(key) ?? 0) > 1) {
|
} else if ((numberCounts.get(key) ?? 0) > 1) {
|
||||||
errs.containerNumber = "Duplicate container number in this shipment.";
|
errs.containerNumber = "Duplicate container number in this shipment.";
|
||||||
}
|
}
|
||||||
|
if (u.sealNumber.trim() === "") {
|
||||||
|
errs.sealNumber = "Seal number is required.";
|
||||||
|
}
|
||||||
const vgm = Number(u.vgmTons);
|
const vgm = Number(u.vgmTons);
|
||||||
if (u.vgmTons.trim() === "" || Number.isNaN(vgm) || vgm <= 0) {
|
if (u.vgmTons.trim() === "" || Number.isNaN(vgm) || vgm <= 0) {
|
||||||
errs.vgmTons = "Enter a valid VGM.";
|
errs.vgmTons = "Enter a valid VGM.";
|
||||||
@@ -1185,7 +1189,7 @@ export default function GlCreateBookingForm() {
|
|||||||
: {}),
|
: {}),
|
||||||
units: l.units.map((u) => ({
|
units: l.units.map((u) => ({
|
||||||
containerNumber: u.containerNumber.trim().toUpperCase(),
|
containerNumber: u.containerNumber.trim().toUpperCase(),
|
||||||
...(u.sealNumber ? { sealNumber: u.sealNumber } : {}),
|
sealNumber: u.sealNumber.trim(),
|
||||||
vgmTons: Number(u.vgmTons) || 0,
|
vgmTons: Number(u.vgmTons) || 0,
|
||||||
// Per-container handling — the server rolls these into the line
|
// Per-container handling — the server rolls these into the line
|
||||||
// counts and bills each surcharge on the ticked containers only.
|
// counts and bills each surcharge on the ticked containers only.
|
||||||
@@ -1247,7 +1251,7 @@ export default function GlCreateBookingForm() {
|
|||||||
reeferQuantity: Number(l.reeferQuantity || 0) || undefined,
|
reeferQuantity: Number(l.reeferQuantity || 0) || undefined,
|
||||||
units: l.units.map((u) => ({
|
units: l.units.map((u) => ({
|
||||||
containerNumber: u.containerNumber.trim().toUpperCase(),
|
containerNumber: u.containerNumber.trim().toUpperCase(),
|
||||||
...(u.sealNumber ? { sealNumber: u.sealNumber } : {}),
|
sealNumber: u.sealNumber.trim(),
|
||||||
vgmTons: Number(u.vgmTons) || 0,
|
vgmTons: Number(u.vgmTons) || 0,
|
||||||
isHazardous: Boolean(u.isHazardous),
|
isHazardous: Boolean(u.isHazardous),
|
||||||
isReefer: Boolean(u.isReefer),
|
isReefer: Boolean(u.isReefer),
|
||||||
@@ -1857,7 +1861,7 @@ export default function GlCreateBookingForm() {
|
|||||||
Container number *
|
Container number *
|
||||||
</Text>
|
</Text>
|
||||||
<Text fz={12} fw={600} c="#10202F" style={{ flex: 1 }}>
|
<Text fz={12} fw={600} c="#10202F" style={{ flex: 1 }}>
|
||||||
Seal number
|
Seal number *
|
||||||
</Text>
|
</Text>
|
||||||
<Text fz={12} fw={600} c="#10202F" style={{ flex: 1 }}>
|
<Text fz={12} fw={600} c="#10202F" style={{ flex: 1 }}>
|
||||||
VGM (tons) *
|
VGM (tons) *
|
||||||
@@ -1901,8 +1905,13 @@ export default function GlCreateBookingForm() {
|
|||||||
style={{ flex: 1 }}
|
style={{ flex: 1 }}
|
||||||
/>
|
/>
|
||||||
<TextInput
|
<TextInput
|
||||||
placeholder="Optional"
|
placeholder="e.g. SL-0099231"
|
||||||
value={unit.sealNumber}
|
value={unit.sealNumber}
|
||||||
|
error={
|
||||||
|
showErrors
|
||||||
|
? unitErrors[lineIdx]?.[unitIdx]?.sealNumber
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
patchUnit(lineIdx, unitIdx, {
|
patchUnit(lineIdx, unitIdx, {
|
||||||
sealNumber: e.currentTarget.value,
|
sealNumber: e.currentTarget.value,
|
||||||
|
|||||||
@@ -151,6 +151,11 @@ export async function parseContainerExcel(
|
|||||||
numberCounts.set(containerNumber, (numberCounts.get(containerNumber) ?? 0) + 1);
|
numberCounts.set(containerNumber, (numberCounts.get(containerNumber) ?? 0) + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const sealNumber = cell("sealNumber");
|
||||||
|
if (!sealNumber) {
|
||||||
|
errors.push(`Row ${rowNo}: seal number is required.`);
|
||||||
|
}
|
||||||
|
|
||||||
const vgmRaw = cell("vgmTons");
|
const vgmRaw = cell("vgmTons");
|
||||||
const vgm = Number(vgmRaw);
|
const vgm = Number(vgmRaw);
|
||||||
if (!vgmRaw || Number.isNaN(vgm) || vgm <= 0) {
|
if (!vgmRaw || Number.isNaN(vgm) || vgm <= 0) {
|
||||||
@@ -160,7 +165,7 @@ export async function parseContainerExcel(
|
|||||||
rows.push({
|
rows.push({
|
||||||
containerSize: size ?? "",
|
containerSize: size ?? "",
|
||||||
containerNumber,
|
containerNumber,
|
||||||
sealNumber: cell("sealNumber"),
|
sealNumber,
|
||||||
vgmTons: vgmRaw,
|
vgmTons: vgmRaw,
|
||||||
hazardous: opts.includeHazardous && parseFlag(cell("hazardous")),
|
hazardous: opts.includeHazardous && parseFlag(cell("hazardous")),
|
||||||
reefer: opts.includeReefer && parseFlag(cell("reefer")),
|
reefer: opts.includeReefer && parseFlag(cell("reefer")),
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
Package,
|
Package,
|
||||||
PackageCheck,
|
PackageCheck,
|
||||||
PackageOpen,
|
PackageOpen,
|
||||||
|
PackagePlus,
|
||||||
Paperclip,
|
Paperclip,
|
||||||
Receipt,
|
Receipt,
|
||||||
Stamp,
|
Stamp,
|
||||||
@@ -366,6 +367,12 @@ export const buildSidebarSections = (
|
|||||||
icon: <Container />,
|
icon: <Container />,
|
||||||
permission: FREIGHT_PERMS.warehouseInventory.view,
|
permission: FREIGHT_PERMS.warehouseInventory.view,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: "Register Full Containers",
|
||||||
|
href: "/dashboard/register-full-containers",
|
||||||
|
icon: <PackagePlus />,
|
||||||
|
permission: FREIGHT_PERMS.warehouseInventory.receive,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: "Terminal Inventory",
|
label: "Terminal Inventory",
|
||||||
href: "/dashboard/warehouse-inventory?direction=IMPORT",
|
href: "/dashboard/warehouse-inventory?direction=IMPORT",
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user