Merge branch 'dev' into freight/fix/type-errors

This commit is contained in:
Nathnael Wondisha
2026-06-25 09:27:14 +03:00
committed by GitHub
409 changed files with 26637 additions and 10747 deletions

View File

@@ -13,7 +13,7 @@ permissions:
jobs:
detect-changes:
name: Detect changed services
runs-on: self-hosted
runs-on: ${{ fromJson(format('["self-hosted", "{0}"]', github.ref_name)) }}
outputs:
matrix: ${{ steps.filter.outputs.matrix }}
steps:
@@ -52,7 +52,7 @@ jobs:
NON_DEPLOYABLE_PATTERN="^docs/|^README[.]md$|^DEPLOYMENT[.]md$|^CLAUDE[.]md$|^checkpoint[.]md$|^orgstructure[.]md$|^ITMLS_DB_Design[.]md$|.*[.]md$|^[.]eslintrc|^[.]prettierrc|^[.]editorconfig|^[.]gitignore|^[.]gitattributes|^commitlint[.]config[.]js$"
GLOBAL_PATTERN="^[.]github/|^docker-compose[.]yaml$|^turbo[.]json$|^tsconfig[.]json$|^tsconfig[.]base[.]json$|^pnpm-workspace[.]yaml$|^pnpm-lock[.]yaml$|^package[.]json$|^[.]env([.][a-z]+)?$|^packages/|^infrastructure/|^scripts/deploy/|^wagon[.][^/]*[.]ts$|^cargo[.][^/]*[.]ts$|^container[.][^/]*[.]ts$|^use-[^/]*[.]ts$|^[^/]*[.]service[.]ts$|^[^/]*[.]entity[.]ts$|^[^/]*-types[.]ts$"
GLOBAL_PATTERN="^[.]github/|^docker-compose[.]yaml$|^turbo[.]json$|^tsconfig[.]json$|^tsconfig[.]base[.]json$|^pnpm-workspace[.]yaml$|^pnpm-lock[.]yaml$|^package[.]json$|^[.]env([.][a-z]+)?$|^packages/|^local-packages/|^infrastructure/|^scripts/deploy/|^wagon[.][^/]*[.]ts$|^cargo[.][^/]*[.]ts$|^container[.][^/]*[.]ts$|^use-[^/]*[.]ts$|^[^/]*[.]service[.]ts$|^[^/]*[.]entity[.]ts$|^[^/]*-types[.]ts$"
DEPLOYABLE=$(echo "$CHANGED" | grep -vE "$NON_DEPLOYABLE_PATTERN" || true)
if [ -z "$DEPLOYABLE" ]; then
@@ -91,7 +91,7 @@ jobs:
name: Deploy ${{ matrix.service }}
needs: detect-changes
if: ${{ needs.detect-changes.outputs.matrix != '[]' }}
runs-on: self-hosted
runs-on: ${{ fromJson(format('["self-hosted", "{0}"]', github.ref_name)) }}
strategy:
fail-fast: false
matrix:

4
.gitignore vendored
View File

@@ -24,3 +24,7 @@ coverage/
.idea/
.vscode/
.npmrc
# emacs cache files
*~
\#*\#
.\#*

View File

View File

@@ -0,0 +1 @@
{"dependencies":{"pnpm":"11.1.1"}}

BIN
.pnpm-store/v11/index.db Normal file

Binary file not shown.

View File

@@ -37,7 +37,7 @@
"@nestjs/swagger": "^11.4.2",
"@nestjs/typeorm": "^11.0.1",
"@tria-plc/api-common": "file:../../local-packages/tria-plc-api-common-1.4.3.tgz",
"@tria-plc/iamapi-common": "file:../../local-packages/tria-plc-iamapi-common-0.7.3.tgz",
"@tria-plc/iamapi-common": "file:../../local-packages/tria-plc-iamapi-common-0.7.4.tgz",
"amqp-connection-manager": "^5.0.0",
"amqplib": "^2.0.1",
"axios": "^1.16.1",

View File

@@ -61,7 +61,6 @@ import { ContainersModule } from './modules/container-management/containers.modu
import { CargoesModule } from './modules/cargoes/cargoes.module';
import { RoutesModule } from './modules/routes/routes.module';
import { WarehousesModule } from './modules/warehouses/warehouses.module';
import { FacilitiesModule } from './modules/facilities/facilities.module';
import { OverviewModule } from './modules/overview/overview.module';
import { VehiclesModule } from './modules/vehicles/vehicles.module';
import { DriversModule } from './modules/drivers/drivers.module';
@@ -123,7 +122,6 @@ import { LastMileModule } from './modules/last-mile/last-mile.module';
ContainersModule,
CargoesModule,
RoutesModule,
FacilitiesModule,
WarehousesModule,
OverviewModule,
VehiclesModule,

View File

@@ -7,13 +7,13 @@ export function deriveTradeDirection(
originYard: YardLike,
destinationYard: YardLike,
): ScheduleTradeDirection {
const originCountry = originYard.country?.trim();
const destinationCountry = destinationYard.country?.trim();
const originCountry = originYard.country?.trim().toLowerCase();
const destinationCountry = destinationYard.country?.trim().toLowerCase();
if (originCountry === 'Djibouti') {
if (originCountry === 'djibouti') {
return 'IMPORT';
}
if (destinationCountry === 'Djibouti' && originCountry !== 'Djibouti') {
if (destinationCountry === 'djibouti' && originCountry !== 'djibouti') {
return 'EXPORT';
}
return 'DOMESTIC';

View File

@@ -39,7 +39,16 @@ async function bootstrap() {
});
app.setGlobalPrefix("api");
app.useGlobalPipes(createValidationPipe());
// enableImplicitConversion is OFF: class-transformer's implicit boolean
// coercion turns any non-empty multipart/form-data string (including the
// literal "false") into `true`, silently corrupting flags like isHazardous
// and isGovernment. With it off, only explicit @Transform/@Type decorators
// coerce values — every numeric/boolean DTO field in this API already has one.
app.useGlobalPipes(
createValidationPipe({
transformOptions: { enableImplicitConversion: false },
}),
);
app.useGlobalFilters(new HttpExceptionFilter());
app.useGlobalInterceptors(new ResponseTransformInterceptor());

View File

@@ -0,0 +1,35 @@
import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm';
export class AddVehicleCodeAndPlates1810000000002 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
const hasCode = await queryRunner.hasColumn('freight.vehicles', 'code');
if (!hasCode) {
await queryRunner.addColumn(
'freight.vehicles',
new TableColumn({ name: 'code', type: 'varchar', isNullable: true }),
);
}
const hasPower = await queryRunner.hasColumn('freight.vehicles', 'power_plate_no');
if (!hasPower) {
await queryRunner.addColumn(
'freight.vehicles',
new TableColumn({ name: 'power_plate_no', type: 'varchar', isNullable: true }),
);
}
const hasTrailer = await queryRunner.hasColumn('freight.vehicles', 'trailer_plate_no');
if (!hasTrailer) {
await queryRunner.addColumn(
'freight.vehicles',
new TableColumn({ name: 'trailer_plate_no', type: 'varchar', isNullable: true }),
);
}
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropColumn('freight.vehicles', 'trailer_plate_no');
await queryRunner.dropColumn('freight.vehicles', 'power_plate_no');
await queryRunner.dropColumn('freight.vehicles', 'code');
}
}

View File

@@ -0,0 +1,23 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Consolidation is now system-managed: the backend consolidates partial-wagon
* container bookings automatically, derived from the container quantities. The
* `allow_consolidation` opt-in flag is therefore redundant and is dropped.
* `consolidation_partner_id` (the actual pairing link) is unaffected.
*/
export class DropAllowConsolidation1820000000000 implements MigrationInterface {
name = 'DropAllowConsolidation1820000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS allow_consolidation;`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS allow_consolidation BOOLEAN NOT NULL DEFAULT false;`,
);
}
}

View File

@@ -0,0 +1,61 @@
import { MigrationInterface, QueryRunner, Table, TableIndex } from 'typeorm';
/**
* Multi-route general contracts: a contract may reserve quantity across several
* routes. Each (contract, route, container type) is a row here; drawdown orders
* reference the route line they drew from via booking_orders.route_line_id.
*/
export class CreateContractRouteLines1820000000001
implements MigrationInterface
{
name = 'CreateContractRouteLines1820000000001';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.createTable(
new Table({
schema: 'freight',
name: 'contract_route_lines',
columns: [
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'gen_random_uuid()' },
{ name: 'contract_booking_id', type: 'uuid' },
{ name: 'origin_yard_id', type: 'uuid' },
{ name: 'destination_yard_id', type: 'uuid' },
{ name: 'container_type_id', type: 'uuid', isNullable: true },
{ name: 'quantity', type: 'numeric', precision: 12, scale: 3 },
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
],
foreignKeys: [
{
columnNames: ['contract_booking_id'],
referencedSchema: 'freight',
referencedTableName: 'bookings',
referencedColumnNames: ['id'],
onDelete: 'CASCADE',
},
],
}),
true,
);
await queryRunner.createIndex(
'freight.contract_route_lines',
new TableIndex({
name: 'idx_contract_route_lines_contract',
columnNames: ['contract_booking_id'],
}),
);
await queryRunner.query(
`ALTER TABLE freight.booking_orders ADD COLUMN IF NOT EXISTS route_line_id uuid;`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.booking_orders DROP COLUMN IF EXISTS route_line_id;`,
);
await queryRunner.dropTable('freight.contract_route_lines', true);
}
}

View File

@@ -0,0 +1,66 @@
import { MigrationInterface, QueryRunner, Table, TableIndex } from 'typeorm';
/**
* Per-document GL review for the post-counter-sign clearance gate. One row per
* required clearance document; GL marks each APPROVED or QUERIED before the
* booking can proceed to operations.
*/
export class CreateBookingDocumentReview1820000000002
implements MigrationInterface
{
name = 'CreateBookingDocumentReview1820000000002';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.createTable(
new Table({
schema: 'freight',
name: 'booking_document_review',
columns: [
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'gen_random_uuid()' },
{ name: 'booking_id', type: 'uuid' },
{ name: 'setting_code', type: 'varchar', length: '128' },
{ name: 'file_key', type: 'varchar', length: '128' },
{ name: 'file_record_id', type: 'uuid', isNullable: true },
{ name: 'status', type: 'varchar', length: '20', default: "'PENDING'" },
{ name: 'note', type: 'text', isNullable: true },
{ name: 'reviewed_by_staff_id', 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: ['booking_id'],
referencedSchema: 'freight',
referencedTableName: 'bookings',
referencedColumnNames: ['id'],
onDelete: 'CASCADE',
},
],
}),
true,
);
await queryRunner.createIndex(
'freight.booking_document_review',
new TableIndex({ name: 'idx_booking_document_review_booking', columnNames: ['booking_id'] }),
);
await queryRunner.createIndex(
'freight.booking_document_review',
new TableIndex({ name: 'idx_booking_document_review_status', columnNames: ['status'] }),
);
await queryRunner.createIndex(
'freight.booking_document_review',
new TableIndex({
name: 'uq_booking_document_review_doc',
columnNames: ['booking_id', 'setting_code', 'file_key'],
isUnique: true,
}),
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropTable('freight.booking_document_review', true);
}
}

View File

@@ -0,0 +1,39 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Staff price adjustment: an optional override of a booking's computed total,
* with who/when/why. When set, the customer sees the adjusted total + a badge.
*/
export class AddPriceAdjustment1820000000003 implements MigrationInterface {
name = 'AddPriceAdjustment1820000000003';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS adjusted_total_amount numeric(14,2);`,
);
await queryRunner.query(
`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS adjusted_by_staff_id uuid;`,
);
await queryRunner.query(
`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS adjusted_at timestamptz;`,
);
await queryRunner.query(
`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS adjustment_reason text;`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS adjustment_reason;`,
);
await queryRunner.query(
`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS adjusted_at;`,
);
await queryRunner.query(
`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS adjusted_by_staff_id;`,
);
await queryRunner.query(
`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS adjusted_total_amount;`,
);
}
}

View File

@@ -0,0 +1,187 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Fold the `surcharge_types` table into self-describing rates.
*
* Previously a surcharge was a separate row {trigger_condition, rate_id}. Now
* each rate carries its own `applies_to` (friendly category) and `trigger`
* (ALWAYS = base freight, otherwise a surcharge condition), plus an optional
* `cargo_type_id` for bulk leaf commodities. The rule engine reads triggers
* directly off LIVE rates, so the join table is no longer needed.
*
* This migration:
* 1. adds applies_to / trigger / cargo_type_id to rates and backfills them
* from the existing rate_type matrix,
* 2. repoints booking_cargo_modifier from surcharge_type_id → rate_id
* (backfilled via surcharge_types.rate_id),
* 3. drops surcharge_types and its FK.
*/
export class FoldSurchargeTypesIntoRates1820000000004 implements MigrationInterface {
name = 'FoldSurchargeTypesIntoRates1820000000004';
public async up(queryRunner: QueryRunner): Promise<void> {
// ── 1. New rate columns ────────────────────────────────────────────────
await queryRunner.query(`
ALTER TABLE freight.rates
ADD COLUMN IF NOT EXISTS applies_to varchar(20) NOT NULL DEFAULT 'OTHER',
ADD COLUMN IF NOT EXISTS "trigger" varchar(20) NOT NULL DEFAULT 'ALWAYS',
ADD COLUMN IF NOT EXISTS cargo_type_id uuid NULL;
`);
await queryRunner.query(`
ALTER TABLE freight.rates
ADD CONSTRAINT "FK_rates_cargo_type_id"
FOREIGN KEY (cargo_type_id) REFERENCES freight.cargo_types(id)
ON DELETE SET NULL;
`);
await queryRunner.query(
`CREATE INDEX IF NOT EXISTS "IDX_rates_trigger" ON freight.rates ("trigger");`,
);
// ── 1a. Backfill applies_to from the legacy rate_type matrix ────────────
await queryRunner.query(`
UPDATE freight.rates SET applies_to = CASE
WHEN rate_type IN ('CONTAINER_IMPORT','CONTAINER_EXPORT','CONTAINER_WITH_RETURN') THEN 'CONTAINER'
WHEN rate_type IN ('BULK_IMPORT','BULK_EXPORT') THEN 'BULK'
WHEN rate_type IN ('INTERCITY_CONTAINER','INTERCITY_BULK') THEN 'INTERCITY'
WHEN rate_type = 'FIRST_MILE' THEN 'FIRST_MILE'
WHEN rate_type = 'LAST_MILE' THEN 'LAST_MILE'
ELSE 'OTHER'
END;
`);
// ── 1b. Backfill trigger from the legacy rate_type matrix ───────────────
await queryRunner.query(`
UPDATE freight.rates SET "trigger" = CASE
WHEN rate_type = 'HAZARD_SURCHARGE' THEN 'HAZARDOUS'
WHEN rate_type = 'REEFER_SURCHARGE' THEN 'REEFER'
WHEN rate_type = 'OVERWEIGHT_PER_TON' THEN 'OVERWEIGHT'
WHEN rate_type = 'DOUBLE_HANDLING' THEN 'SHIPPING_LINE'
WHEN rate_type = 'LASHING' THEN 'CONSOLIDATION'
WHEN rate_type = 'CANCELLATION_FEE' THEN 'CANCELLATION'
WHEN rate_type = 'DEMURRAGE' THEN 'DEMURRAGE'
WHEN rate_type = 'PIL_EXTRA_FEE' THEN 'PIL_EXTRA_FEE'
ELSE 'ALWAYS'
END;
`);
// Align the trigger to the actual surcharge_types mapping where one exists
// (covers any rate wired as a surcharge with a non-obvious rate_type).
await queryRunner.query(`
UPDATE freight.rates r SET "trigger" = m.trig
FROM (
SELECT st.rate_id, CASE st.trigger_condition
WHEN 'CARGO_FLAG_HAZARDOUS' THEN 'HAZARDOUS'
WHEN 'CARGO_FLAG_REEFER' THEN 'REEFER'
WHEN 'VGM_EXCEEDS_LIMIT' THEN 'OVERWEIGHT'
WHEN 'SHIPPING_LINE_MAPPED' THEN 'SHIPPING_LINE'
WHEN 'CONSOLIDATION_ENABLED' THEN 'CONSOLIDATION'
ELSE 'ALWAYS'
END AS trig
FROM freight.surcharge_types st
WHERE st.rate_id IS NOT NULL AND st.deleted_at IS NULL
) m
WHERE r.id = m.rate_id AND m.trig <> 'ALWAYS';
`);
// ── 2. Repoint booking_cargo_modifier to rate_id ────────────────────────
await queryRunner.query(`
ALTER TABLE freight.booking_cargo_modifier
ADD COLUMN IF NOT EXISTS rate_id uuid NULL;
`);
await queryRunner.query(`
UPDATE freight.booking_cargo_modifier bcm
SET rate_id = st.rate_id
FROM freight.surcharge_types st
WHERE bcm.surcharge_type_id = st.id AND st.rate_id IS NOT NULL;
`);
// Rows whose surcharge lost its rate can't be repointed — they reference a
// now-defunct surcharge. Remove them so the NOT NULL + FK can be enforced.
await queryRunner.query(`
DELETE FROM freight.booking_cargo_modifier WHERE rate_id IS NULL;
`);
await queryRunner.query(`
ALTER TABLE freight.booking_cargo_modifier
ALTER COLUMN rate_id SET NOT NULL;
`);
// Drop the old FK + column + index for surcharge_type_id.
await queryRunner.query(`
ALTER TABLE freight.booking_cargo_modifier
DROP CONSTRAINT IF EXISTS "FK_booking_cargo_modifier_surcharge_type_id";
`);
await queryRunner.query(
`DROP INDEX IF EXISTS freight."IDX_booking_cargo_modifier_surcharge_type_id";`,
);
await queryRunner.query(`
ALTER TABLE freight.booking_cargo_modifier
DROP COLUMN IF EXISTS surcharge_type_id;
`);
await queryRunner.query(`
ALTER TABLE freight.booking_cargo_modifier
ADD CONSTRAINT "FK_booking_cargo_modifier_rate_id"
FOREIGN KEY (rate_id) REFERENCES freight.rates(id);
`);
await queryRunner.query(
`CREATE INDEX IF NOT EXISTS "IDX_booking_cargo_modifier_rate_id" ON freight.booking_cargo_modifier (rate_id);`,
);
// ── 3. Drop the surcharge_types table ───────────────────────────────────
await queryRunner.query(`DROP TABLE IF EXISTS freight.surcharge_types;`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
// Recreate surcharge_types (structure only — data is not restored).
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.surcharge_types (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
code varchar(40) NOT NULL,
label varchar(100),
trigger_condition varchar(50),
rate_id uuid,
is_active boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz
);
`);
await queryRunner.query(
`CREATE UNIQUE INDEX IF NOT EXISTS "IDX_surcharge_types_code" ON freight.surcharge_types (code);`,
);
// Restore booking_cargo_modifier.surcharge_type_id (nullable; not backfilled).
await queryRunner.query(`
ALTER TABLE freight.booking_cargo_modifier
DROP CONSTRAINT IF EXISTS "FK_booking_cargo_modifier_rate_id";
`);
await queryRunner.query(
`DROP INDEX IF EXISTS freight."IDX_booking_cargo_modifier_rate_id";`,
);
await queryRunner.query(`
ALTER TABLE freight.booking_cargo_modifier
ADD COLUMN IF NOT EXISTS surcharge_type_id uuid NULL;
`);
await queryRunner.query(`
ALTER TABLE freight.booking_cargo_modifier DROP COLUMN IF EXISTS rate_id;
`);
// Drop the new rate columns.
await queryRunner.query(
`DROP INDEX IF EXISTS freight."IDX_rates_trigger";`,
);
await queryRunner.query(`
ALTER TABLE freight.rates DROP CONSTRAINT IF EXISTS "FK_rates_cargo_type_id";
`);
await queryRunner.query(`
ALTER TABLE freight.rates
DROP COLUMN IF EXISTS cargo_type_id,
DROP COLUMN IF EXISTS "trigger",
DROP COLUMN IF EXISTS applies_to;
`);
}
}

View File

@@ -0,0 +1,37 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Contract validity window. When the backoffice accepts a price-confirmed
* booking, staff define how many days the contract stays valid. The window runs
* from the accept moment (valid_from) through valid_from + N days (valid_until).
* Outside that window the contract is considered expired.
*/
export class AddContractValidityWindow1820000000005
implements MigrationInterface
{
name = 'AddContractValidityWindow1820000000005';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS contract_validity_days integer;`,
);
await queryRunner.query(
`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS contract_valid_from timestamptz;`,
);
await queryRunner.query(
`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS contract_valid_until timestamptz;`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS contract_valid_until;`,
);
await queryRunner.query(
`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS contract_valid_from;`,
);
await queryRunner.query(
`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS contract_validity_days;`,
);
}
}

View File

@@ -0,0 +1,41 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Booking now captures:
* - customs clearing as an explicit flag + the customs clearing agent name
* (shown when the service includes customs), and
* - first/last-mile pickup & delivery coordinates (lat/lng) alongside the
* existing address text, so the map picker can store and restore the pin.
*
* Shipping line is no longer collected from the booking form; the column stays
* for historical data and the (now dormant) shipping-line pricing trigger.
*/
export class AddCustomsAgentAndMileCoordinates1820000000006
implements MigrationInterface
{
name = 'AddCustomsAgentAndMileCoordinates1820000000006';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.bookings
ADD COLUMN IF NOT EXISTS first_mile_pickup_lat numeric(10,7) NULL,
ADD COLUMN IF NOT EXISTS first_mile_pickup_lng numeric(10,7) NULL,
ADD COLUMN IF NOT EXISTS last_mile_delivery_lat numeric(10,7) NULL,
ADD COLUMN IF NOT EXISTS last_mile_delivery_lng numeric(10,7) NULL,
ADD COLUMN IF NOT EXISTS customs_clearing_enabled boolean NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS customs_clearing_agent varchar(200) NULL;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.bookings
DROP COLUMN IF EXISTS customs_clearing_agent,
DROP COLUMN IF EXISTS customs_clearing_enabled,
DROP COLUMN IF EXISTS last_mile_delivery_lng,
DROP COLUMN IF EXISTS last_mile_delivery_lat,
DROP COLUMN IF EXISTS first_mile_pickup_lng,
DROP COLUMN IF EXISTS first_mile_pickup_lat;
`);
}
}

View File

@@ -0,0 +1,50 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* General-contract drawdown order fields:
* - booking_order_lines.hazardous_quantity / reefer_quantity — per-order counts
* the customer enters when toggling hazardous/reefer; drive the surcharge
* rates on the spawned child booking.
* - bookings.is_reefer — booking-level refrigerated flag so REEFER_SURCHARGE
* applies to a contract order even when the container type is not a reefer.
* - contract_route_lines.km — road distance configured with the route; road
* orders bill KM × the PER_KM rate.
*
* NOTE: the shared dev DB has no applied migration history, so these columns
* are also hand-applied there. ADD COLUMN IF NOT EXISTS keeps that idempotent.
*/
export class AddGeneralContractOrderFields1820000000010
implements MigrationInterface
{
name = 'AddGeneralContractOrderFields1820000000010';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.booking_order_lines ADD COLUMN IF NOT EXISTS hazardous_quantity numeric(12,3) NOT NULL DEFAULT 0;`,
);
await queryRunner.query(
`ALTER TABLE freight.booking_order_lines ADD COLUMN IF NOT EXISTS reefer_quantity numeric(12,3) NOT NULL DEFAULT 0;`,
);
await queryRunner.query(
`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS is_reefer boolean NOT NULL DEFAULT false;`,
);
await queryRunner.query(
`ALTER TABLE freight.contract_route_lines ADD COLUMN IF NOT EXISTS km numeric(10,2);`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.contract_route_lines DROP COLUMN IF EXISTS km;`,
);
await queryRunner.query(
`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS is_reefer;`,
);
await queryRunner.query(
`ALTER TABLE freight.booking_order_lines DROP COLUMN IF EXISTS reefer_quantity;`,
);
await queryRunner.query(
`ALTER TABLE freight.booking_order_lines DROP COLUMN IF EXISTS hazardous_quantity;`,
);
}
}

View File

@@ -45,6 +45,15 @@ export class BookingOrdersController {
return this.generalContractService.getQuantityLines(id);
}
@Get('contract/:id/routes')
@ApiOperation({
summary:
'Per-route contracted / ordered / remaining quantities (multi-route contracts). Empty for single-route.',
})
async routes(@Param('id', ParseUUIDPipe) id: string) {
return this.generalContractService.getRouteLines(id);
}
@Get(':id')
@ApiOperation({ summary: 'Get a single booking order' })
async findOne(@Param('id', ParseUUIDPipe) id: string) {

View File

@@ -3,20 +3,23 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { BookingsModule } from '../bookings/bookings.module';
import { CompaniesModule } from '../companies/companies.module';
import { DropdownSettingsModule } from '../dropdown-settings/dropdown-settings.module';
import { RuleEngineModule } from '../rule-engine/rule-engine.module';
import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module';
import { BookingOrdersController } from './booking-orders.controller';
import { BookingOrdersRepository } from './booking-orders.repository';
import { BookingOrdersService } from './booking-orders.service';
import { BookingOrder } from './entities/booking-order.entity';
import { BookingOrderLine } from './entities/booking-order-line.entity';
import { ContractRouteLine } from './entities/contract-route-line.entity';
import { GeneralContractService } from './general-contract.service';
@Module({
imports: [
TypeOrmModule.forFeature([BookingOrder, BookingOrderLine]),
TypeOrmModule.forFeature([BookingOrder, BookingOrderLine, ContractRouteLine]),
BookingsModule,
CompaniesModule,
DropdownSettingsModule,
RuleEngineModule,
forwardRef(() => TrainSchedulingModule),
],
controllers: [BookingOrdersController],

View File

@@ -0,0 +1,127 @@
import { BookingOrdersService } from './booking-orders.service';
/**
* Phase-0 spine: a drawdown order spawns a PRICED, UNPAID child booking that
* waits for Marketing review (or the customs clearance gate first) — it does
* NOT auto-enter the train batch pool, and the contract is not charged.
*/
describe('BookingOrdersService — child spawn on order create', () => {
function makeService(opts: { includesCustoms: boolean; roadKm?: number | null }) {
const contract = {
id: 'c-1',
bookingType: 'GENERAL_CONTRACT',
status: 'CONTRACT_ACTIVE',
expiresAt: new Date('2030-01-01T00:00:00.000Z'),
freightType: 'BULK',
originYardId: 'o-1',
destinationYardId: 'd-1',
companyId: null,
paymentCurrency: 'ETB',
serviceType: { includesCustoms: opts.includesCustoms, code: 'RAIL_BULK' },
bookingContainers: [],
};
// Capture what status the child is created with.
const created: Record<string, unknown>[] = [];
const managerUpdates: Record<string, unknown>[] = [];
const fakeManager = {
create: (_entity: unknown, data: Record<string, unknown>) => {
created.push(data);
return { id: 'child-1', ...data };
},
save: async (row: Record<string, unknown>) => ({ id: 'child-1', ...row }),
getRepository: () => ({
findOne: async () => ({ id: 'child-1', paymentCurrency: 'ETB', bookingContainers: [] }),
update: async (_id: string, data: Record<string, unknown>) => {
managerUpdates.push(data);
},
}),
};
const dataSource = {
transaction: async (cb: (m: unknown) => Promise<unknown>) => cb(fakeManager),
getRepository: () => ({ update: jest.fn() }),
};
const ordersRepository = {
countByYear: jest.fn().mockResolvedValue(0),
findById: jest.fn().mockResolvedValue({ id: 'order-1', lines: [] }),
};
const bookingsRepository = {
findById: jest.fn().mockResolvedValue(contract),
countByYear: jest.fn().mockResolvedValue(0),
};
const generalContractService = {
isGeneralContract: () => true,
getRouteLines: jest.fn().mockResolvedValue([]),
getQuantityLines: jest
.fn()
.mockResolvedValue([
{ containerTypeId: null, remainingQuantity: 100, containerTypeName: null },
]),
isExhausted: jest.fn().mockResolvedValue(false),
};
const pricingService = {
computePriceForBooking: jest.fn().mockResolvedValue({
totalAmount: 500,
priorityScore: 10,
lineItems: [],
currency: 'ETB',
}),
};
const ratesService = { findLiveRates: jest.fn().mockResolvedValue([]) };
const trainSchedulingService = {
existsOpenScheduleOnRouteDay: jest.fn().mockResolvedValue(true),
};
const companiesService = {};
const service = new BookingOrdersService(
dataSource as never,
ordersRepository as never,
bookingsRepository as never,
companiesService as never,
generalContractService as never,
pricingService as never,
ratesService as never,
trainSchedulingService as never,
);
return { service, created, managerUpdates, pricingService };
}
const dto = {
contractBookingId: 'c-1',
scheduledDate: '2026-07-01T00:00:00.000Z',
lines: [{ quantity: 10, hazardousQuantity: 4, reeferQuantity: 0 }],
};
it('spawns the child at OPERATION_REQUEST_PENDING (no customs), priced + unpaid', async () => {
const { service, created, managerUpdates, pricingService } = makeService({
includesCustoms: false,
});
await service.create(dto as never);
const child = created.find((c) => c.bookingType === 'ONE_TIME')!;
expect(child.status).toBe('OPERATION_REQUEST_PENDING');
expect(child.paymentStatus).toBe('PENDING');
expect(child.isHazardous).toBe(true); // line has hazardousQuantity > 0
expect(pricingService.computePriceForBooking).toHaveBeenCalled();
// The computed price is persisted onto the child.
expect(managerUpdates.some((u) => u.totalAmount === 500)).toBe(true);
});
it('spawns the child at AWAITING_DOCUMENTS when the service includes customs', async () => {
const { service, created } = makeService({ includesCustoms: true });
await service.create(dto as never);
const child = created.find((c) => c.bookingType === 'ONE_TIME')!;
expect(child.status).toBe('AWAITING_DOCUMENTS');
});
it('rejects when hazardous quantity exceeds the line quantity', async () => {
const { service } = makeService({ includesCustoms: false });
await expect(
service.create({
...dto,
lines: [{ quantity: 5, hazardousQuantity: 9, reeferQuantity: 0 }],
} as never),
).rejects.toThrow(/exceed the line quantity/);
});
});

View File

@@ -8,11 +8,13 @@ import {
} from '@nestjs/common';
import { DataSource } from 'typeorm';
import { BookingsRepository } from '../bookings/bookings.repository';
import { BookingPricingService } from '../bookings/booking-pricing.service';
import { clearanceCodesForBooking } from '../bookings/clearance.util';
import { Booking } from '../bookings/entities/booking.entity';
import { BookingContainer } from '../bookings/entities/booking-container.entity';
import { CompaniesService } from '../companies/companies.service';
import { ContainerType } from '../rule-engine/entities/container-type.entity';
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
import { RatesService } from '../rule-engine/services/rates.service';
import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service';
import { eatDay } from '../train-scheduling/batch-window.util';
import { BookingOrdersRepository } from './booking-orders.repository';
@@ -20,6 +22,7 @@ import { CreateBookingOrderDto } from './dto/create-booking-order.dto';
import { BookingOrder } from './entities/booking-order.entity';
import { BookingOrderLine } from './entities/booking-order-line.entity';
import { GeneralContractService } from './general-contract.service';
import { isRoadService, roadKmPrice } from './road.util';
@Injectable()
export class BookingOrdersService {
@@ -31,8 +34,8 @@ export class BookingOrdersService {
private readonly bookingsRepository: BookingsRepository,
private readonly companiesService: CompaniesService,
private readonly generalContractService: GeneralContractService,
@Inject(forwardRef(() => BookingBatchService))
private readonly bookingBatchService: BookingBatchService,
private readonly pricingService: BookingPricingService,
private readonly ratesService: RatesService,
@Inject(forwardRef(() => TrainSchedulingService))
private readonly trainSchedulingService: TrainSchedulingService,
) {}
@@ -79,12 +82,40 @@ export class BookingOrdersService {
throw new BadRequestException('You do not have access to this contract');
}
// Resolve the route the order ships on: a chosen contract route line for a
// multi-route contract, else the contract's own origin/destination.
const routeLines = await this.generalContractService.getRouteLines(
contract.id,
);
let originYardId = contract.originYardId;
let destinationYardId = contract.destinationYardId;
let routeLineId: string | null = null;
let routeKm: number | null = null;
if (routeLines.length > 0) {
if (!dto.routeLineId) {
throw new BadRequestException(
'This contract has multiple routes — select a route to draw from',
);
}
const chosen = routeLines.find((r) => r.routeLineId === dto.routeLineId);
if (!chosen) {
throw new BadRequestException(
'Selected route is not part of this contract',
);
}
originYardId = chosen.originYardId;
destinationYardId = chosen.destinationYardId;
routeLineId = chosen.routeLineId;
routeKm = chosen.km ?? null;
}
// Validate the route has a departure on the chosen day.
const day = eatDay(new Date(dto.scheduledDate));
const hasDeparture =
await this.trainSchedulingService.existsOpenScheduleOnRouteDay(
contract.originYardId,
contract.destinationYardId,
originYardId,
destinationYardId,
day,
);
if (!hasDeparture) {
@@ -93,44 +124,84 @@ export class BookingOrdersService {
);
}
// Validate each line against the remaining pool.
const poolLines = await this.generalContractService.getQuantityLines(
contract.id,
);
const isContainer = contract.freightType === 'CONTAINER';
const orderTotal = dto.lines.reduce((sum, l) => sum + l.quantity, 0);
// Hazardous/reefer counts the customer entered cannot exceed the line they
// belong to. Validated for every order regardless of routing.
for (const line of dto.lines) {
if (line.quantity <= 0) {
throw new BadRequestException('Order quantities must be greater than zero');
const haz = line.hazardousQuantity ?? 0;
const reefer = line.reeferQuantity ?? 0;
if (haz < 0 || reefer < 0) {
throw new BadRequestException('Hazardous/reefer quantities cannot be negative');
}
const key = isContainer ? (line.containerTypeId ?? '') : '';
const poolLine = poolLines.find((p) => (p.containerTypeId ?? '') === key);
if (!poolLine) {
if (haz > line.quantity || reefer > line.quantity) {
throw new BadRequestException(
isContainer
? `Container type ${line.containerTypeId} is not part of this contract`
: 'This contract has no matching quantity pool',
'Hazardous/reefer quantity cannot exceed the line quantity',
);
}
if (line.quantity > poolLine.remainingQuantity) {
}
if (routeLineId) {
// Multi-route: validate against the chosen route line's remaining pool.
for (const line of dto.lines) {
if (line.quantity <= 0) {
throw new BadRequestException('Order quantities must be greater than zero');
}
}
const chosen = routeLines.find((r) => r.routeLineId === routeLineId)!;
if (orderTotal > chosen.remainingQuantity) {
throw new BadRequestException(
`Requested ${line.quantity} exceeds remaining ${poolLine.remainingQuantity}` +
(poolLine.containerTypeName ? ` for ${poolLine.containerTypeName}` : ''),
`Requested ${orderTotal} exceeds remaining ${chosen.remainingQuantity} for this route`,
);
}
} else {
// Single-route: validate each line against the per-container-type pool.
const poolLines = await this.generalContractService.getQuantityLines(
contract.id,
);
for (const line of dto.lines) {
if (line.quantity <= 0) {
throw new BadRequestException('Order quantities must be greater than zero');
}
const key = isContainer ? (line.containerTypeId ?? '') : '';
const poolLine = poolLines.find((p) => (p.containerTypeId ?? '') === key);
if (!poolLine) {
throw new BadRequestException(
isContainer
? `Container type ${line.containerTypeId} is not part of this contract`
: 'This contract has no matching quantity pool',
);
}
if (line.quantity > poolLine.remainingQuantity) {
throw new BadRequestException(
`Requested ${line.quantity} exceeds remaining ${poolLine.remainingQuantity}` +
(poolLine.containerTypeName ? ` for ${poolLine.containerTypeName}` : ''),
);
}
}
}
// Persist the order + its child shipment booking atomically.
const order = await this.dataSource.transaction(async (manager) => {
const childBooking = await this.spawnChildBooking(contract, dto, manager);
const childBooking = await this.spawnChildBooking(
contract,
dto,
{ originYardId, destinationYardId, km: routeKm },
manager,
);
const reference = await this.generateReference();
const orderRow = manager.create(BookingOrder, {
reference,
contractBookingId: contract.id,
bookingId: childBooking.id,
routeLineId,
companyId: contract.companyId ?? null,
scheduledDate: new Date(dto.scheduledDate),
status: 'PAID',
// The order is a ledger row; the child booking drives the workflow
// (review → pay → allocate), so the order tracks PENDING until done.
status: 'PENDING',
schedulingStatus: 'NOT_SCHEDULED',
});
const savedOrder = await manager.save(orderRow);
@@ -140,6 +211,8 @@ export class BookingOrdersService {
orderId: savedOrder.id,
containerTypeId: isContainer ? (l.containerTypeId ?? null) : null,
quantity: l.quantity,
hazardousQuantity: l.hazardousQuantity ?? 0,
reeferQuantity: l.reeferQuantity ?? 0,
}),
);
await manager.save(lines);
@@ -147,20 +220,12 @@ export class BookingOrdersService {
return savedOrder;
});
// Feed the child booking into the day-pool batch so it allocates to a train.
try {
await this.bookingBatchService.processRouteDay({
originYardId: contract.originYardId,
destinationYardId: contract.destinationYardId,
day,
});
} catch (err) {
this.logger.error(
`Batch fill after order ${order.reference} failed: ${err instanceof Error ? err.message : String(err)}`,
);
}
// The child does NOT enter the train batch pool here. It is priced and
// unpaid, awaiting Marketing review (OPERATION_REQUEST_PENDING) or customs
// clearance first; the batch enqueue happens only on accept.
// Close the contract once its pool is exhausted.
// Close the contract once its pool is exhausted (pending orders count, so
// the pool reserves quantity as soon as an order is placed).
if (await this.generalContractService.isExhausted(contract.id)) {
await this.dataSource
.getRepository(Booking)
@@ -175,15 +240,18 @@ export class BookingOrdersService {
/**
* Create the ONE_TIME child booking for an order, inheriting the contract's
* shipment context and entering the queue already PAID + FULLY_EXECUTED.
* shipment context. Unlike the contract (which is no longer paid up front),
* the child is PRICED and UNPAID and waits for Marketing review — going
* through the customs clearance gate first when the service includes customs,
* mirroring a one-time booking. It only enters the train pool on accept.
*/
private async spawnChildBooking(
contract: Booking,
dto: CreateBookingOrderDto,
route: { originYardId: string; destinationYardId: string; km: number | null },
manager: import('typeorm').EntityManager,
): Promise<Booking> {
const reference = await this.generateChildBookingReference();
const now = new Date();
const isContainer = contract.freightType === 'CONTAINER';
// Sum line quantities × the contract's per-unit weight for the child total.
@@ -201,6 +269,18 @@ export class BookingOrdersService {
totalWeight = dto.lines.reduce((sum, l) => sum + l.quantity, 0);
}
// Per-order hazardous/reefer: set the child flags from the order's line
// counts so the HAZARD_SURCHARGE / REEFER_SURCHARGE rates apply.
const hasHazardous = dto.lines.some((l) => (l.hazardousQuantity ?? 0) > 0);
const hasReefer = dto.lines.some((l) => (l.reeferQuantity ?? 0) > 0);
// Customs orders flow through the one-time clearance gate first; others go
// straight to operations review with the chosen shipment day.
const { includesCustoms } = clearanceCodesForBooking(contract);
const spawnStatus = includesCustoms
? 'AWAITING_DOCUMENTS'
: 'OPERATION_REQUEST_PENDING';
const child = manager.create(Booking, {
reference,
companyId: contract.companyId ?? null,
@@ -213,27 +293,24 @@ export class BookingOrdersService {
firstMilePickupAddress: contract.firstMilePickupAddress ?? null,
lastMileDeliveryAddress: contract.lastMileDeliveryAddress ?? null,
equipmentReturn: contract.equipmentReturn,
originYardId: contract.originYardId,
destinationYardId: contract.destinationYardId,
originYardId: route.originYardId,
destinationYardId: route.destinationYardId,
tradeDirection: contract.tradeDirection,
freightType: contract.freightType,
cargoTypeId: contract.cargoTypeId ?? null,
cargoFreeText: contract.cargoFreeText ?? null,
shippingLineId: contract.shippingLineId ?? null,
cargoTotalWeightVgm: totalWeight,
isHazardous: contract.isHazardous,
isHazardous: hasHazardous,
isReefer: hasReefer,
paymentCurrency: contract.paymentCurrency,
bookingType: 'ONE_TIME',
scheduledDate: new Date(dto.scheduledDate),
// Already covered by the contract's one-time payment: enter the pool ready
// and paid so the batch engine reserves → allocates it immediately.
status: 'FULLY_EXECUTED',
paymentStatus: 'PAID',
fullyExecutedAt: now,
customerSignedAt: now,
// Priced + unpaid: the customer pays this order on its own.
status: spawnStatus,
paymentStatus: 'PENDING',
priorityScore: contract.priorityScore,
totalAmount: 0,
allowConsolidation: false,
schedulingStatus: 'NOT_SCHEDULED',
});
const savedChild = await manager.save(child);
@@ -261,9 +338,79 @@ export class BookingOrdersService {
}
}
// Price the order: base freight for the drawn quantity + haz/reefer
// surcharges, plus a road KM charge when the service ships by road.
const roadKm = isRoadService(contract.serviceType) ? route.km : null;
await this.priceChildBooking(savedChild.id, roadKm, manager);
return savedChild;
}
/**
* Compute and persist the child order's price (base + surcharges) inside the
* order transaction. The contract is no longer paid up front, so each order
* carries its own total that the customer pays.
*/
private async priceChildBooking(
childId: string,
roadKm: number | null,
manager: import('typeorm').EntityManager,
): Promise<void> {
const child = await manager.getRepository(Booking).findOne({
where: { id: childId },
relations: { bookingContainers: true },
});
if (!child) return;
try {
const computed = await this.pricingService.computePriceForBooking(child);
const lineItems = [...computed.lineItems];
let total = computed.totalAmount;
// Road KM charge: distance × the live PER_KM rate, added as its own line.
if (roadKm && roadKm > 0) {
const perKmRate = await this.findPerKmRate(child.paymentCurrency);
const kmAmount = roadKmPrice(roadKm, perKmRate);
if (kmAmount > 0) {
lineItems.push({
code: 'ROAD_KM',
description: `Road transport (${roadKm} km)`,
amount: kmAmount,
unitAmount: perKmRate!,
unit: 'PER_KM',
quantity: roadKm,
currency: child.paymentCurrency,
});
total += kmAmount;
}
}
await manager.getRepository(Booking).update(childId, {
totalAmount: total,
priorityScore: computed.priorityScore,
pricingBreakdown: {
lineItems,
totalAmount: total,
currency: computed.currency,
generatedAt: new Date().toISOString(),
},
} as never);
} catch (err) {
this.logger.error(
`Pricing child order ${childId} failed: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
/** The live PER_KM rate value for road billing, in the given currency. */
private async findPerKmRate(currency: string): Promise<number | null> {
const rates = await this.ratesService.findLiveRates();
const rate = rates.find(
(r) => r.rateUnit === 'PER_KM' && r.currency === currency,
);
return rate ? Number(rate.rateValue) : null;
}
private async userOwnsContract(
userId: string,
contract: Booking,

View File

@@ -21,3 +21,39 @@ export class ContractQuantityLineView {
@ApiProperty()
remainingQuantity!: number;
}
/** A contracted/ordered/remaining pool line for one route of a general contract. */
export class ContractRouteLineView {
@ApiProperty({ description: 'Contract route line id' })
routeLineId!: string;
@ApiProperty()
originYardId!: string;
@ApiProperty({ nullable: true })
originYardName!: string | null;
@ApiProperty()
destinationYardId!: string;
@ApiProperty({ nullable: true })
destinationYardName!: string | null;
@ApiProperty({ nullable: true, description: 'Container type id (null for bulk/break-bulk)' })
containerTypeId!: string | null;
@ApiProperty({ nullable: true })
containerTypeName!: string | null;
@ApiProperty()
contractedQuantity!: number;
@ApiProperty()
orderedQuantity!: number;
@ApiProperty()
remainingQuantity!: number;
@ApiProperty({ nullable: true, description: 'Road distance (km); used to bill road orders' })
km!: number | null;
}

View File

@@ -0,0 +1,28 @@
import 'reflect-metadata';
import { plainToInstance } from 'class-transformer';
import { CreateBookingOrderLineDto } from './create-booking-order.dto';
/**
* Order line haz/reefer quantities arrive as JSON numbers but must default to 0
* when omitted and coerce string inputs (defensive) to numbers.
*/
describe('CreateBookingOrderLineDto — haz/reefer coercion', () => {
const toDto = (plain: Record<string, unknown>) =>
plainToInstance(CreateBookingOrderLineDto, plain, {
enableImplicitConversion: false,
exposeDefaultValues: true,
}) as unknown as CreateBookingOrderLineDto;
it('defaults hazardous/reefer quantities to 0 when omitted', () => {
const dto = toDto({ quantity: 5 });
expect(dto.hazardousQuantity).toBe(0);
expect(dto.reeferQuantity).toBe(0);
});
it('coerces provided string quantities to numbers', () => {
const dto = toDto({ quantity: '5', hazardousQuantity: '2', reeferQuantity: '3' });
expect(dto.quantity).toBe(5);
expect(dto.hazardousQuantity).toBe(2);
expect(dto.reeferQuantity).toBe(3);
});
});

View File

@@ -25,6 +25,26 @@ export class CreateBookingOrderLineDto {
@Min(0)
@Transform(({ value }) => Number(value))
quantity!: number;
@ApiPropertyOptional({
description: 'How much of this line is hazardous (≤ quantity). Defaults to 0.',
minimum: 0,
})
@IsOptional()
@IsNumber()
@Min(0)
@Transform(({ value }) => Number(value ?? 0))
hazardousQuantity?: number = 0;
@ApiPropertyOptional({
description: 'How much of this line is refrigerated (≤ quantity). Defaults to 0.',
minimum: 0,
})
@IsOptional()
@IsNumber()
@Min(0)
@Transform(({ value }) => Number(value ?? 0))
reeferQuantity?: number = 0;
}
export class CreateBookingOrderDto {
@@ -32,6 +52,16 @@ export class CreateBookingOrderDto {
@IsUUID()
contractBookingId!: string;
@ApiPropertyOptional({
format: 'uuid',
description:
'For multi-route contracts: the contract route line being drawn from. ' +
'Determines the shipment origin/destination. Omit for single-route contracts.',
})
@IsOptional()
@IsUUID()
routeLineId?: string;
@ApiProperty({ example: '2026-07-01T00:00:00.000Z', description: 'Shipment day for this order' })
@IsDateString()
scheduledDate!: string;

View File

@@ -27,4 +27,15 @@ export class BookingOrderLine extends BaseEntity {
/** Containers (count), tons, or items depending on the contract's freight/UoM. */
@Column({ name: 'quantity', type: 'numeric', precision: 12, scale: 3 })
quantity!: number;
/**
* How much of this line is hazardous / refrigerated, entered per order by the
* customer when they toggle the flag. Drives the HAZARD_SURCHARGE /
* REEFER_SURCHARGE rates on the spawned child booking. Both ≤ quantity.
*/
@Column({ name: 'hazardous_quantity', type: 'numeric', precision: 12, scale: 3, default: 0 })
hazardousQuantity!: number;
@Column({ name: 'reefer_quantity', type: 'numeric', precision: 12, scale: 3, default: 0 })
reeferQuantity!: number;
}

View File

@@ -40,6 +40,14 @@ export class BookingOrder extends BaseEntity {
@JoinColumn({ name: 'company_id' })
company?: Company | null;
/**
* The contract route line this order drew down (multi-route general contracts).
* Null for legacy/single-route contracts that have no route lines — the order
* then uses the contract's own origin/destination.
*/
@Column({ name: 'route_line_id', type: 'uuid', nullable: true })
routeLineId?: string | null;
@Column({ name: 'scheduled_date', type: 'timestamptz' })
scheduledDate!: Date;

View File

@@ -0,0 +1,61 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { Booking } from '../../bookings/entities/booking.entity';
import { ContainerType } from '../../rule-engine/entities/container-type.entity';
import { Yard } from '../../rule-engine/entities/yard.entity';
/**
* One contracted route+quantity line of a GENERAL contract. A general contract
* may span several routes (e.g. Addis→Dire Dawa: 10, Modjo→Djibouti: 5); each
* route reserves its own quantity pool. Drawdown orders pick one of these routes
* and decrement that route's pool. One-time bookings do not use this — they keep
* the single origin/destination on the booking itself.
*/
@Entity({ schema: 'freight', name: 'contract_route_lines' })
@Index(['contractBookingId'])
export class ContractRouteLine extends BaseEntity {
/** The general contract (a Booking with bookingType = GENERAL_CONTRACT). */
@Column({ name: 'contract_booking_id', type: 'uuid' })
contractBookingId!: string;
@ManyToOne(() => Booking)
@JoinColumn({ name: 'contract_booking_id' })
contractBooking?: Booking;
@Column({ name: 'origin_yard_id', type: 'uuid' })
originYardId!: string;
@ManyToOne(() => Yard)
@JoinColumn({ name: 'origin_yard_id' })
originYard?: Yard;
@Column({ name: 'destination_yard_id', type: 'uuid' })
destinationYardId!: string;
@ManyToOne(() => Yard)
@JoinColumn({ name: 'destination_yard_id' })
destinationYard?: Yard;
/**
* Container type this route line reserves (CONTAINER contracts); null for
* BULK/BREAK_BULK, where the quantity is tons/items.
*/
@Column({ name: 'container_type_id', type: 'uuid', nullable: true })
containerTypeId?: string | null;
@ManyToOne(() => ContainerType, { nullable: true })
@JoinColumn({ name: 'container_type_id' })
containerType?: ContainerType | null;
/** Contracted quantity for this (route, container type): containers, tons, or items. */
@Column({ name: 'quantity', type: 'numeric', precision: 12, scale: 3 })
quantity!: number;
/**
* Road distance for this route, configured with the route. Road (truck)
* drawdown orders bill KM × the PER_KM rate from this value. Null for
* rail-only routes where KM is not billed.
*/
@Column({ name: 'km', type: 'numeric', precision: 10, scale: 2, nullable: true })
km?: number | null;
}

View File

@@ -4,7 +4,11 @@ import { DataSource } from 'typeorm';
import { DropdownSettingsService } from '../dropdown-settings/dropdown-settings.service';
import { Booking } from '../bookings/entities/booking.entity';
import { BookingOrder } from './entities/booking-order.entity';
import { ContractQuantityLineView } from './dto/contract-view.dto';
import { ContractRouteLine } from './entities/contract-route-line.entity';
import {
ContractQuantityLineView,
ContractRouteLineView,
} from './dto/contract-view.dto';
/** Setting code holding the global ordering window (in months) for general contracts. */
export const CONTRACT_PERIOD_SETTING_CODE = 'general_contract_period';
@@ -120,6 +124,70 @@ export class GeneralContractService {
];
}
/**
* Per-route drawdown pool for a multi-route general contract: contracted vs.
* ordered vs. remaining, one entry per contracted route line. Returns [] for
* single-route contracts (no route lines) — callers fall back to
* {@link getQuantityLines}.
*/
async getRouteLines(
contractBookingId: string,
): Promise<ContractRouteLineView[]> {
const routeLines = await this.dataSource
.getRepository(ContractRouteLine)
.find({
where: { contractBookingId },
relations: {
originYard: true,
destinationYard: true,
containerType: true,
},
order: { createdAt: 'ASC' },
});
if (routeLines.length === 0) return [];
const ordered = await this.orderedByRouteLine(contractBookingId);
return routeLines.map((rl) => {
const orderedQty = ordered.get(rl.id) ?? 0;
const contracted = Number(rl.quantity);
return {
routeLineId: rl.id,
originYardId: rl.originYardId,
originYardName: rl.originYard?.label ?? null,
destinationYardId: rl.destinationYardId,
destinationYardName: rl.destinationYard?.label ?? null,
containerTypeId: rl.containerTypeId ?? null,
containerTypeName: rl.containerType?.label ?? null,
contractedQuantity: contracted,
orderedQuantity: orderedQty,
remainingQuantity: Math.max(0, contracted - orderedQty),
km: rl.km != null ? Number(rl.km) : null,
};
});
}
/** Sum of non-cancelled order quantities, keyed by route_line_id. */
private async orderedByRouteLine(
contractBookingId: string,
): Promise<Map<string, number>> {
const rows = await this.dataSource
.getRepository(BookingOrder)
.createQueryBuilder('o')
.innerJoin('o.lines', 'line')
.select('o.route_line_id', 'key')
.addSelect('SUM(line.quantity)', 'total')
.where('o.contract_booking_id = :contractBookingId', { contractBookingId })
.andWhere('o.route_line_id IS NOT NULL')
.andWhere(`o.status NOT IN ('CANCELLED', 'REJECTED')`)
.groupBy('o.route_line_id')
.getRawMany<{ key: string; total: string }>();
const map = new Map<string, number>();
for (const row of rows) if (row.key) map.set(row.key, Number(row.total));
return map;
}
/** Sum of non-cancelled order line quantities, keyed by container type id ('' = bulk). */
private async orderedByContainerType(
contractBookingId: string,
@@ -155,6 +223,12 @@ export class GeneralContractService {
/** True once every contracted line is fully drawn down. */
async isExhausted(contractBookingId: string): Promise<boolean> {
// Multi-route contracts are exhausted when every route line is drawn down;
// single-route contracts fall back to the per-container-type pool.
const routeLines = await this.getRouteLines(contractBookingId);
if (routeLines.length > 0) {
return routeLines.every((l) => l.remainingQuantity <= 0);
}
const lines = await this.getQuantityLines(contractBookingId);
return lines.every((l) => l.remainingQuantity <= 0);
}

View File

@@ -0,0 +1,32 @@
import { isRoadService, roadKmPrice } from './road.util';
describe('road.util', () => {
describe('isRoadService', () => {
it('treats ROAD/TRUCK codes (and prefixes) as road', () => {
expect(isRoadService({ code: 'ROAD' })).toBe(true);
expect(isRoadService({ code: 'TRUCK' })).toBe(true);
expect(isRoadService({ code: 'ROAD_CONTAINER' })).toBe(true);
expect(isRoadService({ code: 'truck_forwarding' })).toBe(true);
});
it('treats rail / unknown / missing services as not road', () => {
expect(isRoadService({ code: 'RAIL_CONTAINER' })).toBe(false);
expect(isRoadService({ code: 'OFFROADING' })).toBe(false);
expect(isRoadService(null)).toBe(false);
expect(isRoadService(undefined)).toBe(false);
});
});
describe('roadKmPrice', () => {
it('multiplies distance by the per-km rate', () => {
expect(roadKmPrice(120, 5)).toBe(600);
});
it('returns 0 when km or rate is missing/non-positive', () => {
expect(roadKmPrice(null, 5)).toBe(0);
expect(roadKmPrice(120, null)).toBe(0);
expect(roadKmPrice(0, 5)).toBe(0);
expect(roadKmPrice(120, 0)).toBe(0);
});
});
});

View File

@@ -0,0 +1,35 @@
import { ServiceType } from '../rule-engine/entities/service-type.entity';
/**
* Road (truck) services are distinguished by their ServiceType.code. Rail
* services are seeded as RAIL_* and go through the train batch pool; a road
* service (code starting ROAD_ or TRUCK_, or exactly ROAD/TRUCK) instead bills
* by distance and dispatches a truck. Prefix-matching keeps this resilient to
* the exact seeded code (e.g. ROAD_CONTAINER, TRUCK_FORWARDING).
*/
export function isRoadService(
serviceType?: Pick<ServiceType, 'code'> | null,
): boolean {
const code = serviceType?.code?.toUpperCase() ?? '';
return (
code === 'ROAD' ||
code === 'TRUCK' ||
code.startsWith('ROAD_') ||
code.startsWith('TRUCK_')
);
}
/**
* Road freight charge for an order: distance (km, from the route line) × the
* per-km rate. Returns 0 when either input is missing so callers can add it to
* a total without guarding.
*/
export function roadKmPrice(
km: number | null | undefined,
perKmRate: number | null | undefined,
): number {
const distance = Number(km ?? 0);
const rate = Number(perKmRate ?? 0);
if (!(distance > 0) || !(rate > 0)) return 0;
return distance * rate;
}

View File

@@ -19,9 +19,17 @@ import { FileRecord } from '../files/entities/file.entity';
import { BookingsRepository } from './bookings.repository';
import { Booking } from './entities/booking.entity';
import { assertBookingStatus } from './booking-status.util';
import { clearanceSettingCode } from './clearance.util';
import { ContractViewDto } from './dto/contract-view.dto';
import { SignContractDto } from './dto/sign-contract.dto';
import { ContractSignerRole } from './entities/booking-contract-signature.entity';
/**
* Default ordering window (months) for a general contract activated on
* counter-sign. Mirrors GeneralContractService.DEFAULT_CONTRACT_PERIOD_MONTHS;
* defined locally to avoid a circular module dependency on booking-orders.
*/
const DEFAULT_CONTRACT_PERIOD_MONTHS = 3;
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
import { SignaturesService } from '../signatures/signatures.service';
@@ -222,19 +230,45 @@ export class BookingContractService {
const updates: Record<string, unknown> = {};
// Whether a document-clearance gate applies (IMPORT/EXPORT bookings). When it
// does, the counter-signed booking goes to AWAITING_DOCUMENTS for the customer
// to upload clearance documents instead of straight into the batch pipeline.
const includesCustoms = booking.serviceType?.includesCustoms ?? false;
const clearanceCode = clearanceSettingCode(
booking.tradeDirection,
booking.freightType,
includesCustoms,
);
const isGeneralContract = booking.bookingType === 'GENERAL_CONTRACT';
if (role === 'CUSTOMER') {
updates.status = 'SIGNED_CUSTOMER';
updates.customerSignedAt = now;
} else {
updates.status = 'FULLY_EXECUTED';
} else if (isGeneralContract) {
// A general contract is NOT paid up front — each drawdown order is priced
// and paid on its own. So on counter-sign it becomes ACTIVE directly and
// opens its ordering window; orders spawn their own priced child bookings.
const expiresAt = new Date(now);
expiresAt.setMonth(expiresAt.getMonth() + DEFAULT_CONTRACT_PERIOD_MONTHS);
updates.fullyExecutedAt = now;
updates.marketingApprovedAt = now;
updates.marketingApprovedById = options.signerUserId ?? null;
updates.lockedAt = now;
updates.status = 'CONTRACT_ACTIVE';
updates.expiresAt = expiresAt;
} else {
updates.fullyExecutedAt = now;
updates.marketingApprovedAt = now;
updates.marketingApprovedById = options.signerUserId ?? null;
updates.lockedAt = now;
updates.status = clearanceCode ? 'AWAITING_DOCUMENTS' : 'FULLY_EXECUTED';
}
const updated = await this.bookingsRepository.update(bookingId, updates as never);
if (role === 'STAFF' && updated?.trainScheduleId) {
// Only the non-clearance (legacy/domestic) path enters the batch pipeline now;
// clearance bookings enter operations after the GL document gate.
if (role === 'STAFF' && !clearanceCode && updated?.trainScheduleId) {
this.bookingBatchService.enqueueScheduleProcessing(updated.trainScheduleId);
}
try {

View File

@@ -57,6 +57,45 @@ export function computeNextStep(
action: 'AWAIT_PAYMENT',
description: 'Awaiting customer payment',
};
case 'AWAITING_DOCUMENTS':
return {
action: 'UPLOAD_DOCUMENTS',
description: 'Upload the clearance documents for your shipment',
};
case 'DOCUMENTS_UNDER_REVIEW':
return {
action: 'AWAIT_DOCUMENT_REVIEW',
description: 'Global Logistics is reviewing your documents',
};
case 'CLEARANCE_READY':
return {
action: 'PROCEED_TO_OPERATION',
description:
'Clearance is ready — pick a schedule day and request operation',
};
case 'OPERATION_REQUEST_PENDING':
return {
action: 'AWAIT_OPERATION_REVIEW',
description:
'Operations is reviewing your request (capacity, documents, route)',
};
case 'OPERATION_CHANGES_REQUESTED':
return {
action: 'RESUBMIT_OPERATION',
description:
'Operations requested changes — update and resubmit your operation request',
};
case 'OPERATION_PRICE_PENDING_CONFIRM':
return {
action: 'CONFIRM_OPERATION_PRICE',
description:
'Operations adjusted the price — confirm the new total to proceed',
};
case 'OPERATION_REQUESTED':
return {
action: 'AWAIT_OPERATION',
description: 'Operation requested; an operator will take it forward',
};
case 'PAID':
return {
action: 'START_TRANSIT',

View File

@@ -44,7 +44,6 @@ describe('BookingPricingService — domestic corridor', () => {
{} as never,
{} as never,
ratesService as never,
{} as never,
exchangeService as never,
);
});

View File

@@ -2,7 +2,6 @@ import { Injectable, NotFoundException } from '@nestjs/common';
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
import { RatesService } from '../rule-engine/services/rates.service';
import { ServiceTypesService } from '../rule-engine/services/service-types.service';
import { Rate } from '../rule-engine/entities/rate.entity';
import { ExchangeService } from '@edr/api-common';
import {
@@ -11,6 +10,10 @@ import {
RuleEngineService,
} from '../rule-engine/rule-engine.service';
import { BookingsRepository } from './bookings.repository';
import {
containersPerWagon,
wagonRemainder,
} from './consolidation.service';
import { GeneratePriceResponseDto, PriceLineItemDto } from './dto/generate-price-response.dto';
import { Booking } from './entities/booking.entity';
import { assertBookingStatus } from './booking-status.util';
@@ -33,6 +36,29 @@ type StoredPricingBreakdown = {
generatedAt?: string;
} | null;
/** Friendly labels for the per-unit rate card shown at the confirm step. */
const SURCHARGE_LABELS: Record<string, string> = {
HAZARD_SURCHARGE: 'Hazardous cargo',
HAZARDOUS_CARGO: 'Hazardous cargo',
REEFER_SURCHARGE: 'Refrigerated (reefer)',
REEFER_CARGO: 'Refrigerated (reefer)',
OVERWEIGHT_PER_TON: 'Overweight excess',
DOUBLE_HANDLING: 'Double handling',
LASHING: 'Lashing',
PIL_EXTRA_FEE: 'Shipping line fee',
};
function surchargeLabel(code: string): string {
return (
SURCHARGE_LABELS[code] ??
code
.toLowerCase()
.split('_')
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(' ')
);
}
@Injectable()
export class BookingPricingService {
constructor(
@@ -40,7 +66,6 @@ export class BookingPricingService {
private readonly ruleEngineService: RuleEngineService,
private readonly containerTypesService: ContainerTypesService,
private readonly ratesService: RatesService,
private readonly serviceTypesService: ServiceTypesService,
private readonly exchangeService: ExchangeService,
) {}
@@ -103,16 +128,35 @@ export class BookingPricingService {
for (const mod of ruleResult.appliedModifiers) {
const usdAmount = mod.calculatedAmount;
const convertedAmount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount;
const rate = rateById.get(mod.rateId);
const unit = rate?.rateUnit ?? 'FLAT';
const unitUsd = rate ? Number(rate.rateValue) : usdAmount;
const unitAmount = isEtbBooking ? Math.round(unitUsd * usdToEtb) : unitUsd;
// Per-unit count: FLAT and PER_INVOICE are billed once (qty 1); an
// explicit trigger (e.g. overweight tons) wins when present; otherwise
// derive from total ÷ unit price.
const quantity =
unit === 'FLAT' || unit === 'PER_INVOICE'
? 1
: mod.triggerValue != null && mod.triggerValue > 0
? mod.triggerValue
: unitUsd > 0
? Math.max(1, Math.round(usdAmount / unitUsd))
: 1;
const item: PriceLineItemDto = {
code: mod.surchargeTypeCode,
description: `Surcharge: ${mod.surchargeTypeCode}`,
code: mod.surchargeCode,
description: surchargeLabel(mod.surchargeCode),
amount: convertedAmount,
unitAmount,
unit,
quantity,
currency: paymentCurrency,
};
lineItems.push(item);
total += convertedAmount;
const rate = rateById.get(mod.rateId);
if (rate) usedRatesMap.set(rate.id, rate);
}
@@ -152,7 +196,7 @@ export class BookingPricingService {
if (!snapshotId) return null;
return {
bookingId,
surchargeTypeId: m.surchargeTypeId,
rateId: m.rateId,
triggerValue: m.triggerValue,
calculatedAmount: m.calculatedAmount,
rateSnapshotId: snapshotId,
@@ -166,7 +210,7 @@ export class BookingPricingService {
}
async buildEvalInputForBooking(booking: Booking): Promise<BookingEvaluationInput> {
const containers = await Promise.all(
const lines = await Promise.all(
(booking.bookingContainers ?? [])
.filter((bc): bc is typeof bc & { containerTypeId: string } => bc.containerTypeId != null)
.map(async (bc) => {
@@ -174,14 +218,19 @@ export class BookingPricingService {
const vgm = Number(bc.vgmPerUnitTons);
const qty = bc.quantity;
return {
containerTypeId: bc.containerTypeId,
container: {
containerTypeId: bc.containerTypeId,
quantity: qty,
vgmPerUnitTons: vgm,
totalVgmTons: qty * vgm,
isReefer: ct.isReefer,
},
perWagon: containersPerWagon(Number(ct.wagonsPerUnit)),
quantity: qty,
vgmPerUnitTons: vgm,
totalVgmTons: qty * vgm,
isReefer: ct.isReefer,
};
}),
);
const containers = lines.map((l) => l.container);
// Wagon count is persisted per container line at booking creation; sum it.
const totalWagons =
booking.freightType === 'CONTAINER'
@@ -193,15 +242,38 @@ export class BookingPricingService {
)
: 0;
// Consolidation is system-managed: the CONSOLIDATION surcharge fires whenever
// a container type leaves a wagon partially filled. Aggregate by type first —
// two lines of the same type share wagons, so 2× 20FT (= one full wagon) must
// NOT count as a partial wagon. Mirrors ConsolidationService.slotsFromContainerLines.
const remainderByType = new Map<string, { quantity: number; perWagon: number }>();
for (const l of lines) {
const prev = remainderByType.get(l.container.containerTypeId);
remainderByType.set(l.container.containerTypeId, {
quantity: (prev?.quantity ?? 0) + Number(l.quantity || 0),
perWagon: l.perWagon,
});
}
const allowConsolidation =
booking.freightType === 'CONTAINER' &&
[...remainderByType.values()].some(
(t) => wagonRemainder(t.quantity, t.perWagon) > 0,
);
return {
freightType: booking.freightType as 'CONTAINER' | 'BULK',
cargoTypeId: booking.cargoTypeId ?? null,
serviceTypeId: booking.serviceTypeId,
paymentCurrency: booking.paymentCurrency,
tradeDirection: booking.tradeDirection,
isHazardous: booking.isHazardous,
// Coerce defensively in case the stored flag is a string ("true"/"false").
isHazardous: booking.isHazardous === true || (booking.isHazardous as unknown) === 'true',
// Booking-level reefer flag (set by contract drawdown orders that carry a
// reefer quantity) applies the REEFER surcharge even for non-reefer
// container types. ORed with per-container reefer in the engine.
isReefer: booking.isReefer === true || (booking.isReefer as unknown) === 'true',
isGovernment: booking.isGovernment,
allowConsolidation: booking.allowConsolidation,
allowConsolidation,
shippingLineId: booking.shippingLineId,
totalWagons,
containers,
@@ -240,6 +312,9 @@ export class BookingPricingService {
code: 'TOTAL',
description: 'Contract total',
amount: total,
unitAmount: total,
unit: 'FLAT',
quantity: 1,
currency: booking.paymentCurrency,
},
],
@@ -255,27 +330,18 @@ export class BookingPricingService {
};
}
/** Recompute priority on submit (USD + service tier). */
/**
* Recompute priority on submit.
*
* The full priority model is additive and capped at 100:
* service-type bonus (≤ 15) + wagon block (≤ 50) + currency block (≤ 35).
* All three components are produced by RuleEngineService.evaluate, so submit
* simply re-runs the engine — there is no extra submit-time inflation.
*/
async computeSubmitPriorityScore(booking: Booking): Promise<number> {
const evalInput = await this.buildEvalInputForBooking(booking);
const ruleResult = await this.ruleEngineService.evaluate(evalInput);
let score = ruleResult.priorityScore;
const serviceType = await this.serviceTypesService.findById(booking.serviceTypeId);
if (booking.paymentCurrency === 'USD' && serviceType) {
const code = (serviceType.code ?? '').toUpperCase();
const hasForwarding =
serviceType.includesFirstMile ||
serviceType.includesLastMile ||
code.includes('FORWARD') ||
code.includes('Y');
const railOnly = code.includes('RAIL') && !hasForwarding;
if (hasForwarding) score += 1000;
else if (railOnly || code.includes('X')) score += 500;
}
return score;
return ruleResult.priorityScore;
}
private async computeBaseRailLinesWithRates(
@@ -312,10 +378,15 @@ export class BookingPricingService {
usedRatesMap.set(rate.id, rate);
const usdAmount = this.amountForRate(rate, container.quantity, wagonCount);
const amount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount;
const unitUsd = Number(rate.rateValue);
const label = await this.containerTypeLabel(container.containerTypeId);
lines.push({
code: rateType,
description: `Base rail (${rateType})`,
description: `${label} rail freight`,
amount,
unitAmount: isEtbBooking ? Math.round(unitUsd * usdToEtb) : unitUsd,
unit: rate.rateUnit,
quantity: this.effectiveUnitQuantity(rate.rateUnit, container.quantity, wagonCount),
currency: paymentCurrency,
});
}
@@ -331,10 +402,14 @@ export class BookingPricingService {
isBulk && fallback.rateUnit === 'PER_TON' ? Math.max(bulkTons, 0) : 1;
const usdAmount = this.amountForRate(fallback, quantity, wagonCount);
const amount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount;
const unitUsd = Number(fallback.rateValue);
lines.push({
code: rateType,
description: `Base rail (${rateType})`,
description: isBulk ? 'Bulk rail freight' : 'Container rail freight',
amount,
unitAmount: isEtbBooking ? Math.round(unitUsd * usdToEtb) : unitUsd,
unit: fallback.rateUnit,
quantity: this.effectiveUnitQuantity(fallback.rateUnit, quantity, wagonCount),
currency: paymentCurrency,
});
}
@@ -343,6 +418,34 @@ export class BookingPricingService {
return { lineItems: lines, usedRates: [...usedRatesMap.values()] };
}
/** Friendly container-type label for the per-unit card; degrades to "Container". */
private async containerTypeLabel(containerTypeId: string): Promise<string> {
try {
const ct = await this.containerTypesService?.findById?.(containerTypeId);
return ct?.label ?? 'Container';
} catch {
return 'Container';
}
}
/** How many units a rate's total is divided into, by rate unit (for the per-unit card). */
private effectiveUnitQuantity(
rateUnit: string,
quantity: number,
wagonCount: number,
): number {
switch (rateUnit) {
case 'PER_WAGON':
return wagonCount;
case 'FLAT':
return 1;
case 'PER_CONTAINER':
case 'PER_TON':
default:
return quantity;
}
}
private pickRate(
rates: Rate[],
rateType: string,

View File

@@ -0,0 +1,85 @@
import { BadRequestException } from '@nestjs/common';
import { BookingTransitionService } from './booking-transition.service';
/**
* Focused tests for the contract validity window set at the accept step.
* The backoffice must supply a number of days; the window runs from the accept
* moment through accept + N days.
*/
describe('BookingTransitionService — acceptIntake validity window', () => {
const booking = {
id: 'b-1',
status: 'SUBMITTED',
freightType: 'CONTAINER',
cargoTypeId: null,
};
function makeService() {
const bookingsRepository = {
update: jest.fn().mockResolvedValue({ id: 'b-1' }),
};
const bookingsService = {
findById: jest.fn().mockResolvedValue(booking),
};
const ruleEngineService = {
instantiateApprovalSteps: jest.fn().mockResolvedValue([]),
};
const service = new BookingTransitionService(
bookingsRepository as never,
ruleEngineService as never,
{} as never, // pricingService
{} as never, // contractService
{} as never, // filesService
{} as never, // fileUploadSettingsService
{} as never, // bookingBatchService
bookingsService as never,
);
return { service, bookingsRepository, ruleEngineService };
}
it('rejects accept when validity days is missing or non-positive', async () => {
const { service } = makeService();
await expect(
service.acceptIntake('b-1', 'staff-1', 0),
).rejects.toBeInstanceOf(BadRequestException);
await expect(
service.acceptIntake('b-1', 'staff-1', -5),
).rejects.toBeInstanceOf(BadRequestException);
await expect(
service.acceptIntake('b-1', 'staff-1', 1.5),
).rejects.toBeInstanceOf(BadRequestException);
});
it('sets a validity window of validFrom..validFrom + N days', async () => {
const { service, bookingsRepository } = makeService();
await service.acceptIntake('b-1', 'staff-1', 10);
expect(bookingsRepository.update).toHaveBeenCalledTimes(1);
const [id, updates] = bookingsRepository.update.mock.calls[0];
expect(id).toBe('b-1');
expect(updates).toMatchObject({
status: 'PENDING_APPROVAL',
approvedByStaffId: 'staff-1',
contractValidityDays: 10,
});
const from = updates.contractValidFrom as Date;
const until = updates.contractValidUntil as Date;
const diffDays = Math.round(
(until.getTime() - from.getTime()) / (1000 * 60 * 60 * 24),
);
expect(diffDays).toBe(10);
// The accept timestamp and the validity start are the same moment.
expect((updates.approvedByStaffAt as Date).getTime()).toBe(from.getTime());
});
it('instantiates the approval chain when accepting', async () => {
const { service, ruleEngineService } = makeService();
await service.acceptIntake('b-1', 'staff-1', 30);
expect(ruleEngineService.instantiateApprovalSteps).toHaveBeenCalledWith(
'b-1',
expect.objectContaining({ freightType: 'CONTAINER' }),
);
});
});

View File

@@ -0,0 +1,77 @@
import { BadRequestException } from '@nestjs/common';
import { BookingTransitionService } from './booking-transition.service';
/**
* Focused tests for the clearance 100%-approved gate in finalizeClearance.
* Uses minimal stubs for the service's collaborators.
*/
describe('BookingTransitionService — finalizeClearance gate', () => {
const booking = {
id: 'b-1',
status: 'DOCUMENTS_UNDER_REVIEW',
tradeDirection: 'IMPORT',
freightType: 'CONTAINER',
serviceType: { includesCustoms: false }, // no output set → only the input gate
};
// Input set has two required docs.
const inputSetting = {
code: 'clearance_import_container_without_customs',
fields: [
{ fileKey: 'commercial_invoice', isRequired: true },
{ fileKey: 'packing_list', isRequired: true },
],
};
function makeService(reviews: Array<{ settingCode: string; fileKey: string; status: string }>) {
const bookingsRepository = {
findDocumentReviews: jest.fn().mockResolvedValue(reviews),
update: jest.fn().mockResolvedValue({ id: 'b-1' }),
};
const bookingsService = {
findById: jest.fn().mockResolvedValue(booking),
};
const fileUploadSettingsService = {
getByCode: jest.fn().mockResolvedValue(inputSetting),
};
const filesService = { findByResource: jest.fn().mockResolvedValue([]) };
const service = new BookingTransitionService(
bookingsRepository as never,
{} as never, // ruleEngineService
{} as never, // pricingService
{} as never, // contractService
filesService as never,
fileUploadSettingsService as never,
{} as never, // bookingBatchService
bookingsService as never,
);
return { service, bookingsRepository };
}
it('rejects when a required document is not APPROVED', async () => {
const { service } = makeService([
{
settingCode: inputSetting.code,
fileKey: 'commercial_invoice',
status: 'APPROVED',
},
// packing_list is still PENDING (missing approval)
]);
await expect(service.finalizeClearance('b-1')).rejects.toBeInstanceOf(
BadRequestException,
);
});
it('moves to CLEARANCE_READY when all required documents are APPROVED', async () => {
const { service, bookingsRepository } = makeService([
{ settingCode: inputSetting.code, fileKey: 'commercial_invoice', status: 'APPROVED' },
{ settingCode: inputSetting.code, fileKey: 'packing_list', status: 'APPROVED' },
]);
await service.finalizeClearance('b-1');
expect(bookingsRepository.update).toHaveBeenCalledWith(
'b-1',
expect.objectContaining({ status: 'CLEARANCE_READY' }),
);
});
});

View File

@@ -0,0 +1,95 @@
import { BadRequestException } from '@nestjs/common';
import { BookingTransitionService } from './booking-transition.service';
/**
* Operation-request review for general-contract drawdown orders:
* - ACCEPT a train order → FULLY_EXECUTED and enqueued into the batch pool.
* - ACCEPT a road order → ROAD_DISPATCH_PENDING, NOT enqueued.
* - REQUEST_CHANGES requires a note → OPERATION_CHANGES_REQUESTED.
* - ADJUST_PRICE sets the adjusted total → OPERATION_PRICE_PENDING_CONFIRM.
*/
describe('BookingTransitionService — operation review', () => {
function makeService(serviceTypeCode: string) {
const booking = {
id: 'b-1',
status: 'OPERATION_REQUEST_PENDING',
originYardId: 'o-1',
destinationYardId: 'd-1',
scheduledDate: new Date('2026-07-01T00:00:00.000Z'),
serviceType: { code: serviceTypeCode },
};
const bookingsRepository = {
update: jest.fn().mockResolvedValue({ id: 'b-1' }),
createReviewNote: jest.fn().mockResolvedValue(undefined),
};
const bookingsService = {
findById: jest.fn().mockResolvedValue(booking),
};
const bookingBatchService = {
enqueueRouteDayProcessing: jest.fn(),
};
const service = new BookingTransitionService(
bookingsRepository as never,
{} as never, // ruleEngineService
{} as never, // pricingService
{} as never, // contractService
{} as never, // filesService
{} as never, // fileUploadSettingsService
bookingBatchService as never,
bookingsService as never,
);
return { service, bookingsRepository, bookingBatchService };
}
it('ACCEPT of a train order → FULLY_EXECUTED and enqueues the batch pool', async () => {
const { service, bookingsRepository, bookingBatchService } =
makeService('RAIL_CONTAINER');
await service.reviewOperationRequest('b-1', 'ACCEPT', 'staff-1');
expect(bookingsRepository.update).toHaveBeenCalledWith(
'b-1',
expect.objectContaining({ status: 'FULLY_EXECUTED' }),
);
expect(bookingBatchService.enqueueRouteDayProcessing).toHaveBeenCalledTimes(1);
});
it('ACCEPT of a road order → ROAD_DISPATCH_PENDING and does NOT enqueue', async () => {
const { service, bookingsRepository, bookingBatchService } =
makeService('ROAD_CONTAINER');
await service.reviewOperationRequest('b-1', 'ACCEPT', 'staff-1');
expect(bookingsRepository.update).toHaveBeenCalledWith(
'b-1',
expect.objectContaining({ status: 'ROAD_DISPATCH_PENDING' }),
);
expect(bookingBatchService.enqueueRouteDayProcessing).not.toHaveBeenCalled();
});
it('REQUEST_CHANGES requires a note → OPERATION_CHANGES_REQUESTED', async () => {
const { service, bookingsRepository } = makeService('RAIL_CONTAINER');
await expect(
service.reviewOperationRequest('b-1', 'REQUEST_CHANGES', 'staff-1', {}),
).rejects.toBeInstanceOf(BadRequestException);
await service.reviewOperationRequest('b-1', 'REQUEST_CHANGES', 'staff-1', {
note: 'Fix the schedule',
});
expect(bookingsRepository.update).toHaveBeenCalledWith(
'b-1',
expect.objectContaining({ status: 'OPERATION_CHANGES_REQUESTED' }),
);
});
it('ADJUST_PRICE sets the adjusted total → OPERATION_PRICE_PENDING_CONFIRM', async () => {
const { service, bookingsRepository } = makeService('RAIL_CONTAINER');
await service.reviewOperationRequest('b-1', 'ADJUST_PRICE', 'staff-1', {
amount: 1500,
});
expect(bookingsRepository.update).toHaveBeenCalledWith(
'b-1',
expect.objectContaining({
adjustedTotalAmount: 1500,
status: 'OPERATION_PRICE_PENDING_CONFIRM',
}),
);
});
});

View File

@@ -7,11 +7,17 @@ import {
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import { assertCanApproveBookingStep } from '../../common/freight-permission.util';
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
import { eatDay } from '../train-scheduling/batch-window.util';
import { isRoadService } from '../booking-orders/road.util';
import { RuleEngineService } from '../rule-engine/rule-engine.service';
import { FilesService } from '../files/files.service';
import { FileUploadSettingsService } from '../file-upload-settings/file-upload-settings.service';
import { BookingContractService } from './booking-contract.service';
import { BookingPricingService } from './booking-pricing.service';
import { BookingsRepository } from './bookings.repository';
import { assertBookingStatus } from './booking-status.util';
import { clearanceCodesForBooking } from './clearance.util';
import { computeNextStep, type BookingNextStep } from './booking-next-step.util';
import { SubmitBookingResponseDto } from './dto/submit-booking-response.dto';
import { PriceLineItemDto } from './dto/generate-price-response.dto';
@@ -25,6 +31,10 @@ export class BookingTransitionService {
private readonly ruleEngineService: RuleEngineService,
private readonly pricingService: BookingPricingService,
private readonly contractService: BookingContractService,
private readonly filesService: FilesService,
private readonly fileUploadSettingsService: FileUploadSettingsService,
@Inject(forwardRef(() => BookingBatchService))
private readonly bookingBatchService: BookingBatchService,
@Inject(forwardRef(() => BookingsService))
private readonly bookingsService: BookingsService,
) {}
@@ -193,13 +203,31 @@ export class BookingTransitionService {
});
}
async acceptIntake(bookingId: string, actorId: string): Promise<Booking> {
async acceptIntake(
bookingId: string,
actorId: string,
validityDays: number,
): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
// Only SUBMITTED bookings are acceptable. A booking that still needs
// consolidation sits in PENDING_CONSOLIDATION (resolved at submit time) and
// is therefore never offered for accept until a partner moves it to SUBMITTED.
assertBookingStatus(booking, ['SUBMITTED']);
// The backoffice must define how long the accepted contract stays valid.
// Without a window the contract has no end date and cannot be relied on, so
// accept is blocked until a positive number of days is supplied.
if (!Number.isInteger(validityDays) || validityDays < 1) {
throw new BadRequestException(
'A contract validity (in days) is required to accept this booking.',
);
}
// Validity runs from the accept moment through accept + N days.
const validFrom = new Date();
const validUntil = new Date(validFrom);
validUntil.setDate(validUntil.getDate() + validityDays);
await this.ruleEngineService.instantiateApprovalSteps(bookingId, {
freightType: booking.freightType as 'CONTAINER' | 'BULK',
cargoTypeId: booking.cargoTypeId,
@@ -208,7 +236,10 @@ export class BookingTransitionService {
const updated = await this.bookingsRepository.update(bookingId, {
status: 'PENDING_APPROVAL',
approvedByStaffId: actorId,
approvedByStaffAt: new Date(),
approvedByStaffAt: validFrom,
contractValidityDays: validityDays,
contractValidFrom: validFrom,
contractValidUntil: validUntil,
} as never);
return this.bookingsService.findById(updated!.id);
}
@@ -420,6 +451,472 @@ export class BookingTransitionService {
return this.bookingsService.findById(updated!.id);
}
/**
* Customer rejects the priced booking at the confirm step. The booking becomes
* REJECTED (terminal) — the customer starts a new booking rather than editing
* this one. Only a not-yet-committed booking can be rejected this way.
*/
async reject(bookingId: string, reason?: string): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, [
'DRAFT',
'SUBMITTED',
'PRICE_CHANGED_PENDING_CONFIRM',
'PENDING_CONSOLIDATION',
]);
await this.bookingsRepository.createReviewNote(
bookingId,
reason?.trim() || 'Customer rejected the price estimate.',
'REJECTION',
);
const updated = await this.bookingsRepository.update(bookingId, {
status: 'REJECTED',
} as never);
return this.bookingsService.findById(updated!.id);
}
/**
* Staff adjusts a booking's total price. Stores an override (with who/when/why)
* that supersedes the computed total for the customer, who sees an
* "Adjusted by EDR" badge. Passing null clears the adjustment.
*/
async adjustPrice(
bookingId: string,
amount: number | null,
staffId: string,
reason?: string,
): Promise<Booking> {
await this.bookingsService.findById(bookingId);
if (amount != null && amount < 0) {
throw new BadRequestException('Adjusted amount cannot be negative');
}
await this.bookingsRepository.update(bookingId, {
adjustedTotalAmount: amount,
adjustedByStaffId: amount == null ? null : staffId,
adjustedAt: amount == null ? null : new Date(),
adjustmentReason: amount == null ? null : (reason ?? null),
} as never);
return this.bookingsService.findById(bookingId);
}
// ── Document clearance gate (post counter-sign) ───────────────────────────
/**
* The clearance document grid for a booking: each required field from the
* resolved customer-input set (and the GL-output set for customs) with its
* uploaded file and GL review status. Drives both portals' clearance UI.
*/
async getClearanceView(bookingId: string): Promise<{
status: string;
includesCustoms: boolean;
inputCode: string | null;
outputCode: string | null;
documents: Array<{
fileKey: string;
label: string;
required: boolean;
uploadedBy: 'customer' | 'gl';
settingCode: string;
file: { id: string; name: string; url: string } | null;
reviewStatus: 'PENDING' | 'APPROVED' | 'QUERIED' | null;
note: string | null;
}>;
allApproved: boolean;
}> {
const booking = await this.bookingsService.findById(bookingId);
const { inputCode, outputCode, includesCustoms } =
clearanceCodesForBooking(booking);
const files = await this.filesService.findByResource(bookingId, 'bookings');
const fileByCode = new Map(files.map((f) => [f.code, f]));
const reviews = await this.bookingsRepository.findDocumentReviews(bookingId);
const reviewByKey = new Map(
reviews.map((r) => [`${r.settingCode}:${r.fileKey}`, r]),
);
const documents: Awaited<
ReturnType<BookingTransitionService['getClearanceView']>
>['documents'] = [];
const pushSetting = async (
code: string | null,
uploadedBy: 'customer' | 'gl',
) => {
if (!code) return;
let setting;
try {
setting = await this.fileUploadSettingsService.getByCode(code);
} catch {
return; // setting not seeded — skip gracefully
}
for (const field of setting.fields ?? []) {
const file = fileByCode.get(field.fileKey) ?? null;
const review = reviewByKey.get(`${code}:${field.fileKey}`) ?? null;
documents.push({
fileKey: field.fileKey,
label: field.fileLabel,
required: field.isRequired,
uploadedBy,
settingCode: code,
file: file
? { id: file.id, name: file.name, url: file.url }
: null,
reviewStatus: review?.status ?? null,
note: review?.note ?? null,
});
}
};
await pushSetting(inputCode, 'customer');
await pushSetting(outputCode, 'gl');
// Ad-hoc / unknown documents (code custom_*) appear alongside the seeded set.
for (const f of files) {
if (!f.code?.startsWith('custom_')) continue;
const review = reviewByKey.get(`custom:${f.code}`) ?? null;
documents.push({
fileKey: f.code,
label: f.name,
required: false,
uploadedBy: 'customer',
settingCode: 'custom',
file: { id: f.id, name: f.name, url: f.url },
reviewStatus: review?.status ?? null,
note: review?.note ?? null,
});
}
const allApproved = await this.isClearanceFullyApproved(booking);
return {
status: booking.status,
includesCustoms,
inputCode,
outputCode,
documents,
allApproved,
};
}
/**
* True when every REQUIRED field of the booking's customer-input clearance set
* has an APPROVED review row. The 100% gate before clearance can be finalized.
*/
private async isClearanceFullyApproved(booking: Booking): Promise<boolean> {
const { inputCode } = clearanceCodesForBooking(booking);
if (!inputCode) return true; // no gate applies (e.g. domestic)
let setting;
try {
setting = await this.fileUploadSettingsService.getByCode(inputCode);
} catch {
return false;
}
const required = (setting.fields ?? []).filter((f) => f.isRequired);
if (required.length === 0) return true;
const reviews = await this.bookingsRepository.findDocumentReviews(booking.id);
return required.every((field) =>
reviews.some(
(r) =>
r.settingCode === inputCode &&
r.fileKey === field.fileKey &&
r.status === 'APPROVED',
),
);
}
/**
* Customer uploads clearance documents. Each multipart file's fieldname is the
* field's fileKey (or custom_<n> for ad-hoc). Saves FileRecords, refreshes the
* per-document review rows to PENDING, and moves the booking into review.
*/
async submitClearanceDocuments(
bookingId: string,
files: Express.Multer.File[],
): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ['AWAITING_DOCUMENTS', 'DOCUMENTS_UNDER_REVIEW']);
const { inputCode } = clearanceCodesForBooking(booking);
if (!inputCode) {
throw new BadRequestException('This booking has no document-clearance step');
}
if (files.length === 0) {
throw new BadRequestException('No documents uploaded');
}
for (const file of files) {
const record = await this.filesService.upsertByCode({
resourceId: bookingId,
resource: 'bookings',
code: file.fieldname,
file,
});
// Ad-hoc docs (custom_*) are not part of the required gate; still tracked.
const settingCode = file.fieldname.startsWith('custom_')
? 'custom'
: inputCode;
await this.bookingsRepository.upsertDocumentReviewPending({
bookingId,
settingCode,
fileKey: file.fieldname,
fileRecordId: record.id,
});
}
await this.bookingsRepository.update(bookingId, {
status: 'DOCUMENTS_UNDER_REVIEW',
} as never);
return this.bookingsService.findById(bookingId);
}
/** GL reviews a single document: APPROVED or QUERIED (with a note). */
async reviewDocument(
bookingId: string,
fileKey: string,
status: 'APPROVED' | 'QUERIED',
staffId: string,
note?: string,
): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ['DOCUMENTS_UNDER_REVIEW']);
const { inputCode, outputCode } = clearanceCodesForBooking(booking);
const existing = await this.bookingsRepository.findDocumentReviews(bookingId);
const match = existing.find((r) => r.fileKey === fileKey);
const settingCode =
match?.settingCode ??
(fileKey.startsWith('custom_') ? 'custom' : (inputCode ?? outputCode ?? 'custom'));
if (status === 'QUERIED' && !note?.trim()) {
throw new BadRequestException('A note is required when querying a document');
}
await this.bookingsRepository.setDocumentReviewStatus(
bookingId,
settingCode,
fileKey,
status,
staffId,
note,
);
if (status === 'QUERIED') {
await this.bookingsRepository.createReviewNote(
bookingId,
`Document "${fileKey}" queried: ${note}`,
'CHANGES_REQUESTED',
staffId,
);
}
return this.bookingsService.findById(bookingId);
}
/** GL uploads the customs output documents (IM4/IM5/EX3/etc.). */
async uploadClearanceOutputDocuments(
bookingId: string,
files: Express.Multer.File[],
): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ['DOCUMENTS_UNDER_REVIEW']);
const { outputCode } = clearanceCodesForBooking(booking);
if (!outputCode) {
throw new BadRequestException('This booking has no customs output documents');
}
if (files.length === 0) {
throw new BadRequestException('No documents uploaded');
}
for (const file of files) {
await this.filesService.upsertByCode({
resourceId: bookingId,
resource: 'bookings',
code: file.fieldname,
file,
});
}
return this.bookingsService.findById(bookingId);
}
/**
* GL confirms clearance: requires every customer document APPROVED (100% gate)
* and, for customs, the required output documents present → CLEARANCE_READY.
*/
async finalizeClearance(bookingId: string): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ['DOCUMENTS_UNDER_REVIEW']);
const approved = await this.isClearanceFullyApproved(booking);
if (!approved) {
throw new BadRequestException(
'All required documents must be approved before clearance can be finalized',
);
}
const { outputCode } = clearanceCodesForBooking(booking);
if (outputCode) {
const setting = await this.fileUploadSettingsService.getByCode(outputCode);
const files = await this.filesService.findByResource(bookingId, 'bookings');
const uploaded = new Set(files.map((f) => f.code));
const missing = (setting.fields ?? []).filter(
(f) => f.isRequired && !uploaded.has(f.fileKey),
);
if (missing.length > 0) {
throw new BadRequestException(
`Upload all required customs output documents first: ${missing
.map((m) => m.fileLabel)
.join(', ')}`,
);
}
}
await this.bookingsRepository.update(bookingId, {
status: 'CLEARANCE_READY',
} as never);
return this.bookingsService.findById(bookingId);
}
/**
* Customer proceeds to operation once clearance is ready. They pick the
* schedule day (the train departure day) for the shipment; the request then
* sits at OPERATION_REQUEST_PENDING for the operations team to review
* (capacity, documents, route) before it enters the batch holding pool.
*
* Allowed from CLEARANCE_READY (first request) and OPERATION_CHANGES_REQUESTED
* (resubmit after the operations team returned it for changes).
*/
async requestOperation(
bookingId: string,
scheduledDate: string,
): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ['CLEARANCE_READY', 'OPERATION_CHANGES_REQUESTED']);
const date = new Date(scheduledDate);
if (Number.isNaN(date.getTime())) {
throw new BadRequestException('A valid schedule date is required');
}
await this.bookingsRepository.update(bookingId, {
status: 'OPERATION_REQUEST_PENDING',
scheduledDate: date,
} as never);
return this.bookingsService.findById(bookingId);
}
/**
* Operations team reviews a pending operation request (capacity, documents,
* route). Three outcomes:
* - ACCEPT → booking enters the batch holding pool (FULLY_EXECUTED).
* - REQUEST_CHANGES → returned to the customer with a note to fix and resubmit.
* - ADJUST_PRICE → a new total is set; the customer must re-confirm it
* before the booking can enter the pool.
*/
async reviewOperationRequest(
bookingId: string,
decision: 'ACCEPT' | 'REQUEST_CHANGES' | 'ADJUST_PRICE',
actorId: string,
options: { note?: string; amount?: number } = {},
): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ['OPERATION_REQUEST_PENDING']);
if (decision === 'REQUEST_CHANGES') {
if (!options.note?.trim()) {
throw new BadRequestException(
'A note is required when requesting changes',
);
}
await this.bookingsRepository.createReviewNote(
bookingId,
options.note,
'CHANGES_REQUESTED',
actorId,
);
await this.bookingsRepository.update(bookingId, {
status: 'OPERATION_CHANGES_REQUESTED',
} as never);
return this.bookingsService.findById(bookingId);
}
if (decision === 'ADJUST_PRICE') {
if (options.amount == null || options.amount < 0) {
throw new BadRequestException(
'A non-negative adjusted amount is required to adjust the price',
);
}
await this.bookingsRepository.update(bookingId, {
adjustedTotalAmount: options.amount,
adjustedByStaffId: actorId,
adjustedAt: new Date(),
adjustmentReason: options.note ?? null,
status: 'OPERATION_PRICE_PENDING_CONFIRM',
} as never);
return this.bookingsService.findById(bookingId);
}
// ACCEPT — enter the batch holding pool.
return this.acceptOperationRequest(booking);
}
/**
* Customer re-confirms (or rejects) an operations price adjustment. Accepting
* pushes the booking into the pool; rejecting returns it to the customer as an
* operation change request so they can resubmit or cancel.
*/
async confirmOperationPrice(
bookingId: string,
accept: boolean,
): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ['OPERATION_PRICE_PENDING_CONFIRM']);
if (!accept) {
await this.bookingsRepository.update(bookingId, {
status: 'OPERATION_CHANGES_REQUESTED',
} as never);
return this.bookingsService.findById(bookingId);
}
return this.acceptOperationRequest(booking);
}
/**
* Move a reviewed operation request forward after Marketing accepts.
*
* - Train services enter the batch holding pool: the pool query
* (findBatchPoolByRouteDay) keys on FULLY_EXECUTED + scheduled_date, so we
* set those and kick the day-level fill immediately instead of waiting for
* cron.
* - Road (truck) services skip the train batch entirely and wait for truck
* dispatch at ROAD_DISPATCH_PENDING; they are billed by KM, not wagons.
*/
private async acceptOperationRequest(booking: Booking): Promise<Booking> {
const now = new Date();
if (isRoadService(booking.serviceType)) {
await this.bookingsRepository.update(booking.id, {
status: 'ROAD_DISPATCH_PENDING',
fullyExecutedAt: now,
lockedAt: booking.lockedAt ?? now,
} as never);
return this.bookingsService.findById(booking.id);
}
await this.bookingsRepository.update(booking.id, {
status: 'FULLY_EXECUTED',
fullyExecutedAt: now,
lockedAt: booking.lockedAt ?? now,
} as never);
if (booking.scheduledDate) {
this.bookingBatchService.enqueueRouteDayProcessing(
booking.originYardId,
booking.destinationYardId,
eatDay(new Date(booking.scheduledDate)),
);
}
return this.bookingsService.findById(booking.id);
}
async enrichBookingResponse(booking: Booking): Promise<Booking & {
latestChangeRequestNote?: string | null;
contractSummary?: string | null;

View File

@@ -42,10 +42,17 @@ import { FilterBookingDto } from './dto/filter-booking.dto';
import { GeneratePriceResponseDto } from './dto/generate-price-response.dto';
import { SubmitBookingResponseDto } from './dto/submit-booking-response.dto';
import {
AcceptIntakeDto,
AdjustPriceDto,
ApproveStepDto,
CancelBookingDto,
RejectBookingDto,
RejectStepDto,
RequestChangesDto,
ReviewDocumentDto,
RequestOperationDto,
OperationReviewDto,
ConfirmOperationPriceDto,
StaffRejectDto,
} from './dto/request-changes.dto';
import { ContractViewDto } from './dto/contract-view.dto';
@@ -148,15 +155,10 @@ export class BookingsController {
},
};
}
// Scope to the active operational profile (importer/exporter) when one
// resolves; otherwise fall back to company-level scoping.
const companyProfileId =
await this.bookingsService.resolveActiveCompanyProfileId(userId);
return this.bookingsService.findAll(
filter,
companyId,
companyProfileId ?? undefined,
);
// Company-wide by default; the optional filter.companyProfileId (per-page
// service filter) narrows within the company. The company guard always
// applies, so a customer can only ever see their own company's bookings.
return this.bookingsService.findAll(filter, companyId);
}
@Get('by-company/:companyId/customer-view')
@@ -318,6 +320,146 @@ export class BookingsController {
return this.transitionService.confirmSubmit(id);
}
@Post(':id/reject')
@ApiOperation({
summary: 'Customer reject price estimate',
description:
'Customer rejects the priced booking at the confirm step. The booking becomes REJECTED (terminal); the customer must create a new booking.',
})
async reject(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: RejectBookingDto,
) {
const booking = await this.transitionService.reject(id, dto.reason);
return this.transitionService.enrichBookingResponse(booking);
}
// ── Document clearance (post counter-sign) ────────────────────────────────
@Get(':id/clearance')
@ApiOperation({
summary: 'Document-clearance grid (required docs + upload + GL review status)',
})
getClearance(@Param('id', ParseUUIDPipe) id: string) {
return this.transitionService.getClearanceView(id);
}
@Post(':id/clearance/documents')
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes('multipart/form-data')
@ApiOperation({
summary: 'Customer uploads clearance documents (fieldname = document key)',
})
async submitClearanceDocuments(
@Param('id', ParseUUIDPipe) id: string,
@UploadedFiles() files: Express.Multer.File[],
) {
const booking = await this.transitionService.submitClearanceDocuments(
id,
files ?? [],
);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/clearance/proceed')
@ApiOperation({
summary:
'Customer requests operation with a schedule day ' +
'(CLEARANCE_READY | OPERATION_CHANGES_REQUESTED → OPERATION_REQUEST_PENDING)',
})
async proceedToOperation(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: RequestOperationDto,
) {
const booking = await this.transitionService.requestOperation(
id,
dto.scheduledDate,
);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/operation/review')
@BookingStaff(FREIGHT_PERMS.bookings.operations)
@ApiOperation({
summary:
'Operations reviews an operation request: ACCEPT (→ batch pool), ' +
'REQUEST_CHANGES (→ back to customer), or ADJUST_PRICE (→ customer re-confirm)',
})
async reviewOperationRequest(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: OperationReviewDto,
@CurrentUser() user: AuthUserPayload,
) {
const booking = await this.transitionService.reviewOperationRequest(
id,
dto.decision,
resolveAuthUserId(user),
{ note: dto.note, amount: dto.amount },
);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/operation/confirm-price')
@ApiOperation({
summary:
'Customer confirms or rejects an operations price adjustment ' +
'(OPERATION_PRICE_PENDING_CONFIRM → batch pool | OPERATION_CHANGES_REQUESTED)',
})
async confirmOperationPrice(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: ConfirmOperationPriceDto,
) {
const booking = await this.transitionService.confirmOperationPrice(
id,
dto.accept,
);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/clearance/review')
@BookingStaff(FREIGHT_PERMS.bookings.reviewDocuments)
@ApiOperation({ summary: 'GL reviews a clearance document (Approve | Query)' })
async reviewClearanceDocument(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: ReviewDocumentDto,
@CurrentUser() user: AuthUserPayload,
) {
const booking = await this.transitionService.reviewDocument(
id,
dto.fileKey,
dto.status,
resolveAuthUserId(user),
dto.note,
);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/clearance/output-documents')
@BookingStaff(FREIGHT_PERMS.bookings.uploadClearanceOutput)
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes('multipart/form-data')
@ApiOperation({ summary: 'GL uploads customs output documents (IM4/EX3/…)' })
async uploadClearanceOutput(
@Param('id', ParseUUIDPipe) id: string,
@UploadedFiles() files: Express.Multer.File[],
) {
const booking = await this.transitionService.uploadClearanceOutputDocuments(
id,
files ?? [],
);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/clearance/finalize')
@BookingStaff(FREIGHT_PERMS.bookings.finalizeClearance)
@ApiOperation({
summary: 'GL finalizes clearance (requires 100% approved) → CLEARANCE_READY',
})
async finalizeClearance(@Param('id', ParseUUIDPipe) id: string) {
const booking = await this.transitionService.finalizeClearance(id);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/staff/request-changes')
@BookingStaff(FREIGHT_PERMS.bookings.requestChanges)
@ApiOperation({ summary: 'Staff return booking for customer updates' })
@@ -336,14 +478,19 @@ export class BookingsController {
@Post(':id/staff/accept')
@BookingStaff(FREIGHT_PERMS.bookings.staffAccept)
@ApiOperation({ summary: 'Staff accept intake → start approval chain' })
@ApiOperation({
summary:
'Staff accept intake → set contract validity window + start approval chain',
})
async acceptIntake(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: AcceptIntakeDto,
@CurrentUser() user: AuthUserPayload,
) {
const booking = await this.transitionService.acceptIntake(
id,
resolveAuthUserId(user),
dto.validityDays,
);
return this.transitionService.enrichBookingResponse(booking);
}
@@ -364,6 +511,25 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/adjust-price')
@BookingStaff(FREIGHT_PERMS.bookings.staffAccept)
@ApiOperation({
summary: 'Staff adjust booking total price (override; null clears it)',
})
async adjustPrice(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: AdjustPriceDto,
@CurrentUser() user: AuthUserPayload,
) {
const booking = await this.transitionService.adjustPrice(
id,
dto.amount ?? null,
resolveAuthUserId(user),
dto.reason,
);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/government-expedite')
@BookingStaff(FREIGHT_PERMS.bookings.staffAccept)
@ApiOperation({ summary: 'Expedite government booking to PAID / ELIGIBLE for scheduling' })

View File

@@ -8,6 +8,7 @@ import { CompaniesModule } from '../companies/companies.module';
import { FilesModule } from '../files/files.module';
import { MinioModule } from '../minio/minio.module';
import { RuleEngineModule } from '../rule-engine/rule-engine.module';
import { FileUploadSettingsModule } from '../file-upload-settings/file-upload-settings.module';
import { SignaturesModule } from '../signatures/signatures.module';
import { BookingContractService } from './booking-contract.service';
import { BookingPaymentService } from './booking-payment.service';
@@ -21,6 +22,7 @@ import { ConsolidationService } from './consolidation.service';
import { BookingsService } from './bookings.service';
import { BookingApprovalStep } from './entities/booking-approval-step.entity';
import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity';
import { BookingDocumentReview } from './entities/booking-document-review.entity';
import { BookingContainer } from './entities/booking-container.entity';
import { BookingRateSnapshot } from './entities/booking-rate-snapshot.entity';
import { BookingContractSignature } from './entities/booking-contract-signature.entity';
@@ -41,6 +43,7 @@ import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.modu
BookingContainer,
BookingCargoModifier,
BookingApprovalStep,
BookingDocumentReview,
BookingRateSnapshot,
BookingReviewNote,
BookingContractSignature,
@@ -52,6 +55,7 @@ import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.modu
CompaniesModule,
// CustomersModule,
RuleEngineModule,
FileUploadSettingsModule,
SignaturesModule,
ExchangeModule.forRootAsync({
inject: [ConfigService],
@@ -75,6 +79,6 @@ import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.modu
ContractRendererService,
ContractPdfService,
],
exports: [BookingsService, BookingsRepository],
exports: [BookingsService, BookingsRepository, BookingPricingService],
})
export class BookingsModule {}

View File

@@ -7,6 +7,10 @@ import { DataSource, EntityManager, FindOptionsWhere, In, Repository, SelectQuer
import { ContainerType } from '../rule-engine/entities/container-type.entity';
import { BookingApprovalStep } from './entities/booking-approval-step.entity';
import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity';
import {
BookingDocumentReview,
DocumentReviewStatus,
} from './entities/booking-document-review.entity';
import { BookingContainer } from './entities/booking-container.entity';
import { BookingRateSnapshot } from './entities/booking-rate-snapshot.entity';
import { BookingReviewNote, ReviewNoteType } from './entities/booking-review-note.entity';
@@ -37,7 +41,6 @@ export interface BookingListFilterOptions {
excludePaymentStatus?: string;
createdFrom?: string;
createdTo?: string;
allowConsolidation?: boolean;
consolidationPaired?: string;
}
@@ -179,7 +182,6 @@ export class BookingsRepository extends BaseRepository<Booking> {
.innerJoinAndSelect('b.bookingContainers', 'bc')
.innerJoin('bc.containerType', 'ct')
.where('b.id != :bookingId', { bookingId: booking.id })
.andWhere('b.allowConsolidation = true')
.andWhere('b.consolidationPartnerId IS NULL')
// Only pair bookings the customer has committed (SUBMITTED) or that are
// already waiting (PENDING_CONSOLIDATION). DRAFT bookings are excluded so
@@ -315,11 +317,87 @@ export class BookingsRepository extends BaseRepository<Booking> {
return pending === 0;
}
// ── Clearance document reviews ────────────────────────────────────────────
findDocumentReviews(bookingId: string): Promise<BookingDocumentReview[]> {
return this.dataSource.getRepository(BookingDocumentReview).find({
where: { bookingId },
order: { createdAt: 'ASC' },
});
}
findDocumentReview(
bookingId: string,
settingCode: string,
fileKey: string,
): Promise<BookingDocumentReview | null> {
return this.dataSource.getRepository(BookingDocumentReview).findOne({
where: { bookingId, settingCode, fileKey },
});
}
/**
* Upsert a document-review row to PENDING for a freshly uploaded file. Resets
* any prior QUERIED/APPROVED state so the GL re-reviews the new upload.
*/
async upsertDocumentReviewPending(input: {
bookingId: string;
settingCode: string;
fileKey: string;
fileRecordId: string;
}): Promise<void> {
const repo = this.dataSource.getRepository(BookingDocumentReview);
const existing = await repo.findOne({
where: {
bookingId: input.bookingId,
settingCode: input.settingCode,
fileKey: input.fileKey,
},
});
if (existing) {
await repo.update(existing.id, {
fileRecordId: input.fileRecordId,
status: 'PENDING',
note: null,
reviewedByStaffId: null,
reviewedAt: null,
});
return;
}
await repo.save(repo.create({ ...input, status: 'PENDING' }));
}
/** GL marks a document APPROVED or QUERIED (with an optional note). */
async setDocumentReviewStatus(
bookingId: string,
settingCode: string,
fileKey: string,
status: DocumentReviewStatus,
staffId: string,
note?: string,
): Promise<void> {
const repo = this.dataSource.getRepository(BookingDocumentReview);
const existing = await repo.findOne({
where: { bookingId, settingCode, fileKey },
});
const patch = {
status,
note: note ?? null,
reviewedByStaffId: staffId,
reviewedAt: new Date(),
};
if (existing) {
await repo.update(existing.id, patch);
return;
}
await repo.save(repo.create({ bookingId, settingCode, fileKey, ...patch }));
}
/** Persist cargo modifiers linked to rate snapshots. */
async createCargoModifiers(
rows: Array<{
bookingId: string;
surchargeTypeId: string;
rateId: string;
triggerValue: number | null;
calculatedAmount: number;
rateSnapshotId: string;
@@ -650,11 +728,6 @@ export class BookingsRepository extends BaseRepository<Booking> {
excludePaymentStatus: options.excludePaymentStatus,
});
}
if (options.allowConsolidation !== undefined) {
qb.andWhere('booking.allow_consolidation = :allowConsolidation', {
allowConsolidation: options.allowConsolidation,
});
}
if (options.consolidationPaired === 'true') {
qb.andWhere('booking.consolidation_partner_id IS NOT NULL');
} else if (options.consolidationPaired === 'false') {

View File

@@ -11,6 +11,7 @@ import { Freight, SchedulingStatus } from '@edr/types';
// import { CustomersService } from '../customers/customers.service';
import { CompaniesService } from '../companies/companies.service';
import { ProfileType } from '../companies/entities/company-profile.entity';
import { CompanyStatus } from '../companies/entities/company.entity';
import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service';
import { eatDay } from '../train-scheduling/batch-window.util';
import { FilesService } from '../files/files.service';
@@ -25,6 +26,7 @@ import { DataSource, In } from 'typeorm';
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
import { Yard } from '../rule-engine/entities/yard.entity';
import { ContractRouteLine } from '../booking-orders/entities/contract-route-line.entity';
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
import { BookingsRepository } from './bookings.repository';
import { ConsolidationService } from './consolidation.service';
@@ -125,7 +127,6 @@ export class BookingsService {
tradeDirection: string;
isHazardous?: boolean;
isGovernment?: boolean;
allowConsolidation?: boolean;
shippingLineId?: string | null;
containers: CreateBookingContainerDto[];
}): Promise<BookingEvaluationInput> {
@@ -150,6 +151,14 @@ export class BookingsService {
containers.reduce((sum, c) => sum + c.wagonsRequired, 0),
);
// Consolidation is system-managed: the CONSOLIDATION_ENABLED rule trigger
// fires whenever a container line leaves a wagon partially filled. There is
// no customer opt-in — partial-wagon cargo always consolidates.
const allowConsolidation =
dto.freightType === 'CONTAINER'
? await this.needsConsolidation(dto.containers)
: false;
return {
freightType: dto.freightType,
cargoTypeId: dto.cargoTypeId ?? null,
@@ -158,8 +167,7 @@ export class BookingsService {
tradeDirection: dto.tradeDirection,
isHazardous: dto.isHazardous ?? false,
isGovernment: dto.isGovernment ?? false,
allowConsolidation:
dto.freightType === 'CONTAINER' ? dto.allowConsolidation : false,
allowConsolidation,
shippingLineId: dto.shippingLineId,
totalWagons,
containers,
@@ -167,26 +175,20 @@ export class BookingsService {
}
/**
* Enable consolidation when any container line leaves a wagon partially filled
* (e.g. 1×20ft on a 2-slot wagon, 1×10ft on a 4-slot wagon).
*
* Partial-wagon cargo ALWAYS consolidates — the customer cannot opt out of a
* half-empty wagon, so `explicit === false` is ignored when consolidation is
* actually needed. The opt-in flag only matters for cargo that already fills
* whole wagons (where consolidation is moot anyway).
* True when any container line leaves a wagon partially filled (e.g. 1×20ft on
* a 2-slot wagon). Partial-wagon cargo must consolidate before it can finalize;
* cargo that already fills whole wagons never does. This is computed from the
* container quantities alone — there is no customer-facing opt-in flag.
*/
private async resolveConsolidation(
private async needsConsolidation(
containers: CreateBookingContainerDto[],
explicit?: boolean,
): Promise<boolean> {
const needs = await this.consolidationService.needsConsolidation(
return this.consolidationService.needsConsolidation(
containers.map((c) => ({
containerTypeId: c.containerTypeId,
quantity: c.quantity,
})),
);
if (needs) return true;
return explicit ?? false;
}
/** Search for a complementary partner; pair or queue as PENDING_CONSOLIDATION. */
@@ -196,10 +198,12 @@ export class BookingsService {
}> {
const messages: string[] = [];
if (!booking.allowConsolidation || booking.consolidationPartnerId) {
if (booking.consolidationPartnerId) {
return { booking, messages };
}
// Only partial-wagon container lines produce slots; full-wagon (and bulk)
// bookings return none and need no consolidation.
const slots = await this.consolidationService.slotsFromBooking(booking);
if (slots.length === 0) {
return { booking, messages };
@@ -287,6 +291,12 @@ export class BookingsService {
);
}
const { company } = await this.companiesService.getCompanyInfoByUserId(userId);
// A customer can only book once their company has been approved.
if (company.status !== CompanyStatus.Active) {
throw new ForbiddenException(
"Your company is awaiting approval — you can't create bookings yet.",
);
}
companyId = company.id;
}
@@ -374,9 +384,9 @@ export class BookingsService {
}
}
const allowConsolidation =
const needsConsolidation =
dto.freightType === 'CONTAINER'
? await this.resolveConsolidation(containers, dto.allowConsolidation)
? await this.needsConsolidation(containers)
: false;
const evalInput = await this.buildEvalInput({
@@ -387,7 +397,6 @@ export class BookingsService {
tradeDirection,
isHazardous: dto.isHazardous,
isGovernment,
allowConsolidation,
shippingLineId: dto.shippingLineId,
containers,
});
@@ -408,7 +417,13 @@ export class BookingsService {
previousContractId: dto.previousContractId,
serviceTypeId: dto.serviceTypeId,
firstMilePickupAddress: dto.firstMilePickupAddress,
firstMilePickupLat: dto.firstMilePickupLat ?? null,
firstMilePickupLng: dto.firstMilePickupLng ?? null,
lastMileDeliveryAddress: dto.lastMileDeliveryAddress,
lastMileDeliveryLat: dto.lastMileDeliveryLat ?? null,
lastMileDeliveryLng: dto.lastMileDeliveryLng ?? null,
customsClearingEnabled: dto.customsClearingEnabled ?? false,
customsClearingAgent: dto.customsClearingAgent ?? null,
equipmentReturn: dto.equipmentReturn,
originYardId: dto.originYardId,
destinationYardId: dto.destinationYardId,
@@ -427,7 +442,6 @@ export class BookingsService {
startDate: dto.startDate ? new Date(dto.startDate) : undefined,
endDate: dto.endDate ? new Date(dto.endDate) : undefined,
status: 'DRAFT',
allowConsolidation,
priorityScore: ruleResult.priorityScore,
totalAmount: 0,
paymentStatus: 'PENDING',
@@ -447,6 +461,25 @@ export class BookingsService {
warnings.push(`Estimated wagons required: ${wagonCount}`);
}
// Multi-route general contracts: persist the contracted routes + quantities.
// Each drawdown order later draws from one of these route lines.
if (isGeneralContract && dto.routes?.length) {
const routeRepo = this.dataSource.getRepository(ContractRouteLine);
await routeRepo.save(
dto.routes.map((r) =>
routeRepo.create({
contractBookingId: booking.id,
originYardId: r.originYardId,
destinationYardId: r.destinationYardId,
containerTypeId:
dto.freightType === 'CONTAINER' ? (r.containerTypeId ?? null) : null,
quantity: r.quantity,
km: r.km ?? null,
}),
),
);
}
if (files.length > 0) {
try {
await this.filesService.uploadMany(booking.id, 'bookings', files);
@@ -455,9 +488,36 @@ export class BookingsService {
}
}
// Reuse the booking profile's onboarding documents instead of asking the
// customer to re-upload. Snapshot them onto the booking now (by reference),
// so a later active-profile switch never changes this booking's documents.
if (companyProfileId) {
try {
const onboardingFiles =
await this.companiesService.getProfileOnboardingFiles(companyProfileId);
if (onboardingFiles.length > 0) {
await this.filesService.attachExistingFiles(
booking.id,
'bookings',
onboardingFiles.map((f, i) => ({
code: `onboarding_document_${i + 1}`,
name: f.name,
url: f.url,
size: f.size,
mimeType: f.mimeType,
})),
);
}
} catch {
warnings.push(
'Could not attach onboarding documents — they can be added from the booking page.',
);
}
}
let full = await this.findById(booking.id);
if (allowConsolidation) {
if (needsConsolidation) {
const consolidation = await this.tryAutoConsolidate(full);
full = consolidation.booking;
warnings.push(...consolidation.messages);
@@ -516,12 +576,9 @@ export class BookingsService {
dto.tradeDirection,
);
const allowConsolidation =
const needsConsolidation =
freightType === 'CONTAINER'
? await this.resolveConsolidation(
containers,
dto.allowConsolidation ?? existing.allowConsolidation,
)
? await this.needsConsolidation(containers)
: false;
const evalInput = await this.buildEvalInput({
@@ -531,7 +588,6 @@ export class BookingsService {
paymentCurrency: dto.paymentCurrency ?? existing.paymentCurrency,
tradeDirection,
isHazardous: dto.isHazardous ?? existing.isHazardous,
allowConsolidation,
shippingLineId: dto.shippingLineId ?? existing.shippingLineId ?? undefined,
containers,
});
@@ -540,12 +596,11 @@ export class BookingsService {
this.ruleEngineService.assertNoHardBlocks(ruleResult);
warnings.push(...ruleResult.warnings);
const pricingFieldsChanged = this.pricingRelevantFieldsChanged(
const pricingFieldsChanged = await this.pricingRelevantFieldsChanged(
existing,
dto,
freightType,
cargoTypeId,
allowConsolidation,
containers,
);
@@ -553,7 +608,6 @@ export class BookingsService {
...dto,
freightType,
cargoTypeId: freightType === 'BULK' ? cargoTypeId : null,
allowConsolidation,
priorityScore: ruleResult.priorityScore,
tradeDirection,
};
@@ -603,7 +657,7 @@ export class BookingsService {
let booking = await this.findById(id);
if (allowConsolidation && !booking.consolidationPartnerId) {
if (needsConsolidation && !booking.consolidationPartnerId) {
const consolidation = await this.tryAutoConsolidate(booking);
booking = consolidation.booking;
warnings.push(...consolidation.messages);
@@ -667,10 +721,11 @@ export class BookingsService {
assignedToSchedule: filter.assignedToSchedule,
// A forced company scope (portal/customer) overrides any caller-provided
// companyId so a customer can only ever see their own company's bookings.
// When an active profile resolves, scope to it; otherwise fall back to the
// company so nothing breaks for not-yet-onboarded customers.
companyId: forceCompanyProfileId ? undefined : forceCompanyId ?? filter.companyId,
companyProfileId: forceCompanyProfileId,
// The company guard always applies; the optional companyProfileId filter
// (from the per-page service filter) narrows WITHIN the company — the repo
// ANDs both, so cross-company access is impossible.
companyId: forceCompanyId ?? filter.companyId,
companyProfileId: forceCompanyProfileId ?? filter.companyProfileId,
contractType: filter.contractType,
serviceTypeId: filter.serviceTypeId,
cargoTypeId: filter.cargoTypeId,
@@ -681,7 +736,6 @@ export class BookingsService {
paymentStatus: filter.paymentStatus,
createdFrom: filter.createdFrom,
createdTo: filter.createdTo,
allowConsolidation: filter.allowConsolidation,
consolidationPaired: filter.consolidationPaired,
sortBy: filter.sortBy,
sortOrder: filter.sortOrder,
@@ -705,18 +759,15 @@ export class BookingsService {
filter: FilterBookingDto,
): Promise<PaginatedBookings> {
const { company } = await this.companiesService.getCompanyInfoByUserId(userId);
// Scope to the active operational profile when one resolves; fall back to
// company-level so not-yet-onboarded customers still see their payables.
const companyProfileId =
await this.companiesService.resolveActiveCompanyProfileId(userId);
return this.bookingsRepository.findAllPaginated({
page: filter.page ?? 1,
pageSize: filter.pageSize ?? 20,
statuses: BookingsService.PAYABLE_STATUSES,
excludePaymentStatus: 'PAID',
companyId: companyProfileId ? undefined : company.id,
companyProfileId: companyProfileId ?? undefined,
// Company-wide: payables span all of the customer's services.
companyId: company.id,
companyProfileId: filter.companyProfileId,
sortBy: filter.sortBy,
sortOrder: filter.sortOrder,
});
@@ -852,7 +903,6 @@ export class BookingsService {
paymentStatus: filter.paymentStatus,
createdFrom: filter.createdFrom,
createdTo: filter.createdTo,
allowConsolidation: filter.allowConsolidation,
consolidationPaired: filter.consolidationPaired,
};
@@ -961,10 +1011,6 @@ export class BookingsService {
}> {
const booking = await this.findById(id);
if (!booking.allowConsolidation) {
throw new BadRequestException('Booking is not eligible for consolidation');
}
const needs = await this.consolidationService.needsConsolidationFromBooking(
booking,
);
@@ -1048,14 +1094,13 @@ export class BookingsService {
};
}
private pricingRelevantFieldsChanged(
private async pricingRelevantFieldsChanged(
existing: Booking,
dto: UpdateBookingDto,
freightType: FreightType,
cargoTypeId: string | null | undefined,
allowConsolidation: boolean,
containers: CreateBookingContainerDto[],
): boolean {
): Promise<boolean> {
if (dto.freightType !== undefined && dto.freightType !== existing.freightType) {
return true;
}
@@ -1068,18 +1113,14 @@ export class BookingsService {
if (dto.isHazardous !== undefined && dto.isHazardous !== existing.isHazardous) {
return true;
}
if (
dto.allowConsolidation !== undefined &&
dto.allowConsolidation !== existing.allowConsolidation
) {
return true;
}
if (dto.shippingLineId !== undefined && dto.shippingLineId !== existing.shippingLineId) {
return true;
}
if (dto.cargoTypeId !== undefined && dto.cargoTypeId !== existing.cargoTypeId) {
return true;
}
// Container lines drive both the base price and the consolidation surcharge
// (CONSOLIDATION_ENABLED fires on partial wagons), so any line change re-prices.
if (dto.containers !== undefined) {
const existingContainers = (existing.bookingContainers ?? [])
.filter((bc): bc is typeof bc & { containerTypeId: string } => bc.containerTypeId != null)
@@ -1094,8 +1135,7 @@ export class BookingsService {
}
if (
freightType !== existing.freightType ||
(cargoTypeId ?? null) !== (existing.cargoTypeId ?? null) ||
allowConsolidation !== existing.allowConsolidation
(cargoTypeId ?? null) !== (existing.cargoTypeId ?? null)
) {
return true;
}

View File

@@ -0,0 +1,49 @@
import {
clearanceSettingCode,
clearanceOutputSettingCode,
} from './clearance.util';
describe('clearance.util — clearanceSettingCode', () => {
it('resolves import container with/without customs', () => {
expect(clearanceSettingCode('IMPORT', 'CONTAINER', true)).toBe(
'clearance_import_container_with_customs',
);
expect(clearanceSettingCode('IMPORT', 'CONTAINER', false)).toBe(
'clearance_import_container_without_customs',
);
});
it('resolves export bulk with/without customs', () => {
expect(clearanceSettingCode('EXPORT', 'BULK', true)).toBe(
'clearance_export_bulk_with_customs',
);
expect(clearanceSettingCode('EXPORT', 'BULK', false)).toBe(
'clearance_export_bulk_without_customs',
);
});
it('returns null for DOMESTIC (no clearance gate)', () => {
expect(clearanceSettingCode('DOMESTIC', 'CONTAINER', true)).toBeNull();
expect(clearanceSettingCode('DOMESTIC', 'BULK', false)).toBeNull();
});
});
describe('clearance.util — clearanceOutputSettingCode', () => {
it('returns a container output code only for customs container bookings', () => {
expect(clearanceOutputSettingCode('IMPORT', 'CONTAINER', true)).toBe(
'clearance_output_import_container',
);
expect(clearanceOutputSettingCode('EXPORT', 'CONTAINER', true)).toBe(
'clearance_output_export_container',
);
});
it('returns null without customs', () => {
expect(clearanceOutputSettingCode('IMPORT', 'CONTAINER', false)).toBeNull();
});
it('returns null for bulk (no container output set) and domestic', () => {
expect(clearanceOutputSettingCode('IMPORT', 'BULK', true)).toBeNull();
expect(clearanceOutputSettingCode('DOMESTIC', 'CONTAINER', true)).toBeNull();
});
});

View File

@@ -0,0 +1,70 @@
import { Booking } from './entities/booking.entity';
/**
* Resolves which seeded clearance FileUploadSetting applies to a booking, from
* its trade direction, freight type and whether its service includes customs.
* Mirrors the codes seeded in file-upload-settings.seeder.ts.
*/
type Op = 'import' | 'export';
type Freight = 'container' | 'bulk';
/** Trade direction → clearance operation. DOMESTIC has no customs clearance. */
function operationFor(tradeDirection: string): Op | null {
if (tradeDirection === 'IMPORT') return 'import';
if (tradeDirection === 'EXPORT') return 'export';
return null; // DOMESTIC / intercity — no clearance gate
}
function freightFor(freightType: string): Freight {
return freightType === 'BULK' ? 'bulk' : 'container';
}
/** The customer-input clearance setting code, or null when no gate applies. */
export function clearanceSettingCode(
tradeDirection: string,
freightType: string,
includesCustoms: boolean,
): string | null {
const op = operationFor(tradeDirection);
if (!op) return null;
const freight = freightFor(freightType);
const customs = includesCustoms ? 'with_customs' : 'without_customs';
return `clearance_${op}_${freight}_${customs}`;
}
/** The GL-output (customs output) setting code; only container customs sets exist. */
export function clearanceOutputSettingCode(
tradeDirection: string,
freightType: string,
includesCustoms: boolean,
): string | null {
if (!includesCustoms) return null;
const op = operationFor(tradeDirection);
if (!op) return null;
// Only container customs output sets are seeded for this phase.
if (freightFor(freightType) !== 'container') return null;
return `clearance_output_${op}_container`;
}
/** Convenience: resolve both codes for a loaded booking (with its serviceType). */
export function clearanceCodesForBooking(booking: Booking): {
inputCode: string | null;
outputCode: string | null;
includesCustoms: boolean;
} {
const includesCustoms = booking.serviceType?.includesCustoms ?? false;
return {
inputCode: clearanceSettingCode(
booking.tradeDirection,
booking.freightType,
includesCustoms,
),
outputCode: clearanceOutputSettingCode(
booking.tradeDirection,
booking.freightType,
includesCustoms,
),
includesCustoms,
};
}

View File

@@ -57,16 +57,29 @@ export class ConsolidationService {
async slotsFromContainerLines(
lines: Array<{ containerTypeId: string; quantity: number }>,
): Promise<ConsolidationSlot[]> {
const slots: ConsolidationSlot[] = [];
// Aggregate by container type first: two lines of the same type on one
// booking share the same wagons. Counting them separately would flag a
// self-complete booking (e.g. 2× 20FT = exactly one wagon) as a partial
// wagon and wrongly park it in PENDING_CONSOLIDATION.
const quantityByType = new Map<string, number>();
for (const line of lines) {
const ct = await this.containerTypesService.findById(line.containerTypeId);
if (!line.containerTypeId) continue;
quantityByType.set(
line.containerTypeId,
(quantityByType.get(line.containerTypeId) ?? 0) + Number(line.quantity || 0),
);
}
const slots: ConsolidationSlot[] = [];
for (const [containerTypeId, quantity] of quantityByType) {
const ct = await this.containerTypesService.findById(containerTypeId);
const perWagon = containersPerWagon(Number(ct.wagonsPerUnit));
const remainder = wagonRemainder(line.quantity, perWagon);
const remainder = wagonRemainder(quantity, perWagon);
if (remainder === 0) continue;
slots.push({
containerTypeId: line.containerTypeId,
containerTypeId,
containerTypeCode: ct.code,
quantity: line.quantity,
quantity,
containersPerWagon: perWagon,
remainder,
slotsNeeded: perWagon - remainder,

View File

@@ -0,0 +1,47 @@
import 'reflect-metadata';
import { plainToInstance } from 'class-transformer';
import { CreateBookingDto } from './create-booking.dto';
/**
* Boolean flags arrive as STRINGS over multipart/form-data ("true" / "false").
* The global freight ValidationPipe runs with enableImplicitConversion = false,
* so only the explicit @Transform on each flag coerces it. This pins that the
* literal string "false" maps to boolean `false` — class-transformer's implicit
* boolean coercion would otherwise turn any non-empty string (including "false")
* into `true`, silently flagging non-hazardous bookings as hazardous.
*/
describe('CreateBookingDto — boolean coercion from multipart strings', () => {
// Mirror the production pipe: explicit transforms only, no implicit coercion.
const toDto = (plain: Record<string, unknown>) =>
plainToInstance(CreateBookingDto, plain, {
enableImplicitConversion: false,
}) as unknown as CreateBookingDto;
it('maps the string "false" to boolean false for every flag', () => {
const dto = toDto({
isHazardous: 'false',
isGovernment: 'false',
customsClearingEnabled: 'false',
});
expect(dto.isHazardous).toBe(false);
expect(dto.isGovernment).toBe(false);
expect(dto.customsClearingEnabled).toBe(false);
});
it('maps the string "true" to boolean true for every flag', () => {
const dto = toDto({
isHazardous: 'true',
isGovernment: 'true',
customsClearingEnabled: 'true',
});
expect(dto.isHazardous).toBe(true);
expect(dto.isGovernment).toBe(true);
expect(dto.customsClearingEnabled).toBe(true);
});
it('still coerces numeric form strings to numbers', () => {
const dto = toDto({ cargoTotalWeightVgm: '12.5' });
expect(dto.cargoTotalWeightVgm).toBe(12.5);
expect(typeof dto.cargoTotalWeightVgm).toBe('number');
});
});

View File

@@ -11,6 +11,8 @@ import {
IsOptional,
IsString,
IsUUID,
Max,
MaxLength,
Min,
MinLength,
Validate,
@@ -53,6 +55,42 @@ export class CreateBookingContainerDto {
vgmPerUnitTons!: number;
}
export class CreateContractRouteDto {
@ApiProperty({ format: 'uuid', description: 'FK to yards.id (origin)' })
@IsUUID()
originYardId!: string;
@ApiProperty({ format: 'uuid', description: 'FK to yards.id (destination)' })
@IsUUID()
destinationYardId!: string;
@ApiPropertyOptional({
format: 'uuid',
description: 'Container type for CONTAINER contracts; omit for BULK',
})
@IsOptional()
@IsUUID()
containerTypeId?: string;
@ApiProperty({ description: 'Contracted quantity for this route', minimum: 1 })
@IsNumber()
@Min(0)
@Transform(({ value }) => Number(value))
quantity!: number;
@ApiPropertyOptional({
description: 'Road distance (km) for this route; used to bill road orders.',
minimum: 0,
})
@IsOptional()
@IsNumber()
@Min(0)
@Transform(({ value }) =>
value === undefined || value === null || value === '' ? undefined : Number(value),
)
km?: number;
}
export class CreateBookingDto {
/** Class-level freight shape check (not a request field). */
@Validate(BookingFreightShapeConstraint)
@@ -144,11 +182,55 @@ export class CreateBookingDto {
@IsString()
firstMilePickupAddress?: string;
@ApiPropertyOptional({ description: 'First-mile pickup latitude (-90..90)' })
@IsOptional()
@IsNumber()
@Min(-90)
@Max(90)
@Transform(({ value }) => (value == null || value === '' ? undefined : Number(value)))
firstMilePickupLat?: number;
@ApiPropertyOptional({ description: 'First-mile pickup longitude (-180..180)' })
@IsOptional()
@IsNumber()
@Min(-180)
@Max(180)
@Transform(({ value }) => (value == null || value === '' ? undefined : Number(value)))
firstMilePickupLng?: number;
@ApiPropertyOptional()
@IsOptional()
@IsString()
lastMileDeliveryAddress?: string;
@ApiPropertyOptional({ description: 'Last-mile delivery latitude (-90..90)' })
@IsOptional()
@IsNumber()
@Min(-90)
@Max(90)
@Transform(({ value }) => (value == null || value === '' ? undefined : Number(value)))
lastMileDeliveryLat?: number;
@ApiPropertyOptional({ description: 'Last-mile delivery longitude (-180..180)' })
@IsOptional()
@IsNumber()
@Min(-180)
@Max(180)
@Transform(({ value }) => (value == null || value === '' ? undefined : Number(value)))
lastMileDeliveryLng?: number;
@ApiPropertyOptional({ description: 'Whether EDR handles customs clearance' })
@IsOptional()
@IsBoolean()
@Transform(({ value }) => value === 'true' || value === true)
customsClearingEnabled?: boolean;
@ApiPropertyOptional({ maxLength: 200, description: 'Customs clearing agent name (when customs is enabled)' })
@IsOptional()
@IsString()
@MaxLength(200)
customsClearingAgent?: string;
@ApiProperty({ enum: EQUIPMENT_RETURNS })
@IsIn([...EQUIPMENT_RETURNS])
equipmentReturn!: string;
@@ -161,6 +243,21 @@ export class CreateBookingDto {
@IsUUID()
destinationYardId!: string;
/**
* GENERAL_CONTRACT only: the routes this contract reserves quantity across.
* Each entry has its own origin/destination and quantity; the first entry also
* matches the booking's originYardId/destinationYardId. Omitted for one-time
* bookings, which use the single origin/destination above.
*/
@ApiPropertyOptional({ type: [CreateContractRouteDto] })
@ValidateIf((o) => o.bookingType === 'GENERAL_CONTRACT')
@IsOptional()
@IsArray()
@ArrayMinSize(1)
@ValidateNested({ each: true })
@Type(() => CreateContractRouteDto)
routes?: CreateContractRouteDto[];
@ApiProperty({ enum: TRADE_DIRECTIONS })
@IsIn([...TRADE_DIRECTIONS])
tradeDirection!: string;
@@ -233,10 +330,4 @@ export class CreateBookingDto {
@ValidateNested({ each: true })
@Type(() => CreateBookingContainerDto)
containers?: CreateBookingContainerDto[];
@ApiPropertyOptional({ default: false })
@IsOptional()
@IsBoolean()
@Transform(({ value }) => value === 'true' || value === true)
allowConsolidation?: boolean;
}

View File

@@ -38,6 +38,15 @@ export class FilterBookingDto {
@IsUUID()
companyId?: string;
@ApiPropertyOptional({
format: 'uuid',
description:
'Narrow to a single operational profile (importer/exporter/freight_forwarder) within the company.',
})
@IsOptional()
@IsUUID()
companyProfileId?: string;
@ApiPropertyOptional()
@IsOptional()
contractType?: string;
@@ -87,11 +96,6 @@ export class FilterBookingDto {
@IsIn([...PAYMENT_STATUSES])
paymentStatus?: string;
@ApiPropertyOptional()
@IsOptional()
@Transform(({ value }) => value === 'true' || value === true)
allowConsolidation?: boolean;
@ApiPropertyOptional({ description: 'true | false — filter paired consolidation' })
@IsOptional()
consolidationPaired?: string;

View File

@@ -7,9 +7,22 @@ export class PriceLineItemDto {
@ApiProperty()
description!: string;
/** Computed line total (unitAmount × quantity). Retained for totals elsewhere. */
@ApiProperty()
amount!: number;
/** Price for a single unit of this charge (e.g. one 20ft container, one ton). */
@ApiProperty()
unitAmount!: number;
/** Unit the rate is charged per: PER_CONTAINER | PER_TON | PER_WAGON | PER_KM | FLAT. */
@ApiProperty()
unit!: string;
/** How many units this charge applies to (containers, tons, wagons; 1 for FLAT). */
@ApiProperty()
quantity!: number;
@ApiProperty()
currency!: string;
}

View File

@@ -1,5 +1,16 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsString, MinLength } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
IsBoolean,
IsDateString,
IsIn,
IsInt,
IsNumber,
IsOptional,
IsString,
Max,
Min,
MinLength,
} from 'class-validator';
export class RequestChangesDto {
@ApiProperty({ description: 'Staff note explaining what the customer must fix' })
@@ -8,6 +19,21 @@ export class RequestChangesDto {
note!: string;
}
export class AcceptIntakeDto {
@ApiProperty({
description:
'How many days the contract stays valid, counted from the accept date. ' +
'The contract is valid from now through now + validityDays.',
minimum: 1,
maximum: 365,
example: 30,
})
@IsInt()
@Min(1)
@Max(365)
validityDays!: number;
}
export class StaffRejectDto {
@ApiProperty()
@IsString()
@@ -34,3 +60,92 @@ export class CancelBookingDto {
@MinLength(1)
reason!: string;
}
export class RejectBookingDto {
@ApiPropertyOptional({
description: 'Optional reason the customer rejected the price estimate',
})
@IsOptional()
@IsString()
reason?: string;
}
export class AdjustPriceDto {
@ApiPropertyOptional({
description:
'New total price. Omit or send null to clear a previous adjustment.',
})
@IsOptional()
@IsNumber()
@Min(0)
amount?: number | null;
@ApiPropertyOptional({ description: 'Reason for the adjustment' })
@IsOptional()
@IsString()
reason?: string;
}
export class ReviewDocumentDto {
@ApiProperty({ description: 'The document fileKey being reviewed' })
@IsString()
@MinLength(1)
fileKey!: string;
@ApiProperty({ enum: ['APPROVED', 'QUERIED'] })
@IsIn(['APPROVED', 'QUERIED'])
status!: 'APPROVED' | 'QUERIED';
@ApiPropertyOptional({ description: 'Required when querying a document' })
@IsOptional()
@IsString()
note?: string;
}
export class RequestOperationDto {
@ApiProperty({
description:
'The schedule day (train departure day) the customer selects for this ' +
'shipment. ISO date — the booking enters the batch pool for this route + day.',
example: '2026-07-15',
})
@IsDateString()
scheduledDate!: string;
}
export class OperationReviewDto {
@ApiProperty({
description:
'The operations decision: ACCEPT enters the batch pool; REQUEST_CHANGES ' +
'returns it to the customer with a note; ADJUST_PRICE sets a new total the ' +
'customer must re-confirm before it proceeds.',
enum: ['ACCEPT', 'REQUEST_CHANGES', 'ADJUST_PRICE'],
})
@IsIn(['ACCEPT', 'REQUEST_CHANGES', 'ADJUST_PRICE'])
decision!: 'ACCEPT' | 'REQUEST_CHANGES' | 'ADJUST_PRICE';
@ApiPropertyOptional({
description: 'Required for REQUEST_CHANGES (what the customer must fix).',
})
@IsOptional()
@IsString()
note?: string;
@ApiPropertyOptional({
description: 'New total price — required for ADJUST_PRICE.',
})
@IsOptional()
@IsNumber()
@Min(0)
amount?: number;
}
export class ConfirmOperationPriceDto {
@ApiProperty({
description:
'true to accept the operations price adjustment and proceed to the ' +
'batch pool; false to reject it (returns to operation changes requested).',
})
@IsBoolean()
accept!: boolean;
}

View File

@@ -1,12 +1,12 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { SurchargeType } from '../../rule-engine/entities/surcharge-type.entity';
import { Rate } from '../../rule-engine/entities/rate.entity';
import { Booking } from './booking.entity';
import { BookingRateSnapshot } from './booking-rate-snapshot.entity';
@Entity({ schema: 'freight', name: 'booking_cargo_modifier' })
@Index(['bookingId'])
@Index(['surchargeTypeId'])
@Index(['rateId'])
export class BookingCargoModifier extends BaseEntity {
@Column({ name: 'booking_id', type: 'uuid' })
bookingId!: string;
@@ -15,12 +15,17 @@ export class BookingCargoModifier extends BaseEntity {
@JoinColumn({ name: 'booking_id' })
booking?: Booking;
@Column({ name: 'surcharge_type_id', type: 'uuid' })
surchargeTypeId!: string;
/**
* The trigger-based rate (hazard, reefer, overweight …) that produced this
* surcharge line. Replaces the former surcharge_type link now that rates are
* self-describing.
*/
@Column({ name: 'rate_id', type: 'uuid' })
rateId!: string;
@ManyToOne(() => SurchargeType)
@JoinColumn({ name: 'surcharge_type_id' })
surchargeType?: SurchargeType;
@ManyToOne(() => Rate)
@JoinColumn({ name: 'rate_id' })
rate?: Rate;
@Column({ name: 'trigger_value', type: 'numeric', precision: 14, scale: 4, nullable: true })
triggerValue?: number | null;

View File

@@ -0,0 +1,51 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { Booking } from './booking.entity';
export const DOCUMENT_REVIEW_STATUSES = ['PENDING', 'APPROVED', 'QUERIED'] as const;
export type DocumentReviewStatus = (typeof DOCUMENT_REVIEW_STATUSES)[number];
/**
* Per-document GL review for the post-counter-sign clearance gate. One row per
* required clearance document (keyed by fileKey within a setting). GL marks each
* APPROVED or QUERIED (with a note); the booking can only proceed once every
* required customer document is APPROVED. A QUERIED row returns to PENDING when
* the customer re-uploads that file.
*/
@Entity({ schema: 'freight', name: 'booking_document_review' })
@Index(['bookingId'])
@Index(['status'])
@Index(['bookingId', 'settingCode', 'fileKey'], { unique: true })
export class BookingDocumentReview extends BaseEntity {
@Column({ name: 'booking_id', type: 'uuid' })
bookingId!: string;
@ManyToOne(() => Booking, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'booking_id' })
booking?: Booking;
/** The clearance setting this document belongs to (e.g. clearance_import_container_with_customs). */
@Column({ name: 'setting_code', type: 'varchar', length: 128 })
settingCode!: string;
/** The required document's stable key within the setting (e.g. commercial_invoice). */
@Column({ name: 'file_key', type: 'varchar', length: 128 })
fileKey!: string;
/** The uploaded FileRecord backing this review row (null until uploaded). */
@Column({ name: 'file_record_id', type: 'uuid', nullable: true })
fileRecordId?: string | null;
@Column({ name: 'status', type: 'varchar', length: 20, default: 'PENDING' })
status!: DocumentReviewStatus;
/** GL note explaining a QUERIED status. */
@Column({ name: 'note', type: 'text', nullable: true })
note?: string | null;
@Column({ name: 'reviewed_by_staff_id', type: 'uuid', nullable: true })
reviewedByStaffId?: string | null;
@Column({ name: 'reviewed_at', type: 'timestamptz', nullable: true })
reviewedAt?: Date | null;
}

View File

@@ -43,6 +43,20 @@ export const BOOKING_STATUSES = [
'CONSOLIDATED',
'CONTRACT_ACTIVE',
'CONTRACT_CLOSED',
// Post counter-sign document-clearance gate (GL workflow).
'AWAITING_DOCUMENTS',
'DOCUMENTS_UNDER_REVIEW',
'CLEARANCE_READY',
// Road (truck) drawdown orders skip the train batch pool and wait here for
// truck dispatch after Marketing accepts; billed by KM, not wagons.
'ROAD_DISPATCH_PENDING',
'OPERATION_REQUESTED',
// Operations review gate: customer picks a schedule day and submits the
// operation request; the operations team reviews capacity/docs/route before
// the booking enters the batch holding pool.
'OPERATION_REQUEST_PENDING',
'OPERATION_CHANGES_REQUESTED',
'OPERATION_PRICE_PENDING_CONFIRM',
] as const;
export type BookingStatus = (typeof BOOKING_STATUSES)[number];
@@ -156,6 +170,37 @@ export class Booking extends BaseEntity {
@Column({ name: 'total_amount', type: 'numeric', precision: 14, scale: 2, default: 0 })
totalAmount!: number;
/**
* Staff-adjusted total price. When set, it overrides the computed totalAmount
* for the customer, who is shown an "Adjusted by EDR" badge.
*/
@Column({ name: 'adjusted_total_amount', type: 'numeric', precision: 14, scale: 2, nullable: true })
adjustedTotalAmount?: number | null;
@Column({ name: 'adjusted_by_staff_id', type: 'uuid', nullable: true })
adjustedByStaffId?: string | null;
@Column({ name: 'adjusted_at', type: 'timestamptz', nullable: true })
adjustedAt?: Date | null;
@Column({ name: 'adjustment_reason', type: 'text', nullable: true })
adjustmentReason?: string | null;
/**
* Contract validity window, set by the backoffice at the accept step. The
* staff enter a number of days; the contract is valid from contractValidFrom
* (the accept moment) through contractValidUntil (validFrom + N days). Outside
* this window the contract is expired and the booking cannot proceed.
*/
@Column({ name: 'contract_validity_days', type: 'int', nullable: true })
contractValidityDays?: number | null;
@Column({ name: 'contract_valid_from', type: 'timestamptz', nullable: true })
contractValidFrom?: Date | null;
@Column({ name: 'contract_valid_until', type: 'timestamptz', nullable: true })
contractValidUntil?: Date | null;
@Column({ name: 'payment_status', type: 'varchar', length: 20, default: 'PENDING' })
paymentStatus!: string;
@@ -179,9 +224,27 @@ export class Booking extends BaseEntity {
@Column({ name: 'first_mile_pickup_address', type: 'text', nullable: true })
firstMilePickupAddress?: string | null;
@Column({ name: 'first_mile_pickup_lat', type: 'numeric', precision: 10, scale: 7, nullable: true })
firstMilePickupLat?: number | null;
@Column({ name: 'first_mile_pickup_lng', type: 'numeric', precision: 10, scale: 7, nullable: true })
firstMilePickupLng?: number | null;
@Column({ name: 'last_mile_delivery_address', type: 'text', nullable: true })
lastMileDeliveryAddress?: string | null;
@Column({ name: 'last_mile_delivery_lat', type: 'numeric', precision: 10, scale: 7, nullable: true })
lastMileDeliveryLat?: number | null;
@Column({ name: 'last_mile_delivery_lng', type: 'numeric', precision: 10, scale: 7, nullable: true })
lastMileDeliveryLng?: number | null;
@Column({ name: 'customs_clearing_enabled', type: 'boolean', default: false })
customsClearingEnabled!: boolean;
@Column({ name: 'customs_clearing_agent', type: 'varchar', length: 200, nullable: true })
customsClearingAgent?: string | null;
@Column({ name: 'equipment_return', type: 'varchar', length: 20 })
equipmentReturn!: string;
@@ -228,6 +291,15 @@ export class Booking extends BaseEntity {
@Column({ name: 'is_hazardous', type: 'boolean', default: false })
isHazardous!: boolean;
/**
* Refrigerated cargo flag. For one-time bookings reefer is derived from the
* container type; for general-contract drawdown orders the customer enters a
* reefer quantity per order, which sets this flag on the spawned child so the
* REEFER_SURCHARGE rate applies even when the container type is not a reefer.
*/
@Column({ name: 'is_reefer', type: 'boolean', default: false })
isReefer!: boolean;
@Column({ name: 'payment_currency', type: 'varchar', length: 5 })
paymentCurrency!: string;
@@ -294,9 +366,6 @@ export class Booking extends BaseEntity {
@Column({ name: 'priority_score', type: 'int', default: 0 })
priorityScore!: number;
@Column({ name: 'allow_consolidation', type: 'boolean', default: false })
allowConsolidation!: boolean;
@Column({ name: 'consolidation_partner_id', type: 'uuid', nullable: true })
consolidationPartnerId?: string | null;

View File

@@ -28,6 +28,7 @@ import { CreateCompanyProfileDto } from "./dto/create-company-profile.dto";
import { SetActiveModeDto } from "./dto/set-active-mode.dto";
import { SetOnboardingStepDto } from "./dto/set-onboarding-step.dto";
import { StartOnboardingDto } from "./dto/start-onboarding.dto";
import { DashboardQueryDto } from "./dto/dashboard-query.dto";
import {
ResponseCompanyDto,
ResponseCompanyProfileDto,
@@ -87,8 +88,12 @@ export class CompaniesController {
})
async getDashboard(
@CurrentUser() user: CurrentIamUser,
@Query() query: DashboardQueryDto,
): Promise<DashboardSummaryResponseDto> {
return this.companiesService.getDashboardSummary(user.id);
return this.companiesService.getDashboardSummary(
user.id,
query.companyProfileId,
);
}
@Post("fetch-etrade-info")

View File

@@ -8,7 +8,10 @@ import {
import { CompaniesRepository } from "./companies.repository";
import { CompanyProfileRepository } from "./company-profile.repository";
import { ExternalProfileRepository } from "./external-profile.repository";
import { CompanyDashboardRepository } from "./company-dashboard.repository";
import {
CompanyDashboardRepository,
DashboardScope,
} from "./company-dashboard.repository";
import { MinioService } from "../minio/minio.service";
import { FilesService } from "../files/files.service";
import { FileUploadSettingsService } from "../file-upload-settings/file-upload-settings.service";
@@ -380,6 +383,7 @@ export class CompaniesService {
*/
async getDashboardSummary(
userId: string,
companyProfileId?: string,
): Promise<DashboardSummaryResponseDto> {
// A user without a company profile has no bookings — return an empty summary
// rather than 404, so the portal home still renders.
@@ -387,17 +391,17 @@ export class CompaniesService {
const companyId = profile?.company?.id ?? profile?.companyId ?? null;
if (!companyId) return this.emptyDashboardSummary();
// Scope KPIs to the active operational profile (importer/exporter mode) when
// one resolves; otherwise aggregate across the whole company.
const companyProfileId = profile?.activeProfileType
? ((await this.companyProfilesRepo.findByType(
companyId,
profile.activeProfileType,
)) ?? null)
: null;
const scope = companyProfileId
? { companyProfileId: companyProfileId.id }
: { companyId };
// Company-wide by default (all services' data). An optional companyProfileId
// (from the per-page service filter) narrows to one operational profile —
// but only after we confirm it belongs to this user's company, since the
// dashboard scope has no company guard at the repository layer.
let scope: DashboardScope = { companyId };
if (companyProfileId) {
const owned = await this.companyProfilesRepo.findByCompanyId(companyId);
if (owned.some((p) => p.id === companyProfileId)) {
scope = { companyProfileId };
}
}
const now = new Date();
const yearStart = new Date(now.getFullYear(), 0, 1);
@@ -1015,6 +1019,7 @@ export class CompaniesService {
onboardingCompleted: true,
onboardingStep: "done",
});
// Awaiting backoffice approval — stays Pending until an admin activates it.
await this.companiesRepo.update(companyId, {
status: CompanyStatus.Pending,
});
@@ -1103,6 +1108,18 @@ export class CompaniesService {
return profile.businessLicenseFiles ?? [];
}
/**
* 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.
*/
async getProfileOnboardingFiles(
profileId: string,
): Promise<BusinessLicenseFile[]> {
const profile = await this.companyProfilesRepo.findById(profileId);
return profile?.businessLicenseFiles ?? [];
}
/**
* Resolve which company_profile a new booking belongs to, from the company
* and the booking's trade direction. IMPORT → importer profile, EXPORT →

View File

@@ -18,7 +18,9 @@ export class CreateCompanyDto {
@IsString()
@IsNotEmpty()
@Length(10, 10)
@Matches(/^\d+$/, { message: 'TIN must contain only digits' })
@Matches(/^00\d{8}$/, {
message: 'TIN must be 10 digits starting with 00',
})
tin!: string;
@IsOptional()

View File

@@ -0,0 +1,13 @@
import { ApiPropertyOptional } from "@nestjs/swagger";
import { IsOptional, IsUUID } from "class-validator";
export class DashboardQueryDto {
@ApiPropertyOptional({
format: "uuid",
description:
"Narrow dashboard KPIs to a single operational profile (importer/exporter/freight_forwarder) of the user's company. Omit for company-wide totals.",
})
@IsOptional()
@IsUUID()
companyProfileId?: string;
}

View File

@@ -35,7 +35,9 @@ export class UpdateProfileDto {
@IsOptional()
@IsString()
@Length(10, 10)
@Matches(/^\d+$/, { message: 'TIN must contain only digits' })
@Matches(/^00\d{8}$/, {
message: 'TIN must be 10 digits starting with 00',
})
tin?: string;
@IsOptional()

View File

@@ -54,6 +54,38 @@ export class FilesService {
);
}
/**
* Attach already-stored files (e.g. a company profile's onboarding documents)
* to a resource by reference — creates FileRecord rows pointing at the existing
* object-storage URLs, without re-uploading bytes. The snapshot is fixed at call
* time, so later changes to the source documents never alter what was attached.
*/
async attachExistingFiles(
resourceId: string,
resource: string,
files: Array<{
code: string;
name: string;
url: string;
size: number;
mimeType?: string;
}>,
): Promise<FileRecord[]> {
return Promise.all(
files.map((f) =>
this.filesRepository.create({
resourceId,
resource,
code: f.code,
name: f.name,
url: f.url,
size: f.size,
mimeType: f.mimeType ?? "application/octet-stream",
}),
),
);
}
async findById(id: string): Promise<FileRecord> {
const record = await this.filesRepository.findById(id);
if (!record) throw new NotFoundException(`File ${id} not found`);

View File

@@ -55,6 +55,7 @@ export class CreateFirstMileDto {
nullable: true,
})
@IsOptional()
@Transform(({ value }) => (value === '' ? undefined : value))
@IsUUID()
vehicleId?: string | null;
}

View File

@@ -55,6 +55,13 @@ export class FirstMileController {
return this.firstMileService.findById(id);
}
@Post('accept/:reference')
@TrainSchedulingManage()
@ApiOperation({ summary: 'Accept a paid booking and create a first-mile leg' })
acceptBooking(@Param('reference') reference: string) {
return this.firstMileService.acceptBooking(reference);
}
@Post()
@TrainSchedulingManage()
@ApiOperation({ summary: 'Create a first-mile leg' })

View File

@@ -1,13 +1,23 @@
import { Module } from '@nestjs/common';
import { Module, forwardRef } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { BookingsModule } from '../bookings/bookings.module';
import { DriversModule } from '../drivers/drivers.module';
import { NotificationsModule } from '../notifications/notifications.module';
import { VehiclesModule } from '../vehicles/vehicles.module';
import { FirstMile } from './entities/first-mile.entity';
import { FirstMileController } from './first-mile.controller';
import { FirstMileRepository } from './first-mile.repository';
import { FirstMileService } from './first-mile.service';
@Module({
imports: [TypeOrmModule.forFeature([FirstMile])],
imports: [
TypeOrmModule.forFeature([FirstMile]),
forwardRef(() => BookingsModule),
VehiclesModule,
DriversModule,
NotificationsModule,
],
controllers: [FirstMileController],
providers: [FirstMileRepository, FirstMileService],
exports: [FirstMileRepository, FirstMileService],

View File

@@ -1,6 +1,10 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { FindOptionsWhere } from 'typeorm';
import { BookingsRepository } from '../bookings/bookings.repository';
import { DriversService } from '../drivers/drivers.service';
import { NotificationsService } from '../notifications/notifications.service';
import { VehiclesService } from '../vehicles/vehicles.service';
import { CreateFirstMileDto } from './dto/create-first-mile.dto';
import { UpdateFirstMileDto } from './dto/update-first-mile.dto';
import { FirstMile, FirstMileStatus } from './entities/first-mile.entity';
@@ -25,7 +29,37 @@ const SORTABLE_FIELDS: (keyof FirstMile)[] = [
@Injectable()
export class FirstMileService {
constructor(private readonly firstMileRepository: FirstMileRepository) {}
private readonly logger = new Logger(FirstMileService.name);
constructor(
private readonly firstMileRepository: FirstMileRepository,
private readonly bookingsRepository: BookingsRepository,
private readonly vehiclesService: VehiclesService,
private readonly driversService: DriversService,
private readonly notificationsService: NotificationsService,
) {}
/**
* Look up a booking by its human-readable reference and confirm it has been
* paid before any first-mile work proceeds. Throws if the reference is
* unknown or the booking has not reached PAID status.
*/
async acceptBooking(bookingReference: string): Promise<FirstMile | null> {
const booking = await this.bookingsRepository.findByReference(bookingReference);
if (!booking) {
return null;
}
if (booking.paymentStatus !== 'PAID') {
return null;
}
return this.create({
bookingId: booking.id,
advancedPayment: 0,
});
}
async findAll(filter: FirstMileListFilter = {}): Promise<{
data: FirstMile[];
@@ -45,7 +79,10 @@ export class FirstMileService {
const [data, total] = await this.firstMileRepository.findAndCount({
where,
relations: { booking: true, vehicle: true },
relations: {
booking: { company: true, serviceType: true, originYard: true, destinationYard: true, cargoType: true },
vehicle: true,
},
order: { [sortBy]: sortOrder },
skip: (page - 1) * pageSize,
take: pageSize,
@@ -64,7 +101,10 @@ export class FirstMileService {
async findById(id: string): Promise<FirstMile> {
const record = await this.firstMileRepository.findById(id, {
relations: { booking: true, vehicle: true },
relations: {
booking: { company: true, serviceType: true, originYard: true, destinationYard: true, cargoType: true },
vehicle: true,
},
});
if (!record) {
@@ -87,7 +127,7 @@ export class FirstMileService {
}
async update(id: string, dto: UpdateFirstMileDto): Promise<FirstMile> {
await this.findById(id);
const existing = await this.findById(id);
const updated = await this.firstMileRepository.update(id, {
...(dto.bookingId !== undefined ? { bookingId: dto.bookingId } : {}),
@@ -103,9 +143,45 @@ export class FirstMileService {
throw new NotFoundException(`First-mile record ${id} not found`);
}
// Notify assigned driver on every explicit vehicle assignment or reassignment
if (dto.vehicleId) {
void this.notifyDriverAssignment(dto.vehicleId, existing);
}
return updated;
}
private async notifyDriverAssignment(vehicleId: string, record: FirstMile): Promise<void> {
try {
const vehicle = await this.vehiclesService.findById(vehicleId);
if (!vehicle.assignedDriverId) {
this.logger.warn(`Vehicle ${vehicleId} has no assigned driver — skipping SMS`);
return;
}
const driver = await this.driversService.findById(vehicle.assignedDriverId);
if (!driver.phoneNumber) {
this.logger.warn(`Driver ${vehicle.assignedDriverId} has no phone number — skipping SMS`);
return;
}
const booking = (record as FirstMile & { booking?: { reference?: string; firstMilePickupAddress?: string | null; originYard?: { label?: string } | null } }).booking;
await this.notificationsService.notifyDriverVehicleAssignment({
driverPhone: driver.phoneNumber,
driverName: `${driver.firstName ?? ''} ${driver.lastName ?? ''}`.trim(),
vehiclePlateNumber: vehicle.plateNumber ?? vehicleId,
bookingReference: booking?.reference ?? record.bookingId,
pickupAddress: booking?.firstMilePickupAddress,
destinationYard: booking?.originYard?.label,
});
this.logger.log(`SMS sent to driver ${driver.phoneNumber} for vehicle ${vehicleId} assignment`);
} catch (err) {
this.logger.error(`Failed to notify driver for vehicle ${vehicleId}: ${String(err)}`);
}
}
async remove(id: string): Promise<void> {
await this.findById(id);
await this.firstMileRepository.softDelete(id);

View File

@@ -55,6 +55,7 @@ export class CreateLastMileDto {
nullable: true,
})
@IsOptional()
@Transform(({ value }) => (value === '' ? undefined : value))
@IsUUID()
vehicleId?: string | null;
}

View File

@@ -55,6 +55,13 @@ export class LastMileController {
return this.lastMileService.findById(id);
}
@Post('accept/:reference')
@TrainSchedulingManage()
@ApiOperation({ summary: 'Accept a paid booking and create a last-mile leg' })
acceptBooking(@Param('reference') reference: string) {
return this.lastMileService.acceptBooking(reference);
}
@Post()
@TrainSchedulingManage()
@ApiOperation({ summary: 'Create a last-mile leg' })

View File

@@ -1,13 +1,23 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { BookingsModule } from '../bookings/bookings.module';
import { DriversModule } from '../drivers/drivers.module';
import { NotificationsModule } from '../notifications/notifications.module';
import { VehiclesModule } from '../vehicles/vehicles.module';
import { LastMile } from './entities/last-mile.entity';
import { LastMileController } from './last-mile.controller';
import { LastMileRepository } from './last-mile.repository';
import { LastMileService } from './last-mile.service';
@Module({
imports: [TypeOrmModule.forFeature([LastMile])],
imports: [
TypeOrmModule.forFeature([LastMile]),
BookingsModule,
VehiclesModule,
DriversModule,
NotificationsModule,
],
controllers: [LastMileController],
providers: [LastMileRepository, LastMileService],
exports: [LastMileRepository, LastMileService],

View File

@@ -1,6 +1,10 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { FindOptionsWhere } from 'typeorm';
import { BookingsRepository } from '../bookings/bookings.repository';
import { DriversService } from '../drivers/drivers.service';
import { NotificationsService } from '../notifications/notifications.service';
import { VehiclesService } from '../vehicles/vehicles.service';
import { CreateLastMileDto } from './dto/create-last-mile.dto';
import { UpdateLastMileDto } from './dto/update-last-mile.dto';
import { LastMile, LastMileStatus } from './entities/last-mile.entity';
@@ -25,7 +29,34 @@ const SORTABLE_FIELDS: (keyof LastMile)[] = [
@Injectable()
export class LastMileService {
constructor(private readonly lastMileRepository: LastMileRepository) {}
private readonly logger = new Logger(LastMileService.name);
constructor(
private readonly lastMileRepository: LastMileRepository,
private readonly bookingsRepository: BookingsRepository,
private readonly vehiclesService: VehiclesService,
private readonly driversService: DriversService,
private readonly notificationsService: NotificationsService,
) {}
async acceptBooking(bookingReference: string): Promise<LastMile> {
const booking = await this.bookingsRepository.findByReference(bookingReference);
if (!booking) {
throw new NotFoundException(`Booking ${bookingReference} not found`);
}
if (booking.paymentStatus !== 'PAID') {
throw new BadRequestException(
`Booking ${bookingReference} is not paid (payment status: ${booking.paymentStatus})`,
);
}
return this.create({
bookingId: booking.id,
advancedPayment: booking.totalAmount,
});
}
async findAll(filter: LastMileListFilter = {}): Promise<{
data: LastMile[];
@@ -45,7 +76,10 @@ export class LastMileService {
const [data, total] = await this.lastMileRepository.findAndCount({
where,
relations: { booking: true, vehicle: true },
relations: {
booking: { company: true, serviceType: true, originYard: true, destinationYard: true, cargoType: true },
vehicle: true,
},
order: { [sortBy]: sortOrder },
skip: (page - 1) * pageSize,
take: pageSize,
@@ -64,7 +98,10 @@ export class LastMileService {
async findById(id: string): Promise<LastMile> {
const record = await this.lastMileRepository.findById(id, {
relations: { booking: true, vehicle: true },
relations: {
booking: { company: true, serviceType: true, originYard: true, destinationYard: true, cargoType: true },
vehicle: true,
},
});
if (!record) {
@@ -87,7 +124,7 @@ export class LastMileService {
}
async update(id: string, dto: UpdateLastMileDto): Promise<LastMile> {
await this.findById(id);
const existing = await this.findById(id);
const updated = await this.lastMileRepository.update(id, {
...(dto.bookingId !== undefined ? { bookingId: dto.bookingId } : {}),
@@ -103,9 +140,50 @@ export class LastMileService {
throw new NotFoundException(`Last-mile record ${id} not found`);
}
// Notify assigned driver on every explicit vehicle assignment or reassignment
if (dto.vehicleId) {
void this.notifyDriverAssignment(dto.vehicleId, existing);
}
return updated;
}
private async notifyDriverAssignment(vehicleId: string, record: LastMile): Promise<void> {
try {
const vehicle = await this.vehiclesService.findById(vehicleId);
if (!vehicle.assignedDriverId) {
this.logger.warn(`Vehicle ${vehicleId} has no assigned driver — skipping SMS`);
return;
}
const driver = await this.driversService.findById(vehicle.assignedDriverId);
if (!driver.phoneNumber) {
this.logger.warn(`Driver ${vehicle.assignedDriverId} has no phone number — skipping SMS`);
return;
}
type BookingWithYards = {
reference?: string;
lastMileDeliveryAddress?: string | null;
destinationYard?: { label?: string } | null;
};
const booking = (record as LastMile & { booking?: BookingWithYards }).booking;
await this.notificationsService.notifyDriverVehicleAssignment({
driverPhone: driver.phoneNumber,
driverName: `${driver.firstName ?? ''} ${driver.lastName ?? ''}`.trim(),
vehiclePlateNumber: vehicle.plateNumber ?? vehicleId,
bookingReference: booking?.reference ?? record.bookingId,
pickupAddress: booking?.destinationYard?.label,
destinationYard: booking?.lastMileDeliveryAddress,
});
this.logger.log(`SMS sent to driver ${driver.phoneNumber} for vehicle ${vehicleId} assignment`);
} catch (err) {
this.logger.error(`Failed to notify driver for vehicle ${vehicleId}: ${String(err)}`);
}
}
async remove(id: string): Promise<void> {
await this.findById(id);
await this.lastMileRepository.softDelete(id);

View File

@@ -1,14 +1,14 @@
import { Module } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
import { NotificationsService } from "./notifications.service";
import { EmailNotificationStrategy } from "./strategies/notification.email.strategy";
import { SmsNotificationStrategy } from "./strategies/notification.sms.strategy";
import { HttpModule } from "@nestjs/axios";
@Module({
imports: [HttpModule],
imports: [ConfigModule],
controllers: [],
providers: [EmailNotificationStrategy, SmsNotificationStrategy, NotificationsService],
exports: [NotificationsService],
})
export class NotificationsModule { }
export class NotificationsModule {}

View File

@@ -27,9 +27,29 @@ export class NotificationsService {
if (!strategy) {
throw new NotFoundException();
}
const sent = await strategy.send(recipient, message)
this.logger.log(`is sent - ${sent}`)
const sent = await strategy.send(recipient, message);
this.logger.log(`is sent - ${sent}`);
}
async notifyDriverVehicleAssignment(params: {
driverPhone: string;
driverName: string;
vehiclePlateNumber: string;
bookingReference: string;
pickupAddress?: string | null;
destinationYard?: string | null;
}): Promise<void> {
const { driverPhone, driverName, vehiclePlateNumber, bookingReference, pickupAddress, destinationYard } = params;
const message =
`Dear ${driverName}, you have been assigned to a first-mile pickup. ` +
`Booking: ${bookingReference}. Vehicle: ${vehiclePlateNumber}. ` +
(pickupAddress ? `Pickup: ${pickupAddress}. ` : '') +
(destinationYard ? `Destination: ${destinationYard}.` : '');
try {
await this.directSend('sms', driverPhone, message);
} catch (err) {
this.logger.error(`Failed to notify driver ${driverName} (${driverPhone}): ${String(err)}`);
}
}
}

View File

@@ -1,25 +1,36 @@
import { Injectable} from "@nestjs/common";
import { NotificationStrategy } from "./notification.strategy";
import { HttpService } from '@nestjs/axios';
import { Injectable } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { firstValueFrom } from 'rxjs';
import axios from "axios";
import { NotificationStrategy } from "./notification.strategy";
@Injectable()
export class SmsNotificationStrategy implements NotificationStrategy {
constructor(private readonly httpService: HttpService, private readonly configService: ConfigService) { }
async send(recipient: string, message: string) {
const url = this.configService.get("OZIKING_SMS_URL")
const body = {
to: recipient,
text: message
}
const response = await firstValueFrom(
this.httpService.post(
url,
body,
),
);
constructor(private readonly configService: ConfigService) {}
return response.status === 201;
}
async send(recipient: string, message: string): Promise<boolean> {
const url =
this.configService.get<string>("OZIKING_SMS_URL") ??
"https://notification-dev.license.aafda.gov.et/api/sms-services/ozeking/sms";
await axios.post(
url,
{
to: recipient,
sourceId: this.configService.get<string>("OZIKING_SOURCE_ID") ?? "EDR",
sourceName: this.configService.get<string>("OZIKING_SOURCE_NAME") ?? "EDR Freight",
appKey: this.configService.get<string>("OZIKING_APP_KEY") ?? "",
text: message,
callbackUrl: "",
},
{
headers: {
accept: "*/*",
"Content-Type": "application/json",
},
},
);
return true;
}
}

View File

@@ -1,4 +1,4 @@
export const OVERVIEW_URGENT_PRIORITY_THRESHOLD = 1000;
export const OVERVIEW_URGENT_PRIORITY_THRESHOLD = 70;
export const OVERVIEW_NEEDS_ACTION_STATUSES = [
'SUBMITTED',

View File

@@ -1,4 +1,4 @@
import { Module, forwardRef } from "@nestjs/common";
import { DynamicModule, Module, forwardRef } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import { HttpModule } from "@nestjs/axios";
import { ConfigModule, ConfigService } from "@nestjs/config";
@@ -19,18 +19,16 @@ import { InternalPaymentController } from "./internal-payment.controller";
import { ServiceAuthGuard } from "../../common/guards/service-auth.guard";
import { TrainSchedulingModule } from "../train-scheduling/train-scheduling.module";
import { DropdownSettingsModule } from "../dropdown-settings/dropdown-settings.module";
import { FirstMileModule } from "../first-mile/first-mile.module";
import { PaymentWebhookEventEntity } from "./entities/payment-webhook-event.entity";
import { PaymentRefundEntity } from "./entities/payment-refund.entity";
const FREIGHT_QUEUE = PAYMENT_QUEUES[PaymentServiceEnum.FREIGHT];
@Module({
imports: [
HttpModule.register({ timeout: 10_000 }),
ConfigModule,
DropdownSettingsModule,
forwardRef(() => TrainSchedulingModule),
TypeOrmModule.forFeature([PaymentWebhookEventEntity, PaymentRefundEntity]),
function rabbitMQImport(): DynamicModule[] {
if (!process.env.PAYMENT_RABBITMQ_URL) return [];
return [
RabbitMQModule.forRootAsync({
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
@@ -51,6 +49,18 @@ const FREIGHT_QUEUE = PAYMENT_QUEUES[PaymentServiceEnum.FREIGHT];
connectionInitOptions: { wait: false },
}),
}),
];
}
@Module({
imports: [
HttpModule.register({ timeout: 10_000 }),
ConfigModule,
DropdownSettingsModule,
forwardRef(() => FirstMileModule),
forwardRef(() => TrainSchedulingModule),
TypeOrmModule.forFeature([PaymentWebhookEventEntity, PaymentRefundEntity]),
...rabbitMQImport(),
],
providers: [
PaymentRepository,

View File

@@ -35,6 +35,7 @@ import {
} from "./payments.dto";
import { BookingBatchService } from "../train-scheduling/booking-batch.service";
import { DropdownSettingsService } from "../dropdown-settings/dropdown-settings.service";
import { FirstMileService } from "../first-mile/first-mile.service";
/** Setting code holding the global ordering window (months) for general contracts. */
const CONTRACT_PERIOD_SETTING_CODE = "general_contract_period";
@@ -60,6 +61,7 @@ export class PaymentService {
@Inject(forwardRef(() => BookingBatchService))
private readonly bookingBatchService: BookingBatchService,
private readonly dropdownSettings: DropdownSettingsService,
private readonly firstMileService: FirstMileService,
) { }
/** Configured general-contract ordering window in months (defaults to 3). */
@@ -342,6 +344,8 @@ export class PaymentService {
? { paymentStatus: "PAID", status: "CONTRACT_ACTIVE", expiresAt: contractExpiresAt }
: { paymentStatus: "PAID", status: "PAID" },
);
await this.firstMileService.acceptBooking(input.bookingId);
});
if (isGeneralContract) {

View File

@@ -1,56 +0,0 @@
import {
Body, Controller, Delete, Get, HttpCode, HttpStatus,
Param, ParseUUIDPipe, Patch, Post, Query,
} from '@nestjs/common';
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { CreateSurchargeTypeDto } from '../dto/create-surcharge-type.dto';
import { UpdateSurchargeTypeDto } from '../dto/update-surcharge-type.dto';
import { SurchargeTypesService } from '../services/surcharge-types.service';
@ApiTags('surcharge-types')
@Controller('surcharge-types')
@ApiBearerAuth()
export class SurchargeTypesController {
constructor(private readonly service: SurchargeTypesService) {}
@Get()
@RuleEngineView('surcharge-types')
@ApiOperation({ summary: 'List surcharge types' })
findAll(@Query() query: Record<string, string>) {
return this.service.findAll({
isActive: query['isActive'] !== undefined ? query['isActive'] === 'true' : undefined,
page: query['page'] ? parseInt(query['page'], 10) : undefined,
pageSize: query['pageSize'] ? parseInt(query['pageSize'], 10) : undefined,
});
}
@Get(':id')
@RuleEngineView('surcharge-types')
@ApiOperation({ summary: 'Get a surcharge type by ID' })
findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.service.findById(id);
}
@Post()
@RuleEngineManage('surcharge-types')
@ApiOperation({ summary: 'Create a surcharge type' })
create(@Body() dto: CreateSurchargeTypeDto) {
return this.service.create(dto);
}
@Patch(':id')
@RuleEngineManage('surcharge-types')
@ApiOperation({ summary: 'Update a surcharge type' })
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateSurchargeTypeDto) {
return this.service.update(id, dto);
}
@Delete(':id')
@RuleEngineManage('surcharge-types')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Soft-delete a surcharge type' })
remove(@Param('id', ParseUUIDPipe) id: string) {
return this.service.remove(id);
}
}

View File

@@ -1,5 +1,5 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsBoolean, IsIn, IsInt, IsOptional, IsString, MaxLength, Min } from 'class-validator';
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'] })
@@ -30,9 +30,15 @@ export class CreatePriorityConfigDto {
@Min(0)
maxWagonCount!: number;
@ApiProperty({ description: 'Points awarded when booking matches this rule', default: 0 })
@ApiProperty({
description:
'Points awarded when booking matches this rule. Capped so the priority blocks sum to ≤ 100 alongside the service-type bonus (service ≤ 15 + wagon ≤ 50 + currency ≤ 35). WAGON configs should not exceed 50; CURRENCY configs should not exceed 35.',
default: 0,
maximum: 50,
})
@IsInt()
@Min(0)
@Max(50)
scorePoints!: number;
@ApiPropertyOptional({ default: false, description: 'Feature flag — toggle without code deploy' })

View File

@@ -1,29 +1,46 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsDateString, IsIn, IsNumber, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator';
import { RATE_TYPES, RATE_UNITS } from '../entities/rate.entity';
import {
RATE_APPLIES_TO,
RATE_TRIGGERS,
RATE_UNITS,
} from '../entities/rate.entity';
const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'BOTH'] as const;
const CURRENCIES = ['USD'] as const;
export class CreateRateDto {
@ApiProperty({ enum: RATE_TYPES, description: 'Rate type identifier' })
@IsIn([...RATE_TYPES])
rateType!: string;
@ApiProperty({ enum: RATE_APPLIES_TO, description: 'Friendly category the rate applies to' })
@IsIn([...RATE_APPLIES_TO])
appliesTo!: string;
@ApiPropertyOptional({ description: 'FK to container_types.id — null for non-container rates' })
@ApiProperty({
enum: RATE_TRIGGERS,
description: 'What makes this rate apply. ALWAYS = base freight; anything else is a surcharge.',
})
@IsIn([...RATE_TRIGGERS])
trigger!: string;
@ApiPropertyOptional({ description: 'FK to container_types.id — set for container/intercity-container rates' })
@IsOptional()
@IsUUID()
containerTypeId?: string;
@ApiPropertyOptional({ description: 'FK to cargo_types.id (bulk leaf commodity) — set for bulk/intercity-bulk rates' })
@IsOptional()
@IsUUID()
cargoTypeId?: string;
@ApiPropertyOptional({ enum: TRADE_DIRECTIONS, description: 'Trade direction. Null = direction-agnostic' })
@IsOptional()
@IsIn([...TRADE_DIRECTIONS])
tradeDirection?: string;
@ApiProperty({ enum: CURRENCIES })
@ApiPropertyOptional({ enum: CURRENCIES })
@IsOptional()
@IsIn([...CURRENCIES])
currency!: string;
currency?: string;
@ApiProperty({ description: 'Numeric rate value', minimum: 0 })
@IsNumber()

View File

@@ -1,5 +1,5 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsBoolean, IsInt, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator';
import { IsBoolean, IsInt, IsOptional, IsString, IsUUID, Max, MaxLength, Min } from 'class-validator';
export class CreateServiceTypeDto {
@ApiProperty({ description: 'Service type display name', maxLength: 255 })
@@ -32,10 +32,15 @@ export class CreateServiceTypeDto {
@IsBoolean()
includesCustoms?: boolean;
@ApiPropertyOptional({ description: 'Priority bonus points awarded when this service is used', default: 0 })
@ApiPropertyOptional({
description: 'Priority bonus points awarded when this service is used (015)',
default: 0,
maximum: 15,
})
@IsOptional()
@IsInt()
@Min(0)
@Max(15)
priorityBonusPoints?: number;
@ApiPropertyOptional({ default: true })

View File

@@ -1,30 +0,0 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsBoolean, IsIn, IsOptional, IsString, IsUUID, MaxLength } from 'class-validator';
const TRIGGER_CONDITIONS = [
'CARGO_FLAG_HAZARDOUS',
'CARGO_FLAG_REEFER',
'VGM_EXCEEDS_LIMIT',
'SHIPPING_LINE_MAPPED',
'CONSOLIDATION_ENABLED',
] as const;
export class CreateSurchargeTypeDto {
@ApiProperty({ description: 'Human-readable label', maxLength: 100 })
@IsString()
@MaxLength(100)
label!: string;
@ApiProperty({ enum: TRIGGER_CONDITIONS, description: 'Condition that auto-fires this surcharge' })
@IsIn([...TRIGGER_CONDITIONS])
triggerCondition!: string;
@ApiProperty({ description: 'FK to rates.id — the LIVE rate used to price this surcharge' })
@IsUUID()
rateId!: string;
@ApiPropertyOptional({ default: true })
@IsOptional()
@IsBoolean()
isActive?: boolean;
}

View File

@@ -1,4 +0,0 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateSurchargeTypeDto } from './create-surcharge-type.dto';
export class UpdateSurchargeTypeDto extends PartialType(CreateSurchargeTypeDto) {}

View File

@@ -0,0 +1,61 @@
import type { RateAppliesTo, RateTrigger, RateType } from './rate.entity';
/**
* Derive the legacy `rateType` string from the friendly form fields.
*
* `rateType` is still the key the pricing engine uses to look up base rail
* freight (CONTAINER_IMPORT, BULK_EXPORT, …) and what gets snapshotted on a
* booking. The configuration UI no longer asks for it directly — the admin
* picks `appliesTo` + `tradeDirection` (+ `trigger` for surcharges) and we map
* that to the canonical rateType here so both layers stay in agreement.
*/
export function deriveRateType(input: {
appliesTo: RateAppliesTo;
trigger: RateTrigger;
tradeDirection?: string | null;
/** Whether a bulk cargo (vs a container) was selected — disambiguates intercity. */
isBulk?: boolean;
}): RateType {
const { appliesTo, trigger, tradeDirection, isBulk } = input;
// Surcharges (trigger ≠ ALWAYS) map to their dedicated rateType.
if (trigger !== 'ALWAYS') {
switch (trigger) {
case 'HAZARDOUS':
return 'HAZARD_SURCHARGE';
case 'REEFER':
return 'REEFER_SURCHARGE';
case 'OVERWEIGHT':
return 'OVERWEIGHT_PER_TON';
case 'SHIPPING_LINE':
return 'DOUBLE_HANDLING';
case 'CONSOLIDATION':
return 'LASHING';
case 'CANCELLATION':
return 'CANCELLATION_FEE';
case 'DEMURRAGE':
return 'DEMURRAGE';
case 'PIL_EXTRA_FEE':
return 'PIL_EXTRA_FEE';
}
}
// Base freight (trigger = ALWAYS) maps by category + direction.
const isExport = tradeDirection === 'EXPORT';
switch (appliesTo) {
case 'CONTAINER':
return isExport ? 'CONTAINER_EXPORT' : 'CONTAINER_IMPORT';
case 'BULK':
return isExport ? 'BULK_EXPORT' : 'BULK_IMPORT';
case 'INTERCITY':
// Intercity has no trade direction; container vs bulk decided by which
// scope field was filled (cargoTypeId → bulk, containerTypeId → container).
return isBulk ? 'INTERCITY_BULK' : 'INTERCITY_CONTAINER';
case 'FIRST_MILE':
return 'FIRST_MILE';
case 'LAST_MILE':
return 'LAST_MILE';
default:
return 'CANCELLATION_FEE';
}
}

View File

@@ -1,5 +1,6 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { CargoType } from './cargo-type.entity';
import { ContainerType } from './container-type.entity';
export const RATE_TYPES = [
@@ -27,18 +28,72 @@ export type RateType = typeof RATE_TYPES[number];
export const RATE_STATUSES = ['DRAFT', 'PENDING_APPROVAL', 'LIVE', 'SUPERSEDED'] as const;
export type RateStatus = typeof RATE_STATUSES[number];
export const RATE_UNITS = ['PER_WAGON', 'PER_TON', 'PER_CONTAINER', 'PER_KM', 'FLAT'] as const;
export const RATE_UNITS = [
'PER_WAGON',
'PER_TON',
'PER_CONTAINER',
'PER_KM',
'PER_INVOICE',
'FLAT',
] as const;
export type RateUnit = typeof RATE_UNITS[number];
/**
* Friendly, admin-facing category that determines how the rate is used in
* pricing and which fields the rate form shows. Replaces the cryptic
* `rateType` matrix for the configuration UI (rateType is still persisted and
* derived from `appliesTo` + `tradeDirection` + `trigger` for base-freight
* lookup and snapshots).
*
* - BULK / CONTAINER / INTERCITY : base rail freight (trigger = ALWAYS)
* - FIRST_MILE / LAST_MILE : pickup / delivery legs
* - OTHER : trigger-based surcharges (hazard, reefer …)
*/
export const RATE_APPLIES_TO = [
'BULK',
'CONTAINER',
'INTERCITY',
'FIRST_MILE',
'LAST_MILE',
'OTHER',
] as const;
export type RateAppliesTo = typeof RATE_APPLIES_TO[number];
/**
* What makes a rate apply to a booking. `ALWAYS` is base freight (matched by
* direction + container/bulk scope). Everything else is a surcharge that the
* rule engine adds on top, additively, when the booking matches the trigger —
* so hazard stacks on container/bulk with each line's own unit.
*/
export const RATE_TRIGGERS = [
'ALWAYS',
'HAZARDOUS',
'OVERWEIGHT',
'REEFER',
'SHIPPING_LINE',
'CONSOLIDATION',
'CANCELLATION',
'DEMURRAGE',
'PIL_EXTRA_FEE',
] as const;
export type RateTrigger = typeof RATE_TRIGGERS[number];
@Entity({ schema: 'freight', name: 'rates' })
@Index(['rateType'])
@Index(['status'])
@Index(['effectiveFrom'])
@Index(['containerTypeId'])
@Index(['trigger'])
export class Rate extends BaseEntity {
@Column({ name: 'rate_type', type: 'varchar', length: 50 })
rateType!: RateType;
@Column({ name: 'applies_to', type: 'varchar', length: 20, default: 'OTHER' })
appliesTo!: RateAppliesTo;
@Column({ name: 'trigger', type: 'varchar', length: 20, default: 'ALWAYS' })
trigger!: RateTrigger;
@Column({ name: 'container_type_id', type: 'uuid', nullable: true })
containerTypeId?: string | null;
@@ -46,6 +101,13 @@ export class Rate extends BaseEntity {
@JoinColumn({ name: 'container_type_id' })
containerType?: ContainerType | null;
@Column({ name: 'cargo_type_id', type: 'uuid', nullable: true })
cargoTypeId?: string | null;
@ManyToOne(() => CargoType, { nullable: true, eager: false })
@JoinColumn({ name: 'cargo_type_id' })
cargoType?: CargoType | null;
@Column({ name: 'trade_direction', type: 'varchar', length: 10, nullable: true })
tradeDirection?: string | null;

View File

@@ -1,38 +0,0 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { Rate } from './rate.entity';
const TRIGGER_CONDITIONS = [
'CARGO_FLAG_HAZARDOUS',
'CARGO_FLAG_REEFER',
'VGM_EXCEEDS_LIMIT',
'SHIPPING_LINE_MAPPED',
'CONSOLIDATION_ENABLED',
] as const;
export type TriggerCondition = typeof TRIGGER_CONDITIONS[number];
@Entity({ schema: 'freight', name: 'surcharge_types' })
@Index(['code'])
@Index(['isActive'])
@Index(['rateId'])
export class SurchargeType extends BaseEntity {
@Column({ name: 'code', type: 'varchar', length: 40, unique: true })
code!: string;
@Column({ name: 'label', type: 'varchar', length: 100, nullable: true })
label!: string;
@Column({ name: 'trigger_condition', type: 'varchar', length: 50, nullable: true })
triggerCondition!: TriggerCondition;
@Column({ name: 'rate_id', type: 'uuid', nullable: true })
rateId!: string;
@ManyToOne(() => Rate, { eager: false })
@JoinColumn({ name: 'rate_id' })
rate?: Rate;
@Column({ name: 'is_active', type: 'boolean', default: true })
isActive!: boolean;
}

View File

@@ -1,15 +0,0 @@
import { FindManyOptions } from 'typeorm';
import { SurchargeType } from '../entities/surcharge-type.entity';
export interface ISurchargeTypesRepository {
findById(id: string): Promise<SurchargeType | null>;
findByCode(code: string): Promise<SurchargeType | null>;
findAllActiveWithRate(): Promise<SurchargeType[]>;
findAll(options?: FindManyOptions<SurchargeType>): Promise<SurchargeType[]>;
findAndCount(options?: FindManyOptions<SurchargeType>): Promise<[SurchargeType[], number]>;
create(data: Partial<SurchargeType>): Promise<SurchargeType>;
update(id: string, data: Partial<SurchargeType>): Promise<SurchargeType | null>;
softDelete(id: string): Promise<void>;
}
export const SURCHARGE_TYPES_REPOSITORY = Symbol('SURCHARGE_TYPES_REPOSITORY');

View File

@@ -1,50 +0,0 @@
import { Injectable } from '@nestjs/common';
import { DataSource, FindManyOptions, Repository } from 'typeorm';
import { SurchargeType } from '../entities/surcharge-type.entity';
import { ISurchargeTypesRepository } from '../interfaces/surcharge-types.repository.interface';
@Injectable()
export class SurchargeTypesRepository implements ISurchargeTypesRepository {
private readonly repo: Repository<SurchargeType>;
constructor(private readonly dataSource: DataSource) {
this.repo = this.dataSource.getRepository(SurchargeType);
}
findById(id: string): Promise<SurchargeType | null> {
return this.repo.findOne({ where: { id } });
}
findByCode(code: string): Promise<SurchargeType | null> {
return this.repo.findOne({ where: { code } });
}
findAllActiveWithRate(): Promise<SurchargeType[]> {
return this.repo.find({
where: { isActive: true },
relations: { rate: true },
});
}
findAll(options?: FindManyOptions<SurchargeType>): Promise<SurchargeType[]> {
return this.repo.find(options);
}
findAndCount(options?: FindManyOptions<SurchargeType>): Promise<[SurchargeType[], number]> {
return this.repo.findAndCount(options);
}
async create(data: Partial<SurchargeType>): Promise<SurchargeType> {
const entity = this.repo.create(data);
return this.repo.save(entity);
}
async update(id: string, data: Partial<SurchargeType>): Promise<SurchargeType | null> {
await this.repo.update(id, data);
return this.findById(id);
}
async softDelete(id: string): Promise<void> {
await this.repo.softDelete(id);
}
}

View File

@@ -8,7 +8,6 @@ import { PriorityConfigsController } from './controllers/priority-configs.contro
import { RatesController } from './controllers/rates.controller';
import { ServiceTypesController } from './controllers/service-types.controller';
import { ShippingLinesController } from './controllers/shipping-lines.controller';
import { SurchargeTypesController } from './controllers/surcharge-types.controller';
import { WeightLimitRulesController } from './controllers/weight-limit-rules.controller';
import { YardsController } from './controllers/yards.controller';
@@ -19,7 +18,6 @@ import { PriorityConfig } from './entities/priority-config.entity';
import { Rate } from './entities/rate.entity';
import { ServiceType } from './entities/service-type.entity';
import { ShippingLine } from './entities/shipping-line.entity';
import { SurchargeType } from './entities/surcharge-type.entity';
import { WeightLimitRule } from './entities/weight-limit-rule.entity';
import { Yard } from './entities/yard.entity';
@@ -30,7 +28,6 @@ import { PRIORITY_CONFIGS_REPOSITORY } from './interfaces/priority-configs.repos
import { RATES_REPOSITORY } from './interfaces/rates.repository.interface';
import { SERVICE_TYPES_REPOSITORY } from './interfaces/service-types.repository.interface';
import { SHIPPING_LINES_REPOSITORY } from './interfaces/shipping-lines.repository.interface';
import { SURCHARGE_TYPES_REPOSITORY } from './interfaces/surcharge-types.repository.interface';
import { WEIGHT_LIMIT_RULES_REPOSITORY } from './interfaces/weight-limit-rules.repository.interface';
import { YARDS_REPOSITORY } from './interfaces/yards.repository.interface';
@@ -41,7 +38,6 @@ import { PriorityConfigsRepository } from './repositories/priority-configs.repos
import { RatesRepository } from './repositories/rates.repository';
import { ServiceTypesRepository } from './repositories/service-types.repository';
import { ShippingLinesRepository } from './repositories/shipping-lines.repository';
import { SurchargeTypesRepository } from './repositories/surcharge-types.repository';
import { WeightLimitRulesRepository } from './repositories/weight-limit-rules.repository';
import { YardsRepository } from './repositories/yards.repository';
@@ -53,7 +49,6 @@ import { PriorityConfigsService } from './services/priority-configs.service';
import { RatesService } from './services/rates.service';
import { ServiceTypesService } from './services/service-types.service';
import { ShippingLinesService } from './services/shipping-lines.service';
import { SurchargeTypesService } from './services/surcharge-types.service';
import { WeightLimitRulesService } from './services/weight-limit-rules.service';
import { YardsService } from './services/yards.service';
@@ -71,7 +66,6 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.
CargoType,
ContainerType,
PriorityConfig,
SurchargeType,
ServiceType,
WeightLimitRule,
Yard,
@@ -88,7 +82,6 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.
CargoTypesController,
ContainerTypesController,
PriorityConfigsController,
SurchargeTypesController,
ServiceTypesController,
WeightLimitRulesController,
YardsController,
@@ -103,8 +96,6 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.
{ provide: CONTAINER_TYPES_REPOSITORY, useExisting: ContainerTypesRepository },
PriorityConfigsRepository,
{ provide: PRIORITY_CONFIGS_REPOSITORY, useExisting: PriorityConfigsRepository },
SurchargeTypesRepository,
{ provide: SURCHARGE_TYPES_REPOSITORY, useExisting: SurchargeTypesRepository },
ServiceTypesRepository,
{ provide: SERVICE_TYPES_REPOSITORY, useExisting: ServiceTypesRepository },
WeightLimitRulesRepository,
@@ -120,7 +111,6 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.
CargoTypesService,
ContainerTypesService,
PriorityConfigsService,
SurchargeTypesService,
ServiceTypesService,
WeightLimitRulesService,
YardsService,
@@ -135,7 +125,6 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.
CargoTypesService,
ServiceTypesService,
ContainerTypesService,
SurchargeTypesService,
WeightLimitRulesService,
PriorityConfigsService,
YardsService,

View File

@@ -2,7 +2,7 @@ import { Inject, Injectable, BadRequestException } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { BookingApprovalStep } from '../bookings/entities/booking-approval-step.entity';
import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.entity';
import { TriggerCondition } from './entities/surcharge-type.entity';
import { Rate, RateTrigger } from './entities/rate.entity';
import {
ICargoTypesRepository,
CARGO_TYPES_REPOSITORY,
@@ -19,10 +19,6 @@ import {
IPriorityConfigsRepository,
PRIORITY_CONFIGS_REPOSITORY,
} from './interfaces/priority-configs.repository.interface';
import {
ISurchargeTypesRepository,
SURCHARGE_TYPES_REPOSITORY,
} from './interfaces/surcharge-types.repository.interface';
import {
IRatesRepository,
RATES_REPOSITORY,
@@ -55,6 +51,8 @@ export interface BookingEvaluationInput {
paymentCurrency: string;
tradeDirection: string;
isHazardous: boolean;
/** Booking-level reefer flag; ORed with per-container reefer. */
isReefer?: boolean;
isGovernment?: boolean;
allowConsolidation?: boolean;
shippingLineId?: string | null;
@@ -63,11 +61,12 @@ export interface BookingEvaluationInput {
}
export interface AppliedCargoModifier {
surchargeTypeId: string;
surchargeTypeCode: string;
/** The trigger-based rate that produced this surcharge line. */
rateId: string;
/** Stable display/audit code, derived from the rate's trigger + rateType. */
surchargeCode: string;
triggerValue: number | null;
calculatedAmount: number;
rateId: string;
currency: string;
}
@@ -98,8 +97,6 @@ export class RuleEngineService {
private readonly weightLimitRulesRepo: IWeightLimitRulesRepository,
@Inject(PRIORITY_CONFIGS_REPOSITORY)
private readonly priorityConfigsRepo: IPriorityConfigsRepository,
@Inject(SURCHARGE_TYPES_REPOSITORY)
private readonly surchargeTypesRepo: ISurchargeTypesRepository,
@Inject(RATES_REPOSITORY)
private readonly ratesRepo: IRatesRepository,
@Inject(APPROVAL_RULES_REPOSITORY)
@@ -200,15 +197,25 @@ export class RuleEngineService {
shippingLineMapped = Boolean(line?.mappedToCode);
}
const hasReefer = input.containers.some((c) => c.isReefer);
const hasReefer =
input.isReefer === true || input.containers.some((c) => c.isReefer);
const hasOverweight = containerWeightResults.some((r) => r.isOverweight);
const surchargeTypes = await this.surchargeTypesRepo.findAllActiveWithRate();
// Surcharges are now self-describing rates: any LIVE rate whose `trigger`
// is not ALWAYS. Each fires independently and stacks on top of base freight
// — hazard + reefer + overweight all add together, each with its own unit.
//
// A given surcharge identity (same trigger + rateType + unit + value +
// scope) must contribute exactly ONE line. Duplicate LIVE rate rows — e.g.
// from a non-idempotent seeder — would otherwise repeat the same surcharge
// many times and inflate the total, so we collapse them to one row each.
const liveRates = await this.ratesRepo.findLiveRates();
const rateById = new Map(liveRates.map((r) => [r.id, r]));
const surchargeRates = this.dedupeRatesBySignature(
liveRates.filter((r) => r.trigger && r.trigger !== 'ALWAYS'),
);
for (const st of surchargeTypes) {
const triggered = this.matchesTrigger(st.triggerCondition, {
for (const rate of surchargeRates) {
const triggered = this.matchesTrigger(rate.trigger, {
isHazardous: input.isHazardous,
hasReefer,
hasOverweight,
@@ -217,28 +224,28 @@ export class RuleEngineService {
});
if (!triggered) continue;
const rate = st.rate ?? rateById.get(st.rateId);
if (!rate) continue;
let triggerValue: number | null = null;
let calculatedAmount = Number(rate.rateValue);
if (st.triggerCondition === 'VGM_EXCEEDS_LIMIT') {
// Per-ton surcharges (typically OVERWEIGHT) bill against the excess tons.
if (rate.rateUnit === 'PER_TON' && rate.trigger === 'OVERWEIGHT') {
triggerValue = containerWeightResults.reduce(
(sum, r) => sum + (r.overweightExcessTons ?? 0),
0,
);
if (rate.rateUnit === 'PER_TON') {
calculatedAmount = triggerValue * Number(rate.rateValue);
}
calculatedAmount = triggerValue * Number(rate.rateValue);
}
// Safety guard: never include a surcharge with a non-positive amount (a
// zero-rate or zero-trigger line would otherwise show as a confusing
// "free" surcharge on the breakdown).
if (!(calculatedAmount > 0)) continue;
appliedModifiers.push({
surchargeTypeId: st.id,
surchargeTypeCode: st.code,
rateId: rate.id,
surchargeCode: this.surchargeCode(rate),
triggerValue,
calculatedAmount,
rateId: rate.id,
currency: rate.currency,
});
}
@@ -373,7 +380,7 @@ export class RuleEngineService {
}
private matchesTrigger(
condition: TriggerCondition,
trigger: RateTrigger,
state: {
isHazardous: boolean;
hasReefer: boolean;
@@ -382,19 +389,59 @@ export class RuleEngineService {
allowConsolidation: boolean;
},
): boolean {
switch (condition) {
case 'CARGO_FLAG_HAZARDOUS':
return state.isHazardous;
case 'CARGO_FLAG_REEFER':
return state.hasReefer;
case 'VGM_EXCEEDS_LIMIT':
return state.hasOverweight;
case 'SHIPPING_LINE_MAPPED':
return state.shippingLineMapped;
case 'CONSOLIDATION_ENABLED':
return state.allowConsolidation;
// Coerce defensively: a flag may arrive as the string "true"/"false" (e.g.
// from multipart form-data) and a non-empty "false" string is truthy.
const truthy = (v: unknown): boolean => v === true || v === 'true';
switch (trigger) {
case 'HAZARDOUS':
return truthy(state.isHazardous);
case 'REEFER':
return truthy(state.hasReefer);
case 'OVERWEIGHT':
return truthy(state.hasOverweight);
case 'SHIPPING_LINE':
return truthy(state.shippingLineMapped);
case 'CONSOLIDATION':
return truthy(state.allowConsolidation);
// CANCELLATION / DEMURRAGE / PIL_EXTRA_FEE are contextual charges applied
// explicitly elsewhere (not auto-triggered by a booking's cargo flags).
default:
return false;
}
}
/** Stable surcharge code for display + audit, derived from the rate. */
private surchargeCode(rate: Rate): string {
return rate.rateType ?? rate.trigger;
}
/**
* Collapse rates that describe the same charge to a single representative.
*
* Two rates are "the same" when they would produce an identical price line:
* same trigger, rateType, unit, value, currency, and scoping (container /
* cargo type). Duplicate rows (e.g. a seeder run more than once) therefore
* stack into one line instead of repeating — keeping the breakdown clean and
* the total correct. The first row of each signature is kept so an existing
* rateId is preserved for snapshotting.
*/
private dedupeRatesBySignature(rates: Rate[]): Rate[] {
const seen = new Set<string>();
const result: Rate[] = [];
for (const rate of rates) {
const signature = [
rate.trigger,
rate.rateType,
rate.rateUnit,
Number(rate.rateValue),
rate.currency,
rate.containerTypeId ?? '',
rate.cargoTypeId ?? '',
].join('|');
if (seen.has(signature)) continue;
seen.add(signature);
result.push(rate);
}
return result;
}
}

View File

@@ -2,6 +2,7 @@ import { BadRequestException, Inject, Injectable, NotFoundException } from '@nes
import { CreateRateDto } from '../dto/create-rate.dto';
import { UpdateRateDto } from '../dto/update-rate.dto';
import { Rate } from '../entities/rate.entity';
import { deriveRateType } from '../entities/rate-type.util';
import { IRatesRepository, RATES_REPOSITORY } from '../interfaces/rates.repository.interface';
@Injectable()
@@ -47,10 +48,27 @@ export class RatesService {
/** Create a rate in DRAFT status. */
async create(dto: CreateRateDto, proposedByStaffId: string): Promise<Rate> {
const appliesTo = dto.appliesTo as Rate['appliesTo'];
const trigger = dto.trigger as Rate['trigger'];
// Surcharges (trigger ≠ ALWAYS) carry no direction/scope — clear them so
// the engine never accidentally narrows a surcharge by container/direction.
const isSurcharge = trigger !== 'ALWAYS';
const containerTypeId = isSurcharge ? null : (dto.containerTypeId ?? null);
const cargoTypeId = isSurcharge ? null : (dto.cargoTypeId ?? null);
const tradeDirection = isSurcharge ? null : (dto.tradeDirection ?? null);
return this.repository.create({
rateType: dto.rateType as Rate['rateType'],
containerTypeId: dto.containerTypeId,
tradeDirection: dto.tradeDirection,
appliesTo,
trigger,
rateType: deriveRateType({
appliesTo,
trigger,
tradeDirection,
isBulk: Boolean(cargoTypeId),
}),
containerTypeId,
cargoTypeId,
tradeDirection,
currency: dto.currency ?? 'USD',
rateValue: dto.rateValue,
rateUnit: dto.rateUnit as Rate['rateUnit'],
@@ -68,9 +86,41 @@ export class RatesService {
throw new BadRequestException('Only DRAFT rates can be updated');
}
const updates: Partial<Rate> = {};
if (dto.rateType) updates.rateType = dto.rateType as Rate['rateType'];
if (dto.containerTypeId !== undefined) updates.containerTypeId = dto.containerTypeId;
if (dto.tradeDirection !== undefined) updates.tradeDirection = dto.tradeDirection;
const appliesTo = (dto.appliesTo as Rate['appliesTo']) ?? existing.appliesTo;
const trigger = (dto.trigger as Rate['trigger']) ?? existing.trigger;
const isSurcharge = trigger !== 'ALWAYS';
if (dto.appliesTo) updates.appliesTo = appliesTo;
if (dto.trigger) updates.trigger = trigger;
const containerTypeId = isSurcharge
? null
: dto.containerTypeId !== undefined
? dto.containerTypeId
: existing.containerTypeId;
const cargoTypeId = isSurcharge
? null
: dto.cargoTypeId !== undefined
? dto.cargoTypeId
: existing.cargoTypeId;
const tradeDirection = isSurcharge
? null
: dto.tradeDirection !== undefined
? dto.tradeDirection
: existing.tradeDirection;
updates.containerTypeId = containerTypeId;
updates.cargoTypeId = cargoTypeId;
updates.tradeDirection = tradeDirection;
// Keep the derived rateType in sync with whatever changed.
updates.rateType = deriveRateType({
appliesTo,
trigger,
tradeDirection,
isBulk: Boolean(cargoTypeId),
});
updates.currency = dto.currency ?? existing.currency ?? 'USD';
if (dto.rateValue !== undefined) updates.rateValue = dto.rateValue;
if (dto.rateUnit) updates.rateUnit = dto.rateUnit as Rate['rateUnit'];

View File

@@ -1,78 +0,0 @@
import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common';
import { generateCode } from '../../../common/utils/generate-code.util';
import { CreateSurchargeTypeDto } from '../dto/create-surcharge-type.dto';
import { UpdateSurchargeTypeDto } from '../dto/update-surcharge-type.dto';
import { SurchargeType } from '../entities/surcharge-type.entity';
import {
ISurchargeTypesRepository,
SURCHARGE_TYPES_REPOSITORY,
} from '../interfaces/surcharge-types.repository.interface';
@Injectable()
export class SurchargeTypesService {
constructor(
@Inject(SURCHARGE_TYPES_REPOSITORY)
private readonly repository: ISurchargeTypesRepository,
) {}
/** List surcharge types with pagination. */
async findAll(filter: {
isActive?: boolean;
page?: number;
pageSize?: number;
}): Promise<{ data: SurchargeType[]; meta: { total: number; page: number; pageSize: number; totalPages: number } }> {
const page = filter.page ?? 1;
const pageSize = filter.pageSize ?? 20;
const where: Record<string, unknown> = {};
if (filter.isActive !== undefined) where.isActive = filter.isActive;
const [data, total] = await this.repository.findAndCount({
where,
relations: { rate: true },
order: { label: 'ASC' },
skip: (page - 1) * pageSize,
take: pageSize,
});
return { data, meta: { total, page, pageSize, totalPages: Math.ceil(total / pageSize) } };
}
/** Get a single surcharge type by ID. */
async findById(id: string): Promise<SurchargeType> {
const entity = await this.repository.findById(id);
if (!entity) throw new NotFoundException(`Surcharge type ${id} not found`);
return entity;
}
/** Create a new surcharge type. */
async create(dto: CreateSurchargeTypeDto): Promise<SurchargeType> {
const code = generateCode(dto.label);
const existing = await this.repository.findByCode(code);
if (existing) throw new ConflictException(`Surcharge type with label "${dto.label}" conflicts with existing code "${code}"`);
return this.repository.create({
code,
label: dto.label,
triggerCondition: dto.triggerCondition as SurchargeType['triggerCondition'],
rateId: dto.rateId,
isActive: dto.isActive ?? true,
});
}
/** Update an existing surcharge type. */
async update(id: string, dto: UpdateSurchargeTypeDto): Promise<SurchargeType> {
await this.findById(id);
const patch: Partial<SurchargeType> = {};
if (dto.label !== undefined) patch.label = dto.label;
if (dto.triggerCondition !== undefined) patch.triggerCondition = dto.triggerCondition as SurchargeType['triggerCondition'];
if (dto.rateId !== undefined) patch.rateId = dto.rateId;
if (dto.isActive !== undefined) patch.isActive = dto.isActive;
const updated = await this.repository.update(id, patch);
if (!updated) throw new NotFoundException(`Surcharge type ${id} not found`);
return updated;
}
/** Soft-delete a surcharge type. */
async remove(id: string): Promise<void> {
await this.findById(id);
await this.repository.softDelete(id);
}
}

View File

@@ -216,6 +216,26 @@ export class BookingBatchService implements OnModuleInit {
);
}
/**
* Fire-and-forget batch pipeline for a (route, day) directly — used when a
* booking enters the pool without a target train yet (e.g. after the
* operations team accepts an operation request). The booking is already
* FULLY_EXECUTED with its scheduled_date set, so the day-level fill will pick
* it up; this just runs that fill immediately instead of waiting for the cron.
*/
enqueueRouteDayProcessing(
originYardId: string,
destinationYardId: string,
day: string,
): void {
void this.processRouteDay({ originYardId, destinationYardId, day }).catch(
(err) =>
this.logger.error(
`processRouteDay for ${originYardId}${destinationYardId} on ${day} failed: ${(err as Error).message}`,
),
);
}
/** Resolve a schedule's (route, day) group and run the day-level pipeline. */
private async processRouteDayForSchedule(scheduleId: string): Promise<void> {
const schedule = await this.trainSchedulesRepository.findById(scheduleId);

View File

@@ -436,7 +436,7 @@ export class TrainSchedulingController {
return this.trainSchedulingService.cancelTrainSchedule(id);
}
@Post("bulk/schedules/:id/cancel")
@Post('bulk/schedules/:id/cancel')
@TrainSchedulingManage()
@ApiOperation({ summary: "Cancel bulk train schedule" })
cancelBulkTrainSchedule(@Param("id", ParseUUIDPipe) id: string) {

View File

@@ -1,4 +1,4 @@
import {
import {
AllocationLoadType,
SchedulingStatus,
TrainCheckpointKind,
@@ -931,6 +931,19 @@ 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],
);
if (schedule.trainSet?.locomotiveId) {
const loco = await manager
.getRepository(Locomotive)

View File

@@ -37,4 +37,16 @@ export class CreateVehicleDto {
@IsOptional()
@IsString()
assignedDriverName?: string;
@IsOptional()
@IsString()
code?: string;
@IsOptional()
@IsString()
powerPlateNo?: string;
@IsOptional()
@IsString()
trailerPlateNo?: string;
}

View File

@@ -62,4 +62,13 @@ export class Vehicle extends BaseEntity {
@Column({ name: 'assigned_driver_name', nullable: true })
assignedDriverName?: string;
@Column({ name: 'code', nullable: true })
code?: string;
@Column({ name: 'power_plate_no', nullable: true })
powerPlateNo?: string;
@Column({ name: 'trailer_plate_no', nullable: true })
trailerPlateNo?: string;
}

View File

@@ -67,13 +67,12 @@ export class VehiclesRepository extends BaseRepository<Vehicle> {
};
}
async createVehicle(vehicleData: any): Promise<Vehicle> {
async createVehicle(vehicleData: Partial<Vehicle>): Promise<Vehicle> {
const vehicle = this.repository.create(vehicleData);
const vehicles = await this.repository.save(vehicle);
return vehicles?.[0] as Vehicle;
return this.repository.save(vehicle);
}
async updateVehicle(vehicle: Vehicle): Promise<Vehicle> {
return (await this.repository.save(vehicle)) as Vehicle;
return this.repository.save(vehicle);
}
}

View File

@@ -11,6 +11,8 @@ import { Yard } from '../../rule-engine/entities/yard.entity';
export const WAGON_STATUSES = [
WagonStatus.Available,
WagonStatus.Assigned,
WagonStatus.ImportReady,
WagonStatus.ExportReady,
WagonStatus.Maintenance,
WagonStatus.Retired,
] as const;
@@ -65,7 +67,7 @@ export class Wagon extends BaseEntity {
@JoinColumn({ name: 'current_train_schedule_id' })
currentTrainSchedule?: TrainSchedule | null;
/** Fleet master consist grouping separate from operational train_schedules. */
/** Fleet master consist grouping — separate from operational train_schedules. */
@ManyToOne(() => Train, (train) => train.wagons, { onDelete: 'SET NULL' })
@JoinColumn({ name: 'train_id' })
train!: Train | null;

View File

@@ -12,6 +12,11 @@ export class FilterWarehouseInventoryDto {
@IsUUID()
warehouseId?: string;
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID()
facilityId?: string;
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID()
@@ -51,4 +56,14 @@ export class FilterWarehouseInventoryDto {
@IsOptional()
@IsString()
search?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
dateFrom?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
dateTo?: string;
}

View File

@@ -10,6 +10,11 @@ export class InquiryWarehouseInventoryDto {
@ApiPropertyOptional()
@IsOptional()
@IsString()
bookingReference?: string;
@ApiPropertyOptional({ description: 'Legacy alias for bookingReference' })
@IsOptional()
@IsString()
bookingNumber?: string;
@ApiPropertyOptional()

View File

@@ -78,17 +78,13 @@ export class WarehouseAllocationService {
/** Resolve a concrete warehouse/yard/zone for the given criteria, or null if none configured. */
async resolveLocation(criteria: AllocationCriteria): Promise<AllocationResult | null> {
const rule = await this.findMatchingRule(criteria);
const yardCode = rule?.targetYardCode;
if (!rule) return null;
// Resolve yard (by rule code, else first available yard with a zone).
// Resolve yard by rule code.
const [yard] = await this.dataSource.query(
yardCode
? `SELECT y.id, y.warehouse_id AS "warehouseId", y.name FROM freight.warehouse_yards y
WHERE y.code = $1 AND y.deleted_at IS NULL LIMIT 1`
: `SELECT y.id, y.warehouse_id AS "warehouseId", y.name FROM freight.warehouse_yards y
JOIN freight.warehouse_zones z ON z.yard_id = y.id AND z.deleted_at IS NULL
WHERE y.deleted_at IS NULL ORDER BY y.created_at ASC LIMIT 1`,
yardCode ? [yardCode] : [],
`SELECT y.id, y.warehouse_id AS "warehouseId", y.name FROM freight.warehouse_yards y
WHERE y.code = $1 AND y.deleted_at IS NULL LIMIT 1`,
[rule.targetYardCode],
);
if (!yard) return null;

View File

@@ -1,5 +1,5 @@
import { Injectable } from '@nestjs/common';
import { DataSource, IsNull } from 'typeorm';
import { DataSource, FindManyOptions, IsNull, ObjectLiteral, Repository } from 'typeorm';
import { Warehouse } from './entities/warehouse.entity';
import { WarehouseInventory } from './entities/warehouse-inventory.entity';
@@ -26,6 +26,29 @@ export interface WarehouseDashboard {
export class WarehouseDashboardService {
constructor(private readonly dataSource: DataSource) {}
private async safeCount<T extends ObjectLiteral>(
repo: Repository<T>,
options?: FindManyOptions<T>,
): Promise<number> {
try {
return await repo.count(options);
} catch {
return 0;
}
}
private async safeReceivedToday(startOfToday: Date): Promise<number> {
try {
return await this.dataSource
.getRepository(WarehouseInventory)
.createQueryBuilder('inv')
.where('inv.arrived_at >= :start', { start: startOfToday })
.getCount();
} catch {
return 0;
}
}
async getDashboard(): Promise<WarehouseDashboard> {
const warehouseRepo = this.dataSource.getRepository(Warehouse);
const inventoryRepo = this.dataSource.getRepository(WarehouseInventory);
@@ -47,21 +70,18 @@ export class WarehouseDashboardService {
delivered,
receivedToday,
] = await Promise.all([
warehouseRepo.count(),
inventoryRepo.count(),
inventoryRepo.count({ where: { status: 'RECEIVED', inspectionStatus: IsNull() } }),
inventoryRepo.count({ where: { inspectionStatus: 'PASSED' } }),
inventoryRepo.count({ where: { status: 'STORED' } }),
inventoryRepo.count({ where: { status: 'RESERVED' } }),
inventoryRepo.count({ where: { status: 'READY_FOR_LOADING' } }),
inventoryRepo.count({ where: { status: 'LOADED' } }),
inventoryRepo.count({ where: { status: 'DISPATCHED' } }),
inventoryRepo.count({ where: { status: 'READY_FOR_PICKUP' } }),
inventoryRepo.count({ where: { status: 'DELIVERED' } }),
inventoryRepo
.createQueryBuilder('inv')
.where('inv.arrived_at >= :start', { start: startOfToday })
.getCount(),
this.safeCount(warehouseRepo),
this.safeCount(inventoryRepo),
this.safeCount(inventoryRepo, { where: { status: 'RECEIVED', inspectionStatus: IsNull() } }),
this.safeCount(inventoryRepo, { where: { inspectionStatus: 'PASSED' } }),
this.safeCount(inventoryRepo, { where: { status: 'STORED' } }),
this.safeCount(inventoryRepo, { where: { status: 'RESERVED' } }),
this.safeCount(inventoryRepo, { where: { status: 'READY_FOR_LOADING' } }),
this.safeCount(inventoryRepo, { where: { status: 'LOADED' } }),
this.safeCount(inventoryRepo, { where: { status: 'DISPATCHED' } }),
this.safeCount(inventoryRepo, { where: { status: 'READY_FOR_PICKUP' } }),
this.safeCount(inventoryRepo, { where: { status: 'DELIVERED' } }),
this.safeReceivedToday(startOfToday),
]);
return {

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