mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-05 18:53:38 +00:00
@@ -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",
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Why a train schedule was cancelled, captured at cancel time. Staff pick a
|
||||
* reason in the cancel dialog and every view of the cancelled schedule reads it
|
||||
* back — a cancelled train on the board used to say nothing about why it died.
|
||||
*/
|
||||
export class ScheduleCancellationReason3780000000000 implements MigrationInterface {
|
||||
name = 'ScheduleCancellationReason3780000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE "freight"."train_schedules"
|
||||
ADD COLUMN IF NOT EXISTS "cancellation_reason" varchar(500),
|
||||
ADD COLUMN IF NOT EXISTS "cancelled_at" timestamptz,
|
||||
ADD COLUMN IF NOT EXISTS "cancelled_by_user_id" uuid
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE "freight"."train_schedules"
|
||||
DROP COLUMN IF EXISTS "cancellation_reason",
|
||||
DROP COLUMN IF EXISTS "cancelled_at",
|
||||
DROP COLUMN IF EXISTS "cancelled_by_user_id"
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Empties backfilled into the yard belong to a company that may not be a
|
||||
* registered customer yet, so `customer_id` cannot hold it. `company_name` is
|
||||
* the typed fallback, and the display label when the customer IS registered.
|
||||
*/
|
||||
export class EmptyContainerReturnCompanyName3790000000000 implements MigrationInterface {
|
||||
name = 'EmptyContainerReturnCompanyName3790000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.empty_container_returns
|
||||
ADD COLUMN IF NOT EXISTS company_name varchar(200)
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.empty_container_returns
|
||||
DROP COLUMN IF EXISTS company_name
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Backlog registration of full containers that were already sitting in a yard
|
||||
* before the system knew about them. Such a row carries a true, backdated
|
||||
* `arrived_at` for the record but accrues NO storage or demurrage — the
|
||||
* operator decided these are not billable retroactively — so the flag exists
|
||||
* to keep the fee engine off them.
|
||||
*
|
||||
* `company_id` / `company_name` carry the owner, since a backlog row has no
|
||||
* booking to inherit one from. The name is free text for a company that is not
|
||||
* a registered customer yet.
|
||||
*/
|
||||
export class WarehouseInventoryBacklogRegistration3800000000000 implements MigrationInterface {
|
||||
name = 'WarehouseInventoryBacklogRegistration3800000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.warehouse_inventory
|
||||
ADD COLUMN IF NOT EXISTS backlog_registration boolean NOT NULL DEFAULT false,
|
||||
ADD COLUMN IF NOT EXISTS company_id uuid,
|
||||
ADD COLUMN IF NOT EXISTS company_name varchar(200)
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.warehouse_inventory
|
||||
DROP COLUMN IF EXISTS backlog_registration,
|
||||
DROP COLUMN IF EXISTS company_id,
|
||||
DROP COLUMN IF EXISTS company_name
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Seed `edr_freight_app:warehouse_zones:delete` — the zone counterpart of the
|
||||
* warehouse and yard delete permissions, which already exist.
|
||||
*
|
||||
* `ROLE_PERMISSION_PRESETS` spreads `Object.values(FREIGHT_PERMS.warehouseZones)`
|
||||
* into the warehouse positions, so the moment the key is added to the registry
|
||||
* `FreightPositionsSeeder.loadPermissionIds` resolves it against `iam.permissions`
|
||||
* at boot — and throws `missing_permissions:<key>` if the row is absent. The
|
||||
* catalog is otherwise written by `EdrOrgSeeder`, which skips itself unless
|
||||
* `SEED_EDR_ORG` is set, so a migration is the only path that runs everywhere.
|
||||
*
|
||||
* Idempotent on `key`; keeps the registry's fixed uuid so every environment
|
||||
* lands on the same id. Skips silently when the freight application row is
|
||||
* absent, since there is nothing to attach to.
|
||||
*/
|
||||
export class WarehouseZoneDeletePermission3810000000000 implements MigrationInterface {
|
||||
private static readonly KEY = 'edr_freight_app:warehouse_zones:delete';
|
||||
private static readonly ID = 'f1c00001-0001-4000-8000-000000000004';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`INSERT INTO iam.permissions (id, key, name, application_id)
|
||||
SELECT $2::uuid,
|
||||
$1::varchar,
|
||||
'{"am": "Delete warehouse zone", "en": "Delete warehouse zone"}'::jsonb,
|
||||
a.id
|
||||
FROM iam.application a
|
||||
WHERE a.key = 'edr_freight_app'
|
||||
AND NOT EXISTS (SELECT 1 FROM iam.permissions p WHERE p.key = $1::varchar)`,
|
||||
[WarehouseZoneDeletePermission3810000000000.KEY, WarehouseZoneDeletePermission3810000000000.ID],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Grants go first, or the delete trips the position/role permission foreign
|
||||
* keys — a half-removed permission is worse than one left in place.
|
||||
*/
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`DELETE FROM iam.position_permissions
|
||||
WHERE permission_id IN (SELECT id FROM iam.permissions WHERE key = $1)`,
|
||||
[WarehouseZoneDeletePermission3810000000000.KEY],
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DELETE FROM iam.role_permissions
|
||||
WHERE permission_id IN (SELECT id FROM iam.permissions WHERE key = $1)`,
|
||||
[WarehouseZoneDeletePermission3810000000000.KEY],
|
||||
);
|
||||
await queryRunner.query(`DELETE FROM iam.permissions WHERE key = $1`, [
|
||||
WarehouseZoneDeletePermission3810000000000.KEY,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* `freight.warehouses.freight_type` — CONTAINER or BULK, or null for a site
|
||||
* that takes both.
|
||||
*
|
||||
* Nullable with no backfill on purpose: every existing warehouse predates the
|
||||
* field and is unrestricted today, so writing a value would narrow live
|
||||
* allocation behind the operator's back.
|
||||
*/
|
||||
export class WarehouseFreightType3820000000000 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.warehouses ADD COLUMN IF NOT EXISTS freight_type varchar(16)`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE freight.warehouses DROP COLUMN IF EXISTS freight_type`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Physical container positions below the zone: a stack is the ground footprint,
|
||||
* a slot is one level in it. Adds `stack_id` / `slot_id` to warehouse inventory.
|
||||
*
|
||||
* Everything is additive and nullable. Existing inventory keeps warehouse /
|
||||
* yard / zone as its only location and stays valid — nothing is backfilled,
|
||||
* because no one can know where a box already in the yard is actually stacked.
|
||||
*
|
||||
* Occupancy is not stored on the slot. `uq_warehouse_inventory_active_slot`
|
||||
* makes the inventory row the single source of truth: one live placement per
|
||||
* slot, enforced by Postgres. Its status list must stay in step with
|
||||
* `SLOT_OCCUPYING_STATUSES` in warehouse-inventory.entity.ts.
|
||||
*/
|
||||
export class WarehouseZoneStacksSlots3830000000000 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.warehouse_zone_stacks (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
zone_id uuid NOT NULL REFERENCES freight.warehouse_zones(id) ON DELETE CASCADE,
|
||||
code varchar(40) NOT NULL,
|
||||
name varchar(160),
|
||||
"row" varchar(20),
|
||||
bay varchar(20),
|
||||
"position" varchar(20),
|
||||
max_stack_height int NOT NULL DEFAULT 3,
|
||||
status varchar(16) NOT NULL DEFAULT 'ACTIVE',
|
||||
is_active boolean NOT NULL DEFAULT true,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
deleted_at timestamptz,
|
||||
CONSTRAINT chk_warehouse_zone_stacks_height CHECK (max_stack_height >= 1)
|
||||
)
|
||||
`);
|
||||
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX IF NOT EXISTS idx_warehouse_zone_stacks_zone ON freight.warehouse_zone_stacks (zone_id)`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX IF NOT EXISTS idx_warehouse_zone_stacks_status ON freight.warehouse_zone_stacks (status)`,
|
||||
);
|
||||
// Partial: a soft-deleted stack must not block reusing its code.
|
||||
await queryRunner.query(
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS uq_warehouse_zone_stacks_zone_code
|
||||
ON freight.warehouse_zone_stacks (zone_id, code) WHERE deleted_at IS NULL`,
|
||||
);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.warehouse_zone_slots (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
stack_id uuid NOT NULL REFERENCES freight.warehouse_zone_stacks(id) ON DELETE CASCADE,
|
||||
level int NOT NULL,
|
||||
status varchar(16) NOT NULL DEFAULT 'AVAILABLE',
|
||||
is_active boolean NOT NULL DEFAULT true,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
deleted_at timestamptz,
|
||||
CONSTRAINT chk_warehouse_zone_slots_level CHECK (level >= 1)
|
||||
)
|
||||
`);
|
||||
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX IF NOT EXISTS idx_warehouse_zone_slots_stack ON freight.warehouse_zone_slots (stack_id, level)`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS uq_warehouse_zone_slots_stack_level
|
||||
ON freight.warehouse_zone_slots (stack_id, level) WHERE deleted_at IS NULL`,
|
||||
);
|
||||
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.warehouse_inventory ADD COLUMN IF NOT EXISTS stack_id uuid`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.warehouse_inventory ADD COLUMN IF NOT EXISTS slot_id uuid`,
|
||||
);
|
||||
|
||||
// Named FKs added defensively — ADD CONSTRAINT has no IF NOT EXISTS.
|
||||
await queryRunner.query(`
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE freight.warehouse_inventory
|
||||
ADD CONSTRAINT fk_warehouse_inventory_stack
|
||||
FOREIGN KEY (stack_id) REFERENCES freight.warehouse_zone_stacks(id);
|
||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE freight.warehouse_inventory
|
||||
ADD CONSTRAINT fk_warehouse_inventory_slot
|
||||
FOREIGN KEY (slot_id) REFERENCES freight.warehouse_zone_slots(id);
|
||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$
|
||||
`);
|
||||
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX IF NOT EXISTS idx_warehouse_inventory_stack ON freight.warehouse_inventory (stack_id)`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX IF NOT EXISTS idx_warehouse_inventory_slot ON freight.warehouse_inventory (slot_id)`,
|
||||
);
|
||||
|
||||
// One live container per slot. Statuses past the yard gate (LOADED,
|
||||
// DISPATCHED, DELIVERED, UNLOADED_AT_DJIBOUTI_PORT) free the position
|
||||
// without any exit path having to clear the column.
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_warehouse_inventory_active_slot
|
||||
ON freight.warehouse_inventory (slot_id)
|
||||
WHERE deleted_at IS NULL
|
||||
AND slot_id IS NOT NULL
|
||||
AND status IN ('UNLOADED','RECEIVED','STORED','RESERVED','READY_FOR_LOADING','READY_FOR_PICKUP')
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS freight.uq_warehouse_inventory_active_slot`);
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_warehouse_inventory_slot`);
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_warehouse_inventory_stack`);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.warehouse_inventory DROP CONSTRAINT IF EXISTS fk_warehouse_inventory_slot`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.warehouse_inventory DROP CONSTRAINT IF EXISTS fk_warehouse_inventory_stack`,
|
||||
);
|
||||
await queryRunner.query(`ALTER TABLE freight.warehouse_inventory DROP COLUMN IF EXISTS slot_id`);
|
||||
await queryRunner.query(`ALTER TABLE freight.warehouse_inventory DROP COLUMN IF EXISTS stack_id`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.warehouse_zone_slots`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.warehouse_zone_stacks`);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
148
apps/edr-freight-api/src/modules/bookings/booking-content.sql.ts
Normal file
148
apps/edr-freight-api/src/modules/bookings/booking-content.sql.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* What the customer said is IN the booking, per freight type — the list
|
||||
* filter, the summary and the export all read this one expression so the
|
||||
* column, the pill and the sheet can never disagree.
|
||||
*
|
||||
* BULK the commodity picked from the cargo tree (`cargo_types`), falling
|
||||
* back to the free-text description for a bare group or a legacy row
|
||||
* that has no commodity.
|
||||
* CONTAINER the wizard asks for no description at all — VGM and contents are
|
||||
* captured later in operations — so the closest thing to the
|
||||
* customer's own words is the container lines they entered:
|
||||
* "2 × 40FT, 1 × 20FT".
|
||||
*
|
||||
* Containers are checked FIRST: a container booking has no `cargo_type_id`
|
||||
* (the API rejects one), so the order only matters for a mixed legacy row,
|
||||
* where the physical lines are the better answer.
|
||||
*/
|
||||
export function bookingContentSql(alias = 'b'): string {
|
||||
return `COALESCE(
|
||||
NULLIF((SELECT string_agg(bc.quantity || ' × ' || COALESCE(cnt.label, cnt.code), ', '
|
||||
ORDER BY cnt.size_ft DESC NULLS LAST, cnt.code)
|
||||
FROM freight.booking_container bc
|
||||
JOIN freight.container_types cnt ON cnt.id = bc.container_type_id
|
||||
WHERE bc.booking_id = ${alias}.id AND bc.deleted_at IS NULL), ''),
|
||||
NULLIF((SELECT cgt.cargo_type_name FROM freight.cargo_types cgt
|
||||
WHERE cgt.id = ${alias}.cargo_type_id), ''),
|
||||
NULLIF(${alias}.cargo_free_text, ''))`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cargo types at or under `:cargoTypeId`, so picking a GROUP in the filter
|
||||
* matches every commodity beneath it — the same group→commodity drill-down the
|
||||
* booking wizard offers, read back. Recursive because `cargo_types` is an
|
||||
* arbitrary-depth tree (Bulk → Steel Billet → S1 → …), not two levels.
|
||||
*/
|
||||
export const CARGO_TYPE_SUBTREE_SQL = `(
|
||||
WITH RECURSIVE sub AS (
|
||||
SELECT id FROM freight.cargo_types WHERE id = :cargoTypeId
|
||||
UNION ALL
|
||||
SELECT c.id FROM freight.cargo_types c JOIN sub ON c.parent_group_id = sub.id
|
||||
)
|
||||
SELECT id FROM sub)`;
|
||||
|
||||
/**
|
||||
* Contains-match over every part of the content a customer can type or pick:
|
||||
* their own description, the commodity's name, and the container types on the
|
||||
* booking. Bind `:cargoText` already wrapped in `%`.
|
||||
*/
|
||||
export function bookingContentMatchSql(alias = 'b'): string {
|
||||
return `(${alias}.cargo_free_text ILIKE :cargoText
|
||||
OR EXISTS (SELECT 1 FROM freight.cargo_types cgt
|
||||
WHERE cgt.id = ${alias}.cargo_type_id
|
||||
AND cgt.cargo_type_name ILIKE :cargoText)
|
||||
OR EXISTS (SELECT 1 FROM freight.booking_container bc
|
||||
JOIN freight.container_types cnt ON cnt.id = bc.container_type_id
|
||||
WHERE bc.booking_id = ${alias}.id AND bc.deleted_at IS NULL
|
||||
AND (cnt.label ILIKE :cargoText OR cnt.code ILIKE :cargoText)))`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Containers on a booking, as a count of physical boxes — `booking_container`
|
||||
* is one row PER LINE with a `quantity`, not one row per box, so this sums the
|
||||
* quantity rather than counting rows.
|
||||
*
|
||||
* `scopedToType` narrows the sum to `:containerTypeId`, which is what makes one
|
||||
* number filter answer both "10 containers in total" and "10 forty-footers":
|
||||
* the count filter reads the container-type filter when one is set, and counts
|
||||
* every type when it is not.
|
||||
*/
|
||||
export function bookingContainerCountSql(alias = 'b', scopedToType = false): string {
|
||||
return `(SELECT COALESCE(SUM(bc.quantity), 0)
|
||||
FROM freight.booking_container bc
|
||||
WHERE bc.booking_id = ${alias}.id
|
||||
AND bc.deleted_at IS NULL${
|
||||
scopedToType ? '\n AND bc.container_type_id = :containerTypeId' : ''
|
||||
})`;
|
||||
}
|
||||
|
||||
/** Bookings carrying at least one line of `:containerTypeId`. */
|
||||
export function bookingHasContainerTypeSql(alias = 'b'): string {
|
||||
return `EXISTS (SELECT 1 FROM freight.booking_container bc
|
||||
WHERE bc.booking_id = ${alias}.id
|
||||
AND bc.deleted_at IS NULL
|
||||
AND bc.container_type_id = :containerTypeId)`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Container VGM on a booking, in tons — the sum of the per-line totals.
|
||||
*
|
||||
* NOT `bookings.cargo_total_weight_vgm`: the portal wizard leaves that at 0 for
|
||||
* container freight (VGM is captured per container, later, in operations), so
|
||||
* reading the booking-level column showed every portal container booking as
|
||||
* weighing nothing. Same reason `bookingTonsSql` falls through to these lines.
|
||||
*/
|
||||
export function bookingContainerVgmSql(alias = 'b'): string {
|
||||
return `(SELECT COALESCE(SUM(bc.total_vgm_tons), 0)
|
||||
FROM freight.booking_container bc
|
||||
WHERE bc.booking_id = ${alias}.id
|
||||
AND bc.deleted_at IS NULL)`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cargo the customer declared on the SHIPMENT REQUEST behind a booking, which
|
||||
* is not the same fact as cargo on the booking itself.
|
||||
*
|
||||
* On a GENERAL + customs contract the customer cannot book directly: they
|
||||
* submit a request (day + quantities), and `initiateForShipmentRequest` opens a
|
||||
* BARE instance from it — "the request itself carries the quantities; the
|
||||
* instance carries none". So between initiation and `completeUnderContract` the
|
||||
* booking legitimately holds no cargo while the customer's declared quantities
|
||||
* sit on `booking_requests.requested_lines`.
|
||||
*
|
||||
* Kept in its own column rather than folded into the real container count: a
|
||||
* declared 2 × 20FT is a request, not two boxes on a booking, and merging the
|
||||
* two would overstate operational totals.
|
||||
*/
|
||||
const REQUESTED_CONTAINER_LINES = `jsonb_array_elements(
|
||||
CASE WHEN jsonb_typeof(br.requested_lines->'containers') = 'array'
|
||||
THEN br.requested_lines->'containers'
|
||||
ELSE '[]'::jsonb END)`;
|
||||
|
||||
/** Human-readable declared cargo: "2 × 20FT", "12 t", "40 items". */
|
||||
export function bookingRequestedCargoSql(alias = 'b'): string {
|
||||
return `(SELECT COALESCE(
|
||||
(SELECT string_agg((l->>'quantity') || ' × ' || upper(l->>'containerSize'), ', '
|
||||
ORDER BY l->>'containerSize')
|
||||
FROM ${REQUESTED_CONTAINER_LINES} AS l),
|
||||
NULLIF(br.requested_lines->'bulk'->>'cargoWeightTons', '') || ' t',
|
||||
NULLIF(br.requested_lines->'bulk'->>'itemCount', '') || ' items')
|
||||
FROM freight.booking_requests br
|
||||
WHERE br.created_booking_id = ${alias}.id
|
||||
AND br.deleted_at IS NULL
|
||||
ORDER BY br.created_at DESC
|
||||
LIMIT 1)`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Boxes declared on the shipment request. Pairs with the real container count:
|
||||
* `Containers = 0` AND `Requested containers >= 1` is exactly the set awaiting
|
||||
* completion.
|
||||
*/
|
||||
export function bookingRequestedContainerCountSql(alias = 'b'): string {
|
||||
return `(SELECT COALESCE(SUM((l->>'quantity')::int), 0)
|
||||
FROM freight.booking_requests br
|
||||
CROSS JOIN LATERAL ${REQUESTED_CONTAINER_LINES} AS l
|
||||
WHERE br.created_booking_id = ${alias}.id
|
||||
AND br.deleted_at IS NULL)`;
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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)`;
|
||||
}
|
||||
@@ -836,15 +836,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) {
|
||||
@@ -1875,9 +1875,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,
|
||||
|
||||
@@ -21,6 +21,13 @@ import { ShippingLineCompany } from '../shipping-lines/entities/shipping-line-co
|
||||
import { ContractRateSnapshot } from '../contracts/entities/contract-rate-snapshot.entity';
|
||||
import { ContractRoute } from '../contracts/entities/contract-route.entity';
|
||||
import { applyDirectionScope } from '../user-trade-access/trade-scope.util';
|
||||
import {
|
||||
CARGO_TYPE_SUBTREE_SQL,
|
||||
bookingContainerCountSql,
|
||||
bookingContentMatchSql,
|
||||
bookingHasContainerTypeSql,
|
||||
bookingRequestedContainerCountSql,
|
||||
} from './booking-content.sql';
|
||||
import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity';
|
||||
import {
|
||||
BookingDocumentReview,
|
||||
@@ -65,7 +72,17 @@ export interface BookingListFilterOptions {
|
||||
contractId?: string;
|
||||
contractType?: string;
|
||||
serviceTypeId?: string;
|
||||
/** Cargo type OR cargo group — a group matches every commodity beneath it. */
|
||||
cargoTypeId?: string;
|
||||
/** Contains-search over content: description, commodity name, container types. */
|
||||
cargoText?: string;
|
||||
/** Bookings carrying this container type; also scopes the container count. */
|
||||
containerTypeId?: string;
|
||||
containersMin?: number;
|
||||
containersMax?: number;
|
||||
/** Bounds on containers declared on the shipment request behind the booking. */
|
||||
requestedContainersMin?: number;
|
||||
requestedContainersMax?: number;
|
||||
freightType?: string;
|
||||
bookingType?: string;
|
||||
tradeDirection?: string;
|
||||
@@ -1176,11 +1193,55 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
serviceTypeId: options.serviceTypeId,
|
||||
});
|
||||
}
|
||||
// A group is selectable in the filter, not just a leaf commodity, so this
|
||||
// matches the whole subtree — picking "Bulk" must return every commodity
|
||||
// under it, the same drill-down the booking wizard offers, read back.
|
||||
if (options.cargoTypeId) {
|
||||
qb.andWhere('booking.cargo_type_id = :cargoTypeId', {
|
||||
qb.andWhere(`booking.cargo_type_id IN ${CARGO_TYPE_SUBTREE_SQL}`, {
|
||||
cargoTypeId: options.cargoTypeId,
|
||||
});
|
||||
}
|
||||
if (options.cargoText) {
|
||||
qb.andWhere(bookingContentMatchSql('booking'), {
|
||||
cargoText: `%${options.cargoText}%`,
|
||||
});
|
||||
}
|
||||
if (options.containerTypeId) {
|
||||
qb.andWhere(bookingHasContainerTypeSql('booking'), {
|
||||
containerTypeId: options.containerTypeId,
|
||||
});
|
||||
}
|
||||
// One count filter, two questions: with a container type picked it counts
|
||||
// that type, without one it counts every box on the booking.
|
||||
if (options.containersMin != null || options.containersMax != null) {
|
||||
const count = bookingContainerCountSql(
|
||||
'booking',
|
||||
Boolean(options.containerTypeId),
|
||||
);
|
||||
if (options.containersMin != null) {
|
||||
qb.andWhere(`${count} >= :containersMin`, {
|
||||
containersMin: options.containersMin,
|
||||
});
|
||||
}
|
||||
if (options.containersMax != null) {
|
||||
qb.andWhere(`${count} <= :containersMax`, {
|
||||
containersMax: options.containersMax,
|
||||
});
|
||||
}
|
||||
}
|
||||
// Declared on the shipment request, not on the booking. Pairs with the
|
||||
// count above: containers 0..0 AND requested >= 1 is the set awaiting
|
||||
// completion after clearance.
|
||||
if (options.requestedContainersMin != null) {
|
||||
qb.andWhere(`${bookingRequestedContainerCountSql('booking')} >= :requestedContainersMin`, {
|
||||
requestedContainersMin: options.requestedContainersMin,
|
||||
});
|
||||
}
|
||||
if (options.requestedContainersMax != null) {
|
||||
qb.andWhere(`${bookingRequestedContainerCountSql('booking')} <= :requestedContainersMax`, {
|
||||
requestedContainersMax: options.requestedContainersMax,
|
||||
});
|
||||
}
|
||||
if (omit !== 'freightType' && options.freightType) {
|
||||
qb.andWhere('booking.freight_type = :freightType', {
|
||||
freightType: options.freightType,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -1,4 +1,17 @@
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
import { FREIGHT_PERMS } from '../../../seed/freight-permissions.registry';
|
||||
import {
|
||||
CARGO_TYPE_SUBTREE_SQL,
|
||||
bookingContainerCountSql,
|
||||
bookingContentMatchSql,
|
||||
bookingContainerVgmSql,
|
||||
bookingContentSql,
|
||||
bookingHasContainerTypeSql,
|
||||
bookingRequestedCargoSql,
|
||||
bookingRequestedContainerCountSql,
|
||||
} from '../../bookings/booking-content.sql';
|
||||
import { bookingTonsSql } from '../../bookings/booking-tons.sql';
|
||||
import { Booking } from '../../bookings/entities/booking.entity';
|
||||
import { Company } from '../../companies/entities/company.entity';
|
||||
import { CompanyProfile } from '../../companies/entities/company-profile.entity';
|
||||
@@ -10,23 +23,104 @@ import { Yard } from '../../rule-engine/entities/yard.entity';
|
||||
import { ShippingLineCompany } from '../../shipping-lines/entities/shipping-line-company.entity';
|
||||
import { Train } from '../../trains/entities/train.entity';
|
||||
import { applyDirectionScope } from '../../user-trade-access/trade-scope.util';
|
||||
import { ExportDataset } from '../export.types';
|
||||
import { ExportFilterOption } from '../export-filter.util';
|
||||
import { ExportDataset, ExportField } from '../export.types';
|
||||
|
||||
/**
|
||||
* Domain semantics that the retired `bookings-list` report used to share.
|
||||
* Kept identical on purpose — for PER_ITEM bulk bookings `cargo_total_weight_vgm`
|
||||
* holds an item COUNT, not tonnage, and `adjusted_total_amount` silently
|
||||
* overrides `total_amount`. Getting either wrong misreports money or weight.
|
||||
* Tonnage is `bookingTonsSql` — the one resolver for the three ways a booking
|
||||
* stores its weight. `adjusted_total_amount` silently overrides `total_amount`.
|
||||
* Getting either wrong misreports money or weight.
|
||||
*/
|
||||
const TONS = 'COALESCE(b.bulk_total_weight_tons, b.cargo_total_weight_vgm)';
|
||||
const TONS = bookingTonsSql('b');
|
||||
const REVENUE = 'COALESCE(b.adjusted_total_amount, b.total_amount)';
|
||||
|
||||
/** What the customer described as the booking's contents — see the helper. */
|
||||
const CONTENT = bookingContentSql('b');
|
||||
const CONTAINER_COUNT = bookingContainerCountSql('b');
|
||||
const REQUESTED_COUNT = bookingRequestedContainerCountSql('b');
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
'DRAFT', 'SUBMITTED', 'UNDER_REVIEW', 'APPROVED', 'REJECTED',
|
||||
'CANCELLED', 'EXPIRED', 'SCHEDULED', 'LOADED', 'IN_TRANSIT',
|
||||
'ARRIVED', 'DELIVERED', 'COMPLETED',
|
||||
].map((v) => ({ value: v, label: v.replace(/_/g, ' ') }));
|
||||
|
||||
/**
|
||||
* Cargo tree flattened for a single select: groups and every commodity beneath
|
||||
* them, each labelled by its full path ("Bulk → Wheat") the way the booking
|
||||
* wizard shows a deep leaf. Picking a group row filters its whole subtree.
|
||||
*
|
||||
* Recursive because `cargo_types` is arbitrary-depth, not two levels.
|
||||
*/
|
||||
async function cargoTypeOptions(ds: DataSource): Promise<ExportFilterOption[]> {
|
||||
return ds.query(`
|
||||
WITH RECURSIVE t AS (
|
||||
SELECT id, display_order, 0 AS depth,
|
||||
ARRAY[display_order]::int[] AS ord,
|
||||
ARRAY[cargo_type_name]::text[] AS path
|
||||
FROM freight.cargo_types
|
||||
WHERE parent_group_id IS NULL AND deleted_at IS NULL AND is_active
|
||||
UNION ALL
|
||||
SELECT c.id, c.display_order, t.depth + 1,
|
||||
t.ord || c.display_order,
|
||||
t.path || c.cargo_type_name
|
||||
FROM freight.cargo_types c
|
||||
JOIN t ON c.parent_group_id = t.id
|
||||
WHERE c.deleted_at IS NULL AND c.is_active
|
||||
)
|
||||
SELECT id AS value, array_to_string(path, ' → ') AS label
|
||||
FROM t ORDER BY ord, path
|
||||
`) as Promise<ExportFilterOption[]>;
|
||||
}
|
||||
|
||||
/** Container types are 2 rows that change about never. */
|
||||
async function containerTypeOptions(ds: DataSource): Promise<ExportFilterOption[]> {
|
||||
return ds.query(`
|
||||
SELECT id AS value, COALESCE(label, code) AS label
|
||||
FROM freight.container_types
|
||||
WHERE deleted_at IS NULL AND is_active
|
||||
ORDER BY display_order, code
|
||||
`) as Promise<ExportFilterOption[]>;
|
||||
}
|
||||
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
/**
|
||||
* One column per container type ("20FT", "40FT", …), each the box count of
|
||||
* that type on the booking. Resolved from `container_types` rather than
|
||||
* hardcoded, so adding a 45ft adds its column without a deploy of this file.
|
||||
*
|
||||
* The type id is INTERPOLATED, not bound — `ExportField.select` is a raw SQL
|
||||
* string with no parameter bag — so ids that are not uuids are dropped rather
|
||||
* than spliced. They come from our own table; the guard is for the day someone
|
||||
* changes that column's type.
|
||||
*/
|
||||
async function containerTypeFields(ds: DataSource): Promise<ExportField[]> {
|
||||
const rows: Array<{ id: string; code: string; label: string | null }> = await ds.query(`
|
||||
SELECT id, code, label
|
||||
FROM freight.container_types
|
||||
WHERE deleted_at IS NULL AND is_active
|
||||
ORDER BY display_order, code
|
||||
`);
|
||||
return rows
|
||||
.filter((r) => UUID_RE.test(r.id))
|
||||
.map((r) => {
|
||||
const name = r.label || r.code;
|
||||
return {
|
||||
key: `containers${r.code.replace(/[^A-Za-z0-9]/g, '')}`,
|
||||
label: `${name} containers`,
|
||||
type: 'number' as const,
|
||||
group: 'cargo',
|
||||
select: `(SELECT COALESCE(SUM(bc.quantity), 0)
|
||||
FROM freight.booking_container bc
|
||||
WHERE bc.booking_id = b.id
|
||||
AND bc.deleted_at IS NULL
|
||||
AND bc.container_type_id = '${r.id}')::int`,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export const bookingsDataset: ExportDataset = {
|
||||
key: 'bookings',
|
||||
title: 'Bookings',
|
||||
@@ -112,10 +206,21 @@ export const bookingsDataset: ExportDataset = {
|
||||
{ key: 'serviceType', label: 'Service type', type: 'string', group: 'route', requires: ['st'], select: 'st.service_name' },
|
||||
|
||||
// ---- Cargo -----------------------------------------------------------
|
||||
{ key: 'cargo', label: 'Cargo', type: 'string', group: 'cargo', default: true, requires: ['cty'], select: 'COALESCE(cty.cargo_type_name, b.cargo_free_text)' },
|
||||
// What the customer said is in the booking. `cargo` below is the narrower
|
||||
// commodity-only view, kept for saved presets that already tick it.
|
||||
{ key: 'content', label: 'Content', type: 'string', group: 'cargo', default: true, select: CONTENT, sortExpr: CONTENT },
|
||||
{ key: 'cargo', label: 'Cargo (commodity)', type: 'string', group: 'cargo', requires: ['cty'], select: 'COALESCE(cty.cargo_type_name, b.cargo_free_text)' },
|
||||
{ key: 'cargoDescription', label: 'Cargo description', type: 'string', group: 'cargo', select: 'b.cargo_free_text' },
|
||||
// Boxes, not lines: booking_container is one row per LINE with a quantity.
|
||||
{ key: 'containerCount', label: 'Containers', type: 'number', group: 'cargo', default: true, select: `${CONTAINER_COUNT}::int`, sortExpr: CONTAINER_COUNT },
|
||||
// Declared on the shipment request, not yet on the booking — see the helper.
|
||||
{ key: 'requestedCargo', label: 'Requested cargo', type: 'string', group: 'cargo', select: bookingRequestedCargoSql('b') },
|
||||
{ key: 'requestedContainers', label: 'Requested containers', type: 'number', group: 'cargo', select: `${REQUESTED_COUNT}::int`, sortExpr: REQUESTED_COUNT },
|
||||
{ key: 'freightType', label: 'Freight type', type: 'string', group: 'cargo', default: true, select: 'b.freight_type' },
|
||||
{ key: 'tons', label: 'Tonnage', type: 'tons', group: 'cargo', default: true, select: `ROUND(${TONS})::float8`, sortExpr: TONS },
|
||||
{ key: 'containerWeightVgm', label: 'Container VGM', type: 'number', group: 'cargo', select: 'b.cargo_total_weight_vgm' },
|
||||
// The per-line sum, NOT b.cargo_total_weight_vgm — the portal leaves that
|
||||
// column at 0 for container freight, so it read 0 for every such booking.
|
||||
{ key: 'containerWeightVgm', label: 'Container VGM (t)', type: 'tons', group: 'cargo', select: `${bookingContainerVgmSql('b')}::float8`, sortExpr: bookingContainerVgmSql('b') },
|
||||
{ key: 'bulkWeightTons', label: 'Bulk weight (t)', type: 'tons', group: 'cargo', select: 'b.bulk_total_weight_tons' },
|
||||
{ key: 'isHazardous', label: 'Hazardous', type: 'boolean', group: 'cargo', select: 'b.is_hazardous' },
|
||||
{ key: 'isReefer', label: 'Reefer', type: 'boolean', group: 'cargo', select: 'b.is_reefer' },
|
||||
@@ -172,6 +277,8 @@ export const bookingsDataset: ExportDataset = {
|
||||
{ key: 'doubleHandling', label: 'Double handling', type: 'boolean', group: 'clearance', select: 'b.double_handling' },
|
||||
],
|
||||
|
||||
dynamicFields: containerTypeFields,
|
||||
|
||||
filters: [
|
||||
{ key: 'created', label: 'Created', type: 'daterange' },
|
||||
{ key: 'statuses', label: 'Status', type: 'multiselect', options: STATUS_OPTIONS },
|
||||
@@ -190,6 +297,13 @@ export const bookingsDataset: ExportDataset = {
|
||||
{ value: 'PAID', label: 'Paid' },
|
||||
{ value: 'FAILED', label: 'Failed' },
|
||||
] },
|
||||
{ key: 'cargoTypeId', label: 'Content (cargo type)', type: 'select', optionsQuery: cargoTypeOptions },
|
||||
{ key: 'cargoText', label: 'Content contains', type: 'text' },
|
||||
{ key: 'containerTypeId', label: 'Container type', type: 'select', optionsQuery: containerTypeOptions },
|
||||
{ key: 'containersMin', label: 'Containers (min)', type: 'text' },
|
||||
{ key: 'containersMax', label: 'Containers (max)', type: 'text' },
|
||||
{ key: 'requestedContainersMin', label: 'Requested containers (min)', type: 'text' },
|
||||
{ key: 'requestedContainersMax', label: 'Requested containers (max)', type: 'text' },
|
||||
{ key: 'companyId', label: 'Customer', type: 'text' },
|
||||
{ key: 'search', label: 'Search reference or customer', type: 'text' },
|
||||
],
|
||||
@@ -210,6 +324,23 @@ export const bookingsDataset: ExportDataset = {
|
||||
|
||||
if (params.tradeDirection) qb.andWhere('b.trade_direction = :tradeDirection', { tradeDirection: params.tradeDirection });
|
||||
if (params.freightType) qb.andWhere('b.freight_type = :freightType', { freightType: params.freightType });
|
||||
// Group or leaf — a group matches its whole subtree (see CARGO_TYPE_SUBTREE_SQL).
|
||||
if (params.cargoTypeId) qb.andWhere(`b.cargo_type_id IN ${CARGO_TYPE_SUBTREE_SQL}`, { cargoTypeId: params.cargoTypeId });
|
||||
if (params.cargoText) qb.andWhere(bookingContentMatchSql('b'), { cargoText: `%${params.cargoText as string}%` });
|
||||
if (params.containerTypeId) qb.andWhere(bookingHasContainerTypeSql('b'), { containerTypeId: params.containerTypeId });
|
||||
// With a container type picked the count is of THAT type, else of every box.
|
||||
const containerCount = bookingContainerCountSql('b', Boolean(params.containerTypeId));
|
||||
// coerceFilterParams yields null (not undefined) for an unset filter, and
|
||||
// Number(null) is 0 — which would silently apply ">= 0" to every export.
|
||||
const num = (v: unknown) => (v == null || v === '' ? NaN : Number(v));
|
||||
const min = num(params.containersMin);
|
||||
const max = num(params.containersMax);
|
||||
if (Number.isFinite(min)) qb.andWhere(`${containerCount} >= :containersMin`, { containersMin: min });
|
||||
if (Number.isFinite(max)) qb.andWhere(`${containerCount} <= :containersMax`, { containersMax: max });
|
||||
const reqMin = num(params.requestedContainersMin);
|
||||
const reqMax = num(params.requestedContainersMax);
|
||||
if (Number.isFinite(reqMin)) qb.andWhere(`${REQUESTED_COUNT} >= :requestedContainersMin`, { requestedContainersMin: reqMin });
|
||||
if (Number.isFinite(reqMax)) qb.andWhere(`${REQUESTED_COUNT} <= :requestedContainersMax`, { requestedContainersMax: reqMax });
|
||||
if (params.paymentStatus) qb.andWhere('b.payment_status = :paymentStatus', { paymentStatus: params.paymentStatus });
|
||||
if (params.companyId) qb.andWhere('b.company_id = :companyId', { companyId: params.companyId });
|
||||
if (params.search) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)`,
|
||||
},
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
import type { ExportField } from './export.types';
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
export type ExportFilterType = 'daterange' | 'date' | 'select' | 'multiselect' | 'text';
|
||||
@@ -64,6 +66,26 @@ export function coerceFilterParams(
|
||||
*/
|
||||
const optionsCache = new Map<string, ExportFilterOption[]>();
|
||||
|
||||
/** Process-lifetime cache for `dynamicFields`, keyed by dataset. */
|
||||
const fieldsCache = new Map<string, ExportField[]>();
|
||||
|
||||
/**
|
||||
* A dataset's full field list: its static fields plus whatever `dynamicFields`
|
||||
* resolves from the DB. Every read of `dataset.fields` goes through this, so
|
||||
* the catalog and the download agree on which keys exist.
|
||||
*/
|
||||
export async function resolveDatasetFields(
|
||||
dataset: { key: string; fields: ExportField[]; dynamicFields?: (ds: DataSource) => Promise<ExportField[]> },
|
||||
ds: DataSource,
|
||||
): Promise<ExportField[]> {
|
||||
if (!dataset.dynamicFields) return dataset.fields;
|
||||
const cached = fieldsCache.get(dataset.key);
|
||||
if (cached) return cached;
|
||||
const resolved = [...dataset.fields, ...(await dataset.dynamicFields(ds))];
|
||||
fieldsCache.set(dataset.key, resolved);
|
||||
return resolved;
|
||||
}
|
||||
|
||||
export async function resolveFilterOptions(
|
||||
filters: ExportFilterDef[],
|
||||
ds: DataSource,
|
||||
|
||||
@@ -103,6 +103,15 @@ export interface ExportDataset {
|
||||
alwaysJoin?: string[];
|
||||
groups: ExportGroup[];
|
||||
fields: ExportField[];
|
||||
/**
|
||||
* Extra fields resolved from reference data and appended to `fields` — one
|
||||
* column per row of some small, rarely-changing table (a column per container
|
||||
* type, say). Cached for the process, like `ExportFilterDef.optionsQuery`.
|
||||
*
|
||||
* The SQL these build is interpolated, not bound, so a resolver MUST validate
|
||||
* anything it splices in; see `bookingsDataset` for the uuid guard.
|
||||
*/
|
||||
dynamicFields?: (ds: DataSource) => Promise<ExportField[]>;
|
||||
filters: ExportFilterDef[];
|
||||
/** Must name a field whose `sortExpr` references only the base alias. */
|
||||
defaultSort?: { key: string; dir: 'ASC' | 'DESC' };
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -27,6 +27,15 @@ export class EmptyContainerReturn extends BaseEntity {
|
||||
@Column({ name: 'customer_id', type: 'uuid', nullable: true })
|
||||
customerId?: string | null;
|
||||
|
||||
/**
|
||||
* Owning company as text. Set when the box was backfilled for a company that
|
||||
* is not (yet) a registered customer, so `customer_id` cannot carry it. When
|
||||
* a registered company IS picked, both are set — the name is the label the
|
||||
* list renders without a join.
|
||||
*/
|
||||
@Column({ name: 'company_name', type: 'varchar', length: 200, nullable: true })
|
||||
companyName?: string | null;
|
||||
|
||||
@Column({ name: 'return_date', type: 'timestamptz' })
|
||||
returnDate!: Date;
|
||||
|
||||
@@ -75,3 +84,14 @@ export class EmptyContainerReturn extends BaseEntity {
|
||||
performedBy: string | null;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A row of the returns list: the entity's own columns plus the booking
|
||||
* reference and owning company joined in. Standalone returns leave
|
||||
* `bookingId`/`bookingReference` null.
|
||||
*/
|
||||
export interface EmptyContainerReturnListItem
|
||||
extends Omit<EmptyContainerReturn, 'createdAt' | 'updatedAt' | 'deletedAt'> {
|
||||
bookingReference: string | null;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { In, Repository } from 'typeorm';
|
||||
import { In, Not, Repository } from 'typeorm';
|
||||
|
||||
import { NotificationAudience, NotificationType } from '@edr/types';
|
||||
import { LogoSettingsService } from '../logo-settings/logo-settings.service';
|
||||
@@ -10,6 +10,7 @@ import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { sendCompanyChannels } from '../notifications/notify-company.util';
|
||||
import { WarehouseReleaseDocumentService } from '../warehouses/warehouse-release-document.service';
|
||||
import {
|
||||
BulkCreateEmptyContainerReturnsDto,
|
||||
CreateDjiboutiIncidentDto,
|
||||
CreateEmptyContainerReturnDto,
|
||||
ImportOperationActionDto,
|
||||
@@ -24,7 +25,11 @@ import {
|
||||
type DjiboutiIncidentType,
|
||||
} from './entities/djibouti-incident.entity';
|
||||
import { assertWagonLoad } from './empty-container-wagon.util';
|
||||
import { EmptyContainerReturn } from './entities/empty-container-return.entity';
|
||||
import {
|
||||
EmptyContainerReturn,
|
||||
type EmptyContainerReturnListItem,
|
||||
type EmptyContainerReturnStatus,
|
||||
} from './entities/empty-container-return.entity';
|
||||
import {
|
||||
ImportCustomsFinalization,
|
||||
type ImportCustomsDocumentType,
|
||||
@@ -159,8 +164,43 @@ export class ImportOperationsService {
|
||||
return this.getCustoms(bookingId);
|
||||
}
|
||||
|
||||
listEmptyReturns() {
|
||||
return this.emptyReturns.find({ order: { createdAt: 'DESC' } as never });
|
||||
/**
|
||||
* Every empty return, booking-linked and standalone alike, in one list. The
|
||||
* booking reference and the owning company are joined in so the table can
|
||||
* show which booking a box came back on without a second round trip — a
|
||||
* standalone row simply has neither, and falls back to the typed
|
||||
* `company_name`.
|
||||
*/
|
||||
listEmptyReturns(): Promise<EmptyContainerReturnListItem[]> {
|
||||
return this.emptyReturns.manager.query(`
|
||||
SELECT
|
||||
r.id,
|
||||
r.container_number AS "containerNumber",
|
||||
r.booking_id AS "bookingId",
|
||||
b.reference AS "bookingReference",
|
||||
r.customer_id AS "customerId",
|
||||
COALESCE(r.company_name, c.name) AS "companyName",
|
||||
r.return_date AS "returnDate",
|
||||
r.facility,
|
||||
r.yard,
|
||||
r.zone,
|
||||
r.condition,
|
||||
r.handover_note AS "handoverNote",
|
||||
r.status,
|
||||
r.wagon_allocation_reference AS "wagonAllocationReference",
|
||||
r.container_size AS "containerSize",
|
||||
r.train_schedule_id AS "trainScheduleId",
|
||||
r.wagon_sequence_no AS "wagonSequenceNo",
|
||||
r.performed_by AS "performedBy",
|
||||
r.returned_by AS "returnedBy",
|
||||
r.status_history AS "statusHistory",
|
||||
r.created_at AS "createdAt"
|
||||
FROM freight.empty_container_returns r
|
||||
LEFT JOIN freight.bookings b ON b.id = r.booking_id
|
||||
LEFT JOIN freight.companies c ON c.id = b.company_id
|
||||
WHERE r.deleted_at IS NULL
|
||||
ORDER BY r.created_at DESC
|
||||
`);
|
||||
}
|
||||
|
||||
listEmptyReturnsForBooking(bookingId: string) {
|
||||
@@ -174,6 +214,7 @@ export class ImportOperationsService {
|
||||
containerNumber: dto.containerNumber,
|
||||
bookingId: dto.bookingId ?? null,
|
||||
customerId: dto.customerId ?? null,
|
||||
companyName: dto.companyName ?? null,
|
||||
returnDate,
|
||||
containerSize: dto.containerSize ?? null,
|
||||
facility: dto.facility ?? null,
|
||||
@@ -199,6 +240,66 @@ export class ImportOperationsService {
|
||||
return saved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk backfill of empties already sitting in a yard but never recorded.
|
||||
* All-or-nothing: if any container number already has an open (not COMPLETED)
|
||||
* return, nothing is written — re-uploading the same sheet must not duplicate
|
||||
* boxes. No interchange notification is sent; these are historical rows, not
|
||||
* a live handover.
|
||||
*/
|
||||
async bulkCreateEmptyReturns(dto: BulkCreateEmptyContainerReturnsDto) {
|
||||
const numbers = dto.returns.map((r) => r.containerNumber.trim().toUpperCase());
|
||||
|
||||
const seen = new Set<string>();
|
||||
const dupInFile = numbers.filter((n) => (seen.has(n) ? true : (seen.add(n), false)));
|
||||
if (dupInFile.length > 0) {
|
||||
throw new BadRequestException(
|
||||
`Container number(s) repeated in the upload: ${[...new Set(dupInFile)].join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
const existing = await this.emptyReturns.find({
|
||||
where: {
|
||||
containerNumber: In(numbers),
|
||||
status: Not('COMPLETED' as EmptyContainerReturnStatus),
|
||||
},
|
||||
select: { containerNumber: true },
|
||||
});
|
||||
if (existing.length > 0) {
|
||||
throw new BadRequestException(
|
||||
`Already recorded as returned: ${existing.map((r) => r.containerNumber).join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
const rows = dto.returns.map((r, i) => {
|
||||
const returnDate = r.returnDate ? new Date(r.returnDate) : new Date();
|
||||
return this.emptyReturns.create({
|
||||
containerNumber: numbers[i],
|
||||
bookingId: r.bookingId ?? null,
|
||||
customerId: r.customerId ?? null,
|
||||
companyName: r.companyName ?? null,
|
||||
returnDate,
|
||||
containerSize: r.containerSize ?? null,
|
||||
facility: r.facility ?? null,
|
||||
yard: r.yard ?? null,
|
||||
zone: r.zone ?? null,
|
||||
condition: r.condition ?? null,
|
||||
handoverNote: r.handoverNote ?? null,
|
||||
performedBy: r.performedBy ?? null,
|
||||
returnedBy: r.returnedBy ?? null,
|
||||
statusHistory: [
|
||||
{
|
||||
status: 'RETURNED' as const,
|
||||
changedAt: returnDate.toISOString(),
|
||||
performedBy: r.performedBy ?? null,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
return this.emptyReturns.save(rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load returned empties onto an export departure. A wagon takes ONE 40ft or
|
||||
* TWO 20ft — never a mix, never three. Empties already sitting on a wagon of
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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'];
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
|
||||
|
||||
import { bookingTonsSql } from '../../bookings/booking-tons.sql';
|
||||
import { Company } from '../../companies/entities/company.entity';
|
||||
import { Contract } from '../../contracts/entities/contract.entity';
|
||||
import { ReportContext, ReportDefinition } from '../report.types';
|
||||
|
||||
const TONS = 'COALESCE(b.bulk_total_weight_tons, b.cargo_total_weight_vgm)';
|
||||
const TONS = bookingTonsSql('b');
|
||||
const DEAD_STATUSES = ['DRAFT', 'CANCELLED', 'REJECTED', 'EXPIRED'];
|
||||
|
||||
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -1379,6 +1379,8 @@ describe('BookingBatchService — built-train wagon capacity', () => {
|
||||
maxWagons?: number;
|
||||
routeStops?: string[];
|
||||
yardCountries?: Record<string, string>;
|
||||
maxPullWeightTons?: number;
|
||||
maxTrainLengthMeters?: number;
|
||||
}) => {
|
||||
const schedule = {
|
||||
id: scheduleId,
|
||||
@@ -1390,8 +1392,11 @@ describe('BookingBatchService — built-train wagon capacity', () => {
|
||||
scheduleBookings: [],
|
||||
trainSet: {
|
||||
locomotive: {
|
||||
maxPullWeightTons: 1,
|
||||
maxTrainLengthMeters: 1,
|
||||
// Roomy on purpose: these cases exercise the SLOT axis, so the pull
|
||||
// budget must not be what closes the train. Weight-bound behaviour
|
||||
// has its own cases below.
|
||||
maxPullWeightTons: opts.maxPullWeightTons ?? 100000,
|
||||
maxTrainLengthMeters: opts.maxTrainLengthMeters ?? 100000,
|
||||
overageToleranceTons: 0,
|
||||
overageToleranceMeters: 0,
|
||||
},
|
||||
@@ -1459,15 +1464,28 @@ describe('BookingBatchService — built-train wagon capacity', () => {
|
||||
await expect(service.isScheduleFull(scheduleId)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('is NOT full while physical wagons remain, ignoring weight/length limits', async () => {
|
||||
it('is NOT full while physical wagons remain and the loco can still haul them', async () => {
|
||||
const { service } = buildService({
|
||||
physicalWagons: 3,
|
||||
reserved: [reservedBooking('b1'), reservedBooking('b2')],
|
||||
});
|
||||
// 1T pull cap would have been exhausted long ago under the old math.
|
||||
await expect(service.isScheduleFull(scheduleId)).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('is FULL when the locomotive cannot pull another wagon, though slots are free', async () => {
|
||||
// The consist has a spare slot, but every wagon spends its TARE out of the
|
||||
// same pull limit the cargo needs — so a slot-free train can still be
|
||||
// weight-full. This is what let a 44-wagon booking plan 4065T gross onto a
|
||||
// 3500T train while the board advertised free wagons.
|
||||
const { service } = buildService({
|
||||
physicalWagons: 3,
|
||||
reserved: [reservedBooking('b1'), reservedBooking('b2')],
|
||||
maxPullWeightTons: 1,
|
||||
maxTrainLengthMeters: 1,
|
||||
});
|
||||
await expect(service.isScheduleFull(scheduleId)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('is NOT full when only a middle leg is sold and other edges run free (domestic route)', async () => {
|
||||
// Leg-aware allocation (planWagonsWithStock legs) made mid-leg wagons real
|
||||
// capacity on the edges they don't ride: a domestic corridor with cargo
|
||||
|
||||
@@ -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
|
||||
@@ -5376,10 +5404,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 +5423,16 @@ export class BookingBatchService implements OnModuleInit {
|
||||
): Promise<CorridorBudget> {
|
||||
const physicalWagons = await this.builtTrainWagonCount(schedule);
|
||||
if (physicalWagons != null) {
|
||||
// The consist fixes the SLOT count (never the locomotive's length-derived
|
||||
// estimate), but weight and length stay on the locomotive's real budget —
|
||||
// including its overage tolerance, which `fits` may spend on a whole unit.
|
||||
limits = {
|
||||
base: {
|
||||
wagons: physicalWagons,
|
||||
weightTons: Number.POSITIVE_INFINITY,
|
||||
lengthMeters: Number.POSITIVE_INFINITY,
|
||||
weightTons: limits.base.weightTons,
|
||||
lengthMeters: limits.base.lengthMeters,
|
||||
},
|
||||
tolerance: { weightTons: 0, lengthMeters: 0 },
|
||||
tolerance: limits.tolerance,
|
||||
};
|
||||
}
|
||||
// Built trains keep the leg-aware multi-edge corridor too: the wagon
|
||||
@@ -5631,19 +5665,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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
|
||||
import { TrainSchedulingService } from './services/train-scheduling.service';
|
||||
|
||||
/**
|
||||
* Per-wagon loading dispatch gate. A booking half-loaded at the DEPARTURE yard
|
||||
* blocks the train; a booking that boards further down the corridor
|
||||
* (A→B→C→D carrying a B→C load) never does — its wagons are not due until its
|
||||
* own yard, so the SQL is scoped by `b.origin_yard_id = <schedule origin>`.
|
||||
* The scoping lives in the query, so this checks the parameters that carry it
|
||||
* plus the throw/pass decision on the rows it returns.
|
||||
*/
|
||||
describe('TrainSchedulingService.assertNoPartiallyLoadedBookings', () => {
|
||||
const ORIGIN = 'yard-a';
|
||||
const SET = 'set-1';
|
||||
|
||||
const makeService = (rows: Array<{ reference: string; loaded: string; total: string }>) => {
|
||||
const calls: Array<{ sql: string; params: unknown[] }> = [];
|
||||
const svc = Object.create(TrainSchedulingService.prototype) as {
|
||||
dataSource: { query: (sql: string, params: unknown[]) => Promise<unknown> };
|
||||
assertNoPartiallyLoadedBookings(
|
||||
schedule: unknown,
|
||||
boardingYardId: string,
|
||||
context: { action: string; yardLabel?: string },
|
||||
): Promise<void>;
|
||||
assertPassedYardsFullyLoaded(
|
||||
schedule: unknown,
|
||||
stations: Array<{ sequenceNo: number; yardId: string; label: string }>,
|
||||
sequenceNo: number,
|
||||
): Promise<void>;
|
||||
};
|
||||
svc.dataSource = {
|
||||
query: async (sql: string, params: unknown[]) => {
|
||||
calls.push({ sql, params });
|
||||
return rows;
|
||||
},
|
||||
};
|
||||
return { svc, calls };
|
||||
};
|
||||
const schedule = { trainSetId: SET, originStationId: ORIGIN };
|
||||
|
||||
it('scopes the scan to bookings boarding at this departure yard', async () => {
|
||||
const { svc, calls } = makeService([]);
|
||||
await svc.assertNoPartiallyLoadedBookings(schedule, ORIGIN, { action: 'dispatch' });
|
||||
expect(calls).toHaveLength(1);
|
||||
// The origin filter is what keeps a mid-corridor booking from holding the
|
||||
// train — without it, one early-loaded B→C wagon blocks dispatch at A.
|
||||
expect(calls[0].sql).toContain('b.origin_yard_id = $2');
|
||||
expect(calls[0].params).toEqual([SET, ORIGIN]);
|
||||
});
|
||||
|
||||
it('lets the train go when nothing at this yard is half-loaded', async () => {
|
||||
const { svc } = makeService([]);
|
||||
await expect(
|
||||
svc.assertNoPartiallyLoadedBookings(schedule, ORIGIN, { action: 'dispatch' }),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('blocks a booking half-loaded at this yard, naming its progress', async () => {
|
||||
const { svc } = makeService([{ reference: 'BK-2026-000220', loaded: '4', total: '5' }]);
|
||||
await expect(
|
||||
svc.assertNoPartiallyLoadedBookings(schedule, ORIGIN, { action: 'dispatch' }),
|
||||
).rejects.toThrow(/BK-2026-000220 \(4\/5 wagons loaded\)/);
|
||||
await expect(
|
||||
svc.assertNoPartiallyLoadedBookings(schedule, ORIGIN, { action: 'dispatch' }),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('skips the scan entirely for a schedule with no train set', async () => {
|
||||
const { svc, calls } = makeService([{ reference: 'X', loaded: '1', total: '2' }]);
|
||||
await expect(
|
||||
svc.assertNoPartiallyLoadedBookings({ trainSetId: null }, ORIGIN, {
|
||||
action: 'dispatch',
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
expect(calls).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Mid-corridor twin: logging a checkpoint at station N means the train left
|
||||
* every earlier stop, so each of those yards is checked for its OWN
|
||||
* half-loaded bookings. The origin is excluded (dispatch gated it) and the
|
||||
* yard being arrived at is excluded (its loading has not happened yet).
|
||||
*/
|
||||
describe('TrainSchedulingService.assertPassedYardsFullyLoaded', () => {
|
||||
const STATIONS = [
|
||||
{ sequenceNo: 0, yardId: 'mojo', label: 'Mojo' },
|
||||
{ sequenceNo: 1, yardId: 'adama', label: 'Adama' },
|
||||
{ sequenceNo: 2, yardId: 'dire', label: 'Dire Dawa' },
|
||||
{ sequenceNo: 3, yardId: 'djibouti', label: 'Djibouti' },
|
||||
];
|
||||
|
||||
const makeService = (rowsByYard: Record<string, Array<Record<string, string>>>) => {
|
||||
const scanned: string[] = [];
|
||||
const svc = Object.create(TrainSchedulingService.prototype) as {
|
||||
dataSource: { query: (sql: string, params: unknown[]) => Promise<unknown> };
|
||||
assertPassedYardsFullyLoaded(
|
||||
schedule: unknown,
|
||||
stations: typeof STATIONS,
|
||||
sequenceNo: number,
|
||||
): Promise<void>;
|
||||
};
|
||||
svc.dataSource = {
|
||||
query: async (_sql: string, params: unknown[]) => {
|
||||
const yardId = params[1] as string;
|
||||
scanned.push(yardId);
|
||||
return rowsByYard[yardId] ?? [];
|
||||
},
|
||||
};
|
||||
return { svc, scanned };
|
||||
};
|
||||
const schedule = { trainSetId: 'set-1', originStationId: 'mojo' };
|
||||
|
||||
it('checks the stops already departed, never the origin or the yard being reached', async () => {
|
||||
const { svc, scanned } = makeService({});
|
||||
await svc.assertPassedYardsFullyLoaded(schedule, STATIONS, 3);
|
||||
// Mojo is dispatch's job; Djibouti has not been loaded at yet.
|
||||
expect(scanned).toEqual(['adama', 'dire']);
|
||||
});
|
||||
|
||||
it('blocks the checkpoint when a passed yard left a booking half-loaded', async () => {
|
||||
const { svc } = makeService({
|
||||
adama: [{ reference: 'BK-200', loaded: '5', total: '8' }],
|
||||
});
|
||||
await expect(svc.assertPassedYardsFullyLoaded(schedule, STATIONS, 2)).rejects.toThrow(
|
||||
/Adama.*BK-200 \(5\/8 wagons loaded\)/s,
|
||||
);
|
||||
});
|
||||
|
||||
it('names the resolution the operator has: load the rest, or cancel it', async () => {
|
||||
const { svc } = makeService({
|
||||
adama: [{ reference: 'BK-200', loaded: '5', total: '8' }],
|
||||
});
|
||||
await expect(svc.assertPassedYardsFullyLoaded(schedule, STATIONS, 2)).rejects.toThrow(
|
||||
/customer fault: cancellation fee; EDR fault: no fee, rebookable/,
|
||||
);
|
||||
});
|
||||
|
||||
it('scans nothing at the first checkpoint after the origin', async () => {
|
||||
const { svc, scanned } = makeService({});
|
||||
await svc.assertPassedYardsFullyLoaded(schedule, STATIONS, 1);
|
||||
expect(scanned).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1209,7 +1209,7 @@ describe('TrainSchedulingService', () => {
|
||||
expect(html).toContain('<span>To load en route</span><strong>1 containers</strong>');
|
||||
});
|
||||
|
||||
it('prints coupled/switched wagons logged at this stop, and omits the box when there are none', () => {
|
||||
it('prints the consist-changes table for this stop, and omits it when there are none', () => {
|
||||
const schedule = {
|
||||
id: 'schedule-1',
|
||||
trainNumber: '8302',
|
||||
@@ -1223,16 +1223,21 @@ describe('TrainSchedulingService', () => {
|
||||
|
||||
const withChanges = build(schedule, {
|
||||
consistChangesAtStop: [
|
||||
{ action: 'ADD', wagonNumber: 'W-1002' },
|
||||
{ action: 'SWITCH', wagonNumber: 'W-0501 → W-1003' },
|
||||
{ wagonNumber: 'W-1002', event: 'Coupled', containerNumbers: 'EMPTY WAGON' },
|
||||
{ wagonNumber: 'W-1005', event: 'Coupled', containerNumbers: 'CONT-004, CONT-005' },
|
||||
{ wagonNumber: 'W-0501 → W-1003', event: 'Switched', containerNumbers: 'CONT-011' },
|
||||
],
|
||||
});
|
||||
expect(withChanges).toContain('Consist changed at this stop');
|
||||
expect(withChanges).toContain('Coupled: W-1002');
|
||||
expect(withChanges).toContain('Uncoupled — replaced: W-0501 → W-1003');
|
||||
expect(withChanges).toContain('Consist Changed At This Stop');
|
||||
expect(withChanges).toContain('<td>W-1002</td>');
|
||||
expect(withChanges).toContain('<td>Coupled</td>');
|
||||
expect(withChanges).toContain('<td>EMPTY WAGON</td>');
|
||||
expect(withChanges).toContain('<td>CONT-004, CONT-005</td>');
|
||||
expect(withChanges).toContain('<td>W-0501 → W-1003</td>');
|
||||
expect(withChanges).toContain('<td>Switched</td>');
|
||||
|
||||
const withoutChanges = build(schedule, {});
|
||||
expect(withoutChanges).not.toContain('Consist changed at this stop');
|
||||
expect(withoutChanges).not.toContain('Consist Changed At This Stop');
|
||||
});
|
||||
|
||||
it('lists loaded empty containers by number and states they are empty', () => {
|
||||
|
||||
@@ -2949,7 +2949,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 +3151,21 @@ export class TrainSchedulingService {
|
||||
* (their charge sits on the credit ledger) yet ride from accept.
|
||||
*/
|
||||
/**
|
||||
* Per-wagon loading dispatch gate: a booking with SOME wagons LOADED and
|
||||
* SOME still PLANNED/RESERVED must resolve before departure — load the rest
|
||||
* or cancel it (which shrinks the booking to its loaded wagons). Blocking
|
||||
* here beats silently unassigning: unassign would delete LOADED allocations
|
||||
* and strand cargo that is physically on the train.
|
||||
* Per-wagon loading gate: a booking with SOME wagons LOADED and SOME still
|
||||
* PLANNED/RESERVED must resolve before the train leaves the yard it boards
|
||||
* at — load the rest, or cancel the remainder (which shrinks the booking to
|
||||
* its loaded wagons). Blocking beats silently unassigning: unassign would
|
||||
* delete LOADED allocations and strand cargo physically on the train.
|
||||
*
|
||||
* Scoped to bookings BOARDING AT `boardingYardId`, so each yard answers only
|
||||
* for its own cargo: a mid-corridor booking (A→B→C→D carrying a B→C load) is
|
||||
* not due at A and must never hold the train there.
|
||||
*/
|
||||
private async assertNoPartiallyLoadedBookings(schedule: TrainSchedule): Promise<void> {
|
||||
private async assertNoPartiallyLoadedBookings(
|
||||
schedule: TrainSchedule,
|
||||
boardingYardId: string,
|
||||
context: { action: string; yardLabel?: string },
|
||||
): Promise<void> {
|
||||
if (!schedule.trainSetId) return;
|
||||
const rows: Array<{ reference: string; loaded: string; total: string }> =
|
||||
await this.dataSource.query(
|
||||
@@ -3166,24 +3176,51 @@ export class TrainSchedulingService {
|
||||
JOIN freight.train_set_wagons tsw ON tsw.id = a.train_set_wagon_id
|
||||
JOIN freight.bookings b ON b.id = a.booking_id
|
||||
WHERE tsw.train_set_id = $1
|
||||
AND b.origin_yard_id = $2
|
||||
AND a.deleted_at IS NULL
|
||||
AND tsw.deleted_at IS NULL
|
||||
AND b.deleted_at IS NULL
|
||||
GROUP BY b.id, b.reference
|
||||
HAVING COUNT(*) FILTER (WHERE a.status IN ('LOADED', 'DEPARTED')) > 0
|
||||
AND COUNT(*) FILTER (WHERE a.status NOT IN ('LOADED', 'DEPARTED')) > 0`,
|
||||
[schedule.trainSetId],
|
||||
[schedule.trainSetId, boardingYardId],
|
||||
);
|
||||
if (rows.length) {
|
||||
const detail = rows
|
||||
.map((r) => `${r.reference} (${r.loaded}/${r.total} wagons loaded)`)
|
||||
.join(', ');
|
||||
const where = context.yardLabel ? ` at ${context.yardLabel}` : '';
|
||||
throw new BadRequestException(
|
||||
`Cannot dispatch: booking(s) partially loaded — load every wagon or cancel the remainder first: ${detail}`,
|
||||
`Cannot ${context.action}: booking(s) partially loaded${where} — load every wagon ` +
|
||||
`or cancel the remainder (customer fault: cancellation fee; EDR fault: no fee, ` +
|
||||
`rebookable) first: ${detail}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mid-corridor twin of the dispatch gate. Logging a checkpoint at station N
|
||||
* asserts the train has left every earlier stop, so each of those yards must
|
||||
* have no half-loaded booking of its own left behind. The origin (seq 0) is
|
||||
* skipped — dispatch already gated it — and the final station is included:
|
||||
* arriving there still means the train left the stop before it.
|
||||
*/
|
||||
private async assertPassedYardsFullyLoaded(
|
||||
schedule: TrainSchedule,
|
||||
stations: Array<{ sequenceNo: number; yardId: string; label: string }>,
|
||||
sequenceNo: number,
|
||||
): Promise<void> {
|
||||
const departed = stations.filter(
|
||||
(st) => st.sequenceNo > 0 && st.sequenceNo < sequenceNo,
|
||||
);
|
||||
for (const st of departed) {
|
||||
await this.assertNoPartiallyLoadedBookings(schedule, st.yardId, {
|
||||
action: 'record this checkpoint',
|
||||
yardLabel: st.label,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async unloadedOriginBoarderIds(
|
||||
scheduleId: string,
|
||||
originYardId: string,
|
||||
@@ -3580,7 +3617,83 @@ export class TrainSchedulingService {
|
||||
return { wagons, unassignedBookings };
|
||||
}
|
||||
|
||||
async intercityMarshallingDocument(scheduleId: string): Promise<{ filename: string; buffer: Buffer }> {
|
||||
/**
|
||||
* Every corridor stop where the consist actually changed for this schedule
|
||||
* (coupled, uncoupled, or switched — any flavor), in the order the train
|
||||
* reached them. Origin is never in this list — it's always its own doc (the
|
||||
* plain import/export load list), so numbering here starts at 2. A stop with
|
||||
* only a routine checkpoint and no consist change never gets a row, which is
|
||||
* the point: "Marshalling 2, 3, 4…" tracks events, not raw stop count.
|
||||
*/
|
||||
async marshallingStops(
|
||||
scheduleId: string,
|
||||
): Promise<Array<{ stopIndex: number; yardId: string; yardLabel: string; firstOccurredAt: string }>> {
|
||||
const rows = await this.dataSource.getRepository(ScheduleWagonAdjustmentLog).find({
|
||||
where: { trainScheduleId: scheduleId },
|
||||
order: { occurredAt: 'ASC' },
|
||||
});
|
||||
const firstSeenAt = new Map<string, Date>();
|
||||
for (const row of rows) {
|
||||
if (!row.yardId || firstSeenAt.has(row.yardId)) continue;
|
||||
firstSeenAt.set(row.yardId, row.occurredAt);
|
||||
}
|
||||
const orderedYardIds = [...firstSeenAt.entries()]
|
||||
.sort((a, b) => a[1].getTime() - b[1].getTime())
|
||||
.map(([yardId]) => yardId);
|
||||
const labels = await this.yardLabelsById(orderedYardIds);
|
||||
return orderedYardIds.map((yardId, i) => ({
|
||||
stopIndex: i + 2,
|
||||
yardId,
|
||||
yardLabel: labels.get(yardId) ?? yardId,
|
||||
firstOccurredAt: firstSeenAt.get(yardId)!.toISOString(),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* The coupled/uncoupled/switched rows for one stop, in the locked table
|
||||
* shape (wagon, event, containers). "EMPTY WAGON" replaces the container
|
||||
* list rather than a blank cell — the column always exists so a loaded and
|
||||
* an empty coupling read as the same table, not two different layouts.
|
||||
* Cargo for ADD/REMOVE rows is read off the schedule's OWN slot allocations
|
||||
* for that physical wagon: an ADD is a leg slot boarding already loaded (see
|
||||
* stampSlotLegs) or an empty couple (plannedWagonCouples) with none; a
|
||||
* REMOVE is a slot alighting with its cargo, or an empty trim. A SWITCH row
|
||||
* carries the incoming wagon's id — the slot's cargo already rides it.
|
||||
*/
|
||||
private consistChangesAt(
|
||||
schedule: TrainSchedule,
|
||||
logRows: ScheduleWagonAdjustmentLog[],
|
||||
): Array<{ wagonNumber: string; event: 'Coupled' | 'Uncoupled' | 'Switched'; containerNumbers: string }> {
|
||||
const slotByPhysicalWagonId = new Map(
|
||||
(schedule.trainSet?.wagons ?? [])
|
||||
.filter((wagon) => wagon.physicalWagonId)
|
||||
.map((wagon) => [wagon.physicalWagonId as string, wagon]),
|
||||
);
|
||||
return logRows.map((row) => {
|
||||
const slot = slotByPhysicalWagonId.get(row.wagonId);
|
||||
const containerNumbers = (slot?.allocations ?? [])
|
||||
.flatMap((allocation) => allocation.containerItems ?? [])
|
||||
.map((item) => item.containerNumber)
|
||||
.filter(Boolean)
|
||||
.join(', ');
|
||||
return {
|
||||
wagonNumber: row.wagonNumber,
|
||||
event: row.action === 'ADD' ? 'Coupled' : row.action === 'REMOVE' ? 'Uncoupled' : 'Switched',
|
||||
containerNumbers: containerNumbers || 'EMPTY WAGON',
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The numbered marshalling document for one corridor stop (see
|
||||
* marshallingStops — stopIndex 2+, origin is its own separate doc).
|
||||
* ponytail: the wagon table always shows the CURRENT on-board state, not a
|
||||
* point-in-time reconstruction of what stood on the train at that past
|
||||
* stop — a full historical snapshot is a much bigger feature nobody has
|
||||
* asked for. What's stop-specific is the consist-changes table below it,
|
||||
* which IS scoped to that stop's own logged events.
|
||||
*/
|
||||
async marshallingDocumentAt(scheduleId: string, stopIndex: number): Promise<{ filename: string; buffer: Buffer }> {
|
||||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||||
if (!schedule) {
|
||||
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
||||
@@ -3590,24 +3703,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 +3770,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 +3839,14 @@ export class TrainSchedulingService {
|
||||
// Slots that couple to the train downstream (slot id → board yard label).
|
||||
// Their cargo renders as TO LOAD AT and stays out of the loaded tallies.
|
||||
pendingBoardYardLabelBySlot?: Map<string, string>;
|
||||
// Intercity (Marshalling 2) only: couples/switches logged at the stop
|
||||
// this document is printed at (see ScheduleWagonAdjustmentLog). Origin
|
||||
// import/export docs never pass this, so they render no such box.
|
||||
consistChangesAtStop?: ScheduleWagonAdjustmentLog[];
|
||||
// Numbered marshalling docs only (see marshallingDocumentAt /
|
||||
// consistChangesAt) — couples/uncouples/switches logged at THIS stop.
|
||||
// Origin import/export docs never pass this, so they render no such box.
|
||||
consistChangesAtStop?: Array<{
|
||||
wagonNumber: string;
|
||||
event: 'Coupled' | 'Uncoupled' | 'Switched';
|
||||
containerNumbers: string;
|
||||
}>;
|
||||
},
|
||||
): string {
|
||||
const esc = (value: unknown) =>
|
||||
@@ -3853,6 +4011,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 +4060,27 @@ export class TrainSchedulingService {
|
||||
|
||||
${
|
||||
opts?.consistChangesAtStop?.length
|
||||
? `<div class="notice">
|
||||
<b>Consist changed at this stop:</b>
|
||||
${(() => {
|
||||
const coupled = opts.consistChangesAtStop.filter((row) => row.action === 'ADD');
|
||||
const switched = opts.consistChangesAtStop.filter((row) => row.action === 'SWITCH');
|
||||
return [
|
||||
coupled.length ? `Coupled: ${esc(coupled.map((row) => row.wagonNumber).join(', '))}` : '',
|
||||
switched.length ? `Uncoupled — replaced: ${esc(switched.map((row) => row.wagonNumber).join(', '))}` : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' | ');
|
||||
})()}
|
||||
</div>`
|
||||
? `<h2>Consist Changed At This Stop</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Wagon No</th>
|
||||
<th>Event</th>
|
||||
<th>Container No</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${opts.consistChangesAtStop
|
||||
.map(
|
||||
(row) => `<tr>
|
||||
<td>${esc(row.wagonNumber)}</td>
|
||||
<td>${esc(row.event)}</td>
|
||||
<td>${esc(row.containerNumbers)}</td>
|
||||
</tr>`,
|
||||
)
|
||||
.join('')}
|
||||
</tbody>
|
||||
</table>`
|
||||
: ''
|
||||
}
|
||||
|
||||
@@ -4744,6 +4911,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 +5137,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 +5739,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 +5774,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 +8272,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 +10232,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,
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
import { BadRequestException, ConflictException } from '@nestjs/common';
|
||||
|
||||
import { WarehousePlacementService } from './warehouse-placement.service';
|
||||
import { WarehouseZoneStacksService } from './warehouse-zone-stacks.service';
|
||||
|
||||
/**
|
||||
* The physical rules a yard operator would recognise: nothing floats above an
|
||||
* empty level, a slot holds one box, and the ids a client sends are only
|
||||
* believed after the whole chain has been resolved server-side.
|
||||
*/
|
||||
|
||||
const CHAIN = {
|
||||
slotId: 'slot-2',
|
||||
slotStatus: 'AVAILABLE',
|
||||
slotIsActive: true,
|
||||
level: 2,
|
||||
stackId: 'stack-1',
|
||||
stackCode: 'ZA-001',
|
||||
stackStatus: 'ACTIVE',
|
||||
stackIsActive: true,
|
||||
maxStackHeight: 3,
|
||||
zoneId: 'zone-1',
|
||||
zoneCode: 'L1-O-A-ZA',
|
||||
zoneType: 'CONTAINER_ZONE',
|
||||
zoneStatus: 'ACTIVE',
|
||||
zoneIsActive: true,
|
||||
yardId: 'yard-1',
|
||||
yardCode: 'L1-O-A',
|
||||
yardType: 'CONTAINER_YARD',
|
||||
yardDirection: null,
|
||||
yardStatus: 'ACTIVE',
|
||||
yardIsActive: true,
|
||||
warehouseId: 'wh-1',
|
||||
warehouseCode: 'L1-OPEN',
|
||||
warehouseStatus: 'ACTIVE',
|
||||
warehouseIsActive: true,
|
||||
};
|
||||
|
||||
/** A placement service whose slot chain and stack occupancy are dictated by the test. */
|
||||
function makePlacement(chain: Partial<typeof CHAIN>, occupiedLevels: number[], slotTakenBy: string | null = null) {
|
||||
const service = Object.create(WarehousePlacementService.prototype) as Record<string, unknown>;
|
||||
service.resolveSlot = jest.fn().mockResolvedValue({ ...CHAIN, ...chain });
|
||||
service.occupiedLevels = jest.fn().mockResolvedValue(occupiedLevels);
|
||||
service.em = () => ({ query: jest.fn().mockResolvedValue(slotTakenBy ? [{ id: slotTakenBy }] : []) });
|
||||
return service as unknown as WarehousePlacementService;
|
||||
}
|
||||
|
||||
const placementInput = {
|
||||
slotId: 'slot-2',
|
||||
warehouseId: 'wh-1',
|
||||
yardId: 'yard-1',
|
||||
zoneId: 'zone-1',
|
||||
quantity: 1,
|
||||
};
|
||||
|
||||
describe('WarehousePlacementService.assertStackable', () => {
|
||||
const service = Object.create(WarehousePlacementService.prototype) as WarehousePlacementService;
|
||||
|
||||
it('always allows the ground level', () => {
|
||||
expect(() => service.assertStackable({ level: 1, stackCode: 'ZA-001' }, [])).not.toThrow();
|
||||
});
|
||||
|
||||
it('allows level 2 once level 1 is filled', () => {
|
||||
expect(() => service.assertStackable({ level: 2, stackCode: 'ZA-001' }, [1])).not.toThrow();
|
||||
});
|
||||
|
||||
it('allows level 3 once levels 1 and 2 are filled', () => {
|
||||
expect(() => service.assertStackable({ level: 3, stackCode: 'ZA-001' }, [1, 2])).not.toThrow();
|
||||
});
|
||||
|
||||
it('refuses level 2 over an empty ground level', () => {
|
||||
expect(() => service.assertStackable({ level: 2, stackCode: 'ZA-001' }, [])).toThrow(
|
||||
/level 2 cannot be filled while level\(s\) 1 are empty/,
|
||||
);
|
||||
});
|
||||
|
||||
it('refuses level 3 when level 2 is empty', () => {
|
||||
expect(() => service.assertStackable({ level: 3, stackCode: 'ZA-001' }, [1])).toThrow(
|
||||
/level\(s\) 2 are empty/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('WarehousePlacementService.validateSlotForInventory', () => {
|
||||
it('accepts a consistent hierarchy with the level below filled', async () => {
|
||||
const service = makePlacement({}, [1]);
|
||||
await expect(service.validateSlotForInventory(placementInput)).resolves.toMatchObject({
|
||||
stackId: 'stack-1',
|
||||
level: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it('refuses a slot belonging to another zone', async () => {
|
||||
const service = makePlacement({ zoneId: 'other-zone' }, [1]);
|
||||
await expect(service.validateSlotForInventory(placementInput)).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('refuses a zone whose yard is not the one given', async () => {
|
||||
const service = makePlacement({ yardId: 'other-yard' }, [1]);
|
||||
await expect(service.validateSlotForInventory(placementInput)).rejects.toThrow(/does not belong|not the yard given/);
|
||||
});
|
||||
|
||||
it('refuses a yard whose warehouse is not the one given', async () => {
|
||||
const service = makePlacement({ warehouseId: 'other-wh' }, [1]);
|
||||
await expect(service.validateSlotForInventory(placementInput)).rejects.toThrow(/not the warehouse given/);
|
||||
});
|
||||
|
||||
it('refuses an inactive stack', async () => {
|
||||
const service = makePlacement({ stackStatus: 'INACTIVE', stackIsActive: false }, [1]);
|
||||
await expect(service.validateSlotForInventory(placementInput)).rejects.toThrow(/Stack ZA-001 is not active/);
|
||||
});
|
||||
|
||||
it('refuses a blocked slot', async () => {
|
||||
const service = makePlacement({ slotStatus: 'BLOCKED' }, [1]);
|
||||
await expect(service.validateSlotForInventory(placementInput)).rejects.toThrow(/is BLOCKED/);
|
||||
});
|
||||
|
||||
it('accepts a slot reserved for the box now arriving', async () => {
|
||||
const service = makePlacement({ slotStatus: 'RESERVED' }, [1]);
|
||||
await expect(service.validateSlotForInventory(placementInput)).resolves.toMatchObject({ level: 2 });
|
||||
});
|
||||
|
||||
it('refuses a slot another container already stands in', async () => {
|
||||
const service = makePlacement({}, [1], 'other-inventory');
|
||||
await expect(service.validateSlotForInventory(placementInput)).rejects.toBeInstanceOf(ConflictException);
|
||||
});
|
||||
|
||||
it('refuses a level above the stack height', async () => {
|
||||
const service = makePlacement({ level: 4, slotId: 'slot-4' }, [1, 2, 3]);
|
||||
await expect(
|
||||
service.validateSlotForInventory({ ...placementInput, slotId: 'slot-4' }),
|
||||
).rejects.toThrow(/above stack ZA-001's maximum height of 3/);
|
||||
});
|
||||
|
||||
it('refuses a row that still covers several containers', async () => {
|
||||
const service = makePlacement({}, [1]);
|
||||
await expect(service.validateSlotForInventory({ ...placementInput, quantity: 5 })).rejects.toThrow(
|
||||
/covers 5 containers/,
|
||||
);
|
||||
});
|
||||
|
||||
it('skips container stacking rules for a bulk yard', async () => {
|
||||
// Level 2 over an empty level 1 would be refused in a container yard;
|
||||
// a bulk yard has no vertical semantics to enforce.
|
||||
const service = makePlacement({ yardType: 'BULK_YARD' }, []);
|
||||
await expect(service.validateSlotForInventory({ ...placementInput, quantity: 12 })).resolves.toMatchObject({
|
||||
yardType: 'BULK_YARD',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('WarehousePlacementService.getContainerAccessibility', () => {
|
||||
function makeAccessibility(placed: unknown, blocking: unknown[]) {
|
||||
const service = Object.create(WarehousePlacementService.prototype) as Record<string, unknown>;
|
||||
const query = jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce(placed ? [placed] : [])
|
||||
.mockResolvedValueOnce(blocking);
|
||||
service.em = () => ({ query });
|
||||
return service as unknown as WarehousePlacementService;
|
||||
}
|
||||
|
||||
it('reports a ground container buried under two others', async () => {
|
||||
const service = makeAccessibility(
|
||||
{ inventoryId: 'inv-1', level: 1, stackId: 'stack-1', stackCode: 'ZA-001' },
|
||||
[
|
||||
{ inventoryId: 'inv-3', level: 3, status: 'STORED', containerNumber: 'CONT-003' },
|
||||
{ inventoryId: 'inv-2', level: 2, status: 'STORED', containerNumber: 'CONT-002' },
|
||||
],
|
||||
);
|
||||
|
||||
await expect(service.getContainerAccessibility('inv-1')).resolves.toEqual({
|
||||
accessible: false,
|
||||
inventoryId: 'inv-1',
|
||||
stackCode: 'ZA-001',
|
||||
level: 1,
|
||||
blockingContainers: [
|
||||
{ inventoryId: 'inv-3', level: 3, status: 'STORED', containerNumber: 'CONT-003' },
|
||||
{ inventoryId: 'inv-2', level: 2, status: 'STORED', containerNumber: 'CONT-002' },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('reports the top container as reachable', async () => {
|
||||
const service = makeAccessibility({ inventoryId: 'inv-3', level: 3, stackId: 'stack-1', stackCode: 'ZA-001' }, []);
|
||||
await expect(service.getContainerAccessibility('inv-3')).resolves.toMatchObject({ accessible: true });
|
||||
});
|
||||
|
||||
it('treats an item with no slot as reachable', async () => {
|
||||
const service = makeAccessibility({ inventoryId: 'inv-9', level: null, stackId: null, stackCode: null }, []);
|
||||
await expect(service.getContainerAccessibility('inv-9')).resolves.toEqual({
|
||||
accessible: true,
|
||||
inventoryId: 'inv-9',
|
||||
stackCode: null,
|
||||
level: null,
|
||||
blockingContainers: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('WarehouseZoneStacksService guards', () => {
|
||||
function makeStacksService(occupied: number[]) {
|
||||
const service = Object.create(WarehouseZoneStacksService.prototype) as Record<string, unknown>;
|
||||
service.placement = { occupiedLevels: jest.fn().mockResolvedValue(occupied) };
|
||||
service.stacksRepository = {
|
||||
findById: jest.fn().mockResolvedValue({ id: 'stack-1', code: 'ZA-001', zoneId: 'zone-1', slots: [] }),
|
||||
};
|
||||
service.dataSource = { transaction: jest.fn() };
|
||||
return service as unknown as WarehouseZoneStacksService;
|
||||
}
|
||||
|
||||
it('refuses to delete a stack that still holds containers', async () => {
|
||||
await expect(makeStacksService([1, 2]).remove('stack-1')).rejects.toThrow(
|
||||
/still holds 2 container\(s\) at level\(s\) 1, 2/,
|
||||
);
|
||||
});
|
||||
|
||||
it('deletes an empty stack', async () => {
|
||||
const service = makeStacksService([]);
|
||||
await expect(service.remove('stack-1')).resolves.toEqual({ id: 'stack-1', deleted: true });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import { ConflictException, NotFoundException } from '@nestjs/common';
|
||||
|
||||
import { WarehousesService } from './warehouses.service';
|
||||
|
||||
/**
|
||||
* Deleting a warehouse that still holds yards would orphan every zone and the
|
||||
* inventory sitting in them, so remove() refuses instead of cascading.
|
||||
*/
|
||||
function makeService(warehouse: unknown) {
|
||||
const warehousesRepository = {
|
||||
findById: jest.fn().mockResolvedValue(warehouse),
|
||||
softDelete: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
const service = Object.create(WarehousesService.prototype) as Record<string, unknown>;
|
||||
service.warehousesRepository = warehousesRepository;
|
||||
|
||||
return { service: service as unknown as WarehousesService, warehousesRepository };
|
||||
}
|
||||
|
||||
describe('WarehousesService.remove', () => {
|
||||
it('soft-deletes a warehouse with no yards', async () => {
|
||||
const { service, warehousesRepository } = makeService({ id: 'w1', code: 'GMP', yards: [] });
|
||||
|
||||
await expect(service.remove('w1')).resolves.toEqual({ id: 'w1', deleted: true });
|
||||
expect(warehousesRepository.softDelete).toHaveBeenCalledWith('w1');
|
||||
});
|
||||
|
||||
it('refuses while yards remain', async () => {
|
||||
const { service, warehousesRepository } = makeService({
|
||||
id: 'w1',
|
||||
code: 'GMP',
|
||||
yards: [{ id: 'y1' }],
|
||||
});
|
||||
|
||||
await expect(service.remove('w1')).rejects.toBeInstanceOf(ConflictException);
|
||||
expect(warehousesRepository.softDelete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('404s on an unknown warehouse', async () => {
|
||||
const { service, warehousesRepository } = makeService(null);
|
||||
|
||||
await expect(service.remove('nope')).rejects.toBeInstanceOf(NotFoundException);
|
||||
expect(warehousesRepository.softDelete).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { ConflictException, NotFoundException } from '@nestjs/common';
|
||||
|
||||
import { WarehouseYardsService } from './warehouse-yards.service';
|
||||
import { WarehouseZonesService } from './warehouse-zones.service';
|
||||
|
||||
/**
|
||||
* Soft-deleting a parent would leave its children pointing at a row every
|
||||
* joining query drops, so both removes refuse while children exist.
|
||||
*/
|
||||
function makeYardsService(yard: unknown) {
|
||||
const yardsRepository = {
|
||||
findById: jest.fn().mockResolvedValue(yard),
|
||||
softDelete: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const service = Object.create(WarehouseYardsService.prototype) as Record<string, unknown>;
|
||||
service.yardsRepository = yardsRepository;
|
||||
return { service: service as unknown as WarehouseYardsService, yardsRepository };
|
||||
}
|
||||
|
||||
function makeZonesService(zone: unknown, heldInventory: number, configuredStacks = 0) {
|
||||
const zonesRepository = {
|
||||
findById: jest.fn().mockResolvedValue(zone),
|
||||
softDelete: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const inventoryRepository = {
|
||||
findAndCount: jest.fn().mockResolvedValue([[], heldInventory]),
|
||||
};
|
||||
const service = Object.create(WarehouseZonesService.prototype) as Record<string, unknown>;
|
||||
service.zonesRepository = zonesRepository;
|
||||
service.inventoryRepository = inventoryRepository;
|
||||
service.dataSource = { query: jest.fn().mockResolvedValue([{ count: configuredStacks }]) };
|
||||
return { service: service as unknown as WarehouseZonesService, zonesRepository };
|
||||
}
|
||||
|
||||
describe('WarehouseYardsService.remove', () => {
|
||||
it('soft-deletes a yard with no zones', async () => {
|
||||
const { service, yardsRepository } = makeYardsService({ id: 'y1', code: 'CY-A', zones: [] });
|
||||
|
||||
await expect(service.remove('y1')).resolves.toEqual({ id: 'y1', deleted: true });
|
||||
expect(yardsRepository.softDelete).toHaveBeenCalledWith('y1');
|
||||
});
|
||||
|
||||
it('refuses while zones remain', async () => {
|
||||
const { service, yardsRepository } = makeYardsService({
|
||||
id: 'y1',
|
||||
code: 'CY-A',
|
||||
zones: [{ id: 'z1' }],
|
||||
});
|
||||
|
||||
await expect(service.remove('y1')).rejects.toBeInstanceOf(ConflictException);
|
||||
expect(yardsRepository.softDelete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('404s on an unknown yard', async () => {
|
||||
const { service } = makeYardsService(null);
|
||||
|
||||
await expect(service.remove('nope')).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('WarehouseZonesService.remove', () => {
|
||||
it('soft-deletes an empty zone', async () => {
|
||||
const { service, zonesRepository } = makeZonesService({ id: 'z1', code: 'ZA' }, 0);
|
||||
|
||||
await expect(service.remove('z1')).resolves.toEqual({ id: 'z1', deleted: true });
|
||||
expect(zonesRepository.softDelete).toHaveBeenCalledWith('z1');
|
||||
});
|
||||
|
||||
it('refuses while inventory sits in it', async () => {
|
||||
const { service, zonesRepository } = makeZonesService({ id: 'z1', code: 'ZA' }, 16);
|
||||
|
||||
await expect(service.remove('z1')).rejects.toBeInstanceOf(ConflictException);
|
||||
expect(zonesRepository.softDelete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refuses while ground stacks are still configured in it', async () => {
|
||||
const { service, zonesRepository } = makeZonesService({ id: 'z1', code: 'ZA' }, 0, 20);
|
||||
|
||||
await expect(service.remove('z1')).rejects.toThrow(/still has 20 configured stack\(s\)/);
|
||||
expect(zonesRepository.softDelete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('404s on an unknown zone', async () => {
|
||||
const { service } = makeZonesService(null, 0);
|
||||
|
||||
await expect(service.remove('nope')).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,14 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { 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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsIn, IsOptional, IsUUID } from 'class-validator';
|
||||
|
||||
export class FindAvailableSlotDto {
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
@IsUUID()
|
||||
yardId!: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid', description: 'Narrow the search to one zone.' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
zoneId?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: ['IMPORT', 'EXPORT', 'BOTH'], description: 'Null/BOTH matches any yard direction.' })
|
||||
@IsOptional()
|
||||
@IsIn(['IMPORT', 'EXPORT', 'BOTH'])
|
||||
direction?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
cargoTypeId?: string;
|
||||
}
|
||||
|
||||
export class AssignSlotDto {
|
||||
@ApiPropertyOptional({
|
||||
format: 'uuid',
|
||||
description: 'Target slot. Omit to let the placement engine pick the lowest free level.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
slotId?: string;
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
ArrayMinSize,
|
||||
IsArray,
|
||||
IsDateString,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
MaxLength,
|
||||
Min,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
|
||||
/**
|
||||
* A loaded container that was already sitting in a yard before the system knew
|
||||
* about it. It has no booking, so the owner is carried as a company reference
|
||||
* or free text, and `arrivedAt` is the true historical arrival rather than now.
|
||||
*/
|
||||
export class RegisterBacklogContainerDto {
|
||||
@ApiProperty({ description: 'ISO 6346 container number' })
|
||||
@IsString()
|
||||
@MaxLength(20)
|
||||
containerNumber!: string;
|
||||
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
@IsUUID()
|
||||
containerTypeId!: string;
|
||||
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
@IsUUID()
|
||||
warehouseId!: string;
|
||||
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
@IsUUID()
|
||||
yardId!: string;
|
||||
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
@IsUUID()
|
||||
zoneId!: string;
|
||||
|
||||
@ApiProperty({ description: 'True historical arrival date — drives nothing billable.' })
|
||||
@IsDateString()
|
||||
arrivedAt!: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid', description: 'Registered customer, when the owner is one.' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
companyId?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Owner name — free text when the company is not a customer yet.' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
companyName?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
sealNumber?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Net weight in the unit the warehouse records (tonnes).' })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
weight?: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
volume?: number;
|
||||
|
||||
/**
|
||||
* ponytail: defaults to 0 when unknown, which is the honest value for a box
|
||||
* nobody weighed. `containers.max_gross_weight` is a ceiling in
|
||||
* cargoes.service, so set real figures here before this box is ever used for
|
||||
* a new cargo assignment.
|
||||
*/
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
tareWeight?: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
maxGrossWeight?: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
notes?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
performedBy?: string;
|
||||
}
|
||||
|
||||
export class BulkRegisterBacklogDto {
|
||||
@ApiProperty({ type: [RegisterBacklogContainerDto] })
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@ArrayMaxSize(1000)
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => RegisterBacklogContainerDto)
|
||||
containers!: RegisterBacklogContainerDto[];
|
||||
}
|
||||
@@ -22,6 +22,15 @@ export class StoreInventoryDto {
|
||||
@IsUUID()
|
||||
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()
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsBoolean, IsEnum, IsInt, IsOptional, IsString, IsUUID, Max, MaxLength, Min } from 'class-validator';
|
||||
|
||||
import {
|
||||
DEFAULT_MAX_STACK_HEIGHT,
|
||||
WAREHOUSE_ZONE_STACK_STATUSES,
|
||||
WarehouseZoneStackStatus,
|
||||
} from '../entities/warehouse-zone-stack.entity';
|
||||
import {
|
||||
WAREHOUSE_ZONE_SLOT_STATUSES,
|
||||
WarehouseZoneSlotStatus,
|
||||
} from '../entities/warehouse-zone-slot.entity';
|
||||
|
||||
/** Nobody stacks boxes this high; the cap is here to catch a typo'd 30. */
|
||||
const MAX_SUPPORTED_STACK_HEIGHT = 10;
|
||||
|
||||
export class CreateWarehouseZoneStackDto {
|
||||
@ApiPropertyOptional({ format: 'uuid', description: 'Optional — taken from the route param when omitted' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
zoneId?: string;
|
||||
|
||||
@ApiProperty({ example: 'ZA-001' })
|
||||
@IsString()
|
||||
@MaxLength(40)
|
||||
code!: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(160)
|
||||
name?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Physical row label' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(20)
|
||||
row?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Physical bay label' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(20)
|
||||
bay?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Physical position label' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(20)
|
||||
position?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
default: DEFAULT_MAX_STACK_HEIGHT,
|
||||
description: 'One slot is generated per level, 1 to this height.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(MAX_SUPPORTED_STACK_HEIGHT)
|
||||
maxStackHeight?: number;
|
||||
}
|
||||
|
||||
export class UpdateWarehouseZoneStackDto {
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(40)
|
||||
code?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(160)
|
||||
name?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(20)
|
||||
row?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(20)
|
||||
bay?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(20)
|
||||
position?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Raising it adds slots; lowering it removes the empty top levels.' })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(MAX_SUPPORTED_STACK_HEIGHT)
|
||||
maxStackHeight?: number;
|
||||
|
||||
@ApiPropertyOptional({ enum: WAREHOUSE_ZONE_STACK_STATUSES })
|
||||
@IsOptional()
|
||||
@IsEnum(WAREHOUSE_ZONE_STACK_STATUSES)
|
||||
status?: WarehouseZoneStackStatus;
|
||||
}
|
||||
|
||||
export class UpdateWarehouseZoneSlotDto {
|
||||
@ApiPropertyOptional({
|
||||
enum: WAREHOUSE_ZONE_SLOT_STATUSES,
|
||||
description: 'Operator intent only. OCCUPIED is derived from inventory and cannot be set here.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsEnum(WAREHOUSE_ZONE_SLOT_STATUSES)
|
||||
status?: WarehouseZoneSlotStatus;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
}
|
||||
@@ -7,6 +7,8 @@ import { Container } from '../../container-management/entities/container.entity'
|
||||
import { Warehouse } from './warehouse.entity';
|
||||
import { WarehouseYard } from './warehouse-yard.entity';
|
||||
import { WarehouseZone } from './warehouse-zone.entity';
|
||||
import { WarehouseZoneSlot } from './warehouse-zone-slot.entity';
|
||||
import { WarehouseZoneStack } from './warehouse-zone-stack.entity';
|
||||
|
||||
// Lifecycle. Supersedes the Batch 1 set
|
||||
// (ARRIVED_AT_WAREHOUSE / UNDER_INSPECTION / READY_FOR_LOADING) — migrated in place.
|
||||
@@ -50,6 +52,23 @@ export const WAREHOUSE_INVENTORY_TRANSITIONS: Record<WarehouseInventoryStatus, W
|
||||
DELIVERED: [],
|
||||
};
|
||||
|
||||
/**
|
||||
* Statuses in which an inventory row is still physically standing in its slot.
|
||||
* The moment it is LOADED onto a train, dispatched, or handed over, the ground
|
||||
* is free again — so occupancy is read from this list rather than written to
|
||||
* the slot row. The partial unique index in
|
||||
* `WarehouseZoneStacksSlots3830000000000` uses exactly the same list; change
|
||||
* one and you must change the other.
|
||||
*/
|
||||
export const SLOT_OCCUPYING_STATUSES: readonly WarehouseInventoryStatus[] = [
|
||||
'UNLOADED',
|
||||
'RECEIVED',
|
||||
'STORED',
|
||||
'RESERVED',
|
||||
'READY_FOR_LOADING',
|
||||
'READY_FOR_PICKUP',
|
||||
];
|
||||
|
||||
@Entity({ schema: 'freight', name: 'warehouse_inventory' })
|
||||
@Index(['warehouseId'])
|
||||
@Index(['yardId'])
|
||||
@@ -58,6 +77,8 @@ export const WAREHOUSE_INVENTORY_TRANSITIONS: Record<WarehouseInventoryStatus, W
|
||||
@Index(['cargoId'])
|
||||
@Index(['containerId'])
|
||||
@Index(['goodsId'])
|
||||
@Index(['stackId'])
|
||||
@Index(['slotId'])
|
||||
@Index(['status'])
|
||||
export class WarehouseInventory extends BaseEntity {
|
||||
@Column({ name: 'warehouse_id', type: 'uuid' })
|
||||
@@ -81,6 +102,25 @@ export class WarehouseInventory extends BaseEntity {
|
||||
@JoinColumn({ name: 'zone_id' })
|
||||
zone?: WarehouseZone;
|
||||
|
||||
/**
|
||||
* Exact physical position inside the zone. Nullable and additive: every row
|
||||
* that predates the stack/slot model, and every non-container yard, keeps
|
||||
* working with zone-level placement alone.
|
||||
*/
|
||||
@Column({ name: 'stack_id', type: 'uuid', nullable: true })
|
||||
stackId?: string | null;
|
||||
|
||||
@ManyToOne(() => WarehouseZoneStack, { nullable: true })
|
||||
@JoinColumn({ name: 'stack_id' })
|
||||
stack?: WarehouseZoneStack | null;
|
||||
|
||||
@Column({ name: 'slot_id', type: 'uuid', nullable: true })
|
||||
slotId?: string | null;
|
||||
|
||||
@ManyToOne(() => WarehouseZoneSlot, { nullable: true })
|
||||
@JoinColumn({ name: 'slot_id' })
|
||||
slot?: WarehouseZoneSlot | null;
|
||||
|
||||
@Column({ name: 'booking_id', type: 'uuid', nullable: true })
|
||||
bookingId?: string | null;
|
||||
|
||||
@@ -105,6 +145,22 @@ export class WarehouseInventory extends BaseEntity {
|
||||
@Column({ name: 'goods_id', type: 'uuid', nullable: true })
|
||||
goodsId?: string | null;
|
||||
|
||||
/**
|
||||
* Registered as backlog: the box was already in the yard before the system
|
||||
* knew about it. `arrivedAt` is the true, backdated arrival, but no storage
|
||||
* or demurrage accrues — see WarehouseFeeService.previewForInventory.
|
||||
*/
|
||||
@Column({ name: 'backlog_registration', type: 'boolean', default: false })
|
||||
backlogRegistration!: boolean;
|
||||
|
||||
/** Owner of a row with no booking to inherit one from. */
|
||||
@Column({ name: 'company_id', type: 'uuid', nullable: true })
|
||||
companyId?: string | null;
|
||||
|
||||
/** Owner as text — a company that is not a registered customer yet. */
|
||||
@Column({ name: 'company_name', type: 'varchar', length: 200, nullable: true })
|
||||
companyName?: string | null;
|
||||
|
||||
@Column({ name: 'quantity', type: 'numeric', precision: 12, scale: 3, default: 0 })
|
||||
quantity!: number;
|
||||
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
||||
|
||||
import { WarehouseZoneStack } from './warehouse-zone-stack.entity';
|
||||
|
||||
/**
|
||||
* Stored slot status is *operator intent* only. Occupancy is never written
|
||||
* here: it is derived from `warehouse_inventory.slot_id` plus the row's
|
||||
* lifecycle status, so the two can never drift apart and no exit path
|
||||
* (load / dispatch / deliver) has to remember to free a slot. The computed
|
||||
* OCCUPIED value is what the API returns — see `SLOT_EFFECTIVE_STATUSES`.
|
||||
*/
|
||||
export const WAREHOUSE_ZONE_SLOT_STATUSES = ['AVAILABLE', 'BLOCKED', 'RESERVED', 'INACTIVE'] as const;
|
||||
export type WarehouseZoneSlotStatus = (typeof WAREHOUSE_ZONE_SLOT_STATUSES)[number];
|
||||
|
||||
export const SLOT_EFFECTIVE_STATUSES = [...WAREHOUSE_ZONE_SLOT_STATUSES, 'OCCUPIED'] as const;
|
||||
export type SlotEffectiveStatus = (typeof SLOT_EFFECTIVE_STATUSES)[number];
|
||||
|
||||
@Entity({ schema: 'freight', name: 'warehouse_zone_slots' })
|
||||
@Index(['stackId'])
|
||||
@Index(['status'])
|
||||
export class WarehouseZoneSlot extends BaseEntity {
|
||||
@Column({ name: 'stack_id', type: 'uuid' })
|
||||
stackId!: string;
|
||||
|
||||
@ManyToOne(() => WarehouseZoneStack, (stack) => stack.slots, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'stack_id' })
|
||||
stack?: WarehouseZoneStack;
|
||||
|
||||
/** 1 = on the ground. Capped by the parent stack's maxStackHeight. */
|
||||
@Column({ name: 'level', type: 'int' })
|
||||
level!: number;
|
||||
|
||||
@Column({ name: 'status', type: 'varchar', length: 16, default: 'AVAILABLE' })
|
||||
status!: WarehouseZoneSlotStatus;
|
||||
|
||||
@Column({ name: 'is_active', type: 'boolean', default: true })
|
||||
isActive!: boolean;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm';
|
||||
|
||||
import { WarehouseZone } from './warehouse-zone.entity';
|
||||
import { WarehouseZoneSlot } from './warehouse-zone-slot.entity';
|
||||
|
||||
export const WAREHOUSE_ZONE_STACK_STATUSES = ['ACTIVE', 'INACTIVE'] as const;
|
||||
export type WarehouseZoneStackStatus = (typeof WAREHOUSE_ZONE_STACK_STATUSES)[number];
|
||||
|
||||
/** Default vertical height of a container stack — three boxes, EDR's reach-stacker limit. */
|
||||
export const DEFAULT_MAX_STACK_HEIGHT = 3;
|
||||
|
||||
/**
|
||||
* One ground footprint inside a zone: the patch of concrete a container is put
|
||||
* down on, and the levels above it. The zone is where allocation stops; this is
|
||||
* where a box physically sits.
|
||||
*
|
||||
* Generic on purpose — a bulk or general-cargo zone may divide itself into
|
||||
* stacks too — but the vertical stacking rules only run for CONTAINER_YARD.
|
||||
*/
|
||||
@Entity({ schema: 'freight', name: 'warehouse_zone_stacks' })
|
||||
@Index(['zoneId'])
|
||||
@Index(['status'])
|
||||
export class WarehouseZoneStack extends BaseEntity {
|
||||
@Column({ name: 'zone_id', type: 'uuid' })
|
||||
zoneId!: string;
|
||||
|
||||
@ManyToOne(() => WarehouseZone, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'zone_id' })
|
||||
zone?: WarehouseZone;
|
||||
|
||||
/** Unique within the zone, e.g. ZA-001. */
|
||||
@Column({ name: 'code', type: 'varchar', length: 40 })
|
||||
code!: string;
|
||||
|
||||
@Column({ name: 'name', type: 'varchar', length: 160, nullable: true })
|
||||
name?: string | null;
|
||||
|
||||
/** Free-form physical coordinates. Labels, not numbers — yards mix A/B/C with 1/2/3. */
|
||||
@Column({ name: 'row', type: 'varchar', length: 20, nullable: true })
|
||||
row?: string | null;
|
||||
|
||||
@Column({ name: 'bay', type: 'varchar', length: 20, nullable: true })
|
||||
bay?: string | null;
|
||||
|
||||
@Column({ name: 'position', type: 'varchar', length: 20, nullable: true })
|
||||
position?: string | null;
|
||||
|
||||
@Column({ name: 'max_stack_height', type: 'int', default: DEFAULT_MAX_STACK_HEIGHT })
|
||||
maxStackHeight!: number;
|
||||
|
||||
@Column({ name: 'status', type: 'varchar', length: 16, default: 'ACTIVE' })
|
||||
status!: WarehouseZoneStackStatus;
|
||||
|
||||
@Column({ name: 'is_active', type: 'boolean', default: true })
|
||||
isActive!: boolean;
|
||||
|
||||
@OneToMany(() => WarehouseZoneSlot, (slot) => slot.stack)
|
||||
slots?: WarehouseZoneSlot[];
|
||||
}
|
||||
@@ -1,12 +1,16 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { 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;
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@ import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
|
||||
interface ItemAttributes {
|
||||
arrivedAt: Date | null;
|
||||
/** Backlog-registered box: real arrival on the record, but never billed. */
|
||||
backlogRegistration: boolean;
|
||||
gateClearedAt: Date | null;
|
||||
releaseDate: Date | null;
|
||||
freightType: string | null;
|
||||
@@ -260,6 +262,7 @@ export class WarehouseFeeService {
|
||||
private async loadItem(inventoryId: string): Promise<ItemAttributes> {
|
||||
const [row] = await this.dataSource.query(
|
||||
`SELECT inv.arrived_at AS "arrivedAt",
|
||||
inv.backlog_registration AS "backlogRegistration",
|
||||
inv.gate_cleared_at AS "gateClearedAt",
|
||||
inv.release_date AS "releaseDate",
|
||||
inv.quantity AS "inventoryQuantity",
|
||||
@@ -777,6 +780,13 @@ export class WarehouseFeeService {
|
||||
/** Preview demurrage + storage fees for an inventory item using the most specific active rules. */
|
||||
async previewForInventory(inventoryId: string, billingCurrency = 'USD'): Promise<FeePreview[]> {
|
||||
const item = await this.loadItem(inventoryId);
|
||||
|
||||
// A backlog registration carries a backdated arrival so the record is
|
||||
// honest about how long the box has sat, but it was never booked through
|
||||
// EDR and is not billed for that history. No rule applies, so no preview —
|
||||
// which also keeps it off the invoice, since invoicing reads this same list.
|
||||
if (item.backlogRegistration) return [];
|
||||
|
||||
const rules = await this.feeRuleRepository.findAll({ where: { isActive: true } });
|
||||
const now = new Date();
|
||||
|
||||
@@ -894,6 +904,8 @@ export class WarehouseFeeService {
|
||||
trucks.map(async (t) => {
|
||||
const item: ItemAttributes = {
|
||||
arrivedAt: null,
|
||||
// Truck detention is a per-truck charge, never a warehouse backlog row.
|
||||
backlogRegistration: false,
|
||||
gateClearedAt: null,
|
||||
releaseDate: null,
|
||||
freightType: leg.freightType ?? null,
|
||||
|
||||
@@ -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)' })
|
||||
|
||||
@@ -50,6 +50,10 @@ import { InquiryWarehouseInventoryDto } from './dto/inquiry-inventory.dto';
|
||||
import { LoadInventoryDto } from './dto/load-inventory.dto';
|
||||
import { MoveInventoryDto } from './dto/move-inventory.dto';
|
||||
import { ReceiveWarehouseInventoryDto } from './dto/receive-inventory.dto';
|
||||
import {
|
||||
BulkRegisterBacklogDto,
|
||||
RegisterBacklogContainerDto,
|
||||
} from './dto/register-backlog.dto';
|
||||
import { ReleaseOrderDto } from './dto/release-order.dto';
|
||||
import { ReserveInventoryDto } from './dto/reserve-inventory.dto';
|
||||
import { UnloadBookingDto } from './dto/unload-booking.dto';
|
||||
@@ -70,6 +74,7 @@ import { Warehouse } from './entities/warehouse.entity';
|
||||
import { SchedulingReadFacade } from './scheduling-read.facade';
|
||||
import { WarehouseActivityLogService } from './warehouse-activity-log.service';
|
||||
import { WarehouseInventoryRepository } from './warehouse-inventory.repository';
|
||||
import { WarehousePlacementService } from './warehouse-placement.service';
|
||||
import { WarehouseLoadingRepository } from './warehouse-loading.repository';
|
||||
import { WarehouseReleaseDocumentService } from './warehouse-release-document.service';
|
||||
import { HandoverService } from './handover.service';
|
||||
@@ -412,6 +417,7 @@ export class WarehouseInventoryService {
|
||||
private readonly activityLog: WarehouseActivityLogService,
|
||||
private readonly scheduling: SchedulingReadFacade,
|
||||
private readonly allocation: WarehouseAllocationService,
|
||||
private readonly placement: WarehousePlacementService,
|
||||
private readonly invoices: WarehouseInvoiceService,
|
||||
private readonly inspectionService: WarehouseInspectionService,
|
||||
private readonly releaseDocuments: WarehouseReleaseDocumentService,
|
||||
@@ -2863,6 +2869,136 @@ export class WarehouseInventoryService {
|
||||
await this.lastMileService.acceptBooking(booking.reference);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a loaded container that is already physically in a yard but was
|
||||
* never entered in the system. Unlike receive(), there is no booking, no
|
||||
* truck entrance to record (nobody remembers the driver of a box that has sat
|
||||
* for months) and the arrival is backdated to when it actually turned up.
|
||||
*
|
||||
* The row is flagged `backlogRegistration`, which keeps the fee engine off it
|
||||
* entirely — see WarehouseFeeService.previewForInventory. Capacity is still
|
||||
* charged, because the box does occupy the yard.
|
||||
*/
|
||||
async registerBacklogContainer(dto: RegisterBacklogContainerDto): Promise<WarehouseInventory> {
|
||||
const id = await this.dataSource.transaction((manager) => this.saveBacklogContainer(manager, dto));
|
||||
const saved = await this.inventoryRepository.findById(id);
|
||||
if (!saved) throw new NotFoundException(`Inventory ${id} not found after registration`);
|
||||
return saved;
|
||||
}
|
||||
|
||||
/** The write itself, so single and bulk share one transaction each. */
|
||||
private async saveBacklogContainer(
|
||||
manager: EntityManager,
|
||||
dto: RegisterBacklogContainerDto,
|
||||
): Promise<string> {
|
||||
const containerNumber = dto.containerNumber.trim().toUpperCase();
|
||||
const arrivedAt = new Date(dto.arrivedAt);
|
||||
if (Number.isNaN(arrivedAt.getTime())) {
|
||||
throw new BadRequestException(`Arrival date "${dto.arrivedAt}" is not a valid date`);
|
||||
}
|
||||
if (arrivedAt.getTime() > Date.now()) {
|
||||
throw new BadRequestException('Arrival date cannot be in the future');
|
||||
}
|
||||
|
||||
{
|
||||
const { warehouse, yard, zone } = await this.validateLocation(manager, dto);
|
||||
|
||||
const containerType = await manager.query(
|
||||
`SELECT id FROM freight.container_types WHERE id = $1 AND deleted_at IS NULL`,
|
||||
[dto.containerTypeId],
|
||||
);
|
||||
if (containerType.length === 0) {
|
||||
throw new NotFoundException(`Container type ${dto.containerTypeId} not found`);
|
||||
}
|
||||
|
||||
// container_number is UNIQUE — reuse the existing record rather than
|
||||
// colliding, so a box seen before keeps one identity.
|
||||
const containers = manager.getRepository(Container);
|
||||
let container = await containers.findOne({ where: { containerNumber } });
|
||||
if (container) {
|
||||
const alreadyHeld = await manager.getRepository(WarehouseInventory).findOne({
|
||||
where: { containerId: container.id, status: In(['RECEIVED', 'STORED', 'READY_FOR_PICKUP']) },
|
||||
});
|
||||
if (alreadyHeld) {
|
||||
throw new BadRequestException(
|
||||
`Container ${containerNumber} is already in the warehouse (status ${alreadyHeld.status})`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
container = await containers.save(
|
||||
containers.create({
|
||||
containerNumber,
|
||||
containerTypeId: dto.containerTypeId,
|
||||
sealNumber: dto.sealNumber?.trim() || null,
|
||||
tareWeight: dto.tareWeight ?? 0,
|
||||
maxGrossWeight: dto.maxGrossWeight ?? 0,
|
||||
status: 'LOADED',
|
||||
bookingId: null,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const weight = Number(dto.weight) || 0;
|
||||
const volume = Number(dto.volume) || 0;
|
||||
this.assertCapacity('Warehouse', warehouse, weight, volume, 1);
|
||||
this.assertCapacity('Yard', yard, weight, volume, 1);
|
||||
this.assertCapacity('Zone', zone, weight, volume, 1);
|
||||
|
||||
const owner = dto.companyName?.trim() || null;
|
||||
const grnNumber = this.generateGrnNumber('WH', 'BACKLOG', arrivedAt, owner);
|
||||
const saved = await manager.getRepository(WarehouseInventory).save(
|
||||
manager.getRepository(WarehouseInventory).create({
|
||||
warehouseId: dto.warehouseId,
|
||||
yardId: dto.yardId,
|
||||
zoneId: dto.zoneId,
|
||||
bookingId: null,
|
||||
containerId: container.id,
|
||||
companyId: dto.companyId ?? null,
|
||||
companyName: owner,
|
||||
quantity: 1,
|
||||
weight,
|
||||
volume: dto.volume ?? null,
|
||||
grnNumber,
|
||||
status: 'RECEIVED',
|
||||
arrivedAt,
|
||||
backlogRegistration: true,
|
||||
notes: this.buildReceiveNote({
|
||||
grnNumber,
|
||||
notes:
|
||||
dto.notes?.trim() ||
|
||||
`Backlog registration — already in yard, arrived ${arrivedAt.toISOString().slice(0, 10)}`,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
await this.applyCapacityDelta(manager, dto, weight, volume, 1);
|
||||
return saved.id;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk backlog registration. All-or-nothing: one bad row rejects the sheet,
|
||||
* so a half-registered yard can never happen.
|
||||
*/
|
||||
async bulkRegisterBacklogContainers(dto: BulkRegisterBacklogDto): Promise<WarehouseInventory[]> {
|
||||
const numbers = dto.containers.map((c) => c.containerNumber.trim().toUpperCase());
|
||||
const seen = new Set<string>();
|
||||
const repeated = [...new Set(numbers.filter((n) => (seen.has(n) ? true : (seen.add(n), false))))];
|
||||
if (repeated.length > 0) {
|
||||
throw new BadRequestException(`Container number(s) repeated in the upload: ${repeated.join(', ')}`);
|
||||
}
|
||||
|
||||
const ids = await this.dataSource.transaction(async (manager) => {
|
||||
const written: string[] = [];
|
||||
for (const container of dto.containers) {
|
||||
written.push(await this.saveBacklogContainer(manager, container));
|
||||
}
|
||||
return written;
|
||||
});
|
||||
|
||||
return this.inventoryRepository.findAll({ where: { id: In(ids) } });
|
||||
}
|
||||
|
||||
async receive(dto: ReceiveWarehouseInventoryDto): Promise<WarehouseInventory> {
|
||||
const bookingDirection = dto.bookingId ? await this.getBookingDirection(dto.bookingId) : null;
|
||||
await this.assertExportBookingPaid(dto.bookingId, bookingDirection);
|
||||
@@ -2996,12 +3132,37 @@ export class WarehouseInventoryService {
|
||||
if (
|
||||
item.warehouseId === dto.warehouseId &&
|
||||
item.yardId === dto.yardId &&
|
||||
item.zoneId === dto.zoneId
|
||||
item.zoneId === dto.zoneId &&
|
||||
(item.slotId ?? null) === (dto.slotId ?? null)
|
||||
) {
|
||||
throw new BadRequestException('Destination location is the same as current location');
|
||||
}
|
||||
|
||||
const { warehouse, yard, zone } = await this.validateLocation(manager, dto);
|
||||
|
||||
// Taking a box out of a stack is physically impossible while others stand
|
||||
// on top of it — the same rule the release path enforces. Moving is one of
|
||||
// those exits, so it is checked here rather than only at release.
|
||||
if (item.slotId) {
|
||||
await this.placement.assertAccessible(item.id, manager);
|
||||
}
|
||||
|
||||
// A move that names a slot is validated against the hierarchy it claims;
|
||||
// one that does not clears the old slot, because the box has left it.
|
||||
if (dto.slotId) {
|
||||
await this.placement.validateSlotForInventory(
|
||||
{
|
||||
slotId: dto.slotId,
|
||||
warehouseId: dto.warehouseId,
|
||||
yardId: dto.yardId,
|
||||
zoneId: dto.zoneId,
|
||||
inventoryId: item.id,
|
||||
quantity: Number(item.quantity) || 0,
|
||||
},
|
||||
manager,
|
||||
);
|
||||
}
|
||||
|
||||
const weight = Number(item.weight) || 0;
|
||||
const containerCount = item.containerId ? Math.round(Number(item.quantity) || 0) : 0;
|
||||
|
||||
@@ -3011,7 +3172,12 @@ export class WarehouseInventoryService {
|
||||
if (item.yardId !== dto.yardId) {
|
||||
this.assertCapacity('Yard', yard, weight, Number(item.volume) || 0, containerCount);
|
||||
}
|
||||
this.assertCapacity('Zone', zone, weight, Number(item.volume) || 0, containerCount);
|
||||
// Skipped when the zone is unchanged: a slot-to-slot reshuffle inside one
|
||||
// zone adds nothing to it, and a full zone would otherwise refuse to let
|
||||
// its own containers be restacked.
|
||||
if (item.zoneId !== dto.zoneId) {
|
||||
this.assertCapacity('Zone', zone, weight, Number(item.volume) || 0, containerCount);
|
||||
}
|
||||
|
||||
await this.applyCapacityDelta(
|
||||
manager,
|
||||
@@ -3030,6 +3196,14 @@ export class WarehouseInventoryService {
|
||||
item.warehouseId = dto.warehouseId;
|
||||
item.yardId = dto.yardId;
|
||||
item.zoneId = dto.zoneId;
|
||||
if (dto.slotId) {
|
||||
const slot = await this.placement.resolveSlot(dto.slotId, manager);
|
||||
item.stackId = slot.stackId;
|
||||
item.slotId = slot.slotId;
|
||||
} else {
|
||||
item.stackId = null;
|
||||
item.slotId = null;
|
||||
}
|
||||
if (dto.remarks?.trim()) {
|
||||
const existingNotes = item.notes?.trim();
|
||||
item.notes = existingNotes
|
||||
@@ -3044,12 +3218,171 @@ export class WarehouseInventoryService {
|
||||
return this.findById(movedId);
|
||||
}
|
||||
|
||||
// ── Physical slot placement ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Pin an inventory item to an exact stack level, or let the placement engine
|
||||
* pick the lowest free one. Transactional and locked: the row cannot be moved
|
||||
* out from under the placement between validation and write.
|
||||
*/
|
||||
async assignSlot(id: string, slotId?: string, performedBy?: string): Promise<WarehouseInventory> {
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
const item = await manager.getRepository(WarehouseInventory).findOne({
|
||||
where: { id },
|
||||
lock: { mode: 'pessimistic_write' },
|
||||
});
|
||||
if (!item) throw new NotFoundException(`Inventory item ${id} not found`);
|
||||
|
||||
const criteria = await this.getInventoryAllocationCriteria(item);
|
||||
const chosenSlotId =
|
||||
slotId ??
|
||||
(
|
||||
await this.placement.findAvailableContainerSlot(
|
||||
{ yardId: item.yardId, zoneId: item.zoneId, direction: criteria.tradeDirection },
|
||||
manager,
|
||||
)
|
||||
)?.slotId;
|
||||
|
||||
if (!chosenSlotId) {
|
||||
throw new BadRequestException('No free stack level is available in this zone');
|
||||
}
|
||||
|
||||
const slot = await this.placement.validateSlotForInventory(
|
||||
{
|
||||
slotId: chosenSlotId,
|
||||
warehouseId: item.warehouseId,
|
||||
yardId: item.yardId,
|
||||
zoneId: item.zoneId,
|
||||
inventoryId: item.id,
|
||||
quantity: Number(item.quantity) || 0,
|
||||
},
|
||||
manager,
|
||||
);
|
||||
|
||||
await manager.getRepository(WarehouseInventory).update(id, {
|
||||
stackId: slot.stackId,
|
||||
slotId: slot.slotId,
|
||||
notes: this.appendNote(item.notes, `Placed at ${slot.stackCode} level ${slot.level}`),
|
||||
});
|
||||
|
||||
await this.activityLog.record(
|
||||
{
|
||||
activityType: 'INVENTORY_MOVED',
|
||||
inventoryId: id,
|
||||
warehouseId: item.warehouseId,
|
||||
description: `Placed at ${slot.stackCode} level ${slot.level}`,
|
||||
performedBy,
|
||||
},
|
||||
manager,
|
||||
);
|
||||
});
|
||||
|
||||
return this.findById(id);
|
||||
}
|
||||
|
||||
/** Take the item off its stack level without moving it out of the zone. */
|
||||
async releaseSlot(id: string, performedBy?: string): Promise<WarehouseInventory> {
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
const item = await manager.getRepository(WarehouseInventory).findOne({
|
||||
where: { id },
|
||||
lock: { mode: 'pessimistic_write' },
|
||||
});
|
||||
if (!item) throw new NotFoundException(`Inventory item ${id} not found`);
|
||||
if (!item.slotId) return;
|
||||
|
||||
// Nothing may be standing on top of it — freeing a buried box would leave
|
||||
// the containers above it floating over an empty level.
|
||||
await this.placement.assertAccessible(id, manager);
|
||||
|
||||
await manager.getRepository(WarehouseInventory).update(id, { stackId: null, slotId: null });
|
||||
await this.activityLog.record(
|
||||
{
|
||||
activityType: 'INVENTORY_MOVED',
|
||||
inventoryId: id,
|
||||
warehouseId: item.warehouseId,
|
||||
description: 'Released from its stack level',
|
||||
performedBy,
|
||||
},
|
||||
manager,
|
||||
);
|
||||
});
|
||||
|
||||
return this.findById(id);
|
||||
}
|
||||
|
||||
/** Whether the box can be lifted out, and what is stacked on top of it if not. */
|
||||
getContainerAccessibility(id: string) {
|
||||
return this.placement.getContainerAccessibility(id);
|
||||
}
|
||||
|
||||
/** Lowest free stack level for the given yard/zone, without assigning it. */
|
||||
findAvailableSlot(input: {
|
||||
yardId: string;
|
||||
zoneId?: string;
|
||||
direction?: string;
|
||||
cargoTypeId?: string;
|
||||
}) {
|
||||
return this.placement.findAvailableContainerSlot(input);
|
||||
}
|
||||
|
||||
/**
|
||||
* The physical position an item should take when it is stored.
|
||||
*
|
||||
* An explicitly chosen slot is validated and any failure is surfaced — the
|
||||
* operator asked for that exact level. The automatic path is best-effort:
|
||||
* a yard with no stacks configured yet, or one that is full, falls back to
|
||||
* plain zone-level storage rather than blocking a store that worked before
|
||||
* this model existed.
|
||||
*/
|
||||
private async resolveStoragePlacement(
|
||||
manager: EntityManager,
|
||||
item: WarehouseInventory,
|
||||
location: LocationRef,
|
||||
options: { slotId?: string; direction?: string | null },
|
||||
): Promise<{ stackId: string; slotId: string; label: string } | null> {
|
||||
const yard = await manager.getRepository(WarehouseYard).findOne({ where: { id: location.yardId } });
|
||||
if (yard?.type !== 'CONTAINER_YARD') return null;
|
||||
|
||||
if (options.slotId) {
|
||||
const slot = await this.placement.validateSlotForInventory(
|
||||
{
|
||||
slotId: options.slotId,
|
||||
warehouseId: location.warehouseId,
|
||||
yardId: location.yardId,
|
||||
zoneId: location.zoneId,
|
||||
inventoryId: item.id,
|
||||
quantity: Number(item.quantity) || 0,
|
||||
},
|
||||
manager,
|
||||
);
|
||||
return { stackId: slot.stackId, slotId: slot.slotId, label: `${slot.stackCode} level ${slot.level}` };
|
||||
}
|
||||
|
||||
// A row still covering several containers has no single position to take.
|
||||
if ((Number(item.quantity) || 0) > 1) return null;
|
||||
|
||||
try {
|
||||
const found = await this.placement.findAvailableContainerSlot(
|
||||
{ yardId: location.yardId, zoneId: location.zoneId, direction: options.direction },
|
||||
manager,
|
||||
);
|
||||
return found
|
||||
? { stackId: found.stackId, slotId: found.slotId, label: `${found.stackCode} level ${found.level}` }
|
||||
: null;
|
||||
} catch (error) {
|
||||
this.logger.debug(
|
||||
`Automatic slot placement skipped for inventory ${item.id}: ${(error as Error).message}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Lifecycle transitions ────────────────────────────────────────────────
|
||||
|
||||
async store(
|
||||
id: string,
|
||||
performedBy?: string,
|
||||
chosen?: { warehouseId?: string; yardId?: string; zoneId?: string },
|
||||
chosen?: { warehouseId?: string; yardId?: string; zoneId?: string; slotId?: string },
|
||||
): Promise<WarehouseInventory> {
|
||||
const item = await this.findById(id);
|
||||
this.assertTransition(item.status, 'STORED');
|
||||
@@ -3113,11 +3446,17 @@ export class WarehouseInventoryService {
|
||||
await this.applyCapacityDelta(manager, location, weight, volume, containerCount);
|
||||
}
|
||||
|
||||
const storedReason = manualLocation
|
||||
const placed = await this.resolveStoragePlacement(manager, locked, location, {
|
||||
slotId: chosen?.slotId,
|
||||
direction: criteria.tradeDirection,
|
||||
});
|
||||
|
||||
const baseReason = manualLocation
|
||||
? `Stored at operator-selected location -> ${location.path ?? 'chosen yard/zone'}`
|
||||
: ruleLocation?.rule
|
||||
? `Stored by allocation rule "${ruleLocation.rule.name}" -> ${ruleLocation.path}`
|
||||
: `Stored by capacity-balanced allocation -> ${location.path ?? 'assigned yard/zone'}`;
|
||||
const storedReason = placed ? `${baseReason} @ ${placed.label}` : baseReason;
|
||||
|
||||
await manager.getRepository(WarehouseInventory).update(id, {
|
||||
status: 'STORED',
|
||||
@@ -3125,6 +3464,8 @@ export class WarehouseInventoryService {
|
||||
warehouseId: location.warehouseId,
|
||||
yardId: location.yardId,
|
||||
zoneId: location.zoneId,
|
||||
stackId: placed?.stackId ?? null,
|
||||
slotId: placed?.slotId ?? null,
|
||||
notes: this.appendNote(locked.notes, storedReason),
|
||||
});
|
||||
|
||||
@@ -6029,6 +6370,18 @@ export class WarehouseInventoryService {
|
||||
if (!yard) throw new NotFoundException(`Yard ${dto.yardId} not found`);
|
||||
const zone = await manager.getRepository(WarehouseZone).findOne({ where: { id: dto.zoneId } });
|
||||
if (!zone) throw new NotFoundException(`Zone ${dto.zoneId} not found`);
|
||||
|
||||
// The three ids arrive independently from the client, so they have to be
|
||||
// checked against each other: a zone belonging to another yard would send
|
||||
// the item to a location that does not exist on the ground, and every
|
||||
// capacity counter above it would be adjusted on the wrong row.
|
||||
if (zone.yardId !== yard.id) {
|
||||
throw new BadRequestException(`Zone ${zone.code} does not belong to yard ${yard.code}`);
|
||||
}
|
||||
if (yard.warehouseId !== warehouse.id) {
|
||||
throw new BadRequestException(`Yard ${yard.code} does not belong to warehouse ${warehouse.code}`);
|
||||
}
|
||||
|
||||
return { warehouse, yard, zone };
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,644 @@
|
||||
import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import { DataSource, EntityManager } from 'typeorm';
|
||||
|
||||
import {
|
||||
SLOT_OCCUPYING_STATUSES,
|
||||
WarehouseInventory,
|
||||
} from './entities/warehouse-inventory.entity';
|
||||
import { SlotEffectiveStatus } from './entities/warehouse-zone-slot.entity';
|
||||
|
||||
/** The whole chain above one slot, resolved server-side in a single join. */
|
||||
export interface SlotHierarchy {
|
||||
slotId: string;
|
||||
slotStatus: string;
|
||||
slotIsActive: boolean;
|
||||
level: number;
|
||||
stackId: string;
|
||||
stackCode: string;
|
||||
stackStatus: string;
|
||||
stackIsActive: boolean;
|
||||
maxStackHeight: number;
|
||||
zoneId: string;
|
||||
zoneCode: string;
|
||||
zoneType: string;
|
||||
zoneStatus: string;
|
||||
zoneIsActive: boolean;
|
||||
yardId: string;
|
||||
yardCode: string;
|
||||
yardType: string;
|
||||
yardDirection: string | null;
|
||||
yardStatus: string;
|
||||
yardIsActive: boolean;
|
||||
warehouseId: string;
|
||||
warehouseCode: string;
|
||||
warehouseStatus: string;
|
||||
warehouseIsActive: boolean;
|
||||
}
|
||||
|
||||
export interface AvailableSlot {
|
||||
slotId: string;
|
||||
stackId: string;
|
||||
stackCode: string;
|
||||
level: number;
|
||||
zoneId: string;
|
||||
zoneCode: string;
|
||||
}
|
||||
|
||||
export interface FindSlotInput {
|
||||
yardId: string;
|
||||
zoneId?: string | null;
|
||||
/** IMPORT | EXPORT — matched against the yard's direction (null = BOTH). */
|
||||
direction?: string | null;
|
||||
cargoTypeId?: string | null;
|
||||
}
|
||||
|
||||
export interface BlockingContainer {
|
||||
inventoryId: string;
|
||||
containerNumber: string | null;
|
||||
level: number;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface ContainerAccessibility {
|
||||
accessible: boolean;
|
||||
inventoryId: string;
|
||||
stackCode: string | null;
|
||||
level: number | null;
|
||||
blockingContainers: BlockingContainer[];
|
||||
}
|
||||
|
||||
export interface SlotSummary {
|
||||
configuredCapacity: number | null;
|
||||
physicalSlotCount: number;
|
||||
occupiedSlotCount: number;
|
||||
reservedSlotCount: number;
|
||||
blockedSlotCount: number;
|
||||
inactiveSlotCount: number;
|
||||
availableSlotCount: number;
|
||||
/** True when more physical slots are built than the configured capacity allows. */
|
||||
inconsistent: boolean;
|
||||
}
|
||||
|
||||
export interface ZoneLayoutSlot {
|
||||
slotId: string;
|
||||
level: number;
|
||||
effectiveStatus: SlotEffectiveStatus;
|
||||
inventoryId: string | null;
|
||||
containerNumber: string | null;
|
||||
}
|
||||
|
||||
export interface ZoneLayoutStack {
|
||||
stackId: string;
|
||||
code: string;
|
||||
name: string | null;
|
||||
maxStackHeight: number;
|
||||
status: string;
|
||||
isActive: boolean;
|
||||
slots: ZoneLayoutSlot[];
|
||||
}
|
||||
|
||||
export interface ZoneLayout {
|
||||
zoneId: string;
|
||||
zoneCode: string;
|
||||
zoneName: string;
|
||||
stacks: ZoneLayoutStack[];
|
||||
summary: SlotSummary;
|
||||
}
|
||||
|
||||
/**
|
||||
* Container identity has two sources and neither covers the other: a backlog
|
||||
* registration points `warehouse_inventory.container_id` at a `containers` row,
|
||||
* while booked cargo carries its numbers on `booking_container_units`. Scalar
|
||||
* subselects rather than joins, so one slot can never fan out into many rows.
|
||||
* A booking whose units were never split into one inventory row each shows the
|
||||
* first unit number — placement refuses such rows anyway (see assertSingleUnit).
|
||||
*/
|
||||
const CONTAINER_NUMBER_EXPR = `COALESCE(
|
||||
(SELECT c.container_number FROM freight.containers c
|
||||
WHERE c.id = i.container_id AND c.deleted_at IS NULL),
|
||||
(SELECT bcu.container_number FROM freight.booking_container_units bcu
|
||||
JOIN freight.booking_container bc ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
|
||||
WHERE bc.booking_id = i.booking_id AND bcu.deleted_at IS NULL
|
||||
ORDER BY bcu.container_number
|
||||
LIMIT 1)
|
||||
)`;
|
||||
|
||||
/** A row is "standing in its slot" only in these statuses — same list as the DB's partial unique index. */
|
||||
const OCCUPYING = SLOT_OCCUPYING_STATUSES as unknown as string[];
|
||||
|
||||
/**
|
||||
* Physical container placement: the stage after allocation. Allocation picks a
|
||||
* yard (and maybe a zone) from configured rules; this picks the exact stack and
|
||||
* level, enforces the stacking rules, and answers whether a box can be reached.
|
||||
*
|
||||
* Nothing here is called for a non-container yard — bulk, general cargo,
|
||||
* hazardous and cold storage keep zone-level placement.
|
||||
*/
|
||||
@Injectable()
|
||||
export class WarehousePlacementService {
|
||||
constructor(@InjectDataSource() private readonly dataSource: DataSource) {}
|
||||
|
||||
private em(manager?: EntityManager): EntityManager | DataSource {
|
||||
return manager ?? this.dataSource;
|
||||
}
|
||||
|
||||
// ── Hierarchy ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Resolve a slot's full chain up to the warehouse. Ids arriving from a client
|
||||
* are never trusted against one another — this is the one place the chain is
|
||||
* established, and every caller compares against what comes back here.
|
||||
*/
|
||||
async resolveSlot(slotId: string, manager?: EntityManager): Promise<SlotHierarchy> {
|
||||
const [row] = await this.em(manager).query(
|
||||
`SELECT sl.id AS "slotId", sl.status AS "slotStatus", sl.is_active AS "slotIsActive",
|
||||
sl.level AS "level",
|
||||
s.id AS "stackId", s.code AS "stackCode", s.status AS "stackStatus",
|
||||
s.is_active AS "stackIsActive", s.max_stack_height AS "maxStackHeight",
|
||||
z.id AS "zoneId", z.code AS "zoneCode", z.type AS "zoneType",
|
||||
z.status AS "zoneStatus", z.is_active AS "zoneIsActive",
|
||||
y.id AS "yardId", y.code AS "yardCode", y.type AS "yardType",
|
||||
y.direction AS "yardDirection", y.status AS "yardStatus", y.is_active AS "yardIsActive",
|
||||
w.id AS "warehouseId", w.code AS "warehouseCode",
|
||||
w.status AS "warehouseStatus", w.is_active AS "warehouseIsActive"
|
||||
FROM freight.warehouse_zone_slots sl
|
||||
JOIN freight.warehouse_zone_stacks s ON s.id = sl.stack_id AND s.deleted_at IS NULL
|
||||
JOIN freight.warehouse_zones z ON z.id = s.zone_id AND z.deleted_at IS NULL
|
||||
JOIN freight.warehouse_yards y ON y.id = z.yard_id AND y.deleted_at IS NULL
|
||||
JOIN freight.warehouses w ON w.id = y.warehouse_id AND w.deleted_at IS NULL
|
||||
WHERE sl.id = $1 AND sl.deleted_at IS NULL`,
|
||||
[slotId],
|
||||
);
|
||||
|
||||
if (!row) throw new NotFoundException(`Slot ${slotId} not found`);
|
||||
return row as SlotHierarchy;
|
||||
}
|
||||
|
||||
/** Levels in a stack currently holding a container, lowest first. */
|
||||
async occupiedLevels(
|
||||
stackId: string,
|
||||
excludeInventoryId?: string | null,
|
||||
manager?: EntityManager,
|
||||
): Promise<number[]> {
|
||||
const rows: Array<{ level: number }> = await this.em(manager).query(
|
||||
`SELECT sl.level AS "level"
|
||||
FROM freight.warehouse_zone_slots sl
|
||||
JOIN freight.warehouse_inventory i
|
||||
ON i.slot_id = sl.id AND i.deleted_at IS NULL AND i.status = ANY($2)
|
||||
WHERE sl.stack_id = $1 AND sl.deleted_at IS NULL
|
||||
AND ($3::uuid IS NULL OR i.id <> $3::uuid)
|
||||
ORDER BY sl.level`,
|
||||
[stackId, OCCUPYING, excludeInventoryId ?? null],
|
||||
);
|
||||
return rows.map((r) => Number(r.level));
|
||||
}
|
||||
|
||||
// ── Placement validation ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Every check that must pass before a container may stand in a slot, in the
|
||||
* order a yard operator would hit them. Returns the resolved hierarchy so the
|
||||
* caller writes ids it did not invent.
|
||||
*/
|
||||
async validateSlotForInventory(
|
||||
input: {
|
||||
slotId: string;
|
||||
warehouseId: string;
|
||||
yardId: string;
|
||||
zoneId: string;
|
||||
/** Excluded from occupancy checks — the row being moved is allowed to leave its own slot. */
|
||||
inventoryId?: string | null;
|
||||
quantity?: number | null;
|
||||
},
|
||||
manager?: EntityManager,
|
||||
): Promise<SlotHierarchy> {
|
||||
const slot = await this.resolveSlot(input.slotId, manager);
|
||||
|
||||
// 1. Hierarchy — the client may not staple a slot onto an unrelated zone/yard/warehouse.
|
||||
if (slot.zoneId !== input.zoneId) {
|
||||
throw new BadRequestException(
|
||||
`Slot ${slot.stackCode}/L${slot.level} belongs to zone ${slot.zoneCode}, not the zone given`,
|
||||
);
|
||||
}
|
||||
if (slot.yardId !== input.yardId) {
|
||||
throw new BadRequestException(`Zone ${slot.zoneCode} belongs to yard ${slot.yardCode}, not the yard given`);
|
||||
}
|
||||
if (slot.warehouseId !== input.warehouseId) {
|
||||
throw new BadRequestException(
|
||||
`Yard ${slot.yardCode} belongs to warehouse ${slot.warehouseCode}, not the warehouse given`,
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Every level of the chain has to be operationally open.
|
||||
this.assertOperational('Warehouse', slot.warehouseCode, slot.warehouseStatus, slot.warehouseIsActive);
|
||||
this.assertOperational('Yard', slot.yardCode, slot.yardStatus, slot.yardIsActive);
|
||||
this.assertOperational('Zone', slot.zoneCode, slot.zoneStatus, slot.zoneIsActive);
|
||||
this.assertOperational('Stack', slot.stackCode, slot.stackStatus, slot.stackIsActive);
|
||||
|
||||
if (!slot.slotIsActive) {
|
||||
throw new BadRequestException(`Slot ${slot.stackCode}/L${slot.level} is inactive`);
|
||||
}
|
||||
// RESERVED is accepted: a slot is reserved *for* the box now arriving.
|
||||
if (slot.slotStatus !== 'AVAILABLE' && slot.slotStatus !== 'RESERVED') {
|
||||
throw new BadRequestException(`Slot ${slot.stackCode}/L${slot.level} is ${slot.slotStatus}`);
|
||||
}
|
||||
|
||||
// 3. One box per slot. The DB's partial unique index is the backstop; this
|
||||
// is the readable error the operator actually gets.
|
||||
const [taken] = await this.em(manager).query(
|
||||
`SELECT i.id FROM freight.warehouse_inventory i
|
||||
WHERE i.slot_id = $1 AND i.deleted_at IS NULL AND i.status = ANY($2)
|
||||
AND ($3::uuid IS NULL OR i.id <> $3::uuid)
|
||||
LIMIT 1`,
|
||||
[input.slotId, OCCUPYING, input.inventoryId ?? null],
|
||||
);
|
||||
if (taken) {
|
||||
throw new ConflictException(`Slot ${slot.stackCode}/L${slot.level} is already occupied`);
|
||||
}
|
||||
|
||||
if (slot.level > slot.maxStackHeight) {
|
||||
throw new BadRequestException(
|
||||
`Level ${slot.level} is above stack ${slot.stackCode}'s maximum height of ${slot.maxStackHeight}`,
|
||||
);
|
||||
}
|
||||
|
||||
// 4. Container yards only: no box may float above an empty level, and a row
|
||||
// covering several containers has no single physical position.
|
||||
if (slot.yardType === 'CONTAINER_YARD') {
|
||||
this.assertSingleUnit(input.quantity);
|
||||
const occupied = await this.occupiedLevels(slot.stackId, input.inventoryId ?? null, manager);
|
||||
this.assertStackable(slot, occupied);
|
||||
}
|
||||
|
||||
return slot;
|
||||
}
|
||||
|
||||
private assertOperational(label: string, code: string, status: string, isActive: boolean): void {
|
||||
if (status !== 'ACTIVE' || !isActive) {
|
||||
throw new BadRequestException(`${label} ${code} is not active`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A slot is one container. A row that still carries several boxes has no
|
||||
* single position — split it before placing it, rather than silently pinning
|
||||
* five containers to one level.
|
||||
*/
|
||||
private assertSingleUnit(quantity?: number | null): void {
|
||||
const qty = Number(quantity ?? 1);
|
||||
if (qty > 1) {
|
||||
throw new BadRequestException(
|
||||
`This inventory row covers ${qty} containers. Split it into one row per container before assigning a slot.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Level N needs every level below it filled — nothing hovers. */
|
||||
assertStackable(slot: Pick<SlotHierarchy, 'level' | 'stackCode'>, occupiedLevels: number[]): void {
|
||||
if (slot.level === 1) return;
|
||||
const missing: number[] = [];
|
||||
for (let level = 1; level < slot.level; level += 1) {
|
||||
if (!occupiedLevels.includes(level)) missing.push(level);
|
||||
}
|
||||
if (missing.length > 0) {
|
||||
throw new BadRequestException(
|
||||
`Stack ${slot.stackCode}: level ${slot.level} cannot be filled while level(s) ${missing.join(', ')} are empty`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Finding a slot ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Lowest valid free level, deterministic: zone code, then stack code, then
|
||||
* level. Bottom-up by construction — a stack's candidate level is always one
|
||||
* above its current top, so level 2 can never be picked before level 1.
|
||||
*
|
||||
* Isolated on purpose: a smarter strategy (weight, direction, dwell time)
|
||||
* swaps in here without touching any caller.
|
||||
*/
|
||||
async findAvailableContainerSlot(input: FindSlotInput, manager?: EntityManager): Promise<AvailableSlot | null> {
|
||||
const yard = await this.loadYardForPlacement(input, manager);
|
||||
const zoneIds = await this.candidateZoneIds(yard.id, input.zoneId ?? null, manager);
|
||||
if (zoneIds.length === 0) return null;
|
||||
|
||||
const [slot] = await this.em(manager).query(
|
||||
`SELECT sl.id AS "slotId", s.id AS "stackId", s.code AS "stackCode",
|
||||
sl.level AS "level", z.id AS "zoneId", z.code AS "zoneCode"
|
||||
FROM freight.warehouse_zone_stacks s
|
||||
JOIN freight.warehouse_zones z ON z.id = s.zone_id AND z.deleted_at IS NULL
|
||||
CROSS JOIN LATERAL (
|
||||
SELECT COALESCE(MAX(sl2.level), 0) AS top
|
||||
FROM freight.warehouse_zone_slots sl2
|
||||
JOIN freight.warehouse_inventory i2
|
||||
ON i2.slot_id = sl2.id AND i2.deleted_at IS NULL AND i2.status = ANY($2)
|
||||
WHERE sl2.stack_id = s.id AND sl2.deleted_at IS NULL
|
||||
) occ
|
||||
JOIN freight.warehouse_zone_slots sl
|
||||
ON sl.stack_id = s.id AND sl.deleted_at IS NULL
|
||||
AND sl.level = occ.top + 1
|
||||
AND sl.status = 'AVAILABLE' AND sl.is_active = true
|
||||
WHERE s.zone_id = ANY($1::uuid[])
|
||||
AND s.deleted_at IS NULL AND s.status = 'ACTIVE' AND s.is_active = true
|
||||
AND occ.top < s.max_stack_height
|
||||
ORDER BY z.code, s.code, sl.level
|
||||
LIMIT 1`,
|
||||
[zoneIds, OCCUPYING],
|
||||
);
|
||||
|
||||
return (slot as AvailableSlot) ?? null;
|
||||
}
|
||||
|
||||
/** Yard gates: active, a container yard, right direction, right cargo type. */
|
||||
private async loadYardForPlacement(
|
||||
input: FindSlotInput,
|
||||
manager?: EntityManager,
|
||||
): Promise<{ id: string; code: string }> {
|
||||
const [yard] = await this.em(manager).query(
|
||||
`SELECT y.id, y.code, y.type, y.direction, y.status, y.is_active AS "isActive",
|
||||
y.capacity_containers AS "capacityContainers", y.current_containers AS "currentContainers",
|
||||
w.status AS "warehouseStatus", w.is_active AS "warehouseIsActive", w.code AS "warehouseCode"
|
||||
FROM freight.warehouse_yards y
|
||||
JOIN freight.warehouses w ON w.id = y.warehouse_id AND w.deleted_at IS NULL
|
||||
WHERE y.id = $1 AND y.deleted_at IS NULL`,
|
||||
[input.yardId],
|
||||
);
|
||||
if (!yard) throw new NotFoundException(`Yard ${input.yardId} not found`);
|
||||
|
||||
this.assertOperational('Warehouse', yard.warehouseCode, yard.warehouseStatus, yard.warehouseIsActive);
|
||||
this.assertOperational('Yard', yard.code, yard.status, yard.isActive);
|
||||
|
||||
if (yard.type !== 'CONTAINER_YARD') {
|
||||
throw new BadRequestException(`Yard ${yard.code} is a ${yard.type}; container stacking does not apply`);
|
||||
}
|
||||
|
||||
// Null direction has always meant "takes both" — never treat it as invalid.
|
||||
const yardDirection = yard.direction ?? 'BOTH';
|
||||
const wanted = input.direction ?? 'BOTH';
|
||||
if (yardDirection !== 'BOTH' && wanted !== 'BOTH' && yardDirection !== wanted) {
|
||||
throw new BadRequestException(`Yard ${yard.code} serves ${yardDirection} traffic, not ${wanted}`);
|
||||
}
|
||||
|
||||
// Empty cargo-type relation = open to any cargo. Preserved deliberately.
|
||||
if (input.cargoTypeId) {
|
||||
const [{ allowed }] = await this.em(manager).query(
|
||||
`SELECT (NOT EXISTS (SELECT 1 FROM freight.warehouse_yard_cargo_types t WHERE t.yard_id = $1)
|
||||
OR EXISTS (SELECT 1 FROM freight.warehouse_yard_cargo_types t
|
||||
WHERE t.yard_id = $1 AND t.cargo_type_id = $2)) AS allowed`,
|
||||
[yard.id, input.cargoTypeId],
|
||||
);
|
||||
if (!allowed) {
|
||||
throw new BadRequestException(`Yard ${yard.code} does not accept this cargo type`);
|
||||
}
|
||||
}
|
||||
|
||||
if (yard.capacityContainers != null && Number(yard.currentContainers) >= Number(yard.capacityContainers)) {
|
||||
throw new BadRequestException(
|
||||
`Yard ${yard.code} is at its configured capacity (${yard.currentContainers}/${yard.capacityContainers})`,
|
||||
);
|
||||
}
|
||||
|
||||
return { id: yard.id, code: yard.code };
|
||||
}
|
||||
|
||||
/** Active container zones in the yard with configured capacity left, in code order. */
|
||||
private async candidateZoneIds(
|
||||
yardId: string,
|
||||
zoneId: string | null,
|
||||
manager?: EntityManager,
|
||||
): Promise<string[]> {
|
||||
const rows: Array<{ id: string }> = await this.em(manager).query(
|
||||
`SELECT z.id
|
||||
FROM freight.warehouse_zones z
|
||||
WHERE z.yard_id = $1 AND z.deleted_at IS NULL
|
||||
AND z.status = 'ACTIVE' AND z.is_active = true
|
||||
AND z.type = 'CONTAINER_ZONE'
|
||||
AND (z.capacity_containers IS NULL OR z.current_containers < z.capacity_containers)
|
||||
AND ($2::uuid IS NULL OR z.id = $2::uuid)
|
||||
ORDER BY z.code`,
|
||||
[yardId, zoneId],
|
||||
);
|
||||
return rows.map((r) => r.id);
|
||||
}
|
||||
|
||||
// ── Accessibility ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Whether a box can be taken out without touching anything else. Containers
|
||||
* standing above it block it; nothing is moved to clear the way — a
|
||||
* relocation is an operator decision, not a side effect of a read.
|
||||
*/
|
||||
async getContainerAccessibility(inventoryId: string, manager?: EntityManager): Promise<ContainerAccessibility> {
|
||||
const [placed] = await this.em(manager).query(
|
||||
`SELECT i.id AS "inventoryId", sl.level AS "level", s.id AS "stackId", s.code AS "stackCode"
|
||||
FROM freight.warehouse_inventory i
|
||||
LEFT JOIN freight.warehouse_zone_slots sl ON sl.id = i.slot_id AND sl.deleted_at IS NULL
|
||||
LEFT JOIN freight.warehouse_zone_stacks s ON s.id = sl.stack_id AND s.deleted_at IS NULL
|
||||
WHERE i.id = $1 AND i.deleted_at IS NULL`,
|
||||
[inventoryId],
|
||||
);
|
||||
|
||||
if (!placed) throw new NotFoundException(`Inventory item ${inventoryId} not found`);
|
||||
|
||||
// No slot = zone-level placement (bulk, or an item that predates the model):
|
||||
// nothing is stacked on it, so it is reachable.
|
||||
if (!placed.stackId) {
|
||||
return { accessible: true, inventoryId, stackCode: null, level: null, blockingContainers: [] };
|
||||
}
|
||||
|
||||
const blocking: BlockingContainer[] = await this.em(manager).query(
|
||||
`SELECT i.id AS "inventoryId", sl.level AS "level", i.status AS "status",
|
||||
${CONTAINER_NUMBER_EXPR} AS "containerNumber"
|
||||
FROM freight.warehouse_zone_slots sl
|
||||
JOIN freight.warehouse_inventory i
|
||||
ON i.slot_id = sl.id AND i.deleted_at IS NULL AND i.status = ANY($3)
|
||||
WHERE sl.stack_id = $1 AND sl.deleted_at IS NULL AND sl.level > $2
|
||||
ORDER BY sl.level DESC`,
|
||||
[placed.stackId, Number(placed.level), OCCUPYING],
|
||||
);
|
||||
|
||||
return {
|
||||
accessible: blocking.length === 0,
|
||||
inventoryId,
|
||||
stackCode: placed.stackCode,
|
||||
level: Number(placed.level),
|
||||
blockingContainers: blocking.map((b) => ({ ...b, level: Number(b.level) })),
|
||||
};
|
||||
}
|
||||
|
||||
/** Refuse to hand out a box that is buried — used by the exit/delivery paths. */
|
||||
async assertAccessible(inventoryId: string, manager?: EntityManager): Promise<void> {
|
||||
const access = await this.getContainerAccessibility(inventoryId, manager);
|
||||
if (!access.accessible) {
|
||||
const above = access.blockingContainers
|
||||
.map((b) => `${b.containerNumber ?? b.inventoryId} (L${b.level})`)
|
||||
.join(', ');
|
||||
throw new ConflictException(
|
||||
`Container is at ${access.stackCode}/L${access.level} with ${above} stacked above it. Relocate those first.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Reads ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Physical layout of one zone: every stack, every level, what stands there. */
|
||||
async zoneLayout(zoneId: string, manager?: EntityManager): Promise<ZoneLayout> {
|
||||
const [zone] = await this.em(manager).query(
|
||||
`SELECT z.id, z.code, z.name, z.capacity_containers AS "capacityContainers"
|
||||
FROM freight.warehouse_zones z WHERE z.id = $1 AND z.deleted_at IS NULL`,
|
||||
[zoneId],
|
||||
);
|
||||
if (!zone) throw new NotFoundException(`Warehouse zone ${zoneId} not found`);
|
||||
|
||||
const rows: Array<{
|
||||
stackId: string;
|
||||
code: string;
|
||||
name: string | null;
|
||||
maxStackHeight: number;
|
||||
stackStatus: string;
|
||||
stackIsActive: boolean;
|
||||
slotId: string | null;
|
||||
level: number | null;
|
||||
slotStatus: string | null;
|
||||
slotIsActive: boolean | null;
|
||||
inventoryId: string | null;
|
||||
containerNumber: string | null;
|
||||
}> = await this.em(manager).query(
|
||||
`SELECT s.id AS "stackId", s.code AS "code", s.name AS "name",
|
||||
s.max_stack_height AS "maxStackHeight", s.status AS "stackStatus",
|
||||
s.is_active AS "stackIsActive",
|
||||
sl.id AS "slotId", sl.level AS "level", sl.status AS "slotStatus",
|
||||
sl.is_active AS "slotIsActive",
|
||||
i.id AS "inventoryId",
|
||||
CASE WHEN i.id IS NULL THEN NULL ELSE ${CONTAINER_NUMBER_EXPR} END AS "containerNumber"
|
||||
FROM freight.warehouse_zone_stacks s
|
||||
LEFT JOIN freight.warehouse_zone_slots sl ON sl.stack_id = s.id AND sl.deleted_at IS NULL
|
||||
LEFT JOIN freight.warehouse_inventory i
|
||||
ON i.slot_id = sl.id AND i.deleted_at IS NULL AND i.status = ANY($2)
|
||||
WHERE s.zone_id = $1 AND s.deleted_at IS NULL
|
||||
ORDER BY s.code, sl.level DESC`,
|
||||
[zoneId, OCCUPYING],
|
||||
);
|
||||
|
||||
const stacks = new Map<string, ZoneLayoutStack>();
|
||||
for (const row of rows) {
|
||||
let stack = stacks.get(row.stackId);
|
||||
if (!stack) {
|
||||
stack = {
|
||||
stackId: row.stackId,
|
||||
code: row.code,
|
||||
name: row.name,
|
||||
maxStackHeight: Number(row.maxStackHeight),
|
||||
status: row.stackStatus,
|
||||
isActive: row.stackIsActive,
|
||||
slots: [],
|
||||
};
|
||||
stacks.set(row.stackId, stack);
|
||||
}
|
||||
if (row.slotId) {
|
||||
stack.slots.push({
|
||||
slotId: row.slotId,
|
||||
level: Number(row.level),
|
||||
effectiveStatus: this.effectiveStatus(row.slotStatus, row.slotIsActive, row.inventoryId),
|
||||
inventoryId: row.inventoryId,
|
||||
containerNumber: row.containerNumber,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
zoneId: zone.id,
|
||||
zoneCode: zone.code,
|
||||
zoneName: zone.name,
|
||||
stacks: [...stacks.values()],
|
||||
summary: await this.slotSummary({ zoneId }, manager),
|
||||
};
|
||||
}
|
||||
|
||||
private effectiveStatus(
|
||||
status: string | null,
|
||||
isActive: boolean | null,
|
||||
inventoryId: string | null,
|
||||
): SlotEffectiveStatus {
|
||||
if (inventoryId) return 'OCCUPIED';
|
||||
if (isActive === false) return 'INACTIVE';
|
||||
return (status as SlotEffectiveStatus) ?? 'AVAILABLE';
|
||||
}
|
||||
|
||||
/**
|
||||
* The three numbers that are routinely confused: what was configured, what is
|
||||
* physically built, and what is actually full. Configured capacity is never
|
||||
* overwritten from the slot count — a mismatch is reported, not corrected.
|
||||
*/
|
||||
async slotSummary(
|
||||
scope: { zoneId?: string; yardId?: string },
|
||||
manager?: EntityManager,
|
||||
): Promise<SlotSummary> {
|
||||
if (!scope.zoneId && !scope.yardId) {
|
||||
throw new BadRequestException('A zone or yard is required');
|
||||
}
|
||||
|
||||
const [row] = await this.em(manager).query(
|
||||
`SELECT
|
||||
(SELECT SUM(z.capacity_containers)
|
||||
FROM freight.warehouse_zones z
|
||||
WHERE z.deleted_at IS NULL
|
||||
AND ($1::uuid IS NULL OR z.id = $1::uuid)
|
||||
AND ($2::uuid IS NULL OR z.yard_id = $2::uuid)) AS "configuredCapacity",
|
||||
COUNT(sl.id) AS "physicalSlotCount",
|
||||
COUNT(i.id) AS "occupiedSlotCount",
|
||||
COUNT(*) FILTER (WHERE i.id IS NULL AND sl.is_active AND sl.status = 'RESERVED') AS "reservedSlotCount",
|
||||
COUNT(*) FILTER (WHERE i.id IS NULL AND sl.is_active AND sl.status = 'BLOCKED') AS "blockedSlotCount",
|
||||
COUNT(*) FILTER (WHERE sl.id IS NOT NULL AND (NOT sl.is_active OR sl.status = 'INACTIVE'))
|
||||
AS "inactiveSlotCount",
|
||||
COUNT(*) FILTER (WHERE i.id IS NULL AND sl.is_active AND sl.status = 'AVAILABLE'
|
||||
AND s.status = 'ACTIVE' AND s.is_active) AS "availableSlotCount"
|
||||
FROM freight.warehouse_zones z
|
||||
JOIN freight.warehouse_zone_stacks s ON s.zone_id = z.id AND s.deleted_at IS NULL
|
||||
LEFT JOIN freight.warehouse_zone_slots sl ON sl.stack_id = s.id AND sl.deleted_at IS NULL
|
||||
LEFT JOIN freight.warehouse_inventory i
|
||||
ON i.slot_id = sl.id AND i.deleted_at IS NULL AND i.status = ANY($3)
|
||||
WHERE z.deleted_at IS NULL
|
||||
AND ($1::uuid IS NULL OR z.id = $1::uuid)
|
||||
AND ($2::uuid IS NULL OR z.yard_id = $2::uuid)`,
|
||||
[scope.zoneId ?? null, scope.yardId ?? null, OCCUPYING],
|
||||
);
|
||||
|
||||
const configuredCapacity = row?.configuredCapacity == null ? null : Number(row.configuredCapacity);
|
||||
const physicalSlotCount = Number(row?.physicalSlotCount ?? 0);
|
||||
|
||||
return {
|
||||
configuredCapacity,
|
||||
physicalSlotCount,
|
||||
occupiedSlotCount: Number(row?.occupiedSlotCount ?? 0),
|
||||
reservedSlotCount: Number(row?.reservedSlotCount ?? 0),
|
||||
blockedSlotCount: Number(row?.blockedSlotCount ?? 0),
|
||||
inactiveSlotCount: Number(row?.inactiveSlotCount ?? 0),
|
||||
availableSlotCount: Number(row?.availableSlotCount ?? 0),
|
||||
inconsistent: configuredCapacity != null && physicalSlotCount > configuredCapacity,
|
||||
};
|
||||
}
|
||||
|
||||
/** Free a slot explicitly. Exit paths don't need this — status alone frees it. */
|
||||
async releaseSlot(inventoryId: string, manager?: EntityManager): Promise<void> {
|
||||
await this.em(manager).query(
|
||||
`UPDATE freight.warehouse_inventory
|
||||
SET stack_id = NULL, slot_id = NULL, updated_at = now()
|
||||
WHERE id = $1 AND deleted_at IS NULL`,
|
||||
[inventoryId],
|
||||
);
|
||||
}
|
||||
|
||||
/** Write a validated placement onto an inventory row inside the caller's transaction. */
|
||||
async applyPlacement(
|
||||
manager: EntityManager,
|
||||
inventoryId: string,
|
||||
placement: { stackId: string; slotId: string } | null,
|
||||
): Promise<void> {
|
||||
await manager.getRepository(WarehouseInventory).update(inventoryId, {
|
||||
stackId: placement?.stackId ?? null,
|
||||
slotId: placement?.slotId ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post } from '@nestjs/common';
|
||||
import { Body, Controller, Delete, Get, Param, ParseUUIDPipe, Patch, Post } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { 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' })
|
||||
|
||||
@@ -107,6 +107,24 @@ export class WarehouseYardsService {
|
||||
return this.findById(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft-delete a yard. Zones (and the inventory sitting in them) are left
|
||||
* alone — a yard still holding zones is refused rather than orphaning stock.
|
||||
*/
|
||||
async remove(id: string): Promise<{ id: string; deleted: true }> {
|
||||
const existing = await this.findById(id);
|
||||
|
||||
if (existing.zones?.length) {
|
||||
throw new ConflictException(
|
||||
`Yard ${existing.code} still has ${existing.zones.length} zone(s). Delete them first.`,
|
||||
);
|
||||
}
|
||||
|
||||
await this.yardsRepository.softDelete(id);
|
||||
|
||||
return { id, deleted: true };
|
||||
}
|
||||
|
||||
private async assertCodeUnique(warehouseId: string, code: string, ignoreId?: string): Promise<void> {
|
||||
const [existing] = await this.yardsRepository.findAll({ where: { warehouseId, code } });
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { BaseRepository } from '@edr/api-common';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { WarehouseZoneSlot } from './entities/warehouse-zone-slot.entity';
|
||||
|
||||
@Injectable()
|
||||
export class WarehouseZoneSlotsRepository extends BaseRepository<WarehouseZoneSlot> {
|
||||
constructor(@InjectRepository(WarehouseZoneSlot) repository: Repository<WarehouseZoneSlot>) {
|
||||
super(repository);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { BookingStaff } from '../../common/booking-guards';
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
import {
|
||||
CreateWarehouseZoneStackDto,
|
||||
UpdateWarehouseZoneSlotDto,
|
||||
UpdateWarehouseZoneStackDto,
|
||||
} from './dto/warehouse-zone-stack.dto';
|
||||
import { WarehouseZoneStacksService } from './warehouse-zone-stacks.service';
|
||||
|
||||
/**
|
||||
* Stacks and slots are zone configuration, so they ride the warehouse-zone
|
||||
* permissions rather than introducing new keys — a new key needs a matching
|
||||
* `iam.permissions` row in every environment or boot fails.
|
||||
*/
|
||||
@ApiTags('warehouse-zone-stacks')
|
||||
@ApiBearerAuth()
|
||||
@Controller('warehouse-zone-stacks')
|
||||
// Class gate lists every key its routes use: Nest runs class AND method guards.
|
||||
@BookingStaff([
|
||||
FREIGHT_PERMS.warehouseZones.view,
|
||||
FREIGHT_PERMS.warehouseInventory.view,
|
||||
FREIGHT_PERMS.warehouseZones.create,
|
||||
FREIGHT_PERMS.warehouseZones.update,
|
||||
FREIGHT_PERMS.warehouseZones.delete,
|
||||
])
|
||||
export class WarehouseZoneStacksController {
|
||||
constructor(private readonly stacksService: WarehouseZoneStacksService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List the ground stacks configured in a zone' })
|
||||
findByZone(@Query('zoneId', ParseUUIDPipe) zoneId: string) {
|
||||
return this.stacksService.findByZone(zoneId);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseZones.create)
|
||||
@ApiOperation({
|
||||
summary: 'Create a ground stack',
|
||||
description: 'One slot per level is generated automatically, from 1 to maxStackHeight (default 3).',
|
||||
})
|
||||
create(@Body() dto: CreateWarehouseZoneStackDto, @Query('zoneId') zoneIdQuery?: string) {
|
||||
const zoneId = dto.zoneId ?? zoneIdQuery;
|
||||
if (!zoneId) {
|
||||
throw new BadRequestException('zoneId is required');
|
||||
}
|
||||
return this.stacksService.create(zoneId, dto);
|
||||
}
|
||||
|
||||
// Declared before ':id' so 'slots' is never swallowed as a stack id.
|
||||
@Patch('slots/:slotId')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseZones.update)
|
||||
@ApiOperation({
|
||||
summary: 'Block, reserve, or reactivate one slot',
|
||||
description: 'Occupancy is derived from inventory and cannot be set here.',
|
||||
})
|
||||
updateSlot(@Param('slotId', ParseUUIDPipe) slotId: string, @Body() dto: UpdateWarehouseZoneSlotDto) {
|
||||
return this.stacksService.updateSlot(slotId, dto);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get one stack with its slots' })
|
||||
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.stacksService.findById(id);
|
||||
}
|
||||
|
||||
@Get(':id/occupancy')
|
||||
@ApiOperation({ summary: 'Level-by-level occupancy of one stack' })
|
||||
occupancy(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.stacksService.slotOccupancy(id);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseZones.update)
|
||||
@ApiOperation({
|
||||
summary: 'Update a stack',
|
||||
description: 'Raising maxStackHeight adds slots; lowering it trims the empty top levels.',
|
||||
})
|
||||
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateWarehouseZoneStackDto) {
|
||||
return this.stacksService.update(id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseZones.delete)
|
||||
@ApiOperation({ summary: 'Delete a stack', description: 'Refused while containers still stand in it.' })
|
||||
remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.stacksService.remove(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { BaseRepository } from '@edr/api-common';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { WarehouseZoneStack } from './entities/warehouse-zone-stack.entity';
|
||||
|
||||
@Injectable()
|
||||
export class WarehouseZoneStacksRepository extends BaseRepository<WarehouseZoneStack> {
|
||||
constructor(@InjectRepository(WarehouseZoneStack) repository: Repository<WarehouseZoneStack>) {
|
||||
super(repository);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import { DataSource, EntityManager, In, IsNull } from 'typeorm';
|
||||
|
||||
import {
|
||||
CreateWarehouseZoneStackDto,
|
||||
UpdateWarehouseZoneSlotDto,
|
||||
UpdateWarehouseZoneStackDto,
|
||||
} from './dto/warehouse-zone-stack.dto';
|
||||
import {
|
||||
DEFAULT_MAX_STACK_HEIGHT,
|
||||
WarehouseZoneStack,
|
||||
} from './entities/warehouse-zone-stack.entity';
|
||||
import { WarehouseZoneSlot } from './entities/warehouse-zone-slot.entity';
|
||||
import { WarehousePlacementService } from './warehouse-placement.service';
|
||||
import { WarehouseZoneSlotsRepository } from './warehouse-zone-slots.repository';
|
||||
import { WarehouseZoneStacksRepository } from './warehouse-zone-stacks.repository';
|
||||
import { WarehouseZonesService } from './warehouse-zones.service';
|
||||
|
||||
/**
|
||||
* Ground stacks and their vertical slots — the physical layout of a zone.
|
||||
*
|
||||
* Slots are never created by hand: a stack of height 3 is three slots, so they
|
||||
* are generated with the stack and kept in step with its height. That is the
|
||||
* only way the placement engine can trust `level` to mean what it says.
|
||||
*/
|
||||
@Injectable()
|
||||
export class WarehouseZoneStacksService {
|
||||
constructor(
|
||||
private readonly stacksRepository: WarehouseZoneStacksRepository,
|
||||
private readonly slotsRepository: WarehouseZoneSlotsRepository,
|
||||
private readonly zonesService: WarehouseZonesService,
|
||||
private readonly placement: WarehousePlacementService,
|
||||
@InjectDataSource() private readonly dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
findByZone(zoneId: string): Promise<WarehouseZoneStack[]> {
|
||||
return this.stacksRepository.findAll({
|
||||
where: { zoneId },
|
||||
relations: { slots: true },
|
||||
order: { code: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<WarehouseZoneStack> {
|
||||
const stack = await this.stacksRepository.findById(id, { relations: { slots: true, zone: true } });
|
||||
if (!stack) throw new NotFoundException(`Warehouse zone stack ${id} not found`);
|
||||
stack.slots?.sort((a, b) => a.level - b.level);
|
||||
return stack;
|
||||
}
|
||||
|
||||
/** Create the stack and its slots together — a stack with no slots holds nothing. */
|
||||
async create(zoneId: string, dto: CreateWarehouseZoneStackDto): Promise<WarehouseZoneStack> {
|
||||
await this.zonesService.findById(zoneId);
|
||||
const code = dto.code.trim();
|
||||
await this.assertCodeUnique(zoneId, code);
|
||||
|
||||
const maxStackHeight = dto.maxStackHeight ?? DEFAULT_MAX_STACK_HEIGHT;
|
||||
|
||||
const id = await this.dataSource.transaction(async (manager) => {
|
||||
const stack = await manager.getRepository(WarehouseZoneStack).save(
|
||||
manager.getRepository(WarehouseZoneStack).create({
|
||||
zoneId,
|
||||
code,
|
||||
name: dto.name?.trim() ?? null,
|
||||
row: dto.row?.trim() ?? null,
|
||||
bay: dto.bay?.trim() ?? null,
|
||||
position: dto.position?.trim() ?? null,
|
||||
maxStackHeight,
|
||||
status: 'ACTIVE',
|
||||
isActive: true,
|
||||
}),
|
||||
);
|
||||
|
||||
await this.generateSlots(manager, stack.id, 1, maxStackHeight);
|
||||
return stack.id;
|
||||
});
|
||||
|
||||
return this.findById(id);
|
||||
}
|
||||
|
||||
async update(id: string, dto: UpdateWarehouseZoneStackDto): Promise<WarehouseZoneStack> {
|
||||
const existing = await this.findById(id);
|
||||
const code = dto.code?.trim() ?? existing.code;
|
||||
|
||||
if (code !== existing.code) {
|
||||
await this.assertCodeUnique(existing.zoneId, code, id);
|
||||
}
|
||||
|
||||
const newHeight = dto.maxStackHeight ?? existing.maxStackHeight;
|
||||
const status = dto.status ?? existing.status;
|
||||
|
||||
if (status === 'INACTIVE' && existing.status !== 'INACTIVE') {
|
||||
await this.assertStackEmpty(id, 'deactivated');
|
||||
}
|
||||
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
if (newHeight > existing.maxStackHeight) {
|
||||
await this.generateSlots(manager, id, existing.maxStackHeight + 1, newHeight);
|
||||
} else if (newHeight < existing.maxStackHeight) {
|
||||
await this.removeSlotsAbove(manager, id, newHeight, existing.code);
|
||||
}
|
||||
|
||||
await manager.getRepository(WarehouseZoneStack).update(id, {
|
||||
code,
|
||||
name: dto.name?.trim() ?? existing.name,
|
||||
row: dto.row?.trim() ?? existing.row,
|
||||
bay: dto.bay?.trim() ?? existing.bay,
|
||||
position: dto.position?.trim() ?? existing.position,
|
||||
maxStackHeight: newHeight,
|
||||
status,
|
||||
isActive: status === 'ACTIVE',
|
||||
});
|
||||
});
|
||||
|
||||
return this.findById(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft-delete a stack. Refused while anything stands in it — the boxes would
|
||||
* be left pointing at a position every layout query drops.
|
||||
*/
|
||||
async remove(id: string): Promise<{ id: string; deleted: true }> {
|
||||
const existing = await this.findById(id);
|
||||
await this.assertStackEmpty(id, 'deleted');
|
||||
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
await manager.getRepository(WarehouseZoneSlot).softDelete({ stackId: id });
|
||||
await manager.getRepository(WarehouseZoneStack).softDelete(id);
|
||||
});
|
||||
|
||||
return { id: existing.id, deleted: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Set operator intent on one slot. OCCUPIED is not settable — it is derived
|
||||
* from the inventory sitting there — and a slot holding a box cannot be
|
||||
* blocked or switched off underneath it.
|
||||
*/
|
||||
async updateSlot(slotId: string, dto: UpdateWarehouseZoneSlotDto): Promise<WarehouseZoneSlot> {
|
||||
const slot = await this.slotsRepository.findById(slotId);
|
||||
if (!slot) throw new NotFoundException(`Warehouse zone slot ${slotId} not found`);
|
||||
|
||||
const status = dto.status ?? slot.status;
|
||||
const isActive = dto.isActive ?? (dto.status ? dto.status !== 'INACTIVE' : slot.isActive);
|
||||
const closingOff = status === 'BLOCKED' || status === 'INACTIVE' || isActive === false;
|
||||
|
||||
if (closingOff) {
|
||||
const [held] = await this.dataSource.query(
|
||||
`SELECT i.id FROM freight.warehouse_inventory i
|
||||
WHERE i.slot_id = $1 AND i.deleted_at IS NULL
|
||||
AND i.status IN ('UNLOADED','RECEIVED','STORED','RESERVED','READY_FOR_LOADING','READY_FOR_PICKUP')
|
||||
LIMIT 1`,
|
||||
[slotId],
|
||||
);
|
||||
if (held) {
|
||||
throw new ConflictException('Slot still holds a container. Move it out first.');
|
||||
}
|
||||
}
|
||||
|
||||
const updated = await this.slotsRepository.update(slotId, { status, isActive });
|
||||
if (!updated) throw new NotFoundException(`Warehouse zone slot ${slotId} not found`);
|
||||
return updated;
|
||||
}
|
||||
|
||||
/** Occupancy of one stack, level by level. */
|
||||
async slotOccupancy(stackId: string): Promise<
|
||||
Array<{ slotId: string; level: number; effectiveStatus: string; inventoryId: string | null }>
|
||||
> {
|
||||
const stack = await this.findById(stackId);
|
||||
const layout = await this.placement.zoneLayout(stack.zoneId);
|
||||
const found = layout.stacks.find((s) => s.stackId === stackId);
|
||||
return (found?.slots ?? []).map((s) => ({
|
||||
slotId: s.slotId,
|
||||
level: s.level,
|
||||
effectiveStatus: s.effectiveStatus,
|
||||
inventoryId: s.inventoryId,
|
||||
}));
|
||||
}
|
||||
|
||||
// ── internals ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** Idempotent: a level that already exists (e.g. after a height cut and re-raise) is skipped. */
|
||||
private async generateSlots(
|
||||
manager: EntityManager,
|
||||
stackId: string,
|
||||
fromLevel: number,
|
||||
toLevel: number,
|
||||
): Promise<void> {
|
||||
const repository = manager.getRepository(WarehouseZoneSlot);
|
||||
const existing = await repository.find({ where: { stackId }, withDeleted: true });
|
||||
const byLevel = new Map(existing.map((slot) => [slot.level, slot]));
|
||||
|
||||
for (let level = fromLevel; level <= toLevel; level += 1) {
|
||||
const found = byLevel.get(level);
|
||||
if (found?.deletedAt) {
|
||||
// Bring a previously trimmed level back rather than colliding with the
|
||||
// (stack_id, level) unique index.
|
||||
await repository.restore(found.id);
|
||||
await repository.update(found.id, { status: 'AVAILABLE', isActive: true });
|
||||
} else if (!found) {
|
||||
await repository.save(repository.create({ stackId, level, status: 'AVAILABLE', isActive: true }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async removeSlotsAbove(
|
||||
manager: EntityManager,
|
||||
stackId: string,
|
||||
newHeight: number,
|
||||
stackCode: string,
|
||||
): Promise<void> {
|
||||
const occupied = await this.placement.occupiedLevels(stackId, null, manager);
|
||||
const stillUsed = occupied.filter((level) => level > newHeight);
|
||||
if (stillUsed.length > 0) {
|
||||
throw new BadRequestException(
|
||||
`Stack ${stackCode}: level(s) ${stillUsed.join(', ')} still hold containers — cannot lower the height to ${newHeight}`,
|
||||
);
|
||||
}
|
||||
|
||||
const doomed = await manager.getRepository(WarehouseZoneSlot).find({
|
||||
where: { stackId, deletedAt: IsNull() },
|
||||
});
|
||||
const ids = doomed.filter((slot) => slot.level > newHeight).map((slot) => slot.id);
|
||||
if (ids.length > 0) {
|
||||
await manager.getRepository(WarehouseZoneSlot).softDelete({ id: In(ids) });
|
||||
}
|
||||
}
|
||||
|
||||
private async assertStackEmpty(stackId: string, action: string): Promise<void> {
|
||||
const occupied = await this.placement.occupiedLevels(stackId);
|
||||
if (occupied.length > 0) {
|
||||
throw new ConflictException(
|
||||
`Stack still holds ${occupied.length} container(s) at level(s) ${occupied.join(', ')}. Move them out before it can be ${action}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async assertCodeUnique(zoneId: string, code: string, ignoreId?: string): Promise<void> {
|
||||
const [existing] = await this.stacksRepository.findAll({ where: { zoneId, code } });
|
||||
if (existing && existing.id !== ignoreId) {
|
||||
throw new ConflictException(`Stack code ${code} already exists in this zone`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch } from '@nestjs/common';
|
||||
import { Body, Controller, Delete, Get, Param, ParseUUIDPipe, Patch } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<WarehouseZone[]> {
|
||||
@@ -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<ZoneContentItem[]> {
|
||||
await this.findById(zoneId);
|
||||
|
||||
return this.dataSource.query(
|
||||
`SELECT i.id AS "inventoryId",
|
||||
COALESCE(c.container_number, bcu.container_number) AS "containerNumber",
|
||||
i.unloaded_at AS "unloadedAt",
|
||||
COALESCE(ct_direct.label, ct_booked.label, bc.container_size) AS "containerType",
|
||||
b.trade_direction AS "direction",
|
||||
CASE
|
||||
WHEN c.status IS NOT NULL THEN c.status
|
||||
WHEN bcu.is_return THEN 'EMPTY'
|
||||
WHEN bcu.container_number IS NOT NULL THEN 'FULL'
|
||||
ELSE NULL
|
||||
END AS "loadState",
|
||||
i.status AS "status",
|
||||
b.reference AS "bookingReference"
|
||||
FROM freight.warehouse_inventory i
|
||||
LEFT JOIN freight.containers c ON c.id = i.container_id AND c.deleted_at IS NULL
|
||||
LEFT JOIN freight.container_types ct_direct ON ct_direct.id = c.container_type_id
|
||||
LEFT JOIN freight.bookings b ON b.id = i.booking_id AND b.deleted_at IS NULL
|
||||
LEFT JOIN freight.booking_container bc ON bc.booking_id = b.id AND bc.deleted_at IS NULL
|
||||
LEFT JOIN freight.container_types ct_booked ON ct_booked.id = bc.container_type_id
|
||||
LEFT JOIN freight.booking_container_units bcu
|
||||
ON bcu.booking_container_id = bc.id AND bcu.deleted_at IS NULL
|
||||
WHERE i.zone_id = $1 AND i.deleted_at IS NULL
|
||||
ORDER BY i.unloaded_at DESC NULLS LAST,
|
||||
COALESCE(c.container_number, bcu.container_number)`,
|
||||
[zoneId],
|
||||
);
|
||||
}
|
||||
|
||||
/** The zone's physical layout: every ground stack, every level, what stands there. */
|
||||
async layout(zoneId: string) {
|
||||
await this.findById(zoneId);
|
||||
return this.placement.zoneLayout(zoneId);
|
||||
}
|
||||
|
||||
/** Configured capacity vs slots actually built vs slots actually full. */
|
||||
async slotSummary(zoneId: string) {
|
||||
await this.findById(zoneId);
|
||||
return this.placement.slotSummary({ zoneId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft-delete a zone. Inventory points at a zone, so a zone still holding
|
||||
* stock is refused — soft-deleting it would leave those rows pointing at a
|
||||
* location every zone-joining query drops. Configured stacks block it for the
|
||||
* same reason: they would survive their parent and never be reachable again.
|
||||
*/
|
||||
async remove(id: string): Promise<{ id: string; deleted: true }> {
|
||||
const existing = await this.findById(id);
|
||||
const [, held] = await this.inventoryRepository.findAndCount({ where: { zoneId: id } });
|
||||
|
||||
if (held > 0) {
|
||||
throw new ConflictException(
|
||||
`Zone ${existing.code} still holds ${held} inventory item(s). Move them out first.`,
|
||||
);
|
||||
}
|
||||
|
||||
const [stacks] = await this.dataSource.query(
|
||||
`SELECT count(*)::int AS count FROM freight.warehouse_zone_stacks
|
||||
WHERE zone_id = $1 AND deleted_at IS NULL`,
|
||||
[id],
|
||||
);
|
||||
if (Number(stacks?.count ?? 0) > 0) {
|
||||
throw new ConflictException(
|
||||
`Zone ${existing.code} still has ${stacks.count} configured stack(s). Delete them first.`,
|
||||
);
|
||||
}
|
||||
|
||||
await this.zonesRepository.softDelete(id);
|
||||
|
||||
return { id, deleted: true };
|
||||
}
|
||||
|
||||
private async assertCodeUnique(yardId: string, code: string, ignoreId?: string): Promise<void> {
|
||||
const [existing] = await this.zonesRepository.findAll({ where: { yardId, code } });
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common';
|
||||
import { Body, Controller, Delete, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { 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' })
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
35
apps/edr-freight-api/src/scripts/seed-warehouse-layout.ts
Normal file
35
apps/edr-freight-api/src/scripts/seed-warehouse-layout.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { AppDataSource } from '../data-source';
|
||||
import { WarehouseLayoutSeeder } from '../seed/warehouse-layout.seeder';
|
||||
|
||||
/**
|
||||
* Lays out the physical warehouse structure described by
|
||||
* `src/seed/warehouse-layout.json` — edit that file, not this script.
|
||||
*
|
||||
* Idempotent: existing warehouses, yards, zones, stacks and slots are left
|
||||
* untouched, so a re-run only fills in what is missing.
|
||||
*/
|
||||
async function seedWarehouseLayout() {
|
||||
await AppDataSource.initialize();
|
||||
|
||||
try {
|
||||
const summary = await new WarehouseLayoutSeeder(AppDataSource).run();
|
||||
console.table([summary]);
|
||||
|
||||
const counts = await AppDataSource.query(`
|
||||
SELECT
|
||||
(SELECT COUNT(*)::int FROM freight.warehouses WHERE deleted_at IS NULL) AS warehouses,
|
||||
(SELECT COUNT(*)::int FROM freight.warehouse_yards WHERE deleted_at IS NULL) AS yards,
|
||||
(SELECT COUNT(*)::int FROM freight.warehouse_zones WHERE deleted_at IS NULL) AS zones,
|
||||
(SELECT COUNT(*)::int FROM freight.warehouse_zone_stacks WHERE deleted_at IS NULL) AS stacks,
|
||||
(SELECT COUNT(*)::int FROM freight.warehouse_zone_slots WHERE deleted_at IS NULL) AS slots
|
||||
`);
|
||||
console.table(counts);
|
||||
} finally {
|
||||
await AppDataSource.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
seedWarehouseLayout().catch((error) => {
|
||||
console.error('Failed to seed the warehouse layout:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1272,6 +1272,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",
|
||||
@@ -2307,6 +2312,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",
|
||||
|
||||
28
apps/edr-freight-api/src/seed/warehouse-layout.json
Normal file
28
apps/edr-freight-api/src/seed/warehouse-layout.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"facility": {
|
||||
"code": "GELAN",
|
||||
"name": "Gelan Dry Port",
|
||||
"facilityType": "DRY_PORT"
|
||||
},
|
||||
"levels": ["L1", "L2", "L3", "L4"],
|
||||
"kinds": [
|
||||
{ "suffix": "OPEN", "letter": "O", "name": "Open Warehouse", "type": "OPEN_WAREHOUSE" },
|
||||
{ "suffix": "CLOSED", "letter": "C", "name": "Closed Warehouse", "type": "CLOSED_WAREHOUSE" }
|
||||
],
|
||||
"yards": [
|
||||
{ "label": "A", "type": "CONTAINER_YARD", "capacityContainers": 150 },
|
||||
{ "label": "B", "type": "CONTAINER_YARD", "capacityContainers": 150 },
|
||||
{ "label": "C", "type": "GENERAL_CARGO_YARD", "capacityContainers": null },
|
||||
{ "label": "D", "type": "BULK_YARD", "capacityContainers": null },
|
||||
{ "label": "E", "type": "HAZARDOUS_YARD", "capacityContainers": null },
|
||||
{ "label": "F", "type": "COLD_STORAGE_YARD", "capacityContainers": null }
|
||||
],
|
||||
"zones": [
|
||||
{ "label": "A", "capacityContainers": 60 },
|
||||
{ "label": "B", "capacityContainers": 45 },
|
||||
{ "label": "C", "capacityContainers": 45 }
|
||||
],
|
||||
"stack": {
|
||||
"maxStackHeight": 3
|
||||
}
|
||||
}
|
||||
214
apps/edr-freight-api/src/seed/warehouse-layout.seeder.ts
Normal file
214
apps/edr-freight-api/src/seed/warehouse-layout.seeder.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Builds the physical warehouse layout from `warehouse-layout.json`:
|
||||
* facility → warehouses (L1-OPEN …) → yards (A–F) → zones (A–C) → ground
|
||||
* stacks → slots.
|
||||
*
|
||||
* The shape is configuration, never enums: yard letters, zone letters and
|
||||
* stack heights all come from the JSON, because a physical layout changes and
|
||||
* a deployed enum does not.
|
||||
*
|
||||
* Idempotent on every code. A row that already exists is left exactly as it
|
||||
* is — capacities tuned by hand on a live site must survive a re-run.
|
||||
*/
|
||||
export interface WarehouseLayoutConfig {
|
||||
facility: { code: string; name: string; facilityType: string };
|
||||
levels: string[];
|
||||
kinds: Array<{ suffix: string; letter: string; name: string; type: string }>;
|
||||
yards: Array<{ label: string; type: string; capacityContainers: number | null }>;
|
||||
zones: Array<{ label: string; capacityContainers: number }>;
|
||||
stack: { maxStackHeight: number };
|
||||
}
|
||||
|
||||
export interface LayoutSeedSummary {
|
||||
facilityCode: string;
|
||||
warehousesCreated: number;
|
||||
yardsCreated: number;
|
||||
zonesCreated: number;
|
||||
stacksCreated: number;
|
||||
slotsCreated: number;
|
||||
}
|
||||
|
||||
export class WarehouseLayoutSeeder {
|
||||
constructor(
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly config: WarehouseLayoutConfig = WarehouseLayoutSeeder.loadConfig(),
|
||||
) {}
|
||||
|
||||
static loadConfig(path = join(__dirname, 'warehouse-layout.json')): WarehouseLayoutConfig {
|
||||
return JSON.parse(readFileSync(path, 'utf8')) as WarehouseLayoutConfig;
|
||||
}
|
||||
|
||||
async run(): Promise<LayoutSeedSummary> {
|
||||
const summary: LayoutSeedSummary = {
|
||||
facilityCode: this.config.facility.code,
|
||||
warehousesCreated: 0,
|
||||
yardsCreated: 0,
|
||||
zonesCreated: 0,
|
||||
stacksCreated: 0,
|
||||
slotsCreated: 0,
|
||||
};
|
||||
|
||||
const facilityId = await this.upsertFacility();
|
||||
|
||||
for (const level of this.config.levels) {
|
||||
for (const kind of this.config.kinds) {
|
||||
const warehouseCode = `${level}-${kind.suffix}`;
|
||||
const warehouse = await this.upsertWarehouse(facilityId, warehouseCode, `${level} ${kind.name}`, kind.type);
|
||||
summary.warehousesCreated += warehouse.created ? 1 : 0;
|
||||
|
||||
for (const yardCfg of this.config.yards) {
|
||||
const yardCode = `${level}-${kind.letter}-${yardCfg.label}`;
|
||||
const yard = await this.upsertYard(warehouse.id, yardCode, `Yard ${yardCfg.label}`, yardCfg);
|
||||
summary.yardsCreated += yard.created ? 1 : 0;
|
||||
|
||||
// Zones, stacks and slots are only laid out for container yards —
|
||||
// bulk and general cargo do not stand in numbered positions.
|
||||
if (yardCfg.type !== 'CONTAINER_YARD') continue;
|
||||
|
||||
for (const zoneCfg of this.config.zones) {
|
||||
const zoneCode = `${yardCode}-Z${zoneCfg.label}`;
|
||||
const zone = await this.upsertZone(yard.id, zoneCode, `Zone ${zoneCfg.label}`, zoneCfg.capacityContainers);
|
||||
summary.zonesCreated += zone.created ? 1 : 0;
|
||||
|
||||
const height = this.config.stack.maxStackHeight;
|
||||
// Ground positions, not boxes: a 60-container zone stacked three
|
||||
// high needs 20 patches of concrete.
|
||||
const stackCount = Math.floor(zoneCfg.capacityContainers / height);
|
||||
|
||||
for (let n = 1; n <= stackCount; n += 1) {
|
||||
const stackCode = `Z${zoneCfg.label}-${String(n).padStart(3, '0')}`;
|
||||
const stack = await this.upsertStack(zone.id, stackCode, height);
|
||||
summary.stacksCreated += stack.created ? 1 : 0;
|
||||
summary.slotsCreated += await this.upsertSlots(stack.id, height);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return summary;
|
||||
}
|
||||
|
||||
private async upsertFacility(): Promise<string> {
|
||||
const { code, name, facilityType } = this.config.facility;
|
||||
const [existing] = await this.dataSource.query(
|
||||
`SELECT id FROM freight.facilities WHERE code = $1 AND deleted_at IS NULL`,
|
||||
[code],
|
||||
);
|
||||
if (existing) return existing.id;
|
||||
|
||||
const [created] = await this.dataSource.query(
|
||||
`INSERT INTO freight.facilities (code, name, facility_type, facility_status, is_active)
|
||||
VALUES ($1, $2, $3, 'ACTIVE', true)
|
||||
RETURNING id`,
|
||||
[code, name, facilityType],
|
||||
);
|
||||
return created.id;
|
||||
}
|
||||
|
||||
private async upsertWarehouse(
|
||||
facilityId: string,
|
||||
code: string,
|
||||
name: string,
|
||||
type: string,
|
||||
): Promise<{ id: string; created: boolean }> {
|
||||
const [existing] = await this.dataSource.query(
|
||||
`SELECT id FROM freight.warehouses WHERE code = $1 AND deleted_at IS NULL`,
|
||||
[code],
|
||||
);
|
||||
if (existing) return { id: existing.id, created: false };
|
||||
|
||||
const [created] = await this.dataSource.query(
|
||||
`INSERT INTO freight.warehouses (code, name, type, facility_id, status, is_active,
|
||||
current_weight, current_containers, current_volume)
|
||||
VALUES ($1, $2, $3, $4, 'ACTIVE', true, 0, 0, 0)
|
||||
RETURNING id`,
|
||||
[code, name, type, facilityId],
|
||||
);
|
||||
return { id: created.id, created: true };
|
||||
}
|
||||
|
||||
private async upsertYard(
|
||||
warehouseId: string,
|
||||
code: string,
|
||||
name: string,
|
||||
cfg: { type: string; capacityContainers: number | null },
|
||||
): Promise<{ id: string; created: boolean }> {
|
||||
const [existing] = await this.dataSource.query(
|
||||
`SELECT id FROM freight.warehouse_yards
|
||||
WHERE warehouse_id = $1 AND code = $2 AND deleted_at IS NULL`,
|
||||
[warehouseId, code],
|
||||
);
|
||||
if (existing) return { id: existing.id, created: false };
|
||||
|
||||
const [created] = await this.dataSource.query(
|
||||
`INSERT INTO freight.warehouse_yards (warehouse_id, code, name, type, capacity_containers,
|
||||
status, is_active, current_weight, current_containers, current_volume)
|
||||
VALUES ($1, $2, $3, $4, $5, 'ACTIVE', true, 0, 0, 0)
|
||||
RETURNING id`,
|
||||
[warehouseId, code, name, cfg.type, cfg.capacityContainers],
|
||||
);
|
||||
return { id: created.id, created: true };
|
||||
}
|
||||
|
||||
private async upsertZone(
|
||||
yardId: string,
|
||||
code: string,
|
||||
name: string,
|
||||
capacityContainers: number,
|
||||
): Promise<{ id: string; created: boolean }> {
|
||||
const [existing] = await this.dataSource.query(
|
||||
`SELECT id FROM freight.warehouse_zones WHERE yard_id = $1 AND code = $2 AND deleted_at IS NULL`,
|
||||
[yardId, code],
|
||||
);
|
||||
if (existing) return { id: existing.id, created: false };
|
||||
|
||||
const [created] = await this.dataSource.query(
|
||||
`INSERT INTO freight.warehouse_zones (yard_id, code, name, type, capacity_containers,
|
||||
status, is_active, current_weight, current_containers, current_volume)
|
||||
VALUES ($1, $2, $3, 'CONTAINER_ZONE', $4, 'ACTIVE', true, 0, 0, 0)
|
||||
RETURNING id`,
|
||||
[yardId, code, name, capacityContainers],
|
||||
);
|
||||
return { id: created.id, created: true };
|
||||
}
|
||||
|
||||
private async upsertStack(
|
||||
zoneId: string,
|
||||
code: string,
|
||||
maxStackHeight: number,
|
||||
): Promise<{ id: string; created: boolean }> {
|
||||
const [existing] = await this.dataSource.query(
|
||||
`SELECT id FROM freight.warehouse_zone_stacks WHERE zone_id = $1 AND code = $2 AND deleted_at IS NULL`,
|
||||
[zoneId, code],
|
||||
);
|
||||
if (existing) return { id: existing.id, created: false };
|
||||
|
||||
const [created] = await this.dataSource.query(
|
||||
`INSERT INTO freight.warehouse_zone_stacks (zone_id, code, max_stack_height, status, is_active)
|
||||
VALUES ($1, $2, $3, 'ACTIVE', true)
|
||||
RETURNING id`,
|
||||
[zoneId, code, maxStackHeight],
|
||||
);
|
||||
return { id: created.id, created: true };
|
||||
}
|
||||
|
||||
private async upsertSlots(stackId: string, height: number): Promise<number> {
|
||||
const result = await this.dataSource.query(
|
||||
`INSERT INTO freight.warehouse_zone_slots (stack_id, level, status, is_active)
|
||||
SELECT $1, lvl, 'AVAILABLE', true
|
||||
FROM generate_series(1, $2) AS lvl
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM freight.warehouse_zone_slots s
|
||||
WHERE s.stack_id = $1 AND s.level = lvl AND s.deleted_at IS NULL
|
||||
)
|
||||
RETURNING id`,
|
||||
[stackId, height],
|
||||
);
|
||||
return Array.isArray(result) ? result.length : 0;
|
||||
}
|
||||
}
|
||||
@@ -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 = () => {
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="register-full-containers"
|
||||
element={
|
||||
<RequirePermission
|
||||
permission={FREIGHT_PERMS.warehouseInventory.receive}
|
||||
>
|
||||
<RegisterFullContainersPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="loaded-inventory"
|
||||
element={
|
||||
|
||||
@@ -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 }) {
|
||||
</Paper>
|
||||
|
||||
<Row label="Payment status" value={booking.paymentStatus} />
|
||||
{invoiceNumber && <Row label="Invoice number" value={invoiceNumber} mono />}
|
||||
{booking.pnrCode && <Row label="PNR code" value={booking.pnrCode} mono />}
|
||||
|
||||
{lineItems.length > 0 && (
|
||||
|
||||
@@ -118,6 +118,7 @@ const blockNegative = (event: KeyboardEvent<HTMLInputElement>) => {
|
||||
|
||||
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 *
|
||||
</Text>
|
||||
<Text fz={12} fw={600} c="#10202F" style={{ flex: 1 }}>
|
||||
Seal number
|
||||
Seal number *
|
||||
</Text>
|
||||
<Text fz={12} fw={600} c="#10202F" style={{ flex: 1 }}>
|
||||
VGM (tons) *
|
||||
@@ -1901,8 +1905,13 @@ export default function GlCreateBookingForm() {
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<TextInput
|
||||
placeholder="Optional"
|
||||
placeholder="e.g. SL-0099231"
|
||||
value={unit.sealNumber}
|
||||
error={
|
||||
showErrors
|
||||
? unitErrors[lineIdx]?.[unitIdx]?.sealNumber
|
||||
: undefined
|
||||
}
|
||||
onChange={(e) =>
|
||||
patchUnit(lineIdx, unitIdx, {
|
||||
sealNumber: e.currentTarget.value,
|
||||
|
||||
@@ -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")),
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
Package,
|
||||
PackageCheck,
|
||||
PackageOpen,
|
||||
PackagePlus,
|
||||
Paperclip,
|
||||
Receipt,
|
||||
Stamp,
|
||||
@@ -366,6 +367,12 @@ export const buildSidebarSections = (
|
||||
icon: <Container />,
|
||||
permission: FREIGHT_PERMS.warehouseInventory.view,
|
||||
},
|
||||
{
|
||||
label: "Register Full Containers",
|
||||
href: "/dashboard/register-full-containers",
|
||||
icon: <PackagePlus />,
|
||||
permission: FREIGHT_PERMS.warehouseInventory.receive,
|
||||
},
|
||||
{
|
||||
label: "Terminal Inventory",
|
||||
href: "/dashboard/warehouse-inventory?direction=IMPORT",
|
||||
|
||||
@@ -0,0 +1,347 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
Alert,
|
||||
Autocomplete,
|
||||
Badge,
|
||||
Button,
|
||||
FileInput,
|
||||
Group,
|
||||
List,
|
||||
Modal,
|
||||
ScrollArea,
|
||||
Select,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import { Upload } from "lucide-react";
|
||||
|
||||
import { localNowForInput } from "@/lib/format";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { useWarehouseYards, useWarehouseZones } from "@/hooks/useWarehouses";
|
||||
import { importOperationsService } from "@/services/importOperations.service";
|
||||
import { warehouseService } from "@/services/warehouse.service";
|
||||
import type { CreateEmptyContainerReturnPayload } from "@/types/importOperations";
|
||||
|
||||
import { parseContainerReturnExcel, type ParsedReturnRow } from "./container-return-excel";
|
||||
import { useCompanyOptions } from "./useCompanyOptions";
|
||||
|
||||
interface BulkContainerReturnModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
onUploaded: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Backfill of empties physically in a yard but never entered in the system.
|
||||
* The sheet carries per-container detail; the fields above the file are the
|
||||
* defaults for every row whose cell is blank, so the common case is a sheet of
|
||||
* container numbers plus one warehouse picked here.
|
||||
*/
|
||||
export default function BulkContainerReturnModal({
|
||||
opened,
|
||||
onClose,
|
||||
onUploaded,
|
||||
}: BulkContainerReturnModalProps) {
|
||||
const { toast } = useToast();
|
||||
const companies = useCompanyOptions();
|
||||
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [rows, setRows] = useState<ParsedReturnRow[]>([]);
|
||||
const [parseErrors, setParseErrors] = useState<string[]>([]);
|
||||
const [parsing, setParsing] = useState(false);
|
||||
|
||||
const [company, setCompany] = useState("");
|
||||
const [returnedBy, setReturnedBy] = useState<"EDR" | "CUSTOMER" | null>(null);
|
||||
const [warehouseId, setWarehouseId] = useState<string | null>(null);
|
||||
const [yardId, setYardId] = useState<string | null>(null);
|
||||
const [zoneId, setZoneId] = useState<string | null>(null);
|
||||
const [returnDate, setReturnDate] = useState(localNowForInput());
|
||||
|
||||
const { data: warehousesResponse } = useQuery({
|
||||
queryKey: ["warehouses-list"],
|
||||
queryFn: () => warehouseService.list({}),
|
||||
});
|
||||
const warehouses = ((warehousesResponse as any)?.data ?? warehousesResponse ?? []) as any[];
|
||||
const { data: yards } = useWarehouseYards(warehouseId ?? undefined);
|
||||
const { data: zones } = useWarehouseZones(yardId ?? undefined);
|
||||
|
||||
useEffect(() => {
|
||||
setYardId(null);
|
||||
setZoneId(null);
|
||||
}, [warehouseId]);
|
||||
useEffect(() => setZoneId(null), [yardId]);
|
||||
|
||||
const warehouseOptions = Array.isArray(warehouses)
|
||||
? warehouses.map((wh) => ({ value: wh.id, label: wh.code ? `${wh.name} (${wh.code})` : wh.name }))
|
||||
: [];
|
||||
const yardOptions = (yards ?? [])
|
||||
.filter((y) => y.status === "ACTIVE")
|
||||
.map((y) => ({ value: y.id, label: `${y.name} (${y.code})` }));
|
||||
const zoneOptions = (zones ?? [])
|
||||
.filter((z) => z.status === "ACTIVE")
|
||||
.map((z) => ({ value: z.id, label: `${z.name} (${z.code})` }));
|
||||
|
||||
const defaults = useMemo(
|
||||
() => ({
|
||||
facility: warehouses.find((wh) => wh.id === warehouseId)?.name ?? "",
|
||||
yard: yards?.find((y) => y.id === yardId)?.name ?? "",
|
||||
zone: zones?.find((z) => z.id === zoneId)?.name ?? "",
|
||||
}),
|
||||
[warehouses, warehouseId, yards, yardId, zones, zoneId],
|
||||
);
|
||||
|
||||
const reset = () => {
|
||||
setFile(null);
|
||||
setRows([]);
|
||||
setParseErrors([]);
|
||||
};
|
||||
|
||||
const handleFile = async (next: File | null) => {
|
||||
setFile(next);
|
||||
setRows([]);
|
||||
setParseErrors([]);
|
||||
if (!next) return;
|
||||
setParsing(true);
|
||||
const result = await parseContainerReturnExcel(next);
|
||||
setParsing(false);
|
||||
setRows(result.rows);
|
||||
setParseErrors(result.errors);
|
||||
};
|
||||
|
||||
// Row cell wins; the field above the file fills the blanks.
|
||||
const toPayload = (row: ParsedReturnRow): CreateEmptyContainerReturnPayload => {
|
||||
const companyName = row.companyName || company;
|
||||
return {
|
||||
containerNumber: row.containerNumber,
|
||||
containerSize: row.containerSize ?? undefined,
|
||||
companyName: companyName || undefined,
|
||||
customerId: companyName ? companies.resolveId(companyName) : undefined,
|
||||
returnedBy: row.returnedBy ?? returnedBy ?? undefined,
|
||||
returnDate: row.returnDate ?? new Date(returnDate).toISOString(),
|
||||
facility: row.facility || defaults.facility || undefined,
|
||||
yard: row.yard || defaults.yard || undefined,
|
||||
zone: row.zone || defaults.zone || undefined,
|
||||
condition: row.condition || undefined,
|
||||
handoverNote: row.handoverNote || undefined,
|
||||
};
|
||||
};
|
||||
|
||||
const uploadMutation = useMutation({
|
||||
mutationFn: () => importOperationsService.bulkCreateEmptyReturns(rows.map(toPayload)),
|
||||
onSuccess: (created) => {
|
||||
toast({ title: `${created.length} container return${created.length === 1 ? "" : "s"} recorded` });
|
||||
reset();
|
||||
onUploaded();
|
||||
onClose();
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Bulk upload failed",
|
||||
description: error?.response?.data?.message || error?.message,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Every row needs a warehouse from somewhere — the API stores facility as
|
||||
// free text, so a blank one would silently produce unplaceable containers.
|
||||
const missingFacility = rows.filter((r) => !r.facility && !defaults.facility).length;
|
||||
const missingReturnedBy = rows.filter((r) => !r.returnedBy && !returnedBy).length;
|
||||
const blockers = [
|
||||
missingFacility > 0 ? `${missingFacility} row(s) have no facility — pick a default warehouse.` : null,
|
||||
missingReturnedBy > 0 ? `${missingReturnedBy} row(s) have no "Returned By" — pick a default.` : null,
|
||||
].filter(Boolean) as string[];
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={() => {
|
||||
reset();
|
||||
onClose();
|
||||
}}
|
||||
title="Bulk Upload Container Returns"
|
||||
size="xl"
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
For empties already sitting in the yard but not yet on the system. Values below fill any
|
||||
blank cell in the sheet.
|
||||
</Text>
|
||||
|
||||
<Group grow align="flex-start">
|
||||
<Autocomplete
|
||||
label="Company"
|
||||
description="Pick a registered customer, or type a company that is not on the system yet"
|
||||
placeholder={companies.loading ? "Loading companies…" : "Search or type a company"}
|
||||
data={companies.names}
|
||||
value={company}
|
||||
onChange={setCompany}
|
||||
limit={20}
|
||||
/>
|
||||
<Select
|
||||
label="Returned By"
|
||||
placeholder="Select truck type"
|
||||
value={returnedBy}
|
||||
onChange={(v) => setReturnedBy(v as "EDR" | "CUSTOMER" | null)}
|
||||
data={[
|
||||
{ value: "EDR", label: "EDR Truck" },
|
||||
{ value: "CUSTOMER", label: "Customer Truck" },
|
||||
]}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Group grow align="flex-start">
|
||||
<Select
|
||||
label="Warehouse"
|
||||
placeholder="Select warehouse"
|
||||
value={warehouseId}
|
||||
onChange={setWarehouseId}
|
||||
data={warehouseOptions}
|
||||
searchable
|
||||
/>
|
||||
<Select
|
||||
label="Yard"
|
||||
placeholder={warehouseId ? "Select yard" : "Select warehouse first"}
|
||||
value={yardId}
|
||||
onChange={setYardId}
|
||||
data={yardOptions}
|
||||
disabled={!warehouseId}
|
||||
searchable
|
||||
/>
|
||||
<Select
|
||||
label="Zone"
|
||||
placeholder={yardId ? "Select zone" : "Select yard first"}
|
||||
value={zoneId}
|
||||
onChange={setZoneId}
|
||||
data={zoneOptions}
|
||||
disabled={!yardId}
|
||||
searchable
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Group grow align="flex-start">
|
||||
<FileInput
|
||||
label="Excel file"
|
||||
placeholder="Select .xlsx or .xls"
|
||||
accept=".xlsx,.xls"
|
||||
leftSection={<Upload size={16} />}
|
||||
value={file}
|
||||
onChange={(next) => void handleFile(next)}
|
||||
/>
|
||||
<div>
|
||||
<Text size="sm" fw={500} mb={4}>
|
||||
Returned Date & Time (default)
|
||||
</Text>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={returnDate}
|
||||
onChange={(e) => setReturnDate(e.target.value)}
|
||||
style={{ padding: "8px", borderRadius: "4px", border: "1px solid #ced4da", width: "100%" }}
|
||||
/>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
{parsing && <Text size="sm">Reading file…</Text>}
|
||||
|
||||
{parseErrors.length > 0 && (
|
||||
<Alert color="red" title={`${parseErrors.length} problem(s) — nothing was imported`}>
|
||||
<ScrollArea.Autosize mah={200}>
|
||||
<List size="sm">
|
||||
{parseErrors.map((err) => (
|
||||
<List.Item key={err}>{err}</List.Item>
|
||||
))}
|
||||
</List>
|
||||
</ScrollArea.Autosize>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{blockers.length > 0 && (
|
||||
<Alert color="yellow" title="Fill these in before uploading">
|
||||
<List size="sm">
|
||||
{blockers.map((b) => (
|
||||
<List.Item key={b}>{b}</List.Item>
|
||||
))}
|
||||
</List>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{rows.length > 0 && (
|
||||
<Stack gap="xs">
|
||||
<Group gap="xs">
|
||||
<Text fw={600} size="sm">
|
||||
Preview
|
||||
</Text>
|
||||
<Badge size="sm">{rows.length} containers</Badge>
|
||||
</Group>
|
||||
<ScrollArea.Autosize mah={300}>
|
||||
<Table striped highlightOnHover withTableBorder>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Container</Table.Th>
|
||||
<Table.Th>Size</Table.Th>
|
||||
<Table.Th>Company</Table.Th>
|
||||
<Table.Th>Returned By</Table.Th>
|
||||
<Table.Th>Date</Table.Th>
|
||||
<Table.Th>Facility</Table.Th>
|
||||
<Table.Th>Yard</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{rows.map((row) => {
|
||||
const payload = toPayload(row);
|
||||
return (
|
||||
<Table.Tr key={row.containerNumber}>
|
||||
<Table.Td>{payload.containerNumber}</Table.Td>
|
||||
<Table.Td>{payload.containerSize ? `${payload.containerSize} ft` : "—"}</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Text size="sm">{payload.companyName || "—"}</Text>
|
||||
{payload.companyName && !payload.customerId && (
|
||||
<Badge size="xs" color="orange" variant="light">
|
||||
New
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td>{payload.returnedBy ?? "—"}</Table.Td>
|
||||
<Table.Td>
|
||||
{payload.returnDate
|
||||
? new Date(payload.returnDate).toLocaleDateString()
|
||||
: "—"}
|
||||
</Table.Td>
|
||||
<Table.Td>{payload.facility ?? "—"}</Table.Td>
|
||||
<Table.Td>{payload.yard ?? "—"}</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
})}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</ScrollArea.Autosize>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() => {
|
||||
reset();
|
||||
onClose();
|
||||
}}
|
||||
disabled={uploadMutation.isPending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => uploadMutation.mutate()}
|
||||
disabled={rows.length === 0 || blockers.length > 0}
|
||||
loading={uploadMutation.isPending}
|
||||
>
|
||||
Upload {rows.length > 0 ? `${rows.length} containers` : ""}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -13,8 +13,19 @@ import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { api } from '@/services/api';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import type { SaveWarehousePayload, Warehouse, WarehouseType } from '@/types/warehouse';
|
||||
import { extractErrorMessage, lettersOnly, statusOptions, warehouseTypeOptions } from './options';
|
||||
import type {
|
||||
SaveWarehousePayload,
|
||||
Warehouse,
|
||||
WarehouseFreightType,
|
||||
WarehouseType,
|
||||
} from '@/types/warehouse';
|
||||
import {
|
||||
extractErrorMessage,
|
||||
lettersOnly,
|
||||
statusOptions,
|
||||
warehouseFreightTypeOptions,
|
||||
warehouseTypeOptions,
|
||||
} from './options';
|
||||
|
||||
interface CreateWarehouseModalProps {
|
||||
opened: boolean;
|
||||
@@ -26,6 +37,7 @@ interface FormState {
|
||||
name: string;
|
||||
code: string;
|
||||
type: WarehouseType;
|
||||
freightType: WarehouseFreightType | null;
|
||||
stationId: string | null;
|
||||
locationName: string;
|
||||
capacityWeight: number | '';
|
||||
@@ -38,6 +50,7 @@ const emptyForm = (): FormState => ({
|
||||
name: '',
|
||||
code: '',
|
||||
type: 'OPEN_WAREHOUSE',
|
||||
freightType: null,
|
||||
stationId: null,
|
||||
locationName: '',
|
||||
capacityWeight: '',
|
||||
@@ -66,6 +79,7 @@ export function CreateWarehouseModal({ opened, onClose, warehouse }: CreateWareh
|
||||
name: warehouse.name,
|
||||
code: warehouse.code,
|
||||
type: warehouse.type,
|
||||
freightType: warehouse.freightType ?? null,
|
||||
stationId: warehouse.stationId ?? null,
|
||||
locationName: warehouse.locationName ?? '',
|
||||
capacityWeight: warehouse.capacityWeight ?? '',
|
||||
@@ -90,6 +104,7 @@ export function CreateWarehouseModal({ opened, onClose, warehouse }: CreateWareh
|
||||
name: form.name.trim(),
|
||||
code: form.code.trim(),
|
||||
type: form.type,
|
||||
freightType: form.freightType,
|
||||
stationId: form.stationId ?? undefined,
|
||||
locationName: form.locationName.trim() || undefined,
|
||||
capacityWeight: form.capacityWeight === '' ? undefined : Number(form.capacityWeight),
|
||||
@@ -142,6 +157,14 @@ export function CreateWarehouseModal({ opened, onClose, warehouse }: CreateWareh
|
||||
/>
|
||||
|
||||
<Group grow>
|
||||
<Select
|
||||
label="Freight type"
|
||||
placeholder="Both"
|
||||
data={warehouseFreightTypeOptions}
|
||||
value={form.freightType}
|
||||
onChange={(value) => setForm((f) => ({ ...f, freightType: value as WarehouseFreightType | null }))}
|
||||
clearable
|
||||
/>
|
||||
<Select
|
||||
label="Type"
|
||||
data={warehouseTypeOptions}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Button, Group, Modal, Select, Stack, Textarea } from '@mantine/core';
|
||||
import { Alert, Button, Group, List, Modal, Select, Stack, Textarea, Text } from '@mantine/core';
|
||||
import { Layers } from 'lucide-react';
|
||||
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
|
||||
@@ -7,6 +8,7 @@ import { api } from '@/services/api';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import type { WarehouseInventoryItem } from '@/types/warehouse';
|
||||
import { extractErrorMessage } from './options';
|
||||
import { SlotPicker } from './SlotPicker';
|
||||
|
||||
interface MoveInventoryModalProps {
|
||||
opened: boolean;
|
||||
@@ -20,6 +22,7 @@ export function MoveInventoryModal({ opened, onClose, item }: MoveInventoryModal
|
||||
const [warehouseId, setWarehouseId] = useState('');
|
||||
const [yardId, setYardId] = useState('');
|
||||
const [zoneId, setZoneId] = useState('');
|
||||
const [slotId, setSlotId] = useState('');
|
||||
const [remarks, setRemarks] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
@@ -27,10 +30,22 @@ export function MoveInventoryModal({ opened, onClose, item }: MoveInventoryModal
|
||||
setWarehouseId('');
|
||||
setYardId('');
|
||||
setZoneId('');
|
||||
setSlotId('');
|
||||
setRemarks('');
|
||||
}
|
||||
}, [opened]);
|
||||
|
||||
// A container with boxes stacked on top of it cannot be lifted out — the API
|
||||
// refuses the move, so the button says why instead of firing a 409.
|
||||
const accessibilityQuery = useQuery(
|
||||
api.warehouses.containerAccessibility.queryOptions({
|
||||
input: { id: item?.id ?? '' },
|
||||
enabled: opened && Boolean(item?.id),
|
||||
}),
|
||||
);
|
||||
const accessibility = accessibilityQuery.data;
|
||||
const blocked = accessibility ? !accessibility.accessible : false;
|
||||
|
||||
const warehousesQuery = useQuery(
|
||||
api.warehouses.list.queryOptions({ input: { filter: { status: 'ACTIVE' } } }),
|
||||
);
|
||||
@@ -69,7 +84,13 @@ export function MoveInventoryModal({ opened, onClose, item }: MoveInventoryModal
|
||||
try {
|
||||
await moveMutation.mutateAsync({
|
||||
id: item.id,
|
||||
payload: { warehouseId, yardId, zoneId, remarks: remarks.trim() || undefined },
|
||||
payload: {
|
||||
warehouseId,
|
||||
yardId,
|
||||
zoneId,
|
||||
slotId: slotId || undefined,
|
||||
remarks: remarks.trim() || undefined,
|
||||
},
|
||||
});
|
||||
toast({ title: 'Inventory moved' });
|
||||
onClose();
|
||||
@@ -81,6 +102,22 @@ export function MoveInventoryModal({ opened, onClose, item }: MoveInventoryModal
|
||||
return (
|
||||
<Modal opened={opened} onClose={onClose} title="Move inventory" centered size="lg">
|
||||
<Stack gap="md">
|
||||
{blocked && accessibility ? (
|
||||
<Alert icon={<Layers size={16} />} color="orange" variant="light" title="Container is buried">
|
||||
<Text size="sm">
|
||||
It sits at {accessibility.stackCode} level {accessibility.level} with{' '}
|
||||
{accessibility.blockingContainers.length} container(s) stacked on top. Move these out
|
||||
first:
|
||||
</Text>
|
||||
<List size="sm" mt={4}>
|
||||
{accessibility.blockingContainers.map((b) => (
|
||||
<List.Item key={b.inventoryId}>
|
||||
{b.containerNumber ?? 'Container'} — level {b.level}
|
||||
</List.Item>
|
||||
))}
|
||||
</List>
|
||||
</Alert>
|
||||
) : null}
|
||||
<Select
|
||||
label="Destination warehouse"
|
||||
placeholder="Select warehouse"
|
||||
@@ -115,8 +152,13 @@ export function MoveInventoryModal({ opened, onClose, item }: MoveInventoryModal
|
||||
disabled={!yardId}
|
||||
data={zoneOptions}
|
||||
value={zoneId || null}
|
||||
onChange={(v) => setZoneId(v ?? '')}
|
||||
onChange={(v) => {
|
||||
setZoneId(v ?? '');
|
||||
setSlotId('');
|
||||
}}
|
||||
/>
|
||||
{/* Container yards only — the picker hides itself where no stacks exist. */}
|
||||
<SlotPicker zoneId={zoneId} value={slotId} onChange={setSlotId} label="Destination stack position" />
|
||||
<Textarea
|
||||
label="Remarks"
|
||||
placeholder="Reason for the move"
|
||||
@@ -129,7 +171,12 @@ export function MoveInventoryModal({ opened, onClose, item }: MoveInventoryModal
|
||||
<Button variant="default" onClick={onClose} disabled={moveMutation.isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} loading={moveMutation.isPending}>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
loading={moveMutation.isPending}
|
||||
disabled={blocked}
|
||||
title={blocked ? 'Containers stacked above this one must be moved first' : undefined}
|
||||
>
|
||||
Move inventory
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Select, Text } from '@mantine/core';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { api } from '@/services/api';
|
||||
import type { ZoneLayout } from '@/types/warehouse';
|
||||
|
||||
interface SlotPickerProps {
|
||||
zoneId: string;
|
||||
value: string;
|
||||
onChange: (slotId: string) => void;
|
||||
label?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The stack levels a container may actually be put on right now.
|
||||
*
|
||||
* Only the next fillable level of each stack is offered: a box cannot stand on
|
||||
* level 2 while level 1 is empty, so listing level 3 of an empty stack would
|
||||
* only produce a rejected request. The server enforces the same rule — this
|
||||
* mirrors it so the operator never sees a 400 for a position the form offered.
|
||||
*/
|
||||
export function fillableLevels(layout: ZoneLayout | undefined) {
|
||||
if (!layout) return [];
|
||||
|
||||
return layout.stacks
|
||||
.filter((stack) => stack.isActive && stack.status === 'ACTIVE')
|
||||
.flatMap((stack) => {
|
||||
const occupied = stack.slots
|
||||
.filter((slot) => slot.effectiveStatus === 'OCCUPIED')
|
||||
.map((slot) => slot.level);
|
||||
const top = occupied.length > 0 ? Math.max(...occupied) : 0;
|
||||
if (top >= stack.maxStackHeight) return [];
|
||||
|
||||
const next = stack.slots.find(
|
||||
(slot) => slot.level === top + 1 && slot.effectiveStatus === 'AVAILABLE',
|
||||
);
|
||||
if (!next) return [];
|
||||
|
||||
return [
|
||||
{
|
||||
value: next.slotId,
|
||||
label: `${stack.code} — level ${next.level}${top > 0 ? ` (on ${top} container${top > 1 ? 's' : ''})` : ' (ground)'}`,
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
export function SlotPicker({ zoneId, value, onChange, label = 'Stack position', disabled }: SlotPickerProps) {
|
||||
const { data, isLoading } = useQuery(
|
||||
api.warehouses.zoneLayout.queryOptions({
|
||||
input: { zoneId },
|
||||
enabled: Boolean(zoneId),
|
||||
}),
|
||||
);
|
||||
|
||||
const options = useMemo(() => fillableLevels(data), [data]);
|
||||
|
||||
// A zone with no stacks configured keeps plain zone-level placement — showing
|
||||
// an empty picker there would imply a choice that does not exist.
|
||||
if (!zoneId || (!isLoading && (data?.stacks.length ?? 0) === 0)) return null;
|
||||
|
||||
return (
|
||||
<Select
|
||||
label={label}
|
||||
description={
|
||||
options.length === 0 && !isLoading ? (
|
||||
<Text size="xs" c="orange">
|
||||
Every stack in this zone is full or blocked — the item will be stored at zone level.
|
||||
</Text>
|
||||
) : (
|
||||
'Leave blank to take the lowest free level automatically.'
|
||||
)
|
||||
}
|
||||
placeholder={isLoading ? 'Loading positions…' : 'Automatic (lowest free level)'}
|
||||
searchable
|
||||
clearable
|
||||
disabled={disabled || isLoading || options.length === 0}
|
||||
data={options}
|
||||
value={value || null}
|
||||
onChange={(v) => onChange(v ?? '')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default SlotPicker;
|
||||
@@ -8,6 +8,7 @@ import { api } from '@/services/api';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import type { WarehouseInventoryItem } from '@/types/warehouse';
|
||||
import { extractErrorMessage } from './options';
|
||||
import { SlotPicker } from './SlotPicker';
|
||||
|
||||
interface StoreInventoryModalProps {
|
||||
opened: boolean;
|
||||
@@ -25,12 +26,14 @@ export function StoreInventoryModal({ opened, onClose, item }: StoreInventoryMod
|
||||
const [warehouseId, setWarehouseId] = useState('');
|
||||
const [yardId, setYardId] = useState('');
|
||||
const [zoneId, setZoneId] = useState('');
|
||||
const [slotId, setSlotId] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (opened) {
|
||||
setWarehouseId('');
|
||||
setYardId('');
|
||||
setZoneId('');
|
||||
setSlotId('');
|
||||
}
|
||||
}, [opened]);
|
||||
|
||||
@@ -75,7 +78,7 @@ export function StoreInventoryModal({ opened, onClose, item }: StoreInventoryMod
|
||||
try {
|
||||
await storeMutation.mutateAsync({
|
||||
id: item.id,
|
||||
payload: manualComplete ? { warehouseId, yardId, zoneId } : undefined,
|
||||
payload: manualComplete ? { warehouseId, yardId, zoneId, slotId: slotId || undefined } : undefined,
|
||||
});
|
||||
toast({ title: manualComplete ? 'Inventory stored at selected location' : 'Inventory stored (auto-allocated)' });
|
||||
onClose();
|
||||
@@ -127,8 +130,13 @@ export function StoreInventoryModal({ opened, onClose, item }: StoreInventoryMod
|
||||
disabled={!yardId}
|
||||
data={zoneOptions}
|
||||
value={zoneId || null}
|
||||
onChange={(v) => setZoneId(v ?? '')}
|
||||
onChange={(v) => {
|
||||
setZoneId(v ?? '');
|
||||
setSlotId('');
|
||||
}}
|
||||
/>
|
||||
{/* Container yards only — the picker hides itself where no stacks exist. */}
|
||||
<SlotPicker zoneId={zoneId} value={slotId} onChange={setSlotId} />
|
||||
<Group justify="flex-end" mt="sm">
|
||||
<Button variant="default" onClick={onClose} disabled={storeMutation.isPending}>
|
||||
Cancel
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMemo } from 'react';
|
||||
import { ActionIcon, Box, Card, Divider, Group, Progress, SimpleGrid, Stack, Text, Tooltip } from '@mantine/core';
|
||||
import { Building2, Eye, MapPin, Package, Pencil, Weight } from 'lucide-react';
|
||||
import { Building2, Eye, MapPin, Package, Pencil, Trash2, Weight } from 'lucide-react';
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
@@ -13,9 +13,11 @@ interface WarehouseCardViewProps {
|
||||
warehouses: Warehouse[];
|
||||
onView: (warehouse: Warehouse) => void;
|
||||
onEdit: (warehouse: Warehouse) => void;
|
||||
/** Omitted when the user lacks the delete permission. */
|
||||
onDelete?: (warehouse: Warehouse) => void;
|
||||
}
|
||||
|
||||
export function WarehouseCardView({ warehouses, onView, onEdit }: WarehouseCardViewProps) {
|
||||
export function WarehouseCardView({ warehouses, onView, onEdit, onDelete }: WarehouseCardViewProps) {
|
||||
const { data: stations } = useQuery(
|
||||
api.stations.list.queryOptions({ staleTime: 5 * 60 * 1000 }),
|
||||
);
|
||||
@@ -130,6 +132,18 @@ export function WarehouseCardView({ warehouses, onView, onEdit }: WarehouseCardV
|
||||
<Pencil size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
{onDelete ? (
|
||||
<Tooltip label="Delete warehouse" withArrow>
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
color="red"
|
||||
onClick={() => onDelete(warehouse)}
|
||||
aria-label="Delete warehouse"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
Package,
|
||||
Pencil,
|
||||
Scale,
|
||||
Trash2,
|
||||
Warehouse as WarehouseIcon,
|
||||
} from 'lucide-react';
|
||||
import { DataTable, type ColumnDef } from '@edr/ui-common';
|
||||
@@ -24,6 +25,8 @@ interface WarehouseTableProps {
|
||||
warehouses: Warehouse[];
|
||||
onView: (warehouse: Warehouse) => void;
|
||||
onEdit: (warehouse: Warehouse) => void;
|
||||
/** Omitted when the user lacks the delete permission. */
|
||||
onDelete?: (warehouse: Warehouse) => void;
|
||||
}
|
||||
|
||||
const HEADER = bookingTable.headerCell;
|
||||
@@ -63,7 +66,7 @@ function CapacityCell({
|
||||
);
|
||||
}
|
||||
|
||||
export function WarehouseTable({ warehouses, onView, onEdit }: WarehouseTableProps) {
|
||||
export function WarehouseTable({ warehouses, onView, onEdit, onDelete }: WarehouseTableProps) {
|
||||
const { data: stations } = useQuery(
|
||||
api.stations.list.queryOptions({ staleTime: 5 * 60 * 1000 }),
|
||||
);
|
||||
@@ -179,6 +182,11 @@ export function WarehouseTable({ warehouses, onView, onEdit }: WarehouseTablePro
|
||||
<ActionIcon variant="subtle" color="gray" onClick={() => onEdit(row.original)} title="Edit">
|
||||
<Pencil size={16} />
|
||||
</ActionIcon>
|
||||
{onDelete ? (
|
||||
<ActionIcon variant="subtle" color="red" onClick={() => onDelete(row.original)} title="Delete">
|
||||
<Trash2 size={16} />
|
||||
</ActionIcon>
|
||||
) : null}
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import { Badge, Group, Loader, Modal, Text } from '@mantine/core';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { DataTable, type ColumnDef } from '@edr/ui-common';
|
||||
|
||||
import { formatDateTime, humanize } from '@/lib/format';
|
||||
import { api } from '@/services/api';
|
||||
import type { ZoneContentItem } from '@/types/warehouse';
|
||||
|
||||
/** Enough to name the zone and fetch it — satisfied by WarehouseZone and ZoneOccupancy alike. */
|
||||
export interface ZoneRef {
|
||||
id: string;
|
||||
name: string;
|
||||
code: string;
|
||||
}
|
||||
|
||||
interface ZoneContentsModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
zone: ZoneRef | null;
|
||||
}
|
||||
|
||||
const columns: ColumnDef<ZoneContentItem>[] = [
|
||||
{
|
||||
id: 'containerNumber',
|
||||
header: 'Container No.',
|
||||
// Bulk cargo has no container of its own — it still occupies the zone.
|
||||
cell: ({ row }) => row.original.containerNumber ?? 'Bulk cargo',
|
||||
},
|
||||
{
|
||||
id: 'unloadedAt',
|
||||
header: 'Unloaded',
|
||||
cell: ({ row }) => formatDateTime(row.original.unloadedAt),
|
||||
},
|
||||
{
|
||||
id: 'containerType',
|
||||
header: 'Type',
|
||||
cell: ({ row }) => row.original.containerType ?? '—',
|
||||
},
|
||||
{
|
||||
id: 'direction',
|
||||
header: 'Import / Export',
|
||||
cell: ({ row }) =>
|
||||
row.original.direction ? (
|
||||
<Badge color={row.original.direction === 'IMPORT' ? 'blue' : 'teal'} variant="light">
|
||||
{humanize(row.original.direction)}
|
||||
</Badge>
|
||||
) : (
|
||||
'—'
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'loadState',
|
||||
header: 'Full / Empty',
|
||||
cell: ({ row }) =>
|
||||
row.original.loadState ? (
|
||||
<Badge color={row.original.loadState === 'EMPTY' ? 'gray' : 'green'} variant="light">
|
||||
{humanize(row.original.loadState)}
|
||||
</Badge>
|
||||
) : (
|
||||
'—'
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'bookingReference',
|
||||
header: 'Booking',
|
||||
cell: ({ row }) => row.original.bookingReference ?? '—',
|
||||
},
|
||||
{
|
||||
id: 'status',
|
||||
header: 'Status',
|
||||
cell: ({ row }) => humanize(row.original.status),
|
||||
},
|
||||
];
|
||||
|
||||
export function ZoneContentsModal({ opened, onClose, zone }: ZoneContentsModalProps) {
|
||||
const { data, isLoading, isError } = useQuery(
|
||||
api.warehouses.zoneContents.queryOptions({
|
||||
input: { zoneId: zone?.id ?? '' },
|
||||
enabled: opened && Boolean(zone?.id),
|
||||
}),
|
||||
);
|
||||
|
||||
const items = data ?? [];
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
size="xl"
|
||||
title={
|
||||
<Group gap="xs">
|
||||
<Text fw={700}>{zone ? `${zone.name} (${zone.code})` : 'Zone'}</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{items.length} item(s) in this zone
|
||||
</Text>
|
||||
</Group>
|
||||
}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Group justify="center" py="xl">
|
||||
<Loader />
|
||||
</Group>
|
||||
) : isError ? (
|
||||
<Text c="red" ta="center" py="xl">
|
||||
Failed to load zone contents.
|
||||
</Text>
|
||||
) : (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={items}
|
||||
status="success"
|
||||
emptyMessage="This zone is empty."
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default ZoneContentsModal;
|
||||
@@ -0,0 +1,407 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
Loader,
|
||||
Menu,
|
||||
Modal,
|
||||
NumberInput,
|
||||
Paper,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Tooltip,
|
||||
} from '@mantine/core';
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { Ban, CircleSlash, Layers, MoreVertical, Plus, Trash2, Unlock } from 'lucide-react';
|
||||
|
||||
import { useAuth } from '@/auth/useAuth';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { FREIGHT_PERMS, hasPermission } from '@/lib/permissions';
|
||||
import { api } from '@/services/api';
|
||||
import type { SlotEffectiveStatus, ZoneLayoutSlot, ZoneLayoutStack } from '@/types/warehouse';
|
||||
import { extractErrorMessage } from './options';
|
||||
import type { ZoneRef } from './ZoneContentsModal';
|
||||
|
||||
interface ZoneLayoutModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
zone: ZoneRef | null;
|
||||
}
|
||||
|
||||
/** One colour per slot state, used by both the cell and the legend. */
|
||||
const SLOT_TONE: Record<SlotEffectiveStatus, { color: string; label: string }> = {
|
||||
OCCUPIED: { color: 'blue', label: 'Occupied' },
|
||||
AVAILABLE: { color: 'teal', label: 'Free' },
|
||||
RESERVED: { color: 'orange', label: 'Reserved' },
|
||||
BLOCKED: { color: 'red', label: 'Blocked' },
|
||||
INACTIVE: { color: 'gray', label: 'Inactive' },
|
||||
};
|
||||
|
||||
/**
|
||||
* A container stack seen from the side: level 3 on top, level 1 on the ground —
|
||||
* the order the API already returns and the order the yard actually looks.
|
||||
*/
|
||||
function SlotCell({
|
||||
slot,
|
||||
onSetStatus,
|
||||
canEdit,
|
||||
}: {
|
||||
slot: ZoneLayoutSlot;
|
||||
canEdit: boolean;
|
||||
onSetStatus: (slot: ZoneLayoutSlot, status: 'AVAILABLE' | 'BLOCKED' | 'RESERVED') => void;
|
||||
}) {
|
||||
const tone = SLOT_TONE[slot.effectiveStatus];
|
||||
const occupied = slot.effectiveStatus === 'OCCUPIED';
|
||||
|
||||
return (
|
||||
<Paper
|
||||
withBorder
|
||||
radius="sm"
|
||||
px="xs"
|
||||
py={6}
|
||||
style={{ borderLeft: `4px solid var(--mantine-color-${tone.color}-6)` }}
|
||||
>
|
||||
<Group justify="space-between" wrap="nowrap" gap="xs">
|
||||
<Group gap={6} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<Text size="xs" c="dimmed" fw={600} w={20}>
|
||||
L{slot.level}
|
||||
</Text>
|
||||
<Text size="sm" truncate title={slot.containerNumber ?? tone.label}>
|
||||
{occupied ? (slot.containerNumber ?? 'Container') : tone.label}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
{/* An occupied level has no status to set — empty it by moving the box. */}
|
||||
{canEdit && !occupied ? (
|
||||
<Menu position="bottom-end" withinPortal>
|
||||
<Menu.Target>
|
||||
<ActionIcon variant="subtle" size="sm" aria-label={`Level ${slot.level} actions`}>
|
||||
<MoreVertical size={14} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
leftSection={<Unlock size={14} />}
|
||||
disabled={slot.effectiveStatus === 'AVAILABLE'}
|
||||
onClick={() => onSetStatus(slot, 'AVAILABLE')}
|
||||
>
|
||||
Mark free
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<CircleSlash size={14} />}
|
||||
disabled={slot.effectiveStatus === 'RESERVED'}
|
||||
onClick={() => onSetStatus(slot, 'RESERVED')}
|
||||
>
|
||||
Reserve
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<Ban size={14} />}
|
||||
color="red"
|
||||
disabled={slot.effectiveStatus === 'BLOCKED'}
|
||||
onClick={() => onSetStatus(slot, 'BLOCKED')}
|
||||
>
|
||||
Block
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
) : null}
|
||||
</Group>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
function StackCard({
|
||||
stack,
|
||||
canEdit,
|
||||
canDelete,
|
||||
onSetSlotStatus,
|
||||
onDelete,
|
||||
}: {
|
||||
stack: ZoneLayoutStack;
|
||||
canEdit: boolean;
|
||||
canDelete: boolean;
|
||||
onSetSlotStatus: (slot: ZoneLayoutSlot, status: 'AVAILABLE' | 'BLOCKED' | 'RESERVED') => void;
|
||||
onDelete: (stack: ZoneLayoutStack) => void;
|
||||
}) {
|
||||
const filled = stack.slots.filter((s) => s.effectiveStatus === 'OCCUPIED').length;
|
||||
|
||||
return (
|
||||
<Card withBorder radius="md" padding="sm">
|
||||
<Stack gap={8}>
|
||||
<Group justify="space-between" wrap="nowrap" gap="xs">
|
||||
<Group gap={6} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<Text fw={600} size="sm" truncate title={stack.name ?? stack.code}>
|
||||
{stack.code}
|
||||
</Text>
|
||||
{stack.status !== 'ACTIVE' || !stack.isActive ? (
|
||||
<Badge size="xs" color="gray" variant="light">
|
||||
Inactive
|
||||
</Badge>
|
||||
) : null}
|
||||
</Group>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Badge size="sm" variant="light" color={filled === stack.maxStackHeight ? 'blue' : 'gray'}>
|
||||
{filled}/{stack.maxStackHeight}
|
||||
</Badge>
|
||||
{canDelete ? (
|
||||
<Tooltip label="Delete stack">
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
size="sm"
|
||||
onClick={() => onDelete(stack)}
|
||||
aria-label={`Delete stack ${stack.code}`}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<Stack gap={4}>
|
||||
{stack.slots.map((slot) => (
|
||||
<SlotCell key={slot.slotId} slot={slot} canEdit={canEdit} onSetStatus={onSetSlotStatus} />
|
||||
))}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Physical layout of one zone — every ground stack with its levels, what stands
|
||||
* on each, and the capacity numbers that are routinely confused (configured vs
|
||||
* built vs full). Stacks are created and retired from here, since there is
|
||||
* nowhere else the yard layout is visible.
|
||||
*/
|
||||
export function ZoneLayoutModal({ opened, onClose, zone }: ZoneLayoutModalProps) {
|
||||
const { toast } = useToast();
|
||||
const { user } = useAuth();
|
||||
const canCreate = hasPermission(user, FREIGHT_PERMS.warehouseZones.create);
|
||||
const canEdit = hasPermission(user, FREIGHT_PERMS.warehouseZones.update);
|
||||
const canDelete = hasPermission(user, FREIGHT_PERMS.warehouseZones.delete);
|
||||
|
||||
const zoneId = zone?.id ?? '';
|
||||
const { data, isLoading, isError, refetch } = useQuery(
|
||||
api.warehouses.zoneLayout.queryOptions({
|
||||
input: { zoneId },
|
||||
enabled: opened && Boolean(zoneId),
|
||||
}),
|
||||
);
|
||||
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [code, setCode] = useState('');
|
||||
const [height, setHeight] = useState<number>(3);
|
||||
|
||||
const createStack = useMutation(api.warehouses.createStack.mutationOptions());
|
||||
const deleteStack = useMutation(api.warehouses.deleteStack.mutationOptions());
|
||||
const updateSlot = useMutation(api.warehouses.updateSlot.mutationOptions());
|
||||
|
||||
const stacks = data?.stacks ?? [];
|
||||
const summary = data?.summary;
|
||||
|
||||
const nextCode = useMemo(() => {
|
||||
// Suggest the next number in the zone's own series (ZA-001 → ZA-002) so
|
||||
// codes stay sortable, which is the order the placement engine walks.
|
||||
const numbered = stacks
|
||||
.map((s) => /^(.*?)(\d+)$/.exec(s.code))
|
||||
.filter((m): m is RegExpExecArray => Boolean(m));
|
||||
if (numbered.length === 0) return '';
|
||||
const last = numbered[numbered.length - 1];
|
||||
const width = last[2].length;
|
||||
const next = Math.max(...numbered.map((m) => Number(m[2]))) + 1;
|
||||
return `${last[1]}${String(next).padStart(width, '0')}`;
|
||||
}, [stacks]);
|
||||
|
||||
const submitStack = () => {
|
||||
const trimmed = code.trim();
|
||||
if (!trimmed) {
|
||||
toast({ variant: 'destructive', title: 'Stack code is required' });
|
||||
return;
|
||||
}
|
||||
createStack.mutate(
|
||||
{ zoneId, payload: { code: trimmed, maxStackHeight: height } },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast({ title: `Stack ${trimmed} created with ${height} level(s)` });
|
||||
setCode('');
|
||||
setCreating(false);
|
||||
},
|
||||
onError: (error) =>
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Could not create the stack',
|
||||
description: extractErrorMessage(error),
|
||||
}),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const removeStack = (stack: ZoneLayoutStack) => {
|
||||
if (!window.confirm(`Delete stack ${stack.code}? It must be empty first.`)) return;
|
||||
deleteStack.mutate(
|
||||
{ id: stack.stackId, zoneId },
|
||||
{
|
||||
onSuccess: () => toast({ title: `Stack ${stack.code} deleted` }),
|
||||
onError: (error) =>
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Could not delete the stack',
|
||||
description: extractErrorMessage(error),
|
||||
}),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const setSlotStatus = (slot: ZoneLayoutSlot, status: 'AVAILABLE' | 'BLOCKED' | 'RESERVED') => {
|
||||
updateSlot.mutate(
|
||||
{ slotId: slot.slotId, zoneId, payload: { status, isActive: true } },
|
||||
{
|
||||
onSuccess: () => toast({ title: `Level ${slot.level} set to ${SLOT_TONE[status].label}` }),
|
||||
onError: (error) =>
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Could not update the level',
|
||||
description: extractErrorMessage(error),
|
||||
}),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
size="xl"
|
||||
title={
|
||||
<Group gap="xs">
|
||||
<Layers size={18} />
|
||||
<Text fw={700}>{zone ? `${zone.name} (${zone.code}) layout` : 'Zone layout'}</Text>
|
||||
</Group>
|
||||
}
|
||||
>
|
||||
<Stack gap="md">
|
||||
{summary ? (
|
||||
<Card withBorder radius="md" padding="sm">
|
||||
<Group gap="lg" wrap="wrap">
|
||||
<Stat label="Configured capacity" value={summary.configuredCapacity ?? '—'} />
|
||||
<Stat label="Slots built" value={summary.physicalSlotCount} />
|
||||
<Stat label="Occupied" value={summary.occupiedSlotCount} color="blue" />
|
||||
<Stat label="Free" value={summary.availableSlotCount} color="teal" />
|
||||
<Stat label="Reserved" value={summary.reservedSlotCount} color="orange" />
|
||||
<Stat label="Blocked" value={summary.blockedSlotCount} color="red" />
|
||||
</Group>
|
||||
{summary.inconsistent ? (
|
||||
<Text size="xs" c="red" mt={6}>
|
||||
{summary.physicalSlotCount} slots are built but the zone is configured for{' '}
|
||||
{summary.configuredCapacity}. Raise the zone capacity or remove stacks — the
|
||||
configured figure was left as it is.
|
||||
</Text>
|
||||
) : null}
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<Group justify="space-between">
|
||||
<Group gap="xs">
|
||||
{(Object.keys(SLOT_TONE) as SlotEffectiveStatus[]).map((key) => (
|
||||
<Badge key={key} size="xs" variant="light" color={SLOT_TONE[key].color}>
|
||||
{SLOT_TONE[key].label}
|
||||
</Badge>
|
||||
))}
|
||||
</Group>
|
||||
{canCreate ? (
|
||||
<Button
|
||||
size="xs"
|
||||
leftSection={<Plus size={14} />}
|
||||
variant={creating ? 'default' : 'filled'}
|
||||
onClick={() => {
|
||||
setCreating((open) => !open);
|
||||
if (!creating && !code) setCode(nextCode);
|
||||
}}
|
||||
>
|
||||
{creating ? 'Cancel' : 'Add stack'}
|
||||
</Button>
|
||||
) : null}
|
||||
</Group>
|
||||
|
||||
{creating ? (
|
||||
<Card withBorder radius="md" padding="sm">
|
||||
<Group align="flex-end" gap="sm">
|
||||
<TextInput
|
||||
label="Stack code"
|
||||
placeholder="ZA-001"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.currentTarget.value)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Levels"
|
||||
description="One slot is created per level"
|
||||
min={1}
|
||||
max={10}
|
||||
value={height}
|
||||
onChange={(v) => setHeight(Number(v) || 1)}
|
||||
w={140}
|
||||
/>
|
||||
<Button onClick={submitStack} loading={createStack.isPending}>
|
||||
Create
|
||||
</Button>
|
||||
</Group>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{isLoading ? (
|
||||
<Group justify="center" py="xl">
|
||||
<Loader />
|
||||
</Group>
|
||||
) : isError ? (
|
||||
<Stack align="center" py="xl" gap="xs">
|
||||
<Text c="red">Failed to load the zone layout.</Text>
|
||||
<Button variant="default" size="xs" onClick={() => void refetch()}>
|
||||
Retry
|
||||
</Button>
|
||||
</Stack>
|
||||
) : stacks.length === 0 ? (
|
||||
<Text c="dimmed" ta="center" py="xl" size="sm">
|
||||
No ground stacks configured in this zone yet. Containers stored here keep zone-level
|
||||
placement until stacks exist.
|
||||
</Text>
|
||||
) : (
|
||||
<SimpleGrid cols={{ base: 1, xs: 2, sm: 3, lg: 4 }} spacing="sm">
|
||||
{stacks.map((stack) => (
|
||||
<StackCard
|
||||
key={stack.stackId}
|
||||
stack={stack}
|
||||
canEdit={canEdit}
|
||||
canDelete={canDelete}
|
||||
onSetSlotStatus={setSlotStatus}
|
||||
onDelete={removeStack}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({ label, value, color }: { label: string; value: number | string; color?: string }) {
|
||||
return (
|
||||
<Stack gap={0}>
|
||||
<Text size="xs" c="dimmed">
|
||||
{label}
|
||||
</Text>
|
||||
<Text fw={700} c={color}>
|
||||
{value}
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export default ZoneLayoutModal;
|
||||
@@ -25,13 +25,15 @@ function capacityLabel(z: ZoneOccupancy): string {
|
||||
interface ZoneOccupancyHeatmapProps {
|
||||
/** Scope to one yard; omit for all zones. */
|
||||
yardId?: string;
|
||||
/** Pass to make each tile open that zone; omitted leaves the tiles inert. */
|
||||
onZoneClick?: (zone: ZoneOccupancy) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Occupancy heatmap: one tile per zone, coloured by how full it is. Occupancy is
|
||||
* container-count based (unit-consistent); weight is shown as context only.
|
||||
*/
|
||||
export function ZoneOccupancyHeatmap({ yardId }: ZoneOccupancyHeatmapProps) {
|
||||
export function ZoneOccupancyHeatmap({ yardId, onZoneClick }: ZoneOccupancyHeatmapProps) {
|
||||
const { data: zones = [], isLoading } = useZoneOccupancy(yardId);
|
||||
|
||||
if (isLoading) {
|
||||
@@ -67,7 +69,15 @@ export function ZoneOccupancyHeatmap({ yardId }: ZoneOccupancyHeatmapProps) {
|
||||
const t = tone(z.occupancyPct);
|
||||
const pct = z.occupancyPct ?? 0;
|
||||
return (
|
||||
<Card key={z.id} withBorder radius="md" padding="sm">
|
||||
<Card
|
||||
key={z.id}
|
||||
withBorder
|
||||
radius="md"
|
||||
padding="sm"
|
||||
onClick={onZoneClick ? () => onZoneClick(z) : undefined}
|
||||
style={onZoneClick ? { cursor: 'pointer' } : undefined}
|
||||
title={onZoneClick ? `View what is stored in ${z.name}` : undefined}
|
||||
>
|
||||
<Stack gap={6}>
|
||||
<Group justify="space-between" wrap="nowrap" gap="xs">
|
||||
<Text fw={600} size="sm" truncate title={z.name}>
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import * as XLSX from "xlsx";
|
||||
|
||||
import { parseContainerReturnExcel } from "./container-return-excel";
|
||||
|
||||
/** Build an in-memory .xlsx and hand it back as a File, like the dropzone would. */
|
||||
function sheetFile(aoa: unknown[][]): File {
|
||||
const wb = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet(aoa), "Sheet1");
|
||||
const buf = XLSX.write(wb, { type: "array", bookType: "xlsx" }) as ArrayBuffer;
|
||||
return new File([buf], "returns.xlsx");
|
||||
}
|
||||
|
||||
const HEADERS = [
|
||||
"Container Number",
|
||||
"Container Size",
|
||||
"Company",
|
||||
"Returned By",
|
||||
"Returned Date",
|
||||
"Facility",
|
||||
"Yard",
|
||||
"Zone",
|
||||
"Condition",
|
||||
"Handover Note",
|
||||
];
|
||||
|
||||
describe("parseContainerReturnExcel", () => {
|
||||
it("parses a good sheet, normalizing size and returned-by", async () => {
|
||||
const result = await parseContainerReturnExcel(
|
||||
sheetFile([
|
||||
["Yard tally — August"], // title row above the header is ignored
|
||||
HEADERS,
|
||||
["temu1234567", "40ft", "Acme PLC", "EDR last mile", "2026-08-14", "Gelan", "A", "1", "", ""],
|
||||
["MSCU7654321", "20", "Other Trading", "Self haul", "2026-08-15", "Gelan", "", "", "Dented", "n"],
|
||||
]),
|
||||
);
|
||||
|
||||
expect(result.errors).toEqual([]);
|
||||
expect(result.rows).toHaveLength(2);
|
||||
expect(result.rows[0].containerNumber).toBe("TEMU1234567");
|
||||
expect(result.rows[0].containerSize).toBe("40");
|
||||
expect(result.rows[0].returnedBy).toBe("EDR");
|
||||
expect(result.rows[0].companyName).toBe("Acme PLC");
|
||||
expect(result.rows[0].returnDate?.startsWith("2026-08-14")).toBe(true);
|
||||
expect(result.rows[1].containerSize).toBe("20");
|
||||
expect(result.rows[1].returnedBy).toBe("CUSTOMER");
|
||||
});
|
||||
|
||||
it("rejects the whole file when a container number is invalid", async () => {
|
||||
const result = await parseContainerReturnExcel(
|
||||
sheetFile([HEADERS, ["NOTACONTAINER", "40", "Acme", "EDR", "", "", "", "", "", ""]]),
|
||||
);
|
||||
|
||||
expect(result.rows).toEqual([]);
|
||||
expect(result.errors[0]).toContain("Row 2");
|
||||
});
|
||||
|
||||
it("rejects duplicate container numbers", async () => {
|
||||
const result = await parseContainerReturnExcel(
|
||||
sheetFile([
|
||||
HEADERS,
|
||||
["TEMU1234567", "40", "Acme", "EDR", "", "", "", "", "", ""],
|
||||
["temu1234567", "20", "Acme", "EDR", "", "", "", "", "", ""],
|
||||
]),
|
||||
);
|
||||
|
||||
expect(result.rows).toEqual([]);
|
||||
expect(result.errors.some((e) => e.includes("appears 2 times"))).toBe(true);
|
||||
});
|
||||
|
||||
it("errors when there is no container-number column", async () => {
|
||||
const result = await parseContainerReturnExcel(sheetFile([["Company", "Yard"], ["Acme", "A"]]));
|
||||
expect(result.errors[0]).toContain("Container Number");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,231 @@
|
||||
import * as XLSX from "xlsx";
|
||||
|
||||
// Excel import for empties already sitting in an EDR yard that were never
|
||||
// entered in the system. One spreadsheet row per container. All-or-nothing —
|
||||
// any bad row rejects the whole file with row-numbered errors, so a partial
|
||||
// backfill can never silently drop boxes.
|
||||
|
||||
// ISO 6346: 4-letter prefix (owner code + category id) + 7 digits.
|
||||
const ISO_CONTAINER_NUMBER_REGEX = /^[A-Z]{4}\d{7}$/;
|
||||
|
||||
export interface ParsedReturnRow {
|
||||
containerNumber: string;
|
||||
containerSize: "20" | "40" | null;
|
||||
companyName: string;
|
||||
returnedBy: "EDR" | "CUSTOMER" | null;
|
||||
returnDate: string | null;
|
||||
facility: string;
|
||||
yard: string;
|
||||
zone: string;
|
||||
condition: string;
|
||||
handoverNote: string;
|
||||
}
|
||||
|
||||
export interface ContainerReturnExcelResult {
|
||||
rows: ParsedReturnRow[];
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
type ColumnKey =
|
||||
| "containerNumber"
|
||||
| "containerSize"
|
||||
| "companyName"
|
||||
| "returnedBy"
|
||||
| "returnDate"
|
||||
| "facility"
|
||||
| "yard"
|
||||
| "zone"
|
||||
| "condition"
|
||||
| "handoverNote";
|
||||
|
||||
/** Match a header cell to a known column, tolerant of casing/spacing/punctuation. */
|
||||
function headerKey(raw: string): ColumnKey | null {
|
||||
const h = raw.toLowerCase().replace(/[^a-z]/g, "");
|
||||
if (!h) return null;
|
||||
if (h.includes("size") || h.includes("type")) return "containerSize";
|
||||
if (h.includes("company") || h.includes("customer") || h.includes("consignee")) return "companyName";
|
||||
if (h.includes("returnedby") || h.includes("haul") || h.includes("truck")) return "returnedBy";
|
||||
if (h.includes("date")) return "returnDate";
|
||||
if (h.includes("facility") || h.includes("warehouse") || h.includes("terminal")) return "facility";
|
||||
if (h.includes("yard")) return "yard";
|
||||
if (h.includes("zone")) return "zone";
|
||||
if (h.includes("condition") || h.includes("damage")) return "condition";
|
||||
if (h.includes("note") || h.includes("remark")) return "handoverNote";
|
||||
// Least specific last, so "Container Size" is not eaten by "container".
|
||||
if (h.includes("container") || h.includes("number")) return "containerNumber";
|
||||
return null;
|
||||
}
|
||||
|
||||
/** "20", "20ft", "40 HC" … → '20' | '40' | null. */
|
||||
function normalizeSize(raw: string): "20" | "40" | null {
|
||||
const digits = raw.replace(/[^0-9]/g, "");
|
||||
if (digits.startsWith("20")) return "20";
|
||||
if (digits.startsWith("40") || digits.startsWith("45")) return "40";
|
||||
return null;
|
||||
}
|
||||
|
||||
/** "EDR", "EDR last mile", "customer", "self haul" … */
|
||||
function normalizeReturnedBy(raw: string): "EDR" | "CUSTOMER" | null {
|
||||
const v = raw.toLowerCase();
|
||||
if (!v.trim()) return null;
|
||||
if (v.includes("edr")) return "EDR";
|
||||
if (v.includes("customer") || v.includes("self")) return "CUSTOMER";
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Excel dates arrive either as a serial number (raw cells) or as text. Returns
|
||||
* an ISO instant, or null when the cell is empty/unparseable.
|
||||
*/
|
||||
function normalizeDate(raw: string): string | null {
|
||||
const v = raw.trim();
|
||||
if (!v) return null;
|
||||
// Excel serial: days since 1899-12-30.
|
||||
if (/^\d{1,6}(\.\d+)?$/.test(v)) {
|
||||
const serial = Number(v);
|
||||
if (serial > 20000 && serial < 80000) {
|
||||
return new Date(Math.round((serial - 25569) * 86400000)).toISOString();
|
||||
}
|
||||
}
|
||||
const parsed = new Date(v);
|
||||
return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an uploaded workbook into one row per empty container. Returns either
|
||||
* the full row set or the list of row-numbered problems — never both.
|
||||
*/
|
||||
export async function parseContainerReturnExcel(file: File): Promise<ContainerReturnExcelResult> {
|
||||
let sheet: XLSX.WorkSheet | undefined;
|
||||
try {
|
||||
const workbook = XLSX.read(await file.arrayBuffer(), { type: "array" });
|
||||
sheet = workbook.Sheets[workbook.SheetNames[0]];
|
||||
} catch {
|
||||
return { rows: [], errors: ["Could not read the file — is it a valid Excel file?"] };
|
||||
}
|
||||
if (!sheet) return { rows: [], errors: ["The file has no sheets."] };
|
||||
|
||||
const grid = XLSX.utils.sheet_to_json<string[]>(sheet, { header: 1, raw: false, defval: "" });
|
||||
|
||||
// First row carrying a container-number column is the header; titles and
|
||||
// blank rows above it are ignored.
|
||||
let headerRowIdx = -1;
|
||||
let columns: Array<ColumnKey | null> = [];
|
||||
for (let i = 0; i < grid.length; i++) {
|
||||
const mapped = (grid[i] ?? []).map((c) => headerKey(String(c ?? "")));
|
||||
if (mapped.includes("containerNumber")) {
|
||||
headerRowIdx = i;
|
||||
columns = mapped;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (headerRowIdx < 0) {
|
||||
return {
|
||||
rows: [],
|
||||
errors: [
|
||||
'Could not find a "Container Number" column — download the template to see the expected format.',
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const rows: ParsedReturnRow[] = [];
|
||||
const errors: string[] = [];
|
||||
const numberCounts = new Map<string, number>();
|
||||
|
||||
for (let i = headerRowIdx + 1; i < grid.length; i++) {
|
||||
const cells = grid[i] ?? [];
|
||||
if (cells.every((c) => String(c ?? "").trim() === "")) continue;
|
||||
const rowNo = i + 1; // 1-based, as shown in Excel
|
||||
|
||||
const cell = (key: ColumnKey) => {
|
||||
const idx = columns.indexOf(key);
|
||||
return idx >= 0 ? String(cells[idx] ?? "").trim() : "";
|
||||
};
|
||||
|
||||
const containerNumber = cell("containerNumber").toUpperCase().replace(/\s/g, "");
|
||||
if (!ISO_CONTAINER_NUMBER_REGEX.test(containerNumber)) {
|
||||
errors.push(
|
||||
`Row ${rowNo}: "${cell("containerNumber") || "—"}" is not a valid ISO container number (e.g. TEMU1234567).`,
|
||||
);
|
||||
} else {
|
||||
numberCounts.set(containerNumber, (numberCounts.get(containerNumber) ?? 0) + 1);
|
||||
}
|
||||
|
||||
const sizeRaw = cell("containerSize");
|
||||
const containerSize = sizeRaw ? normalizeSize(sizeRaw) : null;
|
||||
if (sizeRaw && !containerSize) {
|
||||
errors.push(`Row ${rowNo}: container size "${sizeRaw}" is not 20 or 40.`);
|
||||
}
|
||||
|
||||
const returnedByRaw = cell("returnedBy");
|
||||
const returnedBy = normalizeReturnedBy(returnedByRaw);
|
||||
if (returnedByRaw && !returnedBy) {
|
||||
errors.push(`Row ${rowNo}: returned by "${returnedByRaw}" must be EDR or CUSTOMER.`);
|
||||
}
|
||||
|
||||
const dateRaw = cell("returnDate");
|
||||
const returnDate = normalizeDate(dateRaw);
|
||||
if (dateRaw && !returnDate) {
|
||||
errors.push(`Row ${rowNo}: returned date "${dateRaw}" is not a date.`);
|
||||
}
|
||||
|
||||
rows.push({
|
||||
containerNumber,
|
||||
containerSize,
|
||||
companyName: cell("companyName"),
|
||||
returnedBy,
|
||||
returnDate,
|
||||
facility: cell("facility"),
|
||||
yard: cell("yard"),
|
||||
zone: cell("zone"),
|
||||
condition: cell("condition"),
|
||||
handoverNote: cell("handoverNote"),
|
||||
});
|
||||
}
|
||||
|
||||
numberCounts.forEach((count, num) => {
|
||||
if (count > 1) {
|
||||
errors.push(`Container number ${num} appears ${count} times — numbers must be unique.`);
|
||||
}
|
||||
});
|
||||
|
||||
if (rows.length === 0 && errors.length === 0) {
|
||||
errors.push("The sheet has no container rows below the header.");
|
||||
}
|
||||
|
||||
return errors.length > 0 ? { rows: [], errors } : { rows, errors: [] };
|
||||
}
|
||||
|
||||
/** Download the import template with one filled sample row. */
|
||||
export function downloadContainerReturnTemplate() {
|
||||
const headers = [
|
||||
"Container Number",
|
||||
"Container Size",
|
||||
"Company",
|
||||
"Returned By",
|
||||
"Returned Date",
|
||||
"Facility",
|
||||
"Yard",
|
||||
"Zone",
|
||||
"Condition",
|
||||
"Handover Note",
|
||||
];
|
||||
const sample = [
|
||||
"TEMU1234567",
|
||||
"40",
|
||||
"Acme Import PLC",
|
||||
"CUSTOMER",
|
||||
new Date().toISOString().split("T")[0],
|
||||
"Gelan Multipurpose port",
|
||||
"Yard A",
|
||||
"Zone 1",
|
||||
"Sound",
|
||||
"Backfilled from yard tally sheet",
|
||||
];
|
||||
|
||||
const sheet = XLSX.utils.aoa_to_sheet([headers, sample]);
|
||||
sheet["!cols"] = headers.map((h) => ({ wch: Math.max(h.length + 2, 18) }));
|
||||
const workbook = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(workbook, sheet, "Container Returns");
|
||||
XLSX.writeFile(workbook, "container-return-import-template.xlsx");
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import * as XLSX from "xlsx";
|
||||
|
||||
import { parseFullContainerExcel } from "./full-container-excel";
|
||||
|
||||
function sheetFile(aoa: unknown[][]): File {
|
||||
const wb = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet(aoa), "Sheet1");
|
||||
const buf = XLSX.write(wb, { type: "array", bookType: "xlsx" }) as ArrayBuffer;
|
||||
return new File([buf], "backlog.xlsx");
|
||||
}
|
||||
|
||||
const HEADERS = [
|
||||
"Container Number",
|
||||
"Container Size",
|
||||
"Company",
|
||||
"Arrival Date",
|
||||
"Facility",
|
||||
"Yard",
|
||||
"Zone",
|
||||
"Seal Number",
|
||||
"Weight (Tons)",
|
||||
"Notes",
|
||||
];
|
||||
|
||||
const row = (num: string, arrived: string, weight: string | number = 24.5) => [
|
||||
num,
|
||||
"40",
|
||||
"Acme PLC",
|
||||
arrived,
|
||||
"Gelan",
|
||||
"A",
|
||||
"1",
|
||||
"SL1",
|
||||
weight,
|
||||
"",
|
||||
];
|
||||
|
||||
describe("parseFullContainerExcel", () => {
|
||||
it("parses a backlog sheet with past arrival dates", async () => {
|
||||
const result = await parseFullContainerExcel(
|
||||
sheetFile([HEADERS, row("temu1234567", "2026-03-14"), row("MSCU7654321", "2025-11-02")]),
|
||||
);
|
||||
|
||||
expect(result.errors).toEqual([]);
|
||||
expect(result.rows).toHaveLength(2);
|
||||
expect(result.rows[0].containerNumber).toBe("TEMU1234567");
|
||||
expect(result.rows[0].arrivedAt?.startsWith("2026-03-14")).toBe(true);
|
||||
expect(result.rows[0].companyName).toBe("Acme PLC");
|
||||
});
|
||||
|
||||
it("rejects a future arrival date — a backlog box arrived in the past", async () => {
|
||||
const future = new Date();
|
||||
future.setFullYear(future.getFullYear() + 1);
|
||||
const result = await parseFullContainerExcel(
|
||||
sheetFile([HEADERS, row("TEMU1234567", future.toISOString().slice(0, 10))]),
|
||||
);
|
||||
|
||||
expect(result.rows).toEqual([]);
|
||||
expect(result.errors.some((e) => e.includes("in the future"))).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts an arrival date of today", async () => {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const result = await parseFullContainerExcel(sheetFile([HEADERS, row("TEMU1234567", today)]));
|
||||
expect(result.errors).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects an invalid container number and a negative weight", async () => {
|
||||
const result = await parseFullContainerExcel(
|
||||
sheetFile([HEADERS, row("NOPE", "2026-03-14"), row("MSCU7654321", "2026-03-14", -3)]),
|
||||
);
|
||||
|
||||
expect(result.rows).toEqual([]);
|
||||
expect(result.errors.some((e) => e.includes("ISO container number"))).toBe(true);
|
||||
expect(result.errors.some((e) => e.includes("0 or more"))).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects duplicate container numbers", async () => {
|
||||
const result = await parseFullContainerExcel(
|
||||
sheetFile([HEADERS, row("TEMU1234567", "2026-03-14"), row("temu1234567", "2026-03-15")]),
|
||||
);
|
||||
|
||||
expect(result.rows).toEqual([]);
|
||||
expect(result.errors.some((e) => e.includes("appears 2 times"))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,206 @@
|
||||
import * as XLSX from "xlsx";
|
||||
|
||||
// Excel import for loaded containers already sitting in a yard but never
|
||||
// entered in the system. One row per container. All-or-nothing — any bad row
|
||||
// rejects the file with row-numbered errors, so a half-registered yard cannot
|
||||
// happen.
|
||||
|
||||
const ISO_CONTAINER_NUMBER_REGEX = /^[A-Z]{4}\d{7}$/;
|
||||
|
||||
export interface ParsedFullContainerRow {
|
||||
containerNumber: string;
|
||||
containerSize: string;
|
||||
companyName: string;
|
||||
/** ISO instant; null when the cell was empty or unreadable. */
|
||||
arrivedAt: string | null;
|
||||
facility: string;
|
||||
yard: string;
|
||||
zone: string;
|
||||
sealNumber: string;
|
||||
weight: string;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
export interface FullContainerExcelResult {
|
||||
rows: ParsedFullContainerRow[];
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
type ColumnKey =
|
||||
| "containerNumber"
|
||||
| "containerSize"
|
||||
| "companyName"
|
||||
| "arrivedAt"
|
||||
| "facility"
|
||||
| "yard"
|
||||
| "zone"
|
||||
| "sealNumber"
|
||||
| "weight"
|
||||
| "notes";
|
||||
|
||||
/** Match a header cell to a known column, tolerant of casing/spacing/punctuation. */
|
||||
function headerKey(raw: string): ColumnKey | null {
|
||||
const h = raw.toLowerCase().replace(/[^a-z]/g, "");
|
||||
if (!h) return null;
|
||||
if (h.includes("seal")) return "sealNumber";
|
||||
if (h.includes("size") || h.includes("type")) return "containerSize";
|
||||
if (h.includes("company") || h.includes("owner") || h.includes("consignee")) return "companyName";
|
||||
if (h.includes("arriv") || h.includes("date")) return "arrivedAt";
|
||||
if (h.includes("facility") || h.includes("warehouse") || h.includes("terminal")) return "facility";
|
||||
if (h.includes("yard")) return "yard";
|
||||
if (h.includes("zone")) return "zone";
|
||||
if (h.includes("weight") || h.includes("vgm")) return "weight";
|
||||
if (h.includes("note") || h.includes("remark")) return "notes";
|
||||
if (h.includes("container") || h.includes("number")) return "containerNumber";
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Excel dates arrive either as a serial number (raw cells) or as text.
|
||||
* Returns an ISO instant, or null when the cell is empty/unparseable.
|
||||
*/
|
||||
function normalizeDate(raw: string): string | null {
|
||||
const v = raw.trim();
|
||||
if (!v) return null;
|
||||
if (/^\d{1,6}(\.\d+)?$/.test(v)) {
|
||||
const serial = Number(v);
|
||||
if (serial > 20000 && serial < 80000) {
|
||||
return new Date(Math.round((serial - 25569) * 86400000)).toISOString();
|
||||
}
|
||||
}
|
||||
const parsed = new Date(v);
|
||||
return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString();
|
||||
}
|
||||
|
||||
/** Parse an uploaded workbook into one row per loaded container. */
|
||||
export async function parseFullContainerExcel(file: File): Promise<FullContainerExcelResult> {
|
||||
let sheet: XLSX.WorkSheet | undefined;
|
||||
try {
|
||||
const workbook = XLSX.read(await file.arrayBuffer(), { type: "array" });
|
||||
sheet = workbook.Sheets[workbook.SheetNames[0]];
|
||||
} catch {
|
||||
return { rows: [], errors: ["Could not read the file — is it a valid Excel file?"] };
|
||||
}
|
||||
if (!sheet) return { rows: [], errors: ["The file has no sheets."] };
|
||||
|
||||
const grid = XLSX.utils.sheet_to_json<string[]>(sheet, { header: 1, raw: false, defval: "" });
|
||||
|
||||
let headerRowIdx = -1;
|
||||
let columns: Array<ColumnKey | null> = [];
|
||||
for (let i = 0; i < grid.length; i++) {
|
||||
const mapped = (grid[i] ?? []).map((c) => headerKey(String(c ?? "")));
|
||||
if (mapped.includes("containerNumber")) {
|
||||
headerRowIdx = i;
|
||||
columns = mapped;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (headerRowIdx < 0) {
|
||||
return {
|
||||
rows: [],
|
||||
errors: [
|
||||
'Could not find a "Container Number" column — download the template to see the expected format.',
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const rows: ParsedFullContainerRow[] = [];
|
||||
const errors: string[] = [];
|
||||
const numberCounts = new Map<string, number>();
|
||||
const startOfTomorrow = new Date();
|
||||
startOfTomorrow.setHours(24, 0, 0, 0);
|
||||
|
||||
for (let i = headerRowIdx + 1; i < grid.length; i++) {
|
||||
const cells = grid[i] ?? [];
|
||||
if (cells.every((c) => String(c ?? "").trim() === "")) continue;
|
||||
const rowNo = i + 1; // 1-based, as shown in Excel
|
||||
|
||||
const cell = (key: ColumnKey) => {
|
||||
const idx = columns.indexOf(key);
|
||||
return idx >= 0 ? String(cells[idx] ?? "").trim() : "";
|
||||
};
|
||||
|
||||
const containerNumber = cell("containerNumber").toUpperCase().replace(/\s/g, "");
|
||||
if (!ISO_CONTAINER_NUMBER_REGEX.test(containerNumber)) {
|
||||
errors.push(
|
||||
`Row ${rowNo}: "${cell("containerNumber") || "—"}" is not a valid ISO container number (e.g. TEMU1234567).`,
|
||||
);
|
||||
} else {
|
||||
numberCounts.set(containerNumber, (numberCounts.get(containerNumber) ?? 0) + 1);
|
||||
}
|
||||
|
||||
const arrivedRaw = cell("arrivedAt");
|
||||
const arrivedAt = normalizeDate(arrivedRaw);
|
||||
if (arrivedRaw && !arrivedAt) {
|
||||
errors.push(`Row ${rowNo}: arrival date "${arrivedRaw}" is not a date.`);
|
||||
}
|
||||
// The whole point of a backlog is that it arrived in the past.
|
||||
if (arrivedAt && new Date(arrivedAt).getTime() >= startOfTomorrow.getTime()) {
|
||||
errors.push(`Row ${rowNo}: arrival date "${arrivedRaw}" is in the future.`);
|
||||
}
|
||||
|
||||
const weightRaw = cell("weight");
|
||||
if (weightRaw && (Number.isNaN(Number(weightRaw)) || Number(weightRaw) < 0)) {
|
||||
errors.push(`Row ${rowNo}: weight "${weightRaw}" must be a number of 0 or more.`);
|
||||
}
|
||||
|
||||
rows.push({
|
||||
containerNumber,
|
||||
containerSize: cell("containerSize"),
|
||||
companyName: cell("companyName"),
|
||||
arrivedAt,
|
||||
facility: cell("facility"),
|
||||
yard: cell("yard"),
|
||||
zone: cell("zone"),
|
||||
sealNumber: cell("sealNumber"),
|
||||
weight: weightRaw,
|
||||
notes: cell("notes"),
|
||||
});
|
||||
}
|
||||
|
||||
numberCounts.forEach((count, num) => {
|
||||
if (count > 1) {
|
||||
errors.push(`Container number ${num} appears ${count} times — numbers must be unique.`);
|
||||
}
|
||||
});
|
||||
|
||||
if (rows.length === 0 && errors.length === 0) {
|
||||
errors.push("The sheet has no container rows below the header.");
|
||||
}
|
||||
|
||||
return errors.length > 0 ? { rows: [], errors } : { rows, errors: [] };
|
||||
}
|
||||
|
||||
/** Download the import template with one filled sample row. */
|
||||
export function downloadFullContainerTemplate() {
|
||||
const headers = [
|
||||
"Container Number",
|
||||
"Container Size",
|
||||
"Company",
|
||||
"Arrival Date",
|
||||
"Facility",
|
||||
"Yard",
|
||||
"Zone",
|
||||
"Seal Number",
|
||||
"Weight (Tons)",
|
||||
"Notes",
|
||||
];
|
||||
const sample = [
|
||||
"TEMU1234567",
|
||||
"40",
|
||||
"Acme Import PLC",
|
||||
"2026-03-14",
|
||||
"Gelan Multipurpose port",
|
||||
"Yard A",
|
||||
"Zone 1",
|
||||
"SL482910",
|
||||
24.5,
|
||||
"Backlog — registered from yard tally sheet",
|
||||
];
|
||||
|
||||
const sheet = XLSX.utils.aoa_to_sheet([headers, sample]);
|
||||
sheet["!cols"] = headers.map((h) => ({ wch: Math.max(h.length + 2, 18) }));
|
||||
const workbook = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(workbook, sheet, "Full Containers");
|
||||
XLSX.writeFile(workbook, "full-container-backlog-template.xlsx");
|
||||
}
|
||||
@@ -9,6 +9,9 @@ export { WarehouseInquiryTable } from './WarehouseInquiryTable';
|
||||
export { CreateWarehouseModal } from './CreateWarehouseModal';
|
||||
export { CreateYardModal } from './CreateYardModal';
|
||||
export { CreateZoneModal } from './CreateZoneModal';
|
||||
export { ZoneContentsModal, type ZoneRef } from './ZoneContentsModal';
|
||||
export { ZoneLayoutModal } from './ZoneLayoutModal';
|
||||
export { SlotPicker } from './SlotPicker';
|
||||
export { ReceiveInventoryModal, WarehouseFlowWorkbench } from './ReceiveInventoryModal';
|
||||
export { WarehouseInfoCard } from './WarehouseInfoCard';
|
||||
export { MoveInventoryModal } from './MoveInventoryModal';
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
WAREHOUSE_FREIGHT_TYPES,
|
||||
WAREHOUSE_TYPES,
|
||||
WAREHOUSE_YARD_TYPES,
|
||||
WAREHOUSE_ZONE_TYPES,
|
||||
@@ -73,6 +74,7 @@ export const yardsForBooking = (
|
||||
};
|
||||
|
||||
export const warehouseTypeOptions = toOptions(WAREHOUSE_TYPES);
|
||||
export const warehouseFreightTypeOptions = toOptions(WAREHOUSE_FREIGHT_TYPES);
|
||||
export const yardTypeOptions = toOptions(WAREHOUSE_YARD_TYPES);
|
||||
export const zoneTypeOptions = toOptions(WAREHOUSE_ZONE_TYPES);
|
||||
export const statusOptions = toOptions(WAREHOUSE_STATUSES);
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { fillableLevels } from './SlotPicker';
|
||||
import type { SlotEffectiveStatus, ZoneLayout } from '@/types/warehouse';
|
||||
|
||||
/**
|
||||
* The picker must offer only positions the API will accept, or the operator
|
||||
* gets a 400 for a level the form itself suggested.
|
||||
*/
|
||||
function layout(
|
||||
stacks: Array<{
|
||||
code: string;
|
||||
height?: number;
|
||||
isActive?: boolean;
|
||||
levels: SlotEffectiveStatus[];
|
||||
}>,
|
||||
): ZoneLayout {
|
||||
return {
|
||||
zoneId: 'zone-1',
|
||||
zoneCode: 'L1-O-A-ZA',
|
||||
zoneName: 'Zone A',
|
||||
summary: {
|
||||
configuredCapacity: 60,
|
||||
physicalSlotCount: 60,
|
||||
occupiedSlotCount: 0,
|
||||
reservedSlotCount: 0,
|
||||
blockedSlotCount: 0,
|
||||
inactiveSlotCount: 0,
|
||||
availableSlotCount: 60,
|
||||
inconsistent: false,
|
||||
},
|
||||
stacks: stacks.map((stack) => ({
|
||||
stackId: `id-${stack.code}`,
|
||||
code: stack.code,
|
||||
name: null,
|
||||
maxStackHeight: stack.height ?? 3,
|
||||
status: stack.isActive === false ? 'INACTIVE' : 'ACTIVE',
|
||||
isActive: stack.isActive !== false,
|
||||
// The API returns the highest level first — mirror that here.
|
||||
slots: stack.levels
|
||||
.map((effectiveStatus, index) => ({
|
||||
slotId: `${stack.code}-L${index + 1}`,
|
||||
level: index + 1,
|
||||
effectiveStatus,
|
||||
inventoryId: effectiveStatus === 'OCCUPIED' ? `inv-${stack.code}-${index + 1}` : null,
|
||||
containerNumber: null,
|
||||
}))
|
||||
.reverse(),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
describe('SlotPicker.fillableLevels', () => {
|
||||
it('offers the ground level of an empty stack', () => {
|
||||
const options = fillableLevels(layout([{ code: 'ZA-001', levels: ['AVAILABLE', 'AVAILABLE', 'AVAILABLE'] }]));
|
||||
expect(options).toEqual([{ value: 'ZA-001-L1', label: 'ZA-001 — level 1 (ground)' }]);
|
||||
});
|
||||
|
||||
it('offers only the level directly above the top container', () => {
|
||||
const options = fillableLevels(layout([{ code: 'ZA-001', levels: ['OCCUPIED', 'AVAILABLE', 'AVAILABLE'] }]));
|
||||
expect(options).toEqual([{ value: 'ZA-001-L2', label: 'ZA-001 — level 2 (on 1 container)' }]);
|
||||
});
|
||||
|
||||
it('never offers a level that would float over an empty one', () => {
|
||||
const options = fillableLevels(layout([{ code: 'ZA-001', levels: ['OCCUPIED', 'OCCUPIED', 'AVAILABLE'] }]));
|
||||
expect(options.map((o) => o.value)).toEqual(['ZA-001-L3']);
|
||||
});
|
||||
|
||||
it('drops a full stack', () => {
|
||||
expect(fillableLevels(layout([{ code: 'ZA-001', levels: ['OCCUPIED', 'OCCUPIED', 'OCCUPIED'] }]))).toEqual([]);
|
||||
});
|
||||
|
||||
it('drops a stack whose next level is blocked or reserved', () => {
|
||||
expect(fillableLevels(layout([{ code: 'ZA-001', levels: ['BLOCKED', 'AVAILABLE', 'AVAILABLE'] }]))).toEqual([]);
|
||||
expect(fillableLevels(layout([{ code: 'ZA-002', levels: ['RESERVED', 'AVAILABLE', 'AVAILABLE'] }]))).toEqual([]);
|
||||
});
|
||||
|
||||
it('drops an inactive stack', () => {
|
||||
expect(
|
||||
fillableLevels(layout([{ code: 'ZA-001', isActive: false, levels: ['AVAILABLE', 'AVAILABLE', 'AVAILABLE'] }])),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('lists one position per stack across the zone', () => {
|
||||
const options = fillableLevels(
|
||||
layout([
|
||||
{ code: 'ZA-001', levels: ['OCCUPIED', 'AVAILABLE', 'AVAILABLE'] },
|
||||
{ code: 'ZA-002', levels: ['AVAILABLE', 'AVAILABLE', 'AVAILABLE'] },
|
||||
]),
|
||||
);
|
||||
expect(options.map((o) => o.value)).toEqual(['ZA-001-L2', 'ZA-002-L1']);
|
||||
});
|
||||
|
||||
it('returns nothing before the layout has loaded', () => {
|
||||
expect(fillableLevels(undefined)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { customersService } from "@/services/customers.service";
|
||||
|
||||
/**
|
||||
* Registered customer companies, as Autocomplete options. The picker is an
|
||||
* Autocomplete rather than a Select on purpose: a company that is not on the
|
||||
* system yet is typed in, and only the name is kept.
|
||||
*/
|
||||
export function useCompanyOptions() {
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["companies-autocomplete"],
|
||||
queryFn: () => customersService.list({ page: 1, pageSize: 1000 }),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
const companies = data?.items ?? [];
|
||||
|
||||
// Company names are not unique — Mantine throws on duplicate option values,
|
||||
// so the list is deduped by the trimmed name.
|
||||
const names = [...new Set(companies.map((c) => c.name.trim()).filter(Boolean))];
|
||||
|
||||
return {
|
||||
loading: isLoading,
|
||||
names,
|
||||
/**
|
||||
* Name → company id, only when exactly one company carries that name. An
|
||||
* ambiguous name resolves to nothing rather than to an arbitrary company:
|
||||
* the container keeps the typed name and no wrong customer is attached.
|
||||
*/
|
||||
resolveId: (name: string): string | undefined => {
|
||||
const key = name.trim().toLowerCase();
|
||||
const matches = companies.filter((c) => c.name.trim().toLowerCase() === key);
|
||||
return matches.length === 1 ? matches[0].id : undefined;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -161,6 +161,8 @@ export const QUERY_KEYS = {
|
||||
["train-scheduling", "schedules", filters ?? {}] as const,
|
||||
scheduleById: (id: string) => ["train-scheduling", "schedule", id] as const,
|
||||
track: (id: string) => ["train-scheduling", "track", id] as const,
|
||||
marshallingStops: (id: string) =>
|
||||
["train-scheduling", "marshalling-stops", id] as const,
|
||||
batchBoard: (filters?: unknown) =>
|
||||
["train-scheduling", "batch-board", "list", filters ?? {}] as const,
|
||||
batchBoardDetail: (scheduleId: string) =>
|
||||
|
||||
@@ -537,6 +537,10 @@ export const URL_CONSTANTS = {
|
||||
`/train-scheduling/schedules/${id}/export/load-list/document`,
|
||||
INTERCITY_MARSHALLING_DOCUMENT: (id: string) =>
|
||||
`/train-scheduling/schedules/${id}/intercity/marshalling/document`,
|
||||
MARSHALLING_STOPS: (id: string) =>
|
||||
`/train-scheduling/schedules/${id}/marshalling/stops`,
|
||||
MARSHALLING_DOCUMENT_AT: (id: string, stopIndex: number) =>
|
||||
`/train-scheduling/schedules/${id}/marshalling/document/${stopIndex}`,
|
||||
CHECKPOINTS: (id: string) =>
|
||||
`/train-scheduling/schedules/${id}/checkpoints`,
|
||||
CHECKPOINT: (id: string, sequenceNo: number) =>
|
||||
@@ -648,6 +652,16 @@ export const URL_CONSTANTS = {
|
||||
WAREHOUSE_ZONES: {
|
||||
BASE: "/warehouse-zones",
|
||||
BY_ID: (id: string) => `/warehouse-zones/${id}`,
|
||||
LAYOUT: (id: string) => `/warehouse-zones/${id}/layout`,
|
||||
SLOT_SUMMARY: (id: string) => `/warehouse-zones/${id}/slot-summary`,
|
||||
},
|
||||
|
||||
WAREHOUSE_ZONE_STACKS: {
|
||||
BASE: "/warehouse-zone-stacks",
|
||||
BY_ZONE: (zoneId: string) => `/warehouse-zone-stacks?zoneId=${zoneId}`,
|
||||
BY_ID: (id: string) => `/warehouse-zone-stacks/${id}`,
|
||||
OCCUPANCY: (id: string) => `/warehouse-zone-stacks/${id}/occupancy`,
|
||||
SLOT: (slotId: string) => `/warehouse-zone-stacks/slots/${slotId}`,
|
||||
},
|
||||
|
||||
WAREHOUSE_INVENTORY: {
|
||||
@@ -662,6 +676,10 @@ export const URL_CONSTANTS = {
|
||||
LOAD: (id: string) => `/warehouse-inventory/${id}/load`,
|
||||
DISPATCH: (id: string) => `/warehouse-inventory/${id}/dispatch`,
|
||||
MOVE: (id: string) => `/warehouse-inventory/${id}/move`,
|
||||
FIND_SLOT: "/warehouse-inventory/placement/find-slot",
|
||||
ASSIGN_SLOT: (id: string) => `/warehouse-inventory/${id}/assign-slot`,
|
||||
RELEASE_SLOT: (id: string) => `/warehouse-inventory/${id}/release-slot`,
|
||||
ACCESSIBILITY: (id: string) => `/warehouse-inventory/${id}/accessibility`,
|
||||
RESERVE: "/warehouse-inventory/reserve",
|
||||
ARRIVAL_QUEUE: "/warehouse-inventory/arrival-queue",
|
||||
OPS_STATS: "/warehouse-inventory/ops-stats",
|
||||
@@ -703,6 +721,8 @@ export const URL_CONSTANTS = {
|
||||
? `/warehouse-inventory/eligible-bookings?direction=${direction}`
|
||||
: `/warehouse-inventory/eligible-bookings`,
|
||||
RECEIVE_BULK: "/warehouse-inventory/receive-bulk",
|
||||
REGISTER_BACKLOG: "/warehouse-inventory/register-backlog",
|
||||
REGISTER_BACKLOG_BULK: "/warehouse-inventory/register-backlog-bulk",
|
||||
LOAD_PASSED_EXPORT: "/warehouse-inventory/load-passed-export",
|
||||
BULK_MARK_INSPECTED: "/warehouse-inventory/bulk-mark-inspected",
|
||||
RECEIVED_EXPORT: "/warehouse-inventory/received-export",
|
||||
@@ -791,6 +811,7 @@ export const URL_CONSTANTS = {
|
||||
CUSTOMS_RELEASE_PERMITTED: (bookingId: string) =>
|
||||
`/import-operations/customs/${bookingId}/release-permitted`,
|
||||
EMPTY_CONTAINER_RETURNS: "/import-operations/empty-container-returns",
|
||||
EMPTY_CONTAINER_RETURNS_BULK: "/import-operations/empty-container-returns/bulk",
|
||||
EMPTY_CONTAINER_RETURN_STATUS: (id: string) =>
|
||||
`/import-operations/empty-container-returns/${id}/status`,
|
||||
EMPTY_CONTAINER_RETURNS_LOAD_ON_TRAIN:
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user