diff --git a/apps/edr-freight-api/package.json b/apps/edr-freight-api/package.json index 0c6b1c727..79e350cc7 100644 --- a/apps/edr-freight-api/package.json +++ b/apps/edr-freight-api/package.json @@ -18,6 +18,7 @@ "type-check": "tsc --noEmit", "seed:demo-scheduling": "ts-node -r tsconfig-paths/register src/scripts/seed-demo-scheduling.ts", "seed:freight-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-freight-demo.ts", + "seed:warehouse-layout": "ts-node -r tsconfig-paths/register src/scripts/seed-warehouse-layout.ts", "seed:warehouse-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-warehouse-demo.ts", "seed:warehouse-export-receive-ready": "ts-node -r tsconfig-paths/register src/scripts/seed-warehouse-export-receive-ready.ts", "seed:export-djibouti-interchange-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-export-djibouti-interchange-demo.ts", diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index 397845cb3..a310e496b 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -35,6 +35,7 @@ import { ConsignmentsModule } from "./modules/consignments/consignments.module"; import { LocomotivesModule } from "./modules/locomotives/locomotives.module"; import { TruckTypesModule } from "./modules/truck-types/truck-types.module"; import { TransitAgentsModule } from "./modules/transit-agents/transit-agents.module"; +import { TransitAssignmentsModule } from "./modules/transit-assignments/transit-assignments.module"; import { WagonTypesModule } from "./modules/wagon-types/wagon-types.module"; import { TrainSetsModule } from "./modules/train-sets/train-sets.module"; import { TrainSchedulesModule } from "./modules/train-schedules/train-schedules.module"; @@ -205,6 +206,7 @@ if (!process.env.APPLICATION_NAME) { LocomotivesModule, TruckTypesModule, TransitAgentsModule, + TransitAssignmentsModule, WagonTypesModule, TrainSetsModule, TrainSchedulesModule, diff --git a/apps/edr-freight-api/src/common/validators/is-phone-number.validator.ts b/apps/edr-freight-api/src/common/validators/is-phone-number.validator.ts index 4f060ca11..c4588af07 100644 --- a/apps/edr-freight-api/src/common/validators/is-phone-number.validator.ts +++ b/apps/edr-freight-api/src/common/validators/is-phone-number.validator.ts @@ -4,25 +4,29 @@ import { ValidationOptions, ValidatorConstraint, ValidatorConstraintInterface, -} from 'class-validator'; -import { isValidPhoneNumber, parsePhoneNumberFromString } from 'libphonenumber-js'; +} from "class-validator"; +import { + isValidPhoneNumber, + parsePhoneNumberFromString, +} from "libphonenumber-js"; /** * Country-aware phone validation. The value is expected as a full international - * number (E.164, e.g. "+251911223344"), so the country is derived from the - * value itself — no separate country field needed. + * number (E.164, e.g. "+25377834567" for Djibouti or "+251911223344" for + * Ethiopia), so the country is derived from the value itself — no separate + * country field needed. */ -@ValidatorConstraint({ name: 'IsValidPhone', async: false }) +@ValidatorConstraint({ name: "IsValidPhone", async: false }) export class IsValidPhoneConstraint implements ValidatorConstraintInterface { validate(value: unknown): boolean { // Empty is allowed here; pair with @IsOptional / @IsNotEmpty as needed. - if (value === undefined || value === null || value === '') return true; - if (typeof value !== 'string') return false; + if (value === undefined || value === null || value === "") return true; + if (typeof value !== "string") return false; return isValidPhoneNumber(value); } defaultMessage(args: ValidationArguments): string { - return `${args.property} must be a valid international phone number (E.164, e.g. +251911223344)`; + return `${args.property} must be a complete international phone number (E.164, e.g. +25377834567 or +251911223344)`; } } @@ -53,7 +57,7 @@ export function IsValidPhone(validationOptions?: ValidationOptions) { export function normalizeE164( value: string | null | undefined, ): string | null | undefined { - if (value === undefined || value === null || value === '') return value; - const parsed = parsePhoneNumberFromString(value, 'ET'); + if (value === undefined || value === null || value === "") return value; + const parsed = parsePhoneNumberFromString(value, "ET"); return parsed?.isValid() ? parsed.number : value.trim(); } diff --git a/apps/edr-freight-api/src/migrations/3780000000000-ScheduleCancellationReason.ts b/apps/edr-freight-api/src/migrations/3780000000000-ScheduleCancellationReason.ts new file mode 100644 index 000000000..dbb68fe61 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3780000000000-ScheduleCancellationReason.ts @@ -0,0 +1,28 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Why a train schedule was cancelled, captured at cancel time. Staff pick a + * reason in the cancel dialog and every view of the cancelled schedule reads it + * back — a cancelled train on the board used to say nothing about why it died. + */ +export class ScheduleCancellationReason3780000000000 implements MigrationInterface { + name = 'ScheduleCancellationReason3780000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "freight"."train_schedules" + ADD COLUMN IF NOT EXISTS "cancellation_reason" varchar(500), + ADD COLUMN IF NOT EXISTS "cancelled_at" timestamptz, + ADD COLUMN IF NOT EXISTS "cancelled_by_user_id" uuid + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "freight"."train_schedules" + DROP COLUMN IF EXISTS "cancellation_reason", + DROP COLUMN IF EXISTS "cancelled_at", + DROP COLUMN IF EXISTS "cancelled_by_user_id" + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/3790000000000-EmptyContainerReturnCompanyName.ts b/apps/edr-freight-api/src/migrations/3790000000000-EmptyContainerReturnCompanyName.ts new file mode 100644 index 000000000..b0088da66 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3790000000000-EmptyContainerReturnCompanyName.ts @@ -0,0 +1,24 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Empties backfilled into the yard belong to a company that may not be a + * registered customer yet, so `customer_id` cannot hold it. `company_name` is + * the typed fallback, and the display label when the customer IS registered. + */ +export class EmptyContainerReturnCompanyName3790000000000 implements MigrationInterface { + name = 'EmptyContainerReturnCompanyName3790000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.empty_container_returns + ADD COLUMN IF NOT EXISTS company_name varchar(200) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.empty_container_returns + DROP COLUMN IF EXISTS company_name + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/3790000000000-TransitAgentAccount.ts b/apps/edr-freight-api/src/migrations/3790000000000-TransitAgentAccount.ts new file mode 100644 index 000000000..eedf8b149 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3790000000000-TransitAgentAccount.ts @@ -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 { + 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 { + 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`, + ); + } +} diff --git a/apps/edr-freight-api/src/migrations/3800000000000-BookingCancellationWagons.ts b/apps/edr-freight-api/src/migrations/3800000000000-BookingCancellationWagons.ts new file mode 100644 index 000000000..df994d832 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3800000000000-BookingCancellationWagons.ts @@ -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 { + 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 { + await queryRunner.query(` + ALTER TABLE freight.bookings + DROP COLUMN IF EXISTS cancellation_wagons + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/3800000000000-WarehouseInventoryBacklogRegistration.ts b/apps/edr-freight-api/src/migrations/3800000000000-WarehouseInventoryBacklogRegistration.ts new file mode 100644 index 000000000..ba4ba7cdb --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3800000000000-WarehouseInventoryBacklogRegistration.ts @@ -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 { + 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 { + 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 + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/3810000000000-TransitAssignments.ts b/apps/edr-freight-api/src/migrations/3810000000000-TransitAssignments.ts new file mode 100644 index 000000000..9d0116681 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3810000000000-TransitAssignments.ts @@ -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 { + 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 { + await queryRunner.query(`DROP TABLE IF EXISTS freight.transit_assignments`); + } +} diff --git a/apps/edr-freight-api/src/migrations/3810000000000-WarehouseZoneDeletePermission.ts b/apps/edr-freight-api/src/migrations/3810000000000-WarehouseZoneDeletePermission.ts new file mode 100644 index 000000000..fa2bcf7ec --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3810000000000-WarehouseZoneDeletePermission.ts @@ -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:` 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 { + 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 { + 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, + ]); + } +} diff --git a/apps/edr-freight-api/src/migrations/3820000000000-WarehouseFreightType.ts b/apps/edr-freight-api/src/migrations/3820000000000-WarehouseFreightType.ts new file mode 100644 index 000000000..563128411 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3820000000000-WarehouseFreightType.ts @@ -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 { + await queryRunner.query( + `ALTER TABLE freight.warehouses ADD COLUMN IF NOT EXISTS freight_type varchar(16)`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE freight.warehouses DROP COLUMN IF EXISTS freight_type`); + } +} diff --git a/apps/edr-freight-api/src/migrations/3830000000000-WarehouseZoneStacksSlots.ts b/apps/edr-freight-api/src/migrations/3830000000000-WarehouseZoneStacksSlots.ts new file mode 100644 index 000000000..38341a1f1 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3830000000000-WarehouseZoneStacksSlots.ts @@ -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 { + 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 { + 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`); + } +} diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.ts b/apps/edr-freight-api/src/modules/billing/billing.service.ts index cf41998ba..1dd4c5656 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -116,6 +116,8 @@ export interface InvoiceListFilters { status?: Freight.InvoiceStatus; statuses?: Freight.InvoiceStatus[]; sources?: string[]; + /** What the invoice bills for (`PREPAID`, `DEMURRAGE`, …) — free-form per source. */ + types?: string[]; eimsStatuses?: string[]; /** Settled payment method, normalised UPPER_SNAKE — see `invoicePaymentMethodExpr`. */ paymentMethods?: string[]; @@ -306,6 +308,9 @@ export class BillingService { sources: filter.sources, }); } + if (filter.types?.length) { + qb.andWhere("invoice.type IN (:...types)", { types: filter.types }); + } if (filter.eimsStatuses?.length) { qb.andWhere("invoice.eimsStatus IN (:...eimsStatuses)", { eimsStatuses: filter.eimsStatuses, diff --git a/apps/edr-freight-api/src/modules/billing/dto/filter-invoice.dto.spec.ts b/apps/edr-freight-api/src/modules/billing/dto/filter-invoice.dto.spec.ts index 55e6b19d2..45c7acb51 100644 --- a/apps/edr-freight-api/src/modules/billing/dto/filter-invoice.dto.spec.ts +++ b/apps/edr-freight-api/src/modules/billing/dto/filter-invoice.dto.spec.ts @@ -22,6 +22,7 @@ describe("FilterInvoiceDto", () => { search: "INV-2026", statuses: "PENDING,OVERDUE", sources: "booking,warehouse", + types: "PREPAID,WAGON_CANCEL_FEE", eimsStatuses: "NOT_SUBMITTED", currency: "etb", issuedFrom: "2026-08-01T00:00:00.000Z", @@ -39,6 +40,7 @@ describe("FilterInvoiceDto", () => { expect(errors).toEqual([]); expect(dto.statuses).toEqual(["PENDING", "OVERDUE"]); expect(dto.sources).toEqual(["booking", "warehouse"]); + expect(dto.types).toEqual(["PREPAID", "WAGON_CANCEL_FEE"]); expect(dto.currency).toBe("ETB"); expect(dto.minAmount).toBe(100); expect(dto.hasBalance).toBe(true); diff --git a/apps/edr-freight-api/src/modules/billing/dto/filter-invoice.dto.ts b/apps/edr-freight-api/src/modules/billing/dto/filter-invoice.dto.ts index a98ad06c1..fa00fb521 100644 --- a/apps/edr-freight-api/src/modules/billing/dto/filter-invoice.dto.ts +++ b/apps/edr-freight-api/src/modules/billing/dto/filter-invoice.dto.ts @@ -89,6 +89,18 @@ export class FilterInvoiceDto { @IsIn(Object.values(Freight.InvoiceSource), { each: true }) sources?: Freight.InvoiceSource[]; + /** + * What the invoice bills for (`?types=PREPAID,WAGON_CANCEL_FEE`). Free-form + * like `paymentMethods`: every billing source mints its own `type` string, so + * an `IsIn` here would silently drop a real value. + */ + @ApiPropertyOptional({ isArray: true, example: ["PREPAID"] }) + @IsOptional() + @Transform(csv) + @IsArray() + @IsString({ each: true }) + types?: string[]; + /** MoR filing state — Finance's "what still needs registering" cut. */ @ApiPropertyOptional({ isArray: true, enum: EimsInvoiceStatus }) @IsOptional() diff --git a/apps/edr-freight-api/src/modules/bookings/booking-content.sql.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-content.sql.spec.ts new file mode 100644 index 000000000..0668b9eba --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/booking-content.sql.spec.ts @@ -0,0 +1,146 @@ +import { + CARGO_TYPE_SUBTREE_SQL, + bookingContainerCountSql, + bookingContainerVgmSql, + bookingContentMatchSql, + bookingContentSql, + bookingHasContainerTypeSql, + bookingRequestedCargoSql, + bookingRequestedContainerCountSql, +} from './booking-content.sql'; + +describe('bookingContentSql', () => { + const sql = bookingContentSql('b'); + + it('prefers the container lines, since container bookings carry no description', () => { + expect(sql.indexOf('freight.booking_container')).toBeLessThan( + sql.indexOf('freight.cargo_types'), + ); + expect(sql).toContain('freight.container_types'); + expect(sql).toContain('bc.deleted_at IS NULL'); + }); + + it('falls back to commodity, then to the free-text description', () => { + expect(sql.indexOf('cgt.cargo_type_name')).toBeLessThan( + sql.indexOf('b.cargo_free_text'), + ); + }); + + // An empty string is not a missing value to COALESCE — without NULLIF a blank + // description would win over the commodity behind it. + it('treats an empty string as absent at every level', () => { + expect(sql.match(/NULLIF/g)).toHaveLength(3); + }); + + it('rewrites every reference when embedded under another alias', () => { + expect(bookingContentSql('bk')).not.toMatch(/\bb\.(cargo|id)/); + }); +}); + +describe('CARGO_TYPE_SUBTREE_SQL', () => { + // The filter offers groups, not just leaves, so picking "Bulk" has to reach + // commodities at any depth beneath it — two levels today, more tomorrow. + it('walks the tree recursively rather than one level of children', () => { + expect(CARGO_TYPE_SUBTREE_SQL).toContain('WITH RECURSIVE'); + expect(CARGO_TYPE_SUBTREE_SQL).toContain('c.parent_group_id = sub.id'); + }); + + it('includes the picked node itself, so a leaf still matches exactly', () => { + expect(CARGO_TYPE_SUBTREE_SQL).toContain('WHERE id = :cargoTypeId'); + }); +}); + +describe('bookingContentMatchSql', () => { + const sql = bookingContentMatchSql('b'); + + it('searches all three places content can live', () => { + expect(sql).toContain('b.cargo_free_text ILIKE :cargoText'); + expect(sql).toContain('cgt.cargo_type_name ILIKE :cargoText'); + expect(sql).toContain('cnt.code ILIKE :cargoText'); + }); + + // Anything but OR would make the text box match nothing for whole freight + // types — a container booking has no commodity, a bulk one has no container. + it('ORs them, and stays one parenthesised term for andWhere', () => { + expect(sql).not.toContain(' AND :cargoText'); + expect(sql.startsWith('(')).toBe(true); + expect(sql.trimEnd().endsWith(')')).toBe(true); + }); +}); + +describe('bookingContainerCountSql', () => { + // booking_container is one row per LINE carrying a quantity, so counting rows + // would report a 54-container booking as 1. + it('sums the line quantities rather than counting lines', () => { + expect(bookingContainerCountSql('b')).toContain('SUM(bc.quantity)'); + expect(bookingContainerCountSql('b')).not.toContain('COUNT('); + }); + + it('counts every type by default and one type when scoped', () => { + expect(bookingContainerCountSql('b')).not.toContain('container_type_id'); + expect(bookingContainerCountSql('b', true)).toContain( + 'bc.container_type_id = :containerTypeId', + ); + }); + + it('is 0, never NULL, so a bound comparison still decides', () => { + expect(bookingContainerCountSql('b')).toContain('COALESCE(SUM(bc.quantity), 0)'); + }); + + it('ignores soft-deleted lines', () => { + expect(bookingContainerCountSql('b')).toContain('bc.deleted_at IS NULL'); + expect(bookingHasContainerTypeSql('b')).toContain('bc.deleted_at IS NULL'); + }); + + it('rewrites the booking reference under another alias', () => { + expect(bookingContainerCountSql('bk')).toContain('bc.booking_id = bk.id'); + expect(bookingHasContainerTypeSql('bk')).toContain('bc.booking_id = bk.id'); + }); +}); + +describe('bookingContainerVgmSql', () => { + // The whole point: b.cargo_total_weight_vgm is 0 for portal container + // bookings, so the weight has to come off the lines. + it('reads the lines, never the booking-level column', () => { + const sql = bookingContainerVgmSql('b'); + expect(sql).toContain('SUM(bc.total_vgm_tons)'); + expect(sql).not.toContain('cargo_total_weight_vgm'); + expect(sql).toContain('bc.deleted_at IS NULL'); + }); +}); + +describe('requested (shipment-request) cargo', () => { + const cargo = bookingRequestedCargoSql('b'); + const count = bookingRequestedContainerCountSql('b'); + + it('reads the request, never the booking or its container lines', () => { + for (const sql of [cargo, count]) { + expect(sql).toContain('freight.booking_requests br'); + expect(sql).toContain('br.created_booking_id = b.id'); + expect(sql).not.toContain('freight.booking_container'); + } + }); + + // requested_lines is a free-form jsonb column; jsonb_array_elements throws on + // a non-array, which would 500 the whole list for one malformed row. + it('survives a requested_lines with no container array', () => { + for (const sql of [cargo, count]) { + expect(sql).toContain("jsonb_typeof(br.requested_lines->'containers') = 'array'"); + expect(sql).toContain("ELSE '[]'::jsonb"); + } + }); + + it('renders the bulk shape too, not only containers', () => { + expect(cargo).toContain("'bulk'->>'cargoWeightTons'"); + expect(cargo).toContain("'bulk'->>'itemCount'"); + }); + + it('counts 0 rather than NULL when no request exists', () => { + expect(count).toContain("COALESCE(SUM((l->>'quantity')::int), 0)"); + }); + + it('ignores soft-deleted requests', () => { + expect(cargo).toContain('br.deleted_at IS NULL'); + expect(count).toContain('br.deleted_at IS NULL'); + }); +}); diff --git a/apps/edr-freight-api/src/modules/bookings/booking-content.sql.ts b/apps/edr-freight-api/src/modules/bookings/booking-content.sql.ts new file mode 100644 index 000000000..744a6bbd8 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/booking-content.sql.ts @@ -0,0 +1,148 @@ +/** + * What the customer said is IN the booking, per freight type — the list + * filter, the summary and the export all read this one expression so the + * column, the pill and the sheet can never disagree. + * + * BULK the commodity picked from the cargo tree (`cargo_types`), falling + * back to the free-text description for a bare group or a legacy row + * that has no commodity. + * CONTAINER the wizard asks for no description at all — VGM and contents are + * captured later in operations — so the closest thing to the + * customer's own words is the container lines they entered: + * "2 × 40FT, 1 × 20FT". + * + * Containers are checked FIRST: a container booking has no `cargo_type_id` + * (the API rejects one), so the order only matters for a mixed legacy row, + * where the physical lines are the better answer. + */ +export function bookingContentSql(alias = 'b'): string { + return `COALESCE( + NULLIF((SELECT string_agg(bc.quantity || ' × ' || COALESCE(cnt.label, cnt.code), ', ' + ORDER BY cnt.size_ft DESC NULLS LAST, cnt.code) + FROM freight.booking_container bc + JOIN freight.container_types cnt ON cnt.id = bc.container_type_id + WHERE bc.booking_id = ${alias}.id AND bc.deleted_at IS NULL), ''), + NULLIF((SELECT cgt.cargo_type_name FROM freight.cargo_types cgt + WHERE cgt.id = ${alias}.cargo_type_id), ''), + NULLIF(${alias}.cargo_free_text, ''))`; +} + +/** + * Cargo types at or under `:cargoTypeId`, so picking a GROUP in the filter + * matches every commodity beneath it — the same group→commodity drill-down the + * booking wizard offers, read back. Recursive because `cargo_types` is an + * arbitrary-depth tree (Bulk → Steel Billet → S1 → …), not two levels. + */ +export const CARGO_TYPE_SUBTREE_SQL = `( + WITH RECURSIVE sub AS ( + SELECT id FROM freight.cargo_types WHERE id = :cargoTypeId + UNION ALL + SELECT c.id FROM freight.cargo_types c JOIN sub ON c.parent_group_id = sub.id + ) + SELECT id FROM sub)`; + +/** + * Contains-match over every part of the content a customer can type or pick: + * their own description, the commodity's name, and the container types on the + * booking. Bind `:cargoText` already wrapped in `%`. + */ +export function bookingContentMatchSql(alias = 'b'): string { + return `(${alias}.cargo_free_text ILIKE :cargoText + OR EXISTS (SELECT 1 FROM freight.cargo_types cgt + WHERE cgt.id = ${alias}.cargo_type_id + AND cgt.cargo_type_name ILIKE :cargoText) + OR EXISTS (SELECT 1 FROM freight.booking_container bc + JOIN freight.container_types cnt ON cnt.id = bc.container_type_id + WHERE bc.booking_id = ${alias}.id AND bc.deleted_at IS NULL + AND (cnt.label ILIKE :cargoText OR cnt.code ILIKE :cargoText)))`; +} + +/** + * Containers on a booking, as a count of physical boxes — `booking_container` + * is one row PER LINE with a `quantity`, not one row per box, so this sums the + * quantity rather than counting rows. + * + * `scopedToType` narrows the sum to `:containerTypeId`, which is what makes one + * number filter answer both "10 containers in total" and "10 forty-footers": + * the count filter reads the container-type filter when one is set, and counts + * every type when it is not. + */ +export function bookingContainerCountSql(alias = 'b', scopedToType = false): string { + return `(SELECT COALESCE(SUM(bc.quantity), 0) + FROM freight.booking_container bc + WHERE bc.booking_id = ${alias}.id + AND bc.deleted_at IS NULL${ + scopedToType ? '\n AND bc.container_type_id = :containerTypeId' : '' + })`; +} + +/** Bookings carrying at least one line of `:containerTypeId`. */ +export function bookingHasContainerTypeSql(alias = 'b'): string { + return `EXISTS (SELECT 1 FROM freight.booking_container bc + WHERE bc.booking_id = ${alias}.id + AND bc.deleted_at IS NULL + AND bc.container_type_id = :containerTypeId)`; +} + +/** + * Container VGM on a booking, in tons — the sum of the per-line totals. + * + * NOT `bookings.cargo_total_weight_vgm`: the portal wizard leaves that at 0 for + * container freight (VGM is captured per container, later, in operations), so + * reading the booking-level column showed every portal container booking as + * weighing nothing. Same reason `bookingTonsSql` falls through to these lines. + */ +export function bookingContainerVgmSql(alias = 'b'): string { + return `(SELECT COALESCE(SUM(bc.total_vgm_tons), 0) + FROM freight.booking_container bc + WHERE bc.booking_id = ${alias}.id + AND bc.deleted_at IS NULL)`; +} + +/** + * Cargo the customer declared on the SHIPMENT REQUEST behind a booking, which + * is not the same fact as cargo on the booking itself. + * + * On a GENERAL + customs contract the customer cannot book directly: they + * submit a request (day + quantities), and `initiateForShipmentRequest` opens a + * BARE instance from it — "the request itself carries the quantities; the + * instance carries none". So between initiation and `completeUnderContract` the + * booking legitimately holds no cargo while the customer's declared quantities + * sit on `booking_requests.requested_lines`. + * + * Kept in its own column rather than folded into the real container count: a + * declared 2 × 20FT is a request, not two boxes on a booking, and merging the + * two would overstate operational totals. + */ +const REQUESTED_CONTAINER_LINES = `jsonb_array_elements( + CASE WHEN jsonb_typeof(br.requested_lines->'containers') = 'array' + THEN br.requested_lines->'containers' + ELSE '[]'::jsonb END)`; + +/** Human-readable declared cargo: "2 × 20FT", "12 t", "40 items". */ +export function bookingRequestedCargoSql(alias = 'b'): string { + return `(SELECT COALESCE( + (SELECT string_agg((l->>'quantity') || ' × ' || upper(l->>'containerSize'), ', ' + ORDER BY l->>'containerSize') + FROM ${REQUESTED_CONTAINER_LINES} AS l), + NULLIF(br.requested_lines->'bulk'->>'cargoWeightTons', '') || ' t', + NULLIF(br.requested_lines->'bulk'->>'itemCount', '') || ' items') + FROM freight.booking_requests br + WHERE br.created_booking_id = ${alias}.id + AND br.deleted_at IS NULL + ORDER BY br.created_at DESC + LIMIT 1)`; +} + +/** + * Boxes declared on the shipment request. Pairs with the real container count: + * `Containers = 0` AND `Requested containers >= 1` is exactly the set awaiting + * completion. + */ +export function bookingRequestedContainerCountSql(alias = 'b'): string { + return `(SELECT COALESCE(SUM((l->>'quantity')::int), 0) + FROM freight.booking_requests br + CROSS JOIN LATERAL ${REQUESTED_CONTAINER_LINES} AS l + WHERE br.created_booking_id = ${alias}.id + AND br.deleted_at IS NULL)`; +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-tons.sql.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-tons.sql.spec.ts new file mode 100644 index 000000000..07067c3ed --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/booking-tons.sql.spec.ts @@ -0,0 +1,30 @@ +import { bookingTonsSql } from './booking-tons.sql'; + +describe('bookingTonsSql', () => { + const sql = bookingTonsSql('b'); + + // The regression this exists for: a plain COALESCE stops at the portal's + // literal 0 for container bookings and reports them as weighing nothing. + it('treats a stored 0 as "no figure" on both booking-level columns', () => { + expect(sql).toContain('NULLIF(b.bulk_total_weight_tons, 0)'); + expect(sql).toContain('NULLIF(b.cargo_total_weight_vgm, 0)'); + }); + + it('falls back to the per-line container VGM, excluding soft-deleted lines', () => { + expect(sql).toContain('SUM(bc.total_vgm_tons)'); + expect(sql).toContain('freight.booking_container bc'); + expect(sql).toContain('bc.booking_id = b.id'); + expect(sql).toContain('bc.deleted_at IS NULL'); + }); + + it('never returns NULL, so callers may SUM it directly', () => { + expect(sql.trimEnd().endsWith('0)')).toBe(true); + }); + + it('rewrites every reference when embedded under another alias', () => { + const aliased = bookingTonsSql('bk'); + expect(aliased).not.toMatch(/\bb\./); + expect(aliased).toContain('bk.cargo_total_weight_vgm'); + expect(aliased).toContain('bc.booking_id = bk.id'); + }); +}); diff --git a/apps/edr-freight-api/src/modules/bookings/booking-tons.sql.ts b/apps/edr-freight-api/src/modules/bookings/booking-tons.sql.ts new file mode 100644 index 000000000..f3a8590a2 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/booking-tons.sql.ts @@ -0,0 +1,26 @@ +/** + * SQL mirror of `bookingCargoTons()` (train-scheduling/train-capacity.util.ts). + * + * Three storage conventions share `bookings.cargo_total_weight_vgm`: + * - BULK PER_TON — the column holds tons. + * - BULK PER_ITEM — the column holds an ITEM COUNT; the tons are in + * `bulk_total_weight_tons`. + * - CONTAINER — the portal wizard captures VGM per line, not per booking, + * and sends 0 (portal NewBookingPage: "containers carry NO weight at the + * wizard"). The tons live in `booking_container.total_vgm_tons`. The + * backoffice wizard does store a booking-level total, so both shapes exist + * in the same table. + * + * Hence NULLIF on both columns: a plain + * `COALESCE(bulk_total_weight_tons, cargo_total_weight_vgm)` stops at the + * portal's 0 — COALESCE falls through on NULL, never on 0 — and every + * portal-created container booking reads as 0 tons in exports and reports. + */ +export function bookingTonsSql(alias = 'b'): string { + return `COALESCE( + NULLIF(${alias}.bulk_total_weight_tons, 0), + NULLIF(${alias}.cargo_total_weight_vgm, 0), + (SELECT SUM(bc.total_vgm_tons) FROM freight.booking_container bc + WHERE bc.booking_id = ${alias}.id AND bc.deleted_at IS NULL), + 0)`; +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.spec.ts index 66ee2cbbb..314d31a7f 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.spec.ts @@ -17,6 +17,7 @@ describe('BookingWagonCancellationService.resolveRequestedCut (bulk)', () => { wagons: number; weightTons: number; quantities: { bulkTons?: number }; + totalWagons: number; }>; }; const booking = { @@ -29,7 +30,39 @@ describe('BookingWagonCancellationService.resolveRequestedCut (bulk)', () => { it('cancels every wagon with the exact total tonnage', async () => { const cut = await svc.resolveRequestedCut(booking, { wagons: 4 }); - expect(cut).toEqual({ wagons: 4, weightTons: 250.5, quantities: { bulkTons: 250.5 } }); + expect(cut).toEqual({ + wagons: 4, + weightTons: 250.5, + quantities: { bulkTons: 250.5 }, + totalWagons: 4, + }); + }); + + /** + * Unassigning a paid booking from a train clears `wagonsRequired` to NULL, so + * cancellation used to reject it outright ("no wagon requirement to cancel + * from"). The pinned `cancellationWagons`, stamped at first allocation, keeps + * the footprint through the unassign. + */ + it('falls back to the pinned cancellation footprint when wagonsRequired is cleared', async () => { + const unassigned = { ...booking, wagonsRequired: null, cancellationWagons: 4 }; + const cut = await svc.resolveRequestedCut(unassigned, { wagons: 4 }); + expect(cut.wagons).toBe(4); + expect(cut.totalWagons).toBe(4); + expect(cut.weightTons).toBe(250.5); + }); + + /** NUMBER_OF_WAGONS bulk never allocated: the customer's pinned count sizes it. */ + it('sizes a never-allocated NUMBER_OF_WAGONS booking from bulkRequestedWagons', async () => { + const fresh = { + ...booking, + wagonsRequired: null, + cancellationWagons: null, + bulkRequestedWagons: 3, + }; + const cut = await svc.resolveRequestedCut(fresh, { wagons: 3 }); + expect(cut.totalWagons).toBe(3); + expect(cut.weightTons).toBe(250.5); }); it('rejects more wagons than the booking has', async () => { diff --git a/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts index ac6fb5aa3..a84f0907f 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts @@ -23,6 +23,8 @@ import { wagonsPerUnitForSize } from '../rule-engine/container-type.util'; import { ContainerType } from '../rule-engine/entities/container-type.entity'; import { Rate } from '../rule-engine/entities/rate.entity'; import { BookingBatchService } from '../train-scheduling/booking-batch.service'; +import { requestedBulkWagons } from '../train-scheduling/train-capacity.util'; +import { wagonsRequiredForBooking } from '../train-scheduling/utils/fleet-plan.util'; import { TrainSchedulingService } from '../train-scheduling/services/train-scheduling.service'; import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity'; import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; @@ -74,6 +76,8 @@ interface RequestedCut { wagons: number; weightTons: number; quantities: CancelledQuantities; + /** The booking's whole wagon footprint the cut came out of — credit divides by it. */ + totalWagons: number; } /** The priced fee for a cut: total, currency and the rate(s) it came from. */ @@ -165,7 +169,7 @@ export class BookingWagonCancellationService { feePerWagon: fee.perWagon, feeAmount: fee.amount, feeCurrency: fee.currency, - creditAmount: this.creditFor(booking, Number(booking.wagonsRequired ?? 0)), + creditAmount: round2(Number(booking.totalAmount ?? 0)), }; } this.assertCutSparesSharedWagon(cut); @@ -177,7 +181,7 @@ export class BookingWagonCancellationService { feePerWagon: fee.perWagon, feeAmount: fee.amount, feeCurrency: fee.currency, - creditAmount: this.creditFor(booking, cut.wagons), + creditAmount: this.creditFor(booking, cut.wagons, cut.totalWagons), }; } @@ -218,7 +222,7 @@ export class BookingWagonCancellationService { : await this.resolveRequestedCut(booking, dto); const fee = await this.priceFee(booking, cut); const feeAmount = fee.amount; - const creditAmount = this.creditFor(booking, cut.wagons); + const creditAmount = this.creditFor(booking, cut.wagons, cut.totalWagons); const row = await this.repo.create({ bookingId, @@ -318,7 +322,7 @@ export class BookingWagonCancellationService { const rows = await this.dataSource.getRepository(WagonBookingAllocation).count({ where: { bookingId: row.bookingId }, }); - if (rows < Math.round(Number(booking.wagonsRequired ?? 0))) { + if (rows < Math.round(await this.wagonFootprint(booking))) { throw new ConflictException( 'The train has no free wagon space left to restore the cancelled wagons — the request cannot be withdrawn. Pay the cancellation fee and rebook the credit on another day instead.', ); @@ -363,7 +367,7 @@ export class BookingWagonCancellationService { const row = await this.openConsolidationBreak( booking, 'ceil', - this.creditFor(booking, Number(booking.wagonsRequired ?? 0)), + round2(Number(booking.totalAmount ?? 0)), reason ?? 'Consolidated pair cancelled', userId, ); @@ -371,7 +375,7 @@ export class BookingWagonCancellationService { await this.openConsolidationBreak( partner, 'floor', - this.creditFor(partner, Number(partner.wagonsRequired ?? 0)), + round2(Number(partner.totalAmount ?? 0)), `Cancelled with its consolidation partner ${booking.reference}`, userId, ); @@ -540,7 +544,8 @@ export class BookingWagonCancellationService { } as RequestWagonCancellationDto); } return this.resolveRequestedCut(booking, { - wagons: Number(booking.wagonsRequired ?? 0), + // Footprint, not the live wagonsRequired: unassign clears that to NULL. + wagons: await this.wagonFootprint(booking), } as RequestWagonCancellationDto); } @@ -568,7 +573,7 @@ export class BookingWagonCancellationService { const row = await this.openConsolidationBreak( booking, 'ceil', - this.creditFor(booking, Number(booking.wagonsRequired ?? 0)), + round2(Number(booking.totalAmount ?? 0)), 'Consolidation partner lapsed unpaid — paired booking cancelled, cancellation fee applies', ); await this.dataSource.getRepository(Booking).update(booking.id, { @@ -729,9 +734,10 @@ export class BookingWagonCancellationService { // Whole-booking cut: nothing is left to ship, so the booking ends // CANCELLED (frees the contract slot/cap for the rebook) and drops off its // train. The credit row still points at it for T3. - const wagonsLeft = round2( - Number(booking.wagonsRequired ?? 0) - Number(row.wagonsCancelled), - ); + // Off the pinned footprint, not the live wagonsRequired — unassign + // clears that to NULL, which read as a full cut on any partial cancel. + const footprint = await this.wagonFootprint(booking); + const wagonsLeft = round2(footprint - Number(row.wagonsCancelled)); const isFull = wagonsLeft <= 0; // NUMBER_OF_WAGONS bookings pin their count in bulkRequestedWagons, which // bulkTonWagonsRequired honours verbatim. Left stale it re-inflates the @@ -745,6 +751,9 @@ export class BookingWagonCancellationService { : null; await manager.getRepository(Booking).update(booking.id, { wagonsRequired: Math.max(0, wagonsLeft), + // Keep the cancellation footprint in step, so a second partial cancel + // prices against what is actually left, not the original booking. + cancellationWagons: Math.max(0, wagonsLeft), ...(requestedWagonsLeft !== null ? { bulkRequestedWagons: requestedWagonsLeft } : {}), @@ -836,15 +845,15 @@ export class BookingWagonCancellationService { ) .where('alloc.booking_id = :bookingId', { bookingId }) .getMany(); - const loaded = allocations.filter( - (a) => a.status === 'LOADED' || a.status === 'DEPARTED', - ); const remaining = allocations.filter( (a) => a.status !== 'LOADED' && a.status !== 'DEPARTED', ); - if (!loaded.length) { + // A booking whose cargo never showed up at all (0 loaded) is cancelled the + // same way — the gate that holds the train does not care whether loading + // started, only that nothing is left unresolved. + if (!allocations.length) { throw new BadRequestException( - 'Loading has not started for this booking — use the normal wagon cancellation flow.', + 'This booking has no wagons on this schedule — use the normal wagon cancellation flow.', ); } if (!remaining.length) { @@ -853,14 +862,37 @@ export class BookingWagonCancellationService { ); } + // Staff may cut a SUBSET of the never-loaded wagons (picked in the loading + // modal) instead of the whole remainder. Anything already LOADED is + // rejected rather than silently dropped: the operator believes they are + // cancelling that wagon, and it is on the train. + let target = remaining; + if (dto.wagonAllocationIds?.length) { + const wanted = new Set(dto.wagonAllocationIds); + const known = new Set(allocations.map((a) => a.id)); + const unknown = dto.wagonAllocationIds.filter((id) => !known.has(id)); + if (unknown.length) { + throw new BadRequestException( + 'Some selected wagons are not allocated to this booking on this schedule.', + ); + } + const loaded = allocations.filter((a) => wanted.has(a.id) && !remaining.includes(a)); + if (loaded.length) { + throw new BadRequestException( + `${loaded.length} selected wagon(s) are already loaded and cannot be cancelled.`, + ); + } + target = remaining.filter((a) => wanted.has(a.id)); + } + const cut = await this.resolveRequestedCut(booking, { - wagonAllocationIds: remaining.map((r) => r.id), + wagonAllocationIds: target.map((r) => r.id), } as RequestWagonCancellationDto); if (booking.consolidationPartnerId) this.assertCutSparesSharedWagon(cut); const edrFault = !!dto.edrFault; const fee = edrFault ? null : await this.priceFee(booking, cut); - const creditAmount = this.creditFor(booking, cut.wagons); + const creditAmount = this.creditFor(booking, cut.wagons, cut.totalWagons); const row = await this.repo.create({ bookingId, @@ -1253,7 +1285,7 @@ export class BookingWagonCancellationService { booking: Booking, dto: RequestWagonCancellationDto, ): Promise { - const totalWagons = Number(booking.wagonsRequired ?? 0); + const totalWagons = await this.wagonFootprint(booking); if (totalWagons <= 0) { throw new BadRequestException('This booking has no wagon requirement to cancel from.'); } @@ -1335,6 +1367,7 @@ export class BookingWagonCancellationService { weightTons: weightShare, // Bookings without unit records fall back to the T2 LIFO trim. quantities: { bySize, ...(units.length === requested ? { units } : {}) }, + totalWagons, }; } @@ -1360,7 +1393,7 @@ export class BookingWagonCancellationService { if (tons <= 0) { throw new BadRequestException('The requested cut is too small to release cargo.'); } - return { wagons, weightTons: tons, quantities: { bulkTons: tons } }; + return { wagons, weightTons: tons, quantities: { bulkTons: tons }, totalWagons }; } /** @@ -1416,6 +1449,7 @@ export class BookingWagonCancellationService { wagons, weightTons: tons, quantities: { bulkTons: tons, allocationIds }, + totalWagons, }; } @@ -1460,12 +1494,54 @@ export class BookingWagonCancellationService { wagons, weightTons: round3(units.reduce((s, u) => s + Number(u.vgmTons || 0), 0)), quantities: { bySize, units, allocationIds }, + totalWagons, }; } + /** + * The booking's wagon footprint for cancellation pricing. + * + * `wagonsRequired` is a LIVE scheduling field: unassign clears it to NULL, so + * a paid booking pulled off a train read 0 wagons and could not be cancelled + * at all. `cancellationWagons` is stamped once at first allocation and never + * cleared — read it first. A booking never allocated has neither, so size it + * from the cargo the same way the scheduler would: TEU geometry for + * containers, the customer's pinned count for NUMBER_OF_WAGONS bulk, tonnage + * ÷ wagon capacity for PER_TON bulk. + */ + private async wagonFootprint(booking: Booking): Promise { + const pinned = Number(booking.cancellationWagons ?? 0); + if (pinned > 0) return round2(pinned); + const stored = Number(booking.wagonsRequired ?? 0); + if (stored > 0) return round2(stored); + + const requested = requestedBulkWagons(booking); + if (requested > 0) return requested; + + // Cargo relations drive the sizing — reload when the caller passed a bare + // booking (findById does not always hydrate them). + const full = + booking.bookingContainers || booking.cargoType + ? booking + : ((await this.dataSource.getRepository(Booking).findOne({ + where: { id: booking.id }, + relations: { + bookingContainers: { containerType: true }, + cargoType: { wagonTypes: true }, + }, + })) ?? booking); + const capacities = (full.cargoType?.wagonTypes ?? []) + .map((wt) => Number(wt.capacityTons)) + .filter((c) => c > 0); + const bulkCapacity = + full.freightType === 'BULK' && capacities.length + ? Math.max(...capacities) + : undefined; + return round2(wagonsRequiredForBooking(full, bulkCapacity)); + } + /** Credit = the cancelled share of the ORIGINAL price (old-price rebooking). */ - private creditFor(booking: Booking, wagons: number): number { - const totalWagons = Number(booking.wagonsRequired ?? 0); + private creditFor(booking: Booking, wagons: number, totalWagons: number): number { if (totalWagons <= 0) return 0; return round2(Number(booking.totalAmount) * (wagons / totalWagons)); } @@ -1875,9 +1951,12 @@ export class BookingWagonCancellationService { // the same cargo); number/seal/VGM come from the override when given. units: sized.map((u, i) => ({ containerNumber: replacement?.[i]?.containerNumber ?? u.containerNumber, + // A credit snapshot taken before seals were mandatory can carry + // none; the booking service normalizes the blank back to null + // rather than blocking the rebook of already-paid cargo. sealNumber: replacement - ? (replacement[i]?.sealNumber ?? undefined) - : (u.sealNumber ?? undefined), + ? (replacement[i]?.sealNumber ?? '') + : (u.sealNumber ?? ''), vgmTons: replacement?.[i]?.vgmTons ?? u.vgmTons, isHazardous: u.isHazardous, isReefer: u.isReefer, diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts index 885486031..863b61fee 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -21,6 +21,13 @@ import { ShippingLineCompany } from '../shipping-lines/entities/shipping-line-co import { ContractRateSnapshot } from '../contracts/entities/contract-rate-snapshot.entity'; import { ContractRoute } from '../contracts/entities/contract-route.entity'; import { applyDirectionScope } from '../user-trade-access/trade-scope.util'; +import { + CARGO_TYPE_SUBTREE_SQL, + bookingContainerCountSql, + bookingContentMatchSql, + bookingHasContainerTypeSql, + bookingRequestedContainerCountSql, +} from './booking-content.sql'; import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity'; import { BookingDocumentReview, @@ -65,7 +72,17 @@ export interface BookingListFilterOptions { contractId?: string; contractType?: string; serviceTypeId?: string; + /** Cargo type OR cargo group — a group matches every commodity beneath it. */ cargoTypeId?: string; + /** Contains-search over content: description, commodity name, container types. */ + cargoText?: string; + /** Bookings carrying this container type; also scopes the container count. */ + containerTypeId?: string; + containersMin?: number; + containersMax?: number; + /** Bounds on containers declared on the shipment request behind the booking. */ + requestedContainersMin?: number; + requestedContainersMax?: number; freightType?: string; bookingType?: string; tradeDirection?: string; @@ -1176,11 +1193,55 @@ export class BookingsRepository extends BaseRepository { serviceTypeId: options.serviceTypeId, }); } + // A group is selectable in the filter, not just a leaf commodity, so this + // matches the whole subtree — picking "Bulk" must return every commodity + // under it, the same drill-down the booking wizard offers, read back. if (options.cargoTypeId) { - qb.andWhere('booking.cargo_type_id = :cargoTypeId', { + qb.andWhere(`booking.cargo_type_id IN ${CARGO_TYPE_SUBTREE_SQL}`, { cargoTypeId: options.cargoTypeId, }); } + if (options.cargoText) { + qb.andWhere(bookingContentMatchSql('booking'), { + cargoText: `%${options.cargoText}%`, + }); + } + if (options.containerTypeId) { + qb.andWhere(bookingHasContainerTypeSql('booking'), { + containerTypeId: options.containerTypeId, + }); + } + // One count filter, two questions: with a container type picked it counts + // that type, without one it counts every box on the booking. + if (options.containersMin != null || options.containersMax != null) { + const count = bookingContainerCountSql( + 'booking', + Boolean(options.containerTypeId), + ); + if (options.containersMin != null) { + qb.andWhere(`${count} >= :containersMin`, { + containersMin: options.containersMin, + }); + } + if (options.containersMax != null) { + qb.andWhere(`${count} <= :containersMax`, { + containersMax: options.containersMax, + }); + } + } + // Declared on the shipment request, not on the booking. Pairs with the + // count above: containers 0..0 AND requested >= 1 is the set awaiting + // completion after clearance. + if (options.requestedContainersMin != null) { + qb.andWhere(`${bookingRequestedContainerCountSql('booking')} >= :requestedContainersMin`, { + requestedContainersMin: options.requestedContainersMin, + }); + } + if (options.requestedContainersMax != null) { + qb.andWhere(`${bookingRequestedContainerCountSql('booking')} <= :requestedContainersMax`, { + requestedContainersMax: options.requestedContainersMax, + }); + } if (omit !== 'freightType' && options.freightType) { qb.andWhere('booking.freight_type = :freightType', { freightType: options.freightType, @@ -1725,6 +1786,7 @@ export class BookingsRepository extends BaseRepository { Booking, | 'schedulingStatus' | 'wagonsRequired' + | 'cancellationWagons' | 'scheduledAt' | 'holdStartedAt' | 'holdExpiresAt' diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index b6047633a..71107650f 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -1845,6 +1845,12 @@ export class BookingsService { contractType: filter.contractType, serviceTypeId: filter.serviceTypeId, cargoTypeId: filter.cargoTypeId, + cargoText: filter.cargoText, + containerTypeId: filter.containerTypeId, + containersMin: filter.containersMin, + containersMax: filter.containersMax, + requestedContainersMin: filter.requestedContainersMin, + requestedContainersMax: filter.requestedContainersMax, freightType: filter.freightType, bookingType: filter.bookingType, tradeDirection: filter.tradeDirection, @@ -2072,6 +2078,12 @@ export class BookingsService { contractType: filter.contractType, serviceTypeId: filter.serviceTypeId, cargoTypeId: filter.cargoTypeId, + cargoText: filter.cargoText, + containerTypeId: filter.containerTypeId, + containersMin: filter.containersMin, + containersMax: filter.containersMax, + requestedContainersMin: filter.requestedContainersMin, + requestedContainersMax: filter.requestedContainersMax, freightType: filter.freightType, bookingType: filter.bookingType, tradeDirection: filter.tradeDirection, diff --git a/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts index a404c005e..a6138e77b 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts @@ -62,11 +62,60 @@ export class FilterBookingDto { @IsUUID() serviceTypeId?: string; - @ApiPropertyOptional({ format: 'uuid' }) + @ApiPropertyOptional({ + format: 'uuid', + description: + 'Cargo type OR cargo group — a group matches every commodity beneath it', + }) @IsOptional() @IsUUID() cargoTypeId?: string; + @ApiPropertyOptional({ + description: + 'Contains-search over booking content: cargo description, commodity name, container types', + }) + @IsOptional() + @Transform(({ value }) => + typeof value === 'string' && value.trim() ? value.trim() : undefined, + ) + cargoText?: string; + + @ApiPropertyOptional({ + format: 'uuid', + description: + 'Bookings carrying this container type. Also scopes containersMin/Max to it.', + }) + @IsOptional() + @IsUUID() + containerTypeId?: string; + + @ApiPropertyOptional({ + description: + 'Minimum container count — of containerTypeId when set, else of all types', + }) + @IsOptional() + @Transform(({ value }) => (value === '' || value == null ? undefined : Number(value))) + containersMin?: number; + + @ApiPropertyOptional({ description: 'Maximum container count — see containersMin' }) + @IsOptional() + @Transform(({ value }) => (value === '' || value == null ? undefined : Number(value))) + containersMax?: number; + + @ApiPropertyOptional({ + description: + 'Minimum containers declared on the shipment request behind the booking', + }) + @IsOptional() + @Transform(({ value }) => (value === '' || value == null ? undefined : Number(value))) + requestedContainersMin?: number; + + @ApiPropertyOptional({ description: 'Maximum requested containers — see requestedContainersMin' }) + @IsOptional() + @Transform(({ value }) => (value === '' || value == null ? undefined : Number(value))) + requestedContainersMax?: number; + @ApiPropertyOptional({ enum: FREIGHT_TYPES }) @IsOptional() @IsIn([...FREIGHT_TYPES]) diff --git a/apps/edr-freight-api/src/modules/bookings/dto/wagon-cancellation.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/wagon-cancellation.dto.ts index f86c3e4bf..7b4d3ca19 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/wagon-cancellation.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/wagon-cancellation.dto.ts @@ -183,6 +183,19 @@ export class CancelRemainingWagonsDto { @IsUUID('4') scheduleId!: string; + @ApiPropertyOptional({ + description: + 'Cancel only THESE never-loaded wagons (wagon_booking_allocation ids from ' + + 'GET /bookings/:id/wagons). Omit to cancel the whole unloaded remainder. ' + + 'Already-loaded wagons are rejected — they are riding.', + type: [String], + }) + @IsOptional() + @IsArray() + @ArrayNotEmpty() + @IsUUID('4', { each: true }) + wagonAllocationIds?: string[]; + @ApiProperty({ description: 'Why the remaining wagons are not riding' }) @IsString() @IsNotEmpty() diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts index b86e1c3f3..9cf728a0d 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts @@ -543,6 +543,13 @@ export class Booking extends BaseEntity { @Column({ name: 'wagons_required', type: 'numeric', precision: 6, scale: 2, nullable: true }) wagonsRequired?: number | null; + // Wagon footprint pinned for cancellation pricing. `wagonsRequired` above is + // a LIVE scheduling field that unassign clears; this one is stamped once at + // first allocation and never cleared, so a paid booking pulled off a train + // can still price its cancellation fee and credit. + @Column({ name: 'cancellation_wagons', type: 'numeric', precision: 6, scale: 2, nullable: true }) + cancellationWagons?: number | null; + @Column({ name: 'scheduling_status', type: 'varchar', length: 30, default: 'NOT_SCHEDULED' }) schedulingStatus!: string; diff --git a/apps/edr-freight-api/src/modules/companies/companies.controller.ts b/apps/edr-freight-api/src/modules/companies/companies.controller.ts index 000b0733f..733b6e441 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.controller.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.controller.ts @@ -57,8 +57,10 @@ import { CompanyInfoResponseDto } from "./dto/company-info-response.dto"; import { AccountInfoResponse, ShippingLineInfoResponseDto, + TransitAgentInfoResponseDto, } from "./dto/account-info-response.dto"; import { ShippingLineCompaniesService } from "../shipping-lines/shipping-line-companies.service"; +import { TransitAgentsService } from "../transit-agents/transit-agents.service"; import { UpdateProfileDto } from "./dto/update-profile.dto"; import { ProfileResponseDto } from "./dto/profile-response.dto"; import { DashboardSummaryResponseDto } from "./dto/dashboard-summary-response.dto"; @@ -104,6 +106,7 @@ export class CompaniesController { private readonly companiesService: CompaniesService, private readonly filesService: FilesService, private readonly shippingLineCompaniesService: ShippingLineCompaniesService, + private readonly transitAgentsService: TransitAgentsService, ) { } /** @@ -133,10 +136,10 @@ export class CompaniesController { async getInfo( @CurrentUser() user: CurrentIamUser, ): Promise { - // A shipping line has no company and no external profile, so the customer - // lookup below would 404. Checked first, and reported with an explicit - // `accountKind` so the portal can skip onboarding for shipping lines - // without inferring it from a missing company. + // Neither a shipping line nor a transit agent has a company or an external + // profile, so the customer lookup below would 404 for both. Checked first, + // and reported with an explicit `accountKind` so the portal can skip + // onboarding for them without inferring it from a missing company. const shippingLine = await this.shippingLineCompaniesService.findByUserId( user.id, ); @@ -144,6 +147,11 @@ export class CompaniesController { return new ShippingLineInfoResponseDto(shippingLine); } + const transitAgent = await this.transitAgentsService.findByUserId(user.id); + if (transitAgent) { + return new TransitAgentInfoResponseDto(transitAgent); + } + const { profile, company } = await this.companiesService.getCompanyInfoByUserId(user.id); const review = await this.companiesService.getOpenChangeRequestForCompany( diff --git a/apps/edr-freight-api/src/modules/companies/companies.module.ts b/apps/edr-freight-api/src/modules/companies/companies.module.ts index 666634cb8..3a3d6c1c4 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.module.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.module.ts @@ -18,6 +18,7 @@ import { CompanyChangeRequest } from "./entities/company-change-request.entity"; import { CompanyRevision } from "./entities/company-revision.entity"; import { Booking } from "../bookings/entities/booking.entity"; import { ShippingLineCompaniesModule } from "../shipping-lines/shipping-line-companies.module"; +import { TransitAgentsModule } from "../transit-agents/transit-agents.module"; import { CompanyProfileRepository } from "./company-profile.repository"; import { CompanyChangeRequestRepository } from "./company-change-request.repository"; import { CompanyRevisionRepository } from "./company-revision.repository"; @@ -49,6 +50,10 @@ import { VerifaydaModule } from "../verifayda/verifayda.module"; // shipping-line session, which has no company row to look up. forwardRef // because that module imports BillingModule, which imports this one. forwardRef(() => ShippingLineCompaniesModule), + // `GET /companies/getInfo` resolves a transit-agent session before falling + // through to the customer lookup. TransitAgentsModule is a leaf here — it + // does not import CompaniesModule — so no forwardRef is needed. + TransitAgentsModule, ], controllers: [CompaniesController], providers: [ diff --git a/apps/edr-freight-api/src/modules/companies/dto/account-info-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/account-info-response.dto.ts index ab680f8f3..9a43e65d4 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/account-info-response.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/account-info-response.dto.ts @@ -1,6 +1,7 @@ import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; import { ShippingLineCompany } from "../../shipping-lines/entities/shipping-line-company.entity"; +import { TransitAgent } from "../../transit-agents/entities/transit-agent.entity"; import { CompanyInfoResponseDto } from "./company-info-response.dto"; /** @@ -9,10 +10,10 @@ import { CompanyInfoResponseDto } from "./company-info-response.dto"; * The portal keys its onboarding gate off this rather than off "is `company` * missing?": a failed or slow company fetch also leaves `company` empty, and * treating that as "no onboarding needed" would let customers skip onboarding - * whenever the request failed. A shipping line is identified positively, and - * anything else defaults to `customer`. + * whenever the request failed. A shipping line and a transit agent are each + * identified positively, and anything else defaults to `customer`. */ -export type AccountKind = "customer" | "shipping_line"; +export type AccountKind = "customer" | "shipping_line" | "transit_agent"; /** The signed-in shipping line. No company, no profile, no onboarding. */ export class ShippingLineInfoResponseDto { @@ -61,6 +62,62 @@ export class ShippingLineInfoResponseDto { } } +/** + * The signed-in transit agent. Like a shipping line: no company, no profile, no + * onboarding — but a separate account kind because the two share nothing beyond + * that, and the portal shows each a different (much smaller) set of tabs. + */ +export class TransitAgentInfoResponseDto { + @ApiProperty({ enum: ["transit_agent"] }) + accountKind: "transit_agent" = "transit_agent"; + + @ApiProperty() + id: string; + + @ApiProperty() + name: string; + + @ApiPropertyOptional() + email?: string | null; + + @ApiPropertyOptional() + phoneNumber?: string | null; + + @ApiProperty() + isActive: boolean; + + @ApiProperty({ + description: "Start of the agent's validity window (yyyy-MM-dd)", + }) + validFrom: string; + + @ApiProperty({ + description: "End of the agent's validity window (yyyy-MM-dd)", + }) + validTo: string; + + /** Always null — see {@link ShippingLineInfoResponseDto.company}. */ + @ApiProperty({ nullable: true }) + company: null = null; + + @ApiProperty({ nullable: true }) + profile: null = null; + + @ApiProperty({ nullable: true }) + review: null = null; + + constructor(entity: TransitAgent) { + this.id = entity.id; + this.name = entity.name; + this.email = entity.email ?? null; + this.phoneNumber = entity.phoneNumber ?? null; + this.isActive = entity.isActive; + this.validFrom = entity.validFrom; + this.validTo = entity.validTo; + } +} + export type AccountInfoResponse = | (CompanyInfoResponseDto & { accountKind: "customer" }) - | ShippingLineInfoResponseDto; + | ShippingLineInfoResponseDto + | TransitAgentInfoResponseDto; diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts index f5e00f503..88a2175b9 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts @@ -2250,7 +2250,9 @@ export class ContractBookingService { unitRepo.create({ bookingContainerId: containerRow.id, containerNumber: unit.containerNumber, - sealNumber: unit.sealNumber ?? null, + // Legacy units recovered by the remainder placement can still + // arrive sealless — keep those null rather than empty-string. + sealNumber: unit.sealNumber?.trim() || null, vgmTons: unit.vgmTons, isHazardous: unit.isHazardous ?? false, isReefer: unit.isReefer ?? false, diff --git a/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts index 0592dafc4..b1dabfa16 100644 --- a/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts +++ b/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts @@ -7,6 +7,7 @@ import { IsEmail, IsIn, IsInt, + IsNotEmpty, IsNumber, IsOptional, IsString, @@ -32,10 +33,12 @@ export class CreateContainerUnitDto { }) containerNumber!: string; - @ApiPropertyOptional() - @IsOptional() + @ApiProperty({ description: 'Seal number — required on every container, import and export alike.' }) @IsString() - sealNumber?: string; + @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value)) + @IsNotEmpty({ message: 'sealNumber is required' }) + @MaxLength(64) + sealNumber!: string; @ApiProperty({ description: 'VGM in tons', minimum: 0 }) @IsNumber() diff --git a/apps/edr-freight-api/src/modules/exports/datasets/bookings.dataset.ts b/apps/edr-freight-api/src/modules/exports/datasets/bookings.dataset.ts index c9f2a7d21..69ff22927 100644 --- a/apps/edr-freight-api/src/modules/exports/datasets/bookings.dataset.ts +++ b/apps/edr-freight-api/src/modules/exports/datasets/bookings.dataset.ts @@ -1,4 +1,17 @@ +import { DataSource } from 'typeorm'; + import { FREIGHT_PERMS } from '../../../seed/freight-permissions.registry'; +import { + CARGO_TYPE_SUBTREE_SQL, + bookingContainerCountSql, + bookingContentMatchSql, + bookingContainerVgmSql, + bookingContentSql, + bookingHasContainerTypeSql, + bookingRequestedCargoSql, + bookingRequestedContainerCountSql, +} from '../../bookings/booking-content.sql'; +import { bookingTonsSql } from '../../bookings/booking-tons.sql'; import { Booking } from '../../bookings/entities/booking.entity'; import { Company } from '../../companies/entities/company.entity'; import { CompanyProfile } from '../../companies/entities/company-profile.entity'; @@ -10,23 +23,104 @@ import { Yard } from '../../rule-engine/entities/yard.entity'; import { ShippingLineCompany } from '../../shipping-lines/entities/shipping-line-company.entity'; import { Train } from '../../trains/entities/train.entity'; import { applyDirectionScope } from '../../user-trade-access/trade-scope.util'; -import { ExportDataset } from '../export.types'; +import { ExportFilterOption } from '../export-filter.util'; +import { ExportDataset, ExportField } from '../export.types'; /** * Domain semantics that the retired `bookings-list` report used to share. - * Kept identical on purpose — for PER_ITEM bulk bookings `cargo_total_weight_vgm` - * holds an item COUNT, not tonnage, and `adjusted_total_amount` silently - * overrides `total_amount`. Getting either wrong misreports money or weight. + * Tonnage is `bookingTonsSql` — the one resolver for the three ways a booking + * stores its weight. `adjusted_total_amount` silently overrides `total_amount`. + * Getting either wrong misreports money or weight. */ -const TONS = 'COALESCE(b.bulk_total_weight_tons, b.cargo_total_weight_vgm)'; +const TONS = bookingTonsSql('b'); const REVENUE = 'COALESCE(b.adjusted_total_amount, b.total_amount)'; +/** What the customer described as the booking's contents — see the helper. */ +const CONTENT = bookingContentSql('b'); +const CONTAINER_COUNT = bookingContainerCountSql('b'); +const REQUESTED_COUNT = bookingRequestedContainerCountSql('b'); + const STATUS_OPTIONS = [ 'DRAFT', 'SUBMITTED', 'UNDER_REVIEW', 'APPROVED', 'REJECTED', 'CANCELLED', 'EXPIRED', 'SCHEDULED', 'LOADED', 'IN_TRANSIT', 'ARRIVED', 'DELIVERED', 'COMPLETED', ].map((v) => ({ value: v, label: v.replace(/_/g, ' ') })); +/** + * Cargo tree flattened for a single select: groups and every commodity beneath + * them, each labelled by its full path ("Bulk → Wheat") the way the booking + * wizard shows a deep leaf. Picking a group row filters its whole subtree. + * + * Recursive because `cargo_types` is arbitrary-depth, not two levels. + */ +async function cargoTypeOptions(ds: DataSource): Promise { + return ds.query(` + WITH RECURSIVE t AS ( + SELECT id, display_order, 0 AS depth, + ARRAY[display_order]::int[] AS ord, + ARRAY[cargo_type_name]::text[] AS path + FROM freight.cargo_types + WHERE parent_group_id IS NULL AND deleted_at IS NULL AND is_active + UNION ALL + SELECT c.id, c.display_order, t.depth + 1, + t.ord || c.display_order, + t.path || c.cargo_type_name + FROM freight.cargo_types c + JOIN t ON c.parent_group_id = t.id + WHERE c.deleted_at IS NULL AND c.is_active + ) + SELECT id AS value, array_to_string(path, ' → ') AS label + FROM t ORDER BY ord, path + `) as Promise; +} + +/** Container types are 2 rows that change about never. */ +async function containerTypeOptions(ds: DataSource): Promise { + return ds.query(` + SELECT id AS value, COALESCE(label, code) AS label + FROM freight.container_types + WHERE deleted_at IS NULL AND is_active + ORDER BY display_order, code + `) as Promise; +} + +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +/** + * One column per container type ("20FT", "40FT", …), each the box count of + * that type on the booking. Resolved from `container_types` rather than + * hardcoded, so adding a 45ft adds its column without a deploy of this file. + * + * The type id is INTERPOLATED, not bound — `ExportField.select` is a raw SQL + * string with no parameter bag — so ids that are not uuids are dropped rather + * than spliced. They come from our own table; the guard is for the day someone + * changes that column's type. + */ +async function containerTypeFields(ds: DataSource): Promise { + const rows: Array<{ id: string; code: string; label: string | null }> = await ds.query(` + SELECT id, code, label + FROM freight.container_types + WHERE deleted_at IS NULL AND is_active + ORDER BY display_order, code + `); + return rows + .filter((r) => UUID_RE.test(r.id)) + .map((r) => { + const name = r.label || r.code; + return { + key: `containers${r.code.replace(/[^A-Za-z0-9]/g, '')}`, + label: `${name} containers`, + type: 'number' as const, + group: 'cargo', + select: `(SELECT COALESCE(SUM(bc.quantity), 0) + FROM freight.booking_container bc + WHERE bc.booking_id = b.id + AND bc.deleted_at IS NULL + AND bc.container_type_id = '${r.id}')::int`, + }; + }); +} + export const bookingsDataset: ExportDataset = { key: 'bookings', title: 'Bookings', @@ -112,10 +206,21 @@ export const bookingsDataset: ExportDataset = { { key: 'serviceType', label: 'Service type', type: 'string', group: 'route', requires: ['st'], select: 'st.service_name' }, // ---- Cargo ----------------------------------------------------------- - { key: 'cargo', label: 'Cargo', type: 'string', group: 'cargo', default: true, requires: ['cty'], select: 'COALESCE(cty.cargo_type_name, b.cargo_free_text)' }, + // What the customer said is in the booking. `cargo` below is the narrower + // commodity-only view, kept for saved presets that already tick it. + { key: 'content', label: 'Content', type: 'string', group: 'cargo', default: true, select: CONTENT, sortExpr: CONTENT }, + { key: 'cargo', label: 'Cargo (commodity)', type: 'string', group: 'cargo', requires: ['cty'], select: 'COALESCE(cty.cargo_type_name, b.cargo_free_text)' }, + { key: 'cargoDescription', label: 'Cargo description', type: 'string', group: 'cargo', select: 'b.cargo_free_text' }, + // Boxes, not lines: booking_container is one row per LINE with a quantity. + { key: 'containerCount', label: 'Containers', type: 'number', group: 'cargo', default: true, select: `${CONTAINER_COUNT}::int`, sortExpr: CONTAINER_COUNT }, + // Declared on the shipment request, not yet on the booking — see the helper. + { key: 'requestedCargo', label: 'Requested cargo', type: 'string', group: 'cargo', select: bookingRequestedCargoSql('b') }, + { key: 'requestedContainers', label: 'Requested containers', type: 'number', group: 'cargo', select: `${REQUESTED_COUNT}::int`, sortExpr: REQUESTED_COUNT }, { key: 'freightType', label: 'Freight type', type: 'string', group: 'cargo', default: true, select: 'b.freight_type' }, { key: 'tons', label: 'Tonnage', type: 'tons', group: 'cargo', default: true, select: `ROUND(${TONS})::float8`, sortExpr: TONS }, - { key: 'containerWeightVgm', label: 'Container VGM', type: 'number', group: 'cargo', select: 'b.cargo_total_weight_vgm' }, + // The per-line sum, NOT b.cargo_total_weight_vgm — the portal leaves that + // column at 0 for container freight, so it read 0 for every such booking. + { key: 'containerWeightVgm', label: 'Container VGM (t)', type: 'tons', group: 'cargo', select: `${bookingContainerVgmSql('b')}::float8`, sortExpr: bookingContainerVgmSql('b') }, { key: 'bulkWeightTons', label: 'Bulk weight (t)', type: 'tons', group: 'cargo', select: 'b.bulk_total_weight_tons' }, { key: 'isHazardous', label: 'Hazardous', type: 'boolean', group: 'cargo', select: 'b.is_hazardous' }, { key: 'isReefer', label: 'Reefer', type: 'boolean', group: 'cargo', select: 'b.is_reefer' }, @@ -172,6 +277,8 @@ export const bookingsDataset: ExportDataset = { { key: 'doubleHandling', label: 'Double handling', type: 'boolean', group: 'clearance', select: 'b.double_handling' }, ], + dynamicFields: containerTypeFields, + filters: [ { key: 'created', label: 'Created', type: 'daterange' }, { key: 'statuses', label: 'Status', type: 'multiselect', options: STATUS_OPTIONS }, @@ -190,6 +297,13 @@ export const bookingsDataset: ExportDataset = { { value: 'PAID', label: 'Paid' }, { value: 'FAILED', label: 'Failed' }, ] }, + { key: 'cargoTypeId', label: 'Content (cargo type)', type: 'select', optionsQuery: cargoTypeOptions }, + { key: 'cargoText', label: 'Content contains', type: 'text' }, + { key: 'containerTypeId', label: 'Container type', type: 'select', optionsQuery: containerTypeOptions }, + { key: 'containersMin', label: 'Containers (min)', type: 'text' }, + { key: 'containersMax', label: 'Containers (max)', type: 'text' }, + { key: 'requestedContainersMin', label: 'Requested containers (min)', type: 'text' }, + { key: 'requestedContainersMax', label: 'Requested containers (max)', type: 'text' }, { key: 'companyId', label: 'Customer', type: 'text' }, { key: 'search', label: 'Search reference or customer', type: 'text' }, ], @@ -210,6 +324,23 @@ export const bookingsDataset: ExportDataset = { if (params.tradeDirection) qb.andWhere('b.trade_direction = :tradeDirection', { tradeDirection: params.tradeDirection }); if (params.freightType) qb.andWhere('b.freight_type = :freightType', { freightType: params.freightType }); + // Group or leaf — a group matches its whole subtree (see CARGO_TYPE_SUBTREE_SQL). + if (params.cargoTypeId) qb.andWhere(`b.cargo_type_id IN ${CARGO_TYPE_SUBTREE_SQL}`, { cargoTypeId: params.cargoTypeId }); + if (params.cargoText) qb.andWhere(bookingContentMatchSql('b'), { cargoText: `%${params.cargoText as string}%` }); + if (params.containerTypeId) qb.andWhere(bookingHasContainerTypeSql('b'), { containerTypeId: params.containerTypeId }); + // With a container type picked the count is of THAT type, else of every box. + const containerCount = bookingContainerCountSql('b', Boolean(params.containerTypeId)); + // coerceFilterParams yields null (not undefined) for an unset filter, and + // Number(null) is 0 — which would silently apply ">= 0" to every export. + const num = (v: unknown) => (v == null || v === '' ? NaN : Number(v)); + const min = num(params.containersMin); + const max = num(params.containersMax); + if (Number.isFinite(min)) qb.andWhere(`${containerCount} >= :containersMin`, { containersMin: min }); + if (Number.isFinite(max)) qb.andWhere(`${containerCount} <= :containersMax`, { containersMax: max }); + const reqMin = num(params.requestedContainersMin); + const reqMax = num(params.requestedContainersMax); + if (Number.isFinite(reqMin)) qb.andWhere(`${REQUESTED_COUNT} >= :requestedContainersMin`, { requestedContainersMin: reqMin }); + if (Number.isFinite(reqMax)) qb.andWhere(`${REQUESTED_COUNT} <= :requestedContainersMax`, { requestedContainersMax: reqMax }); if (params.paymentStatus) qb.andWhere('b.payment_status = :paymentStatus', { paymentStatus: params.paymentStatus }); if (params.companyId) qb.andWhere('b.company_id = :companyId', { companyId: params.companyId }); if (params.search) { diff --git a/apps/edr-freight-api/src/modules/exports/datasets/invoices.dataset.ts b/apps/edr-freight-api/src/modules/exports/datasets/invoices.dataset.ts index 56ab3b74e..d4d635f6f 100644 --- a/apps/edr-freight-api/src/modules/exports/datasets/invoices.dataset.ts +++ b/apps/edr-freight-api/src/modules/exports/datasets/invoices.dataset.ts @@ -123,6 +123,7 @@ export const invoicesDataset: ExportDataset = { // on-screen filter actually carries into the export. { key: 'status', label: 'Status (single)', type: 'text' }, { key: 'sources', label: 'Source', type: 'multiselect' }, + { key: 'types', label: 'Type', type: 'multiselect' }, { key: 'eimsStatuses', label: 'EIMS status', type: 'multiselect' }, { key: 'paymentMethods', label: 'Payment method', type: 'multiselect' }, { key: 'currency', label: 'Currency', type: 'select', options: [ @@ -151,6 +152,8 @@ export const invoicesDataset: ExportDataset = { if (params.status) qb.andWhere('i.status = :status', { status: params.status }); const sources = params.sources as string[] | null; if (sources?.length) qb.andWhere('i.source IN (:...sources)', { sources }); + const types = params.types as string[] | null; + if (types?.length) qb.andWhere('i.type IN (:...types)', { types }); const eimsStatuses = params.eimsStatuses as string[] | null; if (eimsStatuses?.length) qb.andWhere('i.eims_status IN (:...eimsStatuses)', { eimsStatuses }); const paymentMethods = params.paymentMethods as string[] | null; diff --git a/apps/edr-freight-api/src/modules/exports/datasets/train-schedules.dataset.ts b/apps/edr-freight-api/src/modules/exports/datasets/train-schedules.dataset.ts index bb31591da..d380470de 100644 --- a/apps/edr-freight-api/src/modules/exports/datasets/train-schedules.dataset.ts +++ b/apps/edr-freight-api/src/modules/exports/datasets/train-schedules.dataset.ts @@ -1,4 +1,5 @@ import { FREIGHT_PERMS } from '../../../seed/freight-permissions.registry'; +import { bookingTonsSql } from '../../bookings/booking-tons.sql'; import { Route } from '../../routes/entities/route.entity'; import { Yard } from '../../rule-engine/entities/yard.entity'; import { ShippingLineCompany } from '../../shipping-lines/entities/shipping-line-company.entity'; @@ -86,7 +87,7 @@ export const trainSchedulesDataset: ExportDataset = { }, { key: 'totalWeightTons', label: 'Total weight (t)', type: 'tons', group: 'load', default: true, - select: `(SELECT ROUND(COALESCE(SUM(COALESCE(b.bulk_total_weight_tons, b.cargo_total_weight_vgm)), 0))::float8 + select: `(SELECT ROUND(COALESCE(SUM(${bookingTonsSql('b')}), 0))::float8 FROM freight.bookings b WHERE b.train_schedule_id = sch.id AND b.deleted_at IS NULL)`, }, diff --git a/apps/edr-freight-api/src/modules/exports/export-filter.util.ts b/apps/edr-freight-api/src/modules/exports/export-filter.util.ts index c302d9d3b..40c8e6980 100644 --- a/apps/edr-freight-api/src/modules/exports/export-filter.util.ts +++ b/apps/edr-freight-api/src/modules/exports/export-filter.util.ts @@ -1,5 +1,7 @@ import { DataSource } from 'typeorm'; +import type { ExportField } from './export.types'; + const DAY_MS = 24 * 60 * 60 * 1000; export type ExportFilterType = 'daterange' | 'date' | 'select' | 'multiselect' | 'text'; @@ -64,6 +66,26 @@ export function coerceFilterParams( */ const optionsCache = new Map(); +/** Process-lifetime cache for `dynamicFields`, keyed by dataset. */ +const fieldsCache = new Map(); + +/** + * A dataset's full field list: its static fields plus whatever `dynamicFields` + * resolves from the DB. Every read of `dataset.fields` goes through this, so + * the catalog and the download agree on which keys exist. + */ +export async function resolveDatasetFields( + dataset: { key: string; fields: ExportField[]; dynamicFields?: (ds: DataSource) => Promise }, + ds: DataSource, +): Promise { + if (!dataset.dynamicFields) return dataset.fields; + const cached = fieldsCache.get(dataset.key); + if (cached) return cached; + const resolved = [...dataset.fields, ...(await dataset.dynamicFields(ds))]; + fieldsCache.set(dataset.key, resolved); + return resolved; +} + export async function resolveFilterOptions( filters: ExportFilterDef[], ds: DataSource, diff --git a/apps/edr-freight-api/src/modules/exports/export.types.ts b/apps/edr-freight-api/src/modules/exports/export.types.ts index db12f0211..71abdad88 100644 --- a/apps/edr-freight-api/src/modules/exports/export.types.ts +++ b/apps/edr-freight-api/src/modules/exports/export.types.ts @@ -103,6 +103,15 @@ export interface ExportDataset { alwaysJoin?: string[]; groups: ExportGroup[]; fields: ExportField[]; + /** + * Extra fields resolved from reference data and appended to `fields` — one + * column per row of some small, rarely-changing table (a column per container + * type, say). Cached for the process, like `ExportFilterDef.optionsQuery`. + * + * The SQL these build is interpolated, not bound, so a resolver MUST validate + * anything it splices in; see `bookingsDataset` for the uuid guard. + */ + dynamicFields?: (ds: DataSource) => Promise; filters: ExportFilterDef[]; /** Must name a field whose `sortExpr` references only the base alias. */ defaultSort?: { key: string; dir: 'ASC' | 'DESC' }; diff --git a/apps/edr-freight-api/src/modules/exports/exports.controller.ts b/apps/edr-freight-api/src/modules/exports/exports.controller.ts index aea35a9bc..46c6ebad2 100644 --- a/apps/edr-freight-api/src/modules/exports/exports.controller.ts +++ b/apps/edr-freight-api/src/modules/exports/exports.controller.ts @@ -9,7 +9,7 @@ import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/curre import { assertFreightPermission, hasFreightPermission } from '../../common/freight-permission.util'; import { UserTradeAccessService } from '../user-trade-access/user-trade-access.service'; -import { resolveFilterOptions } from './export-filter.util'; +import { resolveDatasetFields, resolveFilterOptions } from './export-filter.util'; import { EXPORT_MIME, formatRowCap, @@ -31,13 +31,16 @@ const CAPS = { csv: CSV_ROW_CAP, xlsx: XLSX_ROW_CAP, pdf: PDF_ROW_CAP }; * Metadata only. `select` / `requires` / `sortExpr` are raw SQL and a map of * the schema — they never leave the server. */ -const toCatalogEntry = (dataset: ExportDataset): ExportCatalogEntry => ({ +const toCatalogEntry = ( + dataset: ExportDataset, + fields: ExportField[], +): ExportCatalogEntry => ({ key: dataset.key, title: dataset.title, description: dataset.description, group: dataset.group, groups: dataset.groups, - fields: dataset.fields.map(({ key, label, type, group, default: isDefault }) => ({ + fields: fields.map(({ key, label, type, group, default: isDefault }) => ({ key, label, type, @@ -72,7 +75,7 @@ export class ExportsController { const allowed = DATASETS.filter((d) => hasFreightPermission(user, d.permission)); return Promise.all( allowed.map(async (d) => ({ - ...toCatalogEntry(d), + ...toCatalogEntry(d, await resolveDatasetFields(d, this.dataSource)), filters: await resolveFilterOptions(d.filters, this.dataSource), })), ); @@ -102,7 +105,10 @@ export class ExportsController { const dataset = this.resolve(key, user); const directions = await this.userTradeAccessService.resolveAllowedDirections(user); const format = resolveExportFormat(query.format); - const fields = this.resolveFields(dataset, query.fields); + const fields = ExportsController.pickFields( + await resolveDatasetFields(dataset, this.dataSource), + query.fields, + ); const rows = await this.runner.run(dataset, fields, query, directions, { cap: formatRowCap(format), @@ -134,15 +140,15 @@ export class ExportsController { * DEFAULT set, not everything — a booking export has ~70 fields and dumping * all of them on an unparameterised call is nobody's intent. */ - private resolveFields(dataset: ExportDataset, raw: string | undefined): ExportField[] { + private static pickFields(all: ExportField[], raw: string | undefined): ExportField[] { if (raw?.trim()) { - const picked = pickByKey(dataset.fields, raw); + const picked = pickByKey(all, raw); // pickByKey falls back to everything when nothing matched; for a dataset // the safer read of "all keys unknown" is still the default set. - if (picked.length !== dataset.fields.length) return picked; + if (picked.length !== all.length) return picked; } - const defaults = dataset.fields.filter((f) => f.default); - return defaults.length ? defaults : dataset.fields; + const defaults = all.filter((f) => f.default); + return defaults.length ? defaults : all; } private resolve(key: string, user: TCurrentUser): ExportDataset { diff --git a/apps/edr-freight-api/src/modules/import-operations/dto/import-operations.dto.ts b/apps/edr-freight-api/src/modules/import-operations/dto/import-operations.dto.ts index 9af97f1e8..92a5a611a 100644 --- a/apps/edr-freight-api/src/modules/import-operations/dto/import-operations.dto.ts +++ b/apps/edr-freight-api/src/modules/import-operations/dto/import-operations.dto.ts @@ -1,6 +1,8 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Type } from 'class-transformer'; import { + ArrayMaxSize, + ArrayMinSize, ArrayNotEmpty, IsArray, IsDateString, @@ -9,6 +11,7 @@ import { IsOptional, IsString, IsUUID, + MaxLength, Min, ValidateNested, } from 'class-validator'; @@ -150,6 +153,14 @@ export class CreateEmptyContainerReturnDto { @IsUUID() customerId?: string; + @ApiPropertyOptional({ + description: 'Owning company name — free text when the company is not a registered customer.', + }) + @IsOptional() + @IsString() + @MaxLength(200) + companyName?: string; + @ApiPropertyOptional() @IsOptional() @IsDateString() @@ -196,6 +207,16 @@ export class CreateEmptyContainerReturnDto { returnedBy?: 'EDR' | 'CUSTOMER'; } +export class BulkCreateEmptyContainerReturnsDto { + @ApiProperty({ type: [CreateEmptyContainerReturnDto] }) + @IsArray() + @ArrayMinSize(1) + @ArrayMaxSize(1000) + @ValidateNested({ each: true }) + @Type(() => CreateEmptyContainerReturnDto) + returns!: CreateEmptyContainerReturnDto[]; +} + export class LoadEmptyContainerItemDto { @ApiProperty({ format: 'uuid' }) @IsUUID() diff --git a/apps/edr-freight-api/src/modules/import-operations/entities/empty-container-return.entity.ts b/apps/edr-freight-api/src/modules/import-operations/entities/empty-container-return.entity.ts index 263dd30d2..230581dd4 100644 --- a/apps/edr-freight-api/src/modules/import-operations/entities/empty-container-return.entity.ts +++ b/apps/edr-freight-api/src/modules/import-operations/entities/empty-container-return.entity.ts @@ -27,6 +27,15 @@ export class EmptyContainerReturn extends BaseEntity { @Column({ name: 'customer_id', type: 'uuid', nullable: true }) customerId?: string | null; + /** + * Owning company as text. Set when the box was backfilled for a company that + * is not (yet) a registered customer, so `customer_id` cannot carry it. When + * a registered company IS picked, both are set — the name is the label the + * list renders without a join. + */ + @Column({ name: 'company_name', type: 'varchar', length: 200, nullable: true }) + companyName?: string | null; + @Column({ name: 'return_date', type: 'timestamptz' }) returnDate!: Date; @@ -75,3 +84,14 @@ export class EmptyContainerReturn extends BaseEntity { performedBy: string | null; }>; } + +/** + * A row of the returns list: the entity's own columns plus the booking + * reference and owning company joined in. Standalone returns leave + * `bookingId`/`bookingReference` null. + */ +export interface EmptyContainerReturnListItem + extends Omit { + bookingReference: string | null; + createdAt: Date; +} diff --git a/apps/edr-freight-api/src/modules/import-operations/import-operations.controller.ts b/apps/edr-freight-api/src/modules/import-operations/import-operations.controller.ts index ae80ea9c8..1116e9440 100644 --- a/apps/edr-freight-api/src/modules/import-operations/import-operations.controller.ts +++ b/apps/edr-freight-api/src/modules/import-operations/import-operations.controller.ts @@ -11,6 +11,7 @@ import { BookingsService } from '../bookings/bookings.service'; import { AssignCustomsRiskDto, CreateDjiboutiIncidentDto, + BulkCreateEmptyContainerReturnsDto, CreateEmptyContainerReturnDto, ImportOperationActionDto, LoadEmptyContainersOnTrainDto, @@ -125,6 +126,15 @@ export class ImportOperationsController { return this.service.createEmptyReturn(dto); } + @Post('empty-container-returns/bulk') + @BookingStaff(FREIGHT_PERMS.bookings.operations) + @ApiOperation({ + summary: 'Bulk-record empties already in the yard but never entered in the system', + }) + bulkCreateEmptyReturns(@Body() dto: BulkCreateEmptyContainerReturnsDto) { + return this.service.bulkCreateEmptyReturns(dto); + } + @Post('empty-container-returns/load-on-train') @BookingStaff(FREIGHT_PERMS.bookings.operations) @ApiOperation({ diff --git a/apps/edr-freight-api/src/modules/import-operations/import-operations.service.ts b/apps/edr-freight-api/src/modules/import-operations/import-operations.service.ts index be94e3f32..9a84a0551 100644 --- a/apps/edr-freight-api/src/modules/import-operations/import-operations.service.ts +++ b/apps/edr-freight-api/src/modules/import-operations/import-operations.service.ts @@ -1,6 +1,6 @@ import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { In, Repository } from 'typeorm'; +import { In, Not, Repository } from 'typeorm'; import { NotificationAudience, NotificationType } from '@edr/types'; import { LogoSettingsService } from '../logo-settings/logo-settings.service'; @@ -10,6 +10,7 @@ import { NotificationsService } from '../notifications/notifications.service'; import { sendCompanyChannels } from '../notifications/notify-company.util'; import { WarehouseReleaseDocumentService } from '../warehouses/warehouse-release-document.service'; import { + BulkCreateEmptyContainerReturnsDto, CreateDjiboutiIncidentDto, CreateEmptyContainerReturnDto, ImportOperationActionDto, @@ -24,7 +25,11 @@ import { type DjiboutiIncidentType, } from './entities/djibouti-incident.entity'; import { assertWagonLoad } from './empty-container-wagon.util'; -import { EmptyContainerReturn } from './entities/empty-container-return.entity'; +import { + EmptyContainerReturn, + type EmptyContainerReturnListItem, + type EmptyContainerReturnStatus, +} from './entities/empty-container-return.entity'; import { ImportCustomsFinalization, type ImportCustomsDocumentType, @@ -159,8 +164,43 @@ export class ImportOperationsService { return this.getCustoms(bookingId); } - listEmptyReturns() { - return this.emptyReturns.find({ order: { createdAt: 'DESC' } as never }); + /** + * Every empty return, booking-linked and standalone alike, in one list. The + * booking reference and the owning company are joined in so the table can + * show which booking a box came back on without a second round trip — a + * standalone row simply has neither, and falls back to the typed + * `company_name`. + */ + listEmptyReturns(): Promise { + return this.emptyReturns.manager.query(` + SELECT + r.id, + r.container_number AS "containerNumber", + r.booking_id AS "bookingId", + b.reference AS "bookingReference", + r.customer_id AS "customerId", + COALESCE(r.company_name, c.name) AS "companyName", + r.return_date AS "returnDate", + r.facility, + r.yard, + r.zone, + r.condition, + r.handover_note AS "handoverNote", + r.status, + r.wagon_allocation_reference AS "wagonAllocationReference", + r.container_size AS "containerSize", + r.train_schedule_id AS "trainScheduleId", + r.wagon_sequence_no AS "wagonSequenceNo", + r.performed_by AS "performedBy", + r.returned_by AS "returnedBy", + r.status_history AS "statusHistory", + r.created_at AS "createdAt" + FROM freight.empty_container_returns r + LEFT JOIN freight.bookings b ON b.id = r.booking_id + LEFT JOIN freight.companies c ON c.id = b.company_id + WHERE r.deleted_at IS NULL + ORDER BY r.created_at DESC + `); } listEmptyReturnsForBooking(bookingId: string) { @@ -174,6 +214,7 @@ export class ImportOperationsService { containerNumber: dto.containerNumber, bookingId: dto.bookingId ?? null, customerId: dto.customerId ?? null, + companyName: dto.companyName ?? null, returnDate, containerSize: dto.containerSize ?? null, facility: dto.facility ?? null, @@ -199,6 +240,66 @@ export class ImportOperationsService { return saved; } + /** + * Bulk backfill of empties already sitting in a yard but never recorded. + * All-or-nothing: if any container number already has an open (not COMPLETED) + * return, nothing is written — re-uploading the same sheet must not duplicate + * boxes. No interchange notification is sent; these are historical rows, not + * a live handover. + */ + async bulkCreateEmptyReturns(dto: BulkCreateEmptyContainerReturnsDto) { + const numbers = dto.returns.map((r) => r.containerNumber.trim().toUpperCase()); + + const seen = new Set(); + const dupInFile = numbers.filter((n) => (seen.has(n) ? true : (seen.add(n), false))); + if (dupInFile.length > 0) { + throw new BadRequestException( + `Container number(s) repeated in the upload: ${[...new Set(dupInFile)].join(', ')}`, + ); + } + + const existing = await this.emptyReturns.find({ + where: { + containerNumber: In(numbers), + status: Not('COMPLETED' as EmptyContainerReturnStatus), + }, + select: { containerNumber: true }, + }); + if (existing.length > 0) { + throw new BadRequestException( + `Already recorded as returned: ${existing.map((r) => r.containerNumber).join(', ')}`, + ); + } + + const rows = dto.returns.map((r, i) => { + const returnDate = r.returnDate ? new Date(r.returnDate) : new Date(); + return this.emptyReturns.create({ + containerNumber: numbers[i], + bookingId: r.bookingId ?? null, + customerId: r.customerId ?? null, + companyName: r.companyName ?? null, + returnDate, + containerSize: r.containerSize ?? null, + facility: r.facility ?? null, + yard: r.yard ?? null, + zone: r.zone ?? null, + condition: r.condition ?? null, + handoverNote: r.handoverNote ?? null, + performedBy: r.performedBy ?? null, + returnedBy: r.returnedBy ?? null, + statusHistory: [ + { + status: 'RETURNED' as const, + changedAt: returnDate.toISOString(), + performedBy: r.performedBy ?? null, + }, + ], + }); + }); + + return this.emptyReturns.save(rows); + } + /** * Load returned empties onto an export departure. A wagon takes ONE 40ft or * TWO 20ft — never a mix, never three. Empties already sitting on a wagon of diff --git a/apps/edr-freight-api/src/modules/otp/otp.service.spec.ts b/apps/edr-freight-api/src/modules/otp/otp.service.spec.ts index 00f8d107a..086baef66 100644 --- a/apps/edr-freight-api/src/modules/otp/otp.service.spec.ts +++ b/apps/edr-freight-api/src/modules/otp/otp.service.spec.ts @@ -35,9 +35,24 @@ describe("isDomesticPhone", () => { (phone) => expect(isDomesticPhone(phone)).toBe(true), ); - it.each(["+14155550123", "+447911123456", "0712345678", "+2519866", "12345"])( - "rejects non-domestic or malformed %s", - (phone) => expect(isDomesticPhone(phone)).toBe(false), + // Djibouti is the line's other end: the gateway reaches its 77x mobiles. + it.each(["+25377123456", "25377123456", "77123456"])( + "accepts Djibouti mobile form %s", + (phone) => expect(isDomesticPhone(phone)).toBe(true), + ); + + it.each([ + "+14155550123", + "+447911123456", + "0712345678", + "+2519866", + "12345", + // Djibouti fixed line (2x) — valid number, not a mobile the gateway serves. + "+25321350000", + // Right length, wrong Djibouti prefix. + "+25366123456", + ])("rejects unreachable or malformed %s", (phone) => + expect(isDomesticPhone(phone)).toBe(false), ); }); diff --git a/apps/edr-freight-api/src/modules/otp/otp.service.ts b/apps/edr-freight-api/src/modules/otp/otp.service.ts index b27520c25..6b5a0e0d9 100644 --- a/apps/edr-freight-api/src/modules/otp/otp.service.ts +++ b/apps/edr-freight-api/src/modules/otp/otp.service.ts @@ -42,20 +42,42 @@ function normalizePhone(rawPhone: string): string { if (digits.startsWith("+")) return digits; const bare = digits.replace(/^0+/, ""); if (/^251\d{9}$/.test(digits)) return `+${digits}`; + if (/^253\d{8}$/.test(digits)) return `+${digits}`; if (/^9\d{8}$|^7\d{8}$/.test(bare)) return `+251${bare}`; + // Djibouti mobiles are 8 digits starting 77 and have no trunk prefix, so a + // bare "77…" is unambiguous — it cannot be an Ethiopian local number, which + // is always 9 digits after the trunk zero. + if (/^77\d{6}$/.test(bare)) return `+253${bare}`; // Unknown shape (foreign number, already-clean intl without +) — prefix + if // it looks like a full international number, else leave as typed. return digits.length >= 11 ? `+${digits}` : raw; } /** - * Whether a phone is an Ethiopian mobile the SMS gateway can actually reach — - * the carrier integration is domestic-only, so a send to anything else is - * queued and silently lost. Callers use this to fall back to email instead of - * pretending an SMS is on its way. + * Mobile ranges the SMS gateway is contracted to reach, as E.164 patterns. + * + * The gateway itself is opaque from here — `SmsClientService` publishes to + * RabbitMQ and the carrier sits several hops downstream — so this list is a + * policy statement, not a capability probe: a number outside it is treated as + * unreachable and callers fall back to email rather than promising an SMS that + * would be queued and silently dropped. + * + * - Ethiopia: `+2519…` mobiles only. `+2517…` is deliberately absent; it parses + * as a valid ET number but is not a range this gateway delivers to. + * - Djibouti: `+25377…`, the country's only mobile range (2x is fixed-line). + */ +const REACHABLE_MOBILE_PATTERNS = [/^\+2519\d{8}$/, /^\+25377\d{6}$/]; + +/** + * Whether a phone sits in a mobile range the SMS gateway can actually reach. + * + * Named "domestic" for the Ethiopian-only era this predates; it now covers both + * countries the railway runs through. Callers use it to fall back to email + * instead of pretending an SMS is on its way. */ export function isDomesticPhone(rawPhone: string): boolean { - return /^\+2519\d{8}$/.test(normalizePhone(rawPhone)); + const normalized = normalizePhone(rawPhone); + return REACHABLE_MOBILE_PATTERNS.some((p) => p.test(normalized)); } /** @@ -99,7 +121,7 @@ export class OtpService { private readonly otpRepository: OtpRepository, private readonly notifications: NotificationsService, private readonly emailClient: EmailClientService, - ) { } + ) {} // --------------------------------------------------------------------------- // Generate OTP @@ -197,8 +219,10 @@ export class OtpService { for (const outcome of outcomes) { this.logger.log( - `otp.dispatch channel=${outcome.channel} target=${label} queued=${outcome.queued - } latencyMs=${Date.now() - startedAt}${outcome.error ? ` error=${outcome.error}` : "" + `otp.dispatch channel=${outcome.channel} target=${label} queued=${ + outcome.queued + } latencyMs=${Date.now() - startedAt}${ + outcome.error ? ` error=${outcome.error}` : "" }`, ); } @@ -222,7 +246,8 @@ export class OtpService { // user who never receives a code — indistinguishable from carrier loss, // and the misleading success response makes it look like our side worked. this.logger.error( - `otp.dispatch.dropped channels=${channels.join("+")} target=${label} rabbitmqEnabled=${process.env.RABBITMQ_ENABLED ?? "unset" + `otp.dispatch.dropped channels=${channels.join("+")} target=${label} rabbitmqEnabled=${ + process.env.RABBITMQ_ENABLED ?? "unset" } — no transport reported hand-off; no code will arrive for this send`, ); } @@ -247,7 +272,8 @@ export class OtpService { // Log the real cause (DB/SMS/email failure) with its stack so a deployed // "Failed to send OTP" 400 is diagnosable from the API logs, not opaque. this.logger.error( - `otp.dispatch.failed channels=${channels.join("+")} target=${label} latencyMs=${Date.now() - startedAt + `otp.dispatch.failed channels=${channels.join("+")} target=${label} latencyMs=${ + Date.now() - startedAt }: ${error instanceof Error ? error.message : String(error)}`, error instanceof Error ? error.stack : undefined, ); @@ -330,8 +356,9 @@ export class OtpService { ) { const line = `otp.verify channels=${channelsOf(target).join( "+", - )} target=${this.targetLabel(target)} mode=${mode} result=${result}${detail ? ` ${detail}` : "" - }`; + )} target=${this.targetLabel(target)} mode=${mode} result=${result}${ + detail ? ` ${detail}` : "" + }`; if (result === "ok") this.logger.log(line); else this.logger.warn(line); diff --git a/apps/edr-freight-api/src/modules/reports/definitions/booking-status-breakdown.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/booking-status-breakdown.report.ts index 449c47181..c70fcb13f 100644 --- a/apps/edr-freight-api/src/modules/reports/definitions/booking-status-breakdown.report.ts +++ b/apps/edr-freight-api/src/modules/reports/definitions/booking-status-breakdown.report.ts @@ -1,6 +1,7 @@ import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; import { BookingStatus } from '@edr/types'; +import { bookingTonsSql } from '../../bookings/booking-tons.sql'; import { Booking } from '../../bookings/entities/booking.entity'; import { Yard } from '../../rule-engine/entities/yard.entity'; import { CargoType } from '../../rule-engine/entities/cargo-type.entity'; @@ -9,7 +10,7 @@ import { ReportContext, ReportDefinition } from '../report.types'; // One resolver behind "Booking per status, per port/train/date/cargo/contract // type" — the same breakdown Operation, Marketing, Global Logistics and the // Operation Report each ask for verbatim. Embed once, reuse everywhere. -const TONS = 'COALESCE(b.bulk_total_weight_tons, b.cargo_total_weight_vgm)'; +const TONS = bookingTonsSql('b'); const REVENUE = 'COALESCE(b.adjusted_total_amount, b.total_amount)'; const STATUS_OPTIONS = [...new Set(Object.values(BookingStatus))].map((v) => ({ diff --git a/apps/edr-freight-api/src/modules/reports/definitions/cargo-summary.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/cargo-summary.report.ts index ee3063ee1..2df862f3c 100644 --- a/apps/edr-freight-api/src/modules/reports/definitions/cargo-summary.report.ts +++ b/apps/edr-freight-api/src/modules/reports/definitions/cargo-summary.report.ts @@ -1,9 +1,10 @@ import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; +import { bookingTonsSql } from '../../bookings/booking-tons.sql'; import { Booking } from '../../bookings/entities/booking.entity'; import { ReportContext, ReportDefinition } from '../report.types'; -const TONS = 'COALESCE(b.bulk_total_weight_tons, b.cargo_total_weight_vgm)'; +const TONS = bookingTonsSql('b'); const NOT_UMBRELLA = "(b.contract_kind IS NULL OR b.contract_kind <> 'GENERAL')"; const DEAD_STATUSES = ['DRAFT', 'CANCELLED', 'REJECTED', 'EXPIRED']; diff --git a/apps/edr-freight-api/src/modules/reports/definitions/contract-utilization.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/contract-utilization.report.ts index 747a14891..64598fa87 100644 --- a/apps/edr-freight-api/src/modules/reports/definitions/contract-utilization.report.ts +++ b/apps/edr-freight-api/src/modules/reports/definitions/contract-utilization.report.ts @@ -1,10 +1,11 @@ import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; +import { bookingTonsSql } from '../../bookings/booking-tons.sql'; import { Company } from '../../companies/entities/company.entity'; import { Contract } from '../../contracts/entities/contract.entity'; import { ReportContext, ReportDefinition } from '../report.types'; -const TONS = 'COALESCE(b.bulk_total_weight_tons, b.cargo_total_weight_vgm)'; +const TONS = bookingTonsSql('b'); const DEAD_STATUSES = ['DRAFT', 'CANCELLED', 'REJECTED', 'EXPIRED']; function baseQuery(ctx: ReportContext): SelectQueryBuilder { diff --git a/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts b/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts index a1f927a77..5f24359a2 100644 --- a/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts +++ b/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts @@ -273,6 +273,20 @@ export class TrainSchedule extends BaseEntity { @Column({ name: 'wagon_allocation_snapshot', type: 'jsonb', nullable: true }) wagonAllocationSnapshot?: WagonAllocationSnapshot | null; + /** + * Why this schedule was cancelled — required at cancel time and shown on every + * view of the cancelled train. NULL on live schedules and on rows cancelled + * before the reason was captured. + */ + @Column({ name: 'cancellation_reason', type: 'varchar', length: 500, nullable: true }) + cancellationReason?: string | null; + + @Column({ name: 'cancelled_at', type: 'timestamptz', nullable: true }) + cancelledAt?: Date | null; + + @Column({ name: 'cancelled_by_user_id', type: 'uuid', nullable: true }) + cancelledByUserId?: string | null; + @OneToMany(() => TrainScheduleBooking, (scheduleBooking) => scheduleBooking.trainSchedule) scheduleBookings?: TrainScheduleBooking[]; } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts index 9051bb816..8599a96bc 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts @@ -1379,6 +1379,8 @@ describe('BookingBatchService — built-train wagon capacity', () => { maxWagons?: number; routeStops?: string[]; yardCountries?: Record; + maxPullWeightTons?: number; + maxTrainLengthMeters?: number; }) => { const schedule = { id: scheduleId, @@ -1390,8 +1392,11 @@ describe('BookingBatchService — built-train wagon capacity', () => { scheduleBookings: [], trainSet: { locomotive: { - maxPullWeightTons: 1, - maxTrainLengthMeters: 1, + // Roomy on purpose: these cases exercise the SLOT axis, so the pull + // budget must not be what closes the train. Weight-bound behaviour + // has its own cases below. + maxPullWeightTons: opts.maxPullWeightTons ?? 100000, + maxTrainLengthMeters: opts.maxTrainLengthMeters ?? 100000, overageToleranceTons: 0, overageToleranceMeters: 0, }, @@ -1459,15 +1464,28 @@ describe('BookingBatchService — built-train wagon capacity', () => { await expect(service.isScheduleFull(scheduleId)).resolves.toBe(true); }); - it('is NOT full while physical wagons remain, ignoring weight/length limits', async () => { + it('is NOT full while physical wagons remain and the loco can still haul them', async () => { const { service } = buildService({ physicalWagons: 3, reserved: [reservedBooking('b1'), reservedBooking('b2')], }); - // 1T pull cap would have been exhausted long ago under the old math. await expect(service.isScheduleFull(scheduleId)).resolves.toBe(false); }); + it('is FULL when the locomotive cannot pull another wagon, though slots are free', async () => { + // The consist has a spare slot, but every wagon spends its TARE out of the + // same pull limit the cargo needs — so a slot-free train can still be + // weight-full. This is what let a 44-wagon booking plan 4065T gross onto a + // 3500T train while the board advertised free wagons. + const { service } = buildService({ + physicalWagons: 3, + reserved: [reservedBooking('b1'), reservedBooking('b2')], + maxPullWeightTons: 1, + maxTrainLengthMeters: 1, + }); + await expect(service.isScheduleFull(scheduleId)).resolves.toBe(true); + }); + it('is NOT full when only a middle leg is sold and other edges run free (domestic route)', async () => { // Leg-aware allocation (planWagonsWithStock legs) made mid-leg wagons real // capacity on the edges they don't ride: a domestic corridor with cargo diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts index d787f737d..d9bae156a 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts @@ -658,8 +658,36 @@ export class BookingBatchService implements OnModuleInit { } } - const linked = + let linked = await this.trainScheduleBookingsRepository.existsForBooking(bookingId); + // A link row can outlive the booking's own pointer (cleared on one path + // while the row survives on another). The booking then looks "linked" here, + // so the branch below calls tryAutoWagonAllocation(null) and the paid + // booking silently never gets wagons — no error, just no allocation. + if (linked && !booking.trainScheduleId) { + // The link row still names the train it belongs to — restore the pointer + // from it rather than dropping the link, so the booking keeps the train + // it was placed on and the allocation below has a schedule to run against. + const [link] = await this.trainScheduleBookingsRepository.findByBookingIds([ + bookingId, + ]); + if (link?.trainScheduleId) { + await this.dataSource + .getRepository(Booking) + .update(bookingId, { trainScheduleId: link.trainScheduleId } as never); + booking.trainScheduleId = link.trainScheduleId; + this.logger.warn( + `[BATCH] ${booking.reference ?? bookingId} was linked to schedule ${link.trainScheduleId} ` + + `with no train_schedule_id of its own — pointer restored so it can allocate`, + ); + } else { + await this.trainScheduleBookingsRepository.deleteByScheduleAndBooking( + link?.trainScheduleId ?? '', + bookingId, + ); + linked = false; + } + } // Intercity is allocated MANUALLY: payment secures the ride, staff then // place it on whichever same-route train suits (intercity panel). Unpin // from the train it reserved against — that train may be the wrong one by @@ -3939,6 +3967,11 @@ export class BookingBatchService implements OnModuleInit { schedulingStatus: "SCHEDULED", scheduledAt: new Date(), wagonsRequired, + // Pinned for cancellation pricing: unassign clears wagonsRequired, this + // stays. Written once — a later re-allocation keeps the first stamp. + ...(Number(booking.cancellationWagons ?? 0) > 0 + ? {} + : { cancellationWagons: wagonsRequired }), paymentDeadline: null, selectedForBatchAt: null, } as never); @@ -5376,10 +5409,13 @@ export class BookingBatchService implements OnModuleInit { * Dire→Djibouti leaves the Addis→Dire edges untouched. * * Two capacity regimes, decided by the schedule's train: - * - Built train (Train Builder consist with physical wagons): the consist IS - * the capacity. Wagon slots = physical wagon count; weight and length are - * NOT re-checked here — the builder and adjust-consist already enforced the - * locomotive's pull/length limits when the consist was assembled. + * - Built train (Train Builder consist with physical wagons): wagon slots = + * physical wagon count, but the locomotive's weight/length budgets STILL + * apply. The builder only proves the EMPTY consist can be pulled; every + * wagon then spends its tare out of the same pull limit the cargo needs, so + * a 54-wagon consist can be slot-free and still weight-full. Treating the + * consist as unlimited tonnage is what let a 44-wagon booking plan 4065T + * gross onto a 3500T train. * - No built train (legacy schedules): the locomotive's length-derived slot * count plus its weight/length budgets, as before — yard staff attach the * missing wagons manually before wagon assignment. @@ -5392,13 +5428,16 @@ export class BookingBatchService implements OnModuleInit { ): Promise { const physicalWagons = await this.builtTrainWagonCount(schedule); if (physicalWagons != null) { + // The consist fixes the SLOT count (never the locomotive's length-derived + // estimate), but weight and length stay on the locomotive's real budget — + // including its overage tolerance, which `fits` may spend on a whole unit. limits = { base: { wagons: physicalWagons, - weightTons: Number.POSITIVE_INFINITY, - lengthMeters: Number.POSITIVE_INFINITY, + weightTons: limits.base.weightTons, + lengthMeters: limits.base.lengthMeters, }, - tolerance: { weightTons: 0, lengthMeters: 0 }, + tolerance: limits.tolerance, }; } // Built trains keep the leg-aware multi-edge corridor too: the wagon @@ -5631,19 +5670,23 @@ export class BookingBatchService implements OnModuleInit { const wagonDims = await this.loadWagonDims(); const physicalWagons = await this.builtTrainWagonCount(schedule); let limits: TrainLimits; + const locomotive = trainSetLocomotiveLimits(schedule.trainSet); if (physicalWagons != null) { - // The consist is the capacity; weight/length were settled at build time. - // remainingBudget swaps in the physical wagon count per edge itself. - limits = { - base: { - wagons: physicalWagons, - weightTons: Number.POSITIVE_INFINITY, - lengthMeters: Number.POSITIVE_INFINITY, - }, - tolerance: { weightTons: 0, lengthMeters: 0 }, - }; + // The consist fixes the slot count, but the locomotive's pull/length + // budget still binds: 54 empty slots are worthless once the tare of the + // wagons already loaded has spent the pull limit. Without a locomotive + // there is nothing to weigh against, so the slot axis is all that is left. + limits = locomotive + ? await this.capacityLimits(locomotive) + : { + base: { + wagons: physicalWagons, + weightTons: Number.POSITIVE_INFINITY, + lengthMeters: Number.POSITIVE_INFINITY, + }, + tolerance: { weightTons: 0, lengthMeters: 0 }, + }; } else { - const locomotive = trainSetLocomotiveLimits(schedule.trainSet); // No loco, no built train: only the slot axis exists to bind against. if (!locomotive) return (await this.remainingWagons(schedule)) <= 0; limits = await this.capacityLimits(locomotive); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts index 1c2d99401..4600f7f38 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts @@ -39,15 +39,30 @@ export class BookingNotifierService { try { const s = await this.trainSchedules.findByIdWithStations(scheduleId); if (!s) return fallback; - const ref = s.reference ?? s.trainNumber ?? null; - const route = + // Customers know the train by its operating number (8001), not the + // schedule reference — lead with it and keep S-… as the secondary id. + const parts = [ + s.reference, s.originStation?.label && s.destinationStation?.label - ? ` (${s.originStation.label} → ${s.destinationStation.label})` - : ''; + ? `${s.originStation.label} → ${s.destinationStation.label}` + : null, + ].filter(Boolean); + const detail = parts.length ? ` (${parts.join(', ')})` : ''; const departure = s.scheduledDepartureDate - ? `, departing ${new Date(s.scheduledDepartureDate).toLocaleDateString('en-GB', { timeZone: BATCH_TIMEZONE })}` + ? `, departing ${new Date(s.scheduledDepartureDate).toLocaleString('en-GB', { + timeZone: BATCH_TIMEZONE, + day: '2-digit', + month: '2-digit', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + hour12: false, + })} EAT` : ''; - return ref ? `train ${ref}${route}${departure}` : `${fallback}${route}${departure}`; + const number = s.trainNumber ?? s.reference ?? null; + return number + ? `train ${number}${number === s.reference ? '' : detail}${departure}` + : `${fallback}${detail}${departure}`; } catch (err) { this.logger.warn( `scheduleLabel(${scheduleId}) failed: ${(err as Error).message}`, diff --git a/apps/edr-freight-api/src/modules/train-scheduling/controllers/train-scheduling.controller.ts b/apps/edr-freight-api/src/modules/train-scheduling/controllers/train-scheduling.controller.ts index fa32b601b..4fc921ccb 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/controllers/train-scheduling.controller.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/controllers/train-scheduling.controller.ts @@ -32,6 +32,7 @@ import { FREIGHT_PERMS } from "../../../seed/freight-permissions.registry"; import { AcceptIntercityBookingsDto } from "../dto/accept-intercity-bookings.dto"; import { StationWorkDto } from "../dto/station-work.dto"; import { AssignBookingsDto } from "../dto/assign-bookings.dto"; +import { CancelTrainScheduleDto } from "../dto/cancel-train-schedule.dto"; import { AssignUnassignedBookingDto } from "../dto/assign-unassigned-booking.dto"; import { SwitchGovernmentBookingDto } from "../dto/switch-government-booking.dto"; import { CreateContainerTrainScheduleDto } from "../dto/create-container-train-schedule.dto"; @@ -894,6 +895,28 @@ export class TrainSchedulingController { return res.send(buffer); } + @Get("schedules/:id/marshalling/stops") + @TrainSchedulingView() + @ApiOperation({ summary: "Corridor stops with a logged consist change, in order (Marshalling 2, 3, 4…)" }) + marshallingStops(@Param("id", ParseUUIDPipe) id: string) { + return this.trainSchedulingService.marshallingStops(id); + } + + @Get("schedules/:id/marshalling/document/:stopIndex") + @TrainSchedulingView() + @ApiOperation({ summary: "Download the numbered marshalling PDF for one corridor stop" }) + async marshallingDocumentAt( + @Param("id", ParseUUIDPipe) id: string, + @Param("stopIndex", ParseIntPipe) stopIndex: number, + @Res() res: Response, + ) { + const { filename, buffer } = await this.trainSchedulingService.marshallingDocumentAt(id, stopIndex); + res.setHeader("Content-Type", "application/pdf"); + res.setHeader("Content-Disposition", `inline; filename="${filename}"`); + res.setHeader("Content-Length", buffer.length); + return res.send(buffer); + } + // ---- batch / booking-window staff actions ---- @Post("schedules/:id/run-batch") @@ -1184,14 +1207,22 @@ export class TrainSchedulingController { @Post("container/schedules/:id/cancel") @TrainSchedulingCancel() @ApiOperation({ summary: "Cancel container train schedule" }) - cancelTrainSchedule(@Param("id", ParseUUIDPipe) id: string) { - return this.trainSchedulingService.cancelTrainSchedule(id); + cancelTrainSchedule( + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: CancelTrainScheduleDto, + @CurrentUser() user: AuthUserPayload, + ) { + return this.trainSchedulingService.cancelTrainSchedule(id, dto, user?.id); } @Post('bulk/schedules/:id/cancel') @TrainSchedulingCancel() @ApiOperation({ summary: "Cancel bulk train schedule" }) - cancelBulkTrainSchedule(@Param("id", ParseUUIDPipe) id: string) { - return this.trainSchedulingService.cancelTrainSchedule(id); + cancelBulkTrainSchedule( + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: CancelTrainScheduleDto, + @CurrentUser() user: AuthUserPayload, + ) { + return this.trainSchedulingService.cancelTrainSchedule(id, dto, user?.id); } } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dispatch-partial-load-gate.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/dispatch-partial-load-gate.spec.ts new file mode 100644 index 000000000..ef98e0887 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/dispatch-partial-load-gate.spec.ts @@ -0,0 +1,145 @@ +import { BadRequestException } from '@nestjs/common'; + +import { TrainSchedulingService } from './services/train-scheduling.service'; + +/** + * Per-wagon loading dispatch gate. A booking half-loaded at the DEPARTURE yard + * blocks the train; a booking that boards further down the corridor + * (A→B→C→D carrying a B→C load) never does — its wagons are not due until its + * own yard, so the SQL is scoped by `b.origin_yard_id = `. + * The scoping lives in the query, so this checks the parameters that carry it + * plus the throw/pass decision on the rows it returns. + */ +describe('TrainSchedulingService.assertNoPartiallyLoadedBookings', () => { + const ORIGIN = 'yard-a'; + const SET = 'set-1'; + + const makeService = (rows: Array<{ reference: string; loaded: string; total: string }>) => { + const calls: Array<{ sql: string; params: unknown[] }> = []; + const svc = Object.create(TrainSchedulingService.prototype) as { + dataSource: { query: (sql: string, params: unknown[]) => Promise }; + assertNoPartiallyLoadedBookings( + schedule: unknown, + boardingYardId: string, + context: { action: string; yardLabel?: string }, + ): Promise; + assertPassedYardsFullyLoaded( + schedule: unknown, + stations: Array<{ sequenceNo: number; yardId: string; label: string }>, + sequenceNo: number, + ): Promise; + }; + svc.dataSource = { + query: async (sql: string, params: unknown[]) => { + calls.push({ sql, params }); + return rows; + }, + }; + return { svc, calls }; + }; + const schedule = { trainSetId: SET, originStationId: ORIGIN }; + + it('scopes the scan to bookings boarding at this departure yard', async () => { + const { svc, calls } = makeService([]); + await svc.assertNoPartiallyLoadedBookings(schedule, ORIGIN, { action: 'dispatch' }); + expect(calls).toHaveLength(1); + // The origin filter is what keeps a mid-corridor booking from holding the + // train — without it, one early-loaded B→C wagon blocks dispatch at A. + expect(calls[0].sql).toContain('b.origin_yard_id = $2'); + expect(calls[0].params).toEqual([SET, ORIGIN]); + }); + + it('lets the train go when nothing at this yard is half-loaded', async () => { + const { svc } = makeService([]); + await expect( + svc.assertNoPartiallyLoadedBookings(schedule, ORIGIN, { action: 'dispatch' }), + ).resolves.toBeUndefined(); + }); + + it('blocks a booking half-loaded at this yard, naming its progress', async () => { + const { svc } = makeService([{ reference: 'BK-2026-000220', loaded: '4', total: '5' }]); + await expect( + svc.assertNoPartiallyLoadedBookings(schedule, ORIGIN, { action: 'dispatch' }), + ).rejects.toThrow(/BK-2026-000220 \(4\/5 wagons loaded\)/); + await expect( + svc.assertNoPartiallyLoadedBookings(schedule, ORIGIN, { action: 'dispatch' }), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('skips the scan entirely for a schedule with no train set', async () => { + const { svc, calls } = makeService([{ reference: 'X', loaded: '1', total: '2' }]); + await expect( + svc.assertNoPartiallyLoadedBookings({ trainSetId: null }, ORIGIN, { + action: 'dispatch', + }), + ).resolves.toBeUndefined(); + expect(calls).toHaveLength(0); + }); +}); + +/** + * Mid-corridor twin: logging a checkpoint at station N means the train left + * every earlier stop, so each of those yards is checked for its OWN + * half-loaded bookings. The origin is excluded (dispatch gated it) and the + * yard being arrived at is excluded (its loading has not happened yet). + */ +describe('TrainSchedulingService.assertPassedYardsFullyLoaded', () => { + const STATIONS = [ + { sequenceNo: 0, yardId: 'mojo', label: 'Mojo' }, + { sequenceNo: 1, yardId: 'adama', label: 'Adama' }, + { sequenceNo: 2, yardId: 'dire', label: 'Dire Dawa' }, + { sequenceNo: 3, yardId: 'djibouti', label: 'Djibouti' }, + ]; + + const makeService = (rowsByYard: Record>>) => { + const scanned: string[] = []; + const svc = Object.create(TrainSchedulingService.prototype) as { + dataSource: { query: (sql: string, params: unknown[]) => Promise }; + assertPassedYardsFullyLoaded( + schedule: unknown, + stations: typeof STATIONS, + sequenceNo: number, + ): Promise; + }; + svc.dataSource = { + query: async (_sql: string, params: unknown[]) => { + const yardId = params[1] as string; + scanned.push(yardId); + return rowsByYard[yardId] ?? []; + }, + }; + return { svc, scanned }; + }; + const schedule = { trainSetId: 'set-1', originStationId: 'mojo' }; + + it('checks the stops already departed, never the origin or the yard being reached', async () => { + const { svc, scanned } = makeService({}); + await svc.assertPassedYardsFullyLoaded(schedule, STATIONS, 3); + // Mojo is dispatch's job; Djibouti has not been loaded at yet. + expect(scanned).toEqual(['adama', 'dire']); + }); + + it('blocks the checkpoint when a passed yard left a booking half-loaded', async () => { + const { svc } = makeService({ + adama: [{ reference: 'BK-200', loaded: '5', total: '8' }], + }); + await expect(svc.assertPassedYardsFullyLoaded(schedule, STATIONS, 2)).rejects.toThrow( + /Adama.*BK-200 \(5\/8 wagons loaded\)/s, + ); + }); + + it('names the resolution the operator has: load the rest, or cancel it', async () => { + const { svc } = makeService({ + adama: [{ reference: 'BK-200', loaded: '5', total: '8' }], + }); + await expect(svc.assertPassedYardsFullyLoaded(schedule, STATIONS, 2)).rejects.toThrow( + /customer fault: cancellation fee; EDR fault: no fee, rebookable/, + ); + }); + + it('scans nothing at the first checkpoint after the origin', async () => { + const { svc, scanned } = makeService({}); + await svc.assertPassedYardsFullyLoaded(schedule, STATIONS, 1); + expect(scanned).toEqual([]); + }); +}); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/cancel-train-schedule.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/cancel-train-schedule.dto.ts new file mode 100644 index 000000000..b3e2c7678 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/cancel-train-schedule.dto.ts @@ -0,0 +1,14 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsString, MaxLength } from 'class-validator'; + +export class CancelTrainScheduleDto { + @ApiProperty({ + description: + 'Why this train is being cancelled. Shown on the schedule from then on, and to the staff who have to re-place its bookings.', + maxLength: 500, + }) + @IsString() + @IsNotEmpty() + @MaxLength(500) + reason!: string; +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/intercity.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/intercity.service.ts index 4e2724f49..9dc75335f 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/intercity.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/intercity.service.ts @@ -7,6 +7,7 @@ import { import { InjectDataSource } from '@nestjs/typeorm'; import { DataSource } from 'typeorm'; +import { bookingTonsSql } from '../bookings/booking-tons.sql'; import { Booking } from '../bookings/entities/booking.entity'; import { RouteMilestone } from '../routes/entities/route-milestone.entity'; import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; @@ -58,7 +59,7 @@ export class IntercityService { b.reference AS "reference", b.status AS "status", b.freight_type AS "freightType", - b.cargo_total_weight_vgm AS "weightTons", + ${bookingTonsSql('b')} AS "weightTons", b.loaded_at AS "loadedAt", b.arrived_at AS "arrivedAt", company.name AS "customer", diff --git a/apps/edr-freight-api/src/modules/train-scheduling/remainder-placement.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/remainder-placement.service.ts index 85b6d0878..e95cb4fe8 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/remainder-placement.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/remainder-placement.service.ts @@ -333,7 +333,9 @@ export class RemainderPlacementService { return deferred.map((u) => ({ containerNumber: u.containerNumber, - sealNumber: u.sealNumber ?? undefined, + // Deferred units predate the seal requirement; the booking service + // normalizes the blank back to null rather than rejecting the re-book. + sealNumber: u.sealNumber ?? '', vgmTons: Number(u.vgmTons), isHazardous: u.isHazardous, isReefer: u.isReefer, diff --git a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.spec.ts index 2b8971e81..d6e778216 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.spec.ts @@ -1209,7 +1209,7 @@ describe('TrainSchedulingService', () => { expect(html).toContain('To load en route1 containers'); }); - it('prints coupled/switched wagons logged at this stop, and omits the box when there are none', () => { + it('prints the consist-changes table for this stop, and omits it when there are none', () => { const schedule = { id: 'schedule-1', trainNumber: '8302', @@ -1223,16 +1223,21 @@ describe('TrainSchedulingService', () => { const withChanges = build(schedule, { consistChangesAtStop: [ - { action: 'ADD', wagonNumber: 'W-1002' }, - { action: 'SWITCH', wagonNumber: 'W-0501 → W-1003' }, + { wagonNumber: 'W-1002', event: 'Coupled', containerNumbers: 'EMPTY WAGON' }, + { wagonNumber: 'W-1005', event: 'Coupled', containerNumbers: 'CONT-004, CONT-005' }, + { wagonNumber: 'W-0501 → W-1003', event: 'Switched', containerNumbers: 'CONT-011' }, ], }); - expect(withChanges).toContain('Consist changed at this stop'); - expect(withChanges).toContain('Coupled: W-1002'); - expect(withChanges).toContain('Uncoupled — replaced: W-0501 → W-1003'); + expect(withChanges).toContain('Consist Changed At This Stop'); + expect(withChanges).toContain('W-1002'); + expect(withChanges).toContain('Coupled'); + expect(withChanges).toContain('EMPTY WAGON'); + expect(withChanges).toContain('CONT-004, CONT-005'); + expect(withChanges).toContain('W-0501 → W-1003'); + expect(withChanges).toContain('Switched'); const withoutChanges = build(schedule, {}); - expect(withoutChanges).not.toContain('Consist changed at this stop'); + expect(withoutChanges).not.toContain('Consist Changed At This Stop'); }); it('lists loaded empty containers by number and states they are empty', () => { diff --git a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts index 858d14c90..19140d822 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts @@ -2320,12 +2320,18 @@ export class TrainSchedulingService { // batch fill, which unlinks it and frees its wagons on the next window cycle. const scheduledAt = new Date(); for (const booking of bookings) { + const wagonsRequired = sumWagonsRequired(booking, wagonPlan); await this.bookingsRepository.updateSchedulingFields( booking.id, { schedulingStatus: SchedulingStatus.Scheduled, scheduledAt, - wagonsRequired: sumWagonsRequired(booking, wagonPlan), + wagonsRequired, + // Pinned for cancellation pricing: unassign clears wagonsRequired, + // this stays. Written once — re-allocation keeps the first stamp. + ...(Number(booking.cancellationWagons ?? 0) > 0 + ? {} + : { cancellationWagons: wagonsRequired }), }, manager, ); @@ -2949,7 +2955,9 @@ export class TrainSchedulingService { // Per-wagon loading: a booking mid-load is neither ridable nor removable — // every wagon must be LOADED, or the never-loaded remainder cancelled // (at-loading cancellation), before the train departs. - await this.assertNoPartiallyLoadedBookings(schedule); + await this.assertNoPartiallyLoadedBookings(schedule, schedule.originStationId, { + action: 'dispatch', + }); await this.dataSource.transaction(async (manager) => { const trainNumber = await this.assignTrainNumber(manager, schedule); @@ -3149,13 +3157,21 @@ export class TrainSchedulingService { * (their charge sits on the credit ledger) yet ride from accept. */ /** - * Per-wagon loading dispatch gate: a booking with SOME wagons LOADED and - * SOME still PLANNED/RESERVED must resolve before departure — load the rest - * or cancel it (which shrinks the booking to its loaded wagons). Blocking - * here beats silently unassigning: unassign would delete LOADED allocations - * and strand cargo that is physically on the train. + * Per-wagon loading gate: a booking with SOME wagons LOADED and SOME still + * PLANNED/RESERVED must resolve before the train leaves the yard it boards + * at — load the rest, or cancel the remainder (which shrinks the booking to + * its loaded wagons). Blocking beats silently unassigning: unassign would + * delete LOADED allocations and strand cargo physically on the train. + * + * Scoped to bookings BOARDING AT `boardingYardId`, so each yard answers only + * for its own cargo: a mid-corridor booking (A→B→C→D carrying a B→C load) is + * not due at A and must never hold the train there. */ - private async assertNoPartiallyLoadedBookings(schedule: TrainSchedule): Promise { + private async assertNoPartiallyLoadedBookings( + schedule: TrainSchedule, + boardingYardId: string, + context: { action: string; yardLabel?: string }, + ): Promise { if (!schedule.trainSetId) return; const rows: Array<{ reference: string; loaded: string; total: string }> = await this.dataSource.query( @@ -3166,24 +3182,51 @@ export class TrainSchedulingService { JOIN freight.train_set_wagons tsw ON tsw.id = a.train_set_wagon_id JOIN freight.bookings b ON b.id = a.booking_id WHERE tsw.train_set_id = $1 + AND b.origin_yard_id = $2 AND a.deleted_at IS NULL AND tsw.deleted_at IS NULL AND b.deleted_at IS NULL GROUP BY b.id, b.reference HAVING COUNT(*) FILTER (WHERE a.status IN ('LOADED', 'DEPARTED')) > 0 AND COUNT(*) FILTER (WHERE a.status NOT IN ('LOADED', 'DEPARTED')) > 0`, - [schedule.trainSetId], + [schedule.trainSetId, boardingYardId], ); if (rows.length) { const detail = rows .map((r) => `${r.reference} (${r.loaded}/${r.total} wagons loaded)`) .join(', '); + const where = context.yardLabel ? ` at ${context.yardLabel}` : ''; throw new BadRequestException( - `Cannot dispatch: booking(s) partially loaded — load every wagon or cancel the remainder first: ${detail}`, + `Cannot ${context.action}: booking(s) partially loaded${where} — load every wagon ` + + `or cancel the remainder (customer fault: cancellation fee; EDR fault: no fee, ` + + `rebookable) first: ${detail}`, ); } } + /** + * Mid-corridor twin of the dispatch gate. Logging a checkpoint at station N + * asserts the train has left every earlier stop, so each of those yards must + * have no half-loaded booking of its own left behind. The origin (seq 0) is + * skipped — dispatch already gated it — and the final station is included: + * arriving there still means the train left the stop before it. + */ + private async assertPassedYardsFullyLoaded( + schedule: TrainSchedule, + stations: Array<{ sequenceNo: number; yardId: string; label: string }>, + sequenceNo: number, + ): Promise { + const departed = stations.filter( + (st) => st.sequenceNo > 0 && st.sequenceNo < sequenceNo, + ); + for (const st of departed) { + await this.assertNoPartiallyLoadedBookings(schedule, st.yardId, { + action: 'record this checkpoint', + yardLabel: st.label, + }); + } + } + private async unloadedOriginBoarderIds( scheduleId: string, originYardId: string, @@ -3580,7 +3623,83 @@ export class TrainSchedulingService { return { wagons, unassignedBookings }; } - async intercityMarshallingDocument(scheduleId: string): Promise<{ filename: string; buffer: Buffer }> { + /** + * Every corridor stop where the consist actually changed for this schedule + * (coupled, uncoupled, or switched — any flavor), in the order the train + * reached them. Origin is never in this list — it's always its own doc (the + * plain import/export load list), so numbering here starts at 2. A stop with + * only a routine checkpoint and no consist change never gets a row, which is + * the point: "Marshalling 2, 3, 4…" tracks events, not raw stop count. + */ + async marshallingStops( + scheduleId: string, + ): Promise> { + const rows = await this.dataSource.getRepository(ScheduleWagonAdjustmentLog).find({ + where: { trainScheduleId: scheduleId }, + order: { occurredAt: 'ASC' }, + }); + const firstSeenAt = new Map(); + for (const row of rows) { + if (!row.yardId || firstSeenAt.has(row.yardId)) continue; + firstSeenAt.set(row.yardId, row.occurredAt); + } + const orderedYardIds = [...firstSeenAt.entries()] + .sort((a, b) => a[1].getTime() - b[1].getTime()) + .map(([yardId]) => yardId); + const labels = await this.yardLabelsById(orderedYardIds); + return orderedYardIds.map((yardId, i) => ({ + stopIndex: i + 2, + yardId, + yardLabel: labels.get(yardId) ?? yardId, + firstOccurredAt: firstSeenAt.get(yardId)!.toISOString(), + })); + } + + /** + * The coupled/uncoupled/switched rows for one stop, in the locked table + * shape (wagon, event, containers). "EMPTY WAGON" replaces the container + * list rather than a blank cell — the column always exists so a loaded and + * an empty coupling read as the same table, not two different layouts. + * Cargo for ADD/REMOVE rows is read off the schedule's OWN slot allocations + * for that physical wagon: an ADD is a leg slot boarding already loaded (see + * stampSlotLegs) or an empty couple (plannedWagonCouples) with none; a + * REMOVE is a slot alighting with its cargo, or an empty trim. A SWITCH row + * carries the incoming wagon's id — the slot's cargo already rides it. + */ + private consistChangesAt( + schedule: TrainSchedule, + logRows: ScheduleWagonAdjustmentLog[], + ): Array<{ wagonNumber: string; event: 'Coupled' | 'Uncoupled' | 'Switched'; containerNumbers: string }> { + const slotByPhysicalWagonId = new Map( + (schedule.trainSet?.wagons ?? []) + .filter((wagon) => wagon.physicalWagonId) + .map((wagon) => [wagon.physicalWagonId as string, wagon]), + ); + return logRows.map((row) => { + const slot = slotByPhysicalWagonId.get(row.wagonId); + const containerNumbers = (slot?.allocations ?? []) + .flatMap((allocation) => allocation.containerItems ?? []) + .map((item) => item.containerNumber) + .filter(Boolean) + .join(', '); + return { + wagonNumber: row.wagonNumber, + event: row.action === 'ADD' ? 'Coupled' : row.action === 'REMOVE' ? 'Uncoupled' : 'Switched', + containerNumbers: containerNumbers || 'EMPTY WAGON', + }; + }); + } + + /** + * The numbered marshalling document for one corridor stop (see + * marshallingStops — stopIndex 2+, origin is its own separate doc). + * ponytail: the wagon table always shows the CURRENT on-board state, not a + * point-in-time reconstruction of what stood on the train at that past + * stop — a full historical snapshot is a much bigger feature nobody has + * asked for. What's stop-specific is the consist-changes table below it, + * which IS scoped to that stop's own logged events. + */ + async marshallingDocumentAt(scheduleId: string, stopIndex: number): Promise<{ filename: string; buffer: Buffer }> { const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); if (!schedule) { throw new NotFoundException(`Train schedule ${scheduleId} not found`); @@ -3590,24 +3709,66 @@ export class TrainSchedulingService { 'Intercity marshalling document applies only to dispatched or arrived trains', ); } + const stops = await this.marshallingStops(scheduleId); + const stop = stops.find((s) => s.stopIndex === stopIndex); + if (!stop) { + throw new NotFoundException( + `No marshalling document at stop ${stopIndex} for this schedule — nothing coupled/uncoupled there, or the stop doesn't exist`, + ); + } + const { wagons, unassignedBookings } = this.intercityOnBoardView(schedule); + const logRows = await this.dataSource.getRepository(ScheduleWagonAdjustmentLog).find({ + where: { trainScheduleId: scheduleId, yardId: stop.yardId }, + order: { occurredAt: 'ASC' }, + }); + const html = this.buildExportLoadListHtml(schedule, { + title: `Intercity Marshalling Document / Load List (Marshalling ${stopIndex})`, + positionLabel: `At ${stop.yardLabel}`, + wagons, + unassignedBookings, + emptyContainers: await this.loadedEmptyContainers(scheduleId), + logoImageUrl: await this.logoSettings.getLogoImageUrl(), + consistChangesAtStop: this.consistChangesAt(schedule, logRows), + }); + // Styled table-aware fallback (marshalling grid) — see importLoadListDocument. + const buffer = await this.pdfDocuments.renderTabularDocument(html, `Marshalling ${stopIndex}`); + const reference = schedule.trainNumber ?? schedule.id; + return { + filename: `marshalling-${stopIndex}-${this.safeDocumentName(reference)}.pdf`, + buffer, + }; + } + + /** + * Back-compat alias: the single "current" intercity doc (Marshalling 2) the + * old one-document-per-schedule UI calls. Resolves to the LATEST stop with + * a logged consist change; falls back to the current-position doc with no + * changes table when nothing has coupled/uncoupled yet (e.g. right after + * dispatch, before any mid-corridor stop). + */ + async intercityMarshallingDocument(scheduleId: string): Promise<{ filename: string; buffer: Buffer }> { + const stops = await this.marshallingStops(scheduleId); + const latest = stops[stops.length - 1]; + if (latest) { + return this.marshallingDocumentAt(scheduleId, latest.stopIndex); + } + + const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); + if (!schedule) { + throw new NotFoundException(`Train schedule ${scheduleId} not found`); + } + if (schedule.status !== 'DISPATCHED' && schedule.status !== 'ARRIVED') { + throw new BadRequestException( + 'Intercity marshalling document applies only to dispatched or arrived trains', + ); + } const checkpoints = await this.trainCheckpointEventsRepository.findBySchedule(scheduleId); const last = checkpoints[checkpoints.length - 1]; const positionLabel = last ? `After ${last.yard?.label ?? last.yard?.code ?? 'checkpoint'}` : `At ${schedule.originStation?.label ?? schedule.originStation?.code ?? 'origin'} — no checkpoint recorded`; - const { wagons, unassignedBookings } = this.intercityOnBoardView(schedule); - // Couples/switches logged AT THIS STOP — what staff standing here actually - // just did to the consist. Bare trims (REMOVE, no replacement) are left - // out: nothing new to point staff at for those. Origin adjustments (a - // different yard) don't show up on this stop's document. - const consistChangesAtStop = last - ? await this.dataSource.getRepository(ScheduleWagonAdjustmentLog).find({ - where: { trainScheduleId: scheduleId, yardId: last.yardId, action: In(['ADD', 'SWITCH']) }, - order: { occurredAt: 'DESC' }, - }) - : []; const html = this.buildExportLoadListHtml(schedule, { title: 'Intercity Marshalling Document / Load List (Marshalling 2)', positionLabel, @@ -3615,7 +3776,6 @@ export class TrainSchedulingService { unassignedBookings, emptyContainers: await this.loadedEmptyContainers(scheduleId), logoImageUrl: await this.logoSettings.getLogoImageUrl(), - consistChangesAtStop, }); // Styled table-aware fallback (marshalling grid) — see importLoadListDocument. const buffer = await this.pdfDocuments.renderTabularDocument(html, 'Intercity marshalling / load list'); @@ -3685,10 +3845,14 @@ export class TrainSchedulingService { // Slots that couple to the train downstream (slot id → board yard label). // Their cargo renders as TO LOAD AT and stays out of the loaded tallies. pendingBoardYardLabelBySlot?: Map; - // Intercity (Marshalling 2) only: couples/switches logged at the stop - // this document is printed at (see ScheduleWagonAdjustmentLog). Origin - // import/export docs never pass this, so they render no such box. - consistChangesAtStop?: ScheduleWagonAdjustmentLog[]; + // Numbered marshalling docs only (see marshallingDocumentAt / + // consistChangesAt) — couples/uncouples/switches logged at THIS stop. + // Origin import/export docs never pass this, so they render no such box. + consistChangesAtStop?: Array<{ + wagonNumber: string; + event: 'Coupled' | 'Uncoupled' | 'Switched'; + containerNumbers: string; + }>; }, ): string { const esc = (value: unknown) => @@ -3853,6 +4017,7 @@ export class TrainSchedulingService { .tile span { display: block; color: #64748b; font-size: 9px; text-transform: uppercase; letter-spacing: .05em; margin-bottom: 4px; } .tile strong { font-size: 11px; } ${logoImageCss()} + h2 { margin: 16px 0 6px; font-size: 12px; color: #0f766e; text-transform: uppercase; letter-spacing: .05em; } table { width: 100%; border-collapse: collapse; } th { background: #f8fafc; color: #475569; text-align: left; } th, td { border: 1px solid #cbd5e1; padding: 5px 6px; font-size: 9.5px; vertical-align: top; } @@ -3901,19 +4066,27 @@ export class TrainSchedulingService { ${ opts?.consistChangesAtStop?.length - ? `
- Consist changed at this stop: - ${(() => { - const coupled = opts.consistChangesAtStop.filter((row) => row.action === 'ADD'); - const switched = opts.consistChangesAtStop.filter((row) => row.action === 'SWITCH'); - return [ - coupled.length ? `Coupled: ${esc(coupled.map((row) => row.wagonNumber).join(', '))}` : '', - switched.length ? `Uncoupled — replaced: ${esc(switched.map((row) => row.wagonNumber).join(', '))}` : '', - ] - .filter(Boolean) - .join('  |  '); - })()} -
` + ? `

Consist Changed At This Stop

+ + + + + + + + + + ${opts.consistChangesAtStop + .map( + (row) => ` + + + + `, + ) + .join('')} + +
Wagon NoEventContainer No
${esc(row.wagonNumber)}${esc(row.event)}${esc(row.containerNumbers)}
` : '' } @@ -4744,6 +4917,12 @@ export class TrainSchedulingService { : TrainCheckpointKind.Passed); const occurredAt = dto.occurredAt ? new Date(dto.occurredAt) : new Date(); await this.assertCheckpointTime(schedule, stations, dto.sequenceNo, occurredAt); + // Per-wagon loading, mid-corridor: recording THIS station means the train + // left the previous one, so every booking that boarded back there must be + // fully loaded or its remainder cancelled. The origin is covered by + // dispatch; here we answer for the stops between it and this one, so a + // skipped checkpoint log cannot smuggle an unresolved yard past the gate. + await this.assertPassedYardsFullyLoaded(schedule, stations, dto.sequenceNo); // Upsert by (scheduleId, sequenceNo) so re-logging a station updates rather than duplicates. const [existing] = await this.trainCheckpointEventsRepository.findAll({ @@ -4964,6 +5143,76 @@ export class TrainSchedulingService { await manager.getRepository(ScheduleWagonAdjustmentLog).save(coupleLogRows); } } + + // Leg slots (booking legs boarding/alighting mid-corridor — see + // stampSlotLegs) reaching their board/alight yard here: logged same as + // planned couples/cuts above, so the marshalling document can show + // what coupled ALREADY LOADED / uncoupled WITH cargo at this stop. + // Purely observational — their physical wagon was already pinned to + // the slot at schedule-build time (assignPhysicalWagonsToSlots), so + // nothing here changes wagon state, only the log. Dedupe against + // existing rows (not a wagon-state flag, unlike the couple/cut blocks + // above) since passedYardIds re-includes earlier stops on every call. + const legSlotsHere = (schedule.trainSet?.wagons ?? []).filter( + (slot) => + slot.physicalWagonId && + ((slot.boardYardId && passedYardIds.includes(slot.boardYardId)) || + (slot.alightYardId && passedYardIds.includes(slot.alightYardId))), + ); + if (legSlotsHere.length && builtTrainId) { + const legWagonIds = [ + ...new Set(legSlotsHere.map((slot) => slot.physicalWagonId!)), + ]; + const legWagonById = new Map( + ( + await manager.getRepository(Wagon).find({ where: { id: In(legWagonIds) } }) + ).map((w) => [w.id, w]), + ); + const alreadyLogged = new Set( + ( + await manager.getRepository(ScheduleWagonAdjustmentLog).find({ + where: { + trainScheduleId: scheduleId, + wagonId: In(legWagonIds), + action: In(['ADD', 'REMOVE']), + }, + }) + ).map((row) => `${row.wagonId}:${row.action}:${row.yardId}`), + ); + const legLogRows: ScheduleWagonAdjustmentLog[] = []; + for (const slot of legSlotsHere) { + const wagon = legWagonById.get(slot.physicalWagonId!); + if (!wagon) continue; + const events: Array<{ action: 'ADD' | 'REMOVE'; yardId: string }> = []; + if (slot.boardYardId && passedYardIds.includes(slot.boardYardId)) { + events.push({ action: 'ADD', yardId: slot.boardYardId }); + } + if (slot.alightYardId && passedYardIds.includes(slot.alightYardId)) { + events.push({ action: 'REMOVE', yardId: slot.alightYardId }); + } + for (const { action, yardId } of events) { + const key = `${wagon.id}:${action}:${yardId}`; + if (alreadyLogged.has(key)) continue; + alreadyLogged.add(key); + legLogRows.push( + manager.getRepository(ScheduleWagonAdjustmentLog).create({ + trainScheduleId: scheduleId, + trainId: builtTrainId, + action, + wagonId: wagon.id, + wagonNumber: wagon.wagonNumber, + adjustedByUserId: null, + yardId, + occurredAt, + }), + ); + } + } + if (legLogRows.length) { + await manager.getRepository(ScheduleWagonAdjustmentLog).save(legLogRows); + } + } + await manager .getRepository(Wagon) .createQueryBuilder() @@ -5496,7 +5745,11 @@ export class TrainSchedulingService { return this.getTrainScheduleById(id); } - async cancelTrainSchedule(id: string) { + async cancelTrainSchedule( + id: string, + dto?: { reason?: string }, + userId?: string, + ) { const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(id); if (!schedule) { throw new NotFoundException(`Train schedule ${id} not found`); @@ -5527,6 +5780,11 @@ export class TrainSchedulingService { TrainScheduleStatusEnum.Cancelled, now, ), + // Why the train died — read back by every view of the cancelled + // schedule, and by the staff who have to re-place its bookings. + cancellationReason: dto?.reason?.trim() || null, + cancelledAt: now, + cancelledByUserId: userId ?? null, }, manager, ); @@ -8020,6 +8278,8 @@ export class TrainSchedulingService { freightType: this.resolveScheduleFreightType(schedule), status: schedule.status, bookingWindowStatus: schedule.bookingWindowStatus ?? 'OPEN', + cancellationReason: schedule.cancellationReason ?? null, + cancelledAt: schedule.cancelledAt ?? null, maxWagons: schedule.maxWagons ?? 0, remainingWagons: Math.max( 0, @@ -9978,6 +10238,8 @@ export class TrainSchedulingService { id: schedule.id, reference: schedule.reference ?? null, status: schedule.status, + cancellationReason: schedule.cancellationReason ?? null, + cancelledAt: schedule.cancelledAt ?? null, freightType: this.resolveScheduleFreightType(schedule), trainNumber: schedule.trainNumber ?? null, voyageNumber: schedule.voyageNumber ?? null, diff --git a/apps/edr-freight-api/src/modules/transit-agents/dto/create-transit-agent.dto.ts b/apps/edr-freight-api/src/modules/transit-agents/dto/create-transit-agent.dto.ts index be3810c0b..d035c53f8 100644 --- a/apps/edr-freight-api/src/modules/transit-agents/dto/create-transit-agent.dto.ts +++ b/apps/edr-freight-api/src/modules/transit-agents/dto/create-transit-agent.dto.ts @@ -1,25 +1,34 @@ -import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { Transform } from 'class-transformer'; -import { IsBoolean, IsDateString, IsOptional, IsString, MaxLength } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { Transform } from "class-transformer"; +import { + IsBoolean, + IsDateString, + IsEmail, + IsOptional, + IsString, + MaxLength, +} from "class-validator"; + +import { IsValidPhone } from "../../../common/validators/is-phone-number.validator"; const toBoolean = ({ value }: { value: unknown }) => { - if (typeof value === 'boolean') return value; - if (value === 'true') return true; - if (value === 'false') return false; + if (typeof value === "boolean") return value; + if (value === "true") return true; + if (value === "false") return false; return value; }; export class CreateTransitAgentDto { - @ApiProperty({ maxLength: 150, example: 'Ahmed Bourhan' }) + @ApiProperty({ maxLength: 150, example: "Ahmed Bourhan" }) @IsString() @MaxLength(150) name!: string; - @ApiProperty({ example: '2026-01-01' }) + @ApiProperty({ example: "2026-01-01" }) @IsDateString() validFrom!: string; - @ApiProperty({ example: '2026-12-31' }) + @ApiProperty({ example: "2026-12-31" }) @IsDateString() validTo!: string; @@ -28,4 +37,33 @@ export class CreateTransitAgentDto { @Transform(toBoolean) @IsBoolean() isActive?: boolean; + + /** + * Becomes the IAM account's email and is where the activation link is sent. + * Optional: an agent may be created as a GL-assignable roster entry only, and + * invited later. Supplying it creates the portal account right away. + */ + @ApiPropertyOptional({ example: "a.bourhan@transit.dj" }) + @IsOptional() + @IsEmail() + @MaxLength(150) + email?: string; + + @ApiPropertyOptional({ + example: "+25377834567", + description: + "E.164. Djiboutian (+253 77…) and Ethiopian (+251 9…) mobiles also receive the activation link by SMS.", + }) + @IsOptional() + @IsString() + @MaxLength(30) + @IsValidPhone() + phoneNumber?: string; + + /** Login name. Defaults to the email, which is what the agent tries first. */ + @ApiPropertyOptional({ example: "a-bourhan" }) + @IsOptional() + @IsString() + @MaxLength(100) + username?: string; } diff --git a/apps/edr-freight-api/src/modules/transit-agents/dto/invite-transit-agent.dto.ts b/apps/edr-freight-api/src/modules/transit-agents/dto/invite-transit-agent.dto.ts new file mode 100644 index 000000000..7b317c58c --- /dev/null +++ b/apps/edr-freight-api/src/modules/transit-agents/dto/invite-transit-agent.dto.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/transit-agents/dto/update-transit-agent.dto.ts b/apps/edr-freight-api/src/modules/transit-agents/dto/update-transit-agent.dto.ts index 7e18a93da..08f28473c 100644 --- a/apps/edr-freight-api/src/modules/transit-agents/dto/update-transit-agent.dto.ts +++ b/apps/edr-freight-api/src/modules/transit-agents/dto/update-transit-agent.dto.ts @@ -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) {} diff --git a/apps/edr-freight-api/src/modules/transit-agents/entities/transit-agent.entity.ts b/apps/edr-freight-api/src/modules/transit-agents/entities/transit-agent.entity.ts index 6d0ef9158..433b6587f 100644 --- a/apps/edr-freight-api/src/modules/transit-agents/entities/transit-agent.entity.ts +++ b/apps/edr-freight-api/src/modules/transit-agents/entities/transit-agent.entity.ts @@ -1,5 +1,5 @@ -import { BaseEntity } from '@edr/api-common'; -import { Column, Entity, Index } from 'typeorm'; +import { BaseEntity } from "@edr/api-common"; +import { Column, Entity, Index } from "typeorm"; /** * Djibouti transit officer GL Djibouti may assign against a shipment's @@ -7,18 +7,43 @@ import { Column, Entity, Index } from 'typeorm'; * validity window arrive without a code change; `isActive` is the manual * suspend/reactivate switch, independent of the validity window. */ -@Entity({ schema: 'freight', name: 'transit_agents' }) -@Index(['isActive']) +@Entity({ schema: "freight", name: "transit_agents" }) +@Index(["isActive"]) export class TransitAgent extends BaseEntity { - @Column({ name: 'name', type: 'varchar', length: 150 }) + @Column({ name: "name", type: "varchar", length: 150 }) name!: string; - @Column({ name: 'valid_from', type: 'date' }) + @Column({ name: "valid_from", type: "date" }) validFrom!: string; - @Column({ name: 'valid_to', type: 'date' }) + @Column({ name: "valid_to", type: "date" }) validTo!: string; - @Column({ name: 'is_active', type: 'boolean', default: true }) + @Column({ name: "is_active", type: "boolean", default: true }) isActive!: boolean; + + /** + * The IAM account (`iam.users`, userType `individual`) that signs in to the + * portal as this agent. No FK: `iam` is a separate schema owned by the IAM + * service, and the rest of the codebase reaches it by query rather than by + * relation. + * + * NULL for every agent that exists only as a GL-assignable roster entry — + * which is all of them before this feature, and stays legal afterwards. An + * agent gains an account when staff invite it, so `userId !== null` IS the + * "has a portal login" predicate; nothing else needs to track it. + */ + @Column({ name: "user_id", type: "uuid", nullable: true }) + userId?: string | null; + + /** + * Mirrors the IAM account's email; the activation link is sent here. Nullable + * because a roster-only agent has never needed one — but an invite cannot be + * sent without it, so {@link TransitAgentsService.invite} requires it. + */ + @Column({ name: "email", type: "varchar", length: 150, nullable: true }) + email?: string | null; + + @Column({ name: "phone_number", type: "varchar", length: 30, nullable: true }) + phoneNumber?: string | null; } diff --git a/apps/edr-freight-api/src/modules/transit-agents/transit-agents.controller.ts b/apps/edr-freight-api/src/modules/transit-agents/transit-agents.controller.ts index 4f4b90c7b..d254f7982 100644 --- a/apps/edr-freight-api/src/modules/transit-agents/transit-agents.controller.ts +++ b/apps/edr-freight-api/src/modules/transit-agents/transit-agents.controller.ts @@ -10,36 +10,38 @@ import { Patch, Post, Query, -} from '@nestjs/common'; -import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +} from "@nestjs/common"; +import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger"; import { RuleEngineCreate, RuleEngineDelete, RuleEngineUpdate, RuleEngineView, -} from '../../common/rule-engine-guards'; +} from "../../common/rule-engine-guards"; -import { CreateTransitAgentDto } from './dto/create-transit-agent.dto'; -import { UpdateTransitAgentDto } from './dto/update-transit-agent.dto'; -import { TransitAgentsService } from './transit-agents.service'; +import { BackofficeResetPasswordDto } from "../auth/dto/forgot-password.dto"; +import { CreateTransitAgentDto } from "./dto/create-transit-agent.dto"; +import { InviteTransitAgentDto } from "./dto/invite-transit-agent.dto"; +import { UpdateTransitAgentDto } from "./dto/update-transit-agent.dto"; +import { TransitAgentsService } from "./transit-agents.service"; -@ApiTags('transit-agents') -@Controller('transit-agents') +@ApiTags("transit-agents") +@Controller("transit-agents") @ApiBearerAuth() export class TransitAgentsController { constructor(private readonly transitAgentsService: TransitAgentsService) {} @Get() - @RuleEngineView('transit-agents') - @ApiOperation({ summary: 'List transit agents' }) + @RuleEngineView("transit-agents") + @ApiOperation({ summary: "List transit agents" }) findAll(@Query() query: Record) { return this.transitAgentsService.findAll({ isActive: - query.isActive === 'all' + query.isActive === "all" ? undefined : query.isActive !== undefined - ? query.isActive === 'true' + ? query.isActive === "true" : undefined, page: query.page ? parseInt(query.page, 10) : undefined, pageSize: query.pageSize ? parseInt(query.pageSize, 10) : undefined, @@ -49,39 +51,77 @@ export class TransitAgentsController { } /** Active + currently valid officers — the transit-assignee assignment dropdown. */ - @Get('assignable') - @RuleEngineView('transit-agents') - @ApiOperation({ summary: 'List transit agents assignable right now (active and in-window)' }) + @Get("assignable") + @RuleEngineView("transit-agents") + @ApiOperation({ + summary: "List transit agents assignable right now (active and in-window)", + }) findAssignable() { return this.transitAgentsService.findAssignable(); } - @Get(':id') - @RuleEngineView('transit-agents') - @ApiOperation({ summary: 'Get a transit agent by ID' }) - findOne(@Param('id', ParseUUIDPipe) id: string) { + @Get(":id") + @RuleEngineView("transit-agents") + @ApiOperation({ summary: "Get a transit agent by ID" }) + findOne(@Param("id", ParseUUIDPipe) id: string) { return this.transitAgentsService.findById(id); } @Post() - @RuleEngineCreate('transit-agents') - @ApiOperation({ summary: 'Create a transit agent' }) + @RuleEngineCreate("transit-agents") + @ApiOperation({ + summary: + "Create a transit agent; with an email, also creates its portal account and sends the activation link", + }) create(@Body() dto: CreateTransitAgentDto) { - return this.transitAgentsService.create(dto); + return this.transitAgentsService.createWithInvite(dto); } - @Patch(':id') - @RuleEngineUpdate('transit-agents') - @ApiOperation({ summary: 'Update a transit agent' }) - update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateTransitAgentDto) { + /** + * The path for the roster entries already in production: they were created + * before transit agents had logins, so they get their account here rather + * than at create time. + */ + @Post(":id/invite") + @RuleEngineUpdate("transit-agents") + @ApiOperation({ + summary: + "Create a portal account for an existing transit agent and send the activation link", + }) + invite( + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: InviteTransitAgentDto, + ) { + return this.transitAgentsService.invite(id, dto); + } + + @Post(":id/resend-activation") + @RuleEngineUpdate("transit-agents") + @ApiOperation({ + summary: "Resend a transit agent's activation / password-reset link", + }) + resendActivation( + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: BackofficeResetPasswordDto, + ) { + return this.transitAgentsService.resendActivation(id, dto.channel); + } + + @Patch(":id") + @RuleEngineUpdate("transit-agents") + @ApiOperation({ summary: "Update a transit agent" }) + update( + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: UpdateTransitAgentDto, + ) { return this.transitAgentsService.update(id, dto); } - @Delete(':id') - @RuleEngineDelete('transit-agents') + @Delete(":id") + @RuleEngineDelete("transit-agents") @HttpCode(HttpStatus.NO_CONTENT) - @ApiOperation({ summary: 'Soft-delete a transit agent' }) - remove(@Param('id', ParseUUIDPipe) id: string) { + @ApiOperation({ summary: "Soft-delete a transit agent" }) + remove(@Param("id", ParseUUIDPipe) id: string) { return this.transitAgentsService.remove(id); } } diff --git a/apps/edr-freight-api/src/modules/transit-agents/transit-agents.module.ts b/apps/edr-freight-api/src/modules/transit-agents/transit-agents.module.ts index 47e655e94..425971170 100644 --- a/apps/edr-freight-api/src/modules/transit-agents/transit-agents.module.ts +++ b/apps/edr-freight-api/src/modules/transit-agents/transit-agents.module.ts @@ -1,13 +1,24 @@ -import { Module } from '@nestjs/common'; -import { TypeOrmModule } from '@nestjs/typeorm'; +import { Module } from "@nestjs/common"; +import { TypeOrmModule } from "@nestjs/typeorm"; -import { TransitAgent } from './entities/transit-agent.entity'; -import { TransitAgentsController } from './transit-agents.controller'; -import { TransitAgentsRepository } from './transit-agents.repository'; -import { TransitAgentsService } from './transit-agents.service'; +import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity"; + +import { FreightAuthModule } from "../auth/freight-auth.module"; +import { OtpModule } from "../otp/otp.module"; +import { TransitAgent } from "./entities/transit-agent.entity"; +import { TransitAgentsController } from "./transit-agents.controller"; +import { TransitAgentsRepository } from "./transit-agents.repository"; +import { TransitAgentsService } from "./transit-agents.service"; @Module({ - imports: [TypeOrmModule.forFeature([TransitAgent])], + imports: [ + // `User` is registered here so this module can create the IAM account that + // backs an invited transit agent, in the same transaction as the agent row. + TypeOrmModule.forFeature([TransitAgent, User]), + // CustomerResetService — activation links reuse the staff-triggered reset path. + FreightAuthModule, + OtpModule, + ], controllers: [TransitAgentsController], providers: [TransitAgentsRepository, TransitAgentsService], exports: [TransitAgentsRepository, TransitAgentsService], diff --git a/apps/edr-freight-api/src/modules/transit-agents/transit-agents.repository.ts b/apps/edr-freight-api/src/modules/transit-agents/transit-agents.repository.ts index 4418ad938..5ae4191eb 100644 --- a/apps/edr-freight-api/src/modules/transit-agents/transit-agents.repository.ts +++ b/apps/edr-freight-api/src/modules/transit-agents/transit-agents.repository.ts @@ -1,9 +1,14 @@ -import { BaseRepository } from '@edr/api-common'; -import { Injectable } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { LessThanOrEqual, MoreThanOrEqual, Repository } from 'typeorm'; +import { BaseRepository } from "@edr/api-common"; +import { Injectable } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { + EntityManager, + LessThanOrEqual, + MoreThanOrEqual, + Repository, +} from "typeorm"; -import { TransitAgent } from './entities/transit-agent.entity'; +import { TransitAgent } from "./entities/transit-agent.entity"; @Injectable() export class TransitAgentsRepository extends BaseRepository { @@ -22,7 +27,48 @@ export class TransitAgentsRepository extends BaseRepository { validFrom: LessThanOrEqual(today), validTo: MoreThanOrEqual(today), }, - order: { name: 'ASC' }, + order: { name: "ASC" }, }); } + + /** The transit agent signed in as `userId`, or null for any other account. */ + findByUserId(userId: string): Promise { + 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 { + 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, + ): Promise { + 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, + ): Promise { + const repo = manager.getRepository(TransitAgent); + await repo.update(id, data); + return repo.findOneOrFail({ where: { id } }); + } } diff --git a/apps/edr-freight-api/src/modules/transit-agents/transit-agents.service.spec.ts b/apps/edr-freight-api/src/modules/transit-agents/transit-agents.service.spec.ts new file mode 100644 index 000000000..3aa5e6e02 --- /dev/null +++ b/apps/edr-freight-api/src/modules/transit-agents/transit-agents.service.spec.ts @@ -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(); + }); + }); +}); diff --git a/apps/edr-freight-api/src/modules/transit-agents/transit-agents.service.ts b/apps/edr-freight-api/src/modules/transit-agents/transit-agents.service.ts index ec9c24e9d..b54f1fddb 100644 --- a/apps/edr-freight-api/src/modules/transit-agents/transit-agents.service.ts +++ b/apps/edr-freight-api/src/modules/transit-agents/transit-agents.service.ts @@ -1,17 +1,49 @@ -import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; -import { FindOptionsOrder } from 'typeorm'; +import { + BadRequestException, + ConflictException, + Injectable, + Logger, + NotFoundException, +} from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { + EUserStatus, + EUserType, +} from "@tria-plc/api-common/utils/enums/user.enum"; +// Subpath import (not the package root) so ts-jest can resolve it when this +// file lands in a spec's compile graph — same reason as backoffice.service.ts. +import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity"; +import { + DataSource, + EntityManager, + FindOptionsOrder, + Repository, +} from "typeorm"; -import { CreateTransitAgentDto } from './dto/create-transit-agent.dto'; -import { UpdateTransitAgentDto } from './dto/update-transit-agent.dto'; -import { TransitAgent } from './entities/transit-agent.entity'; -import { TransitAgentsRepository } from './transit-agents.repository'; +import { CustomerResetService } from "../auth/customer-reset.service"; +import { ResetChannel } from "../auth/dto/forgot-password.dto"; +import { isDomesticPhone } from "../otp/otp.service"; +import { CreateTransitAgentDto } from "./dto/create-transit-agent.dto"; +import { InviteTransitAgentDto } from "./dto/invite-transit-agent.dto"; +import { UpdateTransitAgentDto } from "./dto/update-transit-agent.dto"; +import { TransitAgent } from "./entities/transit-agent.entity"; +import { TransitAgentsRepository } from "./transit-agents.repository"; -export type TransitAgentValidityStatus = 'VALID' | 'NOT_STARTED' | 'EXPIRED'; +export type TransitAgentValidityStatus = "VALID" | "NOT_STARTED" | "EXPIRED"; export type TransitAgentView = TransitAgent & { validityStatus: TransitAgentValidityStatus; + /** True once an IAM account backs this agent — i.e. it can sign in. */ + hasAccount: boolean; }; +export interface InvitedTransitAgent { + agent: TransitAgentView; + /** Masked destination of the activation link, or null if none was sent. */ + activationSentTo: string | null; + activationChannel: ResetChannel | null; +} + type TransitAgentListFilter = { isActive?: boolean; page?: number; @@ -25,20 +57,34 @@ function todayISODate(): string { return new Date().toISOString().slice(0, 10); } -function validityStatus(agent: Pick): TransitAgentValidityStatus { +function validityStatus( + agent: Pick, +): TransitAgentValidityStatus { const today = todayISODate(); - if (today < agent.validFrom) return 'NOT_STARTED'; - if (today > agent.validTo) return 'EXPIRED'; - return 'VALID'; + if (today < agent.validFrom) return "NOT_STARTED"; + if (today > agent.validTo) return "EXPIRED"; + return "VALID"; } function withValidityStatus(agent: TransitAgent): TransitAgentView { - return { ...agent, validityStatus: validityStatus(agent) }; + return { + ...agent, + validityStatus: validityStatus(agent), + hasAccount: Boolean(agent.userId), + }; } @Injectable() export class TransitAgentsService { - constructor(private readonly transitAgentsRepository: TransitAgentsRepository) {} + private readonly logger = new Logger(TransitAgentsService.name); + + constructor( + private readonly transitAgentsRepository: TransitAgentsRepository, + @InjectRepository(User) + private readonly userRepository: Repository, + private readonly customerResetService: CustomerResetService, + private readonly dataSource: DataSource, + ) {} async findAll(filter: TransitAgentListFilter = {}): Promise<{ data: TransitAgentView[]; @@ -46,10 +92,13 @@ export class TransitAgentsService { }> { const page = filter.page ?? 1; const pageSize = filter.pageSize ?? 500; - const sortBy = ['name', 'validFrom', 'validTo', 'isActive'].includes(filter.sortBy ?? '') + const sortBy = ["name", "validFrom", "validTo", "isActive"].includes( + filter.sortBy ?? "", + ) ? (filter.sortBy as keyof TransitAgent) - : 'name'; - const sortOrder = filter.sortOrder?.toUpperCase() === 'DESC' ? 'DESC' : 'ASC'; + : "name"; + const sortOrder = + filter.sortOrder?.toUpperCase() === "DESC" ? "DESC" : "ASC"; const [data, total] = await this.transitAgentsRepository.findAndCount({ where: filter.isActive === undefined ? {} : { isActive: filter.isActive }, @@ -86,12 +135,14 @@ export class TransitAgentsService { async getAssignable(id: string): Promise { const agent = await this.transitAgentsRepository.findById(id); if (!agent) { - throw new BadRequestException('Selected transit officer was not found.'); + throw new BadRequestException("Selected transit officer was not found."); } if (!agent.isActive) { - throw new BadRequestException(`${agent.name} is suspended — pick another transit officer.`); + throw new BadRequestException( + `${agent.name} is suspended — pick another transit officer.`, + ); } - if (validityStatus(agent) !== 'VALID') { + if (validityStatus(agent) !== "VALID") { throw new BadRequestException( `${agent.name}'s validity window has expired — pick another transit officer or extend their dates.`, ); @@ -99,38 +150,364 @@ export class TransitAgentsService { return agent; } - async create(dto: CreateTransitAgentDto): Promise { - if (dto.validTo < dto.validFrom) { - throw new BadRequestException('Valid-to date must be on or after valid-from date.'); + /** + * Create an IAM account for a transit agent, inside the caller's transaction. + * + * Follows `ShippingLineCompaniesService.register` — same entities, same shape + * — including its one deliberate difference from employee creation: no + * `UserCredential` row is written and `hasSetPassword` stays false, so the + * agent must come through the activation link. Staff never handle a password. + */ + private async createIamAccount( + manager: EntityManager, + args: { + name: string; + email: string; + username: string; + phoneNumber?: string; + }, + ): Promise { + 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 { + return (await this.createWithInvite(dto)).agent; + } + + /** {@link create}, also reporting where the activation link went. */ + async createWithInvite( + dto: CreateTransitAgentDto, + ): Promise { + if (dto.validTo < dto.validFrom) { + throw new BadRequestException( + "Valid-to date must be on or after valid-from date.", + ); + } + + const base = { name: dto.name.trim(), validFrom: dto.validFrom, validTo: dto.validTo, isActive: dto.isActive ?? true, + }; + + if (!dto.email) { + // Roster-only agent — no account, nothing to send. + const agent = await this.transitAgentsRepository.create(base); + return { + agent: withValidityStatus(agent), + activationSentTo: null, + activationChannel: null, + }; + } + + const { email, username, phoneNumber } = await this.prepareAccountFields({ + email: dto.email, + phoneNumber: dto.phoneNumber, + username: dto.username, }); - return withValidityStatus(agent); + + const agent = await this.dataSource.transaction(async (manager) => { + const userId = await this.createIamAccount(manager, { + name: base.name, + email, + username, + phoneNumber, + }); + return this.transitAgentsRepository.createInTransaction(manager, { + ...base, + userId, + email, + phoneNumber: phoneNumber ?? null, + }); + }); + + // Outside the transaction on purpose: a delivery failure must not roll back + // a registered agent. The link is resendable, and the account is already + // valid without it. + const activation = await this.sendActivationLink(agent); + return { + agent: withValidityStatus(agent), + activationSentTo: activation?.maskedTarget ?? null, + activationChannel: activation?.channel ?? null, + }; } - async update(id: string, dto: UpdateTransitAgentDto): Promise { + /** + * 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 { + 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 { + return this.transitAgentsRepository.findByUserId(userId); + } + + async update( + id: string, + dto: UpdateTransitAgentDto, + ): Promise { const current = await this.findById(id); const nextValidFrom = dto.validFrom ?? current.validFrom; const nextValidTo = dto.validTo ?? current.validTo; if (nextValidTo < nextValidFrom) { - throw new BadRequestException('Valid-to date must be on or after valid-from date.'); + throw new BadRequestException( + "Valid-to date must be on or after valid-from date.", + ); + } + + // `username` only ever names an IAM account, and it is chosen once at + // account creation. Accepting it here (PartialType inherits it from the + // create DTO) would write a column that does not exist on this table. + const { username: _ignoredUsername, email, phoneNumber, ...rest } = dto; + + const contact: Partial = {}; + if (email !== undefined) { + const normalized = email.trim().toLowerCase(); + if (await this.transitAgentsRepository.existsByEmail(normalized, id)) { + throw new ConflictException( + `A transit agent with email ${normalized} already exists`, + ); + } + contact.email = normalized; + } + if (phoneNumber !== undefined) { + contact.phoneNumber = phoneNumber.trim() || null; } const updated = await this.transitAgentsRepository.update(id, { - ...dto, + ...rest, + ...contact, ...(dto.name ? { name: dto.name.trim() } : {}), }); if (!updated) { throw new NotFoundException(`Transit agent ${id} not found`); } + + // Keep the IAM account in step. Without this, an agent whose address was + // corrected here would still receive its activation link at the old one — + // the reset service reads the address off `iam.users`, not off this row. + if ( + updated.userId && + (contact.email !== undefined || contact.phoneNumber !== undefined) + ) { + await this.syncIamContact(updated); + } + return withValidityStatus(updated); } + /** + * Mirror an edited email/phone onto the linked IAM account. + * + * Best-effort: a failure here must not fail the agent edit that already + * committed, but it does mean the two are out of step, so it is logged loudly + * rather than swallowed. Re-running the edit retries it. + */ + private async syncIamContact(agent: TransitAgent): Promise { + 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 { await this.findById(id); await this.transitAgentsRepository.softDelete(id); diff --git a/apps/edr-freight-api/src/modules/transit-assignments/dto/create-transit-assignment.dto.ts b/apps/edr-freight-api/src/modules/transit-assignments/dto/create-transit-assignment.dto.ts new file mode 100644 index 000000000..a21f75dd9 --- /dev/null +++ b/apps/edr-freight-api/src/modules/transit-assignments/dto/create-transit-assignment.dto.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/transit-assignments/dto/my-assignments-query.dto.ts b/apps/edr-freight-api/src/modules/transit-assignments/dto/my-assignments-query.dto.ts new file mode 100644 index 000000000..4c7089780 --- /dev/null +++ b/apps/edr-freight-api/src/modules/transit-assignments/dto/my-assignments-query.dto.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/transit-assignments/dto/submit-transit-assignment.dto.ts b/apps/edr-freight-api/src/modules/transit-assignments/dto/submit-transit-assignment.dto.ts new file mode 100644 index 000000000..f6e91cd31 --- /dev/null +++ b/apps/edr-freight-api/src/modules/transit-assignments/dto/submit-transit-assignment.dto.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/transit-assignments/dto/transit-assignment-query.dto.ts b/apps/edr-freight-api/src/modules/transit-assignments/dto/transit-assignment-query.dto.ts new file mode 100644 index 000000000..166306f2a --- /dev/null +++ b/apps/edr-freight-api/src/modules/transit-assignments/dto/transit-assignment-query.dto.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/transit-assignments/dto/update-transit-assignment.dto.ts b/apps/edr-freight-api/src/modules/transit-assignments/dto/update-transit-assignment.dto.ts new file mode 100644 index 000000000..2dda6b34e --- /dev/null +++ b/apps/edr-freight-api/src/modules/transit-assignments/dto/update-transit-assignment.dto.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/transit-assignments/entities/transit-assignment.entity.ts b/apps/edr-freight-api/src/modules/transit-assignments/entities/transit-assignment.entity.ts new file mode 100644 index 000000000..dd7f07ac7 --- /dev/null +++ b/apps/edr-freight-api/src/modules/transit-assignments/entities/transit-assignment.entity.ts @@ -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"; diff --git a/apps/edr-freight-api/src/modules/transit-assignments/transit-assignments.controller.ts b/apps/edr-freight-api/src/modules/transit-assignments/transit-assignments.controller.ts new file mode 100644 index 000000000..76dd0eb9c --- /dev/null +++ b/apps/edr-freight-api/src/modules/transit-assignments/transit-assignments.controller.ts @@ -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); + } +} diff --git a/apps/edr-freight-api/src/modules/transit-assignments/transit-assignments.module.ts b/apps/edr-freight-api/src/modules/transit-assignments/transit-assignments.module.ts new file mode 100644 index 000000000..65edc5868 --- /dev/null +++ b/apps/edr-freight-api/src/modules/transit-assignments/transit-assignments.module.ts @@ -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 {} diff --git a/apps/edr-freight-api/src/modules/transit-assignments/transit-assignments.repository.ts b/apps/edr-freight-api/src/modules/transit-assignments/transit-assignments.repository.ts new file mode 100644 index 000000000..9030d58f7 --- /dev/null +++ b/apps/edr-freight-api/src/modules/transit-assignments/transit-assignments.repository.ts @@ -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 { + constructor( + @InjectRepository(TransitAssignment) + private readonly assignmentsRepo: Repository, + ) { + 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, + 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, + 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 { + 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 { + return this.baseQuery() + .andWhere("ta.transitAgentId = :transitAgentId", { transitAgentId }) + .orderBy("ta.assignedAt", "DESC") + .getMany(); + } + + /** Every live assignment on one booking. */ + findByBooking(bookingId: string): Promise { + 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 { + 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; + } +} diff --git a/apps/edr-freight-api/src/modules/transit-assignments/transit-assignments.service.spec.ts b/apps/edr-freight-api/src/modules/transit-assignments/transit-assignments.service.spec.ts new file mode 100644 index 000000000..f23a8426a --- /dev/null +++ b/apps/edr-freight-api/src/modules/transit-assignments/transit-assignments.service.spec.ts @@ -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 = {}) => + ({ + 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[]) => { + 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); + }); + }); +}); diff --git a/apps/edr-freight-api/src/modules/transit-assignments/transit-assignments.service.ts b/apps/edr-freight-api/src/modules/transit-assignments/transit-assignments.service.ts new file mode 100644 index 000000000..389e08fac --- /dev/null +++ b/apps/edr-freight-api/src/modules/transit-assignments/transit-assignments.service.ts @@ -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, + 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 { + 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 { + 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 { + 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(); + + 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 { + 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 { + await this.findMineById(userId, id); + } + + async uploadMyFiles( + userId: string, + id: string, + files: Express.Multer.File[], + uploader: { userId?: string; name?: string }, + titles?: string[], + ): Promise { + await this.assertMine(userId, id); + return this.uploadFiles(id, files, uploader, titles); + } + + async removeMyFile( + userId: string, + id: string, + fileId: string, + ): Promise { + 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 { + 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 { + 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 { + const current = await this.assignmentsRepository.findOneWithRelations(id); + if (!current) { + throw new NotFoundException(`Transit assignment ${id} not found`); + } + + const patch: Partial = {}; + 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 { + 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 { + 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 { + 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 { + 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); + } +} diff --git a/apps/edr-freight-api/src/modules/warehouses/container-stack-placement.spec.ts b/apps/edr-freight-api/src/modules/warehouses/container-stack-placement.spec.ts new file mode 100644 index 000000000..6a9d2ccde --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/container-stack-placement.spec.ts @@ -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, occupiedLevels: number[], slotTakenBy: string | null = null) { + const service = Object.create(WarehousePlacementService.prototype) as Record; + 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; + 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; + 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 }); + }); +}); diff --git a/apps/edr-freight-api/src/modules/warehouses/delete-warehouse-guard.spec.ts b/apps/edr-freight-api/src/modules/warehouses/delete-warehouse-guard.spec.ts new file mode 100644 index 000000000..c87f59886 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/delete-warehouse-guard.spec.ts @@ -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; + 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(); + }); +}); diff --git a/apps/edr-freight-api/src/modules/warehouses/delete-yard-zone-guards.spec.ts b/apps/edr-freight-api/src/modules/warehouses/delete-yard-zone-guards.spec.ts new file mode 100644 index 000000000..6cd0001e3 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/delete-yard-zone-guards.spec.ts @@ -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; + 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; + 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); + }); +}); diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse.dto.ts index a99ca4f46..3928b6118 100644 --- a/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse.dto.ts +++ b/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse.dto.ts @@ -1,7 +1,14 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { IsEnum, IsNumber, IsOptional, IsString, IsUUID, Matches, MaxLength, Min } from 'class-validator'; -import { WAREHOUSE_STATUSES, WAREHOUSE_TYPES, WarehouseStatus, WarehouseType } from '../entities/warehouse.entity'; +import { + FREIGHT_TYPES, + FreightType, + WAREHOUSE_STATUSES, + WAREHOUSE_TYPES, + WarehouseStatus, + WarehouseType, +} from '../entities/warehouse.entity'; export class CreateWarehouseDto { @ApiProperty() @@ -19,6 +26,11 @@ export class CreateWarehouseDto { @IsEnum(WAREHOUSE_TYPES) type!: WarehouseType; + @ApiPropertyOptional({ enum: FREIGHT_TYPES, description: 'Omit for a warehouse that takes both.' }) + @IsOptional() + @IsEnum(FREIGHT_TYPES) + freightType?: FreightType; + @ApiPropertyOptional({ format: 'uuid' }) @IsOptional() @IsUUID() diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/move-inventory.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/move-inventory.dto.ts index 1aae7896f..c34c5fd61 100644 --- a/apps/edr-freight-api/src/modules/warehouses/dto/move-inventory.dto.ts +++ b/apps/edr-freight-api/src/modules/warehouses/dto/move-inventory.dto.ts @@ -14,6 +14,14 @@ export class MoveInventoryDto { @IsUUID() zoneId!: string; + @ApiPropertyOptional({ + format: 'uuid', + description: 'Exact physical slot in the destination zone. Container yards only.', + }) + @IsOptional() + @IsUUID() + slotId?: string; + @ApiPropertyOptional() @IsOptional() @IsString() diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/placement.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/placement.dto.ts new file mode 100644 index 000000000..55c5a2d8f --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/dto/placement.dto.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/register-backlog.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/register-backlog.dto.ts new file mode 100644 index 000000000..0c8ecde4b --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/dto/register-backlog.dto.ts @@ -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[]; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/store-inventory.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/store-inventory.dto.ts index 08b15d536..1bdf1630a 100644 --- a/apps/edr-freight-api/src/modules/warehouses/dto/store-inventory.dto.ts +++ b/apps/edr-freight-api/src/modules/warehouses/dto/store-inventory.dto.ts @@ -22,6 +22,15 @@ export class StoreInventoryDto { @IsUUID() zoneId?: string; + @ApiPropertyOptional({ + format: 'uuid', + description: + 'Exact physical slot. Container yards only; omit to let the placement engine pick the lowest free level.', + }) + @IsOptional() + @IsUUID() + slotId?: string; + @ApiPropertyOptional() @IsOptional() @IsString() diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/warehouse-zone-stack.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/warehouse-zone-stack.dto.ts new file mode 100644 index 000000000..4006d6140 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/dto/warehouse-zone-stack.dto.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-inventory.entity.ts b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-inventory.entity.ts index b2c4ca3c4..61c810b7f 100644 --- a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-inventory.entity.ts +++ b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-inventory.entity.ts @@ -7,6 +7,8 @@ import { Container } from '../../container-management/entities/container.entity' import { Warehouse } from './warehouse.entity'; import { WarehouseYard } from './warehouse-yard.entity'; import { WarehouseZone } from './warehouse-zone.entity'; +import { WarehouseZoneSlot } from './warehouse-zone-slot.entity'; +import { WarehouseZoneStack } from './warehouse-zone-stack.entity'; // Lifecycle. Supersedes the Batch 1 set // (ARRIVED_AT_WAREHOUSE / UNDER_INSPECTION / READY_FOR_LOADING) — migrated in place. @@ -50,6 +52,23 @@ export const WAREHOUSE_INVENTORY_TRANSITIONS: Record WarehouseZoneStack, { nullable: true }) + @JoinColumn({ name: 'stack_id' }) + stack?: WarehouseZoneStack | null; + + @Column({ name: 'slot_id', type: 'uuid', nullable: true }) + slotId?: string | null; + + @ManyToOne(() => WarehouseZoneSlot, { nullable: true }) + @JoinColumn({ name: 'slot_id' }) + slot?: WarehouseZoneSlot | null; + @Column({ name: 'booking_id', type: 'uuid', nullable: true }) bookingId?: string | null; @@ -105,6 +145,22 @@ export class WarehouseInventory extends BaseEntity { @Column({ name: 'goods_id', type: 'uuid', nullable: true }) goodsId?: string | null; + /** + * Registered as backlog: the box was already in the yard before the system + * knew about it. `arrivedAt` is the true, backdated arrival, but no storage + * or demurrage accrues — see WarehouseFeeService.previewForInventory. + */ + @Column({ name: 'backlog_registration', type: 'boolean', default: false }) + backlogRegistration!: boolean; + + /** Owner of a row with no booking to inherit one from. */ + @Column({ name: 'company_id', type: 'uuid', nullable: true }) + companyId?: string | null; + + /** Owner as text — a company that is not a registered customer yet. */ + @Column({ name: 'company_name', type: 'varchar', length: 200, nullable: true }) + companyName?: string | null; + @Column({ name: 'quantity', type: 'numeric', precision: 12, scale: 3, default: 0 }) quantity!: number; diff --git a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-zone-slot.entity.ts b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-zone-slot.entity.ts new file mode 100644 index 000000000..d783ff300 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-zone-slot.entity.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-zone-stack.entity.ts b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-zone-stack.entity.ts new file mode 100644 index 000000000..25add0f8e --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-zone-stack.entity.ts @@ -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[]; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse.entity.ts b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse.entity.ts index 267fcc8af..a67986ad1 100644 --- a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse.entity.ts +++ b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse.entity.ts @@ -1,12 +1,16 @@ import { BaseEntity } from '@edr/api-common'; import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm'; +import { FREIGHT_TYPES, FreightType } from '../../bookings/entities/booking.entity'; import { Facility } from '../../facilities/entities/facility.entity'; import { WarehouseYard } from './warehouse-yard.entity'; export const WAREHOUSE_TYPES = ['OPEN_WAREHOUSE', 'CLOSED_WAREHOUSE'] as const; export type WarehouseType = (typeof WAREHOUSE_TYPES)[number]; +export { FREIGHT_TYPES }; +export type { FreightType }; + export const WAREHOUSE_STATUSES = ['ACTIVE', 'INACTIVE'] as const; export type WarehouseStatus = (typeof WAREHOUSE_STATUSES)[number]; @@ -25,6 +29,14 @@ export class Warehouse extends BaseEntity { @Column({ name: 'type', type: 'varchar', length: 32 }) type!: WarehouseType; + /** + * What the warehouse handles. Null means unrestricted — the pre-existing + * behaviour for every warehouse created before this field existed, so it + * never narrows an already-configured site. + */ + @Column({ name: 'freight_type', type: 'varchar', length: 16, nullable: true }) + freightType?: FreightType | null; + @Column({ name: 'station_id', type: 'uuid', nullable: true }) stationId?: string | null; diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts index 576933c01..c59cd0617 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts @@ -12,6 +12,8 @@ import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; interface ItemAttributes { arrivedAt: Date | null; + /** Backlog-registered box: real arrival on the record, but never billed. */ + backlogRegistration: boolean; gateClearedAt: Date | null; releaseDate: Date | null; freightType: string | null; @@ -260,6 +262,7 @@ export class WarehouseFeeService { private async loadItem(inventoryId: string): Promise { const [row] = await this.dataSource.query( `SELECT inv.arrived_at AS "arrivedAt", + inv.backlog_registration AS "backlogRegistration", inv.gate_cleared_at AS "gateClearedAt", inv.release_date AS "releaseDate", inv.quantity AS "inventoryQuantity", @@ -777,6 +780,13 @@ export class WarehouseFeeService { /** Preview demurrage + storage fees for an inventory item using the most specific active rules. */ async previewForInventory(inventoryId: string, billingCurrency = 'USD'): Promise { const item = await this.loadItem(inventoryId); + + // A backlog registration carries a backdated arrival so the record is + // honest about how long the box has sat, but it was never booked through + // EDR and is not billed for that history. No rule applies, so no preview — + // which also keeps it off the invoice, since invoicing reads this same list. + if (item.backlogRegistration) return []; + const rules = await this.feeRuleRepository.findAll({ where: { isActive: true } }); const now = new Date(); @@ -894,6 +904,8 @@ export class WarehouseFeeService { trucks.map(async (t) => { const item: ItemAttributes = { arrivedAt: null, + // Truck detention is a per-truck charge, never a warehouse backlog row. + backlogRegistration: false, gateClearedAt: null, releaseDate: null, freightType: leg.freightType ?? null, diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts index 19d3b4b7a..15a3f6c4a 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts @@ -14,8 +14,13 @@ import { FilterWarehouseInventoryDto } from './dto/filter-inventory.dto'; import { InquiryWarehouseInventoryDto } from './dto/inquiry-inventory.dto'; import { LoadInventoryDto } from './dto/load-inventory.dto'; import { MoveInventoryDto } from './dto/move-inventory.dto'; +import { AssignSlotDto, FindAvailableSlotDto } from './dto/placement.dto'; import { StoreInventoryDto } from './dto/store-inventory.dto'; import { ReceiveWarehouseInventoryDto } from './dto/receive-inventory.dto'; +import { + BulkRegisterBacklogDto, + RegisterBacklogContainerDto, +} from './dto/register-backlog.dto'; import { ApproveDeliveryDto } from './dto/approve-delivery.dto'; import { SetDoubleHandlingDto } from './dto/double-handling.dto'; import { ReleaseOrderDto } from './dto/release-order.dto'; @@ -143,6 +148,25 @@ export class WarehouseInventoryController { return this.inventoryService.eligibleBookings(dir); } + @Post('register-backlog') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.receive) + @ApiOperation({ + summary: 'Register one loaded container already in the yard but never entered in the system', + }) + registerBacklog(@Body() dto: RegisterBacklogContainerDto, @CurrentUser() user: TCurrentUser) { + dto.performedBy = actorLabel(user) ?? dto.performedBy; + return this.inventoryService.registerBacklogContainer(dto); + } + + @Post('register-backlog-bulk') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.receive) + @ApiOperation({ summary: 'Bulk-register loaded containers already in the yard (Excel backlog)' }) + registerBacklogBulk(@Body() dto: BulkRegisterBacklogDto, @CurrentUser() user: TCurrentUser) { + const performedBy = actorLabel(user); + dto.containers.forEach((c) => (c.performedBy = performedBy ?? c.performedBy)); + return this.inventoryService.bulkRegisterBacklogContainers(dto); + } + @Post('receive-bulk') @BookingStaff(FREIGHT_PERMS.warehouseInventory.receive) @ApiOperation({ summary: 'Bulk-receive selected eligible PAID bookings into a location' }) @@ -369,6 +393,50 @@ export class WarehouseInventoryController { return this.inventoryService.move(id, dto); } + @Post('placement/find-slot') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.move) + @ApiOperation({ + summary: 'Lowest free stack level for a container yard', + description: 'Read-only preview of where the placement engine would put the next container.', + }) + findAvailableSlot(@Body() dto: FindAvailableSlotDto) { + return this.inventoryService.findAvailableSlot(dto); + } + + @Post(':id/assign-slot') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.move) + @ApiOperation({ + summary: 'Place inventory at an exact stack level', + description: 'Omit slotId to take the lowest free level in the item\'s current zone.', + }) + assignSlot( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: AssignSlotDto, + @CurrentUser() user: TCurrentUser, + ) { + return this.inventoryService.assignSlot(id, dto.slotId, actorLabel(user)); + } + + @Post(':id/release-slot') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.move) + @ApiOperation({ + summary: 'Take inventory off its stack level', + description: 'Refused while other containers are stacked on top of it.', + }) + releaseSlot(@Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: TCurrentUser) { + return this.inventoryService.releaseSlot(id, actorLabel(user)); + } + + @Get(':id/accessibility') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.view) + @ApiOperation({ + summary: 'Can this container be lifted out', + description: 'Lists the containers stacked above it. Nothing is moved.', + }) + accessibility(@Param('id', ParseUUIDPipe) id: string) { + return this.inventoryService.getContainerAccessibility(id); + } + @Post(':id/store') @BookingStaff(FREIGHT_PERMS.warehouseInventory.move) @ApiOperation({ summary: 'Mark received inventory as STORED (optional explicit warehouse/yard/zone)' }) diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts index 126998bae..aa032c17d 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts @@ -50,6 +50,10 @@ import { InquiryWarehouseInventoryDto } from './dto/inquiry-inventory.dto'; import { LoadInventoryDto } from './dto/load-inventory.dto'; import { MoveInventoryDto } from './dto/move-inventory.dto'; import { ReceiveWarehouseInventoryDto } from './dto/receive-inventory.dto'; +import { + BulkRegisterBacklogDto, + RegisterBacklogContainerDto, +} from './dto/register-backlog.dto'; import { ReleaseOrderDto } from './dto/release-order.dto'; import { ReserveInventoryDto } from './dto/reserve-inventory.dto'; import { UnloadBookingDto } from './dto/unload-booking.dto'; @@ -70,6 +74,7 @@ import { Warehouse } from './entities/warehouse.entity'; import { SchedulingReadFacade } from './scheduling-read.facade'; import { WarehouseActivityLogService } from './warehouse-activity-log.service'; import { WarehouseInventoryRepository } from './warehouse-inventory.repository'; +import { WarehousePlacementService } from './warehouse-placement.service'; import { WarehouseLoadingRepository } from './warehouse-loading.repository'; import { WarehouseReleaseDocumentService } from './warehouse-release-document.service'; import { HandoverService } from './handover.service'; @@ -412,6 +417,7 @@ export class WarehouseInventoryService { private readonly activityLog: WarehouseActivityLogService, private readonly scheduling: SchedulingReadFacade, private readonly allocation: WarehouseAllocationService, + private readonly placement: WarehousePlacementService, private readonly invoices: WarehouseInvoiceService, private readonly inspectionService: WarehouseInspectionService, private readonly releaseDocuments: WarehouseReleaseDocumentService, @@ -2863,6 +2869,136 @@ export class WarehouseInventoryService { await this.lastMileService.acceptBooking(booking.reference); } + /** + * Register a loaded container that is already physically in a yard but was + * never entered in the system. Unlike receive(), there is no booking, no + * truck entrance to record (nobody remembers the driver of a box that has sat + * for months) and the arrival is backdated to when it actually turned up. + * + * The row is flagged `backlogRegistration`, which keeps the fee engine off it + * entirely — see WarehouseFeeService.previewForInventory. Capacity is still + * charged, because the box does occupy the yard. + */ + async registerBacklogContainer(dto: RegisterBacklogContainerDto): Promise { + 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 { + 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 { + const numbers = dto.containers.map((c) => c.containerNumber.trim().toUpperCase()); + const seen = new Set(); + 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 { const bookingDirection = dto.bookingId ? await this.getBookingDirection(dto.bookingId) : null; await this.assertExportBookingPaid(dto.bookingId, bookingDirection); @@ -2996,12 +3132,37 @@ export class WarehouseInventoryService { if ( item.warehouseId === dto.warehouseId && item.yardId === dto.yardId && - item.zoneId === dto.zoneId + item.zoneId === dto.zoneId && + (item.slotId ?? null) === (dto.slotId ?? null) ) { throw new BadRequestException('Destination location is the same as current location'); } const { warehouse, yard, zone } = await this.validateLocation(manager, dto); + + // Taking a box out of a stack is physically impossible while others stand + // on top of it — the same rule the release path enforces. Moving is one of + // those exits, so it is checked here rather than only at release. + if (item.slotId) { + await this.placement.assertAccessible(item.id, manager); + } + + // A move that names a slot is validated against the hierarchy it claims; + // one that does not clears the old slot, because the box has left it. + if (dto.slotId) { + await this.placement.validateSlotForInventory( + { + slotId: dto.slotId, + warehouseId: dto.warehouseId, + yardId: dto.yardId, + zoneId: dto.zoneId, + inventoryId: item.id, + quantity: Number(item.quantity) || 0, + }, + manager, + ); + } + const weight = Number(item.weight) || 0; const containerCount = item.containerId ? Math.round(Number(item.quantity) || 0) : 0; @@ -3011,7 +3172,12 @@ export class WarehouseInventoryService { if (item.yardId !== dto.yardId) { this.assertCapacity('Yard', yard, weight, Number(item.volume) || 0, containerCount); } - this.assertCapacity('Zone', zone, weight, Number(item.volume) || 0, containerCount); + // Skipped when the zone is unchanged: a slot-to-slot reshuffle inside one + // zone adds nothing to it, and a full zone would otherwise refuse to let + // its own containers be restacked. + if (item.zoneId !== dto.zoneId) { + this.assertCapacity('Zone', zone, weight, Number(item.volume) || 0, containerCount); + } await this.applyCapacityDelta( manager, @@ -3030,6 +3196,14 @@ export class WarehouseInventoryService { item.warehouseId = dto.warehouseId; item.yardId = dto.yardId; item.zoneId = dto.zoneId; + if (dto.slotId) { + const slot = await this.placement.resolveSlot(dto.slotId, manager); + item.stackId = slot.stackId; + item.slotId = slot.slotId; + } else { + item.stackId = null; + item.slotId = null; + } if (dto.remarks?.trim()) { const existingNotes = item.notes?.trim(); item.notes = existingNotes @@ -3044,12 +3218,171 @@ export class WarehouseInventoryService { return this.findById(movedId); } + // ── Physical slot placement ────────────────────────────────────────────── + + /** + * Pin an inventory item to an exact stack level, or let the placement engine + * pick the lowest free one. Transactional and locked: the row cannot be moved + * out from under the placement between validation and write. + */ + async assignSlot(id: string, slotId?: string, performedBy?: string): Promise { + 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 { + await this.dataSource.transaction(async (manager) => { + const item = await manager.getRepository(WarehouseInventory).findOne({ + where: { id }, + lock: { mode: 'pessimistic_write' }, + }); + if (!item) throw new NotFoundException(`Inventory item ${id} not found`); + if (!item.slotId) return; + + // Nothing may be standing on top of it — freeing a buried box would leave + // the containers above it floating over an empty level. + await this.placement.assertAccessible(id, manager); + + await manager.getRepository(WarehouseInventory).update(id, { stackId: null, slotId: null }); + await this.activityLog.record( + { + activityType: 'INVENTORY_MOVED', + inventoryId: id, + warehouseId: item.warehouseId, + description: 'Released from its stack level', + performedBy, + }, + manager, + ); + }); + + return this.findById(id); + } + + /** Whether the box can be lifted out, and what is stacked on top of it if not. */ + getContainerAccessibility(id: string) { + return this.placement.getContainerAccessibility(id); + } + + /** Lowest free stack level for the given yard/zone, without assigning it. */ + findAvailableSlot(input: { + yardId: string; + zoneId?: string; + direction?: string; + cargoTypeId?: string; + }) { + return this.placement.findAvailableContainerSlot(input); + } + + /** + * The physical position an item should take when it is stored. + * + * An explicitly chosen slot is validated and any failure is surfaced — the + * operator asked for that exact level. The automatic path is best-effort: + * a yard with no stacks configured yet, or one that is full, falls back to + * plain zone-level storage rather than blocking a store that worked before + * this model existed. + */ + private async resolveStoragePlacement( + manager: EntityManager, + item: WarehouseInventory, + location: LocationRef, + options: { slotId?: string; direction?: string | null }, + ): Promise<{ stackId: string; slotId: string; label: string } | null> { + const yard = await manager.getRepository(WarehouseYard).findOne({ where: { id: location.yardId } }); + if (yard?.type !== 'CONTAINER_YARD') return null; + + if (options.slotId) { + const slot = await this.placement.validateSlotForInventory( + { + slotId: options.slotId, + warehouseId: location.warehouseId, + yardId: location.yardId, + zoneId: location.zoneId, + inventoryId: item.id, + quantity: Number(item.quantity) || 0, + }, + manager, + ); + return { stackId: slot.stackId, slotId: slot.slotId, label: `${slot.stackCode} level ${slot.level}` }; + } + + // A row still covering several containers has no single position to take. + if ((Number(item.quantity) || 0) > 1) return null; + + try { + const found = await this.placement.findAvailableContainerSlot( + { yardId: location.yardId, zoneId: location.zoneId, direction: options.direction }, + manager, + ); + return found + ? { stackId: found.stackId, slotId: found.slotId, label: `${found.stackCode} level ${found.level}` } + : null; + } catch (error) { + this.logger.debug( + `Automatic slot placement skipped for inventory ${item.id}: ${(error as Error).message}`, + ); + return null; + } + } + // ── Lifecycle transitions ──────────────────────────────────────────────── async store( id: string, performedBy?: string, - chosen?: { warehouseId?: string; yardId?: string; zoneId?: string }, + chosen?: { warehouseId?: string; yardId?: string; zoneId?: string; slotId?: string }, ): Promise { const item = await this.findById(id); this.assertTransition(item.status, 'STORED'); @@ -3113,11 +3446,17 @@ export class WarehouseInventoryService { await this.applyCapacityDelta(manager, location, weight, volume, containerCount); } - const storedReason = manualLocation + const placed = await this.resolveStoragePlacement(manager, locked, location, { + slotId: chosen?.slotId, + direction: criteria.tradeDirection, + }); + + const baseReason = manualLocation ? `Stored at operator-selected location -> ${location.path ?? 'chosen yard/zone'}` : ruleLocation?.rule ? `Stored by allocation rule "${ruleLocation.rule.name}" -> ${ruleLocation.path}` : `Stored by capacity-balanced allocation -> ${location.path ?? 'assigned yard/zone'}`; + const storedReason = placed ? `${baseReason} @ ${placed.label}` : baseReason; await manager.getRepository(WarehouseInventory).update(id, { status: 'STORED', @@ -3125,6 +3464,8 @@ export class WarehouseInventoryService { warehouseId: location.warehouseId, yardId: location.yardId, zoneId: location.zoneId, + stackId: placed?.stackId ?? null, + slotId: placed?.slotId ?? null, notes: this.appendNote(locked.notes, storedReason), }); @@ -6029,6 +6370,18 @@ export class WarehouseInventoryService { if (!yard) throw new NotFoundException(`Yard ${dto.yardId} not found`); const zone = await manager.getRepository(WarehouseZone).findOne({ where: { id: dto.zoneId } }); if (!zone) throw new NotFoundException(`Zone ${dto.zoneId} not found`); + + // The three ids arrive independently from the client, so they have to be + // checked against each other: a zone belonging to another yard would send + // the item to a location that does not exist on the ground, and every + // capacity counter above it would be adjusted on the wrong row. + if (zone.yardId !== yard.id) { + throw new BadRequestException(`Zone ${zone.code} does not belong to yard ${yard.code}`); + } + if (yard.warehouseId !== warehouse.id) { + throw new BadRequestException(`Yard ${yard.code} does not belong to warehouse ${warehouse.code}`); + } + return { warehouse, yard, zone }; } diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-placement.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-placement.service.ts new file mode 100644 index 000000000..7e70a6e15 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-placement.service.ts @@ -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 { + 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 { + 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 { + 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, 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 { + 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 { + 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 { + 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 { + 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 { + 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(); + 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 { + 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 { + 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 { + await manager.getRepository(WarehouseInventory).update(inventoryId, { + stackId: placement?.stackId ?? null, + slotId: placement?.slotId ?? null, + }); + } +} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.controller.ts index 7e658d9db..c51002a5a 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post } from '@nestjs/common'; +import { Body, Controller, Delete, Get, Param, ParseUUIDPipe, Patch, Post } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { BookingStaff } from '../../common/booking-guards'; @@ -40,6 +40,16 @@ export class WarehouseYardsController { return this.yardsService.update(id, dto); } + @Delete(':id') + @BookingStaff(FREIGHT_PERMS.warehouseYards.delete) + @ApiOperation({ + summary: 'Delete warehouse yard', + description: 'Soft-deletes the yard. Refused while it still has zones.', + }) + remove(@Param('id', ParseUUIDPipe) id: string) { + return this.yardsService.remove(id); + } + @Get(':yardId/zones') @BookingStaff(FREIGHT_PERMS.warehouseZones.view) @ApiOperation({ summary: 'List zones within a yard' }) diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.service.ts index 874de75db..b9e212ae1 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.service.ts @@ -107,6 +107,24 @@ export class WarehouseYardsService { return this.findById(id); } + /** + * Soft-delete a yard. Zones (and the inventory sitting in them) are left + * alone — a yard still holding zones is refused rather than orphaning stock. + */ + async remove(id: string): Promise<{ id: string; deleted: true }> { + const existing = await this.findById(id); + + if (existing.zones?.length) { + throw new ConflictException( + `Yard ${existing.code} still has ${existing.zones.length} zone(s). Delete them first.`, + ); + } + + await this.yardsRepository.softDelete(id); + + return { id, deleted: true }; + } + private async assertCodeUnique(warehouseId: string, code: string, ignoreId?: string): Promise { const [existing] = await this.yardsRepository.findAll({ where: { warehouseId, code } }); diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-zone-slots.repository.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-zone-slots.repository.ts new file mode 100644 index 000000000..f24bcfc33 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-zone-slots.repository.ts @@ -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 { + constructor(@InjectRepository(WarehouseZoneSlot) repository: Repository) { + super(repository); + } +} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-zone-stacks.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-zone-stacks.controller.ts new file mode 100644 index 000000000..17ce778d0 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-zone-stacks.controller.ts @@ -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); + } +} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-zone-stacks.repository.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-zone-stacks.repository.ts new file mode 100644 index 000000000..92a798ec1 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-zone-stacks.repository.ts @@ -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 { + constructor(@InjectRepository(WarehouseZoneStack) repository: Repository) { + super(repository); + } +} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-zone-stacks.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-zone-stacks.service.ts new file mode 100644 index 000000000..b74116c7a --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-zone-stacks.service.ts @@ -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 { + return this.stacksRepository.findAll({ + where: { zoneId }, + relations: { slots: true }, + order: { code: 'ASC' }, + }); + } + + async findById(id: string): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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`); + } + } +} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.controller.ts index 04cbacd28..c88b8d3d6 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.controller.ts @@ -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 { BookingStaff } from '../../common/booking-guards'; @@ -18,6 +18,7 @@ import { WarehouseZonesService } from './warehouse-zones.service'; FREIGHT_PERMS.warehouseZones.view, FREIGHT_PERMS.warehouseInventory.view, FREIGHT_PERMS.warehouseZones.update, + FREIGHT_PERMS.warehouseZones.delete, ]) export class WarehouseZonesController { constructor(private readonly zonesService: WarehouseZonesService) {} @@ -40,4 +41,41 @@ export class WarehouseZonesController { update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateWarehouseZoneDto) { 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); + } } diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.service.ts index 367a5a75e..78011a369 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.service.ts @@ -1,16 +1,35 @@ 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 { UpdateWarehouseZoneDto } from './dto/update-warehouse-zone.dto'; 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 { 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() export class WarehouseZonesService { constructor( private readonly zonesRepository: WarehouseZonesRepository, private readonly yardsService: WarehouseYardsService, + private readonly inventoryRepository: WarehouseInventoryRepository, + private readonly placement: WarehousePlacementService, + @InjectDataSource() private readonly dataSource: DataSource, ) {} findAll(): Promise { @@ -98,6 +117,94 @@ export class WarehouseZonesService { 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 { + 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 { const [existing] = await this.zonesRepository.findAll({ where: { yardId, code } }); diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouses.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouses.controller.ts index 275afadf6..6a2a51b4e 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouses.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouses.controller.ts @@ -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 { BookingStaff } from '../../common/booking-guards'; @@ -25,6 +25,7 @@ import { WarehousesService } from './warehouses.service'; FREIGHT_PERMS.warehouseDashboard.view, FREIGHT_PERMS.warehouses.create, FREIGHT_PERMS.warehouses.update, + FREIGHT_PERMS.warehouses.delete, FREIGHT_PERMS.warehouseYards.view, FREIGHT_PERMS.warehouseYards.create, ]) @@ -79,6 +80,16 @@ export class WarehousesController { 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') @BookingStaff(FREIGHT_PERMS.warehouseYards.view) @ApiOperation({ summary: 'List yards within a warehouse' }) diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts b/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts index d91ac2baf..48d787e55 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts @@ -21,6 +21,8 @@ import { WarehouseInventoryMovement } from './entities/warehouse-inventory-movem import { WarehouseLoading } from './entities/warehouse-loading.entity'; import { WarehouseYard } from './entities/warehouse-yard.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 { SchedulingReadFacade } from './scheduling-read.facade'; import { WarehouseActivityLogRepository } from './warehouse-activity-log.repository'; @@ -47,6 +49,11 @@ import { WarehouseSchedulingAdapterService } from './warehouse-scheduling-adapte import { WarehouseYardsController } from './warehouse-yards.controller'; import { WarehouseYardsRepository } from './warehouse-yards.repository'; 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 { WarehouseZonesRepository } from './warehouse-zones.repository'; import { WarehouseZonesService } from './warehouse-zones.service'; @@ -60,6 +67,8 @@ import { WarehousesService } from './warehouses.service'; Warehouse, WarehouseYard, WarehouseZone, + WarehouseZoneStack, + WarehouseZoneSlot, WarehouseInventory, WarehouseInventoryMovement, WarehouseActivityLog, @@ -83,6 +92,7 @@ import { WarehousesService } from './warehouses.service'; WarehousesController, WarehouseYardsController, WarehouseZonesController, + WarehouseZoneStacksController, WarehouseInventoryController, WarehouseLoadingsController, WarehouseInspectionController, @@ -93,6 +103,8 @@ import { WarehousesService } from './warehouses.service'; WarehousesRepository, WarehouseYardsRepository, WarehouseZonesRepository, + WarehouseZoneStacksRepository, + WarehouseZoneSlotsRepository, WarehouseInventoryRepository, WarehouseInventoryMovementRepository, WarehouseActivityLogRepository, @@ -103,6 +115,8 @@ import { WarehousesService } from './warehouses.service'; WarehousesService, WarehouseYardsService, WarehouseZonesService, + WarehouseZoneStacksService, + WarehousePlacementService, WarehouseInventoryService, WarehouseActivityLogService, WarehouseDashboardService, @@ -119,6 +133,8 @@ import { WarehousesService } from './warehouses.service'; WarehousesService, WarehouseYardsService, WarehouseZonesService, + WarehouseZoneStacksService, + WarehousePlacementService, WarehouseInventoryService, WarehouseAllocationService, WarehouseFeeService, diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouses.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouses.service.ts index 140d9f6b4..43284ca8f 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouses.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouses.service.ts @@ -54,6 +54,7 @@ export class WarehousesService { name: dto.name.trim(), code: dto.code.trim(), type: dto.type, + freightType: dto.freightType ?? null, stationId: dto.stationId ?? null, facilityId: dto.facilityId ?? null, locationName: dto.locationName?.trim() ?? null, @@ -87,6 +88,7 @@ export class WarehousesService { name: dto.name?.trim() ?? existing.name, code: dto.code?.trim() ?? existing.code, type: dto.type ?? existing.type, + freightType: dto.freightType ?? existing.freightType, stationId: dto.stationId ?? existing.stationId, facilityId: dto.facilityId ?? existing.facilityId, locationName: dto.locationName?.trim() ?? existing.locationName, @@ -108,6 +110,25 @@ export class WarehousesService { 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. */ private mapDbError(error: unknown): never { if (error instanceof QueryFailedError) { diff --git a/apps/edr-freight-api/src/scripts/seed-warehouse-layout.ts b/apps/edr-freight-api/src/scripts/seed-warehouse-layout.ts new file mode 100644 index 000000000..d0872a6bb --- /dev/null +++ b/apps/edr-freight-api/src/scripts/seed-warehouse-layout.ts @@ -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); +}); diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index ac2507aa5..90a7310c8 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -652,6 +652,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. export const CHAT_PERMISSIONS: FreightPermissionSeed[] = [ perm( @@ -1272,6 +1298,11 @@ export const WAREHOUSE_PERMISSIONS: FreightPermissionSeed[] = [ "edr_freight_app:warehouse_zones:update", "Update warehouse zone", ), + perm( + "f1c00001-0001-4000-8000-000000000004", + "edr_freight_app:warehouse_zones:delete", + "Delete warehouse zone", + ), perm( "f1d00001-0001-4000-8000-000000000001", "edr_freight_app:warehouse_allocation_rules:view", @@ -1885,6 +1916,7 @@ export const ADVANCED_BACKOFFICE_PERMISSIONS: FreightPermissionSeed[] = [ ...OVERVIEW_LAYOUT_PERMISSIONS, ...CUSTOMER_PERMISSIONS, ...SHIPPING_LINE_PERMISSIONS, + ...TRANSIT_ASSIGNMENT_PERMISSIONS, ...CHAT_PERMISSIONS, ...FINANCE_PERMISSIONS, ...MILE_PERMISSIONS, @@ -2113,6 +2145,12 @@ export const FREIGHT_PERMS = { // Notification selector, not a route guard — see NOTIFICATION_PERMISSIONS. 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: { view: "edr_freight_app:shipping_lines:view", create: "edr_freight_app:shipping_lines:create", @@ -2307,6 +2345,7 @@ export const FREIGHT_PERMS = { view: "edr_freight_app:warehouse_zones:view", create: "edr_freight_app:warehouse_zones:create", update: "edr_freight_app:warehouse_zones:update", + delete: "edr_freight_app:warehouse_zones:delete", }, warehouseAllocationRules: { view: "edr_freight_app:warehouse_allocation_rules:view", diff --git a/apps/edr-freight-api/src/seed/warehouse-layout.json b/apps/edr-freight-api/src/seed/warehouse-layout.json new file mode 100644 index 000000000..5563bf112 --- /dev/null +++ b/apps/edr-freight-api/src/seed/warehouse-layout.json @@ -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 + } +} diff --git a/apps/edr-freight-api/src/seed/warehouse-layout.seeder.ts b/apps/edr-freight-api/src/seed/warehouse-layout.seeder.ts new file mode 100644 index 000000000..e7533592e --- /dev/null +++ b/apps/edr-freight-api/src/seed/warehouse-layout.seeder.ts @@ -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 { + 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 { + 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 { + 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; + } +} diff --git a/apps/edr-freight-web/backoffice/package.json b/apps/edr-freight-web/backoffice/package.json index e0ef813e9..7674f924c 100644 --- a/apps/edr-freight-web/backoffice/package.json +++ b/apps/edr-freight-web/backoffice/package.json @@ -97,6 +97,7 @@ "react-markdown": "^9.1.0", "react-pdf": "^10.4.1", "react-pdf-html": "^2.1.5", + "react-phone-number-input": "^3.4.17", "react-quill-new": "^3.8.3", "react-resizable-panels": "^3.0.6", "react-router-dom": "^6.27.0", diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index a2af15786..539f2ee98 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -96,6 +96,7 @@ import TrainBuilderDetailPage from "./pages/trainBuilder/TrainBuilderDetailPage" import TrainBuilderListPage from "./pages/trainBuilder/TrainBuilderListPage"; import ArrivalQueuePage from "./pages/warehouses/ArrivalQueuePage"; import ContainerReturnsPage from "./pages/warehouses/ContainerReturnsPage"; +import RegisterFullContainersPage from "./pages/warehouses/RegisterFullContainersPage"; import DispatchQueuePage from "./pages/warehouses/DispatchQueuePage"; import ExportDjiboutiUnloadingQueuePage from "./pages/warehouses/ExportDjiboutiUnloadingQueuePage"; import ExportWarehouseFlowPage from "./pages/warehouses/ExportWarehouseFlowPage"; @@ -708,6 +709,16 @@ const App = () => { } /> + + + + } + /> { + 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), + ); +}); diff --git a/apps/edr-freight-web/backoffice/src/components/PhoneField.tsx b/apps/edr-freight-web/backoffice/src/components/PhoneField.tsx new file mode 100644 index 000000000..8714ccd8d --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/PhoneField.tsx @@ -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 ( + +
+ +
+
+ ); +} + +export default PhoneField; diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/BookingPricingSummary.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/BookingPricingSummary.tsx index 814fb9a95..0cf206350 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/BookingPricingSummary.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/BookingPricingSummary.tsx @@ -1,12 +1,32 @@ import { Banknote, Receipt } from "lucide-react"; import { Divider, Group, Paper, Stack, Text } from "@mantine/core"; +import { useQuery } from "@tanstack/react-query"; +import { api } from "@/services/api"; import type { BookingDetail } from "@/types/booking"; import { SectionCard } from "./detail/SectionCard"; import { detailStyles } from "./detail/booking-detail.styles"; export function BookingPricingSummary({ booking }: { booking: BookingDetail }) { + // The booking's own freight invoice: source `booking`, sourceId = booking id + // (which `search` matches). Newest first — a re-issue supersedes the old one. + const invoiceQuery = useQuery( + api.invoices.list.queryOptions({ + input: { + filter: { + page: 1, + pageSize: 1, + sources: "booking", + search: booking.id, + sortBy: "createdAt", + sortOrder: "DESC", + }, + }, + }), + ); + const invoiceNumber = invoiceQuery.data?.items[0]?.invoiceNumber ?? null; + const computed = Number(booking.totalAmount); // The booking price is computed from the contract and is NOT staff-editable. // A historical `adjustedTotalAmount` (from before adjustments were removed) @@ -49,6 +69,7 @@ export function BookingPricingSummary({ booking }: { booking: BookingDetail }) { + {invoiceNumber && } {booking.pnrCode && } {lineItems.length > 0 && ( diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingCargoCard.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingCargoCard.tsx index dc7286b5c..f2d6c07d4 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingCargoCard.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingCargoCard.tsx @@ -30,6 +30,13 @@ export function BookingCargoCard({ booking }: BookingCargoCardProps) { ); 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: // the freight kind, with the shipper's own description alongside. const cargoHeadline = isBulk @@ -46,6 +53,11 @@ export function BookingCargoCard({ booking }: BookingCargoCardProps) { {isBulk ? "Bulk" : "Container"} + {requestedWagons != null ? ( + + {requestedWagons} wagon{requestedWagons === 1 ? "" : "s"} + + ) : null} {cargoDescription ? ( — {cargoDescription} @@ -62,6 +74,9 @@ export function BookingCargoCard({ booking }: BookingCargoCardProps) { } /> + {requestedWagons != null && ( + + )} {items != null && } ) => { interface UnitErrors { containerNumber?: string; + sealNumber?: string; vgmTons?: string; } @@ -886,6 +887,9 @@ export default function GlCreateBookingForm() { } else if ((numberCounts.get(key) ?? 0) > 1) { errs.containerNumber = "Duplicate container number in this shipment."; } + if (u.sealNumber.trim() === "") { + errs.sealNumber = "Seal number is required."; + } const vgm = Number(u.vgmTons); if (u.vgmTons.trim() === "" || Number.isNaN(vgm) || vgm <= 0) { errs.vgmTons = "Enter a valid VGM."; @@ -1185,7 +1189,7 @@ export default function GlCreateBookingForm() { : {}), units: l.units.map((u) => ({ containerNumber: u.containerNumber.trim().toUpperCase(), - ...(u.sealNumber ? { sealNumber: u.sealNumber } : {}), + sealNumber: u.sealNumber.trim(), vgmTons: Number(u.vgmTons) || 0, // Per-container handling — the server rolls these into the line // counts and bills each surcharge on the ticked containers only. @@ -1247,7 +1251,7 @@ export default function GlCreateBookingForm() { reeferQuantity: Number(l.reeferQuantity || 0) || undefined, units: l.units.map((u) => ({ containerNumber: u.containerNumber.trim().toUpperCase(), - ...(u.sealNumber ? { sealNumber: u.sealNumber } : {}), + sealNumber: u.sealNumber.trim(), vgmTons: Number(u.vgmTons) || 0, isHazardous: Boolean(u.isHazardous), isReefer: Boolean(u.isReefer), @@ -1857,7 +1861,7 @@ export default function GlCreateBookingForm() { Container number * - Seal number + Seal number * VGM (tons) * @@ -1901,8 +1905,13 @@ export default function GlCreateBookingForm() { style={{ flex: 1 }} /> patchUnit(lineIdx, unitIdx, { sealNumber: e.currentTarget.value, diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/gl-booking-form/container-excel.ts b/apps/edr-freight-web/backoffice/src/components/contracts/gl-booking-form/container-excel.ts index c00091bfc..fd34bd967 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/gl-booking-form/container-excel.ts +++ b/apps/edr-freight-web/backoffice/src/components/contracts/gl-booking-form/container-excel.ts @@ -151,6 +151,11 @@ export async function parseContainerExcel( 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 vgm = Number(vgmRaw); if (!vgmRaw || Number.isNaN(vgm) || vgm <= 0) { @@ -160,7 +165,7 @@ export async function parseContainerExcel( rows.push({ containerSize: size ?? "", containerNumber, - sealNumber: cell("sealNumber"), + sealNumber, vgmTons: vgmRaw, hazardous: opts.includeHazardous && parseFlag(cell("hazardous")), reefer: opts.includeReefer && parseFlag(cell("reefer")), diff --git a/apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx b/apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx index 233d67d2f..8ae6b6c47 100644 --- a/apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx +++ b/apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx @@ -18,6 +18,7 @@ import { Package, PackageCheck, PackageOpen, + PackagePlus, Paperclip, Receipt, Stamp, @@ -366,6 +367,12 @@ export const buildSidebarSections = ( icon: , permission: FREIGHT_PERMS.warehouseInventory.view, }, + { + label: "Register Full Containers", + href: "/dashboard/register-full-containers", + icon: , + permission: FREIGHT_PERMS.warehouseInventory.receive, + }, { label: "Terminal Inventory", href: "/dashboard/warehouse-inventory?direction=IMPORT", diff --git a/apps/edr-freight-web/backoffice/src/components/phone-field.css b/apps/edr-freight-web/backoffice/src/components/phone-field.css new file mode 100644 index 000000000..3a0396ffd --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/phone-field.css @@ -0,0 +1,82 @@ +/* Align react-phone-number-input with the portal's Mantine field styling: + 44px height, 10px radius, edr border, brand-green focus ring. */ + +.edr-phone-wrapper .PhoneInput { + display: flex; + align-items: stretch; + gap: 8px; +} + +/* Country selector — a compact pill matching the input height/radius. */ +.edr-phone-wrapper .PhoneInputCountry { + margin: 0; + padding: 0 10px; + height: 2.25rem; + border: 0.0625rem solid #b0bfce; + border-radius: 6px; + background: #fff; + display: flex; + align-items: center; + gap: 6px; + transition: + border-color 120ms ease, + box-shadow 120ms ease; +} + +.edr-phone-wrapper .PhoneInputCountryIcon { + width: 22px; + height: 16px; + box-shadow: none; +} + +.edr-phone-wrapper .PhoneInputCountrySelectArrow { + color: #6b7c8e; + opacity: 0.8; +} + +/* The number input itself. */ +.edr-phone-input { + flex: 1; + min-width: 0; + height: 44px; + padding: 0 12px; + border: 1px solid #e6ecf2; + border-radius: 10px; + font-size: 14px; + color: #10202f; + background: #fff; + outline: none; + transition: + border-color 120ms ease, + box-shadow 120ms ease; +} + +.edr-phone-input::placeholder { + color: #9aa8b5; +} + +.edr-phone-input:focus { + border-color: #0ea371; + box-shadow: 0 0 0 3px rgba(14, 163, 113, 0.15); +} + +.edr-phone-wrapper .PhoneInputCountry:focus-within { + border-color: #0ea371; + box-shadow: 0 0 0 3px rgba(14, 163, 113, 0.15); +} + +.edr-phone-input:disabled, +.edr-phone-wrapper .PhoneInputCountrySelect:disabled + .PhoneInputCountryIcon { + opacity: 0.6; + cursor: not-allowed; +} + +/* Error state mirrors Mantine's invalid styling. */ +.edr-phone-wrapper--error .edr-phone-input, +.edr-phone-wrapper--error .PhoneInputCountry { + border-color: #e03131; +} + +.edr-phone-wrapper--error .edr-phone-input:focus { + box-shadow: 0 0 0 3px rgba(224, 49, 49, 0.12); +} diff --git a/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineFormDialog.tsx b/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineFormDialog.tsx index e3bf11375..e3a3445c3 100644 --- a/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineFormDialog.tsx +++ b/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineFormDialog.tsx @@ -22,6 +22,7 @@ import { RULE_ENGINE_SELECT_NONE, type FormFieldDef, } from "@/pages/ruleEngine/config/resources"; +import PhoneField, { isValidPhone } from "@/components/PhoneField"; import type { RuleEngineRecord } from "@/types/rule-engine"; import { RULE_ENGINE_POSITION_END } from "./ruleEngineOrder.utils"; @@ -46,7 +47,11 @@ type FormRow = /** One editable distance tier of a tierList field (raw input strings). */ type TierRow = { minKm: string; maxKm: string; rateValue: string }; -const emptyTier = (fromKm = ""): TierRow => ({ minKm: fromKm, maxKm: "", rateValue: "" }); +const emptyTier = (fromKm = ""): TierRow => ({ + minKm: fromKm, + maxKm: "", + rateValue: "", +}); /** * Validate a tier set before submit: every tier complete, ranges sane, no @@ -87,7 +92,11 @@ const buildFormRows = (fields: FormFieldDef[]): FormRow[] => { while (index < fields.length) { const field = fields[index]; - if (field.type === "textarea" || field.type === "boolean" || field.type === "tierList") { + if ( + field.type === "textarea" || + field.type === "boolean" || + field.type === "tierList" + ) { rows.push({ kind: "single", field }); index += 1; continue; @@ -113,9 +122,10 @@ const buildInitialValues = ( ): Record => { const values: Record = {}; for (const field of fields) { - const raw = field.getInitialValue && record - ? field.getInitialValue(record) - : record?.[field.name]; + const raw = + field.getInitialValue && record + ? field.getInitialValue(record) + : record?.[field.name]; if (field.type === "multiselect") { values[field.name] = Array.isArray(raw) ? raw.map(String) : []; } else if (field.type === "tierList") { @@ -160,10 +170,13 @@ const resolveSelectValue = ( }; const inputStyles = { - label: { fontWeight: 600, marginBottom: 6, color: "var(--mantine-color-gray-8)" }, + label: { + fontWeight: 600, + marginBottom: 6, + color: "var(--mantine-color-gray-8)", + }, } as const; - const RuleEngineFormDialog = ({ open, onOpenChange, @@ -195,13 +208,17 @@ const RuleEngineFormDialog = ({ fields.filter((field) => { if ( field.hideWhen && - field.hideWhen.equals.includes(String(values[field.hideWhen.field] ?? "")) + field.hideWhen.equals.includes( + String(values[field.hideWhen.field] ?? ""), + ) ) { return false; } if ( field.showWhen && - !field.showWhen.equals.includes(String(values[field.showWhen.field] ?? "")) + !field.showWhen.equals.includes( + String(values[field.showWhen.field] ?? ""), + ) ) { return false; } @@ -228,7 +245,10 @@ const RuleEngineFormDialog = ({ // Changing what a rate applies to (or its surcharge trigger) can invalidate // the previously-chosen unit — reset it so the admin re-picks from the new // allowed set instead of submitting a stale, rejected unit. - if ((name === "appliesTo" || name === "trigger") && "rateUnit" in current) { + if ( + (name === "appliesTo" || name === "trigger") && + "rateUnit" in current + ) { next.rateUnit = ""; } // The legal yards depend on what the rate is for and which way it runs, so @@ -342,6 +362,20 @@ const RuleEngineFormDialog = ({ [field.name]: `${field.label} is required.`, })); blocked = true; + } else if (field.type === "phone" && raw !== "" && raw !== undefined) { + // The country-picker input emits a PARTIAL E.164 while the user is + // still typing ("+25377"), which is non-empty and would post straight + // through to a 400 from the API's own validator. Reject it here, on the + // field, instead of as a server error the admin has to decode. + if (!isValidPhone(String(raw))) { + setFieldErrors((current) => ({ + ...current, + [field.name]: `${field.label} is not a complete phone number.`, + })); + blocked = true; + } else { + payload[field.name] = raw; + } } else if (raw === "" || raw === undefined) { if (!field.required) continue; payload[field.name] = raw; @@ -350,13 +384,19 @@ const RuleEngineFormDialog = ({ } } - if (fields.some((f) => f.name === "code" && typeof payload.code === "string")) { + if ( + fields.some((f) => f.name === "code" && typeof payload.code === "string") + ) { payload.code = String(payload.code).toUpperCase(); } if (blocked) return; - if (!initialRecord && positionOptions && position !== RULE_ENGINE_POSITION_END) { + if ( + !initialRecord && + positionOptions && + position !== RULE_ENGINE_POSITION_END + ) { payload.insertAfterId = position; } @@ -421,7 +461,9 @@ const RuleEngineFormDialog = ({ const setRows = (next: TierRow[]) => setField(field.name, next); const setRow = (index: number, key: keyof TierRow, value: string) => { if (value.trim().startsWith("-")) return; - setRows(rows.map((row, i) => (i === index ? { ...row, [key]: value } : row))); + setRows( + rows.map((row, i) => (i === index ? { ...row, [key]: value } : row)), + ); }; return ( @@ -443,7 +485,9 @@ const RuleEngineFormDialog = ({ step="any" placeholder="0" value={row.minKm} - onChange={(e) => setRow(index, "minKm", e.currentTarget.value)} + onChange={(e) => + setRow(index, "minKm", e.currentTarget.value) + } size="md" radius="md" styles={inputStyles} @@ -456,7 +500,9 @@ const RuleEngineFormDialog = ({ step="any" placeholder="No limit" value={row.maxKm} - onChange={(e) => setRow(index, "maxKm", e.currentTarget.value)} + onChange={(e) => + setRow(index, "maxKm", e.currentTarget.value) + } size="md" radius="md" styles={inputStyles} @@ -469,7 +515,9 @@ const RuleEngineFormDialog = ({ step="any" placeholder="Rate per km" value={row.rateValue} - onChange={(e) => setRow(index, "rateValue", e.currentTarget.value)} + onChange={(e) => + setRow(index, "rateValue", e.currentTarget.value) + } size="md" radius="md" styles={inputStyles} @@ -494,7 +542,12 @@ const RuleEngineFormDialog = ({ size="xs" leftSection={} // The next tier naturally starts where the previous one ends. - onClick={() => setRows([...rows, emptyTier(rows[rows.length - 1]?.maxKm ?? "")])} + onClick={() => + setRows([ + ...rows, + emptyTier(rows[rows.length - 1]?.maxKm ?? ""), + ]) + } > Add tier @@ -531,7 +584,10 @@ const RuleEngineFormDialog = ({ onChange={(v) => setField(field.name, v)} disabled={selectOptionsLoading} data={options - .filter((opt) => opt.value !== "" && opt.value !== RULE_ENGINE_SELECT_NONE) + .filter( + (opt) => + opt.value !== "" && opt.value !== RULE_ENGINE_SELECT_NONE, + ) .map((opt) => ({ label: opt.label, value: opt.value }))} searchable clearable @@ -545,7 +601,9 @@ const RuleEngineFormDialog = ({ if (field.type === "select") { // Dynamic options (e.g. rate unit) resolve from the live form values so // the choices track the other fields the admin has picked. - const options = field.optionsFromValues ? field.optionsFromValues(values) : (field.options ?? []); + const options = field.optionsFromValues + ? field.optionsFromValues(values) + : (field.options ?? []); // A derived select shows (and submits) its computed value and is locked, // matching the text-input branch — used by fields the shape decides on the // admin's behalf, e.g. a shipping-line rate's import-only direction. @@ -558,14 +616,18 @@ const RuleEngineFormDialog = ({ label={label} description={field.description} placeholder={ - selectOptionsLoading ? "Loading options..." : (field.placeholder ?? "Select an option") + selectOptionsLoading + ? "Loading options..." + : (field.placeholder ?? "Select an option") } value={ computedSelect !== undefined ? computedSelect : resolveSelectValue(field, values) } - onChange={(v) => setField(field.name, v === RULE_ENGINE_SELECT_NONE ? "" : v)} + onChange={(v) => + setField(field.name, v === RULE_ENGINE_SELECT_NONE ? "" : v) + } disabled={ selectOptionsLoading || field.disabled || @@ -635,8 +697,29 @@ const RuleEngineFormDialog = ({ ); } + if (field.type === "phone") { + // Country-picker input restricted to Ethiopia and Djibouti — the two the + // SMS gateway reaches. Emits E.164, so the API never guesses a country + // from a bare local number. + return ( + setField(field.name, v ?? "")} + disabled={field.disabled || (field.disabledOnEdit && !!initialRecord)} + required={field.required} + error={fieldErrors[field.name] || undefined} + placeholder={field.placeholder} + /> + ); + } + const isNumber = field.type === "number"; - const computed = field.computeValue ? field.computeValue(values) : undefined; + const computed = field.computeValue + ? field.computeValue(values) + : undefined; return ( { const next = e.currentTarget.value; if (isNumber && next.trim().startsWith("-")) return; @@ -703,16 +792,27 @@ const RuleEngineFormDialog = ({ >
- + {!initialRecord && positionOptions ? (