mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge branch 'alpha' of github.com:Tria-plc/edr-platform into alpha
This commit is contained in:
114
apps/edr-freight-api/py/prod-check-invoices-payments.sql
Normal file
114
apps/edr-freight-api/py/prod-check-invoices-payments.sql
Normal file
@@ -0,0 +1,114 @@
|
||||
-- ============================================================================
|
||||
-- Production DB drift check + fix for the batch/window flow.
|
||||
--
|
||||
-- WHY: BookingBatchService.reserve() calls billing.syncPayableDueDate, which
|
||||
-- queries freight.invoices.payments (a jsonb ledger added by migration
|
||||
-- 1828000000000-ExtendInvoicesForPartialPayment). If that column is MISSING on
|
||||
-- production (snapshot/restore drift — the migration can read as "applied" in
|
||||
-- freight.migrations while the DDL never took effect), every reserve() throws
|
||||
-- `column Invoice.payments does not exist`, the batch fill loop aborts mid-pass,
|
||||
-- and you see exactly:
|
||||
-- * only ONE booking gets a pay window (the loop dies after the first reserve
|
||||
-- whose invoice sync throws), and
|
||||
-- * reservations never expire cleanly (the settle path hits the same query).
|
||||
--
|
||||
-- Run STEP 1 first (read-only). If it shows the columns are MISSING, run STEP 2
|
||||
-- (idempotent, additive — safe to run even if partially applied).
|
||||
-- ============================================================================
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- STEP 1 — CHECK (read-only). Expect all 6 rows present; if any are missing,
|
||||
-- production has the drift and STEP 2 is required.
|
||||
-- ---------------------------------------------------------------------------
|
||||
SELECT column_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'freight'
|
||||
AND table_name = 'invoices'
|
||||
AND column_name IN (
|
||||
'payments', 'subtotal_amount', 'tax_amount',
|
||||
'paid_amount', 'balance_amount', 'paid_at'
|
||||
)
|
||||
ORDER BY column_name;
|
||||
-- Also confirm the enum has the partial-payment statuses:
|
||||
SELECT unnest(enum_range(NULL::freight.invoices_status_enum))::text AS status;
|
||||
-- Expect ISSUED and PARTIALLY_PAID to be present.
|
||||
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- STEP 2 — FIX (idempotent). Only run if STEP 1 showed missing columns.
|
||||
-- Mirrors migration 1828000000000 up(); all ADD COLUMN IF NOT EXISTS, so
|
||||
-- re-running is safe. Wrapped so the enum additions (which cannot run inside a
|
||||
-- transaction block with immediate use) are applied first, then the columns.
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
-- Enum values (no-op if they already exist).
|
||||
ALTER TYPE freight.invoices_status_enum ADD VALUE IF NOT EXISTS 'ISSUED' BEFORE 'PENDING';
|
||||
ALTER TYPE freight.invoices_status_enum ADD VALUE IF NOT EXISTS 'PARTIALLY_PAID' BEFORE 'PAID';
|
||||
|
||||
-- Money-tracking + payments ledger columns.
|
||||
ALTER TABLE freight.invoices
|
||||
ADD COLUMN IF NOT EXISTS subtotal_amount numeric(14, 2) NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS tax_amount numeric(14, 2) NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS paid_amount numeric(14, 2) NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS balance_amount numeric(14, 2) NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS paid_at timestamptz,
|
||||
ADD COLUMN IF NOT EXISTS payments jsonb NOT NULL DEFAULT '[]';
|
||||
|
||||
-- Backfill derived money fields for existing rows (only rows not already set).
|
||||
UPDATE freight.invoices
|
||||
SET subtotal_amount = total_amount,
|
||||
balance_amount = total_amount
|
||||
WHERE subtotal_amount = 0 AND balance_amount = 0;
|
||||
|
||||
UPDATE freight.invoices
|
||||
SET paid_amount = total_amount,
|
||||
balance_amount = 0,
|
||||
paid_at = COALESCE(paid_at, updated_at)
|
||||
WHERE status = 'PAID' AND paid_amount = 0;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- STEP 3 — RE-CHECK. Re-run STEP 1; all 6 columns + both enum values should
|
||||
-- now be present. After this, deploy the freight_feature/usermanagement branch
|
||||
-- and the batch will reserve ALL fitting bookings + expire non-payers + top up.
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
-- ============================================================================
|
||||
-- STEP 4 — BROADER DRIFT AUDIT (read-only). The same snapshot drift that hid
|
||||
-- invoices.payments can hide OTHER columns the batch flow selects. reserve()
|
||||
-- and settleReserved() load the FULL Booking entity, so ANY missing booking
|
||||
-- column throws mid-loop (e.g. we already hit
|
||||
-- `column Booking.consolidation_resume_status does not exist`). This lists every
|
||||
-- booking column the entity expects that is MISSING from production — expect
|
||||
-- ZERO rows. Any row = a drifted migration whose DDL must be re-applied.
|
||||
-- ============================================================================
|
||||
WITH expected(col) AS (
|
||||
SELECT unnest(ARRAY[
|
||||
'reference','customer_id','company_id','company_profile_id','is_government',
|
||||
'government_institution','train_id','status','contract_id','contract_route_id',
|
||||
'booking_type','contract_kind','created_by_role','created_by_user_id',
|
||||
'scheduled_date','estimated_shipment_date','expires_at','total_amount',
|
||||
'adjusted_total_amount','adjusted_by_staff_id','adjusted_at','adjustment_reason',
|
||||
'contract_validity_days','contract_valid_from','contract_valid_until',
|
||||
'payment_status','contract_type','service_type_id','customs_clearing_enabled',
|
||||
'customs_clearing_agent','equipment_return','origin_yard_id','destination_yard_id',
|
||||
'trade_direction','freight_type','cargo_type_id','cargo_free_text','shipping_line_id',
|
||||
'cargo_total_weight_vgm','is_hazardous','is_reefer','bulk_hazardous_quantity',
|
||||
'bulk_reefer_quantity','payment_currency','pnr_code','fully_executed_at',
|
||||
'pricing_breakdown','locked_at','priority_score','consolidation_partner_id',
|
||||
'consolidation_resume_status','wagons_required','scheduling_status',
|
||||
'hold_started_at','hold_expires_at','scheduled_at','train_schedule_id',
|
||||
'loaded_at','arrived_at','payment_deadline','selected_for_batch_at',
|
||||
'gl_station_yard_id','clearance_current_phase','duty_required',
|
||||
'vessel_departure_date','ro_amendment_requested_at','ro_hold_reason',
|
||||
'pre_clearance_finalized_at','gl_assigned_staff_id','gl_assigned_at'
|
||||
])
|
||||
)
|
||||
SELECT e.col AS missing_booking_column
|
||||
FROM expected e
|
||||
LEFT JOIN information_schema.columns c
|
||||
ON c.table_schema = 'freight' AND c.table_name = 'bookings' AND c.column_name = e.col
|
||||
WHERE c.column_name IS NULL
|
||||
ORDER BY e.col;
|
||||
-- If any rows come back, tell me which columns — I'll give you the exact
|
||||
-- migration(s) to re-apply (each is ADD COLUMN IF NOT EXISTS, idempotent).
|
||||
@@ -8,7 +8,10 @@ import { TypeOrmModule, TypeOrmModuleOptions } from "@nestjs/typeorm";
|
||||
import { ScheduleModule } from "@nestjs/schedule";
|
||||
import { EventEmitterModule } from "@nestjs/event-emitter";
|
||||
import { DataSource, DataSourceOptions } from "typeorm";
|
||||
import { ensurePostgresSchemas } from "./config/ensure-postgres-schemas";
|
||||
import {
|
||||
ensurePostgresSchemas,
|
||||
APPLICATION_SEARCH_PATH,
|
||||
} from "./config/ensure-postgres-schemas";
|
||||
import { IamModule, DataSeeder } from "@tria-plc/iamapi-common";
|
||||
import { SharedAuthModule } from "@tria-plc/api-common/modules/auth/shared-auth.module";
|
||||
|
||||
@@ -48,6 +51,7 @@ import {
|
||||
EDR_FREIGHT_PERMISSIONS,
|
||||
} from "./seed/edr-freight.seed";
|
||||
import { EdrOrgSeeder } from "./seed/edr-org.seeder";
|
||||
import { FreightPositionsSeeder } from "./seed/freight-positions.seeder";
|
||||
import { DemoUsersSeeder } from "./seed/demo-users.seeder";
|
||||
import { FreightStaffUsersSeeder } from "./seed/freight-staff-users.seeder";
|
||||
import { PaymentModule } from "./modules/payment/payment.module";
|
||||
@@ -108,7 +112,27 @@ import { LoggerMiddleware } from "./logger.middleware";
|
||||
}
|
||||
await ensurePostgresSchemas(options as DataSourceOptions);
|
||||
const dataSource = new DataSource(options as DataSourceOptions);
|
||||
return dataSource.initialize();
|
||||
await dataSource.initialize();
|
||||
|
||||
// The remote edr_dev DB sits behind a connection pooler/proxy that rejects
|
||||
// the Postgres `options` startup parameter (08P01). Instead of setting
|
||||
// search_path at connect time, apply it per physical connection: the pg
|
||||
// Pool emits `connect` for every new client (initial fill, pool growth,
|
||||
// reconnect), so every backend session gets the schema search order.
|
||||
const pool = (dataSource.driver as { master?: unknown }).master as
|
||||
| { on?: (event: string, cb: (client: unknown) => void) => void }
|
||||
| undefined;
|
||||
if (pool?.on) {
|
||||
pool.on("connect", (client) => {
|
||||
(client as { query: (sql: string) => Promise<unknown> })
|
||||
.query(`SET search_path TO ${APPLICATION_SEARCH_PATH}`)
|
||||
.catch(() => {
|
||||
/* connection will be validated on first real query */
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return dataSource;
|
||||
},
|
||||
}),
|
||||
SharedAuthModule,
|
||||
@@ -165,6 +189,7 @@ import { LoggerMiddleware } from "./logger.middleware";
|
||||
],
|
||||
providers: [
|
||||
EdrOrgSeeder,
|
||||
FreightPositionsSeeder,
|
||||
DemoUsersSeeder,
|
||||
FreightStaffUsersSeeder,
|
||||
PricingDataSeeder,
|
||||
@@ -188,6 +213,7 @@ export class AppModule implements OnApplicationBootstrap {
|
||||
constructor(
|
||||
private readonly seeder: DataSeeder,
|
||||
private readonly edrOrgSeeder: EdrOrgSeeder,
|
||||
private readonly freightPositionsSeeder: FreightPositionsSeeder,
|
||||
private readonly demoUsersSeeder: DemoUsersSeeder,
|
||||
private readonly freightStaffUsersSeeder: FreightStaffUsersSeeder,
|
||||
private readonly pricingDataSeeder: PricingDataSeeder,
|
||||
@@ -209,6 +235,7 @@ export class AppModule implements OnApplicationBootstrap {
|
||||
await this.freightPermissionKeyMigrationSeeder.run();
|
||||
await this.seeder.run();
|
||||
await this.edrOrgSeeder.run();
|
||||
await this.freightPositionsSeeder.run();
|
||||
await this.demoUsersSeeder.run();
|
||||
await this.freightStaffUsersSeeder.run();
|
||||
await this.pricingDataSeeder.run();
|
||||
|
||||
@@ -44,7 +44,6 @@ import {
|
||||
NotificationTemplate,
|
||||
} from "@tria-plc/iamapi-common";
|
||||
import { OrganizationSetting } from "@tria-plc/iamapi-common/entities/iam/organization-structure/organization-setting.entity";
|
||||
import { APPLICATION_SEARCH_PATH } from "./ensure-postgres-schemas";
|
||||
|
||||
const iamEntities = [
|
||||
DefaultPosition,
|
||||
@@ -105,14 +104,12 @@ export default registerAs("database", (): TypeOrmModuleOptions => {
|
||||
password: process.env.DB_PASSWORD ?? "",
|
||||
database: process.env.DB_NAME ?? "edr_freight",
|
||||
schema: "public",
|
||||
// The `-c search_path=...` startup option is rejected by transaction-pooling
|
||||
// poolers (e.g. PgBouncer: "unsupported startup parameter in options"). When
|
||||
// behind such a pooler set DB_PGBOUNCER=true and instead make the search_path
|
||||
// a role default: ALTER ROLE <user> IN DATABASE <db> SET search_path TO
|
||||
// public,iam,freight,audit;
|
||||
...(process.env.DB_PGBOUNCER === "true"
|
||||
? {}
|
||||
: { extra: { options: `-c search_path=${APPLICATION_SEARCH_PATH}` } }),
|
||||
// NOTE: do NOT pass `extra.options: '-c search_path=...'`. That sends the
|
||||
// Postgres startup `options` parameter, which connection poolers (PgBouncer /
|
||||
// proxies fronting the remote edr_dev DB) reject with
|
||||
// `08P01 unsupported startup parameter in options: search_path`.
|
||||
// The search_path is instead applied per-connection via a pool `connect`
|
||||
// handler in app.module.ts (see setPoolSearchPath).
|
||||
entities: [__dirname + "/../**/*.entity.{ts,js}", ...iamEntities],
|
||||
autoLoadEntities: true,
|
||||
migrations: [
|
||||
|
||||
@@ -12,23 +12,28 @@ export class AddEmailToOtpVerifications1900000000000
|
||||
name = "AddEmailToOtpVerifications1900000000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
// The table lives in the `freight` schema (the OtpVerification entity pins
|
||||
// schema: "freight"). An earlier version of this migration targeted
|
||||
// `public.otp_verifications`, which does not exist there — leaving the real
|
||||
// freight table without an `email` column and OTP send failing with
|
||||
// `column OtpVerification.email does not exist`. Target `freight` explicitly.
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE public.otp_verifications
|
||||
ALTER TABLE freight.otp_verifications
|
||||
ALTER COLUMN phone DROP NOT NULL
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE public.otp_verifications
|
||||
ALTER TABLE freight.otp_verifications
|
||||
ADD COLUMN IF NOT EXISTS email varchar UNIQUE
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE public.otp_verifications
|
||||
ALTER TABLE freight.otp_verifications
|
||||
DROP COLUMN IF EXISTS email
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE public.otp_verifications
|
||||
ALTER TABLE freight.otp_verifications
|
||||
ALTER COLUMN phone SET NOT NULL
|
||||
`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Segment corridor bookings: a booking may ride only part of a train's route
|
||||
* (its own origin→destination leg), so dispatch/arrival become per-booking
|
||||
* facts and wagon capacity is consumed per leg instead of per whole route.
|
||||
*
|
||||
* - bookings.loaded_at / arrived_at (+ by-user): operator-confirmed load at
|
||||
* the booking's origin yard and unload at its destination yard. Clearance
|
||||
* gates read arrived_at, not the train's actual_arrival_at.
|
||||
* - train_set_wagons.board_yard_id / alight_yard_id: the leg a consist slot
|
||||
* occupies; NULL/NULL = whole route (legacy). Non-overlapping legs coexist
|
||||
* without consuming each other's capacity.
|
||||
* - wagon_movements: auditable ledger of every physical wagon relocation
|
||||
* (loaded leg / empty reposition / manual correction) with the acting user.
|
||||
*/
|
||||
export class SegmentCorridorBookings1990000000000 implements MigrationInterface {
|
||||
name = 'SegmentCorridorBookings1990000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
ADD COLUMN IF NOT EXISTS loaded_at timestamptz,
|
||||
ADD COLUMN IF NOT EXISTS loaded_by_user_id uuid,
|
||||
ADD COLUMN IF NOT EXISTS arrived_at timestamptz,
|
||||
ADD COLUMN IF NOT EXISTS arrived_by_user_id uuid;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.train_set_wagons
|
||||
ADD COLUMN IF NOT EXISTS board_yard_id uuid REFERENCES freight.yards(id) ON DELETE SET NULL,
|
||||
ADD COLUMN IF NOT EXISTS alight_yard_id uuid REFERENCES freight.yards(id) ON DELETE SET NULL;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.wagon_movements (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
wagon_id uuid NOT NULL REFERENCES freight.wagons(id) ON DELETE CASCADE,
|
||||
from_yard_id uuid REFERENCES freight.yards(id),
|
||||
to_yard_id uuid NOT NULL REFERENCES freight.yards(id),
|
||||
train_schedule_id uuid REFERENCES freight.train_schedules(id) ON DELETE SET NULL,
|
||||
booking_id uuid REFERENCES freight.bookings(id) ON DELETE SET NULL,
|
||||
kind varchar(30) NOT NULL,
|
||||
moved_by_user_id uuid,
|
||||
occurred_at timestamptz NOT NULL DEFAULT now(),
|
||||
note text,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
deleted_at timestamptz
|
||||
);
|
||||
`);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX IF NOT EXISTS "IDX_wagon_movements_wagon_occurred" ON freight.wagon_movements (wagon_id, occurred_at);`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX IF NOT EXISTS "IDX_wagon_movements_schedule" ON freight.wagon_movements (train_schedule_id);`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.wagon_movements;`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.train_set_wagons
|
||||
DROP COLUMN IF EXISTS board_yard_id,
|
||||
DROP COLUMN IF EXISTS alight_yard_id;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
DROP COLUMN IF EXISTS loaded_at,
|
||||
DROP COLUMN IF EXISTS loaded_by_user_id,
|
||||
DROP COLUMN IF EXISTS arrived_at,
|
||||
DROP COLUMN IF EXISTS arrived_by_user_id;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Moves service-level priority off the service_types table and onto the
|
||||
* admin-managed priority_configs table as a new CUSTOMS rule type.
|
||||
*
|
||||
* - Drops service_types.priority_bonus_points (replaced by CUSTOMS configs).
|
||||
* - Widens priority_configs.type CHECK to allow 'CUSTOMS' (currency must be
|
||||
* null, same as WAGON).
|
||||
* - Seeds the two customs wagon-count tiers: 1–10 → 7 pts, 11–53 → 15 pts.
|
||||
* CUSTOMS rules apply only when the booking's service type includesCustoms.
|
||||
*/
|
||||
export class AddCustomsPriorityConfig2000000000000 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.service_types DROP COLUMN IF EXISTS priority_bonus_points;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.priority_configs
|
||||
DROP CONSTRAINT IF EXISTS priority_configs_type_check;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.priority_configs
|
||||
ADD CONSTRAINT priority_configs_type_check
|
||||
CHECK (type IN ('WAGON', 'CURRENCY', 'CUSTOMS'));
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.priority_configs
|
||||
DROP CONSTRAINT IF EXISTS chk_currency_for_type;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.priority_configs
|
||||
ADD CONSTRAINT chk_currency_for_type CHECK (
|
||||
(type = 'WAGON' AND currency IS NULL) OR
|
||||
(type = 'CURRENCY' AND currency IS NOT NULL) OR
|
||||
(type = 'CUSTOMS' AND currency IS NULL)
|
||||
);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
INSERT INTO freight.priority_configs
|
||||
(type, label, currency, min_wagon_count, max_wagon_count, score_points, is_active, display_order)
|
||||
VALUES
|
||||
('CUSTOMS', 'With customs 1–10 wagons', NULL, 1, 10, 7, true, 1),
|
||||
('CUSTOMS', 'With customs 11–53 wagons', NULL, 11, 53, 15, true, 2);
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
DELETE FROM freight.priority_configs WHERE type = 'CUSTOMS';
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.priority_configs
|
||||
DROP CONSTRAINT IF EXISTS chk_currency_for_type;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.priority_configs
|
||||
ADD CONSTRAINT chk_currency_for_type CHECK (
|
||||
(type = 'WAGON' AND currency IS NULL) OR
|
||||
(type = 'CURRENCY' AND currency IS NOT NULL)
|
||||
);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.priority_configs
|
||||
DROP CONSTRAINT IF EXISTS priority_configs_type_check;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.priority_configs
|
||||
ADD CONSTRAINT priority_configs_type_check
|
||||
CHECK (type IN ('WAGON', 'CURRENCY'));
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.service_types
|
||||
ADD COLUMN IF NOT EXISTS priority_bonus_points INT NOT NULL DEFAULT 0;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { MigrationInterface, QueryRunner, Table, TableIndex } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Staging table for customer profile edits that require backoffice review. An
|
||||
* already-approved company's settings edits are snapshotted here (Pending)
|
||||
* instead of being written to the live `companies` row; a reviewer approves
|
||||
* (snapshot applied) or rejects with a note (customer amends & resubmits).
|
||||
*/
|
||||
export class CreateCompanyChangeRequest2000000000000
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = 'CreateCompanyChangeRequest2000000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.createTable(
|
||||
new Table({
|
||||
schema: 'freight',
|
||||
name: 'company_change_request',
|
||||
columns: [
|
||||
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'gen_random_uuid()' },
|
||||
{ name: 'company_id', type: 'uuid' },
|
||||
{ name: 'snapshot', type: 'jsonb' },
|
||||
{ name: 'documents', type: 'jsonb', isNullable: true },
|
||||
{ name: 'status', type: 'varchar', length: '20', default: "'pending'" },
|
||||
{ name: 'note', type: 'text', isNullable: true },
|
||||
{ name: 'submitted_by', type: 'uuid', isNullable: true },
|
||||
{ name: 'submitted_at', type: 'timestamptz', isNullable: true },
|
||||
{ name: 'reviewed_by', type: 'uuid', isNullable: true },
|
||||
{ name: 'reviewed_at', type: 'timestamptz', isNullable: true },
|
||||
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
|
||||
],
|
||||
foreignKeys: [
|
||||
{
|
||||
columnNames: ['company_id'],
|
||||
referencedSchema: 'freight',
|
||||
referencedTableName: 'companies',
|
||||
referencedColumnNames: ['id'],
|
||||
onDelete: 'CASCADE',
|
||||
},
|
||||
],
|
||||
}),
|
||||
true,
|
||||
);
|
||||
|
||||
await queryRunner.createIndex(
|
||||
'freight.company_change_request',
|
||||
new TableIndex({ name: 'idx_company_change_request_company', columnNames: ['company_id'] }),
|
||||
);
|
||||
await queryRunner.createIndex(
|
||||
'freight.company_change_request',
|
||||
new TableIndex({ name: 'idx_company_change_request_status', columnNames: ['status'] }),
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.dropTable('freight.company_change_request', true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Adds reviewer note/id/timestamp to company_profiles so a rejected operational
|
||||
* role (new ProfileStatus 'rejected') can carry the reason back to the customer,
|
||||
* who can then amend and reapply.
|
||||
*/
|
||||
export class AddCompanyProfileReview2000000000001
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = 'AddCompanyProfileReview2000000000001';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.addColumns('freight.company_profiles', [
|
||||
new TableColumn({ name: 'review_note', type: 'text', isNullable: true }),
|
||||
new TableColumn({ name: 'reviewed_by', type: 'uuid', isNullable: true }),
|
||||
new TableColumn({ name: 'reviewed_at', type: 'timestamptz', isNullable: true }),
|
||||
]);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.dropColumns('freight.company_profiles', [
|
||||
'review_note',
|
||||
'reviewed_by',
|
||||
'reviewed_at',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Adds bookings.consolidation_resume_status: the status a booking parked in
|
||||
* PENDING_CONSOLIDATION returns to once it pairs with a wagon partner.
|
||||
*
|
||||
* Direct customer bookings leave it NULL (they resume to SUBMITTED, unchanged).
|
||||
* Contract-drawdown bookings (GL shipments) set it to the status
|
||||
* createUnderContract would otherwise have used (OPERATION_REQUEST_PENDING or
|
||||
* AWAITING_DOCUMENTS), so pairing resumes them into the contract-booking flow
|
||||
* instead of wrongly moving them to SUBMITTED.
|
||||
*/
|
||||
export class AddConsolidationResumeStatus2010000000000
|
||||
implements MigrationInterface
|
||||
{
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
ADD COLUMN IF NOT EXISTS consolidation_resume_status VARCHAR(40);
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
DROP COLUMN IF EXISTS consolidation_resume_status;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Repair: AddEmailToOtpVerifications1900000000000 originally altered
|
||||
* `public.otp_verifications`, but the OtpVerification entity pins
|
||||
* schema: "freight". On any DB where that migration already ran (and is recorded
|
||||
* as executed, so it won't run again), the real `freight.otp_verifications` table
|
||||
* never got the `email` column and `phone` was never made nullable — so OTP send
|
||||
* dies with `column OtpVerification.email does not exist`.
|
||||
*
|
||||
* This migration re-applies the change against the correct schema. Idempotent
|
||||
* (IF NOT EXISTS / no-op DROP NOT NULL), and guarded so it's a no-op when the
|
||||
* freight table is absent.
|
||||
*/
|
||||
export class RepairOtpEmailSchema2020000000000 implements MigrationInterface {
|
||||
name = "RepairOtpEmailSchema2020000000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
const exists = await queryRunner.hasTable("freight.otp_verifications");
|
||||
if (!exists) return;
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.otp_verifications
|
||||
ALTER COLUMN phone DROP NOT NULL
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.otp_verifications
|
||||
ADD COLUMN IF NOT EXISTS email varchar UNIQUE
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
const exists = await queryRunner.hasTable("freight.otp_verifications");
|
||||
if (!exists) return;
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.otp_verifications
|
||||
DROP COLUMN IF EXISTS email
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Weights are tonnes everywhere. Warehouse / yard / zone capacity was stored in
|
||||
* kg (e.g. 25000, 5000, 2500) — convert existing rows to tonnes (÷1000). Cargo
|
||||
* weight (warehouse_inventory.weight ← cargo_total_weight_vgm) is already tonnes
|
||||
* and is NOT touched; truck gross weight has no data yet. Runs exactly once
|
||||
* (tracked by TypeORM) — re-running would divide again.
|
||||
*/
|
||||
export class WarehouseCapacityKgToTons2020000000000 implements MigrationInterface {
|
||||
name = 'WarehouseCapacityKgToTons2020000000000';
|
||||
|
||||
private readonly tables = ['warehouses', 'warehouse_yards', 'warehouse_zones'];
|
||||
private readonly columns = ['capacity_weight', 'current_weight', 'max_weight'];
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
for (const table of this.tables) {
|
||||
for (const column of this.columns) {
|
||||
await queryRunner.query(
|
||||
`UPDATE freight.${table} SET ${column} = ${column} / 1000.0 WHERE ${column} IS NOT NULL`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
for (const table of this.tables) {
|
||||
for (const column of this.columns) {
|
||||
await queryRunner.query(
|
||||
`UPDATE freight.${table} SET ${column} = ${column} * 1000.0 WHERE ${column} IS NOT NULL`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Adds train_schedules.reference: a human-facing unique schedule number
|
||||
* S-YYYY-NNNNN (per-year sequence, like bookings' BK-YYYY-NNNNNN).
|
||||
*
|
||||
* - Adds the nullable column.
|
||||
* - Backfills existing rows: within each created-at year, numbers rows by
|
||||
* created_at ascending (oldest → S-<year>-00001). Deterministic order.
|
||||
* - Adds a partial unique index (NULLs allowed so a future insert can stage
|
||||
* the row before the app stamps its reference).
|
||||
*/
|
||||
export class AddTrainScheduleReference2030000000000
|
||||
implements MigrationInterface
|
||||
{
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.train_schedules
|
||||
ADD COLUMN IF NOT EXISTS reference VARCHAR(20);
|
||||
`);
|
||||
|
||||
// Backfill per-year, ordered by created_at (oldest = 00001). Uses the row's
|
||||
// own created-at year as the reference year so historical rows keep a
|
||||
// sensible number.
|
||||
await queryRunner.query(`
|
||||
WITH numbered AS (
|
||||
SELECT
|
||||
id,
|
||||
EXTRACT(YEAR FROM created_at)::int AS yr,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY EXTRACT(YEAR FROM created_at)
|
||||
ORDER BY created_at ASC, id ASC
|
||||
) AS seq
|
||||
FROM freight.train_schedules
|
||||
WHERE reference IS NULL
|
||||
)
|
||||
UPDATE freight.train_schedules ts
|
||||
SET reference = 'S-' || numbered.yr || '-' || LPAD(numbered.seq::text, 5, '0')
|
||||
FROM numbered
|
||||
WHERE ts.id = numbered.id;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS ux_train_schedules_reference
|
||||
ON freight.train_schedules (reference)
|
||||
WHERE reference IS NOT NULL;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
DROP INDEX IF EXISTS freight.ux_train_schedules_reference;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.train_schedules DROP COLUMN IF EXISTS reference;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Business-license files used to live inline as a jsonb array on
|
||||
* `company_profiles.business_license_files`. They now belong to the FileRecord
|
||||
* model (`freight.files`, resource `company_profiles`, code `business_license`)
|
||||
* so they get stable ids and stream through `GET /api/files/:id` — the same
|
||||
* proxy path regular documents use — instead of broken direct-MinIO URLs.
|
||||
*
|
||||
* This copies each existing inline entry into `freight.files` by reference
|
||||
* (keeping the stored object URL — no bytes are re-uploaded). The original jsonb
|
||||
* column is left intact for rollback safety.
|
||||
*/
|
||||
export class MigrateLicenseFilesToFileRecords2040000000000
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = "MigrateLicenseFilesToFileRecords2040000000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
INSERT INTO freight.files
|
||||
(id, resource_id, resource, code, name, url, size, mime_type, created_at, updated_at)
|
||||
SELECT
|
||||
gen_random_uuid(),
|
||||
cp.id,
|
||||
'company_profiles',
|
||||
'business_license',
|
||||
COALESCE(elem->>'name', 'license'),
|
||||
elem->>'url',
|
||||
COALESCE(NULLIF(elem->>'size', '')::int, 0),
|
||||
COALESCE(NULLIF(elem->>'mimeType', ''), 'application/octet-stream'),
|
||||
now(),
|
||||
now()
|
||||
FROM freight.company_profiles cp
|
||||
CROSS JOIN LATERAL jsonb_array_elements(cp.business_license_files) AS elem
|
||||
WHERE cp.business_license_files IS NOT NULL
|
||||
AND jsonb_typeof(cp.business_license_files) = 'array'
|
||||
AND elem->>'url' IS NOT NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM freight.files f
|
||||
WHERE f.resource_id = cp.id
|
||||
AND f.resource = 'company_profiles'
|
||||
AND f.code = 'business_license'
|
||||
AND f.url = elem->>'url'
|
||||
AND f.deleted_at IS NULL
|
||||
);
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
// Reverse the model migration by dropping the license FileRecords. The
|
||||
// original jsonb column was never cleared, so the data still exists there.
|
||||
await queryRunner.query(`
|
||||
DELETE FROM freight.files
|
||||
WHERE resource = 'company_profiles'
|
||||
AND code = 'business_license';
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,9 @@ function makeManager(savedLines: unknown[]) {
|
||||
}
|
||||
|
||||
function makeEvents() {
|
||||
return { emit: jest.fn() };
|
||||
// BillingService emits via both emit() and emitAsync() (the post-commit async
|
||||
// listener path) — the mock must provide both.
|
||||
return { emit: jest.fn(), emitAsync: jest.fn().mockResolvedValue([]) };
|
||||
}
|
||||
|
||||
function generateInput(overrides: Record<string, unknown> = {}) {
|
||||
@@ -162,7 +164,7 @@ describe("BillingService.markInvoiceAsPaid", () => {
|
||||
],
|
||||
},
|
||||
);
|
||||
expect(events.emit).toHaveBeenCalledWith(
|
||||
expect(events.emitAsync).toHaveBeenCalledWith(
|
||||
"booking.invoice.paid",
|
||||
expect.objectContaining({
|
||||
invoiceId: "inv-1",
|
||||
@@ -197,6 +199,7 @@ describe("BillingService.markInvoiceAsPaid", () => {
|
||||
|
||||
expect(mg.update).not.toHaveBeenCalled();
|
||||
expect(events.emit).not.toHaveBeenCalled();
|
||||
expect(events.emitAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -257,6 +260,7 @@ describe("BillingService.recordPayment", () => {
|
||||
}),
|
||||
);
|
||||
expect(events.emit).not.toHaveBeenCalled();
|
||||
expect(events.emitAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("settles to PAID, stamps paidAt, and emits ${source}.invoice.paid when the balance clears", async () => {
|
||||
@@ -268,7 +272,7 @@ describe("BillingService.recordPayment", () => {
|
||||
expect(updated.balanceAmount).toBe(0);
|
||||
expect(updated.paidAt).toBeInstanceOf(Date);
|
||||
expect(mg.update).toHaveBeenCalled();
|
||||
expect(events.emit).toHaveBeenCalledWith(
|
||||
expect(events.emitAsync).toHaveBeenCalledWith(
|
||||
"warehouse.invoice.paid",
|
||||
expect.objectContaining({ invoiceId: "inv-1", status: Freight.InvoiceStatus.Paid }),
|
||||
);
|
||||
@@ -296,3 +300,80 @@ describe("BillingService.recordPayment", () => {
|
||||
expect(mg.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Regression: `expirePayable` (batch settle path, called when a payment window
|
||||
* lapses) transitions the invoice to EXPIRED, which locks the row FOR UPDATE.
|
||||
* The bug passed `dataSource.manager` (the non-transactional default) into the
|
||||
* transition, so runTransition skipped opening a transaction and the lock threw
|
||||
* `An open transaction is required for pessimistic lock` — aborting the whole
|
||||
* settle pass (the "settle/reserve one booking at a time" symptom). The locked
|
||||
* write MUST run inside dataSource.transaction.
|
||||
*/
|
||||
describe("BillingService.expirePayable — locked write runs in a transaction", () => {
|
||||
const openInvoice = {
|
||||
id: "inv-1",
|
||||
status: Freight.InvoiceStatus.Pending,
|
||||
source: "booking",
|
||||
sourceId: "booking-1",
|
||||
};
|
||||
|
||||
const build = (lookupResult: Record<string, unknown> | null) => {
|
||||
const defaultManager = {
|
||||
findOne: jest.fn().mockResolvedValue(lookupResult),
|
||||
update: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const txManager = {
|
||||
findOne: jest.fn().mockResolvedValue(openInvoice),
|
||||
update: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const transaction = jest
|
||||
.fn()
|
||||
.mockImplementation((cb: (mg: unknown) => unknown) => cb(txManager));
|
||||
const events = makeEvents();
|
||||
const service = new BillingService(
|
||||
{ manager: defaultManager, transaction } as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
events as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
);
|
||||
return { service, defaultManager, txManager, transaction };
|
||||
};
|
||||
|
||||
it("opens a transaction and runs the pessimistic-lock read on the tx manager", async () => {
|
||||
const { service, transaction, txManager, defaultManager } = build(openInvoice);
|
||||
|
||||
await service.expirePayable(
|
||||
Freight.InvoiceSource.Booking,
|
||||
"booking-1",
|
||||
"prepaid",
|
||||
);
|
||||
|
||||
expect(transaction).toHaveBeenCalledTimes(1);
|
||||
expect(txManager.findOne).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ lock: { mode: "pessimistic_write" } }),
|
||||
);
|
||||
expect(txManager.update).toHaveBeenCalled();
|
||||
// The default manager only does the initial lock-free lookup, never a locked read.
|
||||
for (const call of defaultManager.findOne.mock.calls) {
|
||||
expect(call[1]).not.toHaveProperty("lock");
|
||||
}
|
||||
});
|
||||
|
||||
it("is a no-op (no transaction) when there is no open invoice", async () => {
|
||||
const { service, transaction } = build(null);
|
||||
|
||||
const result = await service.expirePayable(
|
||||
Freight.InvoiceSource.Booking,
|
||||
"booking-1",
|
||||
"prepaid",
|
||||
);
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -825,6 +825,13 @@ export class BillingService {
|
||||
type?: string,
|
||||
manager?: EntityManager,
|
||||
): Promise<Invoice | null> {
|
||||
// Lookup can use the default manager (no lock). But the pessimistic-lock write
|
||||
// inside `transition` NEEDS an open transaction: pass the caller's `manager`
|
||||
// through untouched (undefined when there is no caller txn) so `runTransition`
|
||||
// opens its own. Passing `this.dataSource.manager` here made `runTransition`
|
||||
// treat it as an already-open transaction and skip wrapping — the lock then
|
||||
// threw `An open transaction is required for pessimistic lock`, aborting the
|
||||
// whole settle pass (the "reservations settle/reserve one at a time" symptom).
|
||||
const mg = manager ?? this.dataSource.manager;
|
||||
const invoice = await mg.findOne(Invoice, {
|
||||
where: {
|
||||
@@ -842,7 +849,7 @@ export class BillingService {
|
||||
Freight.InvoiceStatus.Expired,
|
||||
"expired",
|
||||
{},
|
||||
mg,
|
||||
manager,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ export const BOOKING_LIST_TABS: ReadonlyArray<{
|
||||
{ key: 'payment', statuses: ['FULLY_EXECUTED'] },
|
||||
{
|
||||
key: 'operations',
|
||||
statuses: ['IN_TRANSIT', 'PAID'],
|
||||
statuses: ['IN_TRANSIT', 'ARRIVED', 'PAID'],
|
||||
},
|
||||
{ key: 'completed', statuses: ['COMPLETED'] },
|
||||
{ key: 'closed', statuses: ['REJECTED', 'CANCELLED'] },
|
||||
|
||||
@@ -102,6 +102,7 @@ export function computeNextStep(
|
||||
description: 'Mark shipment as in transit',
|
||||
};
|
||||
case 'IN_TRANSIT':
|
||||
case 'ARRIVED':
|
||||
return {
|
||||
action: 'COMPLETE',
|
||||
description: 'Mark shipment complete',
|
||||
|
||||
@@ -485,7 +485,7 @@ export class BookingTransitionService {
|
||||
|
||||
async complete(bookingId: string): Promise<Booking> {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
assertBookingStatus(booking, ["IN_TRANSIT"]);
|
||||
assertBookingStatus(booking, ["IN_TRANSIT", "ARRIVED"]);
|
||||
|
||||
const updated = await this.bookingsRepository.update(bookingId, {
|
||||
status: "COMPLETED",
|
||||
|
||||
@@ -350,6 +350,21 @@ export class BookingsController {
|
||||
return this.customerTruckService.addTruck(id, dto);
|
||||
}
|
||||
|
||||
@Patch(':id/customer-trucks/:assignmentId')
|
||||
@ApiOperation({ summary: 'Edit a not-yet-arrived customer truck (plate/driver/type + containers)' })
|
||||
async updateCustomerTruck(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param('assignmentId', ParseUUIDPipe) assignmentId: string,
|
||||
@Body() dto: AddCustomerTruckDto,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
const booking = await this.bookingsService.findById(id);
|
||||
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
|
||||
await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking);
|
||||
}
|
||||
return this.customerTruckService.updateTruck(id, assignmentId, dto);
|
||||
}
|
||||
|
||||
@Delete(':id/customer-trucks/:assignmentId')
|
||||
@ApiOperation({ summary: 'Remove a not-yet-arrived customer truck from a booking' })
|
||||
async removeCustomerTruck(
|
||||
|
||||
@@ -114,6 +114,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
|
||||
BookingPricingService,
|
||||
BookingInvoiceService,
|
||||
BookingLifecycleNotifierService,
|
||||
ConsolidationService,
|
||||
CustomerTruckService,
|
||||
ContainerReceiptService,
|
||||
],
|
||||
|
||||
@@ -272,26 +272,51 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Pair two bookings for consolidation. Both return to SUBMITTED so staff can
|
||||
* accept them into the approval chain; the link itself (consolidationPartnerId)
|
||||
* marks them as consolidated in the UI.
|
||||
* Pair two bookings for consolidation. Each returns to its own resume status —
|
||||
* SUBMITTED for a direct customer booking (so staff can accept it into the
|
||||
* approval chain) or the stored consolidationResumeStatus for a contract
|
||||
* drawdown (OPERATION_REQUEST_PENDING / AWAITING_DOCUMENTS). The link itself
|
||||
* (consolidationPartnerId) marks them as consolidated in the UI. The resume
|
||||
* status is cleared once used, so a later un-pair re-parks cleanly.
|
||||
*/
|
||||
async pairConsolidation(bookingId: string, partnerId: string): Promise<void> {
|
||||
const [booking, partner] = await Promise.all([
|
||||
this.repository.findOne({
|
||||
where: { id: bookingId },
|
||||
select: { id: true, consolidationResumeStatus: true },
|
||||
}),
|
||||
this.repository.findOne({
|
||||
where: { id: partnerId },
|
||||
select: { id: true, consolidationResumeStatus: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
await this.repository.update(bookingId, {
|
||||
consolidationPartnerId: partnerId,
|
||||
status: 'SUBMITTED',
|
||||
status: booking?.consolidationResumeStatus ?? 'SUBMITTED',
|
||||
consolidationResumeStatus: null,
|
||||
} as never);
|
||||
await this.repository.update(partnerId, {
|
||||
consolidationPartnerId: bookingId,
|
||||
status: 'SUBMITTED',
|
||||
status: partner?.consolidationResumeStatus ?? 'SUBMITTED',
|
||||
consolidationResumeStatus: null,
|
||||
} as never);
|
||||
}
|
||||
|
||||
/** Park a booking that needs consolidation but has no partner yet. */
|
||||
async parkForConsolidation(bookingId: string): Promise<void> {
|
||||
/**
|
||||
* Park a booking that needs consolidation but has no partner yet. The optional
|
||||
* resumeStatus is where the booking returns once it pairs — pass it for a
|
||||
* contract drawdown so pairing resumes the contract-booking flow rather than
|
||||
* the direct-booking SUBMITTED default.
|
||||
*/
|
||||
async parkForConsolidation(
|
||||
bookingId: string,
|
||||
resumeStatus?: string | null,
|
||||
): Promise<void> {
|
||||
await this.repository.update(bookingId, {
|
||||
consolidationPartnerId: null,
|
||||
status: 'PENDING_CONSOLIDATION',
|
||||
consolidationResumeStatus: resumeStatus ?? null,
|
||||
} as never);
|
||||
}
|
||||
|
||||
@@ -1019,6 +1044,81 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
.getMany();
|
||||
}
|
||||
|
||||
/**
|
||||
* Corridor day pool: ready, not-yet-allocated bookings for one EAT day whose
|
||||
* origin AND destination both lie on the day's corridor stop set — covers
|
||||
* full-route bookings and sub-corridor bookings (Dire→Djibouti on an
|
||||
* Addis→…→Djibouti train). The caller still verifies stop ORDER per train
|
||||
* via the corridor budget; this query only narrows the pool. Same status
|
||||
* rules and ordering as {@link findBatchPool}.
|
||||
*/
|
||||
findBatchPoolByCorridorDay(
|
||||
corridorYardIds: string[],
|
||||
day: string,
|
||||
): Promise<Booking[]> {
|
||||
if (corridorYardIds.length === 0) return Promise.resolve([]);
|
||||
return this.repository
|
||||
.createQueryBuilder('booking')
|
||||
.leftJoinAndSelect('booking.company', 'company')
|
||||
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
|
||||
.leftJoin(TrainScheduleBooking, 'sb', 'sb.booking_id = booking.id')
|
||||
.where('booking.origin_yard_id IN (:...corridorYardIds)', { corridorYardIds })
|
||||
.andWhere('booking.destination_yard_id IN (:...corridorYardIds)', {
|
||||
corridorYardIds,
|
||||
})
|
||||
.andWhere(
|
||||
`DATE(booking.scheduled_date AT TIME ZONE 'Africa/Addis_Ababa') = :day`,
|
||||
{ day },
|
||||
)
|
||||
.andWhere('sb.id IS NULL')
|
||||
.andWhere(
|
||||
`((booking.is_government = false AND booking.status = 'FULLY_EXECUTED')
|
||||
OR (booking.is_government = true AND booking.status IN ('APPROVED','PAID')))`,
|
||||
)
|
||||
.orderBy('booking.is_government', 'DESC')
|
||||
.addOrderBy('booking.priority_score', 'DESC')
|
||||
.addOrderBy('booking.fully_executed_at', 'ASC')
|
||||
.addOrderBy('booking.created_at', 'ASC')
|
||||
.getMany();
|
||||
}
|
||||
|
||||
/**
|
||||
* Commercial bookings on the day's corridor whose operation request was NOT
|
||||
* accepted by staff (still pending / changes / price-confirm) and are not yet
|
||||
* linked to a train. These never reached FULLY_EXECUTED, so they never enter the
|
||||
* batch pool; the window's doc-review end sweeps them to EXPIRED. Government
|
||||
* bookings are excluded (they don't go through the customer window).
|
||||
*/
|
||||
findUnacceptedForRouteDay(
|
||||
corridorYardIds: string[],
|
||||
day: string,
|
||||
): Promise<Booking[]> {
|
||||
if (corridorYardIds.length === 0) return Promise.resolve([]);
|
||||
return this.repository
|
||||
.createQueryBuilder('booking')
|
||||
.leftJoinAndSelect('booking.company', 'company')
|
||||
.leftJoin(TrainScheduleBooking, 'sb', 'sb.booking_id = booking.id')
|
||||
.where('booking.origin_yard_id IN (:...corridorYardIds)', { corridorYardIds })
|
||||
.andWhere('booking.destination_yard_id IN (:...corridorYardIds)', {
|
||||
corridorYardIds,
|
||||
})
|
||||
.andWhere(
|
||||
`DATE(booking.scheduled_date AT TIME ZONE 'Africa/Addis_Ababa') = :day`,
|
||||
{ day },
|
||||
)
|
||||
.andWhere('sb.id IS NULL')
|
||||
.andWhere('booking.is_government = false')
|
||||
.andWhere(
|
||||
`booking.status IN (
|
||||
'OPERATION_REQUESTED',
|
||||
'OPERATION_REQUEST_PENDING',
|
||||
'OPERATION_CHANGES_REQUESTED',
|
||||
'OPERATION_PRICE_PENDING_CONFIRM'
|
||||
)`,
|
||||
)
|
||||
.getMany();
|
||||
}
|
||||
|
||||
/** Every booking that targeted a schedule (any status) — for the batch monitoring board. */
|
||||
findAllBySchedule(scheduleId: string): Promise<Booking[]> {
|
||||
return this.repository
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
RuleEngineService,
|
||||
} from '../rule-engine/rule-engine.service';
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { DataSource, In } from 'typeorm';
|
||||
|
||||
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
|
||||
@@ -99,6 +100,7 @@ export class BookingsService {
|
||||
private readonly consolidationService: ConsolidationService,
|
||||
private readonly vehiclesService: VehiclesService,
|
||||
private readonly contractPdfService: ContractPdfService,
|
||||
private readonly events: EventEmitter2,
|
||||
) {}
|
||||
|
||||
async assignCustomerTruck(
|
||||
@@ -493,6 +495,14 @@ export class BookingsService {
|
||||
messages.push(
|
||||
this.consolidationService.describePaired(partner.reference, slots),
|
||||
);
|
||||
// Let deferred owners (e.g. contract drawdowns whose invoice/milestones
|
||||
// were held while the booking waited) finalize now that a whole wagon
|
||||
// exists. Fire-and-forget: a listener failure must not undo the pairing.
|
||||
this.events
|
||||
.emitAsync('booking.consolidation.paired', {
|
||||
bookingIds: [booking.id, partner.id],
|
||||
})
|
||||
.catch(() => undefined);
|
||||
return { booking: paired, messages };
|
||||
}
|
||||
|
||||
@@ -605,10 +615,15 @@ export class BookingsService {
|
||||
if (schedule.bookingWindowStatus !== 'OPEN') {
|
||||
throw new BadRequestException('Selected schedule is no longer accepting bookings');
|
||||
}
|
||||
if (
|
||||
schedule.originStationId !== dto.originYardId ||
|
||||
schedule.destinationStationId !== dto.destinationYardId
|
||||
) {
|
||||
// Corridor-aware: the booking's leg must lie on the schedule's route in
|
||||
// stop order — sub-corridor pins (Dire→Djibouti on an Addis→Djibouti
|
||||
// train) are valid.
|
||||
const stops = await this.trainSchedulingService.stopYardsForSchedule(
|
||||
schedule,
|
||||
);
|
||||
const fromIdx = stops.indexOf(dto.originYardId);
|
||||
const toIdx = stops.indexOf(dto.destinationYardId);
|
||||
if (fromIdx < 0 || toIdx < 0 || fromIdx >= toIdx) {
|
||||
throw new BadRequestException('Selected schedule is not on the booking route');
|
||||
}
|
||||
} else if (dto.scheduledDate) {
|
||||
@@ -1278,6 +1293,14 @@ export class BookingsService {
|
||||
): Promise<Freight.IBookingTracking> {
|
||||
const booking = await this.findById(bookingId);
|
||||
|
||||
const journey = {
|
||||
bookingStatus: booking.status ?? null,
|
||||
bookingOriginYardId: booking.originYardId ?? null,
|
||||
bookingDestinationYardId: booking.destinationYardId ?? null,
|
||||
loadedAt: booking.loadedAt ? new Date(booking.loadedAt).toISOString() : null,
|
||||
arrivedAt: booking.arrivedAt ? new Date(booking.arrivedAt).toISOString() : null,
|
||||
};
|
||||
|
||||
const empty: Freight.IBookingTracking = {
|
||||
bookingId: booking.id,
|
||||
bookingReference: booking.reference,
|
||||
@@ -1295,6 +1318,7 @@ export class BookingsService {
|
||||
actualArrivalAt: null,
|
||||
scheduledDepartureAt: null,
|
||||
scheduledArrivalAt: null,
|
||||
...journey,
|
||||
};
|
||||
|
||||
if (!booking.trainScheduleId) {
|
||||
@@ -1331,6 +1355,7 @@ export class BookingsService {
|
||||
actualArrivalAt: track.actualArrivalAt,
|
||||
scheduledDepartureAt: track.scheduledDepartureAt,
|
||||
scheduledArrivalAt: track.scheduledArrivalAt,
|
||||
...journey,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1399,6 +1424,18 @@ export class BookingsService {
|
||||
schedule?.status ?? null;
|
||||
}
|
||||
|
||||
// A generated-but-unsigned handover means the customer must approve delivery.
|
||||
// Surfaced so the portal shows "Approve delivery" as soon as the handover
|
||||
// exists, independent of the truck-arrival flag.
|
||||
const [pendingHandover] = await this.dataSource.query(
|
||||
`SELECT 1 FROM freight.booking_handovers
|
||||
WHERE booking_id = $1 AND signed_at IS NULL AND deleted_at IS NULL
|
||||
LIMIT 1`,
|
||||
[id],
|
||||
);
|
||||
(booking as Booking & { handoverAwaitingSignature?: boolean }).handoverAwaitingSignature =
|
||||
Boolean(pendingHandover);
|
||||
|
||||
return booking;
|
||||
}
|
||||
|
||||
@@ -1607,7 +1644,7 @@ export class BookingsService {
|
||||
if (!booking.isGovernment) {
|
||||
throw new BadRequestException('Only government bookings can be expedited');
|
||||
}
|
||||
const blocked = ['PAID', 'IN_TRANSIT', 'COMPLETED', 'CANCELLED', 'REJECTED'];
|
||||
const blocked = ['PAID', 'IN_TRANSIT', 'ARRIVED', 'COMPLETED', 'CANCELLED', 'REJECTED'];
|
||||
if (blocked.includes(booking.status)) {
|
||||
throw new BadRequestException(`Cannot expedite booking in status ${booking.status}`);
|
||||
}
|
||||
|
||||
@@ -42,21 +42,30 @@ export class CustomerTruckService {
|
||||
const booking = await this.loadBookingGuard(bookingId);
|
||||
this.assertSelfHaulPaid(booking);
|
||||
|
||||
const isExport = booking.tradeDirection === 'EXPORT';
|
||||
const requested = (dto.containerNumbers ?? []).map((n) => n.trim().toUpperCase());
|
||||
|
||||
// EXPORT: the truck delivers 1–2 known containers. IMPORT: containers are
|
||||
// not pre-specified — they are registered + weighed when the truck leaves.
|
||||
if (isExport) {
|
||||
if (requested.length < 1 || requested.length > 2) {
|
||||
throw new BadRequestException('An export truck must carry 1 or 2 of the booking containers');
|
||||
}
|
||||
} else if (requested.length > 2) {
|
||||
// Both import and export specify the containers each truck carries. Capacity
|
||||
// is size-based: a 40ft container fills the truck (max 1); two 20ft containers
|
||||
// fit (max 2), no size mixing. #trucks <= #containers follows naturally since
|
||||
// each container is assigned to exactly one truck.
|
||||
if (requested.length < 1) {
|
||||
throw new BadRequestException('Select at least one container for this truck');
|
||||
}
|
||||
if (requested.length > 2) {
|
||||
throw new BadRequestException('A truck carries at most 2 containers');
|
||||
}
|
||||
|
||||
if (requested.length) {
|
||||
const bookingNumbers = await this.bookingContainerNumbers(bookingId);
|
||||
// Never assign more trucks than the booking has containers.
|
||||
const existingTrucks = await this.dataSource
|
||||
.getRepository(CustomerTruckAssignment)
|
||||
.count({ where: { bookingId } });
|
||||
if (existingTrucks + 1 > bookingNumbers.length) {
|
||||
throw new BadRequestException(
|
||||
`Cannot assign more trucks than containers — this booking has ${bookingNumbers.length} container(s) and ${existingTrucks} truck(s) already assigned.`,
|
||||
);
|
||||
}
|
||||
for (const n of requested) {
|
||||
if (!bookingNumbers.includes(n)) {
|
||||
throw new BadRequestException(`Container ${n} is not one of this booking's containers`);
|
||||
@@ -68,6 +77,13 @@ export class CustomerTruckService {
|
||||
throw new ConflictException(`Container ${n} is already loaded onto another truck`);
|
||||
}
|
||||
}
|
||||
// Size cap: a 40ft container fills the truck.
|
||||
const sizes = await this.containerSizes(bookingId, requested);
|
||||
if (sizes.some((s) => s.includes('40')) && requested.length > 1) {
|
||||
throw new BadRequestException(
|
||||
'A 40ft container fills the truck — assign only 1 container to this truck',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
@@ -133,6 +149,76 @@ export class CustomerTruckService {
|
||||
return this.listTrucks(bookingId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Edit a truck assignment — plate/driver/type and the containers it carries.
|
||||
* Allowed only until the truck has arrived (same guard as removal). Container
|
||||
* rules mirror {@link addTruck}: 1–2 of the booking's containers, none already
|
||||
* on another truck, and a 40ft container fills the truck (max 1).
|
||||
*/
|
||||
async updateTruck(
|
||||
bookingId: string,
|
||||
assignmentId: string,
|
||||
dto: AddCustomerTruckDto,
|
||||
): Promise<CustomerTruckAssignment[]> {
|
||||
const booking = await this.loadBookingGuard(bookingId);
|
||||
this.assertSelfHaulPaid(booking);
|
||||
|
||||
const assignment = await this.assignments.findByIdWithContainers(assignmentId);
|
||||
if (!assignment || assignment.bookingId !== bookingId) {
|
||||
throw new NotFoundException('Truck assignment not found for this booking');
|
||||
}
|
||||
if (assignment.arrivedAt) {
|
||||
throw new ConflictException('Cannot edit a truck that has already arrived');
|
||||
}
|
||||
|
||||
const requested = (dto.containerNumbers ?? []).map((n) => n.trim().toUpperCase());
|
||||
if (requested.length < 1) {
|
||||
throw new BadRequestException('Select at least one container for this truck');
|
||||
}
|
||||
if (requested.length > 2) {
|
||||
throw new BadRequestException('A truck carries at most 2 containers');
|
||||
}
|
||||
const bookingNumbers = await this.bookingContainerNumbers(bookingId);
|
||||
for (const n of requested) {
|
||||
if (!bookingNumbers.includes(n)) {
|
||||
throw new BadRequestException(`Container ${n} is not one of this booking's containers`);
|
||||
}
|
||||
}
|
||||
// Exclude THIS truck's own containers so re-saving the same set is allowed.
|
||||
const assignedElsewhere = await this.assignedContainerNumbersExcept(bookingId, assignmentId);
|
||||
for (const n of requested) {
|
||||
if (assignedElsewhere.includes(n)) {
|
||||
throw new ConflictException(`Container ${n} is already loaded onto another truck`);
|
||||
}
|
||||
}
|
||||
const sizes = await this.containerSizes(bookingId, requested);
|
||||
if (sizes.some((s) => s.includes('40')) && requested.length > 1) {
|
||||
throw new BadRequestException(
|
||||
'A 40ft container fills the truck — assign only 1 container to this truck',
|
||||
);
|
||||
}
|
||||
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
await manager.getRepository(CustomerTruckAssignment).update(assignmentId, {
|
||||
plateNumber: dto.truckPlateNumber.trim().toUpperCase(),
|
||||
driverName: dto.driverName.trim(),
|
||||
truckType: dto.truckType.trim(),
|
||||
});
|
||||
await manager.getRepository(CustomerTruckContainer).softDelete({ assignmentId });
|
||||
await manager.getRepository(CustomerTruckContainer).save(
|
||||
requested.map((containerNumber) =>
|
||||
manager.getRepository(CustomerTruckContainer).create({
|
||||
assignmentId,
|
||||
bookingId,
|
||||
containerNumber,
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
return this.listTrucks(bookingId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register an IMPORT self-haul truck leaving the port: the containers it
|
||||
* actually loaded (replacing any provisional list) and its weighed gross.
|
||||
@@ -244,7 +330,7 @@ export class CustomerTruckService {
|
||||
}
|
||||
}
|
||||
|
||||
const grossKg = await this.vgmKgForContainers(bookingId, requested);
|
||||
const grossTons = await this.vgmTonsForContainers(bookingId, requested);
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
await manager.getRepository(CustomerTruckContainer).softDelete({ assignmentId });
|
||||
await manager.getRepository(CustomerTruckContainer).save(
|
||||
@@ -256,18 +342,19 @@ export class CustomerTruckService {
|
||||
}),
|
||||
),
|
||||
);
|
||||
// Provisional gross from the loaded containers' VGM — overridden by the
|
||||
// weighed gross on departure.
|
||||
// Provisional gross (tonnes) from the loaded containers' VGM — overridden
|
||||
// by the weighed gross on departure. (Column is *_kg but holds tonnes.)
|
||||
await manager.getRepository(CustomerTruckAssignment).update(assignmentId, {
|
||||
grossWeightKg: grossKg,
|
||||
grossWeightKg: grossTons,
|
||||
});
|
||||
});
|
||||
return this.listTrucks(bookingId);
|
||||
}
|
||||
|
||||
private async vgmKgForContainers(bookingId: string, numbers: string[]): Promise<number> {
|
||||
const [row]: Array<{ kg: string }> = await this.dataSource.query(
|
||||
`SELECT COALESCE(SUM(bcu.vgm_tons), 0) * 1000 AS kg
|
||||
/** Summed VGM (tonnes) of the given containers — provisional truck gross. */
|
||||
private async vgmTonsForContainers(bookingId: string, numbers: string[]): Promise<number> {
|
||||
const [row]: Array<{ tons: string }> = await this.dataSource.query(
|
||||
`SELECT COALESCE(SUM(bcu.vgm_tons), 0) AS tons
|
||||
FROM freight.booking_container_units bcu
|
||||
JOIN freight.booking_container bc
|
||||
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
|
||||
@@ -276,7 +363,7 @@ export class CustomerTruckService {
|
||||
AND bcu.deleted_at IS NULL`,
|
||||
[bookingId, numbers],
|
||||
);
|
||||
return Number(row?.kg ?? 0);
|
||||
return Number(row?.tons ?? 0);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -399,4 +486,20 @@ export class CustomerTruckService {
|
||||
);
|
||||
return rows.map((r) => r.containerNumber.trim().toUpperCase());
|
||||
}
|
||||
|
||||
/** Contract container sizes (e.g. "20ft" / "40ft") for the given container numbers. */
|
||||
private async containerSizes(bookingId: string, numbers: string[]): Promise<string[]> {
|
||||
if (!numbers.length) return [];
|
||||
const rows: Array<{ size: string | null }> = await this.dataSource.query(
|
||||
`SELECT bc.container_size AS "size"
|
||||
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 = $1
|
||||
AND UPPER(bcu.container_number) = ANY($2)
|
||||
AND bcu.deleted_at IS NULL`,
|
||||
[bookingId, numbers],
|
||||
);
|
||||
return rows.map((r) => (r.size ?? '').trim());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ export const BOOKING_STATUSES = [
|
||||
'PAYMENT_VERIFICATION_IN_PROGRESS',
|
||||
'PAID',
|
||||
'IN_TRANSIT',
|
||||
'ARRIVED',
|
||||
'COMPLETED',
|
||||
'REJECTED',
|
||||
'CANCELLED',
|
||||
@@ -430,6 +431,14 @@ export class Booking extends BaseEntity {
|
||||
@JoinColumn({ name: 'consolidation_partner_id' })
|
||||
consolidationPartner?: Booking | null;
|
||||
|
||||
// Status a booking parked in PENDING_CONSOLIDATION returns to once it pairs.
|
||||
// Null for direct customer bookings (they resume to SUBMITTED, the historical
|
||||
// default); contract-drawdown bookings set it to the status createUnderContract
|
||||
// would otherwise have used (OPERATION_REQUEST_PENDING / AWAITING_DOCUMENTS), so
|
||||
// pairing resumes them into the right flow instead of the direct-booking one.
|
||||
@Column({ name: 'consolidation_resume_status', type: 'varchar', length: 40, nullable: true })
|
||||
consolidationResumeStatus?: string | null;
|
||||
|
||||
@Column({ name: 'wagons_required', type: 'numeric', precision: 6, scale: 2, nullable: true })
|
||||
wagonsRequired?: number | null;
|
||||
|
||||
@@ -458,6 +467,24 @@ export class Booking extends BaseEntity {
|
||||
@Column({ name: 'train_schedule_id', type: 'uuid', nullable: true })
|
||||
trainScheduleId?: string | null;
|
||||
|
||||
// ── Per-booking journey (segment corridor bookings) ────────────────────────
|
||||
// A booking rides only its own origin→destination leg of the train's route,
|
||||
// so dispatch/arrival are per-booking facts, not train facts. Clearance gates
|
||||
// read arrivedAt (booking arrival), never the schedule's actualArrivalAt.
|
||||
/** Operator confirmed cargo loaded at the booking's origin yard (per-booking dispatch). */
|
||||
@Column({ name: 'loaded_at', type: 'timestamptz', nullable: true })
|
||||
loadedAt?: Date | null;
|
||||
|
||||
@Column({ name: 'loaded_by_user_id', type: 'uuid', nullable: true })
|
||||
loadedByUserId?: string | null;
|
||||
|
||||
/** Operator confirmed cargo unloaded at the booking's destination yard (per-booking arrival). */
|
||||
@Column({ name: 'arrived_at', type: 'timestamptz', nullable: true })
|
||||
arrivedAt?: Date | null;
|
||||
|
||||
@Column({ name: 'arrived_by_user_id', type: 'uuid', nullable: true })
|
||||
arrivedByUserId?: string | null;
|
||||
|
||||
/** End of the pay window once the booking is SELECTED_FOR_BATCH. */
|
||||
@Column({ name: 'payment_deadline', type: 'timestamptz', nullable: true })
|
||||
paymentDeadline?: Date | null;
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
HttpStatus,
|
||||
UseInterceptors,
|
||||
UploadedFiles,
|
||||
BadRequestException,
|
||||
} from "@nestjs/common";
|
||||
import { AnyFilesInterceptor } from "@nestjs/platform-express";
|
||||
import { ApiOperation, ApiTags, ApiConsumes } from "@nestjs/swagger";
|
||||
@@ -33,7 +34,7 @@ import {
|
||||
ResponseCompanyDto,
|
||||
ResponseCompanyProfileDto,
|
||||
} from "./dto/response-company.dto";
|
||||
import { BusinessLicenseFile } from "./entities/company-profile.entity";
|
||||
import { ProfileLicenseFileView } from "./entities/company-profile.entity";
|
||||
import { ResponseExternalProfileDto } from "./dto/response-external-profile.dto";
|
||||
import { CompanyInfoResponseDto } from "./dto/company-info-response.dto";
|
||||
import { UpdateProfileDto } from "./dto/update-profile.dto";
|
||||
@@ -43,6 +44,8 @@ import { ListCompaniesQueryDto } from "./dto/list-companies-query.dto";
|
||||
import { CompanyStatsResponseDto } from "./dto/company-stats-response.dto";
|
||||
import { OnboardingRequirementsResponseDto } from "./dto/onboarding-requirements-response.dto";
|
||||
import { UpdateCompanyProfileStatusDto } from "./dto/update-company-profile-status.dto";
|
||||
import { RejectChangeRequestDto } from "./dto/reject-change-request.dto";
|
||||
import { ChangeRequestResponseDto } from "./dto/change-request-response.dto";
|
||||
import { FetchETradeDto } from "./dto/fetch-etrade.dto";
|
||||
import { ETradeResponseDto } from "./dto/etrade-response.dto";
|
||||
|
||||
@@ -61,6 +64,25 @@ export class CompaniesController {
|
||||
private readonly filesService: FilesService,
|
||||
) { }
|
||||
|
||||
/**
|
||||
* License files are FileRecord-backed and previewed through `GET /api/files/:id`
|
||||
* (the client builds that URL from the returned `id`). Populate each profile
|
||||
* DTO's `licenseFiles` with its live/pending files in one batched lookup.
|
||||
*/
|
||||
private async populateLicenseFiles(
|
||||
companyId: string,
|
||||
profiles: { id: string; licenseFiles: ProfileLicenseFileView[] }[],
|
||||
): Promise<void> {
|
||||
if (profiles.length === 0) return;
|
||||
const byProfile = await this.companiesService.assembleLicenseFilesByProfile(
|
||||
companyId,
|
||||
profiles.map((p) => p.id),
|
||||
);
|
||||
for (const p of profiles) {
|
||||
p.licenseFiles = byProfile[p.id] ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
@Get("getInfo")
|
||||
@ApiOperation({ summary: "Get company info for the current user" })
|
||||
async getInfo(
|
||||
@@ -68,7 +90,10 @@ export class CompaniesController {
|
||||
): Promise<CompanyInfoResponseDto> {
|
||||
const { profile, company } =
|
||||
await this.companiesService.getCompanyInfoByUserId(user.id);
|
||||
return new CompanyInfoResponseDto(profile, company);
|
||||
const review = await this.companiesService.getOpenChangeRequestForCompany(
|
||||
company.id,
|
||||
);
|
||||
return new CompanyInfoResponseDto(profile, company, review);
|
||||
}
|
||||
|
||||
@Get("profile")
|
||||
@@ -78,7 +103,42 @@ export class CompaniesController {
|
||||
): Promise<ProfileResponseDto> {
|
||||
const { profile, company } =
|
||||
await this.companiesService.getCompanyInfoByUserId(user.id);
|
||||
return new ProfileResponseDto(profile, company);
|
||||
const review = await this.companiesService.getOpenChangeRequestForCompany(
|
||||
company.id,
|
||||
);
|
||||
const dto = new ProfileResponseDto(profile, company, review);
|
||||
await this.populateLicenseFiles(company.id, dto.companyProfiles);
|
||||
return dto;
|
||||
}
|
||||
|
||||
@Get("profile/change-request")
|
||||
@ApiOperation({
|
||||
summary: "Current user's open profile change request (pending/rejected)",
|
||||
})
|
||||
async getMyChangeRequest(
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
): Promise<ChangeRequestResponseDto | null> {
|
||||
const { company } =
|
||||
await this.companiesService.getCompanyInfoByUserId(user.id);
|
||||
const review = await this.companiesService.getOpenChangeRequestForCompany(
|
||||
company.id,
|
||||
);
|
||||
return review ? new ChangeRequestResponseDto(review) : null;
|
||||
}
|
||||
|
||||
@Post("company-profiles/:profileId/reapply")
|
||||
@ApiOperation({
|
||||
summary: "Resubmit a rejected operational role for approval (→ pending)",
|
||||
})
|
||||
async reapplyCompanyProfile(
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
@Param("profileId", ParseUUIDPipe) profileId: string,
|
||||
): Promise<ResponseCompanyProfileDto> {
|
||||
const profile = await this.companiesService.reapplyCompanyProfile(
|
||||
user.id,
|
||||
profileId,
|
||||
);
|
||||
return new ResponseCompanyProfileDto(profile);
|
||||
}
|
||||
|
||||
@Get("dashboard")
|
||||
@@ -177,28 +237,72 @@ export class CompaniesController {
|
||||
@ApiConsumes("multipart/form-data")
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Upload business-license document(s) for one of the current user's company profiles",
|
||||
"Add business-license document(s) to a profile. For an approved company " +
|
||||
"the upload is staged for backoffice review; during onboarding it goes live.",
|
||||
})
|
||||
async uploadProfileLicense(
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
@Param("profileId", ParseUUIDPipe) profileId: string,
|
||||
@UploadedFiles() files: Array<Express.Multer.File>,
|
||||
): Promise<BusinessLicenseFile[]> {
|
||||
return this.companiesService.uploadProfileLicenseFiles(
|
||||
): Promise<ProfileLicenseFileView[]> {
|
||||
return this.companiesService.addProfileLicenseFiles(
|
||||
user.id,
|
||||
profileId,
|
||||
files,
|
||||
);
|
||||
}
|
||||
|
||||
@Post("company-profiles/:profileId/license/:fileId/replace")
|
||||
@UseInterceptors(AnyFilesInterceptor())
|
||||
@ApiConsumes("multipart/form-data")
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Replace a business-license file with a newly uploaded one (staged for " +
|
||||
"review on an approved company).",
|
||||
})
|
||||
async replaceProfileLicense(
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
@Param("profileId", ParseUUIDPipe) profileId: string,
|
||||
@Param("fileId", ParseUUIDPipe) fileId: string,
|
||||
@UploadedFiles() files: Array<Express.Multer.File>,
|
||||
): Promise<ProfileLicenseFileView[]> {
|
||||
const file = files?.[0];
|
||||
if (!file) {
|
||||
throw new BadRequestException("A replacement file is required");
|
||||
}
|
||||
return this.companiesService.replaceProfileLicenseFile(
|
||||
user.id,
|
||||
profileId,
|
||||
fileId,
|
||||
file,
|
||||
);
|
||||
}
|
||||
|
||||
@Delete("company-profiles/:profileId/license/:fileId")
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Remove a business-license file (staged for review on an approved company).",
|
||||
})
|
||||
async removeProfileLicense(
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
@Param("profileId", ParseUUIDPipe) profileId: string,
|
||||
@Param("fileId", ParseUUIDPipe) fileId: string,
|
||||
): Promise<ProfileLicenseFileView[]> {
|
||||
return this.companiesService.removeProfileLicenseFile(
|
||||
user.id,
|
||||
profileId,
|
||||
fileId,
|
||||
);
|
||||
}
|
||||
|
||||
@Get("company-profiles/:profileId/license")
|
||||
@ApiOperation({
|
||||
summary: "List business-license documents for a company profile",
|
||||
summary: "List business-license documents (with review state) for a profile",
|
||||
})
|
||||
async listProfileLicense(
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
@Param("profileId", ParseUUIDPipe) profileId: string,
|
||||
): Promise<BusinessLicenseFile[]> {
|
||||
): Promise<ProfileLicenseFileView[]> {
|
||||
return this.companiesService.listProfileLicenseFiles(user.id, profileId);
|
||||
}
|
||||
|
||||
@@ -306,7 +410,9 @@ export class CompaniesController {
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
): Promise<ResponseCompanyDto> {
|
||||
const company = await this.companiesService.findCompanyById(id);
|
||||
return new ResponseCompanyDto(company);
|
||||
const dto = new ResponseCompanyDto(company);
|
||||
await this.populateLicenseFiles(company.id, dto.companyProfiles ?? []);
|
||||
return dto;
|
||||
}
|
||||
|
||||
@Patch(":id")
|
||||
@@ -354,26 +460,76 @@ export class CompaniesController {
|
||||
@ApiConsumes("multipart/form-data")
|
||||
@ApiOperation({ summary: "Upload documents for a company (onboarding)" })
|
||||
async uploadDocuments(
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
@Param("companyId", ParseUUIDPipe) companyId: string,
|
||||
@UploadedFiles() files: Array<Express.Multer.File>,
|
||||
) {
|
||||
return this.filesService.uploadMany(companyId, "companies", files);
|
||||
// Routed through the service so an approved company's uploads are staged for
|
||||
// review (and lock the customer), while onboarding uploads pass straight through.
|
||||
return this.companiesService.uploadCompanyDocuments(companyId, files, user.id);
|
||||
}
|
||||
|
||||
@Patch("company-profiles/:profileId/status")
|
||||
@FreightAdmin()
|
||||
@ApiOperation({ summary: "Update a company profile's approval status" })
|
||||
async updateCompanyProfileStatus(
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
@Param("profileId", ParseUUIDPipe) profileId: string,
|
||||
@Body() dto: UpdateCompanyProfileStatusDto,
|
||||
): Promise<ResponseCompanyProfileDto> {
|
||||
const profile = await this.companiesService.setCompanyProfileStatus(
|
||||
profileId,
|
||||
dto.status,
|
||||
dto.note,
|
||||
user.id,
|
||||
);
|
||||
return new ResponseCompanyProfileDto(profile);
|
||||
}
|
||||
|
||||
@Get(":companyId/change-requests")
|
||||
@FreightAdmin()
|
||||
@ApiOperation({ summary: "List a company's profile change requests" })
|
||||
async listChangeRequests(
|
||||
@Param("companyId", ParseUUIDPipe) companyId: string,
|
||||
): Promise<ChangeRequestResponseDto[]> {
|
||||
const requests = await this.companiesService.listChangeRequests(companyId);
|
||||
return requests.map((r) => new ChangeRequestResponseDto(r));
|
||||
}
|
||||
|
||||
@Post("change-requests/:id/approve")
|
||||
@FreightAdmin()
|
||||
@ApiOperation({
|
||||
summary: "Approve a pending profile change request (applies the changes)",
|
||||
})
|
||||
async approveChangeRequest(
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
): Promise<ChangeRequestResponseDto> {
|
||||
const request = await this.companiesService.approveChangeRequest(
|
||||
id,
|
||||
user.id,
|
||||
);
|
||||
return new ChangeRequestResponseDto(request);
|
||||
}
|
||||
|
||||
@Post("change-requests/:id/reject")
|
||||
@FreightAdmin()
|
||||
@ApiOperation({
|
||||
summary: "Reject a pending profile change request with a note",
|
||||
})
|
||||
async rejectChangeRequest(
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() dto: RejectChangeRequestDto,
|
||||
): Promise<ChangeRequestResponseDto> {
|
||||
const request = await this.companiesService.rejectChangeRequest(
|
||||
id,
|
||||
dto.note,
|
||||
user.id,
|
||||
);
|
||||
return new ChangeRequestResponseDto(request);
|
||||
}
|
||||
|
||||
@Post(":companyId/profiles")
|
||||
@FreightAdmin()
|
||||
@ApiOperation({ summary: "Add a profile (employee) to a company" })
|
||||
|
||||
@@ -12,13 +12,21 @@ import { CompanyDashboardRepository } from "./company-dashboard.repository";
|
||||
import { Company } from "./entities/company.entity";
|
||||
import { ExternalProfile } from "./entities/external-profile.entity";
|
||||
import { CompanyProfile } from "./entities/company-profile.entity";
|
||||
import { CompanyChangeRequest } from "./entities/company-change-request.entity";
|
||||
import { Booking } from "../bookings/entities/booking.entity";
|
||||
import { CompanyProfileRepository } from "./company-profile.repository";
|
||||
import { CompanyChangeRequestRepository } from "./company-change-request.repository";
|
||||
import { ETradeService } from "./services/etrade.service";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([Company, ExternalProfile, CompanyProfile, Booking]),
|
||||
TypeOrmModule.forFeature([
|
||||
Company,
|
||||
ExternalProfile,
|
||||
CompanyProfile,
|
||||
CompanyChangeRequest,
|
||||
Booking,
|
||||
]),
|
||||
HttpModule,
|
||||
FilesModule,
|
||||
FileUploadSettingsModule,
|
||||
@@ -30,6 +38,7 @@ import { ETradeService } from "./services/etrade.service";
|
||||
CompaniesRepository,
|
||||
ExternalProfileRepository,
|
||||
CompanyProfileRepository,
|
||||
CompanyChangeRequestRepository,
|
||||
CompanyDashboardRepository,
|
||||
ETradeService,
|
||||
],
|
||||
|
||||
@@ -7,13 +7,14 @@ import {
|
||||
} from "@nestjs/common";
|
||||
import { CompaniesRepository } from "./companies.repository";
|
||||
import { CompanyProfileRepository } from "./company-profile.repository";
|
||||
import { CompanyChangeRequestRepository } from "./company-change-request.repository";
|
||||
import { ExternalProfileRepository } from "./external-profile.repository";
|
||||
import {
|
||||
CompanyDashboardRepository,
|
||||
DashboardScope,
|
||||
} from "./company-dashboard.repository";
|
||||
import { MinioService } from "../minio/minio.service";
|
||||
import { FilesService } from "../files/files.service";
|
||||
import { FileRecord } from "../files/entities/file.entity";
|
||||
import { FileUploadSettingsService } from "../file-upload-settings/file-upload-settings.service";
|
||||
import { ETradeService } from "./services/etrade.service";
|
||||
import { OnboardingRequirementsResponseDto } from "./dto/onboarding-requirements-response.dto";
|
||||
@@ -37,9 +38,21 @@ import { ExternalProfile } from "./entities/external-profile.entity";
|
||||
import {
|
||||
BusinessLicenseFile,
|
||||
CompanyProfile,
|
||||
ProfileLicenseFileView,
|
||||
ProfileType,
|
||||
ProfileStatus,
|
||||
} from "./entities/company-profile.entity";
|
||||
import {
|
||||
ChangeRequestStatus,
|
||||
CompanyChangeRequest,
|
||||
LicenseChangeIntent,
|
||||
} from "./entities/company-change-request.entity";
|
||||
|
||||
/** FileRecord `resource` + `code` slots for business-license documents. */
|
||||
const LICENSE_RESOURCE = "company_profiles";
|
||||
const LICENSE_CODE = "business_license";
|
||||
/** Code for a license file staged in an open change request (not yet live). */
|
||||
const LICENSE_PENDING_CODE = "business_license_pending";
|
||||
|
||||
export interface UserIdentity {
|
||||
userId: string;
|
||||
@@ -54,9 +67,9 @@ export class CompaniesService {
|
||||
constructor(
|
||||
private readonly companiesRepo: CompaniesRepository,
|
||||
private readonly companyProfilesRepo: CompanyProfileRepository,
|
||||
private readonly changeRequestRepo: CompanyChangeRequestRepository,
|
||||
private readonly profilesRepo: ExternalProfileRepository,
|
||||
private readonly dashboardRepo: CompanyDashboardRepository,
|
||||
private readonly minioService: MinioService,
|
||||
private readonly filesService: FilesService,
|
||||
private readonly fileUploadSettingsService: FileUploadSettingsService,
|
||||
private readonly etradeService: ETradeService,
|
||||
@@ -334,28 +347,9 @@ export class CompaniesService {
|
||||
const company = await this.companiesRepo.findById(id);
|
||||
if (!company) throw new NotFoundException(`Company ${id} not found`);
|
||||
company.companyProfiles = await this.companyProfilesRepo.findByCompanyId(id);
|
||||
for (const profile of company.companyProfiles) {
|
||||
profile.businessLicenseFiles = await this.signLicenseFiles(
|
||||
profile.businessLicenseFiles,
|
||||
);
|
||||
}
|
||||
return company;
|
||||
}
|
||||
|
||||
/**
|
||||
* Business-license files are stored as raw, unsigned MinIO URLs (see
|
||||
* `BusinessLicenseFile` on `CompanyProfile`) — a browser can't fetch them
|
||||
* directly. Sign each one with a short-lived URL before it reaches a response.
|
||||
*/
|
||||
private async signLicenseFiles(
|
||||
files?: BusinessLicenseFile[] | null,
|
||||
): Promise<BusinessLicenseFile[]> {
|
||||
if (!files?.length) return [];
|
||||
return Promise.all(
|
||||
files.map(async (f) => ({ ...f, url: await this.filesService.signUrl(f.url) })),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate an explicitly-chosen company profile for a booking: it must belong
|
||||
* to the booking's company and be Active. Used for government bookings (staff
|
||||
@@ -574,12 +568,24 @@ export class CompaniesService {
|
||||
return updated;
|
||||
}
|
||||
|
||||
async updateProfile(
|
||||
userId: string,
|
||||
dto: UpdateProfileDto,
|
||||
): Promise<ProfileResponseDto> {
|
||||
const { profile, company } = await this.getCompanyInfoByUserId(userId);
|
||||
/** Keep only the keys that were actually provided (drop `undefined`). */
|
||||
private pickDefined(dto: Record<string, any>): Record<string, any> {
|
||||
const out: Record<string, any> = {};
|
||||
for (const [k, v] of Object.entries(dto)) {
|
||||
if (v !== undefined) out[k] = v;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate an UpdateProfileDto (or a staged change-request snapshot) into a
|
||||
* `Company` patch: scalar columns plus a merged `attributes` blob (contact/GM/
|
||||
* PoA live there). Pure — the caller runs the async TIN-uniqueness check.
|
||||
*/
|
||||
private mapProfileDtoToCompanyUpdates(
|
||||
company: Company,
|
||||
dto: Partial<UpdateProfileDto>,
|
||||
): Record<string, any> {
|
||||
const companyUpdates: Record<string, any> = {};
|
||||
const attrUpdates: Record<string, any> = { ...(company.attributes ?? {}) };
|
||||
|
||||
@@ -593,21 +599,10 @@ export class CompaniesService {
|
||||
companyUpdates.country = dto.companyLocation;
|
||||
if (dto.companyAddress !== undefined)
|
||||
companyUpdates.address = dto.companyAddress;
|
||||
if (dto.tin !== undefined && dto.tin !== company.tin) {
|
||||
// Reject a TIN already taken by a different company (the user's own draft
|
||||
// placeholder is fine to overwrite).
|
||||
const owner = await this.companiesRepo.findByTin(dto.tin);
|
||||
if (owner && owner.id !== company.id) {
|
||||
throw new ConflictException(
|
||||
`This TIN (${dto.tin}) is already registered to another company. Please check the number and try again.`,
|
||||
);
|
||||
}
|
||||
if (dto.tin !== undefined && dto.tin !== company.tin)
|
||||
companyUpdates.tin = dto.tin;
|
||||
}
|
||||
if (dto.vatNumber !== undefined) companyUpdates.vatNumber = dto.vatNumber;
|
||||
if (dto.fanNumber !== undefined) {
|
||||
companyUpdates.fanNumber = dto.fanNumber;
|
||||
}
|
||||
if (dto.fanNumber !== undefined) companyUpdates.fanNumber = dto.fanNumber;
|
||||
|
||||
if (dto.contactPersonName !== undefined)
|
||||
attrUpdates.contactPersonName = dto.contactPersonName;
|
||||
@@ -629,8 +624,7 @@ export class CompaniesService {
|
||||
if (dto.poaPhone !== undefined)
|
||||
attrUpdates.poaPhone = normalizeE164(dto.poaPhone);
|
||||
if (dto.poaEmail !== undefined) attrUpdates.poaEmail = dto.poaEmail;
|
||||
if (dto.poaLocation !== undefined)
|
||||
attrUpdates.poaLocation = dto.poaLocation;
|
||||
if (dto.poaLocation !== undefined) attrUpdates.poaLocation = dto.poaLocation;
|
||||
if (dto.poaAddress !== undefined) attrUpdates.poaAddress = dto.poaAddress;
|
||||
|
||||
if (dto.licenceNumber !== undefined)
|
||||
@@ -653,11 +647,219 @@ export class CompaniesService {
|
||||
companyUpdates.etradePhone = normalizeE164(dto.etradePhone);
|
||||
|
||||
companyUpdates.attributes = attrUpdates;
|
||||
return companyUpdates;
|
||||
}
|
||||
|
||||
const updated = await this.companiesRepo.update(company.id, companyUpdates);
|
||||
if (!updated)
|
||||
throw new NotFoundException(`Company ${company.id} not found`);
|
||||
return new ProfileResponseDto(profile, updated);
|
||||
/** Reject a TIN already registered to a *different* company. */
|
||||
private async assertTinAvailable(
|
||||
company: Company,
|
||||
tin: string | undefined,
|
||||
): Promise<void> {
|
||||
if (tin === undefined || tin === company.tin) return;
|
||||
const owner = await this.companiesRepo.findByTin(tin);
|
||||
if (owner && owner.id !== company.id) {
|
||||
throw new ConflictException(
|
||||
`This TIN (${tin}) is already registered to another company. Please check the number and try again.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** The company's open (pending or last-rejected) profile change request. */
|
||||
async getOpenChangeRequestForCompany(
|
||||
companyId: string,
|
||||
): Promise<CompanyChangeRequest | null> {
|
||||
return this.changeRequestRepo.findLatestOpenByCompanyId(companyId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the current user's profile.
|
||||
*
|
||||
* - Company not yet approved (onboarding) → write straight to the Company row,
|
||||
* as before. The company/role pending→approve gate already covers first-run.
|
||||
* - Company already `active` → do NOT touch the live Company. Stage the edit in
|
||||
* a pending change request (merging into any open one) so a backoffice
|
||||
* reviewer can approve (apply) or reject (with a note). This locks the
|
||||
* customer until the review resolves.
|
||||
*/
|
||||
async updateProfile(
|
||||
userId: string,
|
||||
dto: UpdateProfileDto,
|
||||
): Promise<ProfileResponseDto> {
|
||||
const { profile, company } = await this.getCompanyInfoByUserId(userId);
|
||||
|
||||
if (company.status !== CompanyStatus.Active) {
|
||||
await this.assertTinAvailable(company, dto.tin);
|
||||
const companyUpdates = this.mapProfileDtoToCompanyUpdates(company, dto);
|
||||
const updated = await this.companiesRepo.update(
|
||||
company.id,
|
||||
companyUpdates,
|
||||
);
|
||||
if (!updated)
|
||||
throw new NotFoundException(`Company ${company.id} not found`);
|
||||
return new ProfileResponseDto(profile, updated);
|
||||
}
|
||||
|
||||
// Approved company: stage the change for review, leaving the live row intact.
|
||||
await this.assertTinAvailable(company, dto.tin);
|
||||
const fields = this.pickDefined(dto);
|
||||
|
||||
const existing = await this.changeRequestRepo.findPendingByCompanyId(
|
||||
company.id,
|
||||
);
|
||||
const now = new Date();
|
||||
let request: CompanyChangeRequest;
|
||||
if (existing) {
|
||||
request =
|
||||
(await this.changeRequestRepo.update(existing.id, {
|
||||
snapshot: { ...(existing.snapshot ?? {}), ...fields },
|
||||
submittedBy: userId,
|
||||
submittedAt: now,
|
||||
note: null,
|
||||
})) ?? existing;
|
||||
} else {
|
||||
request = await this.changeRequestRepo.create({
|
||||
companyId: company.id,
|
||||
snapshot: fields,
|
||||
status: ChangeRequestStatus.Pending,
|
||||
submittedBy: userId,
|
||||
submittedAt: now,
|
||||
});
|
||||
}
|
||||
|
||||
// Live company is unchanged; surface the pending state for the settings page.
|
||||
return new ProfileResponseDto(profile, company, request);
|
||||
}
|
||||
|
||||
/** List a company's change requests, newest first (backoffice review). */
|
||||
async listChangeRequests(
|
||||
companyId: string,
|
||||
): Promise<CompanyChangeRequest[]> {
|
||||
await this.findCompanyById(companyId);
|
||||
return this.changeRequestRepo.findByCompanyId(companyId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Approve a pending change request: apply its snapshot to the live Company and
|
||||
* mark the request approved. Any staged documents are already attached to the
|
||||
* company, so nothing else needs promoting.
|
||||
*/
|
||||
async approveChangeRequest(
|
||||
id: string,
|
||||
reviewerId?: string,
|
||||
): Promise<CompanyChangeRequest> {
|
||||
const request = await this.changeRequestRepo.findById(id);
|
||||
if (!request)
|
||||
throw new NotFoundException(`Change request ${id} not found`);
|
||||
if (request.status !== ChangeRequestStatus.Pending) {
|
||||
throw new BadRequestException(
|
||||
`Change request ${id} is already ${request.status}`,
|
||||
);
|
||||
}
|
||||
|
||||
const company = await this.companiesRepo.findById(request.companyId);
|
||||
if (!company)
|
||||
throw new NotFoundException(`Company ${request.companyId} not found`);
|
||||
|
||||
const snapshot = (request.snapshot ?? {}) as Partial<UpdateProfileDto>;
|
||||
await this.assertTinAvailable(company, snapshot.tin);
|
||||
const companyUpdates = this.mapProfileDtoToCompanyUpdates(company, snapshot);
|
||||
await this.companiesRepo.update(company.id, companyUpdates);
|
||||
await this.applyLicenseChanges(request);
|
||||
|
||||
return (
|
||||
(await this.changeRequestRepo.update(id, {
|
||||
status: ChangeRequestStatus.Approved,
|
||||
reviewedBy: reviewerId ?? null,
|
||||
reviewedAt: new Date(),
|
||||
note: null,
|
||||
})) ?? request
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload company documents. For an approved company this also opens/updates a
|
||||
* pending change request (recording the uploaded file ids) so the upload is
|
||||
* reviewed and the customer is locked until it clears — consistent with the
|
||||
* field-edit review. During onboarding (company not yet active) it's a plain
|
||||
* upload with no review.
|
||||
*/
|
||||
async uploadCompanyDocuments(
|
||||
companyId: string,
|
||||
files: Express.Multer.File[],
|
||||
submittedBy?: string,
|
||||
): Promise<FileRecord[]> {
|
||||
const company = await this.findCompanyById(companyId);
|
||||
const uploaded = await this.filesService.uploadMany(
|
||||
companyId,
|
||||
"companies",
|
||||
files,
|
||||
);
|
||||
if (company.status === CompanyStatus.Active) {
|
||||
await this.stageDocumentChange(
|
||||
company.id,
|
||||
uploaded.map((f) => f.id),
|
||||
submittedBy,
|
||||
);
|
||||
}
|
||||
return uploaded;
|
||||
}
|
||||
|
||||
/** Open or append a pending change request recording staged document uploads. */
|
||||
private async stageDocumentChange(
|
||||
companyId: string,
|
||||
fileIds: string[],
|
||||
submittedBy?: string,
|
||||
): Promise<void> {
|
||||
if (fileIds.length === 0) return;
|
||||
const now = new Date();
|
||||
const existing =
|
||||
await this.changeRequestRepo.findPendingByCompanyId(companyId);
|
||||
if (existing) {
|
||||
const prev = existing.documents?.documentFileIds ?? [];
|
||||
await this.changeRequestRepo.update(existing.id, {
|
||||
documents: { documentFileIds: [...prev, ...fileIds] },
|
||||
submittedBy: submittedBy ?? existing.submittedBy ?? null,
|
||||
submittedAt: now,
|
||||
note: null,
|
||||
});
|
||||
} else {
|
||||
await this.changeRequestRepo.create({
|
||||
companyId,
|
||||
snapshot: {},
|
||||
documents: { documentFileIds: fileIds },
|
||||
status: ChangeRequestStatus.Pending,
|
||||
submittedBy: submittedBy ?? null,
|
||||
submittedAt: now,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Reject a pending change request with a note (customer amends & resubmits). */
|
||||
async rejectChangeRequest(
|
||||
id: string,
|
||||
note: string,
|
||||
reviewerId?: string,
|
||||
): Promise<CompanyChangeRequest> {
|
||||
const request = await this.changeRequestRepo.findById(id);
|
||||
if (!request)
|
||||
throw new NotFoundException(`Change request ${id} not found`);
|
||||
if (request.status !== ChangeRequestStatus.Pending) {
|
||||
throw new BadRequestException(
|
||||
`Change request ${id} is already ${request.status}`,
|
||||
);
|
||||
}
|
||||
await this.discardLicenseChanges(request);
|
||||
return (
|
||||
(await this.changeRequestRepo.update(id, {
|
||||
status: ChangeRequestStatus.Rejected,
|
||||
// Staged license uploads were just discarded; drop their intents so an
|
||||
// amended resubmit never re-references deleted files.
|
||||
documents: { ...request.documents, licenseChanges: [] },
|
||||
note,
|
||||
reviewedBy: reviewerId ?? null,
|
||||
reviewedAt: new Date(),
|
||||
})) ?? request
|
||||
);
|
||||
}
|
||||
|
||||
async deleteCompany(id: string): Promise<void> {
|
||||
@@ -713,6 +915,8 @@ export class CompaniesService {
|
||||
async setCompanyProfileStatus(
|
||||
profileId: string,
|
||||
status: ProfileStatus,
|
||||
note?: string,
|
||||
reviewerId?: string,
|
||||
): Promise<CompanyProfile> {
|
||||
const existing = await this.companyProfilesRepo.findById(profileId);
|
||||
if (!existing)
|
||||
@@ -727,6 +931,18 @@ export class CompaniesService {
|
||||
);
|
||||
}
|
||||
|
||||
// Track the review outcome. Rejection keeps the note so the customer knows
|
||||
// why; approval clears it. Any decision stamps the reviewer + time.
|
||||
if (status === ProfileStatus.Rejected) {
|
||||
patch.reviewNote = note ?? null;
|
||||
} else if (status === ProfileStatus.Active) {
|
||||
patch.reviewNote = null;
|
||||
}
|
||||
if (status !== ProfileStatus.Pending) {
|
||||
patch.reviewedBy = reviewerId ?? null;
|
||||
patch.reviewedAt = new Date();
|
||||
}
|
||||
|
||||
const updated = await this.companyProfilesRepo.update(profileId, patch);
|
||||
if (!updated)
|
||||
throw new NotFoundException(`Company profile ${profileId} not found`);
|
||||
@@ -744,6 +960,41 @@ export class CompaniesService {
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Customer reapplies for a rejected operational role (after fixing whatever the
|
||||
* reviewer flagged, e.g. re-uploading a license): flip it back to Pending and
|
||||
* clear the rejection note so it re-enters the approval queue.
|
||||
*/
|
||||
async reapplyCompanyProfile(
|
||||
userId: string,
|
||||
profileId: string,
|
||||
): Promise<CompanyProfile> {
|
||||
const profile = await this.profilesRepo.findByUserId(userId);
|
||||
if (!profile)
|
||||
throw new NotFoundException(`Profile for user ${userId} not found`);
|
||||
const companyId = profile.company?.id ?? profile.companyId;
|
||||
|
||||
const target = await this.companyProfilesRepo.findById(profileId);
|
||||
if (!target || target.companyId !== companyId) {
|
||||
throw new NotFoundException(`Company profile ${profileId} not found`);
|
||||
}
|
||||
if (target.status !== ProfileStatus.Rejected) {
|
||||
throw new BadRequestException(
|
||||
"Only a rejected role can be resubmitted for approval",
|
||||
);
|
||||
}
|
||||
|
||||
const updated = await this.companyProfilesRepo.update(profileId, {
|
||||
status: ProfileStatus.Pending,
|
||||
reviewNote: null,
|
||||
reviewedBy: null,
|
||||
reviewedAt: null,
|
||||
});
|
||||
if (!updated)
|
||||
throw new NotFoundException(`Company profile ${profileId} not found`);
|
||||
return updated;
|
||||
}
|
||||
|
||||
async createCompanyProfile(
|
||||
companyId: string,
|
||||
profileType?: ProfileType,
|
||||
@@ -833,12 +1084,12 @@ export class CompaniesService {
|
||||
);
|
||||
if (existing) continue;
|
||||
|
||||
const reference = await this.companyProfilesRepo.generateReference(type);
|
||||
// Self-service role adds start Pending and carry no reference — a reference
|
||||
// is minted only when a backoffice reviewer approves the role.
|
||||
await this.companyProfilesRepo.create({
|
||||
companyId,
|
||||
type,
|
||||
reference,
|
||||
status: ProfileStatus.Active,
|
||||
status: ProfileStatus.Pending,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -870,13 +1121,14 @@ export class CompaniesService {
|
||||
|
||||
let created = await this.companyProfilesRepo.findByType(companyId, type);
|
||||
if (!created) {
|
||||
const reference = await this.companyProfilesRepo.generateReference(type);
|
||||
// New self-service roles start Pending (awaiting backoffice approval) and
|
||||
// carry no reference until approved. The customer can select this mode but
|
||||
// can't book under it until it's cleared.
|
||||
created = await this.companyProfilesRepo.create({
|
||||
companyId,
|
||||
type,
|
||||
reference,
|
||||
businessLicense: businessLicense ?? null,
|
||||
status: ProfileStatus.Active,
|
||||
status: ProfileStatus.Pending,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -971,13 +1223,21 @@ export class CompaniesService {
|
||||
}));
|
||||
const missingDocs = documents.filter((d) => d.isRequired && !d.uploaded);
|
||||
|
||||
// 3. Per-operational-profile business licenses.
|
||||
const licenseProfiles = (company.companyProfiles ?? []).map((p) => ({
|
||||
profileId: p.id,
|
||||
type: p.type,
|
||||
reference: p.reference ?? "",
|
||||
uploaded: (p.businessLicenseFiles?.length ?? 0) > 0,
|
||||
}));
|
||||
// 3. Per-operational-profile business licenses (FileRecord-backed).
|
||||
const licenseProfiles = await Promise.all(
|
||||
(company.companyProfiles ?? []).map(async (p) => {
|
||||
const records = await this.filesService.findByResource(
|
||||
p.id,
|
||||
LICENSE_RESOURCE,
|
||||
);
|
||||
return {
|
||||
profileId: p.id,
|
||||
type: p.type,
|
||||
reference: p.reference ?? "",
|
||||
uploaded: records.some((r) => r.code === LICENSE_CODE),
|
||||
};
|
||||
}),
|
||||
);
|
||||
const missingLicenses = licenseProfiles.filter((p) => !p.uploaded);
|
||||
|
||||
const outstanding = [
|
||||
@@ -1094,61 +1354,321 @@ export class CompaniesService {
|
||||
return owned;
|
||||
}
|
||||
|
||||
// ─── Business-license files ────────────────────────────────────────────────
|
||||
//
|
||||
// License documents live in the FileRecord model (`freight.files`) with
|
||||
// `resource = "company_profiles"`, `resourceId = <profileId>`. Live files use
|
||||
// code `LICENSE_CODE`; files staged inside an open change request (add /
|
||||
// replacement) use `LICENSE_PENDING_CODE` and only become live on approval.
|
||||
// Preview streams through `GET /api/files/:id` (server-side proxy) — the same
|
||||
// path regular documents use — so it never hits MinIO directly from the
|
||||
// browser (which fails on the internal bucket endpoint).
|
||||
|
||||
/**
|
||||
* Upload business-license document(s) and store them directly on the company
|
||||
* profile (multi-file). Bytes go to object storage; only metadata/URLs are
|
||||
* persisted on the profile — intentionally not via the FileRecord file model.
|
||||
* New files are appended to any already present. Returns the full list.
|
||||
* Upload business-license file(s) for one of the user's profiles. During
|
||||
* onboarding (company not yet Active) they go live immediately; for an Active
|
||||
* company they're staged under the pending code and recorded as `add` intents
|
||||
* on a pending change request for backoffice review. Returns the updated view.
|
||||
*/
|
||||
async uploadProfileLicenseFiles(
|
||||
async addProfileLicenseFiles(
|
||||
userId: string,
|
||||
profileId: string,
|
||||
files: Express.Multer.File[],
|
||||
): Promise<BusinessLicenseFile[]> {
|
||||
): Promise<ProfileLicenseFileView[]> {
|
||||
const profile = await this.resolveOwnedProfile(userId, profileId);
|
||||
const company = await this.findCompanyById(profile.companyId);
|
||||
const gated = company.status === CompanyStatus.Active;
|
||||
const code = gated ? LICENSE_PENDING_CODE : LICENSE_CODE;
|
||||
|
||||
const uploaded: BusinessLicenseFile[] = [];
|
||||
for (const file of files) {
|
||||
const objectName = `company_profiles/${profileId}/${Date.now()}_${file.originalname}`;
|
||||
const url = await this.minioService.uploadFile(
|
||||
objectName,
|
||||
file.buffer,
|
||||
file.mimetype,
|
||||
const uploaded = await Promise.all(
|
||||
files.map((file) =>
|
||||
this.filesService.upload({
|
||||
resourceId: profileId,
|
||||
resource: LICENSE_RESOURCE,
|
||||
code,
|
||||
file,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
if (gated) {
|
||||
await this.stageLicenseChange(
|
||||
company.id,
|
||||
uploaded.map((r) => ({
|
||||
profileId,
|
||||
op: "add" as const,
|
||||
fileId: r.id,
|
||||
fileName: r.name,
|
||||
})),
|
||||
userId,
|
||||
);
|
||||
uploaded.push({
|
||||
name: file.originalname,
|
||||
url,
|
||||
size: file.size,
|
||||
mimeType: file.mimetype,
|
||||
});
|
||||
}
|
||||
|
||||
const next = [...(profile.businessLicenseFiles ?? []), ...uploaded];
|
||||
await this.companyProfilesRepo.update(profileId, {
|
||||
businessLicenseFiles: next,
|
||||
});
|
||||
return next;
|
||||
}
|
||||
|
||||
/** The business-license files stored on a single company profile. */
|
||||
async listProfileLicenseFiles(
|
||||
userId: string,
|
||||
profileId: string,
|
||||
): Promise<BusinessLicenseFile[]> {
|
||||
const profile = await this.resolveOwnedProfile(userId, profileId);
|
||||
return profile.businessLicenseFiles ?? [];
|
||||
return this.getProfileLicenseView(profileId, company.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Onboarding documents stored on a company profile, fetched by profile id.
|
||||
* Internal helper (no ownership check) used when a booking reuses the active
|
||||
* profile's onboarding documents. Returns [] when the profile is unknown.
|
||||
* Remove a license file. A staged (pending) file is withdrawn outright
|
||||
* (soft-deleted, its `add` intent dropped). A live file on an Active company
|
||||
* is kept and recorded as a `remove` intent for review; during onboarding it
|
||||
* is deleted immediately.
|
||||
*/
|
||||
async removeProfileLicenseFile(
|
||||
userId: string,
|
||||
profileId: string,
|
||||
fileId: string,
|
||||
): Promise<ProfileLicenseFileView[]> {
|
||||
const profile = await this.resolveOwnedProfile(userId, profileId);
|
||||
const record = await this.filesService.findById(fileId);
|
||||
if (
|
||||
record.resource !== LICENSE_RESOURCE ||
|
||||
record.resourceId !== profileId
|
||||
) {
|
||||
throw new NotFoundException(`License file ${fileId} not found`);
|
||||
}
|
||||
const company = await this.findCompanyById(profile.companyId);
|
||||
const gated = company.status === CompanyStatus.Active;
|
||||
|
||||
if (record.code === LICENSE_PENDING_CODE) {
|
||||
// Withdraw a not-yet-approved upload: delete it and drop its add intent.
|
||||
await this.filesService.remove(fileId);
|
||||
await this.withdrawLicenseIntent(company.id, fileId);
|
||||
} else if (gated) {
|
||||
await this.stageLicenseChange(
|
||||
company.id,
|
||||
[{ profileId, op: "remove", fileId, fileName: record.name }],
|
||||
userId,
|
||||
);
|
||||
} else {
|
||||
await this.filesService.remove(fileId);
|
||||
}
|
||||
|
||||
return this.getProfileLicenseView(profileId, company.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace a live license file with a freshly uploaded one — recorded as a
|
||||
* `remove` of the old file plus an `add` of the new, so approval swaps them
|
||||
* atomically. During onboarding the swap is applied immediately.
|
||||
*/
|
||||
async replaceProfileLicenseFile(
|
||||
userId: string,
|
||||
profileId: string,
|
||||
fileId: string,
|
||||
file: Express.Multer.File,
|
||||
): Promise<ProfileLicenseFileView[]> {
|
||||
const profile = await this.resolveOwnedProfile(userId, profileId);
|
||||
const old = await this.filesService.findById(fileId);
|
||||
if (old.resource !== LICENSE_RESOURCE || old.resourceId !== profileId) {
|
||||
throw new NotFoundException(`License file ${fileId} not found`);
|
||||
}
|
||||
const company = await this.findCompanyById(profile.companyId);
|
||||
const gated = company.status === CompanyStatus.Active;
|
||||
|
||||
const created = await this.filesService.upload({
|
||||
resourceId: profileId,
|
||||
resource: LICENSE_RESOURCE,
|
||||
code: gated ? LICENSE_PENDING_CODE : LICENSE_CODE,
|
||||
file,
|
||||
});
|
||||
|
||||
if (gated) {
|
||||
await this.stageLicenseChange(
|
||||
company.id,
|
||||
[
|
||||
{ profileId, op: "remove", fileId, fileName: old.name },
|
||||
{ profileId, op: "add", fileId: created.id, fileName: created.name },
|
||||
],
|
||||
userId,
|
||||
);
|
||||
} else {
|
||||
await this.filesService.remove(fileId);
|
||||
}
|
||||
|
||||
return this.getProfileLicenseView(profileId, company.id);
|
||||
}
|
||||
|
||||
/** License files for one profile, with each file's review status resolved. */
|
||||
async listProfileLicenseFiles(
|
||||
userId: string,
|
||||
profileId: string,
|
||||
): Promise<ProfileLicenseFileView[]> {
|
||||
const profile = await this.resolveOwnedProfile(userId, profileId);
|
||||
return this.getProfileLicenseView(profileId, profile.companyId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Live license files for a profile, shaped for by-reference reuse (bookings /
|
||||
* contracts snapshot these). No ownership check — internal callers only.
|
||||
* Returns the raw stored URLs; pending (unapproved) files are excluded.
|
||||
*/
|
||||
async getProfileOnboardingFiles(
|
||||
profileId: string,
|
||||
): Promise<BusinessLicenseFile[]> {
|
||||
const profile = await this.companyProfilesRepo.findById(profileId);
|
||||
return profile?.businessLicenseFiles ?? [];
|
||||
const records = await this.filesService.findByResource(
|
||||
profileId,
|
||||
LICENSE_RESOURCE,
|
||||
);
|
||||
return records
|
||||
.filter((r) => r.code === LICENSE_CODE)
|
||||
.map((r) => ({
|
||||
name: r.name,
|
||||
url: r.url,
|
||||
size: r.size,
|
||||
mimeType: r.mimeType,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Assemble the review-aware license view for a set of profiles in one pass
|
||||
* (single change-request lookup). Used to enrich company/profile responses.
|
||||
*/
|
||||
async assembleLicenseFilesByProfile(
|
||||
companyId: string,
|
||||
profileIds: string[],
|
||||
): Promise<Record<string, ProfileLicenseFileView[]>> {
|
||||
const pending =
|
||||
await this.changeRequestRepo.findPendingByCompanyId(companyId);
|
||||
const removeIds = new Set(
|
||||
(pending?.documents?.licenseChanges ?? [])
|
||||
.filter((c) => c.op === "remove")
|
||||
.map((c) => c.fileId),
|
||||
);
|
||||
const result: Record<string, ProfileLicenseFileView[]> = {};
|
||||
await Promise.all(
|
||||
profileIds.map(async (pid) => {
|
||||
result[pid] = await this.mapLicenseRecords(pid, removeIds);
|
||||
}),
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Single-profile license view (fetches the company's pending request once). */
|
||||
private async getProfileLicenseView(
|
||||
profileId: string,
|
||||
companyId: string,
|
||||
): Promise<ProfileLicenseFileView[]> {
|
||||
const pending =
|
||||
await this.changeRequestRepo.findPendingByCompanyId(companyId);
|
||||
const removeIds = new Set(
|
||||
(pending?.documents?.licenseChanges ?? [])
|
||||
.filter((c) => c.op === "remove")
|
||||
.map((c) => c.fileId),
|
||||
);
|
||||
return this.mapLicenseRecords(profileId, removeIds);
|
||||
}
|
||||
|
||||
private async mapLicenseRecords(
|
||||
profileId: string,
|
||||
pendingRemoveIds: Set<string>,
|
||||
): Promise<ProfileLicenseFileView[]> {
|
||||
const records = await this.filesService.findByResource(
|
||||
profileId,
|
||||
LICENSE_RESOURCE,
|
||||
);
|
||||
return records
|
||||
.filter(
|
||||
(r) => r.code === LICENSE_CODE || r.code === LICENSE_PENDING_CODE,
|
||||
)
|
||||
.map((r) => ({
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
size: r.size,
|
||||
mimeType: r.mimeType,
|
||||
status:
|
||||
r.code === LICENSE_PENDING_CODE
|
||||
? ("pending_add" as const)
|
||||
: pendingRemoveIds.has(r.id)
|
||||
? ("pending_remove" as const)
|
||||
: ("live" as const),
|
||||
}));
|
||||
}
|
||||
|
||||
/** Open or append a pending change request recording license add/remove intents. */
|
||||
private async stageLicenseChange(
|
||||
companyId: string,
|
||||
changes: LicenseChangeIntent[],
|
||||
submittedBy?: string,
|
||||
): Promise<void> {
|
||||
if (changes.length === 0) return;
|
||||
const now = new Date();
|
||||
const existing =
|
||||
await this.changeRequestRepo.findPendingByCompanyId(companyId);
|
||||
if (existing) {
|
||||
const prev = existing.documents?.licenseChanges ?? [];
|
||||
await this.changeRequestRepo.update(existing.id, {
|
||||
documents: {
|
||||
...existing.documents,
|
||||
licenseChanges: [...prev, ...changes],
|
||||
},
|
||||
submittedBy: submittedBy ?? existing.submittedBy ?? null,
|
||||
submittedAt: now,
|
||||
note: null,
|
||||
});
|
||||
} else {
|
||||
await this.changeRequestRepo.create({
|
||||
companyId,
|
||||
snapshot: {},
|
||||
documents: { licenseChanges: changes },
|
||||
status: ChangeRequestStatus.Pending,
|
||||
submittedBy: submittedBy ?? null,
|
||||
submittedAt: now,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop a staged license intent (add or remove) referencing `fileId` from the
|
||||
* company's open request. If that empties the request entirely, delete it so
|
||||
* the customer's settings page unlocks.
|
||||
*/
|
||||
private async withdrawLicenseIntent(
|
||||
companyId: string,
|
||||
fileId: string,
|
||||
): Promise<void> {
|
||||
const existing =
|
||||
await this.changeRequestRepo.findPendingByCompanyId(companyId);
|
||||
if (!existing) return;
|
||||
const remaining = (existing.documents?.licenseChanges ?? []).filter(
|
||||
(c) => c.fileId !== fileId,
|
||||
);
|
||||
const docs = existing.documents ?? {};
|
||||
const stillHasWork =
|
||||
remaining.length > 0 ||
|
||||
(docs.documentFileIds?.length ?? 0) > 0 ||
|
||||
Object.keys(existing.snapshot ?? {}).length > 0;
|
||||
|
||||
if (stillHasWork) {
|
||||
await this.changeRequestRepo.update(existing.id, {
|
||||
documents: { ...docs, licenseChanges: remaining },
|
||||
});
|
||||
} else {
|
||||
await this.changeRequestRepo.softDelete(existing.id);
|
||||
}
|
||||
}
|
||||
|
||||
/** Apply a request's staged license changes: promote adds, delete removes. */
|
||||
private async applyLicenseChanges(
|
||||
request: CompanyChangeRequest,
|
||||
): Promise<void> {
|
||||
for (const change of request.documents?.licenseChanges ?? []) {
|
||||
if (change.op === "add") {
|
||||
await this.filesService.setCode(change.fileId, LICENSE_CODE);
|
||||
} else {
|
||||
await this.filesService.remove(change.fileId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Discard a rejected request's staged license uploads (adds only). */
|
||||
private async discardLicenseChanges(
|
||||
request: CompanyChangeRequest,
|
||||
): Promise<void> {
|
||||
for (const change of request.documents?.licenseChanges ?? []) {
|
||||
if (change.op === "add") {
|
||||
await this.filesService.remove(change.fileId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { InjectRepository } from "@nestjs/typeorm";
|
||||
import { Repository } from "typeorm";
|
||||
import { BaseRepository } from "@edr/api-common";
|
||||
import {
|
||||
ChangeRequestStatus,
|
||||
CompanyChangeRequest,
|
||||
} from "./entities/company-change-request.entity";
|
||||
|
||||
@Injectable()
|
||||
export class CompanyChangeRequestRepository extends BaseRepository<CompanyChangeRequest> {
|
||||
constructor(
|
||||
@InjectRepository(CompanyChangeRequest)
|
||||
repo: Repository<CompanyChangeRequest>,
|
||||
) {
|
||||
super(repo);
|
||||
}
|
||||
|
||||
/** The company's current pending request, if any. */
|
||||
async findPendingByCompanyId(
|
||||
companyId: string,
|
||||
): Promise<CompanyChangeRequest | null> {
|
||||
return this.repository.findOne({
|
||||
where: { companyId, status: ChangeRequestStatus.Pending },
|
||||
order: { createdAt: "DESC" },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The company's latest "open" request — pending (locks the customer) or the
|
||||
* most recent rejected one (drives the reapply banner + prefill). Approved
|
||||
* requests are terminal and ignored here.
|
||||
*/
|
||||
async findLatestOpenByCompanyId(
|
||||
companyId: string,
|
||||
): Promise<CompanyChangeRequest | null> {
|
||||
const pending = await this.findPendingByCompanyId(companyId);
|
||||
if (pending) return pending;
|
||||
return this.repository.findOne({
|
||||
where: { companyId, status: ChangeRequestStatus.Rejected },
|
||||
order: { createdAt: "DESC" },
|
||||
});
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<CompanyChangeRequest | null> {
|
||||
return this.repository.findOne({ where: { id } });
|
||||
}
|
||||
|
||||
async findByCompanyId(companyId: string): Promise<CompanyChangeRequest[]> {
|
||||
return this.repository.find({
|
||||
where: { companyId },
|
||||
order: { createdAt: "DESC" },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ const COMMITTED_STATUSES = [
|
||||
'PAYMENT_VERIFICATION_IN_PROGRESS',
|
||||
'PAID',
|
||||
'IN_TRANSIT',
|
||||
'ARRIVED',
|
||||
'COMPLETED',
|
||||
'DELIVERED',
|
||||
'CONSOLIDATED',
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import {
|
||||
ChangeRequestStatus,
|
||||
CompanyChangeRequest,
|
||||
LicenseChangeIntent,
|
||||
} from "../entities/company-change-request.entity";
|
||||
|
||||
/**
|
||||
* A staged profile change request. Used both by the portal (to lock the settings
|
||||
* page, show the reviewer note, and prefill the proposed values) and by the
|
||||
* backoffice review screen (to render the proposed-vs-current diff).
|
||||
*/
|
||||
export class ChangeRequestResponseDto {
|
||||
id: string;
|
||||
companyId: string;
|
||||
status: ChangeRequestStatus;
|
||||
/** Proposed field values (Partial<UpdateProfileDto>) — the diff payload. */
|
||||
snapshot: Record<string, any>;
|
||||
documentFileIds: string[];
|
||||
/** Staged business-license add/remove intents attached to this request. */
|
||||
licenseChanges: LicenseChangeIntent[];
|
||||
note: string | null;
|
||||
submittedBy: string | null;
|
||||
submittedAt: Date | null;
|
||||
reviewedBy: string | null;
|
||||
reviewedAt: Date | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
|
||||
constructor(req: CompanyChangeRequest) {
|
||||
this.id = req.id;
|
||||
this.companyId = req.companyId;
|
||||
this.status = req.status;
|
||||
this.snapshot = req.snapshot ?? {};
|
||||
this.documentFileIds = req.documents?.documentFileIds ?? [];
|
||||
this.licenseChanges = req.documents?.licenseChanges ?? [];
|
||||
this.note = req.note ?? null;
|
||||
this.submittedBy = req.submittedBy ?? null;
|
||||
this.submittedAt = req.submittedAt ?? null;
|
||||
this.reviewedBy = req.reviewedBy ?? null;
|
||||
this.reviewedAt = req.reviewedAt ?? null;
|
||||
this.createdAt = req.createdAt;
|
||||
this.updatedAt = req.updatedAt;
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,43 @@
|
||||
import { Company } from '../entities/company.entity';
|
||||
import { ExternalProfile } from '../entities/external-profile.entity';
|
||||
import {
|
||||
ChangeRequestStatus,
|
||||
CompanyChangeRequest,
|
||||
} from '../entities/company-change-request.entity';
|
||||
import { ResponseCompanyDto } from './response-company.dto';
|
||||
import { ResponseExternalProfileDto } from './response-external-profile.dto';
|
||||
|
||||
export class CompanyInfoResponseDto {
|
||||
profile: ResponseExternalProfileDto;
|
||||
company: ResponseCompanyDto;
|
||||
/**
|
||||
* Open profile-edit review, if any. Drives the portal-wide lock (pending →
|
||||
* settings + new-contract/booking creation disabled) and the reapply banner.
|
||||
*/
|
||||
review: {
|
||||
status: 'pending' | 'rejected';
|
||||
note: string | null;
|
||||
} | null;
|
||||
|
||||
constructor(profile: ExternalProfile, company: Company) {
|
||||
constructor(
|
||||
profile: ExternalProfile,
|
||||
company: Company,
|
||||
changeRequest?: CompanyChangeRequest | null,
|
||||
) {
|
||||
this.profile = new ResponseExternalProfileDto(profile, company);
|
||||
this.company = new ResponseCompanyDto(company);
|
||||
|
||||
const open =
|
||||
changeRequest &&
|
||||
(changeRequest.status === ChangeRequestStatus.Pending ||
|
||||
changeRequest.status === ChangeRequestStatus.Rejected)
|
||||
? changeRequest
|
||||
: null;
|
||||
this.review = open
|
||||
? {
|
||||
status: open.status as 'pending' | 'rejected',
|
||||
note: open.note ?? null,
|
||||
}
|
||||
: null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { Company } from '../entities/company.entity';
|
||||
import { ExternalProfile } from '../entities/external-profile.entity';
|
||||
import {
|
||||
ChangeRequestStatus,
|
||||
CompanyChangeRequest,
|
||||
} from '../entities/company-change-request.entity';
|
||||
import { ResponseCompanyProfileDto } from './response-company.dto';
|
||||
|
||||
export class ProfileResponseDto {
|
||||
@@ -48,7 +52,20 @@ export class ProfileResponseDto {
|
||||
|
||||
profileId: string;
|
||||
|
||||
constructor(profile: ExternalProfile, company: Company) {
|
||||
/**
|
||||
* Open profile-edit review, if any. `reviewStatus === "pending"` locks the
|
||||
* settings page; `"rejected"` surfaces the note and prefills the (declined)
|
||||
* proposed values from `pendingChanges` so the customer can amend & resubmit.
|
||||
*/
|
||||
reviewStatus: "pending" | "rejected" | null;
|
||||
reviewNote: string | null;
|
||||
pendingChanges: Record<string, any> | null;
|
||||
|
||||
constructor(
|
||||
profile: ExternalProfile,
|
||||
company: Company,
|
||||
changeRequest?: CompanyChangeRequest | null,
|
||||
) {
|
||||
this.companyId = company.id;
|
||||
this.companyName = company.name;
|
||||
this.companyType = company.type;
|
||||
@@ -92,5 +109,20 @@ export class ProfileResponseDto {
|
||||
this.poaEmail = attrs.poaEmail ?? null;
|
||||
this.poaLocation = attrs.poaLocation ?? null;
|
||||
this.poaAddress = attrs.poaAddress ?? null;
|
||||
|
||||
const openReview =
|
||||
changeRequest &&
|
||||
(changeRequest.status === ChangeRequestStatus.Pending ||
|
||||
changeRequest.status === ChangeRequestStatus.Rejected)
|
||||
? changeRequest
|
||||
: null;
|
||||
this.reviewStatus =
|
||||
openReview?.status === ChangeRequestStatus.Pending
|
||||
? "pending"
|
||||
: openReview?.status === ChangeRequestStatus.Rejected
|
||||
? "rejected"
|
||||
: null;
|
||||
this.reviewNote = openReview?.note ?? null;
|
||||
this.pendingChanges = openReview?.snapshot ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { IsString, MaxLength, MinLength } from "class-validator";
|
||||
|
||||
export class RejectChangeRequestDto {
|
||||
/** Why the proposed changes were declined — shown to the customer so they can fix and resubmit. */
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(2000)
|
||||
note!: string;
|
||||
}
|
||||
@@ -5,8 +5,8 @@ import {
|
||||
CompanyNationality,
|
||||
} from '../entities/company.entity';
|
||||
import {
|
||||
BusinessLicenseFile,
|
||||
CompanyProfile,
|
||||
ProfileLicenseFileView,
|
||||
} from '../entities/company-profile.entity';
|
||||
import { ResponseExternalProfileDto } from './response-external-profile.dto';
|
||||
|
||||
@@ -18,9 +18,15 @@ export class ResponseCompanyProfileDto {
|
||||
status: string;
|
||||
/** @deprecated Superseded by licenseFiles. Kept for back-compat. */
|
||||
businessLicense?: string | null;
|
||||
/** Business-license documents stored on the profile (multi-file). */
|
||||
licenseFiles: BusinessLicenseFile[];
|
||||
/**
|
||||
* Business-license documents (FileRecord-backed) with review state. Left empty
|
||||
* by the constructor and populated asynchronously by the controller, since the
|
||||
* files and their pending-change status require DB lookups.
|
||||
*/
|
||||
licenseFiles: ProfileLicenseFileView[];
|
||||
attributes?: Record<string, any> | null;
|
||||
/** Reviewer note when the role is rejected (drives the reapply prompt). */
|
||||
reviewNote?: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
|
||||
@@ -31,8 +37,9 @@ export class ResponseCompanyProfileDto {
|
||||
this.reference = profile.reference ?? '';
|
||||
this.status = profile.status;
|
||||
this.businessLicense = profile.businessLicense;
|
||||
this.licenseFiles = profile.businessLicenseFiles ?? [];
|
||||
this.licenseFiles = [];
|
||||
this.attributes = profile.attributes;
|
||||
this.reviewNote = profile.reviewNote ?? null;
|
||||
this.createdAt = profile.createdAt;
|
||||
this.updatedAt = profile.updatedAt;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { IsIn } from "class-validator";
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { IsIn, IsOptional, IsString, MaxLength } from "class-validator";
|
||||
import { ProfileStatus } from "../entities/company-profile.entity";
|
||||
|
||||
export class UpdateCompanyProfileStatusDto {
|
||||
@ApiProperty({ enum: ProfileStatus })
|
||||
@IsIn(Object.values(ProfileStatus))
|
||||
status!: ProfileStatus;
|
||||
|
||||
/** Reviewer note — required in practice when rejecting so the customer knows why. */
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(2000)
|
||||
note?: string;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { BaseEntity } from "@edr/api-common";
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
|
||||
import { Company } from "./company.entity";
|
||||
|
||||
/**
|
||||
* Lifecycle of a customer's proposed profile change. Edits made on the portal
|
||||
* settings page by an already-approved company are staged here (not written to
|
||||
* the live Company row) until a backoffice reviewer approves — at which point
|
||||
* the snapshot is applied — or rejects with a note, after which the customer can
|
||||
* amend and resubmit.
|
||||
*/
|
||||
export enum ChangeRequestStatus {
|
||||
Pending = "pending",
|
||||
Approved = "approved",
|
||||
Rejected = "rejected",
|
||||
}
|
||||
|
||||
/**
|
||||
* A single staged business-license change on one company profile, awaiting
|
||||
* review. `add` → a new file was uploaded under the pending code and becomes
|
||||
* live on approval; `remove` → an existing live file is deleted on approval.
|
||||
* A "replace" is recorded as a `remove` of the old file plus an `add` of the
|
||||
* new one. `fileId` is the FileRecord id the op targets.
|
||||
*/
|
||||
export interface LicenseChangeIntent {
|
||||
profileId: string;
|
||||
op: "add" | "remove";
|
||||
fileId: string;
|
||||
/** File name, snapshotted for the backoffice review screen. */
|
||||
fileName?: string;
|
||||
}
|
||||
|
||||
/** File references staged alongside a change request (documents/licenses). */
|
||||
export interface ChangeRequestDocuments {
|
||||
/** FileRecord ids uploaded against the company while this request was open. */
|
||||
documentFileIds?: string[];
|
||||
/** Staged per-profile business-license add/remove intents. */
|
||||
licenseChanges?: LicenseChangeIntent[];
|
||||
}
|
||||
|
||||
@Entity({ schema: "freight", name: "company_change_request" })
|
||||
@Index(["companyId"])
|
||||
@Index(["status"])
|
||||
export class CompanyChangeRequest extends BaseEntity {
|
||||
@Column({ name: "company_id", type: "uuid" })
|
||||
companyId!: string;
|
||||
|
||||
@ManyToOne(() => Company, { onDelete: "CASCADE" })
|
||||
@JoinColumn({ name: "company_id" })
|
||||
company?: Company;
|
||||
|
||||
/**
|
||||
* Proposed profile field values, shaped as `Partial<UpdateProfileDto>`. Covers
|
||||
* the Company / Contact / General Manager / Power-of-Attorney tabs (contact/GM/
|
||||
* PoA fields land in `Company.attributes` on approval).
|
||||
*/
|
||||
@Column({ name: "snapshot", type: "jsonb" })
|
||||
snapshot!: Record<string, any>;
|
||||
|
||||
/** Staged document/license file references (see {@link ChangeRequestDocuments}). */
|
||||
@Column({ name: "documents", type: "jsonb", nullable: true })
|
||||
documents?: ChangeRequestDocuments | null;
|
||||
|
||||
@Column({
|
||||
name: "status",
|
||||
type: "varchar",
|
||||
length: 20,
|
||||
default: ChangeRequestStatus.Pending,
|
||||
})
|
||||
status!: ChangeRequestStatus;
|
||||
|
||||
/** Backoffice reviewer's rejection note. */
|
||||
@Column({ name: "note", type: "text", nullable: true })
|
||||
note?: string | null;
|
||||
|
||||
@Column({ name: "submitted_by", type: "uuid", nullable: true })
|
||||
submittedBy?: string | null;
|
||||
|
||||
@Column({ name: "submitted_at", type: "timestamptz", nullable: true })
|
||||
submittedAt?: Date | null;
|
||||
|
||||
@Column({ name: "reviewed_by", type: "uuid", nullable: true })
|
||||
reviewedBy?: string | null;
|
||||
|
||||
@Column({ name: "reviewed_at", type: "timestamptz", nullable: true })
|
||||
reviewedAt?: Date | null;
|
||||
}
|
||||
@@ -13,11 +13,17 @@ export enum ProfileType {
|
||||
export enum ProfileStatus {
|
||||
Active = "active",
|
||||
Pending = "pending",
|
||||
/** Reviewer declined the role; carries a note. Customer can reapply → Pending. */
|
||||
Rejected = "rejected",
|
||||
Suspended = "suspended",
|
||||
Blacklisted = "blacklisted",
|
||||
}
|
||||
|
||||
/** A business-license document stored directly on the company profile. */
|
||||
/**
|
||||
* @deprecated Legacy inline shape. Business-license files now live in the
|
||||
* FileRecord model (`freight.files`, resource `company_profiles`). Kept only for
|
||||
* the by-reference reuse shape consumed by bookings/contracts snapshots.
|
||||
*/
|
||||
export interface BusinessLicenseFile {
|
||||
name: string;
|
||||
url: string;
|
||||
@@ -25,6 +31,19 @@ export interface BusinessLicenseFile {
|
||||
mimeType?: string;
|
||||
}
|
||||
|
||||
/** A business-license file plus its change-review state, surfaced to clients. */
|
||||
export interface ProfileLicenseFileView {
|
||||
id: string;
|
||||
name: string;
|
||||
size: number;
|
||||
mimeType: string;
|
||||
/**
|
||||
* `live` — approved & in effect; `pending_add` — uploaded, awaiting approval;
|
||||
* `pending_remove` — live but flagged for deletion on approval.
|
||||
*/
|
||||
status: "live" | "pending_add" | "pending_remove";
|
||||
}
|
||||
|
||||
@Entity({ schema: "freight", name: "company_profiles" })
|
||||
@Index(["reference"], { unique: true })
|
||||
@Index(["type"])
|
||||
@@ -80,4 +99,14 @@ export class CompanyProfile extends BaseEntity {
|
||||
|
||||
@Column({ name: "attributes", type: "jsonb", nullable: true })
|
||||
attributes?: Record<string, any> | null;
|
||||
|
||||
/** Reviewer's note when the role is Rejected (cleared on reapply). */
|
||||
@Column({ name: "review_note", type: "text", nullable: true })
|
||||
reviewNote?: string | null;
|
||||
|
||||
@Column({ name: "reviewed_by", type: "uuid", nullable: true })
|
||||
reviewedBy?: string | null;
|
||||
|
||||
@Column({ name: "reviewed_at", type: "timestamptz", nullable: true })
|
||||
reviewedAt?: Date | null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
import { ContractBookingService } from './contract-booking.service';
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
|
||||
/**
|
||||
* The GL contract-drawdown path must run wagon consolidation before invoicing.
|
||||
* A partial-wagon drawdown (e.g. 21× 20FT → one leftover container) parks in
|
||||
* PENDING_CONSOLIDATION and is NOT finalized (no invoice / milestones) until it
|
||||
* pairs with a wagon partner. These tests exercise the two new hooks directly.
|
||||
*/
|
||||
describe('ContractBookingService — drawdown consolidation gate', () => {
|
||||
function makeService(overrides: {
|
||||
consolidationService?: Partial<Record<string, jest.Mock>>;
|
||||
bookingsRepository?: Partial<Record<string, jest.Mock>>;
|
||||
invoiceService?: Partial<Record<string, jest.Mock>>;
|
||||
milestoneService?: Partial<Record<string, jest.Mock>>;
|
||||
contractsRepository?: Partial<Record<string, jest.Mock>>;
|
||||
}) {
|
||||
const consolidationService = {
|
||||
slotsFromBooking: jest.fn().mockResolvedValue([]),
|
||||
describePaired: jest.fn().mockReturnValue('paired'),
|
||||
describePending: jest.fn().mockReturnValue('pending'),
|
||||
needsConsolidationFromBooking: jest.fn().mockResolvedValue(false),
|
||||
...overrides.consolidationService,
|
||||
};
|
||||
const bookingsRepository = {
|
||||
findConsolidationPartner: jest.fn().mockResolvedValue(null),
|
||||
pairConsolidation: jest.fn().mockResolvedValue(undefined),
|
||||
parkForConsolidation: jest.fn().mockResolvedValue(undefined),
|
||||
findByIdWithFiles: jest.fn(),
|
||||
...overrides.bookingsRepository,
|
||||
};
|
||||
const invoiceService = {
|
||||
ensureInvoiceForBooking: jest.fn().mockResolvedValue({ id: 'inv-1' }),
|
||||
...overrides.invoiceService,
|
||||
};
|
||||
const milestoneService = {
|
||||
seedPostBookingMilestones: jest.fn().mockResolvedValue(undefined),
|
||||
seedPreBookingMilestonesOnBooking: jest.fn().mockResolvedValue(undefined),
|
||||
...overrides.milestoneService,
|
||||
};
|
||||
const contractsRepository = {
|
||||
findByIdWithRelations: jest.fn(),
|
||||
currentCycle: jest.fn().mockResolvedValue(null),
|
||||
linkBooking: jest.fn().mockResolvedValue(undefined),
|
||||
update: jest.fn().mockResolvedValue(undefined),
|
||||
...overrides.contractsRepository,
|
||||
};
|
||||
|
||||
const service = new ContractBookingService(
|
||||
contractsRepository as never,
|
||||
bookingsRepository as never,
|
||||
{} as never, // bookingPricingService
|
||||
consolidationService as never,
|
||||
{} as never, // containerTypesService
|
||||
{} as never, // ruleEngineService
|
||||
milestoneService as never,
|
||||
{} as never, // workflowService
|
||||
invoiceService as never,
|
||||
{} as never, // dataSource
|
||||
{} as never, // trainSchedulingService
|
||||
);
|
||||
return {
|
||||
service,
|
||||
consolidationService,
|
||||
bookingsRepository,
|
||||
invoiceService,
|
||||
milestoneService,
|
||||
contractsRepository,
|
||||
};
|
||||
}
|
||||
|
||||
const booking = { id: 'b-1', reference: 'BK-1' } as Booking;
|
||||
|
||||
it('parks (not pairs) when no complementary partner exists', async () => {
|
||||
const { service, bookingsRepository } = makeService({
|
||||
consolidationService: {
|
||||
slotsFromBooking: jest
|
||||
.fn()
|
||||
.mockResolvedValue([{ containerTypeId: 'ct', slotsNeeded: 1 }]),
|
||||
},
|
||||
bookingsRepository: {
|
||||
findConsolidationPartner: jest.fn().mockResolvedValue(null),
|
||||
},
|
||||
});
|
||||
|
||||
const result = await (service as never as {
|
||||
consolidateDrawdown: (b: Booking, s: string) => Promise<{ paired: boolean }>;
|
||||
}).consolidateDrawdown(booking, 'OPERATION_REQUEST_PENDING');
|
||||
|
||||
expect(result.paired).toBe(false);
|
||||
expect(bookingsRepository.parkForConsolidation).toHaveBeenCalledWith(
|
||||
'b-1',
|
||||
'OPERATION_REQUEST_PENDING',
|
||||
);
|
||||
expect(bookingsRepository.pairConsolidation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('pairs when a complementary partner exists', async () => {
|
||||
const { service, bookingsRepository } = makeService({
|
||||
consolidationService: {
|
||||
slotsFromBooking: jest
|
||||
.fn()
|
||||
.mockResolvedValue([{ containerTypeId: 'ct', slotsNeeded: 1 }]),
|
||||
},
|
||||
bookingsRepository: {
|
||||
findConsolidationPartner: jest
|
||||
.fn()
|
||||
.mockResolvedValue({ id: 'p-1', reference: 'BK-2' }),
|
||||
},
|
||||
});
|
||||
|
||||
const result = await (service as never as {
|
||||
consolidateDrawdown: (b: Booking, s: string) => Promise<{ paired: boolean }>;
|
||||
}).consolidateDrawdown(booking, 'AWAITING_DOCUMENTS');
|
||||
|
||||
expect(result.paired).toBe(true);
|
||||
expect(bookingsRepository.pairConsolidation).toHaveBeenCalledWith('b-1', 'p-1');
|
||||
expect(bookingsRepository.parkForConsolidation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('onConsolidationPaired finalizes a resumed contract booking (invoice + milestones)', async () => {
|
||||
const paired = {
|
||||
id: 'b-1',
|
||||
reference: 'BK-1',
|
||||
contractId: 'c-1',
|
||||
status: 'OPERATION_REQUEST_PENDING',
|
||||
} as Booking;
|
||||
const contract = {
|
||||
id: 'c-1',
|
||||
contractKind: 'GENERAL',
|
||||
customsClearingEnabled: true,
|
||||
tradeDirection: 'EXPORT',
|
||||
};
|
||||
const { service, invoiceService, milestoneService } = makeService({
|
||||
bookingsRepository: {
|
||||
findByIdWithFiles: jest.fn().mockResolvedValue(paired),
|
||||
},
|
||||
contractsRepository: {
|
||||
findByIdWithRelations: jest.fn().mockResolvedValue(contract),
|
||||
},
|
||||
});
|
||||
|
||||
await service.onConsolidationPaired({ bookingIds: ['b-1'] });
|
||||
|
||||
expect(invoiceService.ensureInvoiceForBooking).toHaveBeenCalledTimes(1);
|
||||
// GENERAL customs → per-booking pre + post milestones.
|
||||
expect(milestoneService.seedPreBookingMilestonesOnBooking).toHaveBeenCalled();
|
||||
expect(milestoneService.seedPostBookingMilestones).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('onConsolidationPaired ignores a booking still PENDING_CONSOLIDATION', async () => {
|
||||
const stillPending = {
|
||||
id: 'b-1',
|
||||
contractId: 'c-1',
|
||||
status: 'PENDING_CONSOLIDATION',
|
||||
} as Booking;
|
||||
const { service, invoiceService } = makeService({
|
||||
bookingsRepository: {
|
||||
findByIdWithFiles: jest.fn().mockResolvedValue(stillPending),
|
||||
},
|
||||
});
|
||||
|
||||
await service.onConsolidationPaired({ bookingIds: ['b-1'] });
|
||||
|
||||
expect(invoiceService.ensureInvoiceForBooking).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('onConsolidationPaired ignores a non-contract (direct) booking', async () => {
|
||||
const direct = { id: 'd-1', status: 'SUBMITTED', contractId: null } as Booking;
|
||||
const { service, invoiceService, contractsRepository } = makeService({
|
||||
bookingsRepository: {
|
||||
findByIdWithFiles: jest.fn().mockResolvedValue(direct),
|
||||
},
|
||||
});
|
||||
|
||||
await service.onConsolidationPaired({ bookingIds: ['d-1'] });
|
||||
|
||||
expect(contractsRepository.findByIdWithRelations).not.toHaveBeenCalled();
|
||||
expect(invoiceService.ensureInvoiceForBooking).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
forwardRef,
|
||||
} from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { OnEvent } from '@nestjs/event-emitter';
|
||||
import { insertWithGeneratedReference } from '@edr/api-common';
|
||||
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
@@ -15,6 +16,7 @@ import { BookingContainer } from '../bookings/entities/booking-container.entity'
|
||||
import { BookingContainerUnit } from '../bookings/entities/booking-container-unit.entity';
|
||||
import { BookingsRepository } from '../bookings/bookings.repository';
|
||||
import { BookingPricingService } from '../bookings/booking-pricing.service';
|
||||
import { ConsolidationService } from '../bookings/consolidation.service';
|
||||
import { PriceLineItemDto } from '../bookings/dto/generate-price-response.dto';
|
||||
import { BookingInvoiceService } from '../bookings/booking-invoice.service';
|
||||
import { validate20ftWeightPairing } from '../bookings/container-pairing.util';
|
||||
@@ -61,6 +63,7 @@ export class ContractBookingService {
|
||||
private readonly contractsRepository: ContractsRepository,
|
||||
private readonly bookingsRepository: BookingsRepository,
|
||||
private readonly bookingPricingService: BookingPricingService,
|
||||
private readonly consolidationService: ConsolidationService,
|
||||
private readonly containerTypesService: ContainerTypesService,
|
||||
private readonly ruleEngineService: RuleEngineService,
|
||||
private readonly milestoneService: ClearanceMilestoneService,
|
||||
@@ -214,6 +217,20 @@ export class ContractBookingService {
|
||||
await this.applyWeightResults(loaded);
|
||||
}
|
||||
const computed = await this.bookingPricingService.computePriceForBooking(loaded);
|
||||
// Reject a zero-price booking outright. A total of 0 means no contract rate
|
||||
// matched the route/container (or the rate is unset), so the booking is not
|
||||
// valid to ship or invoice. Roll back the just-inserted row + its lines so it
|
||||
// does NOT occupy the one-time contract's single active-booking slot — else
|
||||
// the customer's retry hits "already has an active booking" against a broken
|
||||
// draft. The customer must fix the contract's rates, then rebook.
|
||||
if (!(computed.totalAmount > 0)) {
|
||||
await this.bookingsRepository.deleteContainers(booking.id);
|
||||
await this.bookingsRepository.hardDelete(booking.id);
|
||||
throw new BadRequestException(
|
||||
'Booking price came out as 0 — no contract rate matches this ' +
|
||||
'route/cargo. Set the contract rate and try again.',
|
||||
);
|
||||
}
|
||||
await this.bookingsRepository.update(booking.id, {
|
||||
totalAmount: computed.totalAmount,
|
||||
priorityScore: computed.priorityScore,
|
||||
@@ -232,6 +249,105 @@ export class ContractBookingService {
|
||||
warnings.push(...computed.warnings);
|
||||
}
|
||||
|
||||
// Wagon consolidation gate. A container drawdown whose lines leave a partial
|
||||
// wagon (e.g. 21× 20FT → one leftover) must share that wagon with a partner
|
||||
// before it can ship. Direct bookings do this at submit; drawdowns have no
|
||||
// submit step, so we run it here — BEFORE invoicing/milestones. When it parks
|
||||
// for a partner the booking is NOT invoiced or scheduled: those steps run
|
||||
// later in finalizeContractBooking, triggered by the pairing event. When it
|
||||
// pairs (or needs no consolidation) we finalize inline.
|
||||
const withContainers = await this.bookingsRepository.findByIdWithFiles(
|
||||
booking.id,
|
||||
);
|
||||
const intendedStatus = generalCustoms
|
||||
? 'AWAITING_DOCUMENTS'
|
||||
: 'OPERATION_REQUEST_PENDING';
|
||||
if (
|
||||
withContainers &&
|
||||
freightType === 'CONTAINER' &&
|
||||
(await this.consolidationService.needsConsolidationFromBooking(
|
||||
withContainers,
|
||||
))
|
||||
) {
|
||||
const parked = await this.consolidateDrawdown(
|
||||
withContainers,
|
||||
intendedStatus,
|
||||
);
|
||||
warnings.push(parked.message);
|
||||
if (!parked.paired) {
|
||||
// Waiting for a partner — stop here. The booking sits in
|
||||
// PENDING_CONSOLIDATION, unbilled and unscheduled, until it pairs.
|
||||
const pendingResult = await this.bookingsRepository.findByIdWithFiles(
|
||||
booking.id,
|
||||
);
|
||||
return { booking: pendingResult ?? booking, warnings };
|
||||
}
|
||||
}
|
||||
|
||||
await this.finalizeContractBooking(
|
||||
booking.id,
|
||||
contract,
|
||||
generalCustoms,
|
||||
);
|
||||
|
||||
const result = await this.bookingsRepository.findByIdWithFiles(booking.id);
|
||||
return { booking: result ?? booking, warnings };
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for a complementary partner for a parked-eligible drawdown, pair it or
|
||||
* park it in PENDING_CONSOLIDATION with the resume status it should return to.
|
||||
* Pairing (via BookingsRepository.pairConsolidation) resumes both partners and
|
||||
* emits booking.consolidation.paired, which finalizes any deferred contract
|
||||
* booking. Returns whether a partner was found plus a customer-facing message.
|
||||
*/
|
||||
private async consolidateDrawdown(
|
||||
booking: Booking,
|
||||
resumeStatus: string,
|
||||
): Promise<{ paired: boolean; message: string }> {
|
||||
const slots = await this.consolidationService.slotsFromBooking(booking);
|
||||
if (!slots.length) {
|
||||
return { paired: false, message: '' };
|
||||
}
|
||||
|
||||
const partner = await this.bookingsRepository.findConsolidationPartner(
|
||||
booking,
|
||||
slots,
|
||||
);
|
||||
|
||||
if (partner) {
|
||||
await this.bookingsRepository.pairConsolidation(booking.id, partner.id);
|
||||
return {
|
||||
paired: true,
|
||||
message: this.consolidationService.describePaired(
|
||||
partner.reference,
|
||||
slots,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
await this.bookingsRepository.parkForConsolidation(booking.id, resumeStatus);
|
||||
return {
|
||||
paired: false,
|
||||
message: this.consolidationService.describePending(booking, slots),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Finalize a contract booking once it is cleared to proceed (needed no
|
||||
* consolidation, or has just paired): seed clearance milestones / link the
|
||||
* contract cycle, then generate the invoice. Idempotent — safe to call again
|
||||
* for a booking that pairs after having waited. Skips a booking that is still
|
||||
* PENDING_CONSOLIDATION (guards the pairing event against a stray partner).
|
||||
*/
|
||||
private async finalizeContractBooking(
|
||||
bookingId: string,
|
||||
contract: Contract,
|
||||
generalCustoms: boolean,
|
||||
): Promise<void> {
|
||||
const booking = await this.bookingsRepository.findByIdWithFiles(bookingId);
|
||||
if (!booking || booking.status === 'PENDING_CONSOLIDATION') return;
|
||||
|
||||
// ONE_TIME customs (legacy contract-cycle path): link the contract clearance
|
||||
// cycle to this booking, seed post-booking milestones, and lock the contract
|
||||
// to ACTIVE_SHIPMENT_IN_PROGRESS. NOT for GENERAL — it has no contract cycle
|
||||
@@ -239,10 +355,10 @@ export class ContractBookingService {
|
||||
if (contract.customsClearingEnabled && !generalCustoms) {
|
||||
const cycle = await this.contractsRepository.currentCycle(contract.id);
|
||||
if (cycle) {
|
||||
await this.contractsRepository.linkBooking(cycle.id, booking.id);
|
||||
await this.contractsRepository.linkBooking(cycle.id, bookingId);
|
||||
}
|
||||
await this.milestoneService.seedPostBookingMilestones(
|
||||
booking.id,
|
||||
bookingId,
|
||||
contract.tradeDirection,
|
||||
);
|
||||
await this.contractsRepository.update(contract.id, {
|
||||
@@ -252,24 +368,22 @@ export class ContractBookingService {
|
||||
} else if (generalCustoms) {
|
||||
// Per-booking clearance: seed full milestone timeline on the booking.
|
||||
await this.milestoneService.seedPreBookingMilestonesOnBooking(
|
||||
booking.id,
|
||||
bookingId,
|
||||
contract.tradeDirection,
|
||||
);
|
||||
await this.milestoneService.seedPostBookingMilestones(
|
||||
booking.id,
|
||||
bookingId,
|
||||
contract.tradeDirection,
|
||||
);
|
||||
}
|
||||
|
||||
const result = await this.bookingsRepository.findByIdWithFiles(booking.id);
|
||||
|
||||
// Contract bookings are born past the billable gate (the contract is already
|
||||
// executed), so the invoice is generated here — they never pass through the
|
||||
// legacy marketingApprove → FULLY_EXECUTED path that invoices direct bookings.
|
||||
// Idempotent and non-blocking: a billing hiccup must not undo the booking.
|
||||
// Skips silently when unbillable (no company / no priced amount).
|
||||
await this.invoiceService
|
||||
.ensureInvoiceForBooking(result ?? booking)
|
||||
.ensureInvoiceForBooking(booking)
|
||||
.catch((err) =>
|
||||
this.logger.error(
|
||||
`Failed to generate invoice for contract booking ${booking.reference}: ${
|
||||
@@ -277,8 +391,40 @@ export class ContractBookingService {
|
||||
}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return { booking: result ?? booking, warnings };
|
||||
/**
|
||||
* A parked drawdown just paired — finalize whichever partner is a contract
|
||||
* booking that was waiting (invoice + milestones deferred at creation). The
|
||||
* pairing already resumed the booking's status from consolidationResumeStatus;
|
||||
* this runs the create-time tail that was skipped. Non-contract partners have
|
||||
* their own finalize path (staff accept) and are ignored here.
|
||||
*/
|
||||
@OnEvent('booking.consolidation.paired')
|
||||
async onConsolidationPaired(payload: {
|
||||
bookingIds: string[];
|
||||
}): Promise<void> {
|
||||
for (const id of payload.bookingIds ?? []) {
|
||||
const booking = await this.bookingsRepository.findByIdWithFiles(id);
|
||||
if (!booking?.contractId || booking.status === 'PENDING_CONSOLIDATION') {
|
||||
continue;
|
||||
}
|
||||
const contract = await this.contractsRepository.findByIdWithRelations(
|
||||
booking.contractId,
|
||||
);
|
||||
if (!contract) continue;
|
||||
const generalCustoms =
|
||||
contract.contractKind === 'GENERAL' &&
|
||||
Boolean(contract.customsClearingEnabled);
|
||||
await this.finalizeContractBooking(id, contract, generalCustoms).catch(
|
||||
(err) =>
|
||||
this.logger.error(
|
||||
`Failed to finalize paired contract booking ${booking.reference}: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -12,7 +12,7 @@ import { YardCountry } from '@edr/types';
|
||||
|
||||
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
|
||||
import { CompaniesService } from '../companies/companies.service';
|
||||
import { CompanyProfile, ProfileType } from '../companies/entities/company-profile.entity';
|
||||
import { ProfileType } from '../companies/entities/company-profile.entity';
|
||||
import { CompanyStatus } from '../companies/entities/company.entity';
|
||||
import { ServiceType } from '../rule-engine/entities/service-type.entity';
|
||||
import { Yard } from '../rule-engine/entities/yard.entity';
|
||||
@@ -326,10 +326,20 @@ export class ContractsService {
|
||||
companyProfileId: string | null,
|
||||
): Promise<void> {
|
||||
if (!companyProfileId) return;
|
||||
const profile = await this.dataSource
|
||||
.getRepository(CompanyProfile)
|
||||
.findOne({ where: { id: companyProfileId } });
|
||||
const docs = profile?.businessLicenseFiles ?? [];
|
||||
// Business-license files are FileRecords (resource "company_profiles"); carry
|
||||
// the live ones by reference. Staged/pending uploads are excluded by code.
|
||||
const records = await this.filesService.findByResource(
|
||||
companyProfileId,
|
||||
'company_profiles',
|
||||
);
|
||||
const docs = records
|
||||
.filter((r) => r.code === 'business_license')
|
||||
.map((r) => ({
|
||||
name: r.name,
|
||||
url: r.url,
|
||||
size: r.size,
|
||||
mimeType: r.mimeType,
|
||||
}));
|
||||
if (docs.length === 0) return;
|
||||
|
||||
const slug = (name: string) =>
|
||||
|
||||
@@ -202,15 +202,22 @@ export class GlOperationsService {
|
||||
.findOne({ where: { id: booking.trainScheduleId } });
|
||||
}
|
||||
|
||||
// Per-booking journey first: a booking rides only its own leg, so ITS
|
||||
// loaded/arrived timestamps gate clearance — a Dire→Djibouti booking that
|
||||
// unloaded at its own destination clears while the train keeps rolling,
|
||||
// and a booking still on board does NOT clear just because the train
|
||||
// arrived. The schedule actuals remain only as fallback for legacy
|
||||
// in-flight bookings that predate per-booking load/unload (no loadedAt).
|
||||
const departedAt = booking.loadedAt ?? schedule?.actualDepartureAt ?? null;
|
||||
const arrivedAt =
|
||||
booking.arrivedAt ??
|
||||
(booking.loadedAt ? null : (schedule?.actualArrivalAt ?? null));
|
||||
|
||||
return {
|
||||
scheduleId: schedule?.id ?? null,
|
||||
wagonAllocated,
|
||||
departedAt: schedule?.actualDepartureAt
|
||||
? new Date(schedule.actualDepartureAt).toISOString()
|
||||
: null,
|
||||
arrivedAt: schedule?.actualArrivalAt
|
||||
? new Date(schedule.actualArrivalAt).toISOString()
|
||||
: null,
|
||||
departedAt: departedAt ? new Date(departedAt).toISOString() : null,
|
||||
arrivedAt: arrivedAt ? new Date(arrivedAt).toISOString() : null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -278,7 +285,7 @@ export class GlOperationsService {
|
||||
/**
|
||||
* GL Djibouti uploads T1 transport documents (multi-file) once the gate pass
|
||||
* is secured on the train schedule (which itself follows wagon allocation).
|
||||
* Replaces the previous batch; locked once the train departs or T1 is closed.
|
||||
* Replaces the previous batch; locked only once GL Ethiopia closes the T1.
|
||||
*/
|
||||
async uploadT1Documents(
|
||||
bookingId: string,
|
||||
@@ -304,11 +311,8 @@ export class GlOperationsService {
|
||||
if (state.closed) {
|
||||
throw new BadRequestException('T1 has been closed by GL Ethiopia — documents are final.');
|
||||
}
|
||||
if (state.trainDepartedAt) {
|
||||
throw new BadRequestException(
|
||||
'The train has departed — T1 transport documents can no longer be changed.',
|
||||
);
|
||||
}
|
||||
// Departure no longer locks T1 docs — GL DJ may replace them any time until
|
||||
// GL Ethiopia closes/accepts the T1.
|
||||
|
||||
await persistT1TransportUploads(this.filesService, bookingId, files);
|
||||
return { uploaded: files.length };
|
||||
@@ -395,14 +399,20 @@ export class GlOperationsService {
|
||||
}
|
||||
if (!file) throw new BadRequestException('Attach the invoice document.');
|
||||
|
||||
// Invoiceable once cargo is offloaded, or — for export, where OFFLOADED is a
|
||||
// DJ doc milestone that may never be recorded — once the Djibouti gate pass
|
||||
// is secured. The invoice itself stays optional; nothing forces GL DJ to send one.
|
||||
const milestones = await this.milestoneService.listForBooking(bookingId);
|
||||
const offloaded = milestones.find(
|
||||
(m) => m.milestoneCode === 'OFFLOADED' && m.status === 'COMPLETED',
|
||||
);
|
||||
if (!offloaded) {
|
||||
throw new BadRequestException(
|
||||
'Cargo must be offloaded before the final invoice can be raised.',
|
||||
);
|
||||
const gatepass = await this.gatepassForBooking(bookingId);
|
||||
if (!gatepass.granted) {
|
||||
throw new BadRequestException(
|
||||
'Cargo must be offloaded (or the gate pass secured) before the final invoice can be raised.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const existing = await this.billingService.findInvoice(
|
||||
|
||||
@@ -276,6 +276,7 @@ export const PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES = [
|
||||
'OPERATION_CHANGES_REQUESTED',
|
||||
'ROAD_DISPATCH_PENDING',
|
||||
'IN_TRANSIT',
|
||||
'ARRIVED',
|
||||
'PAID',
|
||||
'COMPLETED',
|
||||
'CONTRACT_ACTIVE',
|
||||
|
||||
@@ -124,6 +124,15 @@ export class FilesService {
|
||||
await this.filesRepository.softDelete(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-slot a stored file under a new `code` (e.g. promote a staged
|
||||
* `business_license_pending` file to the live `business_license` code once a
|
||||
* change request is approved). Bytes and URL are untouched.
|
||||
*/
|
||||
async setCode(id: string, code: string): Promise<void> {
|
||||
await this.filesRepository.update(id, { code });
|
||||
}
|
||||
|
||||
findByResource(resourceId: string, resource: string): Promise<FileRecord[]> {
|
||||
return this.filesRepository.findByResource(resourceId, resource);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
import { NotificationsService } from './notifications.service';
|
||||
|
||||
/**
|
||||
* Best-effort SMS + email fan-out to a company's contacts. Looks up the
|
||||
* company's phone/email and sends the message over both channels, swallowing
|
||||
* per-channel failures so a missing provider never breaks the caller's flow.
|
||||
*/
|
||||
export async function sendCompanyChannels(
|
||||
dataSource: DataSource,
|
||||
notifications: NotificationsService,
|
||||
companyId: string,
|
||||
message: string,
|
||||
): Promise<void> {
|
||||
const [contact]: Array<{ phone: string | null; email: string | null }> =
|
||||
await dataSource.query(
|
||||
`SELECT COALESCE(phone, etrade_phone) AS phone, email
|
||||
FROM freight.companies
|
||||
WHERE id = $1 AND deleted_at IS NULL`,
|
||||
[companyId],
|
||||
);
|
||||
if (contact?.phone) {
|
||||
try {
|
||||
await notifications.directSend('sms', contact.phone, message);
|
||||
} catch {
|
||||
/* best-effort: SMS provider unavailable */
|
||||
}
|
||||
}
|
||||
if (contact?.email) {
|
||||
try {
|
||||
await notifications.directSend('email', contact.email, message);
|
||||
} catch {
|
||||
/* best-effort: email provider unavailable */
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,11 @@ import {
|
||||
import { BaseEntity } from "@edr/api-common";
|
||||
|
||||
@Entity({
|
||||
// Table lives in the freight schema like every other freight entity. Without
|
||||
// this the entity inherits the DataSource default schema (public), so TypeORM
|
||||
// queries public.otp_verifications — which doesn't exist — and OTP verify
|
||||
// (e.g. the contract-signature sudo gate) fails with a 500 QueryFailedError.
|
||||
schema: "freight",
|
||||
name: "otp_verifications",
|
||||
})
|
||||
export class OtpVerification extends BaseEntity{
|
||||
|
||||
@@ -72,8 +72,14 @@ export class OtpService {
|
||||
message: "OTP sent successfully",
|
||||
};
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
|
||||
// Log the real cause (DB/SMS/email failure) with its stack so a deployed
|
||||
// "Failed to send OTP" 400 is diagnosable from the API logs, not opaque.
|
||||
this.logger.error(
|
||||
`Failed to send OTP to ${target.email ?? target.phone}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
error instanceof Error ? error.stack : undefined,
|
||||
);
|
||||
throw new BadRequestException("Failed to send OTP");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ export class PriorityConfigsController {
|
||||
@ApiOperation({ summary: 'List priority configs' })
|
||||
findAll(@Query() query: Record<string, string>) {
|
||||
return this.service.findAll({
|
||||
type: (query['type'] as 'WAGON' | 'CURRENCY') || undefined,
|
||||
type: (query['type'] as 'WAGON' | 'CURRENCY' | 'CUSTOMS') || undefined,
|
||||
isActive: query['isActive'] !== undefined ? query['isActive'] === 'true' : undefined,
|
||||
page: query['page'] ? parseInt(query['page'], 10) : undefined,
|
||||
pageSize: query['pageSize'] ? parseInt(query['pageSize'], 10) : undefined,
|
||||
|
||||
@@ -2,9 +2,12 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsBoolean, IsIn, IsInt, IsOptional, IsString, Max, MaxLength, Min } from 'class-validator';
|
||||
|
||||
export class CreatePriorityConfigDto {
|
||||
@ApiProperty({ description: 'Config type: WAGON or CURRENCY', enum: ['WAGON', 'CURRENCY'] })
|
||||
@IsIn(['WAGON', 'CURRENCY'])
|
||||
type!: 'WAGON' | 'CURRENCY';
|
||||
@ApiProperty({
|
||||
description: 'Config type: WAGON, CURRENCY, or CUSTOMS',
|
||||
enum: ['WAGON', 'CURRENCY', 'CUSTOMS'],
|
||||
})
|
||||
@IsIn(['WAGON', 'CURRENCY', 'CUSTOMS'])
|
||||
type!: 'WAGON' | 'CURRENCY' | 'CUSTOMS';
|
||||
|
||||
@ApiProperty({ description: 'Human-readable label', maxLength: 100 })
|
||||
@IsString()
|
||||
@@ -12,7 +15,8 @@ export class CreatePriorityConfigDto {
|
||||
label!: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Currency code (e.g., USD, ETB). Required for type=CURRENCY, must be null for type=WAGON',
|
||||
description:
|
||||
'Currency code (e.g., USD, ETB). Required for type=CURRENCY, must be null for type=WAGON and type=CUSTOMS',
|
||||
maxLength: 5,
|
||||
})
|
||||
@IsOptional()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsBoolean, IsInt, IsOptional, IsString, IsUUID, Max, MaxLength, Min } from 'class-validator';
|
||||
import { IsBoolean, IsInt, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator';
|
||||
|
||||
export class CreateServiceTypeDto {
|
||||
@ApiProperty({ description: 'Service type display name', maxLength: 255 })
|
||||
@@ -32,17 +32,6 @@ export class CreateServiceTypeDto {
|
||||
@IsBoolean()
|
||||
includesCustoms?: boolean;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Priority bonus points awarded when this service is used (0–15)',
|
||||
default: 0,
|
||||
maximum: 15,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Max(15)
|
||||
priorityBonusPoints?: number;
|
||||
|
||||
@ApiPropertyOptional({ default: true })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Column, Entity, Index } from 'typeorm';
|
||||
@Index(['currency', 'type'])
|
||||
export class PriorityConfig extends BaseEntity {
|
||||
@Column({ name: 'type', type: 'varchar', length: 20 })
|
||||
type!: 'WAGON' | 'CURRENCY';
|
||||
type!: 'WAGON' | 'CURRENCY' | 'CUSTOMS';
|
||||
|
||||
@Column({ name: 'label', type: 'varchar', length: 100 })
|
||||
label!: string;
|
||||
|
||||
@@ -27,9 +27,6 @@ export class ServiceType extends BaseEntity {
|
||||
@Column({ name: 'includes_customs', type: 'boolean', default: false })
|
||||
includesCustoms!: boolean;
|
||||
|
||||
@Column({ name: 'priority_bonus_points', type: 'int', default: 0 })
|
||||
priorityBonusPoints!: number;
|
||||
|
||||
@Column({ name: 'is_active', type: 'boolean', default: true })
|
||||
isActive!: boolean;
|
||||
|
||||
|
||||
@@ -176,13 +176,12 @@ export class RuleEngineService {
|
||||
}
|
||||
|
||||
const serviceType = await this.serviceTypesRepo.findById(input.serviceTypeId);
|
||||
if (serviceType) {
|
||||
priorityScore += serviceType.priorityBonusPoints;
|
||||
}
|
||||
const includesCustoms = serviceType?.includesCustoms ?? false;
|
||||
|
||||
// Additive priority blocks, each keyed on the booking's total wagon count:
|
||||
// - WAGON rules apply regardless of currency.
|
||||
// - CURRENCY rules apply only when the payment currency matches.
|
||||
// - CUSTOMS rules apply only when the service type includes customs.
|
||||
const priorityConfigs = await this.priorityConfigsRepo.findAllActive();
|
||||
const wagonsInRange = (cfg: { minWagonCount: number; maxWagonCount: number }) =>
|
||||
input.totalWagons >= cfg.minWagonCount &&
|
||||
@@ -191,7 +190,8 @@ export class RuleEngineService {
|
||||
for (const cfg of priorityConfigs) {
|
||||
const applies =
|
||||
cfg.type === 'WAGON' ||
|
||||
(cfg.type === 'CURRENCY' && cfg.currency === input.paymentCurrency);
|
||||
(cfg.type === 'CURRENCY' && cfg.currency === input.paymentCurrency) ||
|
||||
(cfg.type === 'CUSTOMS' && includesCustoms);
|
||||
if (applies && wagonsInRange(cfg)) {
|
||||
priorityScore += cfg.scorePoints;
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ export class PriorityConfigsService {
|
||||
) {}
|
||||
|
||||
async findAll(filter: {
|
||||
type?: 'WAGON' | 'CURRENCY';
|
||||
type?: 'WAGON' | 'CURRENCY' | 'CUSTOMS';
|
||||
isActive?: boolean;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
@@ -87,12 +87,15 @@ export class PriorityConfigsService {
|
||||
await this.displayOrder.moveOne(PriorityConfig, 'displayOrder', id, direction);
|
||||
}
|
||||
|
||||
private validateCurrencyField(type: 'WAGON' | 'CURRENCY', currency: string | undefined | null): void {
|
||||
private validateCurrencyField(
|
||||
type: 'WAGON' | 'CURRENCY' | 'CUSTOMS',
|
||||
currency: string | undefined | null,
|
||||
): void {
|
||||
if (type === 'CURRENCY' && !currency) {
|
||||
throw new BadRequestException('currency field is required when type is CURRENCY');
|
||||
}
|
||||
if (type === 'WAGON' && currency) {
|
||||
throw new BadRequestException('currency field must be null when type is WAGON');
|
||||
if (type !== 'CURRENCY' && currency) {
|
||||
throw new BadRequestException(`currency field must be null when type is ${type}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,7 +76,6 @@ export class ServiceTypesService {
|
||||
includesFirstMile: dto.includesFirstMile ?? false,
|
||||
includesLastMile: dto.includesLastMile ?? false,
|
||||
includesCustoms: dto.includesCustoms ?? false,
|
||||
priorityBonusPoints: dto.priorityBonusPoints ?? 0,
|
||||
isActive: dto.isActive ?? true,
|
||||
displayOrder,
|
||||
});
|
||||
|
||||
@@ -61,6 +61,12 @@ export class TrainSchedule extends BaseEntity {
|
||||
@Column({ name: 'train_number', type: 'varchar', length: 20, nullable: true })
|
||||
trainNumber?: string | null;
|
||||
|
||||
// Human-facing unique schedule reference (S-YYYY-NNNNN). Shown on the schedule
|
||||
// list, booking windows, and load lists. Assigned at creation from the highest
|
||||
// sequence issued this year (see TrainSchedulesRepository.maxReferenceSequence).
|
||||
@Column({ name: 'reference', type: 'varchar', length: 20, nullable: true, unique: true })
|
||||
reference?: string | null;
|
||||
|
||||
@Column({ name: 'direction', type: 'varchar', length: 10, nullable: true })
|
||||
direction?: string | null;
|
||||
|
||||
|
||||
@@ -58,4 +58,22 @@ export class TrainSchedulesRepository extends BaseRepository<TrainSchedule> {
|
||||
): Promise<void> {
|
||||
await this.repo(manager).update(id, { status, ...extra } as never);
|
||||
}
|
||||
|
||||
/**
|
||||
* Highest NNNNN sequence already issued for `S-<year>-…` references. Includes
|
||||
* soft-deleted rows so the next number never reuses one still occupying the
|
||||
* unique index (see the same pattern on BookingsRepository).
|
||||
*/
|
||||
async maxReferenceSequence(year: number): Promise<number> {
|
||||
const row = await this.repository
|
||||
.createQueryBuilder('schedule')
|
||||
.withDeleted()
|
||||
.select(
|
||||
"COALESCE(MAX(CAST(SUBSTRING(schedule.reference FROM '[0-9]+$') AS int)), 0)",
|
||||
'max',
|
||||
)
|
||||
.where('schedule.reference LIKE :prefix', { prefix: `S-${year}-%` })
|
||||
.getRawOne<{ max: string | number | null }>();
|
||||
return Number(row?.max ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,8 @@ describe('BookingBatchService — PAID reconcile', () => {
|
||||
findPaidUnlinkedForSchedule: jest.Mock;
|
||||
findBatchPool: jest.Mock;
|
||||
findBatchPoolByRouteDay: jest.Mock;
|
||||
findBatchPoolByCorridorDay: jest.Mock;
|
||||
findUnacceptedForRouteDay: jest.Mock;
|
||||
findReservedForSchedule: jest.Mock;
|
||||
update: jest.Mock;
|
||||
};
|
||||
@@ -53,6 +55,8 @@ describe('BookingBatchService — PAID reconcile', () => {
|
||||
findPaidUnlinkedForSchedule: jest.fn().mockResolvedValue([]),
|
||||
findBatchPool: jest.fn().mockResolvedValue([]),
|
||||
findBatchPoolByRouteDay: jest.fn().mockResolvedValue([]),
|
||||
findBatchPoolByCorridorDay: jest.fn().mockResolvedValue([]),
|
||||
findUnacceptedForRouteDay: jest.fn().mockResolvedValue([]),
|
||||
findReservedForSchedule: jest.fn().mockResolvedValue([]),
|
||||
update: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
@@ -121,7 +125,10 @@ describe('BookingBatchService — PAID reconcile', () => {
|
||||
notifier as never,
|
||||
{ addTimeout: jest.fn(), deleteTimeout: jest.fn(), doesExist: jest.fn() } as never,
|
||||
trainSchedulingService as never,
|
||||
{ syncPayableDueDate: jest.fn(), expirePayable: jest.fn() } as never,
|
||||
{
|
||||
syncPayableDueDate: jest.fn().mockResolvedValue(undefined),
|
||||
expirePayable: jest.fn().mockResolvedValue(undefined),
|
||||
} as never,
|
||||
{ emitPhase: jest.fn() } as never,
|
||||
);
|
||||
});
|
||||
@@ -196,6 +203,8 @@ describe('BookingBatchService — PAID reconcile', () => {
|
||||
cargoTotalWeightVgm: 10,
|
||||
freightType: 'CONTAINER',
|
||||
bookingContainers: [],
|
||||
originYardId,
|
||||
destinationYardId,
|
||||
}) as unknown as Booking;
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -227,13 +236,15 @@ describe('BookingBatchService — PAID reconcile', () => {
|
||||
trainSetId: `set-${id}`,
|
||||
trainSet: { locomotive: smallLoco },
|
||||
scheduleBookings: [],
|
||||
originStationId: originYardId,
|
||||
destinationStationId: destinationYardId,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('spills overflow to the next train by priority, then reports unplaced', async () => {
|
||||
// 3 commercial bookings, descending priority; only 1 fits per train (2 total).
|
||||
bookingsRepository.findBatchPoolByRouteDay.mockResolvedValue([
|
||||
bookingsRepository.findBatchPoolByCorridorDay.mockResolvedValue([
|
||||
commercial('hi', 30),
|
||||
commercial('mid', 20),
|
||||
commercial('lo', 10),
|
||||
@@ -241,9 +252,8 @@ describe('BookingBatchService — PAID reconcile', () => {
|
||||
|
||||
const touched = await service.fillRouteDay(originYardId, destinationYardId, day);
|
||||
|
||||
expect(bookingsRepository.findBatchPoolByRouteDay).toHaveBeenCalledWith(
|
||||
originYardId,
|
||||
destinationYardId,
|
||||
expect(bookingsRepository.findBatchPoolByCorridorDay).toHaveBeenCalledWith(
|
||||
[originYardId, destinationYardId],
|
||||
day,
|
||||
);
|
||||
// Both trains were processed.
|
||||
@@ -258,7 +268,7 @@ describe('BookingBatchService — PAID reconcile', () => {
|
||||
});
|
||||
|
||||
it('reserves the chosen train id on each commercial booking', async () => {
|
||||
bookingsRepository.findBatchPoolByRouteDay.mockResolvedValue([commercial('hi', 30)]);
|
||||
bookingsRepository.findBatchPoolByCorridorDay.mockResolvedValue([commercial('hi', 30)]);
|
||||
|
||||
await service.fillRouteDay(originYardId, destinationYardId, day);
|
||||
|
||||
@@ -286,9 +296,11 @@ describe('BookingBatchService — PAID reconcile', () => {
|
||||
freightType: 'CONTAINER',
|
||||
consolidationPartnerId: partnerId,
|
||||
bookingContainers: [{ quantity: 1 }],
|
||||
originYardId,
|
||||
destinationYardId,
|
||||
}) as unknown as Booking;
|
||||
|
||||
bookingsRepository.findBatchPoolByRouteDay.mockResolvedValue([
|
||||
bookingsRepository.findBatchPoolByCorridorDay.mockResolvedValue([
|
||||
consol('a', 'b', 30),
|
||||
consol('b', 'a', 20),
|
||||
]);
|
||||
@@ -313,9 +325,11 @@ describe('BookingBatchService — PAID reconcile', () => {
|
||||
freightType: 'CONTAINER',
|
||||
consolidationPartnerId: 'missing-partner',
|
||||
bookingContainers: [{ quantity: 1 }],
|
||||
originYardId,
|
||||
destinationYardId,
|
||||
} as unknown as Booking;
|
||||
|
||||
bookingsRepository.findBatchPoolByRouteDay.mockResolvedValue([lonely]);
|
||||
bookingsRepository.findBatchPoolByCorridorDay.mockResolvedValue([lonely]);
|
||||
|
||||
await service.fillRouteDay(originYardId, destinationYardId, day);
|
||||
|
||||
@@ -323,4 +337,177 @@ describe('BookingBatchService — PAID reconcile', () => {
|
||||
expect(notifier.payNow).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('expireUnacceptedForRouteDay — doc-review sweep', () => {
|
||||
const originYardId = 'yard-origin';
|
||||
const destinationYardId = 'yard-dest';
|
||||
const day = '2026-06-20';
|
||||
|
||||
const pendingBooking = {
|
||||
id: 'pending-1',
|
||||
reference: 'BK-PENDING-1',
|
||||
status: 'OPERATION_REQUEST_PENDING',
|
||||
isGovernment: false,
|
||||
originYardId,
|
||||
destinationYardId,
|
||||
} as unknown as Booking;
|
||||
|
||||
beforeEach(() => {
|
||||
// One fillable schedule on this corridor/day so corridorYardsForRouteDay
|
||||
// resolves a non-empty yard set (legacy two-stop route → [origin, dest]).
|
||||
trainSchedulesRepository.findAll.mockResolvedValue([
|
||||
{
|
||||
id: 'sched-1',
|
||||
originStationId: originYardId,
|
||||
destinationStationId: destinationYardId,
|
||||
scheduledDepartureDate: new Date('2026-06-20T06:00:00.000Z'),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('expires each un-accepted booking and clears its scheduled day', async () => {
|
||||
bookingsRepository.findUnacceptedForRouteDay.mockResolvedValue([pendingBooking]);
|
||||
|
||||
await service.expireUnacceptedForRouteDay({
|
||||
originYardId,
|
||||
destinationYardId,
|
||||
day,
|
||||
});
|
||||
|
||||
expect(bookingsRepository.findUnacceptedForRouteDay).toHaveBeenCalledWith(
|
||||
expect.arrayContaining([originYardId, destinationYardId]),
|
||||
day,
|
||||
);
|
||||
expect(bookingsRepository.update).toHaveBeenCalledWith(
|
||||
'pending-1',
|
||||
expect.objectContaining({
|
||||
status: 'EXPIRED',
|
||||
schedulingStatus: 'ELIGIBLE',
|
||||
scheduledDate: null,
|
||||
}),
|
||||
);
|
||||
expect(notifier.expired).toHaveBeenCalledWith(pendingBooking);
|
||||
});
|
||||
|
||||
it('is a no-op when nothing is un-accepted', async () => {
|
||||
bookingsRepository.findUnacceptedForRouteDay.mockResolvedValue([]);
|
||||
|
||||
await service.expireUnacceptedForRouteDay({
|
||||
originYardId,
|
||||
destinationYardId,
|
||||
day,
|
||||
});
|
||||
|
||||
expect(bookingsRepository.update).not.toHaveBeenCalled();
|
||||
expect(notifier.expired).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does nothing when the route-day has no fillable schedule', async () => {
|
||||
trainSchedulesRepository.findAll.mockResolvedValue([]);
|
||||
|
||||
await service.expireUnacceptedForRouteDay({
|
||||
originYardId,
|
||||
destinationYardId,
|
||||
day,
|
||||
});
|
||||
|
||||
expect(bookingsRepository.findUnacceptedForRouteDay).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('maybeOfferPartial — split-eligibility gate', () => {
|
||||
const importGeneral = {
|
||||
id: 'b1',
|
||||
reference: 'b1',
|
||||
isGovernment: false,
|
||||
tradeDirection: 'IMPORT',
|
||||
contractKind: 'GENERAL',
|
||||
consolidationPartnerId: null,
|
||||
} as unknown as Booking;
|
||||
|
||||
const call = (booking: Booking, isPair: boolean): boolean =>
|
||||
(
|
||||
service as unknown as {
|
||||
isSplitEligible: (b: Booking, p: boolean) => boolean;
|
||||
}
|
||||
).isSplitEligible(booking, isPair);
|
||||
|
||||
it('allows IMPORT + GENERAL when splitService is present', () => {
|
||||
const withSplit = new BookingBatchService(
|
||||
dataSource as never,
|
||||
bookingsRepository as never,
|
||||
trainSchedulesRepository as never,
|
||||
trainScheduleBookingsRepository as never,
|
||||
notifier as never,
|
||||
{ addTimeout: jest.fn(), deleteTimeout: jest.fn(), doesExist: jest.fn() } as never,
|
||||
trainSchedulingService as never,
|
||||
{ syncPayableDueDate: jest.fn(), expirePayable: jest.fn() } as never,
|
||||
{ emitPhase: jest.fn() } as never,
|
||||
undefined,
|
||||
{ findOpenOffer: jest.fn() } as never,
|
||||
);
|
||||
const eligible = (
|
||||
withSplit as unknown as {
|
||||
isSplitEligible: (b: Booking, p: boolean) => boolean;
|
||||
}
|
||||
).isSplitEligible(importGeneral, false);
|
||||
expect(eligible).toBe(true);
|
||||
});
|
||||
|
||||
it('allows IMPORT + ONE_TIME (promoted to GENERAL on split)', () => {
|
||||
const withSplit = new BookingBatchService(
|
||||
dataSource as never,
|
||||
bookingsRepository as never,
|
||||
trainSchedulesRepository as never,
|
||||
trainScheduleBookingsRepository as never,
|
||||
notifier as never,
|
||||
{ addTimeout: jest.fn(), deleteTimeout: jest.fn(), doesExist: jest.fn() } as never,
|
||||
trainSchedulingService as never,
|
||||
{ syncPayableDueDate: jest.fn(), expirePayable: jest.fn() } as never,
|
||||
{ emitPhase: jest.fn() } as never,
|
||||
undefined,
|
||||
{ findOpenOffer: jest.fn() } as never,
|
||||
);
|
||||
const eligible = (
|
||||
withSplit as unknown as {
|
||||
isSplitEligible: (b: Booking, p: boolean) => boolean;
|
||||
}
|
||||
).isSplitEligible(
|
||||
{ ...importGeneral, contractKind: 'ONE_TIME' } as Booking,
|
||||
false,
|
||||
);
|
||||
expect(eligible).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects when splitService is absent (default test service)', () => {
|
||||
// `service` from the outer beforeEach was built without a splitService.
|
||||
expect(call(importGeneral, false)).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects EXPORT, government, consolidated pairs, and other directions', () => {
|
||||
const withSplit = new BookingBatchService(
|
||||
dataSource as never,
|
||||
bookingsRepository as never,
|
||||
trainSchedulesRepository as never,
|
||||
trainScheduleBookingsRepository as never,
|
||||
notifier as never,
|
||||
{ addTimeout: jest.fn(), deleteTimeout: jest.fn(), doesExist: jest.fn() } as never,
|
||||
trainSchedulingService as never,
|
||||
{ syncPayableDueDate: jest.fn(), expirePayable: jest.fn() } as never,
|
||||
{ emitPhase: jest.fn() } as never,
|
||||
undefined,
|
||||
{ findOpenOffer: jest.fn() } as never,
|
||||
);
|
||||
const check = (
|
||||
withSplit as unknown as {
|
||||
isSplitEligible: (b: Booking, p: boolean) => boolean;
|
||||
}
|
||||
).isSplitEligible.bind(withSplit);
|
||||
|
||||
expect(check({ ...importGeneral, tradeDirection: 'EXPORT' } as Booking, false)).toBe(false);
|
||||
expect(check({ ...importGeneral, isGovernment: true } as Booking, false)).toBe(false);
|
||||
expect(check(importGeneral, true)).toBe(false); // consolidated pair
|
||||
expect(check({ ...importGeneral, contractKind: null } as Booking, false)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,6 +15,7 @@ import { Booking } from '../bookings/entities/booking.entity';
|
||||
import { BookingsRepository } from '../bookings/bookings.repository';
|
||||
import { Locomotive } from '../locomotives/entities/locomotive.entity';
|
||||
import { formatRouteLabel } from '../routes/entities/route.entity';
|
||||
import { RouteMilestone } from '../routes/entities/route-milestone.entity';
|
||||
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
||||
import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity';
|
||||
import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository';
|
||||
@@ -42,13 +43,14 @@ import { ClearanceMilestoneService } from '../contracts/clearance-milestone.serv
|
||||
import { BookingSplitService } from './booking-split.service';
|
||||
import { BookingWindowGateway } from './booking-window.gateway';
|
||||
import { MAX_TEU_SLOTS_PER_WAGON } from './wagon-plan.util';
|
||||
import {
|
||||
Capacity,
|
||||
CorridorBudget,
|
||||
CorridorLeg,
|
||||
stopYardsFor,
|
||||
} from './corridor-capacity.util';
|
||||
|
||||
/** A train's remaining capacity along the three physical limits the batch enforces. */
|
||||
export interface Capacity {
|
||||
wagons: number;
|
||||
weightTons: number;
|
||||
lengthMeters: number;
|
||||
}
|
||||
export type { Capacity } from './corridor-capacity.util';
|
||||
|
||||
/** A day-level pool key: all trains on this route departing on this EAT day. */
|
||||
interface RouteDayGroup {
|
||||
@@ -78,6 +80,10 @@ export interface BatchBoardBooking {
|
||||
lengthMeters: number;
|
||||
paymentDeadline: string | null;
|
||||
state: BatchBoardBookingState;
|
||||
/** Rule-engine priority score used to rank the batch (higher = boards first). */
|
||||
priorityScore: number;
|
||||
/** CONTAINER | BULK — for the priority-tracking visuals. */
|
||||
freightType: string | null;
|
||||
}
|
||||
|
||||
export type BookingAllocationStatus =
|
||||
@@ -171,6 +177,8 @@ export interface BatchBoardSchedule {
|
||||
/** Weight committed on the train (allocated + selected-for-batch). */
|
||||
usedWeightTons: number;
|
||||
maxWeightTons: number | null;
|
||||
/** Wagon-slot cap for the train (locomotive/wagon-type derived). */
|
||||
maxWagons: number | null;
|
||||
};
|
||||
counts: {
|
||||
allocated: number;
|
||||
@@ -284,6 +292,9 @@ export class BookingBatchService implements OnModuleInit {
|
||||
* schedule-scoped — only the fill is day-level).
|
||||
*/
|
||||
async processRouteDay(group: RouteDayGroup): Promise<void> {
|
||||
this.logger.log(
|
||||
`[BATCH] processRouteDay START ${group.originYardId}->${group.destinationYardId} ${group.day}`,
|
||||
);
|
||||
const scheduleIds = await this.fillRouteDay(
|
||||
group.originYardId,
|
||||
group.destinationYardId,
|
||||
@@ -459,18 +470,14 @@ export class BookingBatchService implements OnModuleInit {
|
||||
throw new BadRequestException('Booking has no scheduled date');
|
||||
}
|
||||
const day = eatDay(new Date(booking.scheduledDate));
|
||||
// Corridor-aware: any train whose route carries the booking's origin
|
||||
// strictly before its destination qualifies — a Dire→Djibouti booking may
|
||||
// ride an Addis→…→Djibouti train. The leg check below (legOf) enforces the
|
||||
// stop order, so we fetch the day's open trains without endpoint filters.
|
||||
const corridor = await this.trainSchedulesRepository.findAll({
|
||||
where: [
|
||||
{
|
||||
originStationId: booking.originYardId,
|
||||
destinationStationId: booking.destinationYardId,
|
||||
status: TrainScheduleStatusEnum.Draft,
|
||||
},
|
||||
{
|
||||
originStationId: booking.originYardId,
|
||||
destinationStationId: booking.destinationYardId,
|
||||
status: TrainScheduleStatusEnum.Scheduled,
|
||||
},
|
||||
{ status: TrainScheduleStatusEnum.Draft },
|
||||
{ status: TrainScheduleStatusEnum.Scheduled },
|
||||
],
|
||||
});
|
||||
const candidates = corridor
|
||||
@@ -493,6 +500,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
const rules = await this.loadGlobalRules();
|
||||
const wagonLengths = await this.loadWagonLengths();
|
||||
const required = need ?? this.needFor(booking, wagonLengths);
|
||||
let corridorMatched = false;
|
||||
for (const candidate of candidates) {
|
||||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(
|
||||
candidate.id,
|
||||
@@ -500,8 +508,16 @@ export class BookingBatchService implements OnModuleInit {
|
||||
const locomotive = schedule?.trainSet?.locomotive;
|
||||
if (!schedule || !locomotive) continue;
|
||||
const limits = await this.capacityLimits(locomotive, rules);
|
||||
const budget = await this.remainingCapacity(schedule, limits, wagonLengths);
|
||||
if (this.fits(required, budget)) return schedule.id;
|
||||
const budget = await this.remainingBudget(schedule, limits, wagonLengths);
|
||||
const leg = budget.legOf(booking.originYardId, booking.destinationYardId);
|
||||
if (!leg) continue; // this train's route doesn't carry the booking's leg
|
||||
corridorMatched = true;
|
||||
if (budget.fits(required, leg)) return schedule.id;
|
||||
}
|
||||
if (!corridorMatched) {
|
||||
throw new ConflictException(
|
||||
'No export train is accepting bookings for this day',
|
||||
);
|
||||
}
|
||||
throw new ConflictException('Train is full — no export capacity left for this day');
|
||||
}
|
||||
@@ -631,6 +647,8 @@ export class BookingBatchService implements OnModuleInit {
|
||||
? b.paymentDeadline.toISOString()
|
||||
: null,
|
||||
state: this.boardState(b, linkedIds.has(b.id)),
|
||||
priorityScore: Number(b.priorityScore ?? 0),
|
||||
freightType: b.freightType ?? null,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -719,6 +737,8 @@ export class BookingBatchService implements OnModuleInit {
|
||||
? b.paymentDeadline.toISOString()
|
||||
: null,
|
||||
state: this.boardState(b, linkedIds.has(b.id)),
|
||||
priorityScore: Number(b.priorityScore ?? 0),
|
||||
freightType: b.freightType ?? null,
|
||||
fullyExecutedAt: b.fullyExecutedAt
|
||||
? b.fullyExecutedAt.toISOString()
|
||||
: null,
|
||||
@@ -848,7 +868,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
maxTrainLengthMeters: Number(loco.maxTrainLengthMeters),
|
||||
}
|
||||
: null,
|
||||
capacity: this.computeBoardCapacity(items, loco),
|
||||
capacity: this.computeBoardCapacity(items, loco, s.maxWagons ?? null),
|
||||
counts: {
|
||||
allocated: items.filter((i) => i.state === "ALLOCATED").length,
|
||||
selectedForBatch: items.filter((i) => i.state === "SELECTED_FOR_BATCH")
|
||||
@@ -887,6 +907,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
lengthMeters: number;
|
||||
}>,
|
||||
loco: Locomotive | null,
|
||||
maxWagons: number | null,
|
||||
): BatchBoardSchedule["capacity"] {
|
||||
const allocated = items.filter((i) => i.state === "ALLOCATED");
|
||||
const committed = items.filter(
|
||||
@@ -903,6 +924,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
Math.round(committed.reduce((sum, i) => sum + i.weightTons, 0) * 100) /
|
||||
100,
|
||||
maxWeightTons: loco ? Number(loco.maxPullWeightTons) : null,
|
||||
maxWagons: maxWagons ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -941,7 +963,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
maxTrainLengthMeters: Number(loco.maxTrainLengthMeters),
|
||||
}
|
||||
: null,
|
||||
capacity: this.computeBoardCapacity(items, loco),
|
||||
capacity: this.computeBoardCapacity(items, loco, s.maxWagons ?? null),
|
||||
counts: {
|
||||
allocated: items.filter((i) => i.state === "ALLOCATED").length,
|
||||
selectedForBatch: items.filter((i) => i.state === "SELECTED_FOR_BATCH")
|
||||
@@ -1009,8 +1031,8 @@ export class BookingBatchService implements OnModuleInit {
|
||||
const wagonLengths = await this.loadWagonLengths();
|
||||
const limits = await this.capacityLimits(locomotive, rules);
|
||||
await this.syncScheduleMaxWagons(schedule, locomotive, rules);
|
||||
let budget = await this.remainingCapacity(schedule, limits, wagonLengths);
|
||||
if (budget.wagons <= 0) {
|
||||
const budget = await this.remainingBudget(schedule, limits, wagonLengths);
|
||||
if (budget.maxRemaining().wagons <= 0) {
|
||||
await this.setWindow(scheduleId, "FULL");
|
||||
return;
|
||||
}
|
||||
@@ -1018,6 +1040,16 @@ export class BookingBatchService implements OnModuleInit {
|
||||
const pool = await this.bookingsRepository.findBatchPool(scheduleId);
|
||||
const units = this.groupConsolidatedPool(pool);
|
||||
let armed = false;
|
||||
let reservedThisPass = 0;
|
||||
|
||||
// Batch fill trace: caps + pool at entry. Kept on debug level — invaluable when
|
||||
// reservations trickle instead of landing in one pass (a reserve() throwing
|
||||
// mid-loop, e.g. schema drift, or a mis-synced capacity cap).
|
||||
this.logger.debug(
|
||||
`[fillSchedule ${scheduleId}] limits=${JSON.stringify(limits)} ` +
|
||||
`maxWagons=${schedule.maxWagons} remaining=${JSON.stringify(budget.maxRemaining())} ` +
|
||||
`poolSize=${pool.length} units=${units.length}`,
|
||||
);
|
||||
|
||||
for (const unit of units) {
|
||||
const { primary: booking, partner } = unit;
|
||||
@@ -1026,34 +1058,71 @@ export class BookingBatchService implements OnModuleInit {
|
||||
? this.combinedNeed(booking, partner, wagonLengths)
|
||||
: this.needFor(booking, wagonLengths);
|
||||
const isGov = booking.isGovernment || (partner?.isGovernment ?? false);
|
||||
// Consolidated partners always share one corridor, so the primary's leg
|
||||
// stands for the pair.
|
||||
const leg = budget.legForYards(booking.originYardId, booking.destinationYardId);
|
||||
|
||||
if (!this.fits(need, budget)) {
|
||||
// Per-unit fit trace: which axis (wagons/weight/length) admits or rejects.
|
||||
this.logger.debug(
|
||||
`[fillSchedule ${scheduleId}] unit ${booking.reference}: need=${JSON.stringify(need)} ` +
|
||||
`roomOnLeg=${JSON.stringify(budget.remainingFor(leg))} fits=${budget.fits(need, leg)}`,
|
||||
);
|
||||
|
||||
if (!budget.fits(need, leg)) {
|
||||
if (isGov) {
|
||||
budget = await this.preemptForGovernment(
|
||||
const freed = await this.preemptForGovernment(
|
||||
scheduleId,
|
||||
need,
|
||||
leg,
|
||||
budget,
|
||||
wagonLengths,
|
||||
);
|
||||
if (!this.fits(need, budget)) continue; // still doesn't fit even after preempt
|
||||
if (!freed) continue; // still doesn't fit even after preempt
|
||||
} else {
|
||||
// Doesn't fit whole. A split-eligible import booking is offered the part
|
||||
// that fits in the remaining room (top-up path splits the boundary
|
||||
// booking, mirroring fillRouteDay); otherwise skip and try the next.
|
||||
const cand: { id: string; budget: CorridorBudget; armed: boolean } = {
|
||||
id: scheduleId,
|
||||
budget,
|
||||
armed,
|
||||
};
|
||||
if (await this.maybeOfferPartial(booking, isPair, [cand], need)) {
|
||||
armed = cand.armed;
|
||||
continue;
|
||||
}
|
||||
continue; // skip a unit that exceeds weight/length/wagons, try the next
|
||||
}
|
||||
}
|
||||
|
||||
if (isGov) {
|
||||
await this.allocate(scheduleId, booking, "gov");
|
||||
if (partner) await this.allocate(scheduleId, partner, "gov");
|
||||
} else {
|
||||
await this.reserve(booking, scheduleId);
|
||||
if (partner) await this.reserve(partner, scheduleId);
|
||||
armed = true;
|
||||
// Isolate each unit so a throw in reserve/allocate (e.g. billing hiccup)
|
||||
// can't abort the whole top-up pass and leave the rest to trickle in one
|
||||
// per tick. Log + skip the failing unit, keep going.
|
||||
try {
|
||||
if (isGov) {
|
||||
await this.allocate(scheduleId, booking, "gov");
|
||||
if (partner) await this.allocate(scheduleId, partner, "gov");
|
||||
} else {
|
||||
await this.reserve(booking, scheduleId);
|
||||
if (partner) await this.reserve(partner, scheduleId);
|
||||
armed = true;
|
||||
}
|
||||
budget.subtract(need, leg);
|
||||
reservedThisPass += 1;
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`[fillSchedule ${scheduleId}] reserve/allocate FAILED for ${booking.reference} ` +
|
||||
`— skipping this unit, continuing: ${(err as Error).message}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
budget = this.subtract(budget, need);
|
||||
if (budget.wagons <= 0) break; // no wagon slots left — nothing more can board
|
||||
if (budget.maxRemaining().wagons <= 0) break; // every leg exhausted — nothing more can board
|
||||
}
|
||||
|
||||
if (budget.wagons <= 0) await this.setWindow(scheduleId, "FULL");
|
||||
this.logger.log(
|
||||
`[fillSchedule ${scheduleId}] reserved ${reservedThisPass}/${units.length} unit(s) this pass`,
|
||||
);
|
||||
if (budget.maxRemaining().wagons <= 0) await this.setWindow(scheduleId, "FULL");
|
||||
if (armed) this.armSettle(scheduleId);
|
||||
void this.triggerWagonAllocation(scheduleId);
|
||||
}
|
||||
@@ -1106,8 +1175,8 @@ export class BookingBatchService implements OnModuleInit {
|
||||
const rules = await this.loadGlobalRules();
|
||||
const wagonLengths = await this.loadWagonLengths();
|
||||
|
||||
// Live per-schedule budget + arm flag, in departure order.
|
||||
const trains: Array<{ id: string; budget: Capacity; armed: boolean }> = [];
|
||||
// Live per-schedule corridor budget + arm flag, in departure order.
|
||||
const trains: Array<{ id: string; budget: CorridorBudget; armed: boolean }> = [];
|
||||
for (const id of scheduleIds) {
|
||||
const schedule =
|
||||
await this.trainSchedulesRepository.findByIdWithFullGraph(id);
|
||||
@@ -1120,24 +1189,32 @@ export class BookingBatchService implements OnModuleInit {
|
||||
}
|
||||
const limits = await this.capacityLimits(locomotive, rules);
|
||||
await this.syncScheduleMaxWagons(schedule, locomotive, rules);
|
||||
const budget = await this.remainingCapacity(
|
||||
schedule,
|
||||
limits,
|
||||
wagonLengths,
|
||||
);
|
||||
const budget = await this.remainingBudget(schedule, limits, wagonLengths);
|
||||
trains.push({ id, budget, armed: false });
|
||||
}
|
||||
if (trains.length === 0) return [];
|
||||
|
||||
const pool = await this.bookingsRepository.findBatchPoolByRouteDay(
|
||||
originYardId,
|
||||
destinationYardId,
|
||||
// The day pool covers every booking whose leg lies somewhere on one of the
|
||||
// day's corridors — full-route AND sub-corridor (e.g. Dire→Djibouti on an
|
||||
// Addis→Djibouti train). Which train actually takes a booking is decided
|
||||
// by the per-train legOf check below.
|
||||
const corridorYards = [...new Set(trains.flatMap((t) => t.budget.stops))];
|
||||
const pool = await this.bookingsRepository.findBatchPoolByCorridorDay(
|
||||
corridorYards,
|
||||
day,
|
||||
);
|
||||
// Consolidated partners collapse into one atomic unit (both-or-neither); a
|
||||
// consolidated booking whose partner isn't ready this cycle is skipped.
|
||||
const units = this.groupConsolidatedPool(pool);
|
||||
|
||||
// Batch fill trace: each train's caps + the day pool size at entry.
|
||||
this.logger.debug(
|
||||
`[fillRouteDay ${originYardId}->${destinationYardId} ${day}] ` +
|
||||
`trains=${trains.map((t) => `${t.id}:${JSON.stringify(t.budget.maxRemaining())}`).join(",")} ` +
|
||||
`poolSize=${pool.length} units=${units.length}`,
|
||||
);
|
||||
let reservedThisPass = 0;
|
||||
|
||||
for (const unit of units) {
|
||||
const { primary: booking, partner } = unit;
|
||||
const isPair = partner != null;
|
||||
@@ -1146,20 +1223,42 @@ export class BookingBatchService implements OnModuleInit {
|
||||
: this.needFor(booking, wagonLengths);
|
||||
const isGov = booking.isGovernment || (partner?.isGovernment ?? false);
|
||||
|
||||
// First train (earliest departure) that fits this unit as-is.
|
||||
let target = trains.find((t) => this.fits(need, t.budget));
|
||||
const legOn = (t: { budget: CorridorBudget }): CorridorLeg | null =>
|
||||
t.budget.legOf(booking.originYardId, booking.destinationYardId);
|
||||
|
||||
// First train (earliest departure) whose corridor carries this booking's
|
||||
// leg and still fits it as-is.
|
||||
let target = trains.find((t) => {
|
||||
const leg = legOn(t);
|
||||
return leg != null && t.budget.fits(need, leg);
|
||||
});
|
||||
|
||||
// Per-unit trace: chosen train + each train's remaining room on this leg.
|
||||
this.logger.debug(
|
||||
`[fillRouteDay] unit ${booking.reference}: need=${JSON.stringify(need)} ` +
|
||||
`targetTrain=${target?.id ?? "none"} ` +
|
||||
`rooms=${trains
|
||||
.map((t) => {
|
||||
const leg = legOn(t);
|
||||
return leg ? `${t.id}:${JSON.stringify(t.budget.remainingFor(leg))}` : `${t.id}:offleg`;
|
||||
})
|
||||
.join(",")}`,
|
||||
);
|
||||
|
||||
if (!target && isGov) {
|
||||
// Government fits nowhere on its own — try to preempt commercial
|
||||
// on each train (earliest first) until one frees enough room.
|
||||
// on each corridor-matching train (earliest first) until one frees room.
|
||||
for (const t of trains) {
|
||||
t.budget = await this.preemptForGovernment(
|
||||
const leg = legOn(t);
|
||||
if (!leg) continue;
|
||||
const freed = await this.preemptForGovernment(
|
||||
t.id,
|
||||
need,
|
||||
leg,
|
||||
t.budget,
|
||||
wagonLengths,
|
||||
);
|
||||
if (this.fits(need, t.budget)) {
|
||||
if (freed) {
|
||||
target = t;
|
||||
break;
|
||||
}
|
||||
@@ -1167,52 +1266,51 @@ export class BookingBatchService implements OnModuleInit {
|
||||
}
|
||||
|
||||
if (!target) {
|
||||
// A consolidated pair is placed whole or not at all — never split.
|
||||
if (!isPair) {
|
||||
// Fits no train whole. Import GENERAL-contract commercial bookings get a
|
||||
// partial-capacity offer on the train with the most free wagons.
|
||||
const partialTarget = [...trains]
|
||||
.filter((t) => t.budget.wagons >= 1)
|
||||
.sort((a, b) => b.budget.wagons - a.budget.wagons)[0];
|
||||
if (
|
||||
partialTarget &&
|
||||
!booking.isGovernment &&
|
||||
booking.tradeDirection === "IMPORT" &&
|
||||
booking.contractKind === "GENERAL" &&
|
||||
this.splitService
|
||||
) {
|
||||
const offered = await this.tryPartialOffer(
|
||||
booking,
|
||||
partialTarget.id,
|
||||
partialTarget.budget,
|
||||
need,
|
||||
);
|
||||
if (offered) {
|
||||
partialTarget.budget = this.subtract(partialTarget.budget, offered);
|
||||
partialTarget.armed = true;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fits no train whole. A split-eligible booking is offered the largest
|
||||
// part that fits on the train with the most free wagons on its leg (this
|
||||
// covers both "fits nowhere" and the boundary case where earlier bookings
|
||||
// already consumed most of the room). Consolidated pairs / government /
|
||||
// non-import never split — isSplitEligible guards that. Passing the live
|
||||
// `trains` entries lets maybeOfferPartial mutate the chosen budget/armed.
|
||||
const offered = await this.maybeOfferPartial(booking, isPair, trains, need);
|
||||
if (offered) continue;
|
||||
// Stays in the pool, retried next batch/window cycle.
|
||||
this.notifier.unplaced(booking, day);
|
||||
if (partner) this.notifier.unplaced(partner, day);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isGov) {
|
||||
await this.allocate(target.id, booking, "gov");
|
||||
if (partner) await this.allocate(target.id, partner, "gov");
|
||||
} else {
|
||||
await this.reserve(booking, target.id);
|
||||
if (partner) await this.reserve(partner, target.id);
|
||||
target.armed = true;
|
||||
// A throw here (e.g. a billing/invoice hiccup inside reserve) must NOT abort
|
||||
// the whole pass — otherwise only the bookings before the failure get a pay
|
||||
// window and the rest trickle in one-per-tick on later retries (the
|
||||
// "selected one at a time / staggered" symptom). Isolate each unit: log +
|
||||
// skip a failing one, keep reserving the others. The skipped unit stays in
|
||||
// the pool and is retried next cycle.
|
||||
try {
|
||||
if (isGov) {
|
||||
await this.allocate(target.id, booking, "gov");
|
||||
if (partner) await this.allocate(target.id, partner, "gov");
|
||||
} else {
|
||||
await this.reserve(booking, target.id);
|
||||
if (partner) await this.reserve(partner, target.id);
|
||||
target.armed = true;
|
||||
}
|
||||
target.budget.subtract(need, legOn(target)!);
|
||||
reservedThisPass += 1;
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`[fillRouteDay] reserve/allocate FAILED for ${booking.reference} on ${target.id} ` +
|
||||
`— skipping this unit, continuing the batch: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
target.budget = this.subtract(target.budget, need);
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`[fillRouteDay ${originYardId}->${destinationYardId} ${day}] reserved ${reservedThisPass}/${units.length} unit(s) this pass`,
|
||||
);
|
||||
|
||||
for (const t of trains) {
|
||||
if (t.budget.wagons <= 0) await this.setWindow(t.id, "FULL");
|
||||
if (t.budget.maxRemaining().wagons <= 0) await this.setWindow(t.id, "FULL");
|
||||
if (t.armed) this.armSettle(t.id);
|
||||
void this.triggerWagonAllocation(t.id);
|
||||
}
|
||||
@@ -1220,6 +1318,57 @@ export class BookingBatchService implements OnModuleInit {
|
||||
return trains.map((t) => t.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* A lone commercial IMPORT booking on a GENERAL or ONE_TIME contract may be
|
||||
* offered a partial (split-on-payment). Consolidated pairs never split (both-or-
|
||||
* neither shared wagon) and government bookings never split (they preempt).
|
||||
*/
|
||||
private isSplitEligible(booking: Booking, isPair: boolean): boolean {
|
||||
return (
|
||||
!isPair &&
|
||||
!booking.isGovernment &&
|
||||
booking.tradeDirection === "IMPORT" &&
|
||||
(booking.contractKind === "GENERAL" || booking.contractKind === "ONE_TIME") &&
|
||||
this.splitService != null
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Offer the largest fitting part of a booking that does not fit any candidate
|
||||
* train whole, on the train with the most free wagons on the booking's leg.
|
||||
* Mutates the chosen candidate's budget + armed flag in place. Returns true when
|
||||
* an offer was opened (caller should `continue` past this unit), false otherwise.
|
||||
* Shared by fillRouteDay (multi-train) and fillSchedule (single train). The leg
|
||||
* is computed per candidate from the booking's yards, so callers pass their live
|
||||
* train entries and only leg-carrying trains are considered.
|
||||
*/
|
||||
private async maybeOfferPartial(
|
||||
booking: Booking,
|
||||
isPair: boolean,
|
||||
candidates: Array<{ id: string; budget: CorridorBudget; armed: boolean }>,
|
||||
need: Capacity,
|
||||
): Promise<boolean> {
|
||||
if (!this.isSplitEligible(booking, isPair)) return false;
|
||||
const target = candidates
|
||||
.map((c) => {
|
||||
const leg = c.budget.legOf(booking.originYardId, booking.destinationYardId);
|
||||
return leg ? { c, leg, room: c.budget.remainingFor(leg) } : null;
|
||||
})
|
||||
.filter((x): x is NonNullable<typeof x> => x != null && x.room.wagons >= 1)
|
||||
.sort((a, b) => b.room.wagons - a.room.wagons)[0];
|
||||
if (!target) return false;
|
||||
const offered = await this.tryPartialOffer(
|
||||
booking,
|
||||
target.c.id,
|
||||
target.room,
|
||||
need,
|
||||
);
|
||||
if (!offered) return false;
|
||||
target.c.budget.subtract(offered, target.leg);
|
||||
target.c.armed = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Offer the largest fitting part of an over-capacity booking as a partial
|
||||
* (split-on-payment). Returns the capacity the offer consumes, or null when no
|
||||
@@ -1295,6 +1444,9 @@ export class BookingBatchService implements OnModuleInit {
|
||||
const byId = new Map(reserved.map((b) => [b.id, b]));
|
||||
const done = new Set<string>();
|
||||
let anySettled = false;
|
||||
this.logger.debug(
|
||||
`[settleReserved ${scheduleId}] ${reserved.length} reserved booking(s) to settle`,
|
||||
);
|
||||
|
||||
const isPaid = (b: Booking) =>
|
||||
b.paymentStatus === "PAID" || b.status === "PAID";
|
||||
@@ -1341,7 +1493,14 @@ export class BookingBatchService implements OnModuleInit {
|
||||
/** Durable settle: allocate paid / expire overdue reservations, then top up. */
|
||||
async settleDueReservations(scheduleId: string): Promise<void> {
|
||||
const anySettled = await this.settleReserved(scheduleId, false);
|
||||
if (anySettled) await this.fillSchedule(scheduleId);
|
||||
// A settle that allocated/expired anything frees or fills capacity → re-run the
|
||||
// fill so the next waiting-list bookings get a fresh pay window (top-up).
|
||||
if (anySettled) {
|
||||
this.logger.log(
|
||||
`[BATCH] settle changed state on ${scheduleId} — running top-up fill for the waiting list`,
|
||||
);
|
||||
await this.fillSchedule(scheduleId);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- settle (1h after a batch) -------------------------------------------
|
||||
@@ -1414,10 +1573,10 @@ export class BookingBatchService implements OnModuleInit {
|
||||
"Target schedule is not accepting bookings",
|
||||
);
|
||||
}
|
||||
if (
|
||||
schedule.originStationId !== booking.originYardId ||
|
||||
schedule.destinationStationId !== booking.destinationYardId
|
||||
) {
|
||||
const stops = await this.stopsForSchedule(schedule);
|
||||
const fromIdx = stops.indexOf(booking.originYardId);
|
||||
const toIdx = stops.indexOf(booking.destinationYardId);
|
||||
if (fromIdx < 0 || toIdx < 0 || fromIdx >= toIdx) {
|
||||
throw new BadRequestException(
|
||||
"Target schedule is not on the booking route",
|
||||
);
|
||||
@@ -1461,13 +1620,14 @@ export class BookingBatchService implements OnModuleInit {
|
||||
// ---- intercity ride-along API ---------------------------------------------
|
||||
|
||||
/**
|
||||
* Remaining capacity budget (wagons / weight / length) for a schedule, and
|
||||
* the per-booking need calculator — exposed for the intercity accept flow,
|
||||
* which reserves ride-along bookings onto import/export trains outside the
|
||||
* batch engine.
|
||||
* Remaining corridor capacity budget (per-edge wagons / weight / length) for
|
||||
* a schedule, and the per-booking need calculator — exposed for the intercity
|
||||
* accept flow, which reserves ride-along bookings onto import/export trains
|
||||
* outside the batch engine. Segment-based: an intercity booking fits whenever
|
||||
* ITS leg has room, even if the train is full on other legs.
|
||||
*/
|
||||
async intercityCapacity(scheduleId: string): Promise<{
|
||||
budget: Capacity;
|
||||
budget: CorridorBudget;
|
||||
needFor: (booking: Booking) => Capacity;
|
||||
} | null> {
|
||||
const schedule =
|
||||
@@ -1477,7 +1637,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
const rules = await this.loadGlobalRules();
|
||||
const wagonLengths = await this.loadWagonLengths();
|
||||
const limits = await this.capacityLimits(locomotive, rules);
|
||||
const budget = await this.remainingCapacity(schedule, limits, wagonLengths);
|
||||
const budget = await this.remainingBudget(schedule, limits, wagonLengths);
|
||||
return { budget, needFor: (booking) => this.needFor(booking, wagonLengths) };
|
||||
}
|
||||
|
||||
@@ -1529,6 +1689,11 @@ export class BookingBatchService implements OnModuleInit {
|
||||
"PREPAID",
|
||||
);
|
||||
await this.notifier.payNow(booking, deadline);
|
||||
this.logger.log(
|
||||
`[BATCH] RESERVED ${booking.reference} (${this.wagonsFor(booking)}w, ` +
|
||||
`priority ${booking.priorityScore ?? 0}) on schedule ${scheduleId} — ` +
|
||||
`pay by ${deadline.toISOString()}`,
|
||||
);
|
||||
// Customer tracking: a wagon slot is reserved and the freight pay window is
|
||||
// open. Doc-trigger path — silent no-op for bookings without milestone rows.
|
||||
void this.completeTrackingMilestones(booking.id, [
|
||||
@@ -1563,6 +1728,9 @@ export class BookingBatchService implements OnModuleInit {
|
||||
selectedForBatchAt: null,
|
||||
} as never);
|
||||
});
|
||||
this.logger.log(
|
||||
`[BATCH] ALLOCATED ${booking.reference} (${reason}) to train on schedule ${scheduleId}`,
|
||||
);
|
||||
this.notifier.secured(booking, reason);
|
||||
void this.triggerWagonAllocation(scheduleId);
|
||||
void this.markWagonAllocatedMilestone(booking.id);
|
||||
@@ -1630,18 +1798,104 @@ export class BookingBatchService implements OnModuleInit {
|
||||
// source-agnostic.
|
||||
await this.billing.expirePayable(Freight.InvoiceSource.Booking, booking.id, "PREPAID");
|
||||
this.notifier.expired(booking);
|
||||
this.logger.log(
|
||||
`[BATCH] EXPIRED ${booking.reference} — payment window passed; freed its ` +
|
||||
`wagons back to the pool for top-up`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Union of stop yards across the day's fillable schedules on this corridor —
|
||||
* the same pool scope fillRouteDay uses, so full-route AND sub-corridor bookings
|
||||
* are covered. Empty when no fillable schedule exists for the group.
|
||||
*/
|
||||
private async corridorYardsForRouteDay(
|
||||
group: RouteDayGroup,
|
||||
): Promise<string[]> {
|
||||
const corridor = await this.trainSchedulesRepository.findAll({
|
||||
where: [
|
||||
{
|
||||
originStationId: group.originYardId,
|
||||
destinationStationId: group.destinationYardId,
|
||||
status: TrainScheduleStatusEnum.Draft,
|
||||
},
|
||||
{
|
||||
originStationId: group.originYardId,
|
||||
destinationStationId: group.destinationYardId,
|
||||
status: TrainScheduleStatusEnum.Scheduled,
|
||||
},
|
||||
],
|
||||
});
|
||||
const yards = new Set<string>();
|
||||
for (const schedule of corridor) {
|
||||
if (
|
||||
schedule.scheduledDepartureDate == null ||
|
||||
eatDay(schedule.scheduledDepartureDate) !== group.day
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
for (const yardId of await this.stopsForSchedule(schedule)) {
|
||||
yards.add(yardId);
|
||||
}
|
||||
}
|
||||
return [...yards];
|
||||
}
|
||||
|
||||
/**
|
||||
* Sweep bookings on a route-day whose operation request staff did NOT accept by
|
||||
* the time the window's document-review phase ends. They never reached
|
||||
* FULLY_EXECUTED, so they never enter the batch — expire them (customer must
|
||||
* rebook a new window). No reservation and no invoice exists yet at this stage,
|
||||
* so this is a lighter expiry than `expire()`: just flip status + notify, and
|
||||
* best-effort close any payable if one was issued early. Government/export are
|
||||
* excluded by the query.
|
||||
*/
|
||||
async expireUnacceptedForRouteDay(group: RouteDayGroup): Promise<void> {
|
||||
const corridorYards = await this.corridorYardsForRouteDay(group);
|
||||
if (corridorYards.length === 0) return;
|
||||
const unaccepted = await this.bookingsRepository.findUnacceptedForRouteDay(
|
||||
corridorYards,
|
||||
group.day,
|
||||
);
|
||||
if (unaccepted.length > 0) {
|
||||
this.logger.log(
|
||||
`[BATCH] doc-review end: expiring ${unaccepted.length} un-accepted booking(s) ` +
|
||||
`on ${group.originYardId}->${group.destinationYardId} ${group.day}`,
|
||||
);
|
||||
}
|
||||
for (const booking of unaccepted) {
|
||||
await this.bookingsRepository.update(booking.id, {
|
||||
status: "EXPIRED",
|
||||
schedulingStatus: "ELIGIBLE",
|
||||
// Free the shipment day so the customer can rebook a fresh window.
|
||||
scheduledDate: null,
|
||||
} as never);
|
||||
// Close any payable issued before doc-review end (normally none — the invoice
|
||||
// is created at ops-accept, which by definition has not happened here).
|
||||
await this.billing
|
||||
.expirePayable(Freight.InvoiceSource.Booking, booking.id, "PREPAID")
|
||||
.catch(() => undefined);
|
||||
this.notifier.expired(booking);
|
||||
this.logger.log(
|
||||
`[BATCH] EXPIRED (unaccepted) ${booking.reference}:${booking.id} at doc-review end`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Free capacity for a government booking by displacing the lowest-priority commercial
|
||||
* bookings (reserved first, then allocated — including PAID). Displaced → EXPIRED + notified.
|
||||
* Only victims whose legs overlap the government booking's leg actually free useful
|
||||
* room, so others are skipped. Mutates `budget`; returns whether the need now fits.
|
||||
*/
|
||||
private async preemptForGovernment(
|
||||
scheduleId: string,
|
||||
need: Capacity,
|
||||
budget: Capacity,
|
||||
leg: CorridorLeg,
|
||||
budget: CorridorBudget,
|
||||
wagonLengths: WagonLengths,
|
||||
): Promise<Capacity> {
|
||||
): Promise<boolean> {
|
||||
if (budget.fits(need, leg)) return true;
|
||||
const reservedCommercial = (
|
||||
await this.bookingsRepository.findReservedForSchedule(scheduleId)
|
||||
).filter((b) => !b.isGovernment);
|
||||
@@ -1655,9 +1909,16 @@ export class BookingBatchService implements OnModuleInit {
|
||||
(a, b) => (a.priorityScore ?? 0) - (b.priorityScore ?? 0),
|
||||
);
|
||||
|
||||
let freed = budget;
|
||||
for (const victim of candidates) {
|
||||
if (this.fits(need, freed)) break;
|
||||
if (budget.fits(need, leg)) break;
|
||||
const victimLeg = budget.legForYards(
|
||||
victim.originYardId,
|
||||
victim.destinationYardId,
|
||||
);
|
||||
// Displacing a booking on a disjoint leg frees nothing the government
|
||||
// booking can use — don't kill it for nothing.
|
||||
const overlaps = victimLeg.fromEdge < leg.toEdge && leg.fromEdge < victimLeg.toEdge;
|
||||
if (!overlaps) continue;
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
await this.trainScheduleBookingsRepository.deleteByScheduleAndBooking(
|
||||
scheduleId,
|
||||
@@ -1680,9 +1941,9 @@ export class BookingBatchService implements OnModuleInit {
|
||||
);
|
||||
});
|
||||
this.notifier.displaced(victim);
|
||||
freed = this.add(freed, this.needFor(victim, wagonLengths));
|
||||
budget.add(this.needFor(victim, wagonLengths), victimLeg);
|
||||
}
|
||||
return freed;
|
||||
return budget.fits(need, leg);
|
||||
}
|
||||
|
||||
// ---- capacity helpers -----------------------------------------------------
|
||||
@@ -1790,22 +2051,6 @@ export class BookingBatchService implements OnModuleInit {
|
||||
);
|
||||
}
|
||||
|
||||
private subtract(budget: Capacity, need: Capacity): Capacity {
|
||||
return {
|
||||
wagons: budget.wagons - need.wagons,
|
||||
weightTons: budget.weightTons - need.weightTons,
|
||||
lengthMeters: budget.lengthMeters - need.lengthMeters,
|
||||
};
|
||||
}
|
||||
|
||||
private add(budget: Capacity, freed: Capacity): Capacity {
|
||||
return {
|
||||
wagons: budget.wagons + freed.wagons,
|
||||
weightTons: budget.weightTons + freed.weightTons,
|
||||
lengthMeters: budget.lengthMeters + freed.lengthMeters,
|
||||
};
|
||||
}
|
||||
|
||||
/** Locomotive + wagon-type-derived caps (weight, length, wagon slots — not a fixed 53). */
|
||||
private async capacityLimits(
|
||||
locomotive: Locomotive,
|
||||
@@ -1883,37 +2128,68 @@ export class BookingBatchService implements OnModuleInit {
|
||||
.findOne({ where: {} });
|
||||
}
|
||||
|
||||
/** Remaining capacity = hard caps minus what allocated + reserved bookings already use. */
|
||||
private async remainingCapacity(
|
||||
/**
|
||||
* Ordered stop yards of the schedule's route (origin → milestones →
|
||||
* destination); the legacy two-stop pseudo-route when milestones are absent.
|
||||
*/
|
||||
private async stopsForSchedule(schedule: TrainSchedule): Promise<string[]> {
|
||||
let milestoneYards: string[] | null = null;
|
||||
if (schedule.routeId) {
|
||||
const milestones = await this.dataSource
|
||||
.getRepository(RouteMilestone)
|
||||
.find({ where: { routeId: schedule.routeId }, order: { sequenceNo: 'ASC' } });
|
||||
if (milestones.length >= 2) milestoneYards = milestones.map((m) => m.yardId);
|
||||
}
|
||||
return stopYardsFor(
|
||||
milestoneYards,
|
||||
schedule.originStationId,
|
||||
schedule.destinationStationId,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remaining capacity per corridor edge = hard caps minus what allocated +
|
||||
* reserved bookings already use ON THEIR OWN LEGS. A booking riding only
|
||||
* Dire→Djibouti leaves the Addis→Dire edges untouched.
|
||||
*/
|
||||
private async remainingBudget(
|
||||
schedule: TrainSchedule,
|
||||
limits: Capacity,
|
||||
wagonLengths: WagonLengths,
|
||||
): Promise<Capacity> {
|
||||
): Promise<CorridorBudget> {
|
||||
const stops = await this.stopsForSchedule(schedule);
|
||||
const budget = new CorridorBudget(stops, limits);
|
||||
const allocated = (schedule.scheduleBookings ?? [])
|
||||
.map((sb) => sb.booking)
|
||||
.filter((b): b is Booking => Boolean(b));
|
||||
const reserved = await this.bookingsRepository.findReservedForSchedule(
|
||||
schedule.id,
|
||||
);
|
||||
const used = [...allocated, ...reserved].reduce<Capacity>(
|
||||
(acc, b) => this.add(acc, this.needFor(b, wagonLengths)),
|
||||
{ wagons: 0, weightTons: 0, lengthMeters: 0 },
|
||||
);
|
||||
return this.subtract(limits, used);
|
||||
for (const b of [...allocated, ...reserved]) {
|
||||
budget.subtract(
|
||||
this.needFor(b, wagonLengths),
|
||||
budget.legForYards(b.originYardId, b.destinationYardId),
|
||||
);
|
||||
}
|
||||
return budget;
|
||||
}
|
||||
|
||||
/** maxWagons minus wagons already taken by allocated + reserved bookings. */
|
||||
/**
|
||||
* Wagon slots still boardable somewhere on the corridor (most-open edge).
|
||||
* ≤ 0 means no leg can take another booking — the train-wide FULL signal.
|
||||
*/
|
||||
private async remainingWagons(schedule: TrainSchedule): Promise<number> {
|
||||
const allocated = (schedule.scheduleBookings ?? [])
|
||||
.map((sb) => sb.booking)
|
||||
.filter((b): b is Booking => Boolean(b));
|
||||
const reserved = await this.bookingsRepository.findReservedForSchedule(
|
||||
schedule.id,
|
||||
const wagonLengths = await this.loadWagonLengths();
|
||||
const budget = await this.remainingBudget(
|
||||
schedule,
|
||||
{
|
||||
wagons: schedule.maxWagons ?? 0,
|
||||
weightTons: Number.POSITIVE_INFINITY,
|
||||
lengthMeters: Number.POSITIVE_INFINITY,
|
||||
},
|
||||
wagonLengths,
|
||||
);
|
||||
const used =
|
||||
allocated.reduce((s, b) => s + this.wagonsFor(b), 0) +
|
||||
reserved.reduce((s, b) => s + this.wagonsFor(b), 0);
|
||||
return (schedule.maxWagons ?? 0) - used;
|
||||
return budget.maxRemaining().wagons;
|
||||
}
|
||||
|
||||
async setWindow(
|
||||
|
||||
@@ -0,0 +1,394 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
Optional,
|
||||
} from '@nestjs/common';
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import { DataSource, EntityManager, In } from 'typeorm';
|
||||
import { Freight } from '@edr/types';
|
||||
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service';
|
||||
import { Yard } from '../rule-engine/entities/yard.entity';
|
||||
import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity';
|
||||
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
||||
import { WagonBookingAllocation } from '../train-schedules/entities/wagon-booking-allocation.entity';
|
||||
import { Wagon } from '../wagons/entities/wagon.entity';
|
||||
import { WagonMovement } from '../wagons/entities/wagon-movement.entity';
|
||||
import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity';
|
||||
|
||||
/**
|
||||
* Per-booking journey along a train's corridor — for EVERY trade direction.
|
||||
*
|
||||
* A booking rides only its own origin→destination leg, so "dispatched" and
|
||||
* "arrived" are per-booking facts confirmed by the yard operator, not train
|
||||
* facts: load at the booking's origin yard (PAID → IN_TRANSIT, loadedAt) and
|
||||
* unload at its destination yard (IN_TRANSIT → ARRIVED for import/export,
|
||||
* → COMPLETED for intercity), possibly long before the train's final arrival.
|
||||
* Both are gated on the train's latest recorded checkpoint being at that yard.
|
||||
*
|
||||
* Unloading also settles the physical wagons: each wagon that alights with the
|
||||
* booking is released at that yard and the move is written to the
|
||||
* wagon_movements ledger.
|
||||
*/
|
||||
@Injectable()
|
||||
export class BookingJourneyService {
|
||||
private readonly logger = new Logger(BookingJourneyService.name);
|
||||
|
||||
constructor(
|
||||
@InjectDataSource() private readonly dataSource: DataSource,
|
||||
@Optional() private readonly milestoneService?: ClearanceMilestoneService,
|
||||
) {}
|
||||
|
||||
/** Statuses from which a booking may be loaded (gov bookings don't prepay). */
|
||||
private canLoad(booking: Booking): boolean {
|
||||
if (booking.status === 'PAID') return true;
|
||||
return booking.isGovernment && booking.status === 'APPROVED';
|
||||
}
|
||||
|
||||
async loadBooking(scheduleId: string, bookingId: string, userId?: string | null) {
|
||||
const { schedule, booking } = await this.getScheduleBooking(scheduleId, bookingId);
|
||||
if (booking.loadedAt || booking.status === 'IN_TRANSIT') {
|
||||
throw new BadRequestException('Booking is already loaded');
|
||||
}
|
||||
if (!this.canLoad(booking)) {
|
||||
throw new BadRequestException(
|
||||
`Booking must be paid before loading (currently ${booking.status})`,
|
||||
);
|
||||
}
|
||||
await this.assertTrainAtYard(schedule, booking.originYardId, 'origin');
|
||||
|
||||
const now = new Date();
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
await manager.getRepository(Booking).update(bookingId, {
|
||||
status: 'IN_TRANSIT',
|
||||
loadedAt: now,
|
||||
loadedByUserId: userId ?? null,
|
||||
} as never);
|
||||
await this.setAllocationStatuses(manager, scheduleId, bookingId, 'LOADED');
|
||||
});
|
||||
|
||||
// Customer tracking: cargo is on the train — loading milestones plus the
|
||||
// direction's "departed" handoff. Doc-trigger path no-ops non-customs
|
||||
// bookings (intercity) and already-completed codes.
|
||||
void this.completeMilestones(booking, [
|
||||
'CARGO_ARRIVED',
|
||||
'READY_FOR_LOADING',
|
||||
'LOADED',
|
||||
...(booking.tradeDirection === 'IMPORT'
|
||||
? ['DEPARTED_FROM_DJIBOUTI']
|
||||
: booking.tradeDirection === 'EXPORT'
|
||||
? ['DEPARTED_TO_DJIBOUTI']
|
||||
: []),
|
||||
]);
|
||||
|
||||
return { bookingId, status: 'IN_TRANSIT' as const, loadedAt: now.toISOString() };
|
||||
}
|
||||
|
||||
async unloadBooking(scheduleId: string, bookingId: string, userId?: string | null) {
|
||||
const { schedule, booking } = await this.getScheduleBooking(scheduleId, bookingId);
|
||||
if (booking.status !== 'IN_TRANSIT') {
|
||||
throw new BadRequestException(
|
||||
`Booking must be loaded/in transit before unloading (currently ${booking.status})`,
|
||||
);
|
||||
}
|
||||
await this.assertTrainAtYard(schedule, booking.destinationYardId, 'destination');
|
||||
|
||||
// Intercity has no clearance/delivery tail — unloading completes it. Import/
|
||||
// export continue into clearance, keyed on the booking's own arrival.
|
||||
const nextStatus = booking.tradeDirection === 'DOMESTIC' ? 'COMPLETED' : 'ARRIVED';
|
||||
const now = new Date();
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
await manager.getRepository(Booking).update(bookingId, {
|
||||
status: nextStatus,
|
||||
arrivedAt: now,
|
||||
arrivedByUserId: userId ?? null,
|
||||
} as never);
|
||||
await this.setAllocationStatuses(manager, scheduleId, bookingId, 'DEPARTED');
|
||||
await this.settleWagonsOnUnload(manager, schedule, booking, now, userId ?? null);
|
||||
});
|
||||
|
||||
// Customer tracking: THIS booking arrived (train may still be rolling).
|
||||
void this.completeMilestones(booking, [
|
||||
...(booking.tradeDirection === 'IMPORT'
|
||||
? ['ARRIVED_ETHIOPIA']
|
||||
: booking.tradeDirection === 'EXPORT'
|
||||
? ['ARRIVED_AT_DJIBOUTI']
|
||||
: []),
|
||||
]);
|
||||
|
||||
return { bookingId, status: nextStatus, arrivedAt: now.toISOString() };
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-yard operator worklist for a schedule: which bookings board / alight at
|
||||
* each stop, with their journey state, so the yard operator at Dire sees
|
||||
* exactly what to load and unload when the train is there.
|
||||
*/
|
||||
async listYardWork(scheduleId: string) {
|
||||
const schedule = await this.getSchedule(scheduleId);
|
||||
const bookings = await this.dataSource
|
||||
.getRepository(Booking)
|
||||
.createQueryBuilder('booking')
|
||||
.leftJoinAndSelect('booking.company', 'company')
|
||||
.leftJoinAndSelect('booking.originYard', 'originYard')
|
||||
.leftJoinAndSelect('booking.destinationYard', 'destinationYard')
|
||||
.innerJoin(
|
||||
'freight.train_schedule_bookings',
|
||||
'tsb',
|
||||
'tsb.booking_id = booking.id AND tsb.train_schedule_id = :scheduleId AND tsb.deleted_at IS NULL',
|
||||
{ scheduleId },
|
||||
)
|
||||
.getMany();
|
||||
|
||||
const latest = await this.latestCheckpoint(scheduleId);
|
||||
const yardIds = [
|
||||
...new Set(
|
||||
bookings.flatMap((b) => [b.originYardId, b.destinationYardId]).filter(Boolean),
|
||||
),
|
||||
];
|
||||
const yards = yardIds.length
|
||||
? await this.dataSource.getRepository(Yard).find({ where: { id: In(yardIds) } })
|
||||
: [];
|
||||
const yardById = new Map(yards.map((y) => [y.id, y]));
|
||||
const yardLabel = (id: string) =>
|
||||
yardById.get(id)?.label ?? yardById.get(id)?.code ?? id;
|
||||
|
||||
const mapBooking = (b: Booking) => ({
|
||||
id: b.id,
|
||||
reference: b.reference,
|
||||
status: b.status,
|
||||
tradeDirection: b.tradeDirection,
|
||||
isGovernment: b.isGovernment,
|
||||
customer: b.company?.name ?? 'Unknown customer',
|
||||
originYardId: b.originYardId,
|
||||
destinationYardId: b.destinationYardId,
|
||||
origin: yardLabel(b.originYardId),
|
||||
destination: yardLabel(b.destinationYardId),
|
||||
loadedAt: b.loadedAt?.toISOString() ?? null,
|
||||
arrivedAt: b.arrivedAt?.toISOString() ?? null,
|
||||
canLoad: !b.loadedAt && this.canLoad(b),
|
||||
canUnload: b.status === 'IN_TRANSIT',
|
||||
});
|
||||
|
||||
const byYard = new Map<
|
||||
string,
|
||||
{ yardId: string; yard: string; toLoad: ReturnType<typeof mapBooking>[]; toUnload: ReturnType<typeof mapBooking>[] }
|
||||
>();
|
||||
const bucket = (yardId: string) => {
|
||||
let entry = byYard.get(yardId);
|
||||
if (!entry) {
|
||||
entry = { yardId, yard: yardLabel(yardId), toLoad: [], toUnload: [] };
|
||||
byYard.set(yardId, entry);
|
||||
}
|
||||
return entry;
|
||||
};
|
||||
for (const b of bookings) {
|
||||
bucket(b.originYardId).toLoad.push(mapBooking(b));
|
||||
bucket(b.destinationYardId).toUnload.push(mapBooking(b));
|
||||
}
|
||||
|
||||
return {
|
||||
scheduleId,
|
||||
scheduleStatus: schedule.status,
|
||||
trainAtYardId: latest?.yardId ?? (schedule.status === 'DISPATCHED' ? null : schedule.originStationId),
|
||||
yards: [...byYard.values()],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk fallback at the train's FINAL arrival: any booking destined for the
|
||||
* final yard that operators didn't unload individually gets its per-booking
|
||||
* arrival stamped now, so nothing stays stuck. Mid-corridor bookings are NOT
|
||||
* touched — their arrival is their own unload. Returns the affected ids.
|
||||
*/
|
||||
async autoArriveAtFinalYard(
|
||||
manager: EntityManager,
|
||||
schedule: TrainSchedule,
|
||||
now: Date,
|
||||
): Promise<string[]> {
|
||||
const rows: Array<{ id: string; trade_direction: string }> = await manager.query(
|
||||
`UPDATE freight.bookings b
|
||||
SET status = CASE WHEN b.trade_direction = 'DOMESTIC' THEN 'COMPLETED' ELSE 'ARRIVED' END,
|
||||
scheduling_status = 'DISPATCHED',
|
||||
arrived_at = COALESCE(b.arrived_at, $3),
|
||||
loaded_at = COALESCE(b.loaded_at, b.created_at)
|
||||
FROM freight.train_schedule_bookings tsb
|
||||
WHERE tsb.booking_id = b.id
|
||||
AND tsb.train_schedule_id = $1
|
||||
AND tsb.deleted_at IS NULL
|
||||
AND b.deleted_at IS NULL
|
||||
AND b.destination_yard_id = $2
|
||||
AND b.status NOT IN ('DRAFT', 'REJECTED', 'CANCELLED', 'COMPLETED', 'ARRIVED', 'DELIVERED')
|
||||
RETURNING b.id, b.trade_direction`,
|
||||
[schedule.id, schedule.destinationStationId, now],
|
||||
);
|
||||
return rows.map((r) => r.id);
|
||||
}
|
||||
|
||||
// ---- helpers ---------------------------------------------------------------
|
||||
|
||||
private async getSchedule(scheduleId: string): Promise<TrainSchedule> {
|
||||
const schedule = await this.dataSource
|
||||
.getRepository(TrainSchedule)
|
||||
.findOne({ where: { id: scheduleId } });
|
||||
if (!schedule) {
|
||||
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
||||
}
|
||||
return schedule;
|
||||
}
|
||||
|
||||
private async getScheduleBooking(scheduleId: string, bookingId: string) {
|
||||
const schedule = await this.getSchedule(scheduleId);
|
||||
const booking = await this.dataSource
|
||||
.getRepository(Booking)
|
||||
.findOne({ where: { id: bookingId } });
|
||||
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
|
||||
if (booking.trainScheduleId !== scheduleId) {
|
||||
throw new BadRequestException('Booking is not assigned to this schedule');
|
||||
}
|
||||
return { schedule, booking };
|
||||
}
|
||||
|
||||
private async latestCheckpoint(scheduleId: string): Promise<TrainCheckpointEvent | null> {
|
||||
return this.dataSource.getRepository(TrainCheckpointEvent).findOne({
|
||||
where: { trainScheduleId: scheduleId },
|
||||
order: { occurredAt: 'DESC', createdAt: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The train is "at" a yard when the latest recorded checkpoint is that yard,
|
||||
* or — for a booking boarding at the train's own origin — when the train has
|
||||
* not recorded any checkpoint yet (still sitting at its origin).
|
||||
*/
|
||||
private async assertTrainAtYard(
|
||||
schedule: TrainSchedule,
|
||||
yardId: string,
|
||||
side: 'origin' | 'destination',
|
||||
): Promise<void> {
|
||||
const latest = await this.latestCheckpoint(schedule.id);
|
||||
if (!latest) {
|
||||
if (side === 'origin' && schedule.originStationId === yardId) return;
|
||||
throw new BadRequestException(
|
||||
'Train has not reached this yard yet — record its checkpoint first',
|
||||
);
|
||||
}
|
||||
if (latest.yardId !== yardId) {
|
||||
throw new BadRequestException(
|
||||
`Train's last recorded position is not at the booking's ${side} yard`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async setAllocationStatuses(
|
||||
manager: EntityManager,
|
||||
scheduleId: string,
|
||||
bookingId: string,
|
||||
status: 'LOADED' | 'DEPARTED',
|
||||
): Promise<void> {
|
||||
const allocations = await this.allocationsForBooking(manager, scheduleId, bookingId);
|
||||
if (!allocations.length) return;
|
||||
await manager
|
||||
.getRepository(WagonBookingAllocation)
|
||||
.update({ id: In(allocations.map((a) => a.id)) }, { status });
|
||||
}
|
||||
|
||||
private async allocationsForBooking(
|
||||
manager: EntityManager,
|
||||
scheduleId: string,
|
||||
bookingId: string,
|
||||
): Promise<Array<WagonBookingAllocation & { trainSetWagon?: TrainSetWagon }>> {
|
||||
return manager
|
||||
.getRepository(WagonBookingAllocation)
|
||||
.createQueryBuilder('alloc')
|
||||
.innerJoinAndSelect('alloc.trainSetWagon', 'slot')
|
||||
.innerJoin(
|
||||
'freight.train_schedules',
|
||||
'schedule',
|
||||
'schedule.train_set_id = slot.train_set_id AND schedule.id = :scheduleId',
|
||||
{ scheduleId },
|
||||
)
|
||||
.where('alloc.booking_id = :bookingId', { bookingId })
|
||||
.getMany();
|
||||
}
|
||||
|
||||
/**
|
||||
* On unload: write the wagon_movements ledger rows (board yard → unload yard,
|
||||
* kind LOADED) for the booking's pinned wagons, and release each wagon whose
|
||||
* slot alights here — it detaches, stays at this yard, and becomes Available
|
||||
* (dynamic consist). Wagons shared with a still-loaded consolidated partner
|
||||
* stay pinned until the last booking on the slot unloads.
|
||||
*/
|
||||
private async settleWagonsOnUnload(
|
||||
manager: EntityManager,
|
||||
schedule: TrainSchedule,
|
||||
booking: Booking,
|
||||
now: Date,
|
||||
userId: string | null,
|
||||
): Promise<void> {
|
||||
const allocations = await this.allocationsForBooking(manager, schedule.id, booking.id);
|
||||
for (const alloc of allocations) {
|
||||
const slot = alloc.trainSetWagon;
|
||||
if (!slot?.physicalWagonId) continue;
|
||||
|
||||
const boardYardId = slot.boardYardId ?? schedule.originStationId;
|
||||
await manager.getRepository(WagonMovement).save(
|
||||
manager.getRepository(WagonMovement).create({
|
||||
wagonId: slot.physicalWagonId,
|
||||
fromYardId: boardYardId,
|
||||
toYardId: booking.destinationYardId,
|
||||
trainScheduleId: schedule.id,
|
||||
bookingId: booking.id,
|
||||
kind: Freight.WagonMovementKind.Loaded,
|
||||
movedByUserId: userId,
|
||||
occurredAt: now,
|
||||
}),
|
||||
);
|
||||
|
||||
// Detach only when this yard is where the slot's leg ends and no other
|
||||
// booking on the wagon is still in transit.
|
||||
const slotAlightYardId = slot.alightYardId ?? schedule.destinationStationId;
|
||||
if (slotAlightYardId !== booking.destinationYardId) continue;
|
||||
const siblings = await manager
|
||||
.getRepository(WagonBookingAllocation)
|
||||
.createQueryBuilder('alloc')
|
||||
.innerJoin('alloc.booking', 'b')
|
||||
.where('alloc.train_set_wagon_id = :slotId', { slotId: slot.id })
|
||||
.andWhere('alloc.booking_id != :bookingId', { bookingId: booking.id })
|
||||
.andWhere(`b.status = 'IN_TRANSIT'`)
|
||||
.getCount();
|
||||
if (siblings > 0) continue;
|
||||
|
||||
await manager.getRepository(TrainSetWagon).update(slot.id, { status: 'DEPARTED' });
|
||||
const wagon = await manager
|
||||
.getRepository(Wagon)
|
||||
.findOne({ where: { id: slot.physicalWagonId } });
|
||||
// Only settle a wagon still bound to this schedule (it may have been
|
||||
// re-pinned elsewhere already).
|
||||
if (wagon && wagon.currentTrainScheduleId === schedule.id) {
|
||||
await manager.getRepository(Wagon).update(wagon.id, {
|
||||
currentYardId: booking.destinationYardId,
|
||||
currentTrainScheduleId: null,
|
||||
trainSetWagonId: null,
|
||||
status: Freight.WagonStatus.Available,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async completeMilestones(booking: Booking, codes: string[]): Promise<void> {
|
||||
if (!this.milestoneService || !codes.length) return;
|
||||
for (const code of codes) {
|
||||
try {
|
||||
await this.milestoneService.completeByDocTrigger({ bookingId: booking.id }, code);
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Milestone ${code} completion failed for booking ${booking.id}: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -71,6 +71,24 @@ export class BookingNotifierService {
|
||||
});
|
||||
}
|
||||
|
||||
/** Train carrying the booking departed — dispatched origin → destination. */
|
||||
dispatched(b: Booking, origin: string | null, destination: string | null): void {
|
||||
const msg =
|
||||
`Your booking ${b.reference ?? b.id} has been dispatched` +
|
||||
`${origin || destination ? ` from ${origin ?? '?'} to ${destination ?? '?'}` : ''}.`;
|
||||
void this.notifyContact(b, msg, 'DISPATCHED');
|
||||
this.inApp(b, 'Shipment dispatched', msg);
|
||||
}
|
||||
|
||||
/** Train carrying the booking arrived at destination. */
|
||||
arrived(b: Booking, origin: string | null, destination: string | null): void {
|
||||
const msg =
|
||||
`Your booking ${b.reference ?? b.id} has arrived` +
|
||||
`${destination ? ` at ${destination}` : ''}${origin ? ` (from ${origin})` : ''}.`;
|
||||
void this.notifyContact(b, msg, 'ARRIVED');
|
||||
this.inApp(b, 'Shipment arrived', msg);
|
||||
}
|
||||
|
||||
async payNow(b: Booking, deadline: Date): Promise<void> {
|
||||
const payMinutes = Math.max(1, Math.round((deadline.getTime() - Date.now()) / 60_000));
|
||||
const eat = deadline.toLocaleString('en-GB', { timeZone: 'Africa/Addis_Ababa' });
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { BookingSplitService } from './booking-split.service';
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
import { BookingContainer } from '../bookings/entities/booking-container.entity';
|
||||
import { BookingContainerUnit } from '../bookings/entities/booking-container-unit.entity';
|
||||
import { Contract } from '../contracts/entities/contract.entity';
|
||||
import { BookingBatchOffer } from './entities/booking-batch-offer.entity';
|
||||
|
||||
/**
|
||||
* applySplit promotion behaviour: a ONE_TIME contract must be flipped to GENERAL
|
||||
* (both the parent contract row and the booking's denormalized copy) so the split
|
||||
* remainder can be rebooked. A GENERAL booking is left untouched.
|
||||
*/
|
||||
describe('BookingSplitService — applySplit ONE_TIME promotion', () => {
|
||||
const bookingId = 'bk-1';
|
||||
const contractId = 'ct-1';
|
||||
const offerId = 'of-1';
|
||||
|
||||
const buildService = (bookingContractKind: 'ONE_TIME' | 'GENERAL') => {
|
||||
const offer = {
|
||||
id: offerId,
|
||||
bookingId,
|
||||
status: 'OFFERED',
|
||||
offeredWagons: 3,
|
||||
totalWagons: 5,
|
||||
offeredWeightTons: 30,
|
||||
offeredAmount: 300,
|
||||
offeredPricingBreakdown: {},
|
||||
offeredLines: null,
|
||||
} as unknown as BookingBatchOffer;
|
||||
|
||||
const bookingRepo = {
|
||||
update: jest.fn().mockResolvedValue(undefined),
|
||||
findOne: jest.fn().mockResolvedValue({
|
||||
id: bookingId,
|
||||
contractId,
|
||||
contractKind: bookingContractKind,
|
||||
}),
|
||||
find: jest.fn().mockResolvedValue([]),
|
||||
softDelete: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const contractRepo = { update: jest.fn().mockResolvedValue(undefined) };
|
||||
const offerRepo = {
|
||||
findOne: jest.fn().mockResolvedValue(offer),
|
||||
update: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const containerRepo = {
|
||||
find: jest.fn().mockResolvedValue([]),
|
||||
update: jest.fn(),
|
||||
softDelete: jest.fn(),
|
||||
};
|
||||
const unitRepo = { find: jest.fn().mockResolvedValue([]), softDelete: jest.fn() };
|
||||
|
||||
const repoFor = (entity: unknown) => {
|
||||
if (entity === Booking) return bookingRepo;
|
||||
if (entity === Contract) return contractRepo;
|
||||
if (entity === BookingBatchOffer) return offerRepo;
|
||||
if (entity === BookingContainer) return containerRepo;
|
||||
if (entity === BookingContainerUnit) return unitRepo;
|
||||
return { find: jest.fn().mockResolvedValue([]), update: jest.fn() };
|
||||
};
|
||||
|
||||
const dataSource = {
|
||||
getRepository: jest.fn(repoFor),
|
||||
transaction: jest.fn(async (fn: (m: unknown) => Promise<void>) => {
|
||||
await fn({ getRepository: repoFor });
|
||||
}),
|
||||
};
|
||||
|
||||
const service = new BookingSplitService(
|
||||
dataSource as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{ expirePayable: jest.fn() } as never,
|
||||
{ payNowPartial: jest.fn() } as never,
|
||||
);
|
||||
return { service, bookingRepo, contractRepo };
|
||||
};
|
||||
|
||||
it('promotes a ONE_TIME booking + parent contract to GENERAL', async () => {
|
||||
const { service, bookingRepo, contractRepo } = buildService('ONE_TIME');
|
||||
|
||||
await service.applySplit(bookingId);
|
||||
|
||||
expect(bookingRepo.update).toHaveBeenCalledWith(
|
||||
bookingId,
|
||||
expect.objectContaining({ contractKind: 'GENERAL' }),
|
||||
);
|
||||
expect(contractRepo.update).toHaveBeenCalledWith(
|
||||
contractId,
|
||||
expect.objectContaining({ contractKind: 'GENERAL' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves a GENERAL booking untouched (no contract promotion)', async () => {
|
||||
const { service, contractRepo } = buildService('GENERAL');
|
||||
|
||||
await service.applySplit(bookingId);
|
||||
|
||||
expect(contractRepo.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,7 @@ import { BillingService } from '../billing/billing.service';
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
import { BookingContainer } from '../bookings/entities/booking-container.entity';
|
||||
import { BookingContainerUnit } from '../bookings/entities/booking-container-unit.entity';
|
||||
import { Contract } from '../contracts/entities/contract.entity';
|
||||
import {
|
||||
BookingBatchOffer,
|
||||
OfferedLine,
|
||||
@@ -30,10 +31,11 @@ export interface SizedOffer {
|
||||
* pays, which is the act of accepting the split (applySplit). No payment →
|
||||
* offer expires and the booking stays whole.
|
||||
*
|
||||
* Only GENERAL-contract commercial bookings are offered partials: the remainder
|
||||
* GENERAL and ONE_TIME commercial bookings are offered partials: the remainder
|
||||
* returns to the contract's quantity cap (derived live from booking_container
|
||||
* rows, so reducing the lines releases it automatically) and can be rebooked in
|
||||
* any later window within contract validity.
|
||||
* any later window within contract validity. A ONE_TIME contract is promoted to
|
||||
* GENERAL on split (see applySplit) so its remainder is actually rebookable.
|
||||
*/
|
||||
@Injectable()
|
||||
export class BookingSplitService {
|
||||
@@ -245,6 +247,25 @@ export class BookingSplitService {
|
||||
pricingBreakdown: offer.offeredPricingBreakdown,
|
||||
} as never);
|
||||
|
||||
// A ONE_TIME contract permits a single active booking, which would block the
|
||||
// split remainder from ever being rebooked. Promote the parent contract (and
|
||||
// the booking's denormalized copy) to GENERAL so the leftover quantity draws
|
||||
// down against the cap like any general contract, within the same validity.
|
||||
const booking = await manager.getRepository(Booking).findOne({
|
||||
where: { id: bookingId },
|
||||
select: { id: true, contractId: true, contractKind: true },
|
||||
});
|
||||
if (booking?.contractKind === 'ONE_TIME') {
|
||||
await manager
|
||||
.getRepository(Booking)
|
||||
.update(bookingId, { contractKind: 'GENERAL' } as never);
|
||||
if (booking.contractId) {
|
||||
await manager
|
||||
.getRepository(Contract)
|
||||
.update(booking.contractId, { contractKind: 'GENERAL' } as never);
|
||||
}
|
||||
}
|
||||
|
||||
await manager
|
||||
.getRepository(BookingBatchOffer)
|
||||
.update(offer.id, { status: 'APPLIED' });
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
import { BookingWindowService } from './booking-window.service';
|
||||
import type { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
||||
|
||||
/**
|
||||
* Window state-machine tests: exercise the real advanceImport transitions and the
|
||||
* concludeCycle reopen/done decision with mocked collaborators. Drives the exact
|
||||
* production phase logic (PRE_WINDOW → OPEN → DOC_REVIEW → PAYMENT → conclude) and
|
||||
* asserts the side effects the batch/settle/reopen flow depends on.
|
||||
*/
|
||||
describe('BookingWindowService — window state machine', () => {
|
||||
const scheduleId = 'sched-1';
|
||||
|
||||
let service: BookingWindowService;
|
||||
let batch: {
|
||||
setWindow: jest.Mock;
|
||||
processRouteDay: jest.Mock;
|
||||
expireUnacceptedForRouteDay: jest.Mock;
|
||||
settleDueReservations: jest.Mock;
|
||||
isScheduleFull: jest.Mock;
|
||||
};
|
||||
let trainSchedulesRepository: { findById: jest.Mock; findAll: jest.Mock };
|
||||
let trainSchedulingService: { finalizeSchedule: jest.Mock; getWindowConfig: jest.Mock };
|
||||
let updateMock: jest.Mock;
|
||||
|
||||
const cfg = {
|
||||
importWindowLeadDays: 3,
|
||||
exportBookingLeadHours: 24,
|
||||
windowOpenHour: 0, // 24h desk → reopen opens immediately
|
||||
windowCloseHour: 0,
|
||||
windowDurationHours: 1,
|
||||
docReviewMinutes: 30,
|
||||
paymentWindowMinutes: 60,
|
||||
reopenDelayMinutes: 0,
|
||||
};
|
||||
|
||||
const baseSchedule = (over: Partial<TrainSchedule>): TrainSchedule =>
|
||||
({
|
||||
id: scheduleId,
|
||||
direction: 'IMPORT',
|
||||
originStationId: 'yard-o',
|
||||
destinationStationId: 'yard-d',
|
||||
scheduledDepartureDate: new Date('2026-08-01T06:00:00.000Z'),
|
||||
bookingWindowStatus: 'CLOSED',
|
||||
windowPhase: 'PRE_WINDOW',
|
||||
bookingCycleNo: 0,
|
||||
windowOpensAt: null,
|
||||
windowClosesAt: null,
|
||||
docReviewEndsAt: null,
|
||||
docReviewCompletedAt: null,
|
||||
paymentPhaseEndsAt: null,
|
||||
...over,
|
||||
}) as unknown as TrainSchedule;
|
||||
|
||||
const advanceImport = (s: TrainSchedule, now: Date): Promise<boolean> =>
|
||||
(service as unknown as {
|
||||
advanceImport: (s: TrainSchedule, c: unknown, n: Date) => Promise<boolean>;
|
||||
}).advanceImport(s, cfg, now);
|
||||
const concludeCycle = (s: TrainSchedule, now: Date): Promise<void> =>
|
||||
(service as unknown as {
|
||||
concludeCycle: (s: TrainSchedule, c: unknown, n: Date) => Promise<void>;
|
||||
}).concludeCycle(s, cfg, now);
|
||||
|
||||
beforeEach(() => {
|
||||
updateMock = jest.fn().mockResolvedValue(undefined);
|
||||
batch = {
|
||||
setWindow: jest.fn().mockResolvedValue(undefined),
|
||||
processRouteDay: jest.fn().mockResolvedValue(undefined),
|
||||
expireUnacceptedForRouteDay: jest.fn().mockResolvedValue(undefined),
|
||||
settleDueReservations: jest.fn().mockResolvedValue(undefined),
|
||||
isScheduleFull: jest.fn().mockResolvedValue(false),
|
||||
};
|
||||
trainSchedulesRepository = {
|
||||
findById: jest.fn().mockResolvedValue(null),
|
||||
findAll: jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
trainSchedulingService = {
|
||||
finalizeSchedule: jest.fn().mockResolvedValue(undefined),
|
||||
getWindowConfig: jest.fn().mockResolvedValue(cfg),
|
||||
};
|
||||
|
||||
service = new BookingWindowService(
|
||||
{ getRepository: () => ({ update: updateMock }) } as never,
|
||||
trainSchedulesRepository as never,
|
||||
batch as never,
|
||||
trainSchedulingService as never,
|
||||
{ directSend: jest.fn() } as never,
|
||||
{ notify: jest.fn() } as never,
|
||||
{ emitPhase: jest.fn() } as never,
|
||||
);
|
||||
});
|
||||
|
||||
it('PRE_WINDOW → OPEN at windowOpensAt (opens the customer window)', async () => {
|
||||
const s = baseSchedule({
|
||||
windowPhase: 'PRE_WINDOW',
|
||||
windowOpensAt: new Date('2026-07-01T00:00:00.000Z'),
|
||||
});
|
||||
const advanced = await advanceImport(s, new Date('2026-07-01T00:00:01.000Z'));
|
||||
expect(advanced).toBe(true);
|
||||
expect(s.windowPhase).toBe('OPEN');
|
||||
expect(s.bookingCycleNo).toBe(1);
|
||||
expect(batch.setWindow).toHaveBeenCalledWith(scheduleId, 'OPEN');
|
||||
});
|
||||
|
||||
it('OPEN → DOC_REVIEW at windowClosesAt (closes booking, sets doc-review deadline)', async () => {
|
||||
const closesAt = new Date('2026-07-01T01:00:00.000Z');
|
||||
const s = baseSchedule({
|
||||
windowPhase: 'OPEN',
|
||||
bookingWindowStatus: 'OPEN',
|
||||
windowClosesAt: closesAt,
|
||||
});
|
||||
const advanced = await advanceImport(s, new Date('2026-07-01T01:00:01.000Z'));
|
||||
expect(advanced).toBe(true);
|
||||
expect(s.windowPhase).toBe('DOC_REVIEW');
|
||||
expect(s.docReviewEndsAt).toEqual(new Date(closesAt.getTime() + 30 * 60_000));
|
||||
expect(batch.setWindow).toHaveBeenCalledWith(scheduleId, 'CLOSED');
|
||||
});
|
||||
|
||||
it('DOC_REVIEW → PAYMENT expires un-accepted, then runs the batch', async () => {
|
||||
const s = baseSchedule({
|
||||
windowPhase: 'DOC_REVIEW',
|
||||
docReviewEndsAt: new Date('2026-07-01T01:30:00.000Z'),
|
||||
});
|
||||
const advanced = await advanceImport(s, new Date('2026-07-01T01:30:01.000Z'));
|
||||
expect(advanced).toBe(true);
|
||||
expect(s.windowPhase).toBe('PAYMENT');
|
||||
expect(s.paymentPhaseEndsAt).not.toBeNull();
|
||||
// Expiry sweep runs BEFORE the batch (unaccepted must not compete for capacity).
|
||||
expect(batch.expireUnacceptedForRouteDay).toHaveBeenCalledTimes(1);
|
||||
expect(batch.processRouteDay).toHaveBeenCalledTimes(1);
|
||||
const expireOrder = batch.expireUnacceptedForRouteDay.mock.invocationCallOrder[0];
|
||||
const batchOrder = batch.processRouteDay.mock.invocationCallOrder[0];
|
||||
expect(expireOrder).toBeLessThan(batchOrder);
|
||||
});
|
||||
|
||||
it('DOC_REVIEW → PAYMENT also fires when staff finished review early (docReviewCompletedAt)', async () => {
|
||||
const s = baseSchedule({
|
||||
windowPhase: 'DOC_REVIEW',
|
||||
docReviewEndsAt: new Date('2026-07-01T05:00:00.000Z'), // far future
|
||||
docReviewCompletedAt: new Date('2026-07-01T01:31:00.000Z'), // staff clicked done
|
||||
});
|
||||
const advanced = await advanceImport(s, new Date('2026-07-01T01:31:01.000Z'));
|
||||
expect(advanced).toBe(true);
|
||||
expect(s.windowPhase).toBe('PAYMENT');
|
||||
});
|
||||
|
||||
it('PAYMENT → conclude at paymentPhaseEndsAt settles due reservations', async () => {
|
||||
const s = baseSchedule({
|
||||
windowPhase: 'PAYMENT',
|
||||
paymentPhaseEndsAt: new Date('2026-07-01T02:30:00.000Z'),
|
||||
});
|
||||
const advanced = await advanceImport(s, new Date('2026-07-01T02:30:01.000Z'));
|
||||
expect(advanced).toBe(true);
|
||||
// settleDueReservations runs (allocate paid / expire unpaid, then top-up).
|
||||
expect(batch.settleDueReservations).toHaveBeenCalledWith(scheduleId);
|
||||
});
|
||||
|
||||
it('conclude: train FULL → window FULL + phase DONE + auto-finalize', async () => {
|
||||
batch.isScheduleFull.mockResolvedValue(true);
|
||||
const s = baseSchedule({ windowPhase: 'PAYMENT' });
|
||||
await concludeCycle(s, new Date('2026-07-01T02:30:02.000Z'));
|
||||
expect(batch.setWindow).toHaveBeenCalledWith(scheduleId, 'FULL');
|
||||
expect(s.windowPhase).toBe('DONE');
|
||||
expect(trainSchedulingService.finalizeSchedule).toHaveBeenCalledWith(scheduleId);
|
||||
});
|
||||
|
||||
it('conclude: NOT full + a cycle fits before departure → REOPEN (back to PRE_WINDOW)', async () => {
|
||||
batch.isScheduleFull.mockResolvedValue(false);
|
||||
const s = baseSchedule({
|
||||
windowPhase: 'PAYMENT',
|
||||
// departure well in the future so nextCycleOpensAt returns a real time.
|
||||
scheduledDepartureDate: new Date('2026-08-01T06:00:00.000Z'),
|
||||
});
|
||||
await concludeCycle(s, new Date('2026-07-01T02:30:03.000Z'));
|
||||
expect(s.windowPhase).toBe('PRE_WINDOW');
|
||||
expect(s.windowOpensAt).not.toBeNull();
|
||||
expect(trainSchedulingService.finalizeSchedule).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('conclude: NOT full but NO cycle fits before departure → DONE', async () => {
|
||||
batch.isScheduleFull.mockResolvedValue(false);
|
||||
const s = baseSchedule({
|
||||
windowPhase: 'PAYMENT',
|
||||
// departure already passed → nextCycleOpensAt returns null → finish.
|
||||
scheduledDepartureDate: new Date('2026-07-01T00:00:00.000Z'),
|
||||
});
|
||||
await concludeCycle(s, new Date('2026-07-01T02:30:04.000Z'));
|
||||
expect(s.windowPhase).toBe('DONE');
|
||||
});
|
||||
|
||||
it('no transition fires before its deadline (idempotent tick)', async () => {
|
||||
const s = baseSchedule({
|
||||
windowPhase: 'OPEN',
|
||||
bookingWindowStatus: 'OPEN',
|
||||
windowClosesAt: new Date('2026-07-01T10:00:00.000Z'), // future
|
||||
});
|
||||
const advanced = await advanceImport(s, new Date('2026-07-01T01:00:00.000Z'));
|
||||
expect(advanced).toBe(false);
|
||||
expect(s.windowPhase).toBe('OPEN');
|
||||
expect(batch.setWindow).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -92,8 +92,17 @@ export class BookingWindowService implements OnModuleInit {
|
||||
now,
|
||||
);
|
||||
} catch (err) {
|
||||
// This is THE line to watch when a window freezes mid-phase: the tick
|
||||
// catches a throw here per-schedule and moves on, so a schedule whose
|
||||
// transition keeps throwing stays stuck in its phase forever. Log the
|
||||
// phase + stack so the failing step is obvious.
|
||||
this.logger.error(
|
||||
`Window transition failed for schedule ${schedule.id}: ${(err as Error).message}`,
|
||||
`[WINDOW] transition FAILED for schedule ${schedule.id} ` +
|
||||
`(phase=${schedule.windowPhase}, cycle=${schedule.bookingCycleNo}): ` +
|
||||
`${(err as Error).message}`,
|
||||
);
|
||||
this.logger.error(
|
||||
`[WINDOW] stack: ${((err as Error).stack ?? "").split("\n").slice(0, 5).join(" | ")}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -236,7 +245,8 @@ export class BookingWindowService implements OnModuleInit {
|
||||
// Fire-and-forget so a slow SMS/email gateway never stalls the tick loop.
|
||||
if (schedule.bookingCycleNo === 1) void this.notifyWindowOpened(schedule);
|
||||
this.logger.log(
|
||||
`Import booking window opened for schedule ${schedule.id} (cycle ${schedule.bookingCycleNo})`,
|
||||
`[WINDOW] ${schedule.id} PRE_WINDOW→OPEN — booking window opened ` +
|
||||
`(cycle ${schedule.bookingCycleNo})`,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
@@ -251,7 +261,8 @@ export class BookingWindowService implements OnModuleInit {
|
||||
schedule.bookingWindowStatus = 'CLOSED';
|
||||
}
|
||||
this.logger.log(
|
||||
`Booking stopped for schedule ${schedule.id}; staff document review until ${docReviewEndsAt.toISOString()}`,
|
||||
`[WINDOW] ${schedule.id} OPEN→DOC_REVIEW — booking closed; staff document ` +
|
||||
`review until ${docReviewEndsAt.toISOString()}`,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
@@ -263,16 +274,22 @@ export class BookingWindowService implements OnModuleInit {
|
||||
) {
|
||||
const paymentPhaseEndsAt = new Date(now.getTime() + cfg.paymentWindowMinutes * 60_000);
|
||||
await this.setPhase(schedule, { windowPhase: 'PAYMENT', paymentPhaseEndsAt });
|
||||
// Run the batch: priority fill over the route-day pool, reserving pay windows
|
||||
// (or allocating government) — skipped automatically for everyone who fits
|
||||
// is handled inside the fill (all fit → all reserved → all notified).
|
||||
await this.bookingBatchService.processRouteDay({
|
||||
const routeDay = {
|
||||
originYardId: schedule.originStationId,
|
||||
destinationYardId: schedule.destinationStationId,
|
||||
day: eatDay(schedule.scheduledDepartureDate),
|
||||
});
|
||||
};
|
||||
// Doc review is over: bookings staff never accepted (still pending) can no
|
||||
// longer make this train — expire them BEFORE the batch so they never
|
||||
// compete for capacity and never reach the pool.
|
||||
await this.bookingBatchService.expireUnacceptedForRouteDay(routeDay);
|
||||
// Run the batch: priority fill over the route-day pool, reserving pay windows
|
||||
// (or allocating government) — skipped automatically for everyone who fits
|
||||
// is handled inside the fill (all fit → all reserved → all notified).
|
||||
await this.bookingBatchService.processRouteDay(routeDay);
|
||||
this.logger.log(
|
||||
`Batch ran for schedule ${schedule.id}; payment phase until ${paymentPhaseEndsAt.toISOString()}`,
|
||||
`[WINDOW] ${schedule.id} DOC_REVIEW→PAYMENT — batch ran; payment phase ` +
|
||||
`until ${paymentPhaseEndsAt.toISOString()}`,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
@@ -282,6 +299,10 @@ export class BookingWindowService implements OnModuleInit {
|
||||
schedule.paymentPhaseEndsAt != null &&
|
||||
now >= schedule.paymentPhaseEndsAt
|
||||
) {
|
||||
this.logger.log(
|
||||
`[WINDOW] ${schedule.id} PAYMENT window ended — settling reservations ` +
|
||||
`(allocate paid / expire unpaid) then concluding the cycle`,
|
||||
);
|
||||
await this.bookingBatchService.settleDueReservations(schedule.id);
|
||||
await this.concludeCycle(schedule, cfg, now);
|
||||
return true;
|
||||
@@ -301,6 +322,9 @@ export class BookingWindowService implements OnModuleInit {
|
||||
await this.bookingBatchService.setWindow(schedule.id, 'FULL');
|
||||
await this.setPhase(schedule, { windowPhase: 'DONE' });
|
||||
await this.tryAutoFinalize(schedule.id);
|
||||
this.logger.log(
|
||||
`[WINDOW] ${schedule.id} conclude → train FULL — window DONE, finalizing`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -319,7 +343,8 @@ export class BookingWindowService implements OnModuleInit {
|
||||
if (nextOpensAt == null) {
|
||||
await this.setPhase(schedule, { windowPhase: 'DONE' });
|
||||
this.logger.log(
|
||||
`Schedule ${schedule.id} not full but no cycle fits before departure — window done`,
|
||||
`[WINDOW] ${schedule.id} conclude → not full but no cycle fits before ` +
|
||||
`departure — window DONE`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -342,7 +367,8 @@ export class BookingWindowService implements OnModuleInit {
|
||||
});
|
||||
const sameDay = eatDay(nextOpensAt) === eatDay(now);
|
||||
this.logger.log(
|
||||
`Schedule ${schedule.id} not full — window reopens ${sameDay ? 'today' : 'next booking day'} at ${nextOpensAt.toISOString()}`,
|
||||
`[WINDOW] ${schedule.id} conclude → NOT full, waiting list may remain — ` +
|
||||
`REOPENS ${sameDay ? 'today' : 'next booking day'} at ${nextOpensAt.toISOString()}`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* Segment (leg) aware capacity accounting for corridor bookings.
|
||||
*
|
||||
* A train's route is an ordered list of stops; a booking occupies only the
|
||||
* edges between its own origin and destination. Capacity (wagons / weight /
|
||||
* length) is therefore tracked PER EDGE, not per train: two bookings whose
|
||||
* legs don't overlap (Addis→Dire and Dire→Djibouti) consume the same wagon
|
||||
* budget on disjoint edges and can share physical wagons.
|
||||
*
|
||||
* Legacy schedules without route milestones degrade to a single-edge corridor
|
||||
* ([origin, destination]) where this is exactly the old train-wide math.
|
||||
*/
|
||||
|
||||
export interface Capacity {
|
||||
wagons: number;
|
||||
weightTons: number;
|
||||
lengthMeters: number;
|
||||
}
|
||||
|
||||
/** Half-open edge span along the stop list: occupies edges [fromEdge, toEdge). */
|
||||
export interface CorridorLeg {
|
||||
fromEdge: number;
|
||||
toEdge: number;
|
||||
}
|
||||
|
||||
export function addCapacity(a: Capacity, b: Capacity): Capacity {
|
||||
return {
|
||||
wagons: a.wagons + b.wagons,
|
||||
weightTons: a.weightTons + b.weightTons,
|
||||
lengthMeters: a.lengthMeters + b.lengthMeters,
|
||||
};
|
||||
}
|
||||
|
||||
export function subtractCapacity(a: Capacity, b: Capacity): Capacity {
|
||||
return {
|
||||
wagons: a.wagons - b.wagons,
|
||||
weightTons: a.weightTons - b.weightTons,
|
||||
lengthMeters: a.lengthMeters - b.lengthMeters,
|
||||
};
|
||||
}
|
||||
|
||||
export function capacityFits(need: Capacity, budget: Capacity): boolean {
|
||||
return (
|
||||
need.wagons <= budget.wagons &&
|
||||
need.weightTons <= budget.weightTons &&
|
||||
need.lengthMeters <= budget.lengthMeters
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ordered stop yard ids for a schedule. Route milestones (already ordered by
|
||||
* sequence) when there are at least two; otherwise the schedule's own
|
||||
* origin/destination pair — the legacy two-stop pseudo-route.
|
||||
*/
|
||||
export function stopYardsFor(
|
||||
milestoneYardIdsInOrder: string[] | null | undefined,
|
||||
originStationId: string,
|
||||
destinationStationId: string,
|
||||
): string[] {
|
||||
if (milestoneYardIdsInOrder && milestoneYardIdsInOrder.length >= 2) {
|
||||
return milestoneYardIdsInOrder;
|
||||
}
|
||||
return [originStationId, destinationStationId];
|
||||
}
|
||||
|
||||
/** Per-edge capacity budget along a schedule's stop list. */
|
||||
export class CorridorBudget {
|
||||
private readonly edges: Capacity[];
|
||||
private readonly stopIndex: Map<string, number>;
|
||||
|
||||
constructor(
|
||||
readonly stops: string[],
|
||||
initial: Capacity,
|
||||
) {
|
||||
const edgeCount = Math.max(1, stops.length - 1);
|
||||
this.edges = Array.from({ length: edgeCount }, () => ({ ...initial }));
|
||||
this.stopIndex = new Map(stops.map((yardId, i) => [yardId, i]));
|
||||
}
|
||||
|
||||
/** The leg between two stops, or null when they aren't on this corridor in order. */
|
||||
legOf(originYardId: string, destinationYardId: string): CorridorLeg | null {
|
||||
const from = this.stopIndex.get(originYardId);
|
||||
const to = this.stopIndex.get(destinationYardId);
|
||||
if (from == null || to == null || from >= to) return null;
|
||||
return { fromEdge: from, toEdge: to };
|
||||
}
|
||||
|
||||
/** Every edge — for whole-route consumers and unknown-leg fallbacks. */
|
||||
fullLeg(): CorridorLeg {
|
||||
return { fromEdge: 0, toEdge: this.edges.length };
|
||||
}
|
||||
|
||||
/**
|
||||
* The leg a booking occupies; bookings whose yards aren't on the corridor
|
||||
* (legacy data drift) conservatively occupy the whole route so capacity is
|
||||
* never double-booked against them.
|
||||
*/
|
||||
legForYards(originYardId: string, destinationYardId: string): CorridorLeg {
|
||||
return this.legOf(originYardId, destinationYardId) ?? this.fullLeg();
|
||||
}
|
||||
|
||||
/** Remaining capacity usable by this leg = min across its edges. */
|
||||
remainingFor(leg: CorridorLeg): Capacity {
|
||||
let min = { ...this.edges[leg.fromEdge] };
|
||||
for (let i = leg.fromEdge + 1; i < leg.toEdge; i++) {
|
||||
const e = this.edges[i];
|
||||
min = {
|
||||
wagons: Math.min(min.wagons, e.wagons),
|
||||
weightTons: Math.min(min.weightTons, e.weightTons),
|
||||
lengthMeters: Math.min(min.lengthMeters, e.lengthMeters),
|
||||
};
|
||||
}
|
||||
return min;
|
||||
}
|
||||
|
||||
fits(need: Capacity, leg: CorridorLeg): boolean {
|
||||
return capacityFits(need, this.remainingFor(leg));
|
||||
}
|
||||
|
||||
subtract(need: Capacity, leg: CorridorLeg): void {
|
||||
for (let i = leg.fromEdge; i < leg.toEdge; i++) {
|
||||
this.edges[i] = subtractCapacity(this.edges[i], need);
|
||||
}
|
||||
}
|
||||
|
||||
add(freed: Capacity, leg: CorridorLeg): void {
|
||||
for (let i = leg.fromEdge; i < leg.toEdge; i++) {
|
||||
this.edges[i] = addCapacity(this.edges[i], freed);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The most open edge — when even this has no wagon slots left, nothing can
|
||||
* board anywhere and the schedule's window is genuinely FULL. (A train can be
|
||||
* full on one leg while another still has room, so train-wide FULL keys on
|
||||
* the max, not the min.)
|
||||
*/
|
||||
maxRemaining(): Capacity {
|
||||
return this.edges.reduce(
|
||||
(max, e) => ({
|
||||
wagons: Math.max(max.wagons, e.wagons),
|
||||
weightTons: Math.max(max.weightTons, e.weightTons),
|
||||
lengthMeters: Math.max(max.lengthMeters, e.lengthMeters),
|
||||
}),
|
||||
{ ...this.edges[0] },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -10,8 +10,8 @@ import { DataSource } from 'typeorm';
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
import { RouteMilestone } from '../routes/entities/route-milestone.entity';
|
||||
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
||||
import { BookingBatchService, type Capacity } from './booking-batch.service';
|
||||
import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity';
|
||||
import { BookingBatchService } from './booking-batch.service';
|
||||
import { BookingJourneyService } from './booking-journey.service';
|
||||
|
||||
/**
|
||||
* Intercity (DOMESTIC) ride-along: intercity bookings never get their own
|
||||
@@ -32,6 +32,7 @@ export class IntercityService {
|
||||
constructor(
|
||||
@InjectDataSource() private readonly dataSource: DataSource,
|
||||
private readonly bookingBatchService: BookingBatchService,
|
||||
private readonly bookingJourneyService: BookingJourneyService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -52,13 +53,20 @@ export class IntercityService {
|
||||
return {
|
||||
scheduleId,
|
||||
routeId: schedule.routeId ?? null,
|
||||
remaining: capacity?.budget ?? null,
|
||||
// Segment-based: "remaining" is the most-open edge; each candidate's
|
||||
// `fits` is judged against ITS OWN leg, so a booking on a free leg fits
|
||||
// even when the train is full elsewhere.
|
||||
remaining: capacity?.budget.maxRemaining() ?? null,
|
||||
candidates: waiting.map((booking) => {
|
||||
const need = capacity?.needFor(booking) ?? null;
|
||||
const leg = capacity?.budget.legOf(
|
||||
booking.originYardId,
|
||||
booking.destinationYardId,
|
||||
);
|
||||
return {
|
||||
...this.mapBooking(booking),
|
||||
need,
|
||||
fits: need && capacity ? fits(need, capacity.budget) : false,
|
||||
fits: Boolean(need && capacity && leg && capacity.budget.fits(need, leg)),
|
||||
};
|
||||
}),
|
||||
accepted: accepted.map((booking) => ({
|
||||
@@ -94,7 +102,7 @@ export class IntercityService {
|
||||
|
||||
const accepted: string[] = [];
|
||||
const rejected: Array<{ bookingId: string; reason: string }> = [];
|
||||
let budget = capacity.budget;
|
||||
const budget = capacity.budget;
|
||||
|
||||
for (const bookingId of bookingIds) {
|
||||
const booking = await this.dataSource
|
||||
@@ -110,45 +118,35 @@ export class IntercityService {
|
||||
continue;
|
||||
}
|
||||
const need = capacity.needFor(booking);
|
||||
if (!fits(need, budget)) {
|
||||
const leg = budget.legOf(booking.originYardId, booking.destinationYardId);
|
||||
// Segment-based: only the booking's own leg must have room, so an
|
||||
// intercity booking still boards a train that is full on other legs.
|
||||
if (!leg || !budget.fits(need, leg)) {
|
||||
rejected.push({
|
||||
bookingId,
|
||||
reason: 'Does not fit the remaining wagon/weight/length capacity',
|
||||
reason:
|
||||
'Does not fit the remaining wagon/weight/length capacity on its leg',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
await this.bookingBatchService.acceptIntercity(booking, scheduleId);
|
||||
budget = subtract(budget, need);
|
||||
budget.subtract(need, leg);
|
||||
accepted.push(bookingId);
|
||||
this.logger.log(
|
||||
`Intercity booking ${booking.reference ?? bookingId} accepted onto schedule ${scheduleId}`,
|
||||
);
|
||||
}
|
||||
|
||||
return { accepted, rejected, remaining: budget };
|
||||
return { accepted, rejected, remaining: budget.maxRemaining() };
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark an accepted intercity booking's cargo as loaded. Only allowed while
|
||||
* the train is physically at the booking's origin yard: either it has not
|
||||
* departed yet and the booking boards at the train's own origin, or the
|
||||
* latest recorded checkpoint is at the booking's origin yard.
|
||||
* Mark an accepted intercity booking's cargo as loaded. Delegates to the
|
||||
* shared per-booking journey flow (same checkpoint gating as import/export).
|
||||
*/
|
||||
async loadBooking(scheduleId: string, bookingId: string) {
|
||||
const { schedule, booking } = await this.getAcceptedBooking(
|
||||
scheduleId,
|
||||
bookingId,
|
||||
);
|
||||
if (booking.status !== 'PAID') {
|
||||
throw new BadRequestException(
|
||||
`Booking must be paid before loading (currently ${booking.status})`,
|
||||
);
|
||||
}
|
||||
await this.assertTrainAtYard(schedule, booking.originYardId, 'origin');
|
||||
await this.dataSource
|
||||
.getRepository(Booking)
|
||||
.update(bookingId, { status: 'IN_TRANSIT' });
|
||||
return { bookingId, status: 'IN_TRANSIT' as const };
|
||||
await this.getAcceptedBooking(scheduleId, bookingId); // intercity-only guard
|
||||
return this.bookingJourneyService.loadBooking(scheduleId, bookingId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -156,20 +154,8 @@ export class IntercityService {
|
||||
* requires the latest checkpoint to be at that yard. Completes the booking.
|
||||
*/
|
||||
async unloadBooking(scheduleId: string, bookingId: string) {
|
||||
const { schedule, booking } = await this.getAcceptedBooking(
|
||||
scheduleId,
|
||||
bookingId,
|
||||
);
|
||||
if (booking.status !== 'IN_TRANSIT') {
|
||||
throw new BadRequestException(
|
||||
`Booking must be loaded/in transit before unloading (currently ${booking.status})`,
|
||||
);
|
||||
}
|
||||
await this.assertTrainAtYard(schedule, booking.destinationYardId, 'destination');
|
||||
await this.dataSource
|
||||
.getRepository(Booking)
|
||||
.update(bookingId, { status: 'COMPLETED' });
|
||||
return { bookingId, status: 'COMPLETED' as const };
|
||||
await this.getAcceptedBooking(scheduleId, bookingId); // intercity-only guard
|
||||
return this.bookingJourneyService.unloadBooking(scheduleId, bookingId);
|
||||
}
|
||||
|
||||
// ---- helpers ---------------------------------------------------------------
|
||||
@@ -298,36 +284,6 @@ export class IntercityService {
|
||||
return { schedule, booking };
|
||||
}
|
||||
|
||||
/**
|
||||
* The train is "at" a yard when the latest recorded checkpoint is that yard,
|
||||
* or — for a booking boarding at the train's own origin — when the train has
|
||||
* not recorded any checkpoint yet (still sitting at its origin).
|
||||
*/
|
||||
private async assertTrainAtYard(
|
||||
schedule: TrainSchedule,
|
||||
yardId: string,
|
||||
side: 'origin' | 'destination',
|
||||
): Promise<void> {
|
||||
const latest = await this.dataSource
|
||||
.getRepository(TrainCheckpointEvent)
|
||||
.findOne({
|
||||
where: { trainScheduleId: schedule.id },
|
||||
order: { occurredAt: 'DESC', createdAt: 'DESC' },
|
||||
});
|
||||
|
||||
if (!latest) {
|
||||
if (side === 'origin' && schedule.originStationId === yardId) return;
|
||||
throw new BadRequestException(
|
||||
'Train has not reached this yard yet — record its checkpoint first',
|
||||
);
|
||||
}
|
||||
if (latest.yardId !== yardId) {
|
||||
throw new BadRequestException(
|
||||
`Train's last recorded position is not at the booking's ${side} yard`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private mapBooking(booking: Booking) {
|
||||
return {
|
||||
id: booking.id,
|
||||
@@ -350,18 +306,3 @@ export class IntercityService {
|
||||
}
|
||||
}
|
||||
|
||||
function fits(need: Capacity, budget: Capacity): boolean {
|
||||
return (
|
||||
need.wagons <= budget.wagons &&
|
||||
need.weightTons <= budget.weightTons &&
|
||||
need.lengthMeters <= budget.lengthMeters
|
||||
);
|
||||
}
|
||||
|
||||
function subtract(budget: Capacity, need: Capacity): Capacity {
|
||||
return {
|
||||
wagons: budget.wagons - need.wagons,
|
||||
weightTons: budget.weightTons - need.weightTons,
|
||||
lengthMeters: budget.lengthMeters - need.lengthMeters,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ import { UpdateScheduleWindowRuleDto } from "./dto/update-schedule-window-rule.d
|
||||
import { UpdateScheduleDateDto } from "./dto/update-schedule-date.dto";
|
||||
import { TrainSchedulingService } from "./train-scheduling.service";
|
||||
import { BookingBatchService } from "./booking-batch.service";
|
||||
import { BookingJourneyService } from "./booking-journey.service";
|
||||
import { BookingWindowService } from "./booking-window.service";
|
||||
import { IntercityService } from "./intercity.service";
|
||||
import { BillingService } from "../billing/billing.service";
|
||||
@@ -60,6 +61,7 @@ export class TrainSchedulingController {
|
||||
private readonly bookingBatchService: BookingBatchService,
|
||||
private readonly bookingWindowService: BookingWindowService,
|
||||
private readonly intercityService: IntercityService,
|
||||
private readonly bookingJourneyService: BookingJourneyService,
|
||||
private readonly billingService: BillingService,
|
||||
) { }
|
||||
|
||||
@@ -432,6 +434,42 @@ export class TrainSchedulingController {
|
||||
return this.intercityService.acceptBookings(id, dto.bookingIds);
|
||||
}
|
||||
|
||||
@Get("schedules/:id/yard-work")
|
||||
@TrainSchedulingView()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Per-yard operator worklist: which bookings board/alight at each stop, with journey state",
|
||||
})
|
||||
getYardWork(@Param("id", ParseUUIDPipe) id: string) {
|
||||
return this.bookingJourneyService.listYardWork(id);
|
||||
}
|
||||
|
||||
@Post("schedules/:id/bookings/:bookingId/load")
|
||||
@TrainSchedulingManage()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Confirm a booking's cargo loaded at its origin yard (any direction; train must be at that yard)",
|
||||
})
|
||||
loadScheduleBooking(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Param("bookingId", ParseUUIDPipe) bookingId: string,
|
||||
) {
|
||||
return this.bookingJourneyService.loadBooking(id, bookingId);
|
||||
}
|
||||
|
||||
@Post("schedules/:id/bookings/:bookingId/unload")
|
||||
@TrainSchedulingManage()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Confirm a booking's cargo unloaded at its destination yard — per-booking arrival, may precede the train's final arrival",
|
||||
})
|
||||
unloadScheduleBooking(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Param("bookingId", ParseUUIDPipe) bookingId: string,
|
||||
) {
|
||||
return this.bookingJourneyService.unloadBooking(id, bookingId);
|
||||
}
|
||||
|
||||
@Post("schedules/:id/intercity/:bookingId/load")
|
||||
@TrainSchedulingManage()
|
||||
@ApiOperation({
|
||||
|
||||
@@ -30,8 +30,10 @@ import { BookingWindowGateway } from './booking-window.gateway';
|
||||
import { BookingWindowService } from './booking-window.service';
|
||||
import { IntercityService } from './intercity.service';
|
||||
import { WsAuthService } from '../notification-inbox/ws-auth.service';
|
||||
import { BookingJourneyService } from './booking-journey.service';
|
||||
import { BookingSplitService } from './booking-split.service';
|
||||
import { BookingBatchOffer } from './entities/booking-batch-offer.entity';
|
||||
import { WagonMovement } from '../wagons/entities/wagon-movement.entity';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
import { NotificationInboxModule } from '../notification-inbox/notification-inbox.module';
|
||||
import { ContractsModule } from '../contracts/contracts.module';
|
||||
@@ -51,6 +53,7 @@ import { ContractsModule } from '../contracts/contracts.module';
|
||||
TrainCheckpointEvent,
|
||||
ImportDjiboutiOperation,
|
||||
BookingBatchOffer,
|
||||
WagonMovement,
|
||||
// WsAuthService (booking-window gateway handshake) verifies IAM sessions.
|
||||
Session,
|
||||
]),
|
||||
@@ -77,6 +80,7 @@ import { ContractsModule } from '../contracts/contracts.module';
|
||||
BookingWindowService,
|
||||
BookingSplitService,
|
||||
IntercityService,
|
||||
BookingJourneyService,
|
||||
],
|
||||
exports: [
|
||||
TrainSchedulingService,
|
||||
|
||||
@@ -155,6 +155,10 @@ describe('TrainSchedulingService', () => {
|
||||
htmlToPdfBuffer: jest.fn(),
|
||||
} as never,
|
||||
{ emitPhase: jest.fn() } as never, // bookingWindowGateway
|
||||
{
|
||||
autoArriveAtFinalYard: jest.fn().mockResolvedValue([]),
|
||||
} as never, // bookingJourneyService
|
||||
{ dispatched: jest.fn(), arrived: jest.fn() } as never, // bookingNotifier
|
||||
);
|
||||
|
||||
const defaultFleetWagons = [
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
SchedulingStatus,
|
||||
TrainCheckpointKind,
|
||||
TrainScheduleStatus as TrainScheduleStatusEnum,
|
||||
WagonMovementKind,
|
||||
WagonStatus,
|
||||
} from '@edr/types';
|
||||
import {
|
||||
@@ -16,7 +17,7 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import { DataSource, EntityManager, In, IsNull, Not } from 'typeorm';
|
||||
import { DataSource, EntityManager, In, IsNull, Not, QueryFailedError } from 'typeorm';
|
||||
|
||||
import { BookingsRepository } from '../bookings/bookings.repository';
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
@@ -27,6 +28,7 @@ import { Container } from '../container-management/entities/container.entity';
|
||||
import { Locomotive } from '../locomotives/entities/locomotive.entity';
|
||||
import { LocomotivesRepository } from '../locomotives/locomotives.repository';
|
||||
import { formatRouteLabel, Route } from '../routes/entities/route.entity';
|
||||
import { WagonMovement } from '../wagons/entities/wagon-movement.entity';
|
||||
import { TrainSetLocomotive } from '../train-sets/entities/train-set-locomotive.entity';
|
||||
import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity';
|
||||
import { TrainSet } from '../train-sets/entities/train-set.entity';
|
||||
@@ -70,6 +72,7 @@ import { UpdateScheduleWindowRuleDto } from './dto/update-schedule-window-rule.d
|
||||
import { UpdateScheduleDateDto } from './dto/update-schedule-date.dto';
|
||||
import { type BookingWindowConfig } from './booking-window.config';
|
||||
import { BookingWindowGateway } from './booking-window.gateway';
|
||||
import { BookingNotifierService } from './booking-notifier.service';
|
||||
import {
|
||||
buildCappedWagonPlan,
|
||||
computeFleetAvailability,
|
||||
@@ -113,6 +116,7 @@ import {
|
||||
eatDay,
|
||||
} from './batch-window.util';
|
||||
import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity';
|
||||
import { BookingJourneyService } from './booking-journey.service';
|
||||
import { TrainCheckpointEventsRepository } from './train-checkpoint-events.repository';
|
||||
import { RecordCheckpointDto } from './dto/record-checkpoint.dto';
|
||||
import { RouteMilestone } from '../routes/entities/route-milestone.entity';
|
||||
@@ -239,6 +243,7 @@ const DEFAULT_TRAIN_LIMITS: Required<TrainLimitConfig> = {
|
||||
/** Raw row shape for the booking-window queries (company- and contract-scoped). */
|
||||
interface BookingWindowRow {
|
||||
schedule_id: string;
|
||||
reference: string | null;
|
||||
contract_id: string | null;
|
||||
contract_kind: string | null;
|
||||
direction: string | null;
|
||||
@@ -276,10 +281,39 @@ export class TrainSchedulingService {
|
||||
private readonly warehouseInventoryService: WarehouseInventoryService,
|
||||
private readonly pdfDocuments: WarehouseReleaseDocumentService,
|
||||
private readonly bookingWindowGateway: BookingWindowGateway,
|
||||
private readonly bookingJourneyService: BookingJourneyService,
|
||||
private readonly bookingNotifier: BookingNotifierService,
|
||||
@Optional() private readonly milestoneService?: ClearanceMilestoneService,
|
||||
private readonly configService?: ConfigService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Notify each booking's customer that their shipment was dispatched / arrived,
|
||||
* with a deep-link to the booking. Fire-and-forget — never blocks the action.
|
||||
*/
|
||||
private async notifyScheduleBookings(
|
||||
schedule: TrainSchedule,
|
||||
event: 'dispatched' | 'arrived',
|
||||
): Promise<void> {
|
||||
try {
|
||||
const ids = (schedule.scheduleBookings ?? []).map((sb) => sb.bookingId).filter(Boolean);
|
||||
if (!ids.length) return;
|
||||
const origin = schedule.originStation?.label ?? schedule.originStation?.code ?? null;
|
||||
const destination =
|
||||
schedule.destinationStation?.label ?? schedule.destinationStation?.code ?? null;
|
||||
const bookings = await this.dataSource.getRepository(Booking).find({
|
||||
where: { id: In(ids) },
|
||||
relations: { company: true },
|
||||
});
|
||||
for (const b of bookings) {
|
||||
if (event === 'dispatched') this.bookingNotifier.dispatched(b, origin, destination);
|
||||
else this.bookingNotifier.arrived(b, origin, destination);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.warn(`Failed to notify schedule bookings (${event}): ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete customer-tracking clearance milestones for every booking on a
|
||||
* schedule when a physical lifecycle event fires (dispatch, arrive, load,
|
||||
@@ -290,15 +324,26 @@ export class TrainSchedulingService {
|
||||
private async completeMilestonesForScheduleBookings(
|
||||
scheduleId: string,
|
||||
codes: string[],
|
||||
filter?: { originYardId?: string; destinationYardId?: string },
|
||||
): Promise<void> {
|
||||
if (!this.milestoneService || codes.length === 0) return;
|
||||
try {
|
||||
const conditions = ['tsb.train_schedule_id = $1', 'tsb.deleted_at IS NULL'];
|
||||
const params: unknown[] = [scheduleId];
|
||||
if (filter?.originYardId) {
|
||||
params.push(filter.originYardId);
|
||||
conditions.push(`b.origin_yard_id = $${params.length}`);
|
||||
}
|
||||
if (filter?.destinationYardId) {
|
||||
params.push(filter.destinationYardId);
|
||||
conditions.push(`b.destination_yard_id = $${params.length}`);
|
||||
}
|
||||
const rows: Array<{ booking_id: string }> = await this.dataSource.query(
|
||||
`SELECT tsb.booking_id
|
||||
FROM freight.train_schedule_bookings tsb
|
||||
WHERE tsb.train_schedule_id = $1
|
||||
AND tsb.deleted_at IS NULL`,
|
||||
[scheduleId],
|
||||
JOIN freight.bookings b ON b.id = tsb.booking_id
|
||||
WHERE ${conditions.join(' AND ')}`,
|
||||
params,
|
||||
);
|
||||
for (const { booking_id } of rows) {
|
||||
for (const code of codes) {
|
||||
@@ -806,20 +851,24 @@ export class TrainSchedulingService {
|
||||
...ruleSnapshot,
|
||||
...computeImportWindowTimes(departure, windowCfg, new Date()),
|
||||
};
|
||||
const schedule = manager.getRepository(TrainSchedule).create({
|
||||
trainSetId: trainSet.id,
|
||||
routeId: route.id,
|
||||
originStationId: route.originYardId,
|
||||
destinationStationId: route.destinationYardId,
|
||||
scheduledDepartureDate: departure,
|
||||
status: TrainScheduleStatusEnum.Draft,
|
||||
direction,
|
||||
maxWagons: (
|
||||
await this.resolveTrainLimitConfig(dto, limitLoco)
|
||||
).maxWagonsPerTrain,
|
||||
...windowFields,
|
||||
});
|
||||
const saved = await manager.getRepository(TrainSchedule).save(schedule);
|
||||
const maxWagons = (await this.resolveTrainLimitConfig(dto, limitLoco))
|
||||
.maxWagonsPerTrain;
|
||||
// Retry past a concurrent insert that grabbed the same S-<year> sequence
|
||||
// (the unique index rejects the loser; it re-reads the max and tries again).
|
||||
const saved = await this.insertScheduleWithReference(manager, (reference) =>
|
||||
manager.getRepository(TrainSchedule).create({
|
||||
reference,
|
||||
trainSetId: trainSet.id,
|
||||
routeId: route.id,
|
||||
originStationId: route.originYardId,
|
||||
destinationStationId: route.destinationYardId,
|
||||
scheduledDepartureDate: departure,
|
||||
status: TrainScheduleStatusEnum.Draft,
|
||||
direction,
|
||||
maxWagons,
|
||||
...windowFields,
|
||||
}),
|
||||
);
|
||||
// Locomotives stay in their current status until dispatch — advance scheduling
|
||||
// must not block the locomotive from serving earlier trains.
|
||||
return saved.id;
|
||||
@@ -1457,6 +1506,24 @@ export class TrainSchedulingService {
|
||||
manager,
|
||||
);
|
||||
}
|
||||
// Per-booking journey fallback: bookings boarding at the TRAIN's origin
|
||||
// that the operator didn't load individually are auto-loaded now — the
|
||||
// train is leaving with them. Mid-corridor boarders stay PAID until the
|
||||
// operator loads them at their own yard.
|
||||
await manager.query(
|
||||
`UPDATE freight.bookings b
|
||||
SET status = 'IN_TRANSIT',
|
||||
loaded_at = COALESCE(b.loaded_at, $3)
|
||||
FROM freight.train_schedule_bookings tsb
|
||||
WHERE tsb.booking_id = b.id
|
||||
AND tsb.train_schedule_id = $1
|
||||
AND tsb.deleted_at IS NULL
|
||||
AND b.deleted_at IS NULL
|
||||
AND b.origin_yard_id = $2
|
||||
AND b.loaded_at IS NULL
|
||||
AND (b.status = 'PAID' OR (b.is_government = true AND b.status = 'APPROVED'))`,
|
||||
[scheduleId, schedule.originStationId, now],
|
||||
);
|
||||
// Close the booking window; any still-pending (unallocated) reservations don't ride this train.
|
||||
await manager
|
||||
.getRepository(TrainSchedule)
|
||||
@@ -1488,19 +1555,26 @@ export class TrainSchedulingService {
|
||||
// Dispatch closed the window — drop it from portal/GL cards right away.
|
||||
void this.emitWindowState(scheduleId);
|
||||
// Customer tracking: cargo is on the departing train — loading milestones
|
||||
// plus the direction's "departed" handoff milestone.
|
||||
// plus the direction's "departed" handoff milestone. Restricted to bookings
|
||||
// that BOARD at the train's origin; mid-corridor boarders get their loading
|
||||
// milestones from their own operator load at their own yard.
|
||||
if (schedule.direction === 'IMPORT' || schedule.direction === 'EXPORT') {
|
||||
void this.completeMilestonesForScheduleBookings(scheduleId, [
|
||||
// CARGO_ARRIVED is export-only (cargo reached the origin yard) — the
|
||||
// doc-trigger path no-ops it for import bookings.
|
||||
'CARGO_ARRIVED',
|
||||
'READY_FOR_LOADING',
|
||||
'LOADED',
|
||||
schedule.direction === 'IMPORT'
|
||||
? 'DEPARTED_FROM_DJIBOUTI'
|
||||
: 'DEPARTED_TO_DJIBOUTI',
|
||||
]);
|
||||
void this.completeMilestonesForScheduleBookings(
|
||||
scheduleId,
|
||||
[
|
||||
// CARGO_ARRIVED is export-only (cargo reached the origin yard) — the
|
||||
// doc-trigger path no-ops it for import bookings.
|
||||
'CARGO_ARRIVED',
|
||||
'READY_FOR_LOADING',
|
||||
'LOADED',
|
||||
schedule.direction === 'IMPORT'
|
||||
? 'DEPARTED_FROM_DJIBOUTI'
|
||||
: 'DEPARTED_TO_DJIBOUTI',
|
||||
],
|
||||
{ originYardId: schedule.originStationId },
|
||||
);
|
||||
}
|
||||
void this.notifyScheduleBookings(schedule, 'dispatched');
|
||||
return this.getTrainScheduleById(scheduleId);
|
||||
}
|
||||
|
||||
@@ -2426,18 +2500,11 @@ export class TrainSchedulingService {
|
||||
});
|
||||
}
|
||||
|
||||
await manager.query(
|
||||
`UPDATE freight.bookings b
|
||||
SET status = $2,
|
||||
scheduling_status = $3
|
||||
FROM freight.train_schedule_bookings tsb
|
||||
WHERE tsb.booking_id = b.id
|
||||
AND tsb.train_schedule_id = $1
|
||||
AND tsb.deleted_at IS NULL
|
||||
AND b.deleted_at IS NULL
|
||||
AND b.status NOT IN ('DRAFT', 'REJECTED', 'CANCELLED', 'COMPLETED')`,
|
||||
[scheduleId, 'IN_TRANSIT', SchedulingStatus.Dispatched],
|
||||
);
|
||||
// Per-booking journey: bookings destined for the FINAL yard that the
|
||||
// operator didn't unload individually get their arrival stamped now as a
|
||||
// bulk fallback. Mid-corridor bookings are NOT touched — their arrival is
|
||||
// their own unload (possibly already done while the train kept rolling).
|
||||
await this.bookingJourneyService.autoArriveAtFinalYard(manager, schedule, now);
|
||||
|
||||
// Release every locomotive of the set (not just the legacy primary) and move it
|
||||
// to the destination yard where it physically arrived.
|
||||
@@ -2455,12 +2522,33 @@ export class TrainSchedulingService {
|
||||
.getRepository(Wagon)
|
||||
.findOne({ where: { id: slot.physicalWagonId } });
|
||||
if (!wagon) continue;
|
||||
// A wagon that already alighted mid-route (unload released it, possibly
|
||||
// re-pinned elsewhere since) is no longer this schedule's to move.
|
||||
if (wagon.currentTrainScheduleId !== scheduleId) continue;
|
||||
// Dynamic consist: the wagon settles at its slot's alight yard, not
|
||||
// blanket at the train's destination.
|
||||
const settleYardId = slot.alightYardId ?? schedule.destinationStationId;
|
||||
await manager.getRepository(Wagon).update(wagon.id, {
|
||||
currentTrainScheduleId: null,
|
||||
trainSetWagonId: null,
|
||||
status: WagonStatus.Available,
|
||||
currentYardId: schedule.destinationStationId,
|
||||
currentYardId: settleYardId,
|
||||
});
|
||||
// Ledger: the wagon rode this schedule to its settle yard.
|
||||
const slotAllocations = slot.allocations ?? [];
|
||||
await manager.getRepository(WagonMovement).save(
|
||||
manager.getRepository(WagonMovement).create({
|
||||
wagonId: wagon.id,
|
||||
fromYardId: slot.boardYardId ?? schedule.originStationId,
|
||||
toYardId: settleYardId,
|
||||
trainScheduleId: scheduleId,
|
||||
bookingId: slotAllocations[0]?.bookingId ?? null,
|
||||
kind: slotAllocations.length
|
||||
? WagonMovementKind.Loaded
|
||||
: WagonMovementKind.EmptyReposition,
|
||||
occurredAt: now,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Ensure a destination checkpoint exists so the timeline shows ARRIVED.
|
||||
@@ -2482,12 +2570,17 @@ export class TrainSchedulingService {
|
||||
}
|
||||
});
|
||||
|
||||
// Customer tracking: the train reached the corridor's far end.
|
||||
// Customer tracking: the train reached the corridor's far end. Restricted
|
||||
// to bookings destined for the FINAL yard — mid-corridor bookings get their
|
||||
// arrival milestone from their own operator unload at their own yard.
|
||||
if (schedule.direction === 'IMPORT' || schedule.direction === 'EXPORT') {
|
||||
void this.completeMilestonesForScheduleBookings(scheduleId, [
|
||||
schedule.direction === 'IMPORT' ? 'ARRIVED_ETHIOPIA' : 'ARRIVED_AT_DJIBOUTI',
|
||||
]);
|
||||
void this.completeMilestonesForScheduleBookings(
|
||||
scheduleId,
|
||||
[schedule.direction === 'IMPORT' ? 'ARRIVED_ETHIOPIA' : 'ARRIVED_AT_DJIBOUTI'],
|
||||
{ destinationYardId: schedule.destinationStationId },
|
||||
);
|
||||
}
|
||||
void this.notifyScheduleBookings(schedule, 'arrived');
|
||||
|
||||
const detail = await this.getTrainScheduleById(scheduleId);
|
||||
const warehouseAutomation = await this.runWarehouseArrivalAutomation(scheduleId);
|
||||
@@ -2503,7 +2596,8 @@ export class TrainSchedulingService {
|
||||
destinationStation: true,
|
||||
scheduleBookings: { booking: true },
|
||||
},
|
||||
order: { scheduledDepartureDate: 'DESC', createdAt: 'DESC' },
|
||||
// Newest-created first (the client can re-sort; this is the default order).
|
||||
order: { createdAt: 'DESC', scheduledDepartureDate: 'DESC' },
|
||||
});
|
||||
return schedules.map((s) => this.mapScheduleListItem(s));
|
||||
}
|
||||
@@ -2635,17 +2729,26 @@ export class TrainSchedulingService {
|
||||
}
|
||||
|
||||
if (
|
||||
bookings.some((b) => {
|
||||
if (targetScheduleId && b.trainScheduleId === targetScheduleId) {
|
||||
return false;
|
||||
await (async () => {
|
||||
// Corridor-aware: a booking belongs on this train when its origin and
|
||||
// destination lie on the schedule's stop list in order — sub-corridor
|
||||
// bookings (Dire→Djibouti on an Addis→…→Djibouti train) are valid.
|
||||
let stops = [dto.originStationId, dto.destinationStationId];
|
||||
if (targetScheduleId) {
|
||||
const target = await this.trainSchedulesRepository.findById(targetScheduleId);
|
||||
if (target) stops = await this.stopYardsForSchedule(target);
|
||||
}
|
||||
return (
|
||||
b.originYardId !== dto.originStationId ||
|
||||
b.destinationYardId !== dto.destinationStationId
|
||||
);
|
||||
})
|
||||
return bookings.some((b) => {
|
||||
if (targetScheduleId && b.trainScheduleId === targetScheduleId) {
|
||||
return false;
|
||||
}
|
||||
const fromIdx = stops.indexOf(b.originYardId);
|
||||
const toIdx = stops.indexOf(b.destinationYardId);
|
||||
return fromIdx < 0 || toIdx < 0 || fromIdx >= toIdx;
|
||||
});
|
||||
})()
|
||||
) {
|
||||
violations.push('Selected bookings must share the same origin and destination as the schedule');
|
||||
violations.push('Selected bookings must lie on the schedule route (origin before destination)');
|
||||
}
|
||||
|
||||
if (!forceAssign) {
|
||||
@@ -2695,7 +2798,37 @@ export class TrainSchedulingService {
|
||||
}
|
||||
|
||||
const originYardId = dto.originStationId;
|
||||
const fleetCounts = await this.countFleetAvailability(originYardId, targetScheduleId);
|
||||
// Dynamic consist: a slot's physical wagon may ride from the train's origin
|
||||
// OR already sit at the booking's own boarding yard and attach there — so
|
||||
// the usable fleet is the union across the origin and every boarding yard.
|
||||
const boardYardIds = [
|
||||
...new Set(
|
||||
[originYardId, ...bookings.map((b) => b.originYardId)].filter(Boolean),
|
||||
),
|
||||
];
|
||||
const fleetCountsByYard = await Promise.all(
|
||||
boardYardIds.map((yardId) =>
|
||||
this.countFleetAvailability(yardId, targetScheduleId),
|
||||
),
|
||||
);
|
||||
const mergedFleet = new Map<string, { code: string; available: number }>();
|
||||
for (const rows of fleetCountsByYard) {
|
||||
for (const row of rows) {
|
||||
const existing = mergedFleet.get(row.wagonTypeId) ?? {
|
||||
code: row.wagonTypeCode,
|
||||
available: 0,
|
||||
};
|
||||
existing.available += row.available;
|
||||
mergedFleet.set(row.wagonTypeId, existing);
|
||||
}
|
||||
}
|
||||
const fleetCounts = [...mergedFleet.entries()].map(
|
||||
([wagonTypeId, value]) => ({
|
||||
wagonTypeId,
|
||||
wagonTypeCode: value.code,
|
||||
available: value.available,
|
||||
}),
|
||||
);
|
||||
const fleetByTypeId = new Map(fleetCounts.map((row) => [row.wagonTypeId, row.available]));
|
||||
fleetAvailability = computeFleetAvailability(
|
||||
demandPlan,
|
||||
@@ -2720,6 +2853,12 @@ export class TrainSchedulingService {
|
||||
containerWagonType,
|
||||
bulkWagonType,
|
||||
});
|
||||
this.stampSlotLegs(
|
||||
wagonPlan,
|
||||
fittingBookings,
|
||||
dto.originStationId,
|
||||
dto.destinationStationId,
|
||||
);
|
||||
|
||||
violations.push(
|
||||
...(await this.validatePhysicalFleetForPlan(
|
||||
@@ -3047,6 +3186,7 @@ export class TrainSchedulingService {
|
||||
wagonTypeId: slot.wagonTypeId,
|
||||
wagonTypeCode: typeCodeById.get(slot.wagonTypeId) ?? slot.wagonTypeId,
|
||||
trainSetWagonId: slot.id,
|
||||
boardYardId: slot.boardYardId ?? null,
|
||||
}));
|
||||
|
||||
const unpinnable = this.findUnpinnableWagonSlots(
|
||||
@@ -3100,6 +3240,7 @@ export class TrainSchedulingService {
|
||||
sequenceNo: slot.sequenceNo,
|
||||
wagonTypeId: slot.wagonTypeId,
|
||||
wagonTypeCode: slot.wagonTypeCode,
|
||||
boardYardId: slot.boardYardId ?? null,
|
||||
})),
|
||||
wagons,
|
||||
targetScheduleId,
|
||||
@@ -3108,7 +3249,12 @@ export class TrainSchedulingService {
|
||||
}
|
||||
|
||||
private findUnpinnableWagonSlots(
|
||||
slots: Array<{ sequenceNo: number; wagonTypeId: string; wagonTypeCode: string }>,
|
||||
slots: Array<{
|
||||
sequenceNo: number;
|
||||
wagonTypeId: string;
|
||||
wagonTypeCode: string;
|
||||
boardYardId?: string | null;
|
||||
}>,
|
||||
wagons: Wagon[],
|
||||
scheduleId: string | undefined,
|
||||
originYardId: string,
|
||||
@@ -3136,22 +3282,35 @@ export class TrainSchedulingService {
|
||||
return violations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dynamic consist: a slot's wagon may either ride from the train's origin
|
||||
* yard (attaching there, possibly empty until the slot's board yard) or
|
||||
* already sit AT the slot's board yard and hook on when the train arrives.
|
||||
*/
|
||||
private pickPhysicalWagonForSlot(
|
||||
slot: { wagonTypeId: string },
|
||||
slot: { wagonTypeId: string; boardYardId?: string | null },
|
||||
wagons: Wagon[],
|
||||
scheduleId: string | undefined,
|
||||
originYardId: string,
|
||||
assignedPhysicalIds: Set<string>,
|
||||
): Wagon | undefined {
|
||||
return wagons.find((wagon) => {
|
||||
const usable = (wagon: Wagon): boolean => {
|
||||
if (wagon.wagonTypeId !== slot.wagonTypeId) return false;
|
||||
if (assignedPhysicalIds.has(wagon.id)) return false;
|
||||
const pinnedOnSchedule = scheduleId
|
||||
? wagon.currentTrainScheduleId === scheduleId
|
||||
: false;
|
||||
if (wagon.status !== WagonStatus.Available && !pinnedOnSchedule) return false;
|
||||
return wagon.currentYardId === originYardId;
|
||||
});
|
||||
return wagon.status === WagonStatus.Available || pinnedOnSchedule;
|
||||
};
|
||||
// Prefer a wagon already waiting at the slot's board yard (no empty haul);
|
||||
// fall back to one riding from the train's origin.
|
||||
if (slot.boardYardId) {
|
||||
const atBoardYard = wagons.find(
|
||||
(w) => usable(w) && w.currentYardId === slot.boardYardId,
|
||||
);
|
||||
if (atBoardYard) return atBoardYard;
|
||||
}
|
||||
return wagons.find((w) => usable(w) && w.currentYardId === originYardId);
|
||||
}
|
||||
|
||||
private positiveNumber(value: number | undefined, fallback: number): number {
|
||||
@@ -3303,6 +3462,42 @@ export class TrainSchedulingService {
|
||||
return containerType?.wagonType?.isActive ? containerType.wagonType : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stamp each plan slot with the leg it occupies (dynamic consist): the
|
||||
* boarding/alighting yards of the bookings it carries. Null means the
|
||||
* schedule's own endpoint (whole-route slot, legacy behavior). A slot
|
||||
* carrying bookings with mixed corridors stays whole-route (conservative).
|
||||
*/
|
||||
private stampSlotLegs(
|
||||
wagonPlan: WagonPlanSlot[],
|
||||
bookings: Booking[],
|
||||
scheduleOriginYardId: string,
|
||||
scheduleDestinationYardId: string,
|
||||
): void {
|
||||
const bookingById = new Map(bookings.map((b) => [b.id, b]));
|
||||
for (const slot of wagonPlan) {
|
||||
const slotBookings = [
|
||||
...new Set(slot.allocations.map((a) => a.bookingId)),
|
||||
]
|
||||
.map((id) => bookingById.get(id))
|
||||
.filter((b): b is Booking => Boolean(b));
|
||||
if (!slotBookings.length) continue;
|
||||
const [first] = slotBookings;
|
||||
const sameCorridor = slotBookings.every(
|
||||
(b) =>
|
||||
b.originYardId === first.originYardId &&
|
||||
b.destinationYardId === first.destinationYardId,
|
||||
);
|
||||
if (!sameCorridor) continue;
|
||||
slot.boardYardId =
|
||||
first.originYardId === scheduleOriginYardId ? null : first.originYardId;
|
||||
slot.alightYardId =
|
||||
first.destinationYardId === scheduleDestinationYardId
|
||||
? null
|
||||
: first.destinationYardId;
|
||||
}
|
||||
}
|
||||
|
||||
private async persistTrainSetWagons(
|
||||
manager: EntityManager,
|
||||
trainSetId: string,
|
||||
@@ -3318,6 +3513,8 @@ export class TrainSchedulingService {
|
||||
lengthMeters: slot.lengthMeters,
|
||||
assignedWeightTons: slot.assignedWeightTons,
|
||||
status: 'PLANNED',
|
||||
boardYardId: slot.boardYardId ?? null,
|
||||
alightYardId: slot.alightYardId ?? null,
|
||||
}),
|
||||
);
|
||||
return manager.getRepository(TrainSetWagon).save(wagons);
|
||||
@@ -3625,9 +3822,41 @@ export class TrainSchedulingService {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert a schedule with a freshly generated S-<year>-NNNNN reference, retrying
|
||||
* past a concurrent insert that grabbed the same sequence (the unique index
|
||||
* rejects the loser). Mirrors insertWithGeneratedReference for bookings, but
|
||||
* runs inside the caller's transaction manager so the row joins the same commit.
|
||||
*/
|
||||
private async insertScheduleWithReference(
|
||||
manager: EntityManager,
|
||||
build: (reference: string) => TrainSchedule,
|
||||
): Promise<TrainSchedule> {
|
||||
const year = new Date().getFullYear();
|
||||
const repo = manager.getRepository(TrainSchedule);
|
||||
for (let attempt = 0; attempt < 5; attempt += 1) {
|
||||
const seq = await this.trainSchedulesRepository.maxReferenceSequence(year);
|
||||
const reference = `S-${year}-${String(seq + 1).padStart(5, '0')}`;
|
||||
try {
|
||||
return await repo.save(build(reference));
|
||||
} catch (err) {
|
||||
// 23505 = unique_violation on ux_train_schedules_reference; re-read + retry.
|
||||
const code = (err as { driverError?: { code?: string } })?.driverError?.code;
|
||||
if (err instanceof QueryFailedError && code === '23505' && attempt < 4) {
|
||||
continue;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
// Unreachable — the loop either returns or throws — but satisfies the compiler.
|
||||
throw new ConflictException('Could not allocate a unique schedule reference');
|
||||
}
|
||||
|
||||
private mapScheduleListItem(schedule: import('../train-schedules/entities/train-schedule.entity').TrainSchedule) {
|
||||
return {
|
||||
id: schedule.id,
|
||||
reference: schedule.reference ?? null,
|
||||
createdAt: schedule.createdAt ?? null,
|
||||
scheduleDate: schedule.scheduledDepartureDate,
|
||||
trainNumber: schedule.trainNumber ?? null,
|
||||
routeName: schedule.route ? formatRouteLabel(schedule.route) : null,
|
||||
@@ -3722,6 +3951,7 @@ export class TrainSchedulingService {
|
||||
const rows: Array<BookingWindowRow> = await this.dataSource.query(
|
||||
`SELECT DISTINCT ON (ts.id)
|
||||
ts.id AS schedule_id,
|
||||
ts.reference AS reference,
|
||||
cr.contract_id AS contract_id,
|
||||
c.contract_kind AS contract_kind,
|
||||
ts.direction,
|
||||
@@ -3752,14 +3982,16 @@ export class TrainSchedulingService {
|
||||
AND ts.window_phase IS NOT NULL
|
||||
AND ts.window_phase NOT IN ('DONE', 'CLOSED_FOR_DAY')
|
||||
AND ts.scheduled_departure_date >= now()
|
||||
ORDER BY ts.id, c.id NULLS LAST, ts.window_opens_at ASC NULLS LAST`,
|
||||
ORDER BY ts.id, c.id NULLS LAST, ts.scheduled_departure_date ASC NULLS LAST`,
|
||||
[companyId],
|
||||
);
|
||||
// Nearest dispatch (departure) date first — the DISTINCT ON above forces a
|
||||
// per-row ordering, so re-sort the mapped rows by departure for the client.
|
||||
return rows
|
||||
.map((r) => this.mapBookingWindowRow(r))
|
||||
.sort((a, b) => {
|
||||
const ta = a.windowOpensAt ? new Date(a.windowOpensAt).getTime() : Infinity;
|
||||
const tb = b.windowOpensAt ? new Date(b.windowOpensAt).getTime() : Infinity;
|
||||
const ta = a.departureDate ? new Date(a.departureDate).getTime() : Infinity;
|
||||
const tb = b.departureDate ? new Date(b.departureDate).getTime() : Infinity;
|
||||
return ta - tb;
|
||||
});
|
||||
}
|
||||
@@ -3772,6 +4004,7 @@ export class TrainSchedulingService {
|
||||
async getBookingWindowsForContract(contractId: string) {
|
||||
const rows: Array<BookingWindowRow> = await this.dataSource.query(
|
||||
`SELECT DISTINCT ts.id AS schedule_id,
|
||||
ts.reference AS reference,
|
||||
cr.contract_id AS contract_id,
|
||||
c.contract_kind AS contract_kind,
|
||||
ts.direction,
|
||||
@@ -3801,7 +4034,7 @@ export class TrainSchedulingService {
|
||||
AND ts.window_phase IS NOT NULL
|
||||
AND ts.window_phase NOT IN ('DONE', 'CLOSED_FOR_DAY')
|
||||
AND ts.scheduled_departure_date >= now()
|
||||
ORDER BY ts.window_opens_at ASC NULLS LAST`,
|
||||
ORDER BY ts.scheduled_departure_date ASC NULLS LAST`,
|
||||
[contractId],
|
||||
);
|
||||
return rows.map((r) => this.mapBookingWindowRow(r));
|
||||
@@ -3819,6 +4052,7 @@ export class TrainSchedulingService {
|
||||
}
|
||||
> = await this.dataSource.query(
|
||||
`SELECT ts.id AS schedule_id,
|
||||
ts.reference AS reference,
|
||||
ts.train_number,
|
||||
ts.direction,
|
||||
ts.window_phase,
|
||||
@@ -3839,7 +4073,7 @@ export class TrainSchedulingService {
|
||||
AND ts.window_phase IS NOT NULL
|
||||
AND ts.window_phase NOT IN ('DONE', 'CLOSED_FOR_DAY')
|
||||
AND ts.scheduled_departure_date >= now()
|
||||
ORDER BY ts.window_opens_at ASC NULLS LAST`,
|
||||
ORDER BY ts.scheduled_departure_date ASC NULLS LAST`,
|
||||
);
|
||||
return rows.map((r) => ({
|
||||
...this.mapBookingWindowRow({
|
||||
@@ -3854,6 +4088,7 @@ export class TrainSchedulingService {
|
||||
private mapBookingWindowRow(r: BookingWindowRow) {
|
||||
return {
|
||||
scheduleId: r.schedule_id,
|
||||
reference: r.reference ?? null,
|
||||
contractId: r.contract_id,
|
||||
contractKind: r.contract_kind,
|
||||
direction: r.direction,
|
||||
@@ -4012,26 +4247,44 @@ export class TrainSchedulingService {
|
||||
|
||||
// How many wagons of that type the cargo needs.
|
||||
const slotsNeeded = this.wagonsNeededForCargo(input, requiredType);
|
||||
void slotsNeeded; // TEMP: unused while the wagon-availability filter is off.
|
||||
|
||||
// AVAILABLE wagons of the required type, counted once per origin yard.
|
||||
const availableByYard = new Map<string, number>();
|
||||
const availableAt = async (yardId: string): Promise<number> => {
|
||||
const cached = availableByYard.get(yardId);
|
||||
if (cached !== undefined) return cached;
|
||||
const counts = await this.countFleetAvailability(yardId);
|
||||
const n =
|
||||
counts.find((c) => c.wagonTypeId === requiredType.id)?.available ?? 0;
|
||||
availableByYard.set(yardId, n);
|
||||
return n;
|
||||
};
|
||||
// TEMP (per request): wagon-availability filtering is DISABLED. A day is now
|
||||
// offered whenever a bookable schedule that day has remaining train capacity
|
||||
// — regardless of whether matching wagons are actually available at the
|
||||
// origin / boarding yard. This surfaces days even when no wagon is on hand.
|
||||
// Restore the block below to bring back the "enough matching wagons" gate.
|
||||
//
|
||||
// // AVAILABLE wagons of the required type, counted once per origin yard.
|
||||
// const availableByYard = new Map<string, number>();
|
||||
// const availableAt = async (yardId: string): Promise<number> => {
|
||||
// const cached = availableByYard.get(yardId);
|
||||
// if (cached !== undefined) return cached;
|
||||
// const counts = await this.countFleetAvailability(yardId);
|
||||
// const n =
|
||||
// counts.find((c) => c.wagonTypeId === requiredType.id)?.available ?? 0;
|
||||
// availableByYard.set(yardId, n);
|
||||
// return n;
|
||||
// };
|
||||
|
||||
const days = new Set<string>();
|
||||
for (const s of schedules) {
|
||||
const hasCapacity =
|
||||
Math.max(0, (s.maxWagons ?? 0) - (s.trainSet?.wagonCount ?? 0)) > 0;
|
||||
if (!hasCapacity) continue;
|
||||
const enoughWagons = (await availableAt(s.originStationId)) >= slotsNeeded;
|
||||
if (!enoughWagons) continue;
|
||||
// TEMP (per request): wagon-availability check commented out — see note
|
||||
// above. Dynamic consist: wagons may ride from the train's origin OR
|
||||
// already sit at the booking's own boarding yard and attach when the train
|
||||
// arrives — either pool can serve a sub-corridor booking.
|
||||
// let enoughWagons = (await availableAt(s.originStationId)) >= slotsNeeded;
|
||||
// if (
|
||||
// !enoughWagons &&
|
||||
// input.originYardId &&
|
||||
// input.originYardId !== s.originStationId
|
||||
// ) {
|
||||
// enoughWagons = (await availableAt(input.originYardId)) >= slotsNeeded;
|
||||
// }
|
||||
// if (!enoughWagons) continue;
|
||||
if (s.scheduledDepartureDate)
|
||||
days.add(eatDay(new Date(s.scheduledDepartureDate)));
|
||||
}
|
||||
@@ -4063,6 +4316,33 @@ export class TrainSchedulingService {
|
||||
return Math.max(1, Math.ceil(teu / 2));
|
||||
}
|
||||
|
||||
/**
|
||||
* Ordered stop yards of a schedule's route: origin → milestones → destination,
|
||||
* de-duplicated. Falls back to the two-endpoint pseudo-route when the schedule
|
||||
* has no route milestones. Shared by corridor (sub-leg) validation everywhere.
|
||||
*/
|
||||
async stopYardsForSchedule(schedule: TrainSchedule): Promise<string[]> {
|
||||
let milestoneYards: string[] = [];
|
||||
if (schedule.route?.milestones?.length) {
|
||||
milestoneYards = [...schedule.route.milestones]
|
||||
.sort((a, b) => a.sequenceNo - b.sequenceNo)
|
||||
.map((m) => m.yardId);
|
||||
} else if (schedule.routeId) {
|
||||
const milestones = await this.dataSource
|
||||
.getRepository(RouteMilestone)
|
||||
.find({ where: { routeId: schedule.routeId }, order: { sequenceNo: 'ASC' } });
|
||||
milestoneYards = milestones.map((m) => m.yardId);
|
||||
}
|
||||
const raw = milestoneYards.length >= 2
|
||||
? milestoneYards
|
||||
: [schedule.originStationId, ...milestoneYards, schedule.destinationStationId];
|
||||
const unique: string[] = [];
|
||||
for (const yardId of raw) {
|
||||
if (yardId && !unique.includes(yardId)) unique.push(yardId);
|
||||
}
|
||||
return unique;
|
||||
}
|
||||
|
||||
/** Whether a route has ≥1 OPEN bookable departure on a given EAT day. */
|
||||
async existsOpenScheduleOnRouteDay(
|
||||
originYardId: string,
|
||||
@@ -4170,6 +4450,7 @@ export class TrainSchedulingService {
|
||||
|
||||
return {
|
||||
id: schedule.id,
|
||||
reference: schedule.reference ?? null,
|
||||
status: schedule.status,
|
||||
freightType: this.resolveScheduleFreightType(schedule),
|
||||
trainNumber: schedule.trainNumber ?? null,
|
||||
|
||||
@@ -38,6 +38,13 @@ export type WagonPlanSlot = {
|
||||
assignedWeightTons: number;
|
||||
allocations: WagonAllocationRecord[];
|
||||
slotLoadType?: SlotLoadType;
|
||||
/**
|
||||
* Leg occupancy for sub-corridor bookings (dynamic consist): the slot boards
|
||||
* at boardYardId and alights at alightYardId. Null = the schedule's own
|
||||
* endpoint (whole-route slot, legacy behavior).
|
||||
*/
|
||||
boardYardId?: string | null;
|
||||
alightYardId?: string | null;
|
||||
};
|
||||
|
||||
export type ContainerUnitRow = {
|
||||
|
||||
@@ -55,6 +55,17 @@ export class TrainSetWagon extends BaseEntity {
|
||||
@Column({ name: 'status', type: 'varchar', length: 20, default: 'PLANNED' })
|
||||
status!: string;
|
||||
|
||||
// ── Leg occupancy (segment corridor bookings) ──────────────────────────────
|
||||
// A slot may occupy only part of the route: it boards (attaches/loads) at
|
||||
// board_yard_id and alights (unloads/detaches) at alight_yard_id. NULL on both
|
||||
// means the slot rides the whole route (legacy full-route bookings). Slots
|
||||
// whose legs don't overlap coexist without consuming each other's capacity.
|
||||
@Column({ name: 'board_yard_id', type: 'uuid', nullable: true })
|
||||
boardYardId?: string | null;
|
||||
|
||||
@Column({ name: 'alight_yard_id', type: 'uuid', nullable: true })
|
||||
alightYardId?: string | null;
|
||||
|
||||
@OneToMany(() => WagonBookingAllocation, (allocation) => allocation.trainSetWagon)
|
||||
allocations?: WagonBookingAllocation[];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { WagonMovementKind } from '@edr/types';
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
||||
|
||||
import { Yard } from '../../rule-engine/entities/yard.entity';
|
||||
import { Wagon } from './wagon.entity';
|
||||
|
||||
/**
|
||||
* Ledger of every physical wagon relocation between yards — one row per move.
|
||||
* Written when a wagon carries a booking's leg (LOADED), rides a train empty to
|
||||
* reposition (EMPTY_REPOSITION), or staff manually correct its yard (MANUAL).
|
||||
* `wagons.current_yard_id` is the derived "where is it now"; this table is the
|
||||
* auditable history of how it got there and by whom.
|
||||
*/
|
||||
@Entity({ schema: 'freight', name: 'wagon_movements' })
|
||||
@Index(['wagonId', 'occurredAt'])
|
||||
export class WagonMovement extends BaseEntity {
|
||||
@Column({ name: 'wagon_id', type: 'uuid' })
|
||||
wagonId!: string;
|
||||
|
||||
@ManyToOne(() => Wagon, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'wagon_id' })
|
||||
wagon?: Wagon;
|
||||
|
||||
/** Null when the prior location is unknown (e.g. first manual registration). */
|
||||
@Column({ name: 'from_yard_id', type: 'uuid', nullable: true })
|
||||
fromYardId?: string | null;
|
||||
|
||||
@ManyToOne(() => Yard, { nullable: true })
|
||||
@JoinColumn({ name: 'from_yard_id' })
|
||||
fromYard?: Yard | null;
|
||||
|
||||
@Column({ name: 'to_yard_id', type: 'uuid' })
|
||||
toYardId!: string;
|
||||
|
||||
@ManyToOne(() => Yard)
|
||||
@JoinColumn({ name: 'to_yard_id' })
|
||||
toYard?: Yard | null;
|
||||
|
||||
/** Set when the move happened by riding a scheduled train (LOADED / EMPTY_REPOSITION). */
|
||||
@Column({ name: 'train_schedule_id', type: 'uuid', nullable: true })
|
||||
trainScheduleId?: string | null;
|
||||
|
||||
/** Set when the move carried a specific booking's cargo (kind LOADED). */
|
||||
@Column({ name: 'booking_id', type: 'uuid', nullable: true })
|
||||
bookingId?: string | null;
|
||||
|
||||
@Column({ name: 'kind', type: 'varchar', length: 30 })
|
||||
kind!: WagonMovementKind;
|
||||
|
||||
@Column({ name: 'moved_by_user_id', type: 'uuid', nullable: true })
|
||||
movedByUserId?: string | null;
|
||||
|
||||
@Column({ name: 'occurred_at', type: 'timestamptz' })
|
||||
occurredAt!: Date;
|
||||
|
||||
@Column({ name: 'note', type: 'text', nullable: true })
|
||||
note?: string | null;
|
||||
}
|
||||
@@ -43,6 +43,14 @@ export class WagonsController {
|
||||
return this.wagonsService.findById(id);
|
||||
}
|
||||
|
||||
@Get(':id/movements')
|
||||
@ApiOperation({
|
||||
summary: "Wagon movement ledger (loaded legs, empty repositions, manual moves), newest first",
|
||||
})
|
||||
listMovements(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.wagonsService.listMovements(id);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@FleetManage()
|
||||
@ApiOperation({ summary: 'Update a wagon' })
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { WagonStatus } from '@edr/types';
|
||||
import { WagonMovementKind, WagonStatus } from '@edr/types';
|
||||
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, DataSource, FindOptionsOrder, FindOptionsWhere, ILike } from 'typeorm';
|
||||
@@ -8,6 +8,7 @@ import { UpdateWagonDto } from './dto/update-wagon.dto';
|
||||
import { AssignWagonToTrainDto } from './dto/assign-wagon-to-train.dto';
|
||||
import { ReorderWagonsDto } from './dto/reorder-wagons.dto';
|
||||
import { Wagon } from './entities/wagon.entity';
|
||||
import { WagonMovement } from './entities/wagon-movement.entity';
|
||||
import { Train } from '../trains/entities/train.entity';
|
||||
|
||||
@Injectable()
|
||||
@@ -74,8 +75,9 @@ export class WagonsService {
|
||||
return wagon;
|
||||
}
|
||||
|
||||
async update(id: string, dto: UpdateWagonDto): Promise<Wagon> {
|
||||
async update(id: string, dto: UpdateWagonDto, userId?: string | null): Promise<Wagon> {
|
||||
const wagon = await this.findById(id);
|
||||
const previousYardId = wagon.currentYardId ?? null;
|
||||
Object.assign(wagon, dto);
|
||||
// `findById` eager-loads `currentYard`; when the DTO changes the scalar FK
|
||||
// TypeORM otherwise re-derives `current_yard_id` from the STALE relation
|
||||
@@ -85,11 +87,40 @@ export class WagonsService {
|
||||
wagon.currentYard = null;
|
||||
}
|
||||
await this.wagonRepo.save(wagon);
|
||||
// Staff manually relocated the wagon — write the movement ledger row so the
|
||||
// wagon's yard history stays auditable (who moved it, from where, when).
|
||||
if (
|
||||
dto.currentYardId !== undefined &&
|
||||
dto.currentYardId !== null &&
|
||||
dto.currentYardId !== previousYardId
|
||||
) {
|
||||
const movementRepo = this.dataSource.getRepository(WagonMovement);
|
||||
await movementRepo.save(
|
||||
movementRepo.create({
|
||||
wagonId: id,
|
||||
fromYardId: previousYardId,
|
||||
toYardId: dto.currentYardId,
|
||||
kind: WagonMovementKind.Manual,
|
||||
movedByUserId: userId ?? null,
|
||||
occurredAt: new Date(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
// Re-read with the relation so the response reflects the new yard label
|
||||
// instead of the stale relation object loaded before the assign.
|
||||
return this.findById(id);
|
||||
}
|
||||
|
||||
/** Movement ledger for one wagon, newest first (loaded legs, repositions, manual moves). */
|
||||
async listMovements(wagonId: string): Promise<WagonMovement[]> {
|
||||
await this.findById(wagonId); // 404 on unknown wagon
|
||||
return this.dataSource.getRepository(WagonMovement).find({
|
||||
where: { wagonId },
|
||||
relations: { fromYard: true, toYard: true },
|
||||
order: { occurredAt: 'DESC', createdAt: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
async remove(id: string): Promise<void> {
|
||||
const wagon = await this.findById(id);
|
||||
await this.wagonRepo.remove(wagon);
|
||||
|
||||
@@ -35,7 +35,7 @@ export class CreateWarehouseYardDto {
|
||||
@Min(0)
|
||||
capacityContainers?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Max weight capacity (kg). Defaults to capacityWeight.' })
|
||||
@ApiPropertyOptional({ description: 'Max weight capacity (t). Defaults to capacityWeight.' })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
|
||||
@@ -35,7 +35,7 @@ export class CreateWarehouseZoneDto {
|
||||
@Min(0)
|
||||
capacityContainers?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Max weight capacity (kg). Defaults to capacityWeight.' })
|
||||
@ApiPropertyOptional({ description: 'Max weight capacity (t). Defaults to capacityWeight.' })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsEnum, IsNumber, IsOptional, IsString, IsUUID, Matches, MaxLength, Min } from 'class-validator';
|
||||
|
||||
import { WAREHOUSE_TYPES, WarehouseType } from '../entities/warehouse.entity';
|
||||
import { WAREHOUSE_STATUSES, WAREHOUSE_TYPES, WarehouseStatus, WarehouseType } from '../entities/warehouse.entity';
|
||||
|
||||
export class CreateWarehouseDto {
|
||||
@ApiProperty()
|
||||
@@ -47,7 +47,7 @@ export class CreateWarehouseDto {
|
||||
@Min(0)
|
||||
capacityContainers?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Max weight capacity (kg). Defaults to capacityWeight.' })
|
||||
@ApiPropertyOptional({ description: 'Max weight capacity (t). Defaults to capacityWeight.' })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@@ -58,4 +58,9 @@ export class CreateWarehouseDto {
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
maxVolume?: number;
|
||||
|
||||
@ApiPropertyOptional({ enum: WAREHOUSE_STATUSES, default: 'ACTIVE' })
|
||||
@IsOptional()
|
||||
@IsEnum(WAREHOUSE_STATUSES)
|
||||
status?: WarehouseStatus;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ export class LoadInventoryDto {
|
||||
@IsUUID()
|
||||
wagonId!: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Weight loaded onto the wagon (kg)' })
|
||||
@ApiPropertyOptional({ description: 'Weight loaded onto the wagon (t)' })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsOptional, IsString, IsUUID } from 'class-validator';
|
||||
|
||||
/**
|
||||
* Optional explicit storage location. When warehouse/yard/zone are all provided,
|
||||
* the item is stored there directly; otherwise store() falls back to the
|
||||
* allocation-rule / capacity-balanced auto pick.
|
||||
*/
|
||||
export class StoreInventoryDto {
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
warehouseId?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
yardId?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
zoneId?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
performedBy?: string;
|
||||
}
|
||||
@@ -34,7 +34,9 @@ export const WAREHOUSE_INVENTORY_TRANSITIONS: Record<WarehouseInventoryStatus, W
|
||||
UNLOADED: ['STORED', 'READY_FOR_PICKUP'],
|
||||
UNLOADED_AT_DJIBOUTI_PORT: [],
|
||||
RECEIVED: ['STORED', 'READY_FOR_PICKUP'],
|
||||
STORED: ['RESERVED'],
|
||||
// Reserve is retired from the operator flow — a stored export item advances
|
||||
// straight to loading prep. RESERVED kept for any in-flight/legacy items.
|
||||
STORED: ['RESERVED', 'READY_FOR_LOADING'],
|
||||
RESERVED: ['READY_FOR_LOADING'],
|
||||
READY_FOR_LOADING: ['LOADED'],
|
||||
LOADED: ['DISPATCHED'],
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { NotificationAudience, NotificationType } from '@edr/types';
|
||||
import { DataSource, EntityManager, IsNull } from 'typeorm';
|
||||
|
||||
import { BookingHandover } from './entities/booking-handover.entity';
|
||||
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { sendCompanyChannels } from '../notifications/notify-company.util';
|
||||
|
||||
/**
|
||||
* Import handover records. A booking has one handover per truck (single truck ⇒
|
||||
@@ -13,7 +17,35 @@ import { BookingHandover } from './entities/booking-handover.entity';
|
||||
export class HandoverService {
|
||||
private readonly logger = new Logger(HandoverService.name);
|
||||
|
||||
constructor(private readonly dataSource: DataSource) {}
|
||||
constructor(
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly inbox: NotificationInboxService,
|
||||
private readonly notifications: NotificationsService,
|
||||
) {}
|
||||
|
||||
/** Tell the customer a handover is ready and needs their signature. */
|
||||
private async notifySignNeeded(bookingId: string, reference: string): Promise<void> {
|
||||
try {
|
||||
const [b]: Array<{ companyId: string | null; reference: string }> = await this.dataSource.query(
|
||||
`SELECT company_id AS "companyId", reference FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`,
|
||||
[bookingId],
|
||||
);
|
||||
if (!b?.companyId) return;
|
||||
const body = `Your import handover ${reference} for booking ${b.reference} is ready. Please review and sign it from the portal before the truck leaves.`;
|
||||
await this.inbox.notify({
|
||||
recipients: { companyId: b.companyId },
|
||||
audience: NotificationAudience.PORTAL,
|
||||
type: NotificationType.DOCUMENT_ACTION,
|
||||
title: 'Handover — signature needed',
|
||||
body,
|
||||
link: `/bookings/${bookingId}`,
|
||||
data: { bookingId, reference },
|
||||
});
|
||||
await sendCompanyChannels(this.dataSource, this.notifications, b.companyId, body);
|
||||
} catch (err) {
|
||||
this.logger.warn(`Failed to notify handover sign for ${bookingId}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
list(bookingId: string): Promise<BookingHandover[]> {
|
||||
return this.dataSource.getRepository(BookingHandover).find({
|
||||
@@ -22,6 +54,32 @@ export class HandoverService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask the customer to sign the booking's handover. Ensures a handover exists
|
||||
* (creates a booking-level self-haul one if none yet), then fires the
|
||||
* sign-needed notification (in-app + SMS + email). Idempotent to re-send.
|
||||
*/
|
||||
async requestSignature(
|
||||
bookingId: string,
|
||||
): Promise<{ notified: boolean; reference: string | null; alreadySigned: boolean }> {
|
||||
const repo = this.dataSource.getRepository(BookingHandover);
|
||||
const existing = await repo.find({ where: { bookingId }, order: { generatedAt: 'ASC' } });
|
||||
|
||||
if (existing.length === 0) {
|
||||
// No handover yet (truck not arrived): create a booking-level one so the
|
||||
// customer has something to sign. ensureForArrivedTruck notifies on create.
|
||||
const created = await this.ensureForArrivedTruck(bookingId, {});
|
||||
return { notified: true, reference: created.reference, alreadySigned: false };
|
||||
}
|
||||
|
||||
const unsigned = existing.find((h) => !h.signedAt);
|
||||
if (!unsigned) {
|
||||
return { notified: false, reference: existing[0].reference, alreadySigned: true };
|
||||
}
|
||||
await this.notifySignNeeded(bookingId, unsigned.reference);
|
||||
return { notified: true, reference: unsigned.reference, alreadySigned: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Self-haul: ensure a handover exists for a customer truck that just arrived.
|
||||
* Idempotent — one per (booking, truck). Runs inside the caller's transaction
|
||||
@@ -54,6 +112,7 @@ export class HandoverService {
|
||||
}),
|
||||
);
|
||||
this.logger.log(`Handover ${reference} generated on arrival for booking ${bookingId}`);
|
||||
void this.notifySignNeeded(bookingId, reference);
|
||||
return saved;
|
||||
}
|
||||
|
||||
|
||||
@@ -340,6 +340,7 @@ export class SchedulingReadFacade {
|
||||
'LOADED',
|
||||
'DISPATCHED',
|
||||
'IN_TRANSIT',
|
||||
'ARRIVED',
|
||||
'ARRIVED_AT_DJIBOUTI',
|
||||
'ARRIVED_AT_PORT',
|
||||
'ARRIVED_AT_DESTINATION',
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
import { NotificationAudience, NotificationType } from '@edr/types';
|
||||
|
||||
import { FilesService } from '../files/files.service';
|
||||
import { LastMileService } from '../last-mile/last-mile.service';
|
||||
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { sendCompanyChannels } from '../notifications/notify-company.util';
|
||||
import { CreateInspectionReportDto } from './dto/create-inspection-report.dto';
|
||||
import { UpdateInspectionReportDto } from './dto/update-inspection-report.dto';
|
||||
import { WarehouseInspectionReport } from './entities/warehouse-inspection-report.entity';
|
||||
@@ -13,11 +18,15 @@ const INSPECTION_RESOURCE = 'warehouse-inspection-report';
|
||||
|
||||
@Injectable()
|
||||
export class WarehouseInspectionService {
|
||||
private readonly logger = new Logger(WarehouseInspectionService.name);
|
||||
|
||||
constructor(
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly inspectionRepository: WarehouseInspectionRepository,
|
||||
private readonly filesService: FilesService,
|
||||
private readonly lastMileService: LastMileService,
|
||||
private readonly inbox: NotificationInboxService,
|
||||
private readonly notifications: NotificationsService,
|
||||
) {}
|
||||
|
||||
/** Create or update the inspection report for an inventory item and sync its inspectionStatus. */
|
||||
@@ -44,7 +53,7 @@ export class WarehouseInspectionService {
|
||||
expectedWeight: expected,
|
||||
actualWeight: actual,
|
||||
weightLoss,
|
||||
weightLossUnit: weightLoss !== null ? 'kg' : null,
|
||||
weightLossUnit: weightLoss !== null ? 't' : null,
|
||||
hasMissingItems: dto.hasMissingItems ?? false,
|
||||
missingItemsDescription: dto.missingItemsDescription ?? null,
|
||||
remarks: dto.remarks ?? null,
|
||||
@@ -83,8 +92,10 @@ export class WarehouseInspectionService {
|
||||
const [row] = await this.dataSource.query(
|
||||
`SELECT inv.booking_id AS "bookingId",
|
||||
b.reference AS "bookingReference",
|
||||
b.company_id AS "companyId",
|
||||
b.trade_direction AS "tradeDirection",
|
||||
b.last_mile_delivery_address AS "lastMileDeliveryAddress",
|
||||
b.customer_truck_assigned_at AS "customerTruckAssignedAt",
|
||||
COALESCE(st.includes_last_mile, false) AS "serviceIncludesLastMile"
|
||||
FROM freight.warehouse_inventory inv
|
||||
LEFT JOIN freight.bookings b ON b.id = inv.booking_id
|
||||
@@ -105,6 +116,36 @@ export class WarehouseInspectionService {
|
||||
|
||||
if (row.bookingReference && hasLastMile) {
|
||||
await this.lastMileService.acceptBooking(row.bookingReference);
|
||||
} else if (!hasLastMile && !row.customerTruckAssignedAt) {
|
||||
// Self-haul import: goods are pickup-ready but no collection truck is
|
||||
// assigned yet — nudge the customer to assign one from the portal.
|
||||
void this.notifyTruckAssignmentNeeded(row);
|
||||
}
|
||||
}
|
||||
|
||||
/** Portal nudge: import goods are ready for pickup but no customer truck is assigned. */
|
||||
private async notifyTruckAssignmentNeeded(row: {
|
||||
bookingId?: string | null;
|
||||
bookingReference?: string | null;
|
||||
companyId?: string | null;
|
||||
}): Promise<void> {
|
||||
if (!row.companyId || !row.bookingId) return;
|
||||
const body = `Booking ${row.bookingReference ?? row.bookingId} has passed inspection and is ready for pickup. Please assign your collection truck(s) from the portal to proceed.`;
|
||||
try {
|
||||
await this.inbox.notify({
|
||||
recipients: { companyId: row.companyId },
|
||||
audience: NotificationAudience.PORTAL,
|
||||
type: NotificationType.BOOKING_STATUS,
|
||||
title: 'Assign a truck for pickup',
|
||||
body,
|
||||
link: `/bookings/${row.bookingId}`,
|
||||
data: { bookingId: row.bookingId, action: 'ASSIGN_TRUCK' },
|
||||
});
|
||||
await sendCompanyChannels(this.dataSource, this.notifications, row.companyId, body);
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Truck-assignment notify failed for ${row.bookingId}: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ 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 { StoreInventoryDto } from './dto/store-inventory.dto';
|
||||
import { ReceiveWarehouseInventoryDto } from './dto/receive-inventory.dto';
|
||||
import { ReleaseOrderDto } from './dto/release-order.dto';
|
||||
import { ReserveInventoryDto } from './dto/reserve-inventory.dto';
|
||||
@@ -267,9 +268,9 @@ export class WarehouseInventoryController {
|
||||
}
|
||||
|
||||
@Post(':id/store')
|
||||
@ApiOperation({ summary: 'Mark received inventory as STORED' })
|
||||
store(@Param('id', ParseUUIDPipe) id: string, @Body('performedBy') performedBy?: string) {
|
||||
return this.inventoryService.store(id, performedBy);
|
||||
@ApiOperation({ summary: 'Mark received inventory as STORED (optional explicit warehouse/yard/zone)' })
|
||||
store(@Param('id', ParseUUIDPipe) id: string, @Body() dto: StoreInventoryDto) {
|
||||
return this.inventoryService.store(id, dto.performedBy, dto);
|
||||
}
|
||||
|
||||
@Post(':id/ready-for-loading')
|
||||
@@ -354,12 +355,24 @@ export class WarehouseInventoryController {
|
||||
return this.handoverService.list(bookingId);
|
||||
}
|
||||
|
||||
@Post('bookings/:bookingId/request-handover-signature')
|
||||
@ApiOperation({ summary: 'Ask the customer to sign the handover (creates one if none, then notifies)' })
|
||||
requestHandoverSignature(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
|
||||
return this.handoverService.requestSignature(bookingId);
|
||||
}
|
||||
|
||||
@Get('bookings/:bookingId/container-items')
|
||||
@ApiOperation({ summary: 'Per-container/bulk items of a booking with lifecycle stage + refs' })
|
||||
containerItems(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
|
||||
return this.inventoryService.containerItems(bookingId);
|
||||
}
|
||||
|
||||
@Get('bookings/:bookingId/container-weights')
|
||||
@ApiOperation({ summary: "A booking's containers + VGM cargo weight (tonnes) for exit weighing" })
|
||||
containerWeights(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
|
||||
return this.inventoryService.bookingContainerWeights(bookingId);
|
||||
}
|
||||
|
||||
@Post(':id/deliver')
|
||||
@ApiOperation({ summary: 'Deliver import goods to the customer + capture proof of delivery' })
|
||||
deliver(@Param('id', ParseUUIDPipe) id: string, @Body() dto: DeliverInventoryDto) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { InterchangeDocumentsService } from '../interchange-documents/interchang
|
||||
import type { InterchangeDocument } from '../interchange-documents/entities/interchange-document.entity';
|
||||
import { LastMileService } from '../last-mile/last-mile.service';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { sendCompanyChannels } from '../notifications/notify-company.util';
|
||||
import { SignaturesService } from '../signatures/signatures.service';
|
||||
import { BulkInspectDto } from './dto/bulk-inspect.dto';
|
||||
import { BulkReceiveDto, TruckEntranceDto } from './dto/bulk-receive.dto';
|
||||
@@ -39,6 +40,8 @@ import { WarehouseInventoryRepository } from './warehouse-inventory.repository';
|
||||
import { WarehouseLoadingRepository } from './warehouse-loading.repository';
|
||||
import { WarehouseReleaseDocumentService } from './warehouse-release-document.service';
|
||||
import { HandoverService } from './handover.service';
|
||||
import { NotificationAudience, NotificationType } from '@edr/types';
|
||||
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
|
||||
|
||||
/** Wagon states that may receive a load (besides being part of an existing schedule). */
|
||||
const LOADABLE_WAGON_STATUSES = ['AVAILABLE', 'IMPORT_READY', 'EXPORT_READY', 'ASSIGNED'];
|
||||
@@ -356,12 +359,14 @@ export interface ImportUnloadedRow {
|
||||
customerTruckType: string | null;
|
||||
customerTruckContainerNumber: string | null;
|
||||
customerTruckAssignedAt: string | null;
|
||||
hasAssignedTruck: boolean;
|
||||
currentStatus: string;
|
||||
releaseDate: string | null;
|
||||
releaseOrderReference: string | null;
|
||||
handoverDocumentReference: string | null;
|
||||
handoverDocumentDate: string | null;
|
||||
deliveredAt: string | null;
|
||||
notes: string | null;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
@@ -383,8 +388,41 @@ export class WarehouseInventoryService {
|
||||
private readonly notifications: NotificationsService,
|
||||
private readonly signatures: SignaturesService,
|
||||
private readonly handover: HandoverService,
|
||||
private readonly inbox: NotificationInboxService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* When a self-haul booking (no EDR first/last mile) is received to the warehouse
|
||||
* but has no customer truck assigned yet, nudge the customer to assign one — with
|
||||
* a deep-link to the booking's truck-assignment card. Fire-and-forget.
|
||||
*/
|
||||
private async notifyTruckAssignmentNeeded(booking: {
|
||||
companyId?: string | null;
|
||||
reference?: string | null;
|
||||
hasFirstMile?: boolean;
|
||||
hasLastMile?: boolean;
|
||||
customerTruckAssignedAt?: string | null;
|
||||
}, bookingId: string): Promise<void> {
|
||||
if (!booking.companyId) return;
|
||||
if (booking.hasFirstMile || booking.hasLastMile) return; // EDR mile — no customer truck
|
||||
if (booking.customerTruckAssignedAt) return; // already assigned
|
||||
const body = `Booking ${booking.reference ?? bookingId} has been received at the warehouse. Please assign your collection truck(s) from the portal to proceed.`;
|
||||
try {
|
||||
await this.inbox.notify({
|
||||
recipients: { companyId: booking.companyId },
|
||||
audience: NotificationAudience.PORTAL,
|
||||
type: NotificationType.BOOKING_STATUS,
|
||||
title: 'Assign a truck for pickup',
|
||||
body,
|
||||
link: `/bookings/${bookingId}`,
|
||||
data: { bookingId, action: 'ASSIGN_TRUCK' },
|
||||
});
|
||||
await sendCompanyChannels(this.dataSource, this.notifications, booking.companyId, body);
|
||||
} catch (err) {
|
||||
this.logger.warn(`Truck-assignment notify failed for ${bookingId}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch 6 — final terminal release / gate clearance.
|
||||
* Blocked while an unpaid demurrage/storage invoice exists. Does NOT touch
|
||||
@@ -510,7 +548,7 @@ export class WarehouseInventoryService {
|
||||
// ── Batch 4.5: Arrival / Unload / Load automation ──────────────────────────
|
||||
|
||||
/** Bookings whose goods have arrived and may be unloaded into the warehouse. */
|
||||
private readonly ARRIVED_BOOKING_STATUSES = ['IN_TRANSIT'];
|
||||
private readonly ARRIVED_BOOKING_STATUSES = ['IN_TRANSIT', 'ARRIVED'];
|
||||
|
||||
/** Arrived bookings + their current inventory/inspection state (queue view). */
|
||||
async arrivalQueue(): Promise<ArrivalQueueItem[]> {
|
||||
@@ -866,7 +904,10 @@ export class WarehouseInventoryService {
|
||||
b.customer_truck_driver_name AS "customerTruckDriverName",
|
||||
b.customer_truck_type AS "customerTruckType",
|
||||
b.customer_truck_container_number AS "customerTruckContainerNumber",
|
||||
b.customer_truck_assigned_at AS "customerTruckAssignedAt"
|
||||
b.customer_truck_assigned_at AS "customerTruckAssignedAt",
|
||||
b.company_id AS "companyId",
|
||||
(NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NOT NULL
|
||||
OR COALESCE(st.includes_last_mile, false)) AS "hasLastMile"
|
||||
FROM freight.bookings b
|
||||
LEFT JOIN freight.companies company ON company.id = b.company_id
|
||||
LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id
|
||||
@@ -1002,6 +1043,7 @@ export class WarehouseInventoryService {
|
||||
|
||||
result.receivedCount += 1;
|
||||
result.results.push({ bookingId, status: 'RECEIVED', inventoryId: saved.id, grnNumber });
|
||||
void this.notifyTruckAssignmentNeeded(booking, bookingId);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1301,12 +1343,18 @@ export class WarehouseInventoryService {
|
||||
b.customer_truck_type AS "customerTruckType",
|
||||
b.customer_truck_container_number AS "customerTruckContainerNumber",
|
||||
b.customer_truck_assigned_at AS "customerTruckAssignedAt",
|
||||
(b.customer_truck_assigned_at IS NOT NULL
|
||||
OR EXISTS (SELECT 1 FROM freight.last_mile lm
|
||||
WHERE lm.booking_id = b.id
|
||||
AND lm.vehicle_id IS NOT NULL
|
||||
AND lm.deleted_at IS NULL)) AS "hasAssignedTruck",
|
||||
inv.status AS "currentStatus",
|
||||
inv.release_date AS "releaseDate",
|
||||
inv.release_order_reference AS "releaseOrderReference",
|
||||
substring(inv.notes FROM 'Handover Reference: ([^\\n\\r]+)') AS "handoverDocumentReference",
|
||||
substring(inv.notes FROM 'Generated At: ([^\\n\\r]+)') AS "handoverDocumentDate",
|
||||
inv.delivered_at AS "deliveredAt",
|
||||
inv.notes AS "notes",
|
||||
oy.country AS "originCountry",
|
||||
dy.country AS "destinationCountry"
|
||||
FROM freight.warehouse_inventory inv
|
||||
@@ -2020,7 +2068,7 @@ export class WarehouseInventoryService {
|
||||
activityType: 'INVENTORY_RECEIVED',
|
||||
inventoryId: saved.id,
|
||||
warehouseId: dto.warehouseId,
|
||||
description: `GRN ${grnNumber}: received ${weight}kg via truck ${truckEntrance.truckPlateNumber}`,
|
||||
description: `GRN ${grnNumber}: received ${weight}t via truck ${truckEntrance.truckPlateNumber}`,
|
||||
performedBy: dto.performedBy,
|
||||
},
|
||||
manager,
|
||||
@@ -2104,13 +2152,30 @@ export class WarehouseInventoryService {
|
||||
|
||||
// ── Lifecycle transitions ────────────────────────────────────────────────
|
||||
|
||||
async store(id: string, performedBy?: string): Promise<WarehouseInventory> {
|
||||
async store(
|
||||
id: string,
|
||||
performedBy?: string,
|
||||
chosen?: { warehouseId?: string; yardId?: string; zoneId?: string },
|
||||
): Promise<WarehouseInventory> {
|
||||
const item = await this.findById(id);
|
||||
this.assertTransition(item.status, 'STORED');
|
||||
|
||||
// Explicit location wins when the operator picked warehouse + yard + zone;
|
||||
// otherwise fall back to the allocation-rule / capacity-balanced auto pick.
|
||||
const manualLocation =
|
||||
chosen?.warehouseId && chosen?.yardId && chosen?.zoneId
|
||||
? {
|
||||
warehouseId: chosen.warehouseId,
|
||||
yardId: chosen.yardId,
|
||||
zoneId: chosen.zoneId,
|
||||
path: undefined as string | undefined,
|
||||
}
|
||||
: null;
|
||||
|
||||
const criteria = await this.getInventoryAllocationCriteria(item);
|
||||
const ruleLocation = await this.allocation.resolveLocation(criteria);
|
||||
const location = ruleLocation ?? (await this.pickCapacityBalancedStorageLocation(item, criteria));
|
||||
const ruleLocation = manualLocation ? null : await this.allocation.resolveLocation(criteria);
|
||||
const location =
|
||||
manualLocation ?? ruleLocation ?? (await this.pickCapacityBalancedStorageLocation(item, criteria));
|
||||
|
||||
if (!location) {
|
||||
throw new BadRequestException('No active warehouse yard/zone is available for this inventory item');
|
||||
@@ -2154,18 +2219,19 @@ export class WarehouseInventoryService {
|
||||
await this.applyCapacityDelta(manager, location, weight, volume, containerCount);
|
||||
}
|
||||
|
||||
const storedReason = 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'}`;
|
||||
|
||||
await manager.getRepository(WarehouseInventory).update(id, {
|
||||
status: 'STORED',
|
||||
storedAt: new Date(),
|
||||
warehouseId: location.warehouseId,
|
||||
yardId: location.yardId,
|
||||
zoneId: location.zoneId,
|
||||
notes: this.appendNote(
|
||||
locked.notes,
|
||||
ruleLocation?.rule
|
||||
? `Stored by allocation rule "${ruleLocation.rule.name}" -> ${ruleLocation.path}`
|
||||
: `Stored by capacity-balanced allocation -> ${location.path ?? 'assigned yard/zone'}`,
|
||||
),
|
||||
notes: this.appendNote(locked.notes, storedReason),
|
||||
});
|
||||
|
||||
await this.activityLog.record(
|
||||
@@ -2173,9 +2239,7 @@ export class WarehouseInventoryService {
|
||||
activityType: 'INVENTORY_STORED',
|
||||
inventoryId: id,
|
||||
warehouseId: location.warehouseId,
|
||||
description: ruleLocation?.rule
|
||||
? `Inventory stored by rule "${ruleLocation.rule.name}" at ${ruleLocation.path}`
|
||||
: `Inventory stored at ${location.path ?? 'assigned yard/zone'}`,
|
||||
description: storedReason.replace(/^Stored/, 'Inventory stored'),
|
||||
performedBy,
|
||||
},
|
||||
manager,
|
||||
@@ -2294,6 +2358,26 @@ export class WarehouseInventoryService {
|
||||
'Customer must sign the handover before the exit paper can be generated',
|
||||
);
|
||||
}
|
||||
|
||||
// Authoritative weight match: the truck's net (gross − tare) must equal the
|
||||
// total VGM cargo weight of the containers selected as loaded on it.
|
||||
if (dto.containerNumber && dto.grossWeight != null && dto.tareWeight != null) {
|
||||
const selected = dto.containerNumber
|
||||
.split(/[,;\n]+/)
|
||||
.map((n) => n.trim())
|
||||
.filter(Boolean);
|
||||
if (selected.length) {
|
||||
const weights = await this.bookingContainerWeights(item.bookingId);
|
||||
const byNumber = new Map(weights.map((w) => [w.containerNumber.toUpperCase(), w.weightTons]));
|
||||
const expected = selected.reduce((sum, n) => sum + (byNumber.get(n.toUpperCase()) ?? 0), 0);
|
||||
const computedNet = Number((dto.grossWeight - dto.tareWeight).toFixed(3));
|
||||
if (expected > 0 && Math.abs(computedNet - expected) > 0.001) {
|
||||
throw new BadRequestException(
|
||||
`Weight mismatch: gross − tare (${computedNet} t) must equal the selected containers' cargo weight (${expected} t).`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const releaseDate = isTruckLeaving
|
||||
@@ -2528,6 +2612,7 @@ export class WarehouseInventoryService {
|
||||
bookingReference: string | null;
|
||||
contractId: string | null;
|
||||
hasLastMile: boolean;
|
||||
handoverSigned: boolean;
|
||||
}>
|
||||
> {
|
||||
const rows: Array<{
|
||||
@@ -2574,6 +2659,10 @@ export class WarehouseInventoryService {
|
||||
[bookingId],
|
||||
);
|
||||
|
||||
// Booking-level gate: the per-truck exit paper is blocked until the handover
|
||||
// is fully signed, so the UI can disable "Exit Paper" with a clear reason.
|
||||
const handoverSigned = await this.handover.isFullySigned(bookingId);
|
||||
|
||||
return rows.map((r) => ({
|
||||
containerNumber: r.containerNumber,
|
||||
goods: r.goods,
|
||||
@@ -2596,6 +2685,32 @@ export class WarehouseInventoryService {
|
||||
bookingReference: r.bookingReference,
|
||||
contractId: r.contractId,
|
||||
hasLastMile: r.hasLastMile,
|
||||
handoverSigned,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* The booking's containers with their VGM cargo weight (tonnes), keyed by
|
||||
* container number. Drives the truck-leaving exit weighing: the selected
|
||||
* containers' total cargo weight must match (gross − tare).
|
||||
*/
|
||||
async bookingContainerWeights(
|
||||
bookingId: string,
|
||||
): Promise<Array<{ containerNumber: string; weightTons: number }>> {
|
||||
const rows: Array<{ containerNumber: string; weightTons: string }> =
|
||||
await this.dataSource.query(
|
||||
`SELECT bcu.container_number AS "containerNumber",
|
||||
COALESCE(bcu.vgm_tons, 0) AS "weightTons"
|
||||
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 = $1 AND bcu.deleted_at IS NULL
|
||||
ORDER BY bcu.container_number`,
|
||||
[bookingId],
|
||||
);
|
||||
return rows.map((r) => ({
|
||||
containerNumber: r.containerNumber,
|
||||
weightTons: Number(r.weightTons) || 0,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -2677,7 +2792,7 @@ export class WarehouseInventoryService {
|
||||
['Pickup Truck Plate', data.plateNumber],
|
||||
['Driver', data.driverName],
|
||||
['Truck Type', data.truckType],
|
||||
['Gross Weight (Loaded on Truck)', `${data.grossWeightKg.toLocaleString()} kg`],
|
||||
['Gross Weight (Loaded on Truck)', `${data.grossWeightKg.toLocaleString()} t`],
|
||||
['Gate-Out Time', gateOut],
|
||||
['Clearance Status', 'CLEARED FOR WAREHOUSE EXIT'],
|
||||
];
|
||||
@@ -3608,8 +3723,8 @@ export class WarehouseInventoryService {
|
||||
['Booking Containers', data.bookingContainerSummary],
|
||||
['Cargo / Goods Description', data.cargoDescription],
|
||||
['Quantity', data.quantity],
|
||||
['Received Weight', `${data.weight.toLocaleString()} kg`],
|
||||
['Booking Declared Weight', data.bookingDeclaredWeight ? `${data.bookingDeclaredWeight.toLocaleString()} kg` : null],
|
||||
['Received Weight', `${data.weight.toLocaleString()} t`],
|
||||
['Booking Declared Weight', data.bookingDeclaredWeight ? `${data.bookingDeclaredWeight.toLocaleString()} t` : null],
|
||||
['Volume', data.volume == null ? null : data.volume.toLocaleString()],
|
||||
['Warehouse', data.warehouse],
|
||||
['Yard', data.yard],
|
||||
@@ -3731,7 +3846,7 @@ export class WarehouseInventoryService {
|
||||
`${(data.truckPlateNumber && data.truckWeightKg
|
||||
? data.truckWeightKg
|
||||
: data.weight
|
||||
).toLocaleString()} kg`,
|
||||
).toLocaleString()} t`,
|
||||
],
|
||||
['Warehouse', data.warehouse],
|
||||
['Yard', data.yard],
|
||||
@@ -3894,8 +4009,8 @@ export class WarehouseInventoryService {
|
||||
['Booking Containers', data.bookingContainerSummary],
|
||||
['Cargo / Goods Description', data.cargoDescription],
|
||||
['Quantity', data.quantity],
|
||||
['Inventory Weight', `${data.weight.toLocaleString()} kg`],
|
||||
['Booking Declared Weight', data.bookingDeclaredWeight ? `${data.bookingDeclaredWeight.toLocaleString()} kg` : null],
|
||||
['Inventory Weight', `${data.weight.toLocaleString()} t`],
|
||||
['Booking Declared Weight', data.bookingDeclaredWeight ? `${data.bookingDeclaredWeight.toLocaleString()} t` : null],
|
||||
['Warehouse', data.warehouse],
|
||||
['Yard', data.yard],
|
||||
['Zone', data.zone],
|
||||
@@ -3968,8 +4083,8 @@ export class WarehouseInventoryService {
|
||||
<tr><th>1. Goods</th><td>${esc(data.cargoDescription || data.containerNumber || data.bookingReference)}</td></tr>
|
||||
<tr><th>Container</th><td>${esc(data.containerNumber)}</td></tr>
|
||||
<tr><th>Booking Containers</th><td>${esc(data.bookingContainerSummary)}</td></tr>
|
||||
<tr><th>Inventory Weight</th><td>${esc(`${data.weight.toLocaleString()} kg`)}</td></tr>
|
||||
<tr><th>Booking Declared Weight</th><td>${esc(data.bookingDeclaredWeight ? `${data.bookingDeclaredWeight.toLocaleString()} kg` : null)}</td></tr>
|
||||
<tr><th>Inventory Weight</th><td>${esc(`${data.weight.toLocaleString()} t`)}</td></tr>
|
||||
<tr><th>Booking Declared Weight</th><td>${esc(data.bookingDeclaredWeight ? `${data.bookingDeclaredWeight.toLocaleString()} t` : null)}</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="section-title">Handover Clause</div>
|
||||
@@ -4303,9 +4418,9 @@ export class WarehouseInventoryService {
|
||||
dto.truckType?.trim() ? `Truck Type: ${dto.truckType.trim()}` : null,
|
||||
dto.containerNumber?.trim() ? `Container Number: ${dto.containerNumber.trim()}` : null,
|
||||
dto.gateInTime ? `Gate In Time: ${dto.gateInTime}` : null,
|
||||
`Tare Weight: ${tareWeight} kg`,
|
||||
grossWeight == null ? null : `Gross Weight: ${grossWeight} kg`,
|
||||
computedNetWeight == null ? null : `Net Weight: ${computedNetWeight} kg`,
|
||||
`Tare Weight: ${tareWeight} t`,
|
||||
grossWeight == null ? null : `Gross Weight: ${grossWeight} t`,
|
||||
computedNetWeight == null ? null : `Net Weight: ${computedNetWeight} t`,
|
||||
dto.gateOutTime ? `Gate Out Time: ${dto.gateOutTime}` : null,
|
||||
];
|
||||
|
||||
@@ -4357,7 +4472,7 @@ export class WarehouseInventoryService {
|
||||
}
|
||||
|
||||
private extractExitInspectionNumber(note: string | null | undefined, label: string): number | undefined {
|
||||
const value = this.extractExitInspectionLine(note, label)?.replace(/\s*kg$/i, '');
|
||||
const value = this.extractExitInspectionLine(note, label)?.replace(/\s*(kg|t)$/i, '');
|
||||
if (!value) return undefined;
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
@@ -4420,9 +4535,9 @@ export class WarehouseInventoryService {
|
||||
truck?.driverName ? `Driver: ${truck.driverName}` : null,
|
||||
truck?.driverPhone ? `Driver Phone: ${truck.driverPhone}` : null,
|
||||
truck?.driverLicenseNumber ? `Driver License: ${truck.driverLicenseNumber}` : null,
|
||||
truck?.entranceTareWeightKg !== undefined ? `Entrance Tare Weight: ${Number(truck.entranceTareWeightKg)} kg` : null,
|
||||
truck?.entranceTareWeightKg !== undefined ? `Entrance Tare Weight: ${Number(truck.entranceTareWeightKg)} t` : null,
|
||||
truck?.weighingRequired !== undefined ? `Weighing Required: ${truck.weighingRequired ? 'Yes' : 'No'}` : null,
|
||||
truck?.exitTareWeightKg !== undefined ? `Exit Tare Weight: ${Number(truck.exitTareWeightKg)} kg` : null,
|
||||
truck?.exitTareWeightKg !== undefined ? `Exit Tare Weight: ${Number(truck.exitTareWeightKg)} t` : null,
|
||||
truck?.declarationNumber ? `Declaration / Bill of Entry: ${truck.declarationNumber}` : null,
|
||||
truck?.incoterms ? `Incoterms: ${truck.incoterms}` : null,
|
||||
truck?.hsCodes ? `HS Codes: ${truck.hsCodes}` : null,
|
||||
@@ -4430,8 +4545,8 @@ export class WarehouseInventoryService {
|
||||
truck?.itemDescription ? `Item Description: ${truck.itemDescription}` : null,
|
||||
truck?.packagingType ? `Packaging Type: ${truck.packagingType}` : null,
|
||||
truck?.unitCount !== undefined ? `Unit Count: ${Number(truck.unitCount)}` : null,
|
||||
truck?.grossWeightKg !== undefined ? `Gross Weight: ${Number(truck.grossWeightKg)} kg` : null,
|
||||
truck?.netWeightKg !== undefined ? `Net Weight: ${Number(truck.netWeightKg)} kg` : null,
|
||||
truck?.grossWeightKg !== undefined ? `Gross Weight: ${Number(truck.grossWeightKg)} t` : null,
|
||||
truck?.netWeightKg !== undefined ? `Net Weight: ${Number(truck.netWeightKg)} t` : null,
|
||||
truck?.volumeDimensions ? `Volume / Dimensions: ${truck.volumeDimensions}` : null,
|
||||
truck?.conditionAtReceipt ? `Condition at Receipt: ${truck.conditionAtReceipt}` : null,
|
||||
truck?.damagedRejectedQuantity !== undefined ? `Damaged / Rejected Quantity: ${Number(truck.damagedRejectedQuantity)}` : null,
|
||||
|
||||
@@ -6,9 +6,11 @@ import {
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import { OnEvent } from "@nestjs/event-emitter";
|
||||
import { Freight } from "@edr/types";
|
||||
import { Freight, NotificationAudience, NotificationType } from "@edr/types";
|
||||
import { DataSource } from "typeorm";
|
||||
|
||||
import { NotificationInboxService } from "../notification-inbox/notification-inbox.service";
|
||||
|
||||
import {
|
||||
BillingService,
|
||||
InvoiceEventPayload,
|
||||
@@ -135,6 +137,7 @@ export class WarehouseInvoiceService {
|
||||
private readonly invoiceDocuments: InvoiceDocumentService,
|
||||
private readonly feeService: WarehouseFeeService,
|
||||
private readonly notifications: NotificationsService,
|
||||
private readonly inbox: NotificationInboxService,
|
||||
) { }
|
||||
|
||||
// ── Generation ───────────────────────────────────────────────────────────
|
||||
@@ -968,6 +971,25 @@ export class WarehouseInvoiceService {
|
||||
message,
|
||||
`warehouse fee invoice ${invoice.invoiceNumber}`,
|
||||
);
|
||||
|
||||
// In-app deep-link to pay the fee from the booking.
|
||||
if (invoice.customerId && invoice.bookingId) {
|
||||
try {
|
||||
await this.inbox.notify({
|
||||
recipients: { companyId: invoice.customerId },
|
||||
audience: NotificationAudience.PORTAL,
|
||||
type: NotificationType.INVOICE_ISSUED,
|
||||
title: "Warehouse fee due",
|
||||
body:
|
||||
`Warehouse ${invoice.invoiceType.replace(/_/g, " ").toLowerCase()} fee ${invoice.invoiceNumber} is due — ` +
|
||||
`${Number(invoice.totalAmount).toLocaleString()} ${invoice.currency}. Pay from the portal before cargo pickup.`,
|
||||
link: `/bookings/${invoice.bookingId}`,
|
||||
data: { bookingId: invoice.bookingId, invoiceNumber: invoice.invoiceNumber },
|
||||
});
|
||||
} catch (err) {
|
||||
this.logger.warn(`In-app warehouse fee notify failed: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async notifyWarehouseFeePayment(
|
||||
|
||||
@@ -9,6 +9,7 @@ import { FilesModule } from '../files/files.module';
|
||||
import { InterchangeDocumentsModule } from '../interchange-documents/interchange-documents.module';
|
||||
import { LastMileModule } from '../last-mile/last-mile.module';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
import { NotificationInboxModule } from '../notification-inbox/notification-inbox.module';
|
||||
import { SignaturesModule } from '../signatures/signatures.module';
|
||||
import { WarehouseActivityLog } from './entities/warehouse-activity-log.entity';
|
||||
import { WarehouseAllocationRule } from './entities/warehouse-allocation-rule.entity';
|
||||
@@ -75,6 +76,7 @@ import { WarehousesService } from './warehouses.service';
|
||||
InterchangeDocumentsModule,
|
||||
forwardRef(() => LastMileModule),
|
||||
NotificationsModule,
|
||||
NotificationInboxModule,
|
||||
SignaturesModule,
|
||||
ExchangeModule.forRootAsync({
|
||||
inject: [ConfigService],
|
||||
|
||||
@@ -64,8 +64,8 @@ export class WarehousesService {
|
||||
currentWeight: 0,
|
||||
currentContainers: 0,
|
||||
currentVolume: 0,
|
||||
status: 'ACTIVE',
|
||||
isActive: true,
|
||||
status: dto.status ?? 'ACTIVE',
|
||||
isActive: (dto.status ?? 'ACTIVE') === 'ACTIVE',
|
||||
});
|
||||
} catch (error) {
|
||||
this.mapDbError(error);
|
||||
|
||||
@@ -196,7 +196,6 @@ async function ensureReferences(manager: any) {
|
||||
includesFirstMile: false,
|
||||
includesLastMile: false,
|
||||
includesCustoms: false,
|
||||
priorityBonusPoints: 0,
|
||||
isActive: true,
|
||||
displayOrder: 1,
|
||||
}),
|
||||
|
||||
@@ -138,7 +138,6 @@ async function main() {
|
||||
includesFirstMile: false,
|
||||
includesLastMile: false,
|
||||
includesCustoms: false,
|
||||
priorityBonusPoints: 0,
|
||||
isActive: true,
|
||||
displayOrder: 1,
|
||||
}),
|
||||
|
||||
@@ -214,7 +214,6 @@ export class ApprovedFirstLastMileDemoBookingsSeeder {
|
||||
includesFirstMile: true,
|
||||
includesLastMile: true,
|
||||
includesCustoms: false,
|
||||
priorityBonusPoints: 0,
|
||||
isActive: true,
|
||||
displayOrder: 10,
|
||||
},
|
||||
|
||||
@@ -295,7 +295,6 @@ export class DemoBookingsSeeder {
|
||||
includesFirstMile: false,
|
||||
includesLastMile: false,
|
||||
includesCustoms: false,
|
||||
priorityBonusPoints: 0,
|
||||
isActive: true,
|
||||
displayOrder: 1,
|
||||
},
|
||||
|
||||
171
apps/edr-freight-api/src/seed/freight-positions.seeder.ts
Normal file
171
apps/edr-freight-api/src/seed/freight-positions.seeder.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import {
|
||||
Organization,
|
||||
Permission,
|
||||
Position,
|
||||
PositionPermission,
|
||||
Unit,
|
||||
} from '@tria-plc/iamapi-common';
|
||||
import { DataSource, EntityManager, In } from 'typeorm';
|
||||
|
||||
import { EDR_FREIGHT_POSITIONS } from './edr-freight.seed';
|
||||
|
||||
const SEED_FLAG = 'SEED_EDR_ORG';
|
||||
const EDR_ORG_KEY = 'edr_freight';
|
||||
const EDR_UNIT_KEY = 'edr_freight_app';
|
||||
|
||||
/**
|
||||
* Seeds the operational freight positions (CEO, Chief, Director, Marketer,
|
||||
* Operation, Ethiopian GL, Djibouti GL) as Position + PositionPermission rows
|
||||
* on the `edr_freight_app` unit. Positions-as-roles: users get their freight
|
||||
* access by being assigned to a Position (via EmployeePosition), and the
|
||||
* position's PositionPermission grants come from EDR_FREIGHT_POSITIONS.
|
||||
*
|
||||
* Gated behind the same SEED_EDR_ORG flag as EdrOrgSeeder and depends on the
|
||||
* org/unit/permission catalog it seeds, so it must run AFTER EdrOrgSeeder.
|
||||
* Idempotent: positions upsert by (key, unitId); grants insert only the
|
||||
* permission ids a position is still missing.
|
||||
*/
|
||||
@Injectable()
|
||||
export class FreightPositionsSeeder {
|
||||
private readonly logger = new Logger(FreightPositionsSeeder.name);
|
||||
|
||||
constructor(private readonly dataSource: DataSource) {}
|
||||
|
||||
async run() {
|
||||
if (process.env[SEED_FLAG]?.trim().toLowerCase() !== 'true') {
|
||||
this.logger.log(
|
||||
`Skipping freight positions seed because ${SEED_FLAG} is not enabled`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
const organization = await manager.getRepository(Organization).findOne({
|
||||
where: { key: EDR_ORG_KEY },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (!organization) {
|
||||
throw new Error(`missing_organization:${EDR_ORG_KEY}`);
|
||||
}
|
||||
|
||||
const unit = await manager.getRepository(Unit).findOne({
|
||||
where: { key: EDR_UNIT_KEY, organizationId: organization.id },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (!unit) {
|
||||
throw new Error(`missing_unit:${EDR_UNIT_KEY}`);
|
||||
}
|
||||
|
||||
const permissionKeyToId = await this.loadPermissionIds(manager);
|
||||
|
||||
for (const seed of EDR_FREIGHT_POSITIONS) {
|
||||
const positionId = await this.ensurePosition(
|
||||
manager,
|
||||
seed,
|
||||
unit.id as string,
|
||||
organization.id as string,
|
||||
);
|
||||
|
||||
await this.ensurePositionPermissions(
|
||||
manager,
|
||||
positionId,
|
||||
seed,
|
||||
permissionKeyToId,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
this.logger.log(
|
||||
`Ensured ${EDR_FREIGHT_POSITIONS.length} freight positions on unit '${EDR_UNIT_KEY}'`,
|
||||
);
|
||||
}
|
||||
|
||||
/** Resolve every permission key referenced by any position to its id. */
|
||||
private async loadPermissionIds(
|
||||
manager: EntityManager,
|
||||
): Promise<Map<string, string>> {
|
||||
const keys = [
|
||||
...new Set(EDR_FREIGHT_POSITIONS.flatMap((p) => p.permissionKeys)),
|
||||
];
|
||||
|
||||
const permissions = await manager.getRepository(Permission).find({
|
||||
where: { key: In(keys) },
|
||||
select: { id: true, key: true },
|
||||
});
|
||||
|
||||
const map = new Map(permissions.map((p) => [p.key, p.id as string]));
|
||||
|
||||
const missing = keys.filter((key) => !map.has(key));
|
||||
if (missing.length > 0) {
|
||||
throw new Error(`missing_permissions:${missing.join(',')}`);
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
private async ensurePosition(
|
||||
manager: EntityManager,
|
||||
seed: (typeof EDR_FREIGHT_POSITIONS)[number],
|
||||
unitId: string,
|
||||
organizationId: string,
|
||||
): Promise<string> {
|
||||
const positionRepository = manager.getRepository(Position);
|
||||
|
||||
const existing = await positionRepository.findOne({
|
||||
where: { key: seed.key, unitId },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
return existing.id as string;
|
||||
}
|
||||
|
||||
const inserted = await positionRepository.insert({
|
||||
key: seed.key,
|
||||
name: { ...seed.name },
|
||||
rank: seed.rank,
|
||||
unitId,
|
||||
organizationId,
|
||||
});
|
||||
|
||||
this.logger.log(`Seeded freight position '${seed.key}'`);
|
||||
|
||||
return inserted.identifiers[0]?.id as string;
|
||||
}
|
||||
|
||||
private async ensurePositionPermissions(
|
||||
manager: EntityManager,
|
||||
positionId: string,
|
||||
seed: (typeof EDR_FREIGHT_POSITIONS)[number],
|
||||
permissionKeyToId: Map<string, string>,
|
||||
) {
|
||||
const positionPermissionRepository =
|
||||
manager.getRepository(PositionPermission);
|
||||
|
||||
const existing = await positionPermissionRepository.find({
|
||||
where: { positionId },
|
||||
select: { permissionId: true },
|
||||
});
|
||||
const existingPermissionIds = new Set(
|
||||
existing.map((row) => row.permissionId),
|
||||
);
|
||||
|
||||
const rowsToInsert = seed.permissionKeys
|
||||
.map((key) => permissionKeyToId.get(key) as string)
|
||||
.filter((permissionId) => !existingPermissionIds.has(permissionId))
|
||||
.map((permissionId) => ({ positionId, permissionId }));
|
||||
|
||||
if (rowsToInsert.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await positionPermissionRepository.insert(rowsToInsert);
|
||||
|
||||
this.logger.log(
|
||||
`Granted ${rowsToInsert.length} permissions to position '${seed.key}'`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,7 @@ import { DataSource } from 'typeorm';
|
||||
|
||||
const SEED_FLAG = 'SEED_FREIGHT_STAFF';
|
||||
const EDR_ORG_KEY = 'edr_freight';
|
||||
const EDR_UNIT_KEY = 'edr_freight_hq';
|
||||
const EDR_UNIT_KEY = 'edr_freight_app';
|
||||
|
||||
// roleKey is kept only for backwards compatibility with existing UserRole rows;
|
||||
// access is granted via the assigned position (positionKey) + PositionPermission.
|
||||
|
||||
@@ -136,7 +136,6 @@ export class PaidImportExportMileDemoSeeder {
|
||||
includesFirstMile: true,
|
||||
includesLastMile: true,
|
||||
includesCustoms: false,
|
||||
priorityBonusPoints: 0,
|
||||
isActive: true,
|
||||
displayOrder: 11,
|
||||
},
|
||||
|
||||
@@ -12,6 +12,7 @@ import { WeightLimitRule } from "../modules/rule-engine/entities/weight-limit-ru
|
||||
import { Route } from "../modules/routes/entities/route.entity";
|
||||
import { RouteMilestone } from "../modules/routes/entities/route-milestone.entity";
|
||||
import { Yard } from "../modules/rule-engine/entities/yard.entity";
|
||||
import { deriveTradeDirection } from "../common/derive-trade-direction.util";
|
||||
|
||||
const STAFF_USER_ID = "00000000-0000-0000-0000-000000000001";
|
||||
const CEO_USER_ID = "00000000-0000-0000-0000-000000000002";
|
||||
@@ -295,6 +296,7 @@ export class PricingDataSeeder {
|
||||
originYardId: addis.id,
|
||||
destinationYardId: direDawa.id,
|
||||
status: 'AVAILABLE',
|
||||
direction: deriveTradeDirection(addis, direDawa),
|
||||
}),
|
||||
);
|
||||
await milestoneRepo.save([
|
||||
|
||||
@@ -1,2 +1,6 @@
|
||||
VITE_API_URL=http://localhost:3001
|
||||
VITE_BASE_API_URL=http://localhost:3001
|
||||
|
||||
# Proactive token refresh cadence (minutes). Must stay well under the 60-min
|
||||
# server session window. Default: 10.
|
||||
VITE_TOKEN_REFRESH_INTERVAL_MINUTES=10
|
||||
|
||||
@@ -16,6 +16,10 @@ import {
|
||||
setCookie,
|
||||
} from "./cookies";
|
||||
import { applyTokens } from "./http";
|
||||
import {
|
||||
startTokenRefreshScheduler,
|
||||
stopTokenRefreshScheduler,
|
||||
} from "./refreshScheduler";
|
||||
import type { AuthTokens, AuthUser } from "./types";
|
||||
|
||||
interface LoginPayload {
|
||||
@@ -99,6 +103,18 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => {
|
||||
void bootstrap();
|
||||
}, []);
|
||||
|
||||
// Keep the server session alive while a user is logged in. Runs after
|
||||
// login, MFA verification, and page-reload bootstrap alike.
|
||||
useEffect(() => {
|
||||
if (!user) {
|
||||
stopTokenRefreshScheduler();
|
||||
return;
|
||||
}
|
||||
|
||||
startTokenRefreshScheduler();
|
||||
return stopTokenRefreshScheduler;
|
||||
}, [user]);
|
||||
|
||||
const value = useMemo<AuthContextValue>(
|
||||
() => ({
|
||||
user,
|
||||
|
||||
@@ -28,6 +28,30 @@ const applyTokens = ({ token, refreshToken }: AuthTokens) => {
|
||||
setCookie(REFRESH_TOKEN_COOKIE, refreshToken);
|
||||
};
|
||||
|
||||
/**
|
||||
* Single-flight token refresh: concurrent callers (the 401 interceptor and
|
||||
* the proactive scheduler) share one in-flight request so the refresh token
|
||||
* is only rotated once. Throws if no refresh token is stored or the server
|
||||
* rejects it — callers decide how to end the session.
|
||||
*/
|
||||
const refreshSessionTokens = async (): Promise<AuthTokens> => {
|
||||
const refreshToken = getCookie(REFRESH_TOKEN_COOKIE);
|
||||
if (!refreshToken) {
|
||||
throw new Error("missing refresh token");
|
||||
}
|
||||
|
||||
refreshPromise ??= api
|
||||
.post<AuthTokens>("/auth/refresh-token", { refreshToken })
|
||||
.then((response) => response.data)
|
||||
.finally(() => {
|
||||
refreshPromise = null;
|
||||
});
|
||||
|
||||
const tokens = await refreshPromise;
|
||||
applyTokens(tokens);
|
||||
return tokens;
|
||||
};
|
||||
|
||||
api.interceptors.request.use((config) => {
|
||||
const token = getCookie(AUTH_TOKEN_COOKIE);
|
||||
|
||||
@@ -65,8 +89,7 @@ api.interceptors.response.use(
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
const refreshToken = getCookie(REFRESH_TOKEN_COOKIE);
|
||||
if (!refreshToken) {
|
||||
if (!getCookie(REFRESH_TOKEN_COOKIE)) {
|
||||
clearSessionCookies();
|
||||
return Promise.reject(error);
|
||||
}
|
||||
@@ -74,15 +97,7 @@ api.interceptors.response.use(
|
||||
originalRequest._retry = true;
|
||||
|
||||
try {
|
||||
refreshPromise ??= api
|
||||
.post<AuthTokens>("/auth/refresh-token", { refreshToken })
|
||||
.then((response) => response.data)
|
||||
.finally(() => {
|
||||
refreshPromise = null;
|
||||
});
|
||||
|
||||
const tokens = await refreshPromise;
|
||||
applyTokens(tokens);
|
||||
const tokens = await refreshSessionTokens();
|
||||
originalRequest.headers = {
|
||||
...originalRequest.headers,
|
||||
Authorization: `Bearer ${tokens.token}`,
|
||||
@@ -97,4 +112,4 @@ api.interceptors.response.use(
|
||||
},
|
||||
);
|
||||
|
||||
export { api, applyTokens };
|
||||
export { api, applyTokens, refreshSessionTokens };
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user