Release Order plus Storage Allocation Rule and fee

This commit is contained in:
hagiye
2026-06-24 15:27:38 +03:00
174 changed files with 11729 additions and 3162 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

@@ -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,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;
}
@@ -363,9 +373,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({
@@ -376,7 +386,6 @@ export class BookingsService {
tradeDirection,
isHazardous: dto.isHazardous,
isGovernment,
allowConsolidation,
shippingLineId: dto.shippingLineId,
containers,
});
@@ -397,7 +406,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,
@@ -416,7 +431,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',
@@ -436,6 +450,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);
@@ -444,9 +477,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);
@@ -505,12 +565,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({
@@ -520,7 +577,6 @@ export class BookingsService {
paymentCurrency: dto.paymentCurrency ?? existing.paymentCurrency,
tradeDirection,
isHazardous: dto.isHazardous ?? existing.isHazardous,
allowConsolidation,
shippingLineId: dto.shippingLineId ?? existing.shippingLineId ?? undefined,
containers,
});
@@ -529,12 +585,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,
);
@@ -542,7 +597,6 @@ export class BookingsService {
...dto,
freightType,
cargoTypeId: freightType === 'BULK' ? cargoTypeId : null,
allowConsolidation,
priorityScore: ruleResult.priorityScore,
tradeDirection,
};
@@ -592,7 +646,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);
@@ -656,10 +710,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,
@@ -670,7 +725,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,
@@ -694,18 +748,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,
});
@@ -841,7 +892,6 @@ export class BookingsService {
paymentStatus: filter.paymentStatus,
createdFrom: filter.createdFrom,
createdTo: filter.createdTo,
allowConsolidation: filter.allowConsolidation,
consolidationPaired: filter.consolidationPaired,
};
@@ -950,10 +1000,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,
);
@@ -1037,14 +1083,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;
}
@@ -1057,18 +1102,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)
@@ -1083,8 +1124,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,
@@ -86,8 +87,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

@@ -7,7 +7,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 { ETradeService } from "./services/etrade.service";
import { normalizeE164 } from "../../common/validators/is-phone-number.validator";
@@ -321,6 +324,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.
@@ -328,17 +332,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);
@@ -841,8 +845,9 @@ export class CompaniesService {
onboardingCompleted: true,
onboardingStep: "done",
});
// Awaiting backoffice approval — stays Pending until an admin activates it.
await this.companiesRepo.update(companyId, {
status: CompanyStatus.Active,
status: CompanyStatus.Pending,
});
return this.getCompanyInfoByUserId(userId);
}
@@ -910,6 +915,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,14 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { BookingsModule } from '../bookings/bookings.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]), BookingsModule],
controllers: [FirstMileController],
providers: [FirstMileRepository, FirstMileService],
exports: [FirstMileRepository, FirstMileService],

View File

@@ -1,6 +1,7 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { FindOptionsWhere } from 'typeorm';
import { BookingsRepository } from '../bookings/bookings.repository';
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 +26,32 @@ const SORTABLE_FIELDS: (keyof FirstMile)[] = [
@Injectable()
export class FirstMileService {
constructor(private readonly firstMileRepository: FirstMileRepository) {}
constructor(
private readonly firstMileRepository: FirstMileRepository,
private readonly bookingsRepository: BookingsRepository,
) {}
/**
* 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 +71,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 +93,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) {

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,14 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { BookingsModule } from '../bookings/bookings.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],
controllers: [LastMileController],
providers: [LastMileRepository, LastMileService],
exports: [LastMileRepository, LastMileService],

View File

@@ -1,6 +1,7 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { FindOptionsWhere } from 'typeorm';
import { BookingsRepository } from '../bookings/bookings.repository';
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 +26,29 @@ const SORTABLE_FIELDS: (keyof LastMile)[] = [
@Injectable()
export class LastMileService {
constructor(private readonly lastMileRepository: LastMileRepository) {}
constructor(
private readonly lastMileRepository: LastMileRepository,
private readonly bookingsRepository: BookingsRepository,
) {}
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 +68,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 +90,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) {

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

@@ -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

@@ -386,9 +386,7 @@ export class DemoBookingsSeeder {
shippingLineId: null,
cargoTotalWeightVgm: demoBooking.totalWeightTons,
isHazardous: false,
paymentCurrency: "ETB",
allowConsolidation: false,
priorityScore: 0,
paymentCurrency: "ETB", priorityScore: 0,
versionNumber: 1,
},
{ conflictPaths: { reference: true } },
@@ -459,9 +457,7 @@ export class DemoBookingsSeeder {
shippingLineId: null,
cargoTotalWeightVgm: demoBulk.totalWeightTons,
isHazardous: false,
paymentCurrency: "USD",
allowConsolidation: false,
priorityScore: 10,
paymentCurrency: "USD", priorityScore: 10,
schedulingStatus: "HOLDING",
versionNumber: 1,
},

View File

@@ -20,12 +20,13 @@ import { DEFAULT_APPROVAL_RULE_ROWS } from '../modules/rule-engine/approval-rule
const EDR_ORG_KEY = 'edr_freight';
const MIN_WAGONS_PER_TYPE = 100;
/** The four demo staff users, each mapped to a seeded freight role. */
/** The demo staff users, each mapped to a seeded freight role. */
const DEMO_STAFF_USERS = [
{ email: 'marketing@edr.local', username: 'marketing', roleKey: 'edr_marketing' },
{ email: 'operations@edr.local', username: 'operations', roleKey: 'edr_operations_officer' },
{ email: 'director@edr.local', username: 'director', roleKey: 'edr_director' },
{ email: 'ceo@edr.local', username: 'ceo', roleKey: 'edr_ceo' },
{ email: 'gl@edr.local', username: 'gl', roleKey: 'edr_global_logistics' },
] as const;
/**

View File

@@ -237,6 +237,11 @@ export const EDR_FREIGHT_ROLES: FreightSeedRole[] = [
name: { en: "EDR Marketing" },
permissionKeys: [...ROLE_PERMISSION_PRESETS.marketing],
},
{
key: "edr_global_logistics",
name: { en: "EDR Global Logistics" },
permissionKeys: [...ROLE_PERMISSION_PRESETS.globalLogistics],
},
{
key: "edr_org_manager",
name: { en: "EDR Org Manager" },

View File

@@ -192,6 +192,169 @@ const COMPANY_ONBOARDING_DOCUMENTS: OnboardingDocumentSetting[] = [
const COMPANY_ONBOARDING_DESCRIPTION =
"Required documents for external company onboarding, by company nationality.";
// ── Clearance document settings ────────────────────────────────────────────
// Operation/clearance documents collected after contract counter-sign, resolved
// at runtime from (operationType, freightType, includesCustoms). The `entity`
// is "booking_clearance" so the backoffice file-settings editor can filter them.
// Two kinds of set per customs category: a CUSTOMER-INPUT set (the customer
// uploads) and a GL-OUTPUT set (Global Logistics uploads the customs outputs).
const JPG_EXTENSIONS = ["jpg", "jpeg", "png", "pdf"];
const CLEARANCE_ENTITY = "booking_clearance";
/** Build a clearance field with sensible defaults; `critical` marks isRequired. */
function clearanceField(
fileKey: string,
fileLabel: string,
displayOrder: number,
opts?: { required?: boolean; help?: string; extensions?: string[] },
): OnboardingField {
return {
fileKey,
fileLabel,
helpText: opts?.help ?? "",
isRequired: opts?.required ?? true,
isMultiple: false,
maxFiles: 1,
allowedExtensions: opts?.extensions ?? DOC_EXTENSIONS,
maxSizeMb: 10,
displayOrder,
};
}
/** Documents shared by every container import category (with/without customs). */
const IMPORT_CONTAINER_FIELDS: OnboardingField[] = [
clearanceField("commercial_invoice", "Commercial Invoice", 1),
clearanceField("packing_list", "Packing List", 2),
clearanceField("import_license", "Import License", 3),
clearanceField("certificate_of_origin", "Certificate of Origin", 4),
clearanceField(
"external_freight_cost",
"External Freight Cost / Checkup Documentation",
5,
),
clearanceField("bill_of_lading", "Bill of Lading / Railway Bill", 6),
clearanceField("vgm", "Verified Gross Mass (VGM)", 7, { required: true }),
clearanceField("release_order", "Release Order", 8, { required: true }),
];
/** Documents shared by every container export category (with/without customs). */
const EXPORT_CONTAINER_FIELDS: OnboardingField[] = [
clearanceField("booking_confirmation", "Booking Confirmation", 1),
clearanceField("commercial_invoice", "Commercial Invoice", 2),
clearanceField("packing_list", "Packing List", 3),
clearanceField("shipping_instruction", "Shipping Instruction", 4),
clearanceField("bank_permit", "Bank Permit", 5),
clearanceField("export_license", "Export License", 6),
clearanceField("vgm_letter", "VGM Letter", 7, { required: true }),
clearanceField("railway_bill", "Railway Bill", 8),
clearanceField("delegation_letter", "Delegation Letter / POA", 9, {
required: false,
help: "Required only if EDR manages all transit activity.",
}),
];
/** Bulk import documents (shorter, transit-focused set). */
const IMPORT_BULK_FIELDS: OnboardingField[] = [
clearanceField("packing_list", "Packing List", 1, { required: true }),
clearanceField("bill_of_loading", "Bill of Loading", 2, { required: true }),
clearanceField("port_invoice", "Port Invoice", 3),
];
/** Bulk export documents (transit/customs corridor docs). */
const EXPORT_BULK_FIELDS: OnboardingField[] = [
clearanceField("release_order_djibouti", "Release Order (Djibouti)", 1),
clearanceField("port_gate_pass", "Port Gate Pass", 2),
clearanceField("port_invoice", "Port Invoice", 3),
];
/** GL-uploaded customs output documents (import container). */
const IMPORT_CONTAINER_OUTPUT_FIELDS: OnboardingField[] = [
clearanceField("im4", "IM4 — Permanent Import Document", 1),
clearanceField("im5", "IM5 — Temporary Import Document", 2, {
required: false,
}),
clearanceField("transit_permitted", "Transit Permitted Screenshot", 3, {
extensions: JPG_EXTENSIONS,
}),
];
/** GL-uploaded customs output documents (export container). */
const EXPORT_CONTAINER_OUTPUT_FIELDS: OnboardingField[] = [
clearanceField("ex3", "EX3 — Permanent Export Document", 1),
clearanceField("ex8", "EX8 — Export Transit Document", 2),
clearanceField("export_release", "Export Release", 3),
clearanceField("t1", "T1 — Transport Document", 4),
];
const CLEARANCE_DOCUMENT_SETTINGS: OnboardingDocumentSetting[] = [
// ── Customer-input sets ──
{
code: "clearance_import_container_with_customs",
label: "Import container clearance documents (with customs)",
entity: CLEARANCE_ENTITY,
fields: IMPORT_CONTAINER_FIELDS,
},
{
code: "clearance_import_container_without_customs",
label: "Import container documents (without customs)",
entity: CLEARANCE_ENTITY,
fields: IMPORT_CONTAINER_FIELDS,
},
{
code: "clearance_export_container_with_customs",
label: "Export container clearance documents (with customs)",
entity: CLEARANCE_ENTITY,
fields: EXPORT_CONTAINER_FIELDS,
},
{
code: "clearance_export_container_without_customs",
label: "Export container documents (without customs)",
entity: CLEARANCE_ENTITY,
fields: EXPORT_CONTAINER_FIELDS,
},
{
code: "clearance_import_bulk_with_customs",
label: "Import bulk clearance documents (with customs)",
entity: CLEARANCE_ENTITY,
fields: IMPORT_BULK_FIELDS,
},
{
code: "clearance_import_bulk_without_customs",
label: "Import bulk documents (without customs)",
entity: CLEARANCE_ENTITY,
fields: IMPORT_BULK_FIELDS,
},
{
code: "clearance_export_bulk_with_customs",
label: "Export bulk clearance documents (with customs)",
entity: CLEARANCE_ENTITY,
fields: EXPORT_BULK_FIELDS,
},
{
code: "clearance_export_bulk_without_customs",
label: "Export bulk documents (without customs)",
entity: CLEARANCE_ENTITY,
fields: EXPORT_BULK_FIELDS,
},
// ── GL-output sets (customs only) ──
{
code: "clearance_output_import_container",
label: "Customs output documents (import container)",
entity: CLEARANCE_ENTITY,
fields: IMPORT_CONTAINER_OUTPUT_FIELDS,
},
{
code: "clearance_output_export_container",
label: "Customs output documents (export container)",
entity: CLEARANCE_ENTITY,
fields: EXPORT_CONTAINER_OUTPUT_FIELDS,
},
];
const CLEARANCE_DESCRIPTION =
"Operation/clearance documents collected after contract execution, by operation, freight type and customs.";
@Injectable()
export class FileUploadSettingsSeeder {
private readonly logger = new Logger(FileUploadSettingsSeeder.name);
@@ -203,12 +366,25 @@ export class FileUploadSettingsSeeder {
const settingRepository = manager.getRepository(FileUploadSetting);
const fieldRepository = manager.getRepository(FileUploadField);
for (const documentSetting of COMPANY_ONBOARDING_DOCUMENTS) {
const allSettings: Array<
OnboardingDocumentSetting & { description: string }
> = [
...COMPANY_ONBOARDING_DOCUMENTS.map((s) => ({
...s,
description: COMPANY_ONBOARDING_DESCRIPTION,
})),
...CLEARANCE_DOCUMENT_SETTINGS.map((s) => ({
...s,
description: CLEARANCE_DESCRIPTION,
})),
];
for (const documentSetting of allSettings) {
await settingRepository.upsert(
{
code: documentSetting.code,
label: documentSetting.label,
description: COMPANY_ONBOARDING_DESCRIPTION,
description: documentSetting.description,
entity: documentSetting.entity,
},
{
@@ -245,7 +421,7 @@ export class FileUploadSettingsSeeder {
});
this.logger.log(
"Ensured company onboarding file upload settings for external companies",
"Ensured company onboarding + booking clearance file upload settings",
);
}
}

View File

@@ -15,7 +15,6 @@ export const RULE_ENGINE_RESOURCE_SLUGS = [
'yards',
'shipping-lines',
'weight-limit-rules',
'surcharge-types',
'priority-configs',
'rates',
'approval-rules',
@@ -52,6 +51,9 @@ export const BOOKING_PERMISSIONS: FreightPermissionSeed[] = [
perm('a1000001-0001-4000-8000-00000000000c', 'edr_freight_app:bookings:payment_verify', 'Verify payment'),
perm('a1000001-0001-4000-8000-00000000000d', 'edr_freight_app:bookings:operations', 'Booking operations'),
perm('a1000001-0001-4000-8000-00000000000e', 'edr_freight_app:bookings:cancel', 'Cancel booking'),
perm('a1000001-0001-4000-8000-000000000020', 'edr_freight_app:bookings:review_documents', 'Review clearance documents'),
perm('a1000001-0001-4000-8000-000000000021', 'edr_freight_app:bookings:upload_clearance_output', 'Upload customs output documents'),
perm('a1000001-0001-4000-8000-000000000022', 'edr_freight_app:bookings:finalize_clearance', 'Finalize document clearance'),
perm('a1000001-0001-4000-8000-00000000000f', 'edr_freight_app:train_scheduling:view', 'View train scheduling'),
perm('a1000001-0001-4000-8000-000000000010', 'edr_freight_app:train_scheduling:manage', 'Manage train scheduling'),
perm('a1000001-0001-4000-8000-000000000011', 'edr_freight_app:fleet:view', 'View fleet'),
@@ -67,7 +69,6 @@ const RULE_ENGINE_PERMISSION_IDS: Record<RuleEngineResourceSlug, { view: string;
yards: { view: 'b2000001-0001-4000-8000-000000000007', manage: 'b2000001-0001-4000-8000-000000000008' },
'shipping-lines': { view: 'b2000001-0001-4000-8000-000000000009', manage: 'b2000001-0001-4000-8000-00000000000a' },
'weight-limit-rules': { view: 'b2000001-0001-4000-8000-00000000000b', manage: 'b2000001-0001-4000-8000-00000000000c' },
'surcharge-types': { view: 'b2000001-0001-4000-8000-00000000000d', manage: 'b2000001-0001-4000-8000-00000000000e' },
'priority-configs': { view: 'b2000001-0001-4000-8000-00000000000f', manage: 'b2000001-0001-4000-8000-000000000010' },
rates: { view: 'b2000001-0001-4000-8000-000000000011', manage: 'b2000001-0001-4000-8000-000000000012' },
'approval-rules': { view: 'b2000001-0001-4000-8000-000000000013', manage: 'b2000001-0001-4000-8000-000000000014' },
@@ -107,6 +108,9 @@ export const FREIGHT_PERMS = {
signStaff: 'edr_freight_app:bookings:sign_staff',
operations: 'edr_freight_app:bookings:operations',
cancel: 'edr_freight_app:bookings:cancel',
reviewDocuments: 'edr_freight_app:bookings:review_documents',
uploadClearanceOutput: 'edr_freight_app:bookings:upload_clearance_output',
finalizeClearance: 'edr_freight_app:bookings:finalize_clearance',
},
trainScheduling: {
view: 'edr_freight_app:train_scheduling:view',
@@ -167,6 +171,14 @@ export const ROLE_PERMISSION_PRESETS = {
...allRuleEngineViewKeys(),
],
finance: [FREIGHT_PERMS.bookings.view],
// Global Logistics: reviews post-counter-sign clearance documents, uploads
// customs output documents, and finalizes the clearance gate.
globalLogistics: [
FREIGHT_PERMS.bookings.view,
FREIGHT_PERMS.bookings.reviewDocuments,
FREIGHT_PERMS.bookings.uploadClearanceOutput,
FREIGHT_PERMS.bookings.finalizeClearance,
],
// Marketing handles intake through contract (same as line staff here).
marketing: [
FREIGHT_PERMS.bookings.view,

View File

@@ -18,6 +18,7 @@ const STAFF_USERS = [
{ email: 'linestaff@edr.local', username: 'linestaff', roleKey: 'edr_line_staff' },
{ email: 'director@edr.local', username: 'director', roleKey: 'edr_director' },
{ email: 'ceo@edr.local', username: 'ceo', roleKey: 'edr_ceo' },
{ email: 'gl@edr.local', username: 'gl', roleKey: 'edr_global_logistics' },
] as const;
@Injectable()
@@ -122,6 +123,8 @@ export class FreightStaffUsersSeeder {
}
});
this.logger.log('Ensured freight staff users (linestaff@, director@, ceo@)');
this.logger.log(
'Ensured freight staff users (linestaff@, director@, ceo@, gl@)',
);
}
}

View File

@@ -8,7 +8,6 @@ import { PriorityConfig } from "../modules/rule-engine/entities/priority-config.
import { Rate } from "../modules/rule-engine/entities/rate.entity";
import { ServiceType } from "../modules/rule-engine/entities/service-type.entity";
import { ShippingLine } from "../modules/rule-engine/entities/shipping-line.entity";
import { SurchargeType } from "../modules/rule-engine/entities/surcharge-type.entity";
import { WeightLimitRule } from "../modules/rule-engine/entities/weight-limit-rule.entity";
import { Route } from "../modules/routes/entities/route.entity";
import { RouteMilestone } from "../modules/routes/entities/route-milestone.entity";
@@ -40,15 +39,13 @@ export class PricingDataSeeder {
const containerTypes = await ctRepo.find();
const ctByCode = new Map(containerTypes.map((ct) => [ct.code, ct]));
const rates = await this.seedRates(rRepo, ctByCode);
const ratesByType = new Map<string, Rate[]>();
for (const r of rates) {
const key = `${r.rateType}|${r.currency}|${r.containerTypeId ?? ""}`;
if (!ratesByType.has(key)) ratesByType.set(key, []);
ratesByType.get(key)!.push(r);
}
// Clear booking cargo modifiers up front — they reference rate snapshots
// that get recomputed when bookings are repriced.
await manager.getRepository(BookingCargoModifier).createQueryBuilder().delete().execute();
await this.seedSurchargeTypes(manager, ratesByType);
const cargoTypesForRates = await manager.getRepository(CargoType).find();
const cargoForRatesByCode = new Map(cargoTypesForRates.map((c) => [c.code, c]));
await this.seedRates(rRepo, ctByCode, cargoForRatesByCode);
const yards = await yRepo.find();
const yardByCode = new Map(yards.map((y) => [y.code, y]));
@@ -180,7 +177,7 @@ export class PricingDataSeeder {
includesFirstMile: true,
includesLastMile: true,
includesCustoms: true,
priorityBonusPoints: 100,
priorityBonusPoints: 15,
isActive: true,
displayOrder: 2,
},
@@ -192,7 +189,7 @@ export class PricingDataSeeder {
includesFirstMile: false,
includesLastMile: false,
includesCustoms: false,
priorityBonusPoints: 50,
priorityBonusPoints: 10,
isActive: true,
displayOrder: 3,
},
@@ -246,6 +243,7 @@ export class PricingDataSeeder {
{
code: "GRAIN",
cargoTypeName: "Grain / Cereals",
showFreeTextBox: false,
requiresDirectorApproval: false,
isActive: true,
displayOrder: 1,
@@ -253,6 +251,7 @@ export class PricingDataSeeder {
{
code: "FERTILIZER",
cargoTypeName: "Fertilizer",
showFreeTextBox: false,
requiresDirectorApproval: false,
isActive: true,
displayOrder: 2,
@@ -260,6 +259,7 @@ export class PricingDataSeeder {
{
code: "CEMENT",
cargoTypeName: "Cement / Clinker",
showFreeTextBox: false,
requiresDirectorApproval: false,
isActive: true,
displayOrder: 3,
@@ -267,6 +267,7 @@ export class PricingDataSeeder {
{
code: "STEEL",
cargoTypeName: "Steel / Rebar",
showFreeTextBox: false,
requiresDirectorApproval: true,
isActive: true,
displayOrder: 4,
@@ -274,6 +275,7 @@ export class PricingDataSeeder {
{
code: "MACHINERY",
cargoTypeName: "Heavy Machinery",
showFreeTextBox: false,
requiresDirectorApproval: true,
isActive: true,
displayOrder: 5,
@@ -281,6 +283,7 @@ export class PricingDataSeeder {
{
code: "OTHER_BULK",
cargoTypeName: "Other Bulk Cargo",
showFreeTextBox: false,
requiresDirectorApproval: false,
isActive: true,
displayOrder: 6,
@@ -382,9 +385,11 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise<void> {
this.logger.log("Seeded weight limit rules");
}
private async seedPriorityConfigs(prRepo: any): Promise<void> {
// Wagon Count Block — independent, applies regardless of currency.
// Currency Block — applies only to the matching payment currency, within the wagon range.
// Both blocks are additive (see RuleEngineService.evaluate).
// Priority rule = Wagon Block + Currency Block (both additive; see RuleEngineService.evaluate).
// Combined with the service-type bonus the total priority score caps at 100:
// service-type bonus (≤ 15) + wagon block (≤ 50) + currency block (≤ 35) = 100.
// Wagon Count Block — independent, applies regardless of currency. Max 50.
// Currency Block — applies only to the matching payment currency, within the wagon range. Max 35.
const rows = [
// ── Wagon Count Block ───────────────────────────────────────────────
{ type: "WAGON", label: "Wagons 120", currency: null, minWagonCount: 1, maxWagonCount: 20, scorePoints: 0, displayOrder: 1 },
@@ -392,7 +397,7 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise<void> {
{ type: "WAGON", label: "Wagons 3140", currency: null, minWagonCount: 31, maxWagonCount: 40, scorePoints: 30, displayOrder: 3 },
{ type: "WAGON", label: "Wagons 4150", currency: null, minWagonCount: 41, maxWagonCount: 50, scorePoints: 50, displayOrder: 4 },
// ── Payment Currency Block ──────────────────────────────────────────
{ type: "CURRENCY", label: "USD · Wagons 125", currency: "USD", minWagonCount: 1, maxWagonCount: 25, scorePoints: 17, displayOrder: 5 },
{ type: "CURRENCY", label: "USD · Wagons 125", currency: "USD", minWagonCount: 1, maxWagonCount: 25, scorePoints: 15, displayOrder: 5 },
{ type: "CURRENCY", label: "USD · Wagons 2650", currency: "USD", minWagonCount: 26, maxWagonCount: 50, scorePoints: 35, displayOrder: 6 },
{ type: "CURRENCY", label: "ETB · Wagons 150", currency: "ETB", minWagonCount: 1, maxWagonCount: 50, scorePoints: 0, displayOrder: 7 },
];
@@ -414,204 +419,84 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise<void> {
private async seedRates(
rRepo: any,
ctByCode: Map<string, any>,
cargoByCode: Map<string, any>,
): Promise<Rate[]> {
const effectiveFrom = new Date("2026-01-01");
const now = new Date();
// await rRepo.createQueryBuilder().delete().execute();
// Each rate is self-describing: `appliesTo` + `trigger` decide how the
// engine uses it. trigger=ALWAYS → base freight; anything else → a
// surcharge that stacks additively when the booking matches.
const rateData = [
{
rateType: "CONTAINER_IMPORT",
containerTypeId: ctByCode.get("20FT")!.id,
currency: "USD",
rateValue: 800,
rateUnit: "PER_CONTAINER",
},
{
rateType: "CONTAINER_IMPORT",
containerTypeId: ctByCode.get("40FT")!.id,
currency: "USD",
rateValue: 1200,
rateUnit: "PER_CONTAINER",
},
{
rateType: "CONTAINER_EXPORT",
containerTypeId: ctByCode.get("20FT")!.id,
currency: "USD",
rateValue: 600,
rateUnit: "PER_CONTAINER",
},
{
rateType: "CONTAINER_EXPORT",
containerTypeId: ctByCode.get("40FT")!.id,
currency: "USD",
rateValue: 900,
rateUnit: "PER_CONTAINER",
},
{
rateType: "CONTAINER_IMPORT",
containerTypeId: null,
currency: "USD",
rateValue: 1000,
rateUnit: "PER_CONTAINER",
},
{
rateType: "CONTAINER_EXPORT",
containerTypeId: null,
currency: "USD",
rateValue: 750,
rateUnit: "PER_CONTAINER",
},
{
rateType: "INTERCITY_CONTAINER",
containerTypeId: ctByCode.get("20FT")!.id,
currency: "USD",
rateValue: 350,
rateUnit: "PER_CONTAINER",
},
{
rateType: "INTERCITY_CONTAINER",
containerTypeId: ctByCode.get("40FT")!.id,
currency: "USD",
rateValue: 550,
rateUnit: "PER_CONTAINER",
},
{
rateType: "INTERCITY_CONTAINER",
containerTypeId: null,
currency: "USD",
rateValue: 400,
rateUnit: "PER_CONTAINER",
},
{
rateType: "INTERCITY_BULK",
containerTypeId: null,
currency: "USD",
rateValue: 35,
rateUnit: "PER_TON",
},
{
rateType: "BULK_IMPORT",
containerTypeId: null,
currency: "USD",
rateValue: 50,
rateUnit: "PER_TON",
},
{
rateType: "BULK_EXPORT",
containerTypeId: null,
currency: "USD",
rateValue: 40,
rateUnit: "PER_TON",
},
{
rateType: "OVERWEIGHT_PER_TON",
containerTypeId: null,
currency: "USD",
rateValue: 25,
rateUnit: "PER_TON",
},
{
rateType: "HAZARD_SURCHARGE",
containerTypeId: null,
currency: "USD",
rateValue: 150,
rateUnit: "FLAT",
},
{
rateType: "REEFER_SURCHARGE",
containerTypeId: null,
currency: "USD",
rateValue: 200,
rateUnit: "FLAT",
},
{
rateType: "DOUBLE_HANDLING",
containerTypeId: null,
currency: "USD",
rateValue: 100,
rateUnit: "PER_CONTAINER",
},
{
rateType: "LASHING",
containerTypeId: null,
currency: "USD",
rateValue: 50,
rateUnit: "PER_CONTAINER",
},
// ── Container base freight ──────────────────────────────────────────
{ appliesTo: "CONTAINER", trigger: "ALWAYS", rateType: "CONTAINER_IMPORT", tradeDirection: "IMPORT", containerTypeId: ctByCode.get("20FT")!.id, rateValue: 800, rateUnit: "PER_CONTAINER" },
{ appliesTo: "CONTAINER", trigger: "ALWAYS", rateType: "CONTAINER_IMPORT", tradeDirection: "IMPORT", containerTypeId: ctByCode.get("40FT")!.id, rateValue: 1200, rateUnit: "PER_CONTAINER" },
{ appliesTo: "CONTAINER", trigger: "ALWAYS", rateType: "CONTAINER_EXPORT", tradeDirection: "EXPORT", containerTypeId: ctByCode.get("20FT")!.id, rateValue: 600, rateUnit: "PER_CONTAINER" },
{ appliesTo: "CONTAINER", trigger: "ALWAYS", rateType: "CONTAINER_EXPORT", tradeDirection: "EXPORT", containerTypeId: ctByCode.get("40FT")!.id, rateValue: 900, rateUnit: "PER_CONTAINER" },
{ appliesTo: "CONTAINER", trigger: "ALWAYS", rateType: "CONTAINER_IMPORT", tradeDirection: "IMPORT", containerTypeId: null, rateValue: 1000, rateUnit: "PER_CONTAINER" },
{ appliesTo: "CONTAINER", trigger: "ALWAYS", rateType: "CONTAINER_EXPORT", tradeDirection: "EXPORT", containerTypeId: null, rateValue: 750, rateUnit: "PER_CONTAINER" },
// ── Intercity base freight ──────────────────────────────────────────
{ appliesTo: "INTERCITY", trigger: "ALWAYS", rateType: "INTERCITY_CONTAINER", containerTypeId: ctByCode.get("20FT")!.id, rateValue: 350, rateUnit: "PER_CONTAINER" },
{ appliesTo: "INTERCITY", trigger: "ALWAYS", rateType: "INTERCITY_CONTAINER", containerTypeId: ctByCode.get("40FT")!.id, rateValue: 550, rateUnit: "PER_CONTAINER" },
{ appliesTo: "INTERCITY", trigger: "ALWAYS", rateType: "INTERCITY_CONTAINER", containerTypeId: null, rateValue: 400, rateUnit: "PER_CONTAINER" },
{ appliesTo: "INTERCITY", trigger: "ALWAYS", rateType: "INTERCITY_BULK", rateValue: 35, rateUnit: "PER_TON" },
// ── Bulk base freight (by leaf cargo type where known) ──────────────
{ appliesTo: "BULK", trigger: "ALWAYS", rateType: "BULK_IMPORT", tradeDirection: "IMPORT", cargoTypeId: cargoByCode.get("GRAIN")?.id ?? null, rateValue: 50, rateUnit: "PER_TON" },
{ appliesTo: "BULK", trigger: "ALWAYS", rateType: "BULK_EXPORT", tradeDirection: "EXPORT", cargoTypeId: cargoByCode.get("GRAIN")?.id ?? null, rateValue: 40, rateUnit: "PER_TON" },
{ appliesTo: "BULK", trigger: "ALWAYS", rateType: "BULK_IMPORT", tradeDirection: "IMPORT", cargoTypeId: null, rateValue: 50, rateUnit: "PER_TON" },
{ appliesTo: "BULK", trigger: "ALWAYS", rateType: "BULK_EXPORT", tradeDirection: "EXPORT", cargoTypeId: null, rateValue: 40, rateUnit: "PER_TON" },
// ── Surcharges (trigger-based) ──────────────────────────────────────
{ appliesTo: "OTHER", trigger: "OVERWEIGHT", rateType: "OVERWEIGHT_PER_TON", rateValue: 25, rateUnit: "PER_TON" },
{ appliesTo: "OTHER", trigger: "HAZARDOUS", rateType: "HAZARD_SURCHARGE", rateValue: 150, rateUnit: "FLAT" },
{ appliesTo: "OTHER", trigger: "REEFER", rateType: "REEFER_SURCHARGE", rateValue: 200, rateUnit: "FLAT" },
{ appliesTo: "OTHER", trigger: "SHIPPING_LINE", rateType: "DOUBLE_HANDLING", rateValue: 100, rateUnit: "PER_CONTAINER" },
{ appliesTo: "OTHER", trigger: "CONSOLIDATION", rateType: "LASHING", rateValue: 50, rateUnit: "PER_CONTAINER" },
];
const entities = rateData.map((d) =>
rRepo.create({
// Idempotent: insert each canonical rate only if no row with the same
// signature already exists. Re-running the seeder must NOT accumulate
// duplicate rows — duplicated surcharge rates would otherwise repeat on
// every booking's price breakdown.
const signature = (r: {
rateType: string;
rateUnit: string;
rateValue: number;
currency: string;
containerTypeId?: string | null;
cargoTypeId?: string | null;
}) =>
[
r.rateType,
r.rateUnit,
Number(r.rateValue),
r.currency,
r.containerTypeId ?? "",
r.cargoTypeId ?? "",
].join("|");
const existing: Rate[] = await rRepo.find();
const existingBySignature = new Set(existing.map((r) => signature(r)));
const toCreate = rateData
.map((d) => ({
currency: "USD",
...d,
status: "LIVE",
status: "LIVE" as const,
proposedByStaffId: STAFF_USER_ID,
approvedByCeoId: CEO_USER_ID,
approvedAt: now,
effectiveFrom,
}),
);
return rRepo.save(entities);
}
}))
.filter((d) => !existingBySignature.has(signature(d)));
private async seedSurchargeTypes(
manager: any,
ratesByType: Map<string, Rate[]>,
): Promise<void> {
const surRepo = manager.getRepository(SurchargeType);
const bcmRepo = manager.getRepository(BookingCargoModifier);
await bcmRepo.createQueryBuilder().delete().execute();
const findRate = (rateType: string, currency: string) => {
const key = `${rateType}|${currency}|`;
const rates = ratesByType.get(key);
return rates?.[0];
};
if (toCreate.length === 0) {
this.logger.log("Rates already seeded — skipping (idempotent)");
return existing;
}
const hazardRateUsd = findRate("HAZARD_SURCHARGE", "USD");
const reeferRateUsd = findRate("REEFER_SURCHARGE", "USD");
const overweightRateUsd = findRate("OVERWEIGHT_PER_TON", "USD");
const shipLineRateUsd = findRate("DOUBLE_HANDLING", "USD");
const consolidRateUsd = findRate("LASHING", "USD");
await surRepo.createQueryBuilder().delete().execute();
await surRepo.save([
surRepo.create({
code: "HAZARDOUS_CARGO",
label: "Hazardous Cargo",
triggerCondition: "CARGO_FLAG_HAZARDOUS",
rateId: hazardRateUsd?.id,
isActive: true,
}),
surRepo.create({
code: "REEFER_CARGO",
label: "Reefer Cargo",
triggerCondition: "CARGO_FLAG_REEFER",
rateId: reeferRateUsd?.id,
isActive: true,
}),
surRepo.create({
code: "OVERWEIGHT_CARGO",
label: "Overweight Cargo",
triggerCondition: "VGM_EXCEEDS_LIMIT",
rateId: overweightRateUsd?.id,
isActive: true,
}),
surRepo.create({
code: "SHIPPING_LINE_FEE",
label: "Shipping Line Fee",
triggerCondition: "SHIPPING_LINE_MAPPED",
rateId: shipLineRateUsd?.id,
isActive: true,
}),
surRepo.create({
code: "CONSOLIDATION_FEE",
label: "Consolidation Fee",
triggerCondition: "CONSOLIDATION_ENABLED",
rateId: consolidRateUsd?.id,
isActive: true,
}),
]);
this.logger.log("Seeded surcharge types");
const created = await rRepo.save(toCreate.map((d) => rRepo.create(d)));
this.logger.log(`Seeded ${created.length} new rate(s)`);
return [...existing, ...created];
}
private async seedDraftBookings(
@@ -621,15 +506,29 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise<void> {
slByCode: Map<string, any>,
cargoByCode: Map<string, any>,
): Promise<void> {
const djibouti = yardByCode.get("DJIBOUTI")!;
const addis = yardByCode.get("ADDIS_ABABA")!;
const railContainer = stByCode.get("RAIL_CONTAINER")!;
const railBulk = stByCode.get("RAIL_BULK")!;
const maersk = slByCode.get("MAERSK")!;
const grain = cargoByCode.get("GRAIN")!;
const twenty = ctByCode.get("20FT")!;
const forty = ctByCode.get("40FT")!;
const twentyReefer = ctByCode.get("20FT_REEFER")!;
const djibouti = yardByCode.get("DJIBOUTI");
const addis = yardByCode.get("ADDIS_ABABA");
const railContainer = stByCode.get("RAIL_CONTAINER");
const railBulk = stByCode.get("RAIL_BULK");
const maersk = slByCode.get("MAERSK");
const grain = cargoByCode.get("GRAIN");
const twenty = ctByCode.get("20FT");
const forty = ctByCode.get("40FT");
const twentyReefer = ctByCode.get("20FT_REEFER");
const missing: string[] = [];
if (!djibouti) missing.push("yard:DJIBOUTI");
if (!addis) missing.push("yard:ADDIS_ABABA");
if (!railContainer) missing.push("serviceType:RAIL_CONTAINER");
if (!railBulk) missing.push("serviceType:RAIL_BULK");
if (!grain) missing.push("cargoType:GRAIN");
if (!twenty) missing.push("containerType:20FT");
if (!forty) missing.push("containerType:40FT");
if (!twentyReefer) missing.push("containerType:20FT_REEFER");
if (missing.length > 0) {
this.logger.warn(`seedDraftBookings: skipping — missing reference data: ${missing.join(", ")}`);
return;
}
const drafts = [
{
@@ -641,9 +540,7 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise<void> {
serviceTypeId: railContainer.id,
originYardId: djibouti.id,
destinationYardId: addis.id,
isHazardous: false,
allowConsolidation: false,
shippingLineId: null,
isHazardous: false, shippingLineId: null,
cargoTypeId: null,
cargoTotalWeightVgm: 250,
containers: [
@@ -661,9 +558,7 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise<void> {
serviceTypeId: railContainer.id,
originYardId: djibouti.id,
destinationYardId: addis.id,
isHazardous: true,
allowConsolidation: false,
shippingLineId: null,
isHazardous: true, shippingLineId: null,
cargoTypeId: null,
cargoTotalWeightVgm: 135,
containers: [
@@ -681,9 +576,7 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise<void> {
serviceTypeId: railContainer.id,
originYardId: djibouti.id,
destinationYardId: addis.id,
isHazardous: false,
allowConsolidation: false,
shippingLineId: maersk.id,
isHazardous: false, shippingLineId: maersk.id,
cargoTypeId: null,
cargoTotalWeightVgm: 480,
containers: [
@@ -701,9 +594,7 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise<void> {
serviceTypeId: railContainer.id,
originYardId: djibouti.id,
destinationYardId: addis.id,
isHazardous: false,
allowConsolidation: true,
shippingLineId: null,
isHazardous: false, shippingLineId: null,
cargoTypeId: null,
cargoTotalWeightVgm: 224,
containers: [
@@ -721,9 +612,7 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise<void> {
serviceTypeId: railBulk.id,
originYardId: djibouti.id,
destinationYardId: addis.id,
isHazardous: false,
allowConsolidation: false,
shippingLineId: null,
isHazardous: false, shippingLineId: null,
cargoTypeId: grain.id,
cargoTotalWeightVgm: 500,
containers: [],
@@ -739,9 +628,7 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise<void> {
serviceTypeId: railContainer.id,
originYardId: djibouti.id,
destinationYardId: addis.id,
isHazardous: false,
allowConsolidation: false,
shippingLineId: null,
isHazardous: false, shippingLineId: null,
cargoTypeId: null,
cargoTotalWeightVgm: 75,
containers: [
@@ -759,9 +646,7 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise<void> {
serviceTypeId: railContainer.id,
originYardId: djibouti.id,
destinationYardId: addis.id,
isHazardous: false,
allowConsolidation: false,
shippingLineId: null,
isHazardous: false, shippingLineId: null,
cargoTypeId: null,
cargoTotalWeightVgm: 300,
containers: [

View File

@@ -1,3 +1,4 @@
<<<<<<< HEAD
import { Navigate, Outlet, Route, Routes, useLocation, useNavigate } from "react-router-dom";
import {
Boxes,
@@ -562,3 +563,710 @@ const App = () => {
};
export default App;
=======
import {
Boxes,
Building2,
Container,
FileText,
LayoutDashboard,
LayoutGrid,
Network,
Package,
PackageCheck,
PackageOpen,
Paperclip,
Send,
Settings,
ShieldCheck,
SlidersHorizontal,
Train,
Truck,
Users,
Wallet,
} from "lucide-react";
import { Navigate, Outlet, Route, Routes, useLocation, useNavigate } from "react-router-dom";
import { FreightDashboardLayout, type SidebarItem, type SidebarSection } from "@/components/layout";
import { useAuth } from "./auth/useAuth";
import LoadingScreen from "./components/LoadingScreen";
import LoginPage from "./pages/auth/LoginPage";
import BookingContractPage from "./pages/bookings/BookingContractPage";
import BookingRequestDetailPage from "./pages/bookings/BookingRequestDetailPage";
import BookingRequestsPage from "./pages/bookings/BookingRequestsPage";
import GlClearancePage from "./pages/bookings/GlClearancePage";
import NewBookingPage from "./pages/bookings/NewBookingPage";
import CustomerDetailPage from "./pages/customers/CustomerDetailPage";
import CustomersPage from "./pages/customers/CustomersPage";
import DemoUser1Page from "./pages/dashboard/demo/DemoUser1Page";
import DemoUser2Page from "./pages/dashboard/demo/DemoUser2Page";
import MyProfilePage from "./pages/dashboard/MyProfilePage";
import OverviewPage from "./pages/dashboard/OverviewPage";
import UserManagementHostPage from "./pages/dashboard/user-management/UserManagementHostPage";
import PaymentsPage from "./pages/payments/PaymentsPage";
//import EmployeesPage from "./pages/dashboard/user-management/EmployeesPage";
import { RequirePermission } from "./components/auth/RequirePermission";
import { FREIGHT_PERMS, hasPermission as hasFreightPermission } from "./lib/permissions";
import PermissionsPage from "./pages/dashboard/user-management/PermissionsPage";
import PositionTypesPage from "./pages/dashboard/user-management/PositionTypesPage";
import RolesPage from "./pages/dashboard/user-management/RolesPage";
import UserManagementPage from "./pages/dashboard/user-management/UserManagementPage";
import UsersPage from "./pages/dashboard/user-management/UsersPage";
import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage";
import DropdownSettingsPage from "./pages/dropdown_settings/DropdownSettingsPage";
import FleetResourcePage from "./pages/fleet/FleetResourcePage";
import RoutesPage from "./pages/fleet/RoutesPage";
import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources";
import RuleEngineLegacyRedirect from "./pages/ruleEngine/RuleEngineLegacyRedirect";
import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage";
import CargoTypesPage from "./pages/ruleEngine/CargoTypesPage";
import TrainScheduleV2ListPage from "./pages/trainScheduling/TrainScheduleV2ListPage";
import BatchBoardPage from "./pages/trainScheduling/BatchBoardPage";
import BatchScheduleDetailPage from "./pages/trainScheduling/BatchScheduleDetailPage";
import TrainScheduleV2DetailPage from "./pages/trainScheduling/TrainScheduleV2DetailPage";
import TrainScheduleTrackPage from "./pages/trainScheduling/TrainScheduleTrackPage";
import TrainSchedulingGlobalRulesPage from "./pages/trainScheduling/TrainSchedulingGlobalRulesPage";
import FirstMilePage from "./pages/operations/FirstMilePage";
import LastMilePage from "./pages/operations/LastMilePage";
import TrainDetailPage from "./pages/trains/TrainDetailPage";
import ArrivalQueuePage from "./pages/warehouses/ArrivalQueuePage";
import DispatchQueuePage from "./pages/warehouses/DispatchQueuePage";
import InventoryInquiryPage from "./pages/warehouses/InventoryInquiryPage";
import LoadedInventoryPage from "./pages/warehouses/LoadedInventoryPage";
import LoadingQueuePage from "./pages/warehouses/LoadingQueuePage";
import WarehouseDashboardPage from "./pages/warehouses/WarehouseDashboardPage";
import WarehouseDetailPage from "./pages/warehouses/WarehouseDetailPage";
import WarehouseInventoryPage from "./pages/warehouses/WarehouseInventoryPage";
import WarehouseInvoicesPage from "./pages/warehouses/WarehouseInvoicesPage";
import WarehouseListPage from "./pages/warehouses/WarehouseListPage";
import WarehouseRulesPage from "./pages/warehouses/WarehouseRulesPage";
const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
{
title: "Main menu",
items: [
{
label: "Overview",
href: "/dashboard/overview",
icon: <LayoutDashboard />,
},
{
label: "UM",
href: "/um",
icon: <Users />,
},
{
label: "Booking requests",
href: "/dashboard/booking-requests",
icon: <FileText />,
},
{
label: "Customers",
href: "/dashboard/customers",
icon: <Building2 />,
},
{
label: "Payments",
href: "/dashboard/payments",
icon: <Wallet />,
permission: FREIGHT_PERMS.bookings.view,
},
...demoItems,
],
},
{
title: "Operations",
items: [
{
label: "Document Clearance",
href: "/dashboard/clearance",
icon: <ShieldCheck />,
permission: FREIGHT_PERMS.bookings.reviewDocuments,
},
{
label: "Train Schedules",
href: "/dashboard/operations/train-scheduling-v2",
icon: <Train />,
permission: FREIGHT_PERMS.trainScheduling.view,
},
{
label: "Batch Board",
href: "/dashboard/operations/batch-board",
icon: <LayoutGrid />,
permission: FREIGHT_PERMS.trainScheduling.view,
},
{
label: "First Mile",
href: "/dashboard/operations/first-mile",
icon: <Truck />,
permission: FREIGHT_PERMS.trainScheduling.view,
},
{
label: "Last Mile",
href: "/dashboard/operations/last-mile",
icon: <Truck />,
permission: FREIGHT_PERMS.trainScheduling.view,
},
],
},
{
title: "Fleet Management",
items: [
{
label: "Routes",
href: "/dashboard/routes",
icon: <Network />,
permission: FREIGHT_PERMS.fleet.view,
},
{
label: "Locomotives",
href: "/dashboard/locomotives",
icon: <Train />,
permission: FREIGHT_PERMS.fleet.view,
},
// {
// label: "Wagon types",
// href: "/dashboard/wagon-types",
// icon: <Boxes />,
// },
{
label: "Wagons",
href: "/dashboard/wagons",
icon: <Truck />,
permission: FREIGHT_PERMS.fleet.view,
},
{
label: "Vehicles",
href: "/dashboard/vehicles",
icon: <Truck />,
permission: FREIGHT_PERMS.fleet.view,
},
{
label: "Drivers",
href: "/dashboard/drivers",
icon: <Users />,
permission: FREIGHT_PERMS.fleet.view,
},
// {
// label: "Containers",
// href: "/dashboard/containers",
// icon: <Container />,
// },
// {
// label: "Cargoes",
// href: "/dashboard/cargoes",
// icon: <Package />,
// },
],
},
{
title: "Warehouse Management",
items: [
{
label: "Warehouse Dashboard",
href: "/dashboard/warehouse-dashboard",
icon: <LayoutDashboard />,
},
{
label: "Warehouses",
href: "/dashboard/warehouses",
icon: <Container />,
},
{
label: "Inventory",
href: "/dashboard/warehouse-inventory",
icon: <Package />,
},
{
label: "Arrival Queue",
href: "/dashboard/arrival-queue",
icon: <PackageOpen />,
},
{
label: "Loading Queue",
href: "/dashboard/loading-queue",
icon: <Truck />,
},
{
label: "Loaded Inventory",
href: "/dashboard/loaded-inventory",
icon: <PackageCheck />,
},
{
label: "Dispatch Queue",
href: "/dashboard/dispatch-queue",
icon: <Send />,
},
{
label: "Inventory Inquiry",
href: "/dashboard/inventory-inquiry",
icon: <Boxes />,
},
{
label: "Allocation & Fees",
href: "/dashboard/warehouse-rules",
icon: <SlidersHorizontal />,
},
{
label: "Fee Invoices",
href: "/dashboard/warehouse-fee-invoices",
icon: <Wallet />,
},
],
},
{
title: "Administration",
items: [
{
label: "File settings",
href: "/dashboard/file-settings",
icon: <Paperclip />,
permission: FREIGHT_PERMS.admin,
},
{
label: "Dropdown settings",
href: "/dashboard/dropdown-settings",
icon: <Settings />,
permission: FREIGHT_PERMS.admin,
},
],
},
{
title: "Freight configuration",
mutedTitle: true,
items: [
{
label: "Configuration",
href: "/dashboard/configuration",
icon: <Boxes />,
children: [
...getCategorySidebarChildren("configuration"),
// {
// label: "Train scheduling rules",
// href: "/dashboard/configuration/train-scheduling-rules",
// },
],
},
{
label: "Rules",
href: "/dashboard/rules",
icon: <SlidersHorizontal />,
children: getCategorySidebarChildren("rules"),
},
],
},
];
/** Keep only items the user is permitted to see; drop now-empty sections. */
const filterSidebarByPermission = (
sections: SidebarSection[],
user: ReturnType<typeof useAuth>["user"],
): SidebarSection[] => {
const itemAllowed = (item: SidebarItem): boolean => {
if (!item.permission) return true;
const keys = Array.isArray(item.permission)
? item.permission
: [item.permission];
return keys.some((key) => hasFreightPermission(user, key));
};
return sections
.map((section) => ({
...section,
items: section.items.filter(itemAllowed),
}))
.filter((section) => section.items.length > 0);
};
const DashboardShell = () => {
const navigate = useNavigate();
const location = useLocation();
const { user, logout } = useAuth();
const demoItems: SidebarItem[] = [];
const sidebarSections = filterSidebarByPermission(
buildSidebarSections(demoItems),
user,
);
const displayName = user?.name?.en || user?.username || user?.email || "User";
return (
<FreightDashboardLayout
sidebarSections={sidebarSections}
activeHref={location.pathname}
onNavigate={navigate}
enableThemeToggle
userName={displayName}
userEmail={user?.email}
onLogout={logout}
>
<Outlet />
</FreightDashboardLayout>
);
};
const App = () => {
const { user, loading } = useAuth();
if (loading) {
return <LoadingScreen />;
}
if (!user) {
return (
<Routes>
<Route path="/auth" element={<LoginPage />} />
<Route path="/um/*" element={<UserManagementHostPage />} />
<Route path="*" element={<Navigate to="/auth" replace />} />
</Routes>
);
}
return (
<Routes>
<Route path="/um/*" element={<UserManagementHostPage />} />
<Route path="/" element={<Navigate to="/dashboard/overview" replace />} />
<Route path="/dashboard" element={<Navigate to="/dashboard/overview" replace />} />
<Route path="/dashboard" element={<DashboardShell />}>
<Route path="overview" element={<OverviewPage />} />
<Route path="profile" element={<MyProfilePage />} />
<Route path="booking-requests" element={<BookingRequestsPage />} />
<Route
path="payments"
element={
<RequirePermission permission={FREIGHT_PERMS.bookings.view}>
<PaymentsPage />
</RequirePermission>
}
/>
<Route path="customers" element={<CustomersPage />} />
<Route path="customers/:id" element={<CustomerDetailPage />} />
<Route path="booking-requests/new" element={<NewBookingPage />} />
<Route path="booking-requests/:id" element={<BookingRequestDetailPage />} />
<Route
path="booking-requests/:id/contract"
element={<BookingContractPage />}
/>
<Route
path="clearance"
element={
<RequirePermission permission={FREIGHT_PERMS.bookings.reviewDocuments}>
<GlClearancePage />
</RequirePermission>
}
/>
<Route path="warehouses" element={<WarehouseListPage />} />
<Route path="warehouses/:id" element={<WarehouseDetailPage />} />
<Route path="warehouse-inventory" element={<WarehouseInventoryPage />} />
<Route path="arrival-queue" element={<ArrivalQueuePage />} />
<Route path="loading-queue" element={<LoadingQueuePage />} />
<Route path="loaded-inventory" element={<LoadedInventoryPage />} />
<Route path="dispatch-queue" element={<DispatchQueuePage />} />
<Route path="inventory-inquiry" element={<InventoryInquiryPage />} />
<Route path="warehouse-rules" element={<WarehouseRulesPage />} />
<Route path="warehouse-fee-invoices" element={<WarehouseInvoicesPage />} />
<Route path="warehouse-dashboard" element={<WarehouseDashboardPage />} />
<Route
path="operations/train-scheduling"
element={<Navigate to="/dashboard/operations/train-scheduling-v2" replace />}
/>
<Route
path="operations/batch-board"
element={
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
<BatchBoardPage />
</RequirePermission>
}
/>
<Route
path="operations/first-mile"
element={
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
<FirstMilePage />
</RequirePermission>
}
/>
<Route
path="operations/last-mile"
element={
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
<LastMilePage />
</RequirePermission>
}
/>
<Route
path="operations/batch-board/:scheduleId"
element={
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
<BatchScheduleDetailPage />
</RequirePermission>
}
/>
<Route
path="operations/train-scheduling-v2"
element={
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
<TrainScheduleV2ListPage />
</RequirePermission>
}
/>
<Route
path="operations/train-scheduling-v2/:scheduleId"
element={
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
<TrainScheduleV2DetailPage />
</RequirePermission>
}
/>
<Route
path="operations/train-scheduling-v2/:scheduleId/track"
element={
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
<TrainScheduleTrackPage />
</RequirePermission>
}
/>
<Route
path="routes"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<RoutesPage />
</RequirePermission>
}
/>
<Route
path="locomotives"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<FleetResourcePage />
</RequirePermission>
}
/>
<Route
path="trains"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<FleetResourcePage />
</RequirePermission>
}
/>
<Route
path="trains/:id"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<TrainDetailPage />
</RequirePermission>
}
/>
<Route
path="wagons"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<FleetResourcePage />
</RequirePermission>
}
/>
<Route
path="containers"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<FleetResourcePage />
</RequirePermission>
}
/>
<Route
path="cargoes"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<FleetResourcePage />
</RequirePermission>
}
/>
<Route
path="vehicles"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<FleetResourcePage />
</RequirePermission>
}
/>
<Route
path="drivers"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<FleetResourcePage />
</RequirePermission>
}
/>
<Route
path="operations/train-scheduling"
element={<Navigate to="/dashboard/operations/train-scheduling-v2" replace />}
/>
<Route
path="operations/batch-board"
element={
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
<BatchBoardPage />
</RequirePermission>
}
/>
<Route
path="operations/batch-board/:scheduleId"
element={
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
<BatchScheduleDetailPage />
</RequirePermission>
}
/>
<Route
path="operations/train-scheduling-v2"
element={
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
<TrainScheduleV2ListPage />
</RequirePermission>
}
/>
<Route
path="operations/train-scheduling-v2/:scheduleId"
element={
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
<TrainScheduleV2DetailPage />
</RequirePermission>
}
/>
<Route
path="operations/train-scheduling-v2/:scheduleId/track"
element={
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
<TrainScheduleTrackPage />
</RequirePermission>
}
/>
<Route
path="routes"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<RoutesPage />
</RequirePermission>
}
/>
<Route
path="locomotives"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<FleetResourcePage />
</RequirePermission>
}
/>
<Route
path="trains"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<FleetResourcePage />
</RequirePermission>
}
/>
<Route
path="trains/:id"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<TrainDetailPage />
</RequirePermission>
}
/>
<Route
path="wagons"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<FleetResourcePage />
</RequirePermission>
}
/>
<Route
path="containers"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<FleetResourcePage />
</RequirePermission>
}
/>
<Route
path="cargoes"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<FleetResourcePage />
</RequirePermission>
}
/>
{/* Legacy embedded user management routes */}
<Route path="user-management" element={<UserManagementPage />} />
<Route path="user-management/users" element={<UsersPage />} />
<Route path="user-management/position-types" element={<PositionTypesPage />} />
{/* <Route path="user-management/employees" element={<EmployeesPage />} /> */}
<Route path="user-management/permissions" element={<PermissionsPage />} />
<Route path="user-management/roles" element={<RolesPage />} />
<Route
path="file-settings"
element={
<RequirePermission permission={FREIGHT_PERMS.admin}>
<FileUploadSettingsPage />
</RequirePermission>
}
/>
<Route
path="dropdown-settings"
element={
<RequirePermission permission={FREIGHT_PERMS.admin}>
<DropdownSettingsPage />
</RequirePermission>
}
/>
<Route
path="configuration"
element={<Navigate to="/dashboard/configuration/cargo-types" replace />}
/>
<Route
path="configuration/train-scheduling-rules"
element={
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
<TrainSchedulingGlobalRulesPage />
</RequirePermission>
}
/>
<Route path="configuration/cargo-types" element={<CargoTypesPage />} />
<Route path="configuration/cargo-types/:id" element={<CargoTypesPage />} />
<Route path="configuration/:resource" element={<RuleEngineResourcePage />} />
<Route
path="rules"
element={<Navigate to="/dashboard/rules/priority-configs" replace />}
/>
<Route path="rules/:resource" element={<RuleEngineResourcePage />} />
<Route
path="rule-engine"
element={<Navigate to="/dashboard/configuration/cargo-types" replace />}
/>
<Route path="rule-engine/:resource" element={<RuleEngineLegacyRedirect />} />
<Route path="user1" element={<DemoUser1Page />} />
<Route path="user2" element={<DemoUser2Page />} />
<Route path="org-structure" element={<Navigate to="/um" replace />} />
<Route path="org-structure/*" element={<Navigate to="/um" replace />} />
</Route>
<Route path="*" element={<Navigate to="/dashboard/overview" replace />} />
</Routes>
);
};
export default App;
>>>>>>> ee94833a9ead8a825391bb18604e405ac76c70ca

View File

@@ -8,10 +8,22 @@ import {
Button,
Textarea,
FileInput,
NumberInput,
} from "@mantine/core";
import type { BookingActionDef } from "@/features/bookings/booking-actions.config";
/** Today + `days`, formatted as a readable date for the validity preview. */
function validUntilLabel(days: number): string {
const until = new Date();
until.setDate(until.getDate() + days);
return until.toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
});
}
interface BookingConfirmDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
@@ -46,8 +58,18 @@ export function BookingConfirmDialog({
const Icon = action.icon;
const needsTextInput = action.input === "note" || action.input === "reason";
const needsFileInput = action.input === "file";
const needsDaysInput = action.input === "days";
const needsAmountInput = action.input === "amount";
const daysValue = Number(inputValue.trim());
const daysValid =
Number.isInteger(daysValue) && daysValue >= 1 && daysValue <= 365;
const amountValue = Number(inputValue.trim());
const amountValid = !!inputValue.trim() && Number.isFinite(amountValue) && amountValue >= 0;
const inputMissing =
(needsTextInput && !inputValue.trim()) || (needsFileInput && !selectedFile);
(needsTextInput && !inputValue.trim()) ||
(needsFileInput && !selectedFile) ||
(needsDaysInput && !daysValid) ||
(needsAmountInput && !amountValid);
const isDestructive = action.variant === "destructive";
const accent = isDestructive ? "red" : "edr-green";
@@ -129,6 +151,40 @@ export function BookingConfirmDialog({
clearable
/>
)}
{needsDaysInput && (
<Stack gap={4}>
<NumberInput
label={action.inputLabel ?? "Contract validity (days)"}
withAsterisk
min={1}
max={365}
clampBehavior="strict"
allowDecimal={false}
allowNegative={false}
placeholder={action.inputPlaceholder ?? "e.g. 30"}
value={inputValue === "" ? "" : Number(inputValue)}
onChange={(value) => onInputChange(value === "" ? "" : String(value))}
/>
<Text size="xs" c="dimmed">
{daysValid
? `Contract valid from today until ${validUntilLabel(daysValue)} (${daysValue} day${daysValue === 1 ? "" : "s"}).`
: "Enter a whole number of days between 1 and 365."}
</Text>
</Stack>
)}
{needsAmountInput && (
<NumberInput
label={action.inputLabel ?? "Adjusted total"}
withAsterisk
min={0}
allowNegative={false}
decimalScale={2}
thousandSeparator=","
placeholder={action.inputPlaceholder ?? "0.00"}
value={inputValue === "" ? "" : Number(inputValue)}
onChange={(value) => onInputChange(value === "" ? "" : String(value))}
/>
)}
{extra}
</Stack>

View File

@@ -1,50 +1,171 @@
import { Banknote, Receipt } from "lucide-react";
import { Paper, Stack, Group, Text, Divider } from "@mantine/core";
import { useState } from "react";
import { Banknote, Pencil, Receipt } from "lucide-react";
import {
Button,
Divider,
Group,
NumberInput,
Paper,
Stack,
Text,
Textarea,
} from "@mantine/core";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import toast from "react-hot-toast";
import type { BookingDetail } from "@/types/booking";
import { bookingsService } from "@/services/bookings.service";
import { SectionCard } from "./detail/SectionCard";
import { detailStyles } from "./detail/booking-detail.styles";
export function BookingPricingSummary({ booking }: { booking: BookingDetail }) {
const amount = Number(booking.totalAmount);
const modifiers = booking.cargoModifiers ?? [];
const qc = useQueryClient();
const computed = Number(booking.totalAmount);
const isAdjusted =
booking.adjustedTotalAmount !== null &&
booking.adjustedTotalAmount !== undefined;
const effective = isAdjusted ? Number(booking.adjustedTotalAmount) : computed;
const lineItems = booking.pricingBreakdown?.lineItems ?? [];
const [editing, setEditing] = useState(false);
const [amount, setAmount] = useState<number | "">(effective);
const [reason, setReason] = useState("");
const adjustMutation = useMutation({
mutationFn: (payload: { amount: number | null; reason?: string }) =>
bookingsService.adjustPrice(booking.id, payload.amount, payload.reason),
onSuccess: () => {
toast.success("Price updated");
setEditing(false);
qc.invalidateQueries({ queryKey: ["bookings"] });
},
onError: () => toast.error("Could not update price"),
});
const fmt = (n: number) =>
`${booking.paymentCurrency} ${n.toLocaleString(undefined, { minimumFractionDigits: 2 })}`;
return (
<SectionCard icon={Banknote} title="Pricing & payment">
<Stack gap="md">
<Paper radius="md" withBorder p="md" style={detailStyles.highlightCard}>
<Text size="xs" c="dimmed" fw={500} tt="uppercase" lts="0.04em">
Total amount
</Text>
<Text
size="xl"
fw={700}
c="edr-green.9"
mt={4}
style={{ fontVariantNumeric: "tabular-nums", letterSpacing: "-0.5px" }}
>
{booking.paymentCurrency}{" "}
{amount.toLocaleString(undefined, { minimumFractionDigits: 2 })}
</Text>
<Group justify="space-between" align="flex-start">
<div>
<Text size="xs" c="dimmed" fw={500} tt="uppercase" lts="0.04em">
{isAdjusted ? "Adjusted total" : "Total amount"}
</Text>
<Text
size="xl"
fw={700}
c="edr-green.9"
mt={4}
style={{ fontVariantNumeric: "tabular-nums", letterSpacing: "-0.5px" }}
>
{fmt(effective)}
</Text>
{isAdjusted && (
<Text size="xs" c="dimmed" mt={2}>
Computed: {fmt(computed)}
{booking.adjustmentReason ? ` · ${booking.adjustmentReason}` : ""}
</Text>
)}
</div>
{!editing && (
<Button
size="compact-xs"
variant="light"
leftSection={<Pencil size={13} />}
onClick={() => {
setAmount(effective);
setEditing(true);
}}
>
Adjust
</Button>
)}
</Group>
{editing && (
<Stack gap="xs" mt="md">
<NumberInput
label="New total"
value={amount}
onChange={(v) => setAmount(v === "" ? "" : Number(v))}
min={0}
radius="md"
prefix={`${booking.paymentCurrency} `}
thousandSeparator=","
/>
<Textarea
label="Reason (optional)"
value={reason}
onChange={(e) => setReason(e.currentTarget.value)}
autosize
minRows={2}
radius="md"
/>
<Group justify="space-between" mt={4}>
{isAdjusted ? (
<Button
size="compact-sm"
variant="subtle"
color="red"
loading={adjustMutation.isPending}
onClick={() =>
adjustMutation.mutate({ amount: null })
}
>
Clear adjustment
</Button>
) : (
<span />
)}
<Group gap="xs">
<Button
size="compact-sm"
variant="default"
onClick={() => setEditing(false)}
>
Cancel
</Button>
<Button
size="compact-sm"
color="edr-green"
loading={adjustMutation.isPending}
disabled={amount === ""}
onClick={() =>
adjustMutation.mutate({
amount: Number(amount),
reason: reason.trim() || undefined,
})
}
>
Save
</Button>
</Group>
</Group>
</Stack>
)}
</Paper>
<Row label="Payment status" value={booking.paymentStatus} />
{booking.pnrCode && <Row label="PNR code" value={booking.pnrCode} mono />}
{modifiers.length > 0 && (
{lineItems.length > 0 && (
<>
<Divider color="var(--mantine-color-gray-2)" />
<Group gap={6}>
<Receipt size={13} color="var(--mantine-color-gray-5)" />
<Text size="xs" c="dimmed" fw={500} tt="uppercase" lts="0.04em">
Surcharges applied
Price breakdown
</Text>
</Group>
<Stack gap="xs">
{modifiers.map((m) => (
{lineItems.map((li, i) => (
<Group
key={m.id}
key={`${li.code}-${i}`}
justify="space-between"
px="sm"
py={6}
@@ -55,10 +176,10 @@ export function BookingPricingSummary({ booking }: { booking: BookingDetail }) {
}}
>
<Text size="sm" c="dimmed">
Modifier
{li.description}
</Text>
<Text size="sm" fw={600} style={{ fontVariantNumeric: "tabular-nums" }}>
{Number(m.calculatedAmount).toLocaleString()}
{Number(li.amount).toLocaleString()} {li.currency}
</Text>
</Group>
))}

View File

@@ -1,14 +1,14 @@
import { Badge } from "@mantine/core";
export function BookingPriorityBadge({ score }: { score: number }) {
if (score >= 1000) {
if (score >= 70) {
return (
<Badge color="red" variant="filled" size="sm" radius="lg" tt="uppercase">
Urgent
</Badge>
);
}
if (score >= 500) {
if (score >= 40) {
return (
<Badge color="yellow" variant="filled" size="sm" radius="lg" tt="uppercase">
High

View File

@@ -9,6 +9,19 @@ import {
import { useAuth } from "@/auth/useAuth";
import { useBookingDetail, useBookingMutations } from "@/hooks/bookings/useBookings";
/** A contract validity window must be a whole number of days, 1365. */
function isValidValidityDays(value: string): boolean {
const days = Number(value.trim());
return Number.isInteger(days) && days >= 1 && days <= 365;
}
/** An adjusted price must be a non-negative number. */
function isValidAmount(value: string): boolean {
if (!value.trim()) return false;
const amount = Number(value.trim());
return Number.isFinite(amount) && amount >= 0;
}
export function useBookingActionDialog(
bookingId: string,
context: BookingActionContext,
@@ -59,15 +72,36 @@ export function useBookingActionDialog(
const onSuccess = () => closeDialog();
switch (pendingAction.id) {
case "accept":
mutations.staffAccept.mutate(undefined, { onSuccess });
case "accept": {
const days = Number(inputValue.trim());
if (!Number.isInteger(days) || days < 1 || days > 365) return;
mutations.staffAccept.mutate(days, { onSuccess });
break;
}
case "requestChanges":
mutations.requestChanges.mutate(inputValue.trim(), { onSuccess });
break;
case "reject":
mutations.staffReject.mutate(inputValue.trim(), { onSuccess });
break;
case "operationAccept":
mutations.reviewOperation.mutate({ decision: "ACCEPT" }, { onSuccess });
break;
case "operationRequestChanges":
mutations.reviewOperation.mutate(
{ decision: "REQUEST_CHANGES", note: inputValue.trim() },
{ onSuccess },
);
break;
case "operationAdjustPrice": {
const amount = Number(inputValue.trim());
if (!Number.isFinite(amount) || amount < 0) return;
mutations.reviewOperation.mutate(
{ decision: "ADJUST_PRICE", amount },
{ onSuccess },
);
break;
}
case "approve": {
const step = getNextPendingApprovalStep(mergedContext.approvalSteps);
if (!step) return;
@@ -116,7 +150,9 @@ export function useBookingActionDialog(
!getNextPendingApprovalStep(mergedContext.approvalSteps)) ||
(pendingAction?.input === "file" && !selectedFile) ||
(pendingAction?.input === "reason" && !inputValue.trim()) ||
(pendingAction?.input === "note" && !inputValue.trim());
(pendingAction?.input === "note" && !inputValue.trim()) ||
(pendingAction?.input === "days" && !isValidValidityDays(inputValue)) ||
(pendingAction?.input === "amount" && !isValidAmount(inputValue));
return {
actions,

View File

@@ -195,7 +195,7 @@ const ROUTE_META: Array<{ prefix: string; meta: PageMeta }> = [
prefix: RULE_ENGINE_CATEGORY_BASE_PATH.configuration,
meta: {
title: "Configuration",
subtitle: "Master data: cargo, containers, wagon types, services, surcharges, yards, and shipping lines",
subtitle: "Master data: cargo, containers, wagon types, services, yards, and shipping lines",
},
},
...configurationRouteMeta,

View File

@@ -158,11 +158,21 @@ const RuleEngineFormDialog = ({
const visibleFields = useMemo(
() =>
fields.filter(
(field) =>
!field.hideWhen ||
!field.hideWhen.equals.includes(String(values[field.hideWhen.field] ?? "")),
),
fields.filter((field) => {
if (
field.hideWhen &&
field.hideWhen.equals.includes(String(values[field.hideWhen.field] ?? ""))
) {
return false;
}
if (
field.showWhen &&
!field.showWhen.equals.includes(String(values[field.showWhen.field] ?? ""))
) {
return false;
}
return true;
}),
[fields, values],
);
@@ -244,6 +254,7 @@ const RuleEngineFormDialog = ({
<Select
key={field.name}
label={label}
description={field.description}
placeholder={
selectOptionsLoading ? "Loading options..." : (field.placeholder ?? "Select an option")
}

View File

@@ -69,6 +69,18 @@ export const QUERY_KEYS = {
list: (resource: FleetResourceSlug | string) => ["fleet", "list", resource] as const,
},
FIRST_MILE: {
ROOT: ["first-mile"] as const,
list: (filter?: Record<string, unknown>) => ["first-mile", "list", filter ?? {}] as const,
byId: (id: string) => ["first-mile", "detail", id] as const,
},
LAST_MILE: {
ROOT: ["last-mile"] as const,
list: (filter?: Record<string, unknown>) => ["last-mile", "list", filter ?? {}] as const,
byId: (id: string) => ["last-mile", "detail", id] as const,
},
RULE_ENGINE: {
ROOT: ["rule-engine"] as const,
list: (resource: RuleEngineResourceSlug | string, params?: RuleEngineListParams) =>

View File

@@ -229,9 +229,6 @@ export const URL_CONSTANTS = {
SERVICE_TYPES: "/service-types",
SERVICE_TYPE_BY_ID: (id: string) => `/service-types/${id}`,
SURCHARGE_TYPES: "/surcharge-types",
SURCHARGE_TYPE_BY_ID: (id: string) => `/surcharge-types/${id}`,
WEIGHT_LIMIT_RULES: "/weight-limit-rules",
WEIGHT_LIMIT_RULE_BY_ID: (id: string) => `/weight-limit-rules/${id}`,
@@ -370,6 +367,18 @@ export const URL_CONSTANTS = {
BY_ID: (id: string) => `/vehicles/${id}`,
},
FIRST_MILE: {
BASE: '/first-mile',
BY_ID: (id: string) => `/first-mile/${id}`,
ACCEPT: (reference: string) => `/first-mile/accept/${reference}`,
},
LAST_MILE: {
BASE: '/last-mile',
BY_ID: (id: string) => `/last-mile/${id}`,
ACCEPT: (reference: string) => `/last-mile/accept/${reference}`,
},
DRIVERS: {
BASE: '/drivers',
BY_ID: (id: string) => `/drivers/${id}`,

View File

@@ -1,3 +1,3 @@
// export const API_BASE_URL = 'https://edrfreightapi.triaplc.com';
export const API_BASE_URL = 'https://edrfreightapi.triaplc.com';
export const API_BASE_URL = 'http://localhost:3001';
// export const API_BASE_URL = 'http://localhost:3001';

View File

@@ -2,6 +2,7 @@ import type { LucideIcon } from "lucide-react";
import {
Ban,
Check,
Coins,
FileSignature,
MessageSquareWarning,
Play,
@@ -34,9 +35,17 @@ export type BookingActionId =
| "allocateBooking"
| "startTransit"
| "complete"
| "operationAccept"
| "operationRequestChanges"
| "operationAdjustPrice"
| "cancel";
export type BookingActionInputKind = "note" | "reason" | "file";
export type BookingActionInputKind =
| "note"
| "reason"
| "file"
| "days"
| "amount";
export interface BookingActionDef {
id: BookingActionId;
@@ -121,10 +130,13 @@ const SUBMITTED_ACTIONS: BookingActionDef[] = [
description: "Start the formal approval chain",
confirmTitle: "Accept submission?",
confirmDescription:
"The booking moves to pending approval and approval steps are created from the rule engine.",
"Set how long the contract stays valid, then the booking moves to pending approval and approval steps are created from the rule engine.",
variant: "default",
icon: ShieldCheck,
primary: true,
input: "days",
inputLabel: "Contract validity (days)",
inputPlaceholder: "e.g. 30",
},
{
id: "requestChanges",
@@ -156,6 +168,50 @@ const SUBMITTED_ACTIONS: BookingActionDef[] = [
},
];
// Marketing/operations review of a drawdown order's operation request.
const OPERATION_REVIEW_ACTIONS: BookingActionDef[] = [
{
id: "operationAccept",
label: "Accept operation",
shortLabel: "Accept",
description: "Accept the operation request and release it for dispatch",
confirmTitle: "Accept operation request?",
confirmDescription:
"Train orders enter the batch pool; road orders move to truck dispatch.",
variant: "default",
icon: Check,
primary: true,
},
{
id: "operationRequestChanges",
label: "Request changes",
shortLabel: "Changes",
description: "Ask the customer to adjust the operation request",
confirmTitle: "Request changes to the operation?",
confirmDescription:
"The customer will see your note and can adjust and resubmit the order.",
variant: "outline",
icon: MessageSquareWarning,
input: "note",
inputLabel: "Message to customer",
inputPlaceholder: "Describe what needs to change…",
},
{
id: "operationAdjustPrice",
label: "Adjust price",
shortLabel: "Price",
description: "Set an adjusted total the customer must confirm",
confirmTitle: "Adjust the order price?",
confirmDescription:
"Enter the new total. The customer must confirm it before the order proceeds.",
variant: "outline",
icon: Coins,
input: "amount",
inputLabel: "Adjusted total",
inputPlaceholder: "0.00",
},
];
const CANCEL_ACTION: BookingActionDef = {
id: "cancel",
label: "Cancel booking",
@@ -207,6 +263,9 @@ const ACTION_PERMISSION: Partial<Record<BookingActionId, string>> = {
signContractStaff: FREIGHT_PERMS.bookings.signStaff,
startTransit: FREIGHT_PERMS.bookings.operations,
complete: FREIGHT_PERMS.bookings.operations,
operationAccept: FREIGHT_PERMS.bookings.operations,
operationRequestChanges: FREIGHT_PERMS.bookings.operations,
operationAdjustPrice: FREIGHT_PERMS.bookings.operations,
allocateBooking: FREIGHT_PERMS.trainScheduling.manage,
cancel: FREIGHT_PERMS.bookings.cancel,
};
@@ -300,6 +359,9 @@ export function getBookingActions(
},
];
break;
case "OPERATION_REQUEST_PENDING":
actions = withCancel(OPERATION_REVIEW_ACTIONS);
break;
case "PAID":
if (
canAllocateBooking({ status, schedulingStatus: ctx.schedulingStatus })

View File

@@ -86,6 +86,22 @@ export const BOOKING_STATUS_STYLES: Record<string, StatusStyle> = {
label: "Consolidated",
color: "bg-indigo-50 text-indigo-700 border-indigo-200",
},
OPERATION_REQUEST_PENDING: {
label: "Operation Review",
color: "bg-amber-50 text-amber-700 border-amber-200",
},
OPERATION_CHANGES_REQUESTED: {
label: "Operation Changes",
color: "bg-orange-50 text-orange-700 border-orange-200",
},
OPERATION_PRICE_PENDING_CONFIRM: {
label: "Price Confirm",
color: "bg-amber-50 text-amber-700 border-amber-200",
},
ROAD_DISPATCH_PENDING: {
label: "Truck Dispatch",
color: "bg-blue-50 text-blue-700 border-blue-200",
},
};
export interface StatusMeta {
@@ -257,10 +273,19 @@ export const BOOKING_LIST_TABS = [
"EXPIRED",
],
},
{
key: "ops_review",
label: "Ops review",
statuses: [
"OPERATION_REQUEST_PENDING",
"OPERATION_CHANGES_REQUESTED",
"OPERATION_PRICE_PENDING_CONFIRM",
],
},
{
key: "operations",
label: "Operations",
statuses: ["PAID", "IN_TRANSIT"],
statuses: ["PAID", "IN_TRANSIT", "ROAD_DISPATCH_PENDING"],
},
{ key: "completed", label: "Completed", statuses: ["COMPLETED"] },
{ key: "closed", label: "Closed", statuses: ["REJECTED", "CANCELLED"] },

View File

@@ -41,7 +41,8 @@ export function useBookingMutations(bookingId: string) {
};
const staffAccept = useMutation({
mutationFn: () => api.bookings.staffAccept.call({ id: bookingId }),
mutationFn: (validityDays: number) =>
api.bookings.staffAccept.call({ id: bookingId, validityDays }),
onSuccess: (data) => onSuccess(data, "Booking accepted for approval"),
onError: () => toast.error("Failed to accept booking"),
});
@@ -60,6 +61,16 @@ export function useBookingMutations(bookingId: string) {
onError: () => toast.error("Failed to reject booking"),
});
const reviewOperation = useMutation({
mutationFn: (payload: {
decision: "ACCEPT" | "REQUEST_CHANGES" | "ADJUST_PRICE";
note?: string;
amount?: number;
}) => api.bookings.reviewOperation.call({ id: bookingId, ...payload }),
onSuccess: (data) => onSuccess(data, "Operation request reviewed"),
onError: () => toast.error("Failed to review operation request"),
});
const approveStep = useMutation({
mutationFn: ({
stepId,
@@ -147,12 +158,14 @@ export function useBookingMutations(bookingId: string) {
payBooking.isPending ||
startTransit.isPending ||
complete.isPending ||
reviewOperation.isPending ||
cancel.isPending;
return {
staffAccept,
requestChanges,
staffReject,
reviewOperation,
approveStep,
rejectStep,
generateContract,

View File

@@ -96,6 +96,40 @@ export const useCargoTypeParentOptions = (excludeId?: string, enabled = true) =>
},
});
/**
* Cargo-type options restricted to LEAF nodes (actual commodities, not parent
* groups). A node is a leaf when no other cargo type names it as parent. Used
* by the Rate form's "Bulk cargo type" picker.
*/
export const useCargoLeafOptions = (enabled = true) =>
useQuery({
queryKey: QUERY_KEYS.RULE_ENGINE.selectOptions("cargo-types", { leafOnly: true }),
queryFn: () =>
ruleEngineService.list<RuleEngineRecord>("cargo-types", {
page: 1,
pageSize: CARGO_TYPE_PARENT_PAGE_SIZE,
}),
enabled,
select: (result) => {
const rows = result.data ?? [];
const parentIds = new Set(
rows
.map((row) => row.parentGroupId)
.filter((id): id is string => Boolean(id))
.map((id) => String(id)),
);
return rows
.filter((row) => row.id && !parentIds.has(String(row.id)))
.map((row) => {
const name = String(row.cargoTypeName ?? "").trim();
const code = String(row.code ?? "").trim();
const label =
name && code ? `${name} (${code})` : name || code || String(row.id);
return { label, value: String(row.id) };
});
},
});
export function buildContainerTypeSelectOptions(
rows: RuleEngineRecord[],
includeNone: boolean,

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