mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-01 18:13:27 +00:00
Merge pull request #259 from Tria-plc/freight_feature/profile
Freight feature/profile
This commit is contained in:
@@ -39,7 +39,16 @@ async function bootstrap() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.setGlobalPrefix("api");
|
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.useGlobalFilters(new HttpExceptionFilter());
|
||||||
app.useGlobalInterceptors(new ResponseTransformInterceptor());
|
app.useGlobalInterceptors(new ResponseTransformInterceptor());
|
||||||
|
|
||||||
|
|||||||
@@ -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;`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -45,6 +45,15 @@ export class BookingOrdersController {
|
|||||||
return this.generalContractService.getQuantityLines(id);
|
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')
|
@Get(':id')
|
||||||
@ApiOperation({ summary: 'Get a single booking order' })
|
@ApiOperation({ summary: 'Get a single booking order' })
|
||||||
async findOne(@Param('id', ParseUUIDPipe) id: string) {
|
async findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
|||||||
@@ -3,20 +3,23 @@ import { TypeOrmModule } from '@nestjs/typeorm';
|
|||||||
import { BookingsModule } from '../bookings/bookings.module';
|
import { BookingsModule } from '../bookings/bookings.module';
|
||||||
import { CompaniesModule } from '../companies/companies.module';
|
import { CompaniesModule } from '../companies/companies.module';
|
||||||
import { DropdownSettingsModule } from '../dropdown-settings/dropdown-settings.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 { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module';
|
||||||
import { BookingOrdersController } from './booking-orders.controller';
|
import { BookingOrdersController } from './booking-orders.controller';
|
||||||
import { BookingOrdersRepository } from './booking-orders.repository';
|
import { BookingOrdersRepository } from './booking-orders.repository';
|
||||||
import { BookingOrdersService } from './booking-orders.service';
|
import { BookingOrdersService } from './booking-orders.service';
|
||||||
import { BookingOrder } from './entities/booking-order.entity';
|
import { BookingOrder } from './entities/booking-order.entity';
|
||||||
import { BookingOrderLine } from './entities/booking-order-line.entity';
|
import { BookingOrderLine } from './entities/booking-order-line.entity';
|
||||||
|
import { ContractRouteLine } from './entities/contract-route-line.entity';
|
||||||
import { GeneralContractService } from './general-contract.service';
|
import { GeneralContractService } from './general-contract.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
TypeOrmModule.forFeature([BookingOrder, BookingOrderLine]),
|
TypeOrmModule.forFeature([BookingOrder, BookingOrderLine, ContractRouteLine]),
|
||||||
BookingsModule,
|
BookingsModule,
|
||||||
CompaniesModule,
|
CompaniesModule,
|
||||||
DropdownSettingsModule,
|
DropdownSettingsModule,
|
||||||
|
RuleEngineModule,
|
||||||
forwardRef(() => TrainSchedulingModule),
|
forwardRef(() => TrainSchedulingModule),
|
||||||
],
|
],
|
||||||
controllers: [BookingOrdersController],
|
controllers: [BookingOrdersController],
|
||||||
|
|||||||
@@ -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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -8,11 +8,13 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
import { BookingsRepository } from '../bookings/bookings.repository';
|
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 { Booking } from '../bookings/entities/booking.entity';
|
||||||
import { BookingContainer } from '../bookings/entities/booking-container.entity';
|
import { BookingContainer } from '../bookings/entities/booking-container.entity';
|
||||||
import { CompaniesService } from '../companies/companies.service';
|
import { CompaniesService } from '../companies/companies.service';
|
||||||
import { ContainerType } from '../rule-engine/entities/container-type.entity';
|
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 { TrainSchedulingService } from '../train-scheduling/train-scheduling.service';
|
||||||
import { eatDay } from '../train-scheduling/batch-window.util';
|
import { eatDay } from '../train-scheduling/batch-window.util';
|
||||||
import { BookingOrdersRepository } from './booking-orders.repository';
|
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 { BookingOrder } from './entities/booking-order.entity';
|
||||||
import { BookingOrderLine } from './entities/booking-order-line.entity';
|
import { BookingOrderLine } from './entities/booking-order-line.entity';
|
||||||
import { GeneralContractService } from './general-contract.service';
|
import { GeneralContractService } from './general-contract.service';
|
||||||
|
import { isRoadService, roadKmPrice } from './road.util';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class BookingOrdersService {
|
export class BookingOrdersService {
|
||||||
@@ -31,8 +34,8 @@ export class BookingOrdersService {
|
|||||||
private readonly bookingsRepository: BookingsRepository,
|
private readonly bookingsRepository: BookingsRepository,
|
||||||
private readonly companiesService: CompaniesService,
|
private readonly companiesService: CompaniesService,
|
||||||
private readonly generalContractService: GeneralContractService,
|
private readonly generalContractService: GeneralContractService,
|
||||||
@Inject(forwardRef(() => BookingBatchService))
|
private readonly pricingService: BookingPricingService,
|
||||||
private readonly bookingBatchService: BookingBatchService,
|
private readonly ratesService: RatesService,
|
||||||
@Inject(forwardRef(() => TrainSchedulingService))
|
@Inject(forwardRef(() => TrainSchedulingService))
|
||||||
private readonly trainSchedulingService: TrainSchedulingService,
|
private readonly trainSchedulingService: TrainSchedulingService,
|
||||||
) {}
|
) {}
|
||||||
@@ -79,12 +82,40 @@ export class BookingOrdersService {
|
|||||||
throw new BadRequestException('You do not have access to this contract');
|
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.
|
// Validate the route has a departure on the chosen day.
|
||||||
const day = eatDay(new Date(dto.scheduledDate));
|
const day = eatDay(new Date(dto.scheduledDate));
|
||||||
const hasDeparture =
|
const hasDeparture =
|
||||||
await this.trainSchedulingService.existsOpenScheduleOnRouteDay(
|
await this.trainSchedulingService.existsOpenScheduleOnRouteDay(
|
||||||
contract.originYardId,
|
originYardId,
|
||||||
contract.destinationYardId,
|
destinationYardId,
|
||||||
day,
|
day,
|
||||||
);
|
);
|
||||||
if (!hasDeparture) {
|
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 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) {
|
for (const line of dto.lines) {
|
||||||
if (line.quantity <= 0) {
|
const haz = line.hazardousQuantity ?? 0;
|
||||||
throw new BadRequestException('Order quantities must be greater than zero');
|
const reefer = line.reeferQuantity ?? 0;
|
||||||
|
if (haz < 0 || reefer < 0) {
|
||||||
|
throw new BadRequestException('Hazardous/reefer quantities cannot be negative');
|
||||||
}
|
}
|
||||||
const key = isContainer ? (line.containerTypeId ?? '') : '';
|
if (haz > line.quantity || reefer > line.quantity) {
|
||||||
const poolLine = poolLines.find((p) => (p.containerTypeId ?? '') === key);
|
|
||||||
if (!poolLine) {
|
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
isContainer
|
'Hazardous/reefer quantity cannot exceed the line quantity',
|
||||||
? `Container type ${line.containerTypeId} is not part of this contract`
|
|
||||||
: 'This contract has no matching quantity pool',
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
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(
|
throw new BadRequestException(
|
||||||
`Requested ${line.quantity} exceeds remaining ${poolLine.remainingQuantity}` +
|
`Requested ${orderTotal} exceeds remaining ${chosen.remainingQuantity} for this route`,
|
||||||
(poolLine.containerTypeName ? ` for ${poolLine.containerTypeName}` : ''),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
} 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.
|
// Persist the order + its child shipment booking atomically.
|
||||||
const order = await this.dataSource.transaction(async (manager) => {
|
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 reference = await this.generateReference();
|
||||||
const orderRow = manager.create(BookingOrder, {
|
const orderRow = manager.create(BookingOrder, {
|
||||||
reference,
|
reference,
|
||||||
contractBookingId: contract.id,
|
contractBookingId: contract.id,
|
||||||
bookingId: childBooking.id,
|
bookingId: childBooking.id,
|
||||||
|
routeLineId,
|
||||||
companyId: contract.companyId ?? null,
|
companyId: contract.companyId ?? null,
|
||||||
scheduledDate: new Date(dto.scheduledDate),
|
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',
|
schedulingStatus: 'NOT_SCHEDULED',
|
||||||
});
|
});
|
||||||
const savedOrder = await manager.save(orderRow);
|
const savedOrder = await manager.save(orderRow);
|
||||||
@@ -140,6 +211,8 @@ export class BookingOrdersService {
|
|||||||
orderId: savedOrder.id,
|
orderId: savedOrder.id,
|
||||||
containerTypeId: isContainer ? (l.containerTypeId ?? null) : null,
|
containerTypeId: isContainer ? (l.containerTypeId ?? null) : null,
|
||||||
quantity: l.quantity,
|
quantity: l.quantity,
|
||||||
|
hazardousQuantity: l.hazardousQuantity ?? 0,
|
||||||
|
reeferQuantity: l.reeferQuantity ?? 0,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
await manager.save(lines);
|
await manager.save(lines);
|
||||||
@@ -147,20 +220,12 @@ export class BookingOrdersService {
|
|||||||
return savedOrder;
|
return savedOrder;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Feed the child booking into the day-pool batch so it allocates to a train.
|
// The child does NOT enter the train batch pool here. It is priced and
|
||||||
try {
|
// unpaid, awaiting Marketing review (OPERATION_REQUEST_PENDING) or customs
|
||||||
await this.bookingBatchService.processRouteDay({
|
// clearance first; the batch enqueue happens only on accept.
|
||||||
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)}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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)) {
|
if (await this.generalContractService.isExhausted(contract.id)) {
|
||||||
await this.dataSource
|
await this.dataSource
|
||||||
.getRepository(Booking)
|
.getRepository(Booking)
|
||||||
@@ -175,15 +240,18 @@ export class BookingOrdersService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Create the ONE_TIME child booking for an order, inheriting the contract's
|
* 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(
|
private async spawnChildBooking(
|
||||||
contract: Booking,
|
contract: Booking,
|
||||||
dto: CreateBookingOrderDto,
|
dto: CreateBookingOrderDto,
|
||||||
|
route: { originYardId: string; destinationYardId: string; km: number | null },
|
||||||
manager: import('typeorm').EntityManager,
|
manager: import('typeorm').EntityManager,
|
||||||
): Promise<Booking> {
|
): Promise<Booking> {
|
||||||
const reference = await this.generateChildBookingReference();
|
const reference = await this.generateChildBookingReference();
|
||||||
const now = new Date();
|
|
||||||
const isContainer = contract.freightType === 'CONTAINER';
|
const isContainer = contract.freightType === 'CONTAINER';
|
||||||
|
|
||||||
// Sum line quantities × the contract's per-unit weight for the child total.
|
// 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);
|
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, {
|
const child = manager.create(Booking, {
|
||||||
reference,
|
reference,
|
||||||
companyId: contract.companyId ?? null,
|
companyId: contract.companyId ?? null,
|
||||||
@@ -213,27 +293,24 @@ export class BookingOrdersService {
|
|||||||
firstMilePickupAddress: contract.firstMilePickupAddress ?? null,
|
firstMilePickupAddress: contract.firstMilePickupAddress ?? null,
|
||||||
lastMileDeliveryAddress: contract.lastMileDeliveryAddress ?? null,
|
lastMileDeliveryAddress: contract.lastMileDeliveryAddress ?? null,
|
||||||
equipmentReturn: contract.equipmentReturn,
|
equipmentReturn: contract.equipmentReturn,
|
||||||
originYardId: contract.originYardId,
|
originYardId: route.originYardId,
|
||||||
destinationYardId: contract.destinationYardId,
|
destinationYardId: route.destinationYardId,
|
||||||
tradeDirection: contract.tradeDirection,
|
tradeDirection: contract.tradeDirection,
|
||||||
freightType: contract.freightType,
|
freightType: contract.freightType,
|
||||||
cargoTypeId: contract.cargoTypeId ?? null,
|
cargoTypeId: contract.cargoTypeId ?? null,
|
||||||
cargoFreeText: contract.cargoFreeText ?? null,
|
cargoFreeText: contract.cargoFreeText ?? null,
|
||||||
shippingLineId: contract.shippingLineId ?? null,
|
shippingLineId: contract.shippingLineId ?? null,
|
||||||
cargoTotalWeightVgm: totalWeight,
|
cargoTotalWeightVgm: totalWeight,
|
||||||
isHazardous: contract.isHazardous,
|
isHazardous: hasHazardous,
|
||||||
|
isReefer: hasReefer,
|
||||||
paymentCurrency: contract.paymentCurrency,
|
paymentCurrency: contract.paymentCurrency,
|
||||||
bookingType: 'ONE_TIME',
|
bookingType: 'ONE_TIME',
|
||||||
scheduledDate: new Date(dto.scheduledDate),
|
scheduledDate: new Date(dto.scheduledDate),
|
||||||
// Already covered by the contract's one-time payment: enter the pool ready
|
// Priced + unpaid: the customer pays this order on its own.
|
||||||
// and paid so the batch engine reserves → allocates it immediately.
|
status: spawnStatus,
|
||||||
status: 'FULLY_EXECUTED',
|
paymentStatus: 'PENDING',
|
||||||
paymentStatus: 'PAID',
|
|
||||||
fullyExecutedAt: now,
|
|
||||||
customerSignedAt: now,
|
|
||||||
priorityScore: contract.priorityScore,
|
priorityScore: contract.priorityScore,
|
||||||
totalAmount: 0,
|
totalAmount: 0,
|
||||||
allowConsolidation: false,
|
|
||||||
schedulingStatus: 'NOT_SCHEDULED',
|
schedulingStatus: 'NOT_SCHEDULED',
|
||||||
});
|
});
|
||||||
const savedChild = await manager.save(child);
|
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;
|
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(
|
private async userOwnsContract(
|
||||||
userId: string,
|
userId: string,
|
||||||
contract: Booking,
|
contract: Booking,
|
||||||
|
|||||||
@@ -21,3 +21,39 @@ export class ContractQuantityLineView {
|
|||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
remainingQuantity!: number;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -25,6 +25,26 @@ export class CreateBookingOrderLineDto {
|
|||||||
@Min(0)
|
@Min(0)
|
||||||
@Transform(({ value }) => Number(value))
|
@Transform(({ value }) => Number(value))
|
||||||
quantity!: number;
|
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 {
|
export class CreateBookingOrderDto {
|
||||||
@@ -32,6 +52,16 @@ export class CreateBookingOrderDto {
|
|||||||
@IsUUID()
|
@IsUUID()
|
||||||
contractBookingId!: string;
|
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' })
|
@ApiProperty({ example: '2026-07-01T00:00:00.000Z', description: 'Shipment day for this order' })
|
||||||
@IsDateString()
|
@IsDateString()
|
||||||
scheduledDate!: string;
|
scheduledDate!: string;
|
||||||
|
|||||||
@@ -27,4 +27,15 @@ export class BookingOrderLine extends BaseEntity {
|
|||||||
/** Containers (count), tons, or items depending on the contract's freight/UoM. */
|
/** Containers (count), tons, or items depending on the contract's freight/UoM. */
|
||||||
@Column({ name: 'quantity', type: 'numeric', precision: 12, scale: 3 })
|
@Column({ name: 'quantity', type: 'numeric', precision: 12, scale: 3 })
|
||||||
quantity!: number;
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,14 @@ export class BookingOrder extends BaseEntity {
|
|||||||
@JoinColumn({ name: 'company_id' })
|
@JoinColumn({ name: 'company_id' })
|
||||||
company?: Company | null;
|
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' })
|
@Column({ name: 'scheduled_date', type: 'timestamptz' })
|
||||||
scheduledDate!: Date;
|
scheduledDate!: Date;
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -4,7 +4,11 @@ import { DataSource } from 'typeorm';
|
|||||||
import { DropdownSettingsService } from '../dropdown-settings/dropdown-settings.service';
|
import { DropdownSettingsService } from '../dropdown-settings/dropdown-settings.service';
|
||||||
import { Booking } from '../bookings/entities/booking.entity';
|
import { Booking } from '../bookings/entities/booking.entity';
|
||||||
import { BookingOrder } from './entities/booking-order.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. */
|
/** Setting code holding the global ordering window (in months) for general contracts. */
|
||||||
export const CONTRACT_PERIOD_SETTING_CODE = 'general_contract_period';
|
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). */
|
/** Sum of non-cancelled order line quantities, keyed by container type id ('' = bulk). */
|
||||||
private async orderedByContainerType(
|
private async orderedByContainerType(
|
||||||
contractBookingId: string,
|
contractBookingId: string,
|
||||||
@@ -155,6 +223,12 @@ export class GeneralContractService {
|
|||||||
|
|
||||||
/** True once every contracted line is fully drawn down. */
|
/** True once every contracted line is fully drawn down. */
|
||||||
async isExhausted(contractBookingId: string): Promise<boolean> {
|
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);
|
const lines = await this.getQuantityLines(contractBookingId);
|
||||||
return lines.every((l) => l.remainingQuantity <= 0);
|
return lines.every((l) => l.remainingQuantity <= 0);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
35
apps/edr-freight-api/src/modules/booking-orders/road.util.ts
Normal file
35
apps/edr-freight-api/src/modules/booking-orders/road.util.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -19,9 +19,17 @@ import { FileRecord } from '../files/entities/file.entity';
|
|||||||
import { BookingsRepository } from './bookings.repository';
|
import { BookingsRepository } from './bookings.repository';
|
||||||
import { Booking } from './entities/booking.entity';
|
import { Booking } from './entities/booking.entity';
|
||||||
import { assertBookingStatus } from './booking-status.util';
|
import { assertBookingStatus } from './booking-status.util';
|
||||||
|
import { clearanceSettingCode } from './clearance.util';
|
||||||
import { ContractViewDto } from './dto/contract-view.dto';
|
import { ContractViewDto } from './dto/contract-view.dto';
|
||||||
import { SignContractDto } from './dto/sign-contract.dto';
|
import { SignContractDto } from './dto/sign-contract.dto';
|
||||||
import { ContractSignerRole } from './entities/booking-contract-signature.entity';
|
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 { BookingBatchService } from '../train-scheduling/booking-batch.service';
|
||||||
import { SignaturesService } from '../signatures/signatures.service';
|
import { SignaturesService } from '../signatures/signatures.service';
|
||||||
|
|
||||||
@@ -222,19 +230,45 @@ export class BookingContractService {
|
|||||||
|
|
||||||
const updates: Record<string, unknown> = {};
|
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') {
|
if (role === 'CUSTOMER') {
|
||||||
updates.status = 'SIGNED_CUSTOMER';
|
updates.status = 'SIGNED_CUSTOMER';
|
||||||
updates.customerSignedAt = now;
|
updates.customerSignedAt = now;
|
||||||
} else {
|
} else if (isGeneralContract) {
|
||||||
updates.status = 'FULLY_EXECUTED';
|
// 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.fullyExecutedAt = now;
|
||||||
updates.marketingApprovedAt = now;
|
updates.marketingApprovedAt = now;
|
||||||
updates.marketingApprovedById = options.signerUserId ?? null;
|
updates.marketingApprovedById = options.signerUserId ?? null;
|
||||||
updates.lockedAt = now;
|
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);
|
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);
|
this.bookingBatchService.enqueueScheduleProcessing(updated.trainScheduleId);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -57,6 +57,45 @@ export function computeNextStep(
|
|||||||
action: 'AWAIT_PAYMENT',
|
action: 'AWAIT_PAYMENT',
|
||||||
description: 'Awaiting customer 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':
|
case 'PAID':
|
||||||
return {
|
return {
|
||||||
action: 'START_TRANSIT',
|
action: 'START_TRANSIT',
|
||||||
|
|||||||
@@ -44,7 +44,6 @@ describe('BookingPricingService — domestic corridor', () => {
|
|||||||
{} as never,
|
{} as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
ratesService as never,
|
ratesService as never,
|
||||||
{} as never,
|
|
||||||
exchangeService as never,
|
exchangeService as never,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { Injectable, NotFoundException } from '@nestjs/common';
|
|||||||
|
|
||||||
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
|
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
|
||||||
import { RatesService } from '../rule-engine/services/rates.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 { Rate } from '../rule-engine/entities/rate.entity';
|
||||||
import { ExchangeService } from '@edr/api-common';
|
import { ExchangeService } from '@edr/api-common';
|
||||||
import {
|
import {
|
||||||
@@ -11,6 +10,10 @@ import {
|
|||||||
RuleEngineService,
|
RuleEngineService,
|
||||||
} from '../rule-engine/rule-engine.service';
|
} from '../rule-engine/rule-engine.service';
|
||||||
import { BookingsRepository } from './bookings.repository';
|
import { BookingsRepository } from './bookings.repository';
|
||||||
|
import {
|
||||||
|
containersPerWagon,
|
||||||
|
wagonRemainder,
|
||||||
|
} from './consolidation.service';
|
||||||
import { GeneratePriceResponseDto, PriceLineItemDto } from './dto/generate-price-response.dto';
|
import { GeneratePriceResponseDto, PriceLineItemDto } from './dto/generate-price-response.dto';
|
||||||
import { Booking } from './entities/booking.entity';
|
import { Booking } from './entities/booking.entity';
|
||||||
import { assertBookingStatus } from './booking-status.util';
|
import { assertBookingStatus } from './booking-status.util';
|
||||||
@@ -33,6 +36,29 @@ type StoredPricingBreakdown = {
|
|||||||
generatedAt?: string;
|
generatedAt?: string;
|
||||||
} | null;
|
} | 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()
|
@Injectable()
|
||||||
export class BookingPricingService {
|
export class BookingPricingService {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -40,7 +66,6 @@ export class BookingPricingService {
|
|||||||
private readonly ruleEngineService: RuleEngineService,
|
private readonly ruleEngineService: RuleEngineService,
|
||||||
private readonly containerTypesService: ContainerTypesService,
|
private readonly containerTypesService: ContainerTypesService,
|
||||||
private readonly ratesService: RatesService,
|
private readonly ratesService: RatesService,
|
||||||
private readonly serviceTypesService: ServiceTypesService,
|
|
||||||
private readonly exchangeService: ExchangeService,
|
private readonly exchangeService: ExchangeService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -103,16 +128,35 @@ export class BookingPricingService {
|
|||||||
for (const mod of ruleResult.appliedModifiers) {
|
for (const mod of ruleResult.appliedModifiers) {
|
||||||
const usdAmount = mod.calculatedAmount;
|
const usdAmount = mod.calculatedAmount;
|
||||||
const convertedAmount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount;
|
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 = {
|
const item: PriceLineItemDto = {
|
||||||
code: mod.surchargeTypeCode,
|
code: mod.surchargeCode,
|
||||||
description: `Surcharge: ${mod.surchargeTypeCode}`,
|
description: surchargeLabel(mod.surchargeCode),
|
||||||
amount: convertedAmount,
|
amount: convertedAmount,
|
||||||
|
unitAmount,
|
||||||
|
unit,
|
||||||
|
quantity,
|
||||||
currency: paymentCurrency,
|
currency: paymentCurrency,
|
||||||
};
|
};
|
||||||
lineItems.push(item);
|
lineItems.push(item);
|
||||||
total += convertedAmount;
|
total += convertedAmount;
|
||||||
|
|
||||||
const rate = rateById.get(mod.rateId);
|
|
||||||
if (rate) usedRatesMap.set(rate.id, rate);
|
if (rate) usedRatesMap.set(rate.id, rate);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,7 +196,7 @@ export class BookingPricingService {
|
|||||||
if (!snapshotId) return null;
|
if (!snapshotId) return null;
|
||||||
return {
|
return {
|
||||||
bookingId,
|
bookingId,
|
||||||
surchargeTypeId: m.surchargeTypeId,
|
rateId: m.rateId,
|
||||||
triggerValue: m.triggerValue,
|
triggerValue: m.triggerValue,
|
||||||
calculatedAmount: m.calculatedAmount,
|
calculatedAmount: m.calculatedAmount,
|
||||||
rateSnapshotId: snapshotId,
|
rateSnapshotId: snapshotId,
|
||||||
@@ -166,7 +210,7 @@ export class BookingPricingService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async buildEvalInputForBooking(booking: Booking): Promise<BookingEvaluationInput> {
|
async buildEvalInputForBooking(booking: Booking): Promise<BookingEvaluationInput> {
|
||||||
const containers = await Promise.all(
|
const lines = await Promise.all(
|
||||||
(booking.bookingContainers ?? [])
|
(booking.bookingContainers ?? [])
|
||||||
.filter((bc): bc is typeof bc & { containerTypeId: string } => bc.containerTypeId != null)
|
.filter((bc): bc is typeof bc & { containerTypeId: string } => bc.containerTypeId != null)
|
||||||
.map(async (bc) => {
|
.map(async (bc) => {
|
||||||
@@ -174,14 +218,19 @@ export class BookingPricingService {
|
|||||||
const vgm = Number(bc.vgmPerUnitTons);
|
const vgm = Number(bc.vgmPerUnitTons);
|
||||||
const qty = bc.quantity;
|
const qty = bc.quantity;
|
||||||
return {
|
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,
|
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.
|
// Wagon count is persisted per container line at booking creation; sum it.
|
||||||
const totalWagons =
|
const totalWagons =
|
||||||
booking.freightType === 'CONTAINER'
|
booking.freightType === 'CONTAINER'
|
||||||
@@ -193,15 +242,38 @@ export class BookingPricingService {
|
|||||||
)
|
)
|
||||||
: 0;
|
: 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 {
|
return {
|
||||||
freightType: booking.freightType as 'CONTAINER' | 'BULK',
|
freightType: booking.freightType as 'CONTAINER' | 'BULK',
|
||||||
cargoTypeId: booking.cargoTypeId ?? null,
|
cargoTypeId: booking.cargoTypeId ?? null,
|
||||||
serviceTypeId: booking.serviceTypeId,
|
serviceTypeId: booking.serviceTypeId,
|
||||||
paymentCurrency: booking.paymentCurrency,
|
paymentCurrency: booking.paymentCurrency,
|
||||||
tradeDirection: booking.tradeDirection,
|
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,
|
isGovernment: booking.isGovernment,
|
||||||
allowConsolidation: booking.allowConsolidation,
|
allowConsolidation,
|
||||||
shippingLineId: booking.shippingLineId,
|
shippingLineId: booking.shippingLineId,
|
||||||
totalWagons,
|
totalWagons,
|
||||||
containers,
|
containers,
|
||||||
@@ -240,6 +312,9 @@ export class BookingPricingService {
|
|||||||
code: 'TOTAL',
|
code: 'TOTAL',
|
||||||
description: 'Contract total',
|
description: 'Contract total',
|
||||||
amount: total,
|
amount: total,
|
||||||
|
unitAmount: total,
|
||||||
|
unit: 'FLAT',
|
||||||
|
quantity: 1,
|
||||||
currency: booking.paymentCurrency,
|
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> {
|
async computeSubmitPriorityScore(booking: Booking): Promise<number> {
|
||||||
const evalInput = await this.buildEvalInputForBooking(booking);
|
const evalInput = await this.buildEvalInputForBooking(booking);
|
||||||
const ruleResult = await this.ruleEngineService.evaluate(evalInput);
|
const ruleResult = await this.ruleEngineService.evaluate(evalInput);
|
||||||
let score = ruleResult.priorityScore;
|
return 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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async computeBaseRailLinesWithRates(
|
private async computeBaseRailLinesWithRates(
|
||||||
@@ -312,10 +378,15 @@ export class BookingPricingService {
|
|||||||
usedRatesMap.set(rate.id, rate);
|
usedRatesMap.set(rate.id, rate);
|
||||||
const usdAmount = this.amountForRate(rate, container.quantity, wagonCount);
|
const usdAmount = this.amountForRate(rate, container.quantity, wagonCount);
|
||||||
const amount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount;
|
const amount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount;
|
||||||
|
const unitUsd = Number(rate.rateValue);
|
||||||
|
const label = await this.containerTypeLabel(container.containerTypeId);
|
||||||
lines.push({
|
lines.push({
|
||||||
code: rateType,
|
code: rateType,
|
||||||
description: `Base rail (${rateType})`,
|
description: `${label} rail freight`,
|
||||||
amount,
|
amount,
|
||||||
|
unitAmount: isEtbBooking ? Math.round(unitUsd * usdToEtb) : unitUsd,
|
||||||
|
unit: rate.rateUnit,
|
||||||
|
quantity: this.effectiveUnitQuantity(rate.rateUnit, container.quantity, wagonCount),
|
||||||
currency: paymentCurrency,
|
currency: paymentCurrency,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -331,10 +402,14 @@ export class BookingPricingService {
|
|||||||
isBulk && fallback.rateUnit === 'PER_TON' ? Math.max(bulkTons, 0) : 1;
|
isBulk && fallback.rateUnit === 'PER_TON' ? Math.max(bulkTons, 0) : 1;
|
||||||
const usdAmount = this.amountForRate(fallback, quantity, wagonCount);
|
const usdAmount = this.amountForRate(fallback, quantity, wagonCount);
|
||||||
const amount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount;
|
const amount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount;
|
||||||
|
const unitUsd = Number(fallback.rateValue);
|
||||||
lines.push({
|
lines.push({
|
||||||
code: rateType,
|
code: rateType,
|
||||||
description: `Base rail (${rateType})`,
|
description: isBulk ? 'Bulk rail freight' : 'Container rail freight',
|
||||||
amount,
|
amount,
|
||||||
|
unitAmount: isEtbBooking ? Math.round(unitUsd * usdToEtb) : unitUsd,
|
||||||
|
unit: fallback.rateUnit,
|
||||||
|
quantity: this.effectiveUnitQuantity(fallback.rateUnit, quantity, wagonCount),
|
||||||
currency: paymentCurrency,
|
currency: paymentCurrency,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -343,6 +418,34 @@ export class BookingPricingService {
|
|||||||
return { lineItems: lines, usedRates: [...usedRatesMap.values()] };
|
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(
|
private pickRate(
|
||||||
rates: Rate[],
|
rates: Rate[],
|
||||||
rateType: string,
|
rateType: string,
|
||||||
|
|||||||
@@ -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' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -7,11 +7,17 @@ import {
|
|||||||
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
||||||
|
|
||||||
import { assertCanApproveBookingStep } from '../../common/freight-permission.util';
|
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 { 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 { BookingContractService } from './booking-contract.service';
|
||||||
import { BookingPricingService } from './booking-pricing.service';
|
import { BookingPricingService } from './booking-pricing.service';
|
||||||
import { BookingsRepository } from './bookings.repository';
|
import { BookingsRepository } from './bookings.repository';
|
||||||
import { assertBookingStatus } from './booking-status.util';
|
import { assertBookingStatus } from './booking-status.util';
|
||||||
|
import { clearanceCodesForBooking } from './clearance.util';
|
||||||
import { computeNextStep, type BookingNextStep } from './booking-next-step.util';
|
import { computeNextStep, type BookingNextStep } from './booking-next-step.util';
|
||||||
import { SubmitBookingResponseDto } from './dto/submit-booking-response.dto';
|
import { SubmitBookingResponseDto } from './dto/submit-booking-response.dto';
|
||||||
import { PriceLineItemDto } from './dto/generate-price-response.dto';
|
import { PriceLineItemDto } from './dto/generate-price-response.dto';
|
||||||
@@ -25,6 +31,10 @@ export class BookingTransitionService {
|
|||||||
private readonly ruleEngineService: RuleEngineService,
|
private readonly ruleEngineService: RuleEngineService,
|
||||||
private readonly pricingService: BookingPricingService,
|
private readonly pricingService: BookingPricingService,
|
||||||
private readonly contractService: BookingContractService,
|
private readonly contractService: BookingContractService,
|
||||||
|
private readonly filesService: FilesService,
|
||||||
|
private readonly fileUploadSettingsService: FileUploadSettingsService,
|
||||||
|
@Inject(forwardRef(() => BookingBatchService))
|
||||||
|
private readonly bookingBatchService: BookingBatchService,
|
||||||
@Inject(forwardRef(() => BookingsService))
|
@Inject(forwardRef(() => BookingsService))
|
||||||
private readonly bookingsService: 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);
|
const booking = await this.bookingsService.findById(bookingId);
|
||||||
// Only SUBMITTED bookings are acceptable. A booking that still needs
|
// Only SUBMITTED bookings are acceptable. A booking that still needs
|
||||||
// consolidation sits in PENDING_CONSOLIDATION (resolved at submit time) and
|
// consolidation sits in PENDING_CONSOLIDATION (resolved at submit time) and
|
||||||
// is therefore never offered for accept until a partner moves it to SUBMITTED.
|
// is therefore never offered for accept until a partner moves it to SUBMITTED.
|
||||||
assertBookingStatus(booking, ['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, {
|
await this.ruleEngineService.instantiateApprovalSteps(bookingId, {
|
||||||
freightType: booking.freightType as 'CONTAINER' | 'BULK',
|
freightType: booking.freightType as 'CONTAINER' | 'BULK',
|
||||||
cargoTypeId: booking.cargoTypeId,
|
cargoTypeId: booking.cargoTypeId,
|
||||||
@@ -208,7 +236,10 @@ export class BookingTransitionService {
|
|||||||
const updated = await this.bookingsRepository.update(bookingId, {
|
const updated = await this.bookingsRepository.update(bookingId, {
|
||||||
status: 'PENDING_APPROVAL',
|
status: 'PENDING_APPROVAL',
|
||||||
approvedByStaffId: actorId,
|
approvedByStaffId: actorId,
|
||||||
approvedByStaffAt: new Date(),
|
approvedByStaffAt: validFrom,
|
||||||
|
contractValidityDays: validityDays,
|
||||||
|
contractValidFrom: validFrom,
|
||||||
|
contractValidUntil: validUntil,
|
||||||
} as never);
|
} as never);
|
||||||
return this.bookingsService.findById(updated!.id);
|
return this.bookingsService.findById(updated!.id);
|
||||||
}
|
}
|
||||||
@@ -420,6 +451,472 @@ export class BookingTransitionService {
|
|||||||
return this.bookingsService.findById(updated!.id);
|
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 & {
|
async enrichBookingResponse(booking: Booking): Promise<Booking & {
|
||||||
latestChangeRequestNote?: string | null;
|
latestChangeRequestNote?: string | null;
|
||||||
contractSummary?: string | null;
|
contractSummary?: string | null;
|
||||||
|
|||||||
@@ -42,10 +42,17 @@ import { FilterBookingDto } from './dto/filter-booking.dto';
|
|||||||
import { GeneratePriceResponseDto } from './dto/generate-price-response.dto';
|
import { GeneratePriceResponseDto } from './dto/generate-price-response.dto';
|
||||||
import { SubmitBookingResponseDto } from './dto/submit-booking-response.dto';
|
import { SubmitBookingResponseDto } from './dto/submit-booking-response.dto';
|
||||||
import {
|
import {
|
||||||
|
AcceptIntakeDto,
|
||||||
|
AdjustPriceDto,
|
||||||
ApproveStepDto,
|
ApproveStepDto,
|
||||||
CancelBookingDto,
|
CancelBookingDto,
|
||||||
|
RejectBookingDto,
|
||||||
RejectStepDto,
|
RejectStepDto,
|
||||||
RequestChangesDto,
|
RequestChangesDto,
|
||||||
|
ReviewDocumentDto,
|
||||||
|
RequestOperationDto,
|
||||||
|
OperationReviewDto,
|
||||||
|
ConfirmOperationPriceDto,
|
||||||
StaffRejectDto,
|
StaffRejectDto,
|
||||||
} from './dto/request-changes.dto';
|
} from './dto/request-changes.dto';
|
||||||
import { ContractViewDto } from './dto/contract-view.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
|
// Company-wide by default; the optional filter.companyProfileId (per-page
|
||||||
// resolves; otherwise fall back to company-level scoping.
|
// service filter) narrows within the company. The company guard always
|
||||||
const companyProfileId =
|
// applies, so a customer can only ever see their own company's bookings.
|
||||||
await this.bookingsService.resolveActiveCompanyProfileId(userId);
|
return this.bookingsService.findAll(filter, companyId);
|
||||||
return this.bookingsService.findAll(
|
|
||||||
filter,
|
|
||||||
companyId,
|
|
||||||
companyProfileId ?? undefined,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('by-company/:companyId/customer-view')
|
@Get('by-company/:companyId/customer-view')
|
||||||
@@ -318,6 +320,146 @@ export class BookingsController {
|
|||||||
return this.transitionService.confirmSubmit(id);
|
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')
|
@Post(':id/staff/request-changes')
|
||||||
@BookingStaff(FREIGHT_PERMS.bookings.requestChanges)
|
@BookingStaff(FREIGHT_PERMS.bookings.requestChanges)
|
||||||
@ApiOperation({ summary: 'Staff return booking for customer updates' })
|
@ApiOperation({ summary: 'Staff return booking for customer updates' })
|
||||||
@@ -336,14 +478,19 @@ export class BookingsController {
|
|||||||
|
|
||||||
@Post(':id/staff/accept')
|
@Post(':id/staff/accept')
|
||||||
@BookingStaff(FREIGHT_PERMS.bookings.staffAccept)
|
@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(
|
async acceptIntake(
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body() dto: AcceptIntakeDto,
|
||||||
@CurrentUser() user: AuthUserPayload,
|
@CurrentUser() user: AuthUserPayload,
|
||||||
) {
|
) {
|
||||||
const booking = await this.transitionService.acceptIntake(
|
const booking = await this.transitionService.acceptIntake(
|
||||||
id,
|
id,
|
||||||
resolveAuthUserId(user),
|
resolveAuthUserId(user),
|
||||||
|
dto.validityDays,
|
||||||
);
|
);
|
||||||
return this.transitionService.enrichBookingResponse(booking);
|
return this.transitionService.enrichBookingResponse(booking);
|
||||||
}
|
}
|
||||||
@@ -364,6 +511,25 @@ export class BookingsController {
|
|||||||
return this.transitionService.enrichBookingResponse(booking);
|
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')
|
@Post(':id/government-expedite')
|
||||||
@BookingStaff(FREIGHT_PERMS.bookings.staffAccept)
|
@BookingStaff(FREIGHT_PERMS.bookings.staffAccept)
|
||||||
@ApiOperation({ summary: 'Expedite government booking to PAID / ELIGIBLE for scheduling' })
|
@ApiOperation({ summary: 'Expedite government booking to PAID / ELIGIBLE for scheduling' })
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { CompaniesModule } from '../companies/companies.module';
|
|||||||
import { FilesModule } from '../files/files.module';
|
import { FilesModule } from '../files/files.module';
|
||||||
import { MinioModule } from '../minio/minio.module';
|
import { MinioModule } from '../minio/minio.module';
|
||||||
import { RuleEngineModule } from '../rule-engine/rule-engine.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 { SignaturesModule } from '../signatures/signatures.module';
|
||||||
import { BookingContractService } from './booking-contract.service';
|
import { BookingContractService } from './booking-contract.service';
|
||||||
import { BookingPaymentService } from './booking-payment.service';
|
import { BookingPaymentService } from './booking-payment.service';
|
||||||
@@ -21,6 +22,7 @@ import { ConsolidationService } from './consolidation.service';
|
|||||||
import { BookingsService } from './bookings.service';
|
import { BookingsService } from './bookings.service';
|
||||||
import { BookingApprovalStep } from './entities/booking-approval-step.entity';
|
import { BookingApprovalStep } from './entities/booking-approval-step.entity';
|
||||||
import { BookingCargoModifier } from './entities/booking-cargo-modifier.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 { BookingContainer } from './entities/booking-container.entity';
|
||||||
import { BookingRateSnapshot } from './entities/booking-rate-snapshot.entity';
|
import { BookingRateSnapshot } from './entities/booking-rate-snapshot.entity';
|
||||||
import { BookingContractSignature } from './entities/booking-contract-signature.entity';
|
import { BookingContractSignature } from './entities/booking-contract-signature.entity';
|
||||||
@@ -41,6 +43,7 @@ import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.modu
|
|||||||
BookingContainer,
|
BookingContainer,
|
||||||
BookingCargoModifier,
|
BookingCargoModifier,
|
||||||
BookingApprovalStep,
|
BookingApprovalStep,
|
||||||
|
BookingDocumentReview,
|
||||||
BookingRateSnapshot,
|
BookingRateSnapshot,
|
||||||
BookingReviewNote,
|
BookingReviewNote,
|
||||||
BookingContractSignature,
|
BookingContractSignature,
|
||||||
@@ -52,6 +55,7 @@ import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.modu
|
|||||||
CompaniesModule,
|
CompaniesModule,
|
||||||
// CustomersModule,
|
// CustomersModule,
|
||||||
RuleEngineModule,
|
RuleEngineModule,
|
||||||
|
FileUploadSettingsModule,
|
||||||
SignaturesModule,
|
SignaturesModule,
|
||||||
ExchangeModule.forRootAsync({
|
ExchangeModule.forRootAsync({
|
||||||
inject: [ConfigService],
|
inject: [ConfigService],
|
||||||
@@ -75,6 +79,6 @@ import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.modu
|
|||||||
ContractRendererService,
|
ContractRendererService,
|
||||||
ContractPdfService,
|
ContractPdfService,
|
||||||
],
|
],
|
||||||
exports: [BookingsService, BookingsRepository],
|
exports: [BookingsService, BookingsRepository, BookingPricingService],
|
||||||
})
|
})
|
||||||
export class BookingsModule {}
|
export class BookingsModule {}
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ import { DataSource, EntityManager, FindOptionsWhere, In, Repository, SelectQuer
|
|||||||
import { ContainerType } from '../rule-engine/entities/container-type.entity';
|
import { ContainerType } from '../rule-engine/entities/container-type.entity';
|
||||||
import { BookingApprovalStep } from './entities/booking-approval-step.entity';
|
import { BookingApprovalStep } from './entities/booking-approval-step.entity';
|
||||||
import { BookingCargoModifier } from './entities/booking-cargo-modifier.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 { BookingContainer } from './entities/booking-container.entity';
|
||||||
import { BookingRateSnapshot } from './entities/booking-rate-snapshot.entity';
|
import { BookingRateSnapshot } from './entities/booking-rate-snapshot.entity';
|
||||||
import { BookingReviewNote, ReviewNoteType } from './entities/booking-review-note.entity';
|
import { BookingReviewNote, ReviewNoteType } from './entities/booking-review-note.entity';
|
||||||
@@ -37,7 +41,6 @@ export interface BookingListFilterOptions {
|
|||||||
excludePaymentStatus?: string;
|
excludePaymentStatus?: string;
|
||||||
createdFrom?: string;
|
createdFrom?: string;
|
||||||
createdTo?: string;
|
createdTo?: string;
|
||||||
allowConsolidation?: boolean;
|
|
||||||
consolidationPaired?: string;
|
consolidationPaired?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,7 +182,6 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
|||||||
.innerJoinAndSelect('b.bookingContainers', 'bc')
|
.innerJoinAndSelect('b.bookingContainers', 'bc')
|
||||||
.innerJoin('bc.containerType', 'ct')
|
.innerJoin('bc.containerType', 'ct')
|
||||||
.where('b.id != :bookingId', { bookingId: booking.id })
|
.where('b.id != :bookingId', { bookingId: booking.id })
|
||||||
.andWhere('b.allowConsolidation = true')
|
|
||||||
.andWhere('b.consolidationPartnerId IS NULL')
|
.andWhere('b.consolidationPartnerId IS NULL')
|
||||||
// Only pair bookings the customer has committed (SUBMITTED) or that are
|
// Only pair bookings the customer has committed (SUBMITTED) or that are
|
||||||
// already waiting (PENDING_CONSOLIDATION). DRAFT bookings are excluded so
|
// already waiting (PENDING_CONSOLIDATION). DRAFT bookings are excluded so
|
||||||
@@ -315,11 +317,87 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
|||||||
return pending === 0;
|
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. */
|
/** Persist cargo modifiers linked to rate snapshots. */
|
||||||
async createCargoModifiers(
|
async createCargoModifiers(
|
||||||
rows: Array<{
|
rows: Array<{
|
||||||
bookingId: string;
|
bookingId: string;
|
||||||
surchargeTypeId: string;
|
rateId: string;
|
||||||
triggerValue: number | null;
|
triggerValue: number | null;
|
||||||
calculatedAmount: number;
|
calculatedAmount: number;
|
||||||
rateSnapshotId: string;
|
rateSnapshotId: string;
|
||||||
@@ -650,11 +728,6 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
|||||||
excludePaymentStatus: options.excludePaymentStatus,
|
excludePaymentStatus: options.excludePaymentStatus,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (options.allowConsolidation !== undefined) {
|
|
||||||
qb.andWhere('booking.allow_consolidation = :allowConsolidation', {
|
|
||||||
allowConsolidation: options.allowConsolidation,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (options.consolidationPaired === 'true') {
|
if (options.consolidationPaired === 'true') {
|
||||||
qb.andWhere('booking.consolidation_partner_id IS NOT NULL');
|
qb.andWhere('booking.consolidation_partner_id IS NOT NULL');
|
||||||
} else if (options.consolidationPaired === 'false') {
|
} else if (options.consolidationPaired === 'false') {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { Freight, SchedulingStatus } from '@edr/types';
|
|||||||
// import { CustomersService } from '../customers/customers.service';
|
// import { CustomersService } from '../customers/customers.service';
|
||||||
import { CompaniesService } from '../companies/companies.service';
|
import { CompaniesService } from '../companies/companies.service';
|
||||||
import { ProfileType } from '../companies/entities/company-profile.entity';
|
import { ProfileType } from '../companies/entities/company-profile.entity';
|
||||||
|
import { CompanyStatus } from '../companies/entities/company.entity';
|
||||||
import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service';
|
import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service';
|
||||||
import { eatDay } from '../train-scheduling/batch-window.util';
|
import { eatDay } from '../train-scheduling/batch-window.util';
|
||||||
import { FilesService } from '../files/files.service';
|
import { FilesService } from '../files/files.service';
|
||||||
@@ -25,6 +26,7 @@ import { DataSource, In } from 'typeorm';
|
|||||||
|
|
||||||
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
|
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
|
||||||
import { Yard } from '../rule-engine/entities/yard.entity';
|
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 { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
||||||
import { BookingsRepository } from './bookings.repository';
|
import { BookingsRepository } from './bookings.repository';
|
||||||
import { ConsolidationService } from './consolidation.service';
|
import { ConsolidationService } from './consolidation.service';
|
||||||
@@ -125,7 +127,6 @@ export class BookingsService {
|
|||||||
tradeDirection: string;
|
tradeDirection: string;
|
||||||
isHazardous?: boolean;
|
isHazardous?: boolean;
|
||||||
isGovernment?: boolean;
|
isGovernment?: boolean;
|
||||||
allowConsolidation?: boolean;
|
|
||||||
shippingLineId?: string | null;
|
shippingLineId?: string | null;
|
||||||
containers: CreateBookingContainerDto[];
|
containers: CreateBookingContainerDto[];
|
||||||
}): Promise<BookingEvaluationInput> {
|
}): Promise<BookingEvaluationInput> {
|
||||||
@@ -150,6 +151,14 @@ export class BookingsService {
|
|||||||
containers.reduce((sum, c) => sum + c.wagonsRequired, 0),
|
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 {
|
return {
|
||||||
freightType: dto.freightType,
|
freightType: dto.freightType,
|
||||||
cargoTypeId: dto.cargoTypeId ?? null,
|
cargoTypeId: dto.cargoTypeId ?? null,
|
||||||
@@ -158,8 +167,7 @@ export class BookingsService {
|
|||||||
tradeDirection: dto.tradeDirection,
|
tradeDirection: dto.tradeDirection,
|
||||||
isHazardous: dto.isHazardous ?? false,
|
isHazardous: dto.isHazardous ?? false,
|
||||||
isGovernment: dto.isGovernment ?? false,
|
isGovernment: dto.isGovernment ?? false,
|
||||||
allowConsolidation:
|
allowConsolidation,
|
||||||
dto.freightType === 'CONTAINER' ? dto.allowConsolidation : false,
|
|
||||||
shippingLineId: dto.shippingLineId,
|
shippingLineId: dto.shippingLineId,
|
||||||
totalWagons,
|
totalWagons,
|
||||||
containers,
|
containers,
|
||||||
@@ -167,26 +175,20 @@ export class BookingsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Enable consolidation when any container line leaves a wagon partially filled
|
* True when any container line leaves a wagon partially filled (e.g. 1×20ft on
|
||||||
* (e.g. 1×20ft on a 2-slot wagon, 1×10ft on a 4-slot wagon).
|
* 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
|
||||||
* Partial-wagon cargo ALWAYS consolidates — the customer cannot opt out of a
|
* container quantities alone — there is no customer-facing opt-in flag.
|
||||||
* 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).
|
|
||||||
*/
|
*/
|
||||||
private async resolveConsolidation(
|
private async needsConsolidation(
|
||||||
containers: CreateBookingContainerDto[],
|
containers: CreateBookingContainerDto[],
|
||||||
explicit?: boolean,
|
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const needs = await this.consolidationService.needsConsolidation(
|
return this.consolidationService.needsConsolidation(
|
||||||
containers.map((c) => ({
|
containers.map((c) => ({
|
||||||
containerTypeId: c.containerTypeId,
|
containerTypeId: c.containerTypeId,
|
||||||
quantity: c.quantity,
|
quantity: c.quantity,
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
if (needs) return true;
|
|
||||||
return explicit ?? false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Search for a complementary partner; pair or queue as PENDING_CONSOLIDATION. */
|
/** Search for a complementary partner; pair or queue as PENDING_CONSOLIDATION. */
|
||||||
@@ -196,10 +198,12 @@ export class BookingsService {
|
|||||||
}> {
|
}> {
|
||||||
const messages: string[] = [];
|
const messages: string[] = [];
|
||||||
|
|
||||||
if (!booking.allowConsolidation || booking.consolidationPartnerId) {
|
if (booking.consolidationPartnerId) {
|
||||||
return { booking, messages };
|
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);
|
const slots = await this.consolidationService.slotsFromBooking(booking);
|
||||||
if (slots.length === 0) {
|
if (slots.length === 0) {
|
||||||
return { booking, messages };
|
return { booking, messages };
|
||||||
@@ -287,6 +291,12 @@ export class BookingsService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
const { company } = await this.companiesService.getCompanyInfoByUserId(userId);
|
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;
|
companyId = company.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -363,9 +373,9 @@ export class BookingsService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const allowConsolidation =
|
const needsConsolidation =
|
||||||
dto.freightType === 'CONTAINER'
|
dto.freightType === 'CONTAINER'
|
||||||
? await this.resolveConsolidation(containers, dto.allowConsolidation)
|
? await this.needsConsolidation(containers)
|
||||||
: false;
|
: false;
|
||||||
|
|
||||||
const evalInput = await this.buildEvalInput({
|
const evalInput = await this.buildEvalInput({
|
||||||
@@ -376,7 +386,6 @@ export class BookingsService {
|
|||||||
tradeDirection,
|
tradeDirection,
|
||||||
isHazardous: dto.isHazardous,
|
isHazardous: dto.isHazardous,
|
||||||
isGovernment,
|
isGovernment,
|
||||||
allowConsolidation,
|
|
||||||
shippingLineId: dto.shippingLineId,
|
shippingLineId: dto.shippingLineId,
|
||||||
containers,
|
containers,
|
||||||
});
|
});
|
||||||
@@ -397,7 +406,13 @@ export class BookingsService {
|
|||||||
previousContractId: dto.previousContractId,
|
previousContractId: dto.previousContractId,
|
||||||
serviceTypeId: dto.serviceTypeId,
|
serviceTypeId: dto.serviceTypeId,
|
||||||
firstMilePickupAddress: dto.firstMilePickupAddress,
|
firstMilePickupAddress: dto.firstMilePickupAddress,
|
||||||
|
firstMilePickupLat: dto.firstMilePickupLat ?? null,
|
||||||
|
firstMilePickupLng: dto.firstMilePickupLng ?? null,
|
||||||
lastMileDeliveryAddress: dto.lastMileDeliveryAddress,
|
lastMileDeliveryAddress: dto.lastMileDeliveryAddress,
|
||||||
|
lastMileDeliveryLat: dto.lastMileDeliveryLat ?? null,
|
||||||
|
lastMileDeliveryLng: dto.lastMileDeliveryLng ?? null,
|
||||||
|
customsClearingEnabled: dto.customsClearingEnabled ?? false,
|
||||||
|
customsClearingAgent: dto.customsClearingAgent ?? null,
|
||||||
equipmentReturn: dto.equipmentReturn,
|
equipmentReturn: dto.equipmentReturn,
|
||||||
originYardId: dto.originYardId,
|
originYardId: dto.originYardId,
|
||||||
destinationYardId: dto.destinationYardId,
|
destinationYardId: dto.destinationYardId,
|
||||||
@@ -416,7 +431,6 @@ export class BookingsService {
|
|||||||
startDate: dto.startDate ? new Date(dto.startDate) : undefined,
|
startDate: dto.startDate ? new Date(dto.startDate) : undefined,
|
||||||
endDate: dto.endDate ? new Date(dto.endDate) : undefined,
|
endDate: dto.endDate ? new Date(dto.endDate) : undefined,
|
||||||
status: 'DRAFT',
|
status: 'DRAFT',
|
||||||
allowConsolidation,
|
|
||||||
priorityScore: ruleResult.priorityScore,
|
priorityScore: ruleResult.priorityScore,
|
||||||
totalAmount: 0,
|
totalAmount: 0,
|
||||||
paymentStatus: 'PENDING',
|
paymentStatus: 'PENDING',
|
||||||
@@ -436,6 +450,25 @@ export class BookingsService {
|
|||||||
warnings.push(`Estimated wagons required: ${wagonCount}`);
|
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) {
|
if (files.length > 0) {
|
||||||
try {
|
try {
|
||||||
await this.filesService.uploadMany(booking.id, 'bookings', files);
|
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);
|
let full = await this.findById(booking.id);
|
||||||
|
|
||||||
if (allowConsolidation) {
|
if (needsConsolidation) {
|
||||||
const consolidation = await this.tryAutoConsolidate(full);
|
const consolidation = await this.tryAutoConsolidate(full);
|
||||||
full = consolidation.booking;
|
full = consolidation.booking;
|
||||||
warnings.push(...consolidation.messages);
|
warnings.push(...consolidation.messages);
|
||||||
@@ -505,12 +565,9 @@ export class BookingsService {
|
|||||||
dto.tradeDirection,
|
dto.tradeDirection,
|
||||||
);
|
);
|
||||||
|
|
||||||
const allowConsolidation =
|
const needsConsolidation =
|
||||||
freightType === 'CONTAINER'
|
freightType === 'CONTAINER'
|
||||||
? await this.resolveConsolidation(
|
? await this.needsConsolidation(containers)
|
||||||
containers,
|
|
||||||
dto.allowConsolidation ?? existing.allowConsolidation,
|
|
||||||
)
|
|
||||||
: false;
|
: false;
|
||||||
|
|
||||||
const evalInput = await this.buildEvalInput({
|
const evalInput = await this.buildEvalInput({
|
||||||
@@ -520,7 +577,6 @@ export class BookingsService {
|
|||||||
paymentCurrency: dto.paymentCurrency ?? existing.paymentCurrency,
|
paymentCurrency: dto.paymentCurrency ?? existing.paymentCurrency,
|
||||||
tradeDirection,
|
tradeDirection,
|
||||||
isHazardous: dto.isHazardous ?? existing.isHazardous,
|
isHazardous: dto.isHazardous ?? existing.isHazardous,
|
||||||
allowConsolidation,
|
|
||||||
shippingLineId: dto.shippingLineId ?? existing.shippingLineId ?? undefined,
|
shippingLineId: dto.shippingLineId ?? existing.shippingLineId ?? undefined,
|
||||||
containers,
|
containers,
|
||||||
});
|
});
|
||||||
@@ -529,12 +585,11 @@ export class BookingsService {
|
|||||||
this.ruleEngineService.assertNoHardBlocks(ruleResult);
|
this.ruleEngineService.assertNoHardBlocks(ruleResult);
|
||||||
warnings.push(...ruleResult.warnings);
|
warnings.push(...ruleResult.warnings);
|
||||||
|
|
||||||
const pricingFieldsChanged = this.pricingRelevantFieldsChanged(
|
const pricingFieldsChanged = await this.pricingRelevantFieldsChanged(
|
||||||
existing,
|
existing,
|
||||||
dto,
|
dto,
|
||||||
freightType,
|
freightType,
|
||||||
cargoTypeId,
|
cargoTypeId,
|
||||||
allowConsolidation,
|
|
||||||
containers,
|
containers,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -542,7 +597,6 @@ export class BookingsService {
|
|||||||
...dto,
|
...dto,
|
||||||
freightType,
|
freightType,
|
||||||
cargoTypeId: freightType === 'BULK' ? cargoTypeId : null,
|
cargoTypeId: freightType === 'BULK' ? cargoTypeId : null,
|
||||||
allowConsolidation,
|
|
||||||
priorityScore: ruleResult.priorityScore,
|
priorityScore: ruleResult.priorityScore,
|
||||||
tradeDirection,
|
tradeDirection,
|
||||||
};
|
};
|
||||||
@@ -592,7 +646,7 @@ export class BookingsService {
|
|||||||
|
|
||||||
let booking = await this.findById(id);
|
let booking = await this.findById(id);
|
||||||
|
|
||||||
if (allowConsolidation && !booking.consolidationPartnerId) {
|
if (needsConsolidation && !booking.consolidationPartnerId) {
|
||||||
const consolidation = await this.tryAutoConsolidate(booking);
|
const consolidation = await this.tryAutoConsolidate(booking);
|
||||||
booking = consolidation.booking;
|
booking = consolidation.booking;
|
||||||
warnings.push(...consolidation.messages);
|
warnings.push(...consolidation.messages);
|
||||||
@@ -656,10 +710,11 @@ export class BookingsService {
|
|||||||
assignedToSchedule: filter.assignedToSchedule,
|
assignedToSchedule: filter.assignedToSchedule,
|
||||||
// A forced company scope (portal/customer) overrides any caller-provided
|
// A forced company scope (portal/customer) overrides any caller-provided
|
||||||
// companyId so a customer can only ever see their own company's bookings.
|
// 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
|
// The company guard always applies; the optional companyProfileId filter
|
||||||
// company so nothing breaks for not-yet-onboarded customers.
|
// (from the per-page service filter) narrows WITHIN the company — the repo
|
||||||
companyId: forceCompanyProfileId ? undefined : forceCompanyId ?? filter.companyId,
|
// ANDs both, so cross-company access is impossible.
|
||||||
companyProfileId: forceCompanyProfileId,
|
companyId: forceCompanyId ?? filter.companyId,
|
||||||
|
companyProfileId: forceCompanyProfileId ?? filter.companyProfileId,
|
||||||
contractType: filter.contractType,
|
contractType: filter.contractType,
|
||||||
serviceTypeId: filter.serviceTypeId,
|
serviceTypeId: filter.serviceTypeId,
|
||||||
cargoTypeId: filter.cargoTypeId,
|
cargoTypeId: filter.cargoTypeId,
|
||||||
@@ -670,7 +725,6 @@ export class BookingsService {
|
|||||||
paymentStatus: filter.paymentStatus,
|
paymentStatus: filter.paymentStatus,
|
||||||
createdFrom: filter.createdFrom,
|
createdFrom: filter.createdFrom,
|
||||||
createdTo: filter.createdTo,
|
createdTo: filter.createdTo,
|
||||||
allowConsolidation: filter.allowConsolidation,
|
|
||||||
consolidationPaired: filter.consolidationPaired,
|
consolidationPaired: filter.consolidationPaired,
|
||||||
sortBy: filter.sortBy,
|
sortBy: filter.sortBy,
|
||||||
sortOrder: filter.sortOrder,
|
sortOrder: filter.sortOrder,
|
||||||
@@ -694,18 +748,15 @@ export class BookingsService {
|
|||||||
filter: FilterBookingDto,
|
filter: FilterBookingDto,
|
||||||
): Promise<PaginatedBookings> {
|
): Promise<PaginatedBookings> {
|
||||||
const { company } = await this.companiesService.getCompanyInfoByUserId(userId);
|
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({
|
return this.bookingsRepository.findAllPaginated({
|
||||||
page: filter.page ?? 1,
|
page: filter.page ?? 1,
|
||||||
pageSize: filter.pageSize ?? 20,
|
pageSize: filter.pageSize ?? 20,
|
||||||
statuses: BookingsService.PAYABLE_STATUSES,
|
statuses: BookingsService.PAYABLE_STATUSES,
|
||||||
excludePaymentStatus: 'PAID',
|
excludePaymentStatus: 'PAID',
|
||||||
companyId: companyProfileId ? undefined : company.id,
|
// Company-wide: payables span all of the customer's services.
|
||||||
companyProfileId: companyProfileId ?? undefined,
|
companyId: company.id,
|
||||||
|
companyProfileId: filter.companyProfileId,
|
||||||
sortBy: filter.sortBy,
|
sortBy: filter.sortBy,
|
||||||
sortOrder: filter.sortOrder,
|
sortOrder: filter.sortOrder,
|
||||||
});
|
});
|
||||||
@@ -841,7 +892,6 @@ export class BookingsService {
|
|||||||
paymentStatus: filter.paymentStatus,
|
paymentStatus: filter.paymentStatus,
|
||||||
createdFrom: filter.createdFrom,
|
createdFrom: filter.createdFrom,
|
||||||
createdTo: filter.createdTo,
|
createdTo: filter.createdTo,
|
||||||
allowConsolidation: filter.allowConsolidation,
|
|
||||||
consolidationPaired: filter.consolidationPaired,
|
consolidationPaired: filter.consolidationPaired,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -950,10 +1000,6 @@ export class BookingsService {
|
|||||||
}> {
|
}> {
|
||||||
const booking = await this.findById(id);
|
const booking = await this.findById(id);
|
||||||
|
|
||||||
if (!booking.allowConsolidation) {
|
|
||||||
throw new BadRequestException('Booking is not eligible for consolidation');
|
|
||||||
}
|
|
||||||
|
|
||||||
const needs = await this.consolidationService.needsConsolidationFromBooking(
|
const needs = await this.consolidationService.needsConsolidationFromBooking(
|
||||||
booking,
|
booking,
|
||||||
);
|
);
|
||||||
@@ -1037,14 +1083,13 @@ export class BookingsService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private pricingRelevantFieldsChanged(
|
private async pricingRelevantFieldsChanged(
|
||||||
existing: Booking,
|
existing: Booking,
|
||||||
dto: UpdateBookingDto,
|
dto: UpdateBookingDto,
|
||||||
freightType: FreightType,
|
freightType: FreightType,
|
||||||
cargoTypeId: string | null | undefined,
|
cargoTypeId: string | null | undefined,
|
||||||
allowConsolidation: boolean,
|
|
||||||
containers: CreateBookingContainerDto[],
|
containers: CreateBookingContainerDto[],
|
||||||
): boolean {
|
): Promise<boolean> {
|
||||||
if (dto.freightType !== undefined && dto.freightType !== existing.freightType) {
|
if (dto.freightType !== undefined && dto.freightType !== existing.freightType) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -1057,18 +1102,14 @@ export class BookingsService {
|
|||||||
if (dto.isHazardous !== undefined && dto.isHazardous !== existing.isHazardous) {
|
if (dto.isHazardous !== undefined && dto.isHazardous !== existing.isHazardous) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (
|
|
||||||
dto.allowConsolidation !== undefined &&
|
|
||||||
dto.allowConsolidation !== existing.allowConsolidation
|
|
||||||
) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (dto.shippingLineId !== undefined && dto.shippingLineId !== existing.shippingLineId) {
|
if (dto.shippingLineId !== undefined && dto.shippingLineId !== existing.shippingLineId) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (dto.cargoTypeId !== undefined && dto.cargoTypeId !== existing.cargoTypeId) {
|
if (dto.cargoTypeId !== undefined && dto.cargoTypeId !== existing.cargoTypeId) {
|
||||||
return true;
|
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) {
|
if (dto.containers !== undefined) {
|
||||||
const existingContainers = (existing.bookingContainers ?? [])
|
const existingContainers = (existing.bookingContainers ?? [])
|
||||||
.filter((bc): bc is typeof bc & { containerTypeId: string } => bc.containerTypeId != null)
|
.filter((bc): bc is typeof bc & { containerTypeId: string } => bc.containerTypeId != null)
|
||||||
@@ -1083,8 +1124,7 @@ export class BookingsService {
|
|||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
freightType !== existing.freightType ||
|
freightType !== existing.freightType ||
|
||||||
(cargoTypeId ?? null) !== (existing.cargoTypeId ?? null) ||
|
(cargoTypeId ?? null) !== (existing.cargoTypeId ?? null)
|
||||||
allowConsolidation !== existing.allowConsolidation
|
|
||||||
) {
|
) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
70
apps/edr-freight-api/src/modules/bookings/clearance.util.ts
Normal file
70
apps/edr-freight-api/src/modules/bookings/clearance.util.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -57,16 +57,29 @@ export class ConsolidationService {
|
|||||||
async slotsFromContainerLines(
|
async slotsFromContainerLines(
|
||||||
lines: Array<{ containerTypeId: string; quantity: number }>,
|
lines: Array<{ containerTypeId: string; quantity: number }>,
|
||||||
): Promise<ConsolidationSlot[]> {
|
): 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) {
|
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 perWagon = containersPerWagon(Number(ct.wagonsPerUnit));
|
||||||
const remainder = wagonRemainder(line.quantity, perWagon);
|
const remainder = wagonRemainder(quantity, perWagon);
|
||||||
if (remainder === 0) continue;
|
if (remainder === 0) continue;
|
||||||
slots.push({
|
slots.push({
|
||||||
containerTypeId: line.containerTypeId,
|
containerTypeId,
|
||||||
containerTypeCode: ct.code,
|
containerTypeCode: ct.code,
|
||||||
quantity: line.quantity,
|
quantity,
|
||||||
containersPerWagon: perWagon,
|
containersPerWagon: perWagon,
|
||||||
remainder,
|
remainder,
|
||||||
slotsNeeded: perWagon - remainder,
|
slotsNeeded: perWagon - remainder,
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -11,6 +11,8 @@ import {
|
|||||||
IsOptional,
|
IsOptional,
|
||||||
IsString,
|
IsString,
|
||||||
IsUUID,
|
IsUUID,
|
||||||
|
Max,
|
||||||
|
MaxLength,
|
||||||
Min,
|
Min,
|
||||||
MinLength,
|
MinLength,
|
||||||
Validate,
|
Validate,
|
||||||
@@ -53,6 +55,42 @@ export class CreateBookingContainerDto {
|
|||||||
vgmPerUnitTons!: number;
|
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 {
|
export class CreateBookingDto {
|
||||||
/** Class-level freight shape check (not a request field). */
|
/** Class-level freight shape check (not a request field). */
|
||||||
@Validate(BookingFreightShapeConstraint)
|
@Validate(BookingFreightShapeConstraint)
|
||||||
@@ -144,11 +182,55 @@ export class CreateBookingDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
firstMilePickupAddress?: string;
|
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()
|
@ApiPropertyOptional()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
lastMileDeliveryAddress?: string;
|
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 })
|
@ApiProperty({ enum: EQUIPMENT_RETURNS })
|
||||||
@IsIn([...EQUIPMENT_RETURNS])
|
@IsIn([...EQUIPMENT_RETURNS])
|
||||||
equipmentReturn!: string;
|
equipmentReturn!: string;
|
||||||
@@ -161,6 +243,21 @@ export class CreateBookingDto {
|
|||||||
@IsUUID()
|
@IsUUID()
|
||||||
destinationYardId!: string;
|
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 })
|
@ApiProperty({ enum: TRADE_DIRECTIONS })
|
||||||
@IsIn([...TRADE_DIRECTIONS])
|
@IsIn([...TRADE_DIRECTIONS])
|
||||||
tradeDirection!: string;
|
tradeDirection!: string;
|
||||||
@@ -233,10 +330,4 @@ export class CreateBookingDto {
|
|||||||
@ValidateNested({ each: true })
|
@ValidateNested({ each: true })
|
||||||
@Type(() => CreateBookingContainerDto)
|
@Type(() => CreateBookingContainerDto)
|
||||||
containers?: CreateBookingContainerDto[];
|
containers?: CreateBookingContainerDto[];
|
||||||
|
|
||||||
@ApiPropertyOptional({ default: false })
|
|
||||||
@IsOptional()
|
|
||||||
@IsBoolean()
|
|
||||||
@Transform(({ value }) => value === 'true' || value === true)
|
|
||||||
allowConsolidation?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,15 @@ export class FilterBookingDto {
|
|||||||
@IsUUID()
|
@IsUUID()
|
||||||
companyId?: string;
|
companyId?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
format: 'uuid',
|
||||||
|
description:
|
||||||
|
'Narrow to a single operational profile (importer/exporter/freight_forwarder) within the company.',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
companyProfileId?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional()
|
@ApiPropertyOptional()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
contractType?: string;
|
contractType?: string;
|
||||||
@@ -87,11 +96,6 @@ export class FilterBookingDto {
|
|||||||
@IsIn([...PAYMENT_STATUSES])
|
@IsIn([...PAYMENT_STATUSES])
|
||||||
paymentStatus?: string;
|
paymentStatus?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional()
|
|
||||||
@IsOptional()
|
|
||||||
@Transform(({ value }) => value === 'true' || value === true)
|
|
||||||
allowConsolidation?: boolean;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: 'true | false — filter paired consolidation' })
|
@ApiPropertyOptional({ description: 'true | false — filter paired consolidation' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
consolidationPaired?: string;
|
consolidationPaired?: string;
|
||||||
|
|||||||
@@ -7,9 +7,22 @@ export class PriceLineItemDto {
|
|||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
description!: string;
|
description!: string;
|
||||||
|
|
||||||
|
/** Computed line total (unitAmount × quantity). Retained for totals elsewhere. */
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
amount!: number;
|
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()
|
@ApiProperty()
|
||||||
currency!: string;
|
currency!: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,16 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import { IsString, MinLength } from 'class-validator';
|
import {
|
||||||
|
IsBoolean,
|
||||||
|
IsDateString,
|
||||||
|
IsIn,
|
||||||
|
IsInt,
|
||||||
|
IsNumber,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
Max,
|
||||||
|
Min,
|
||||||
|
MinLength,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
export class RequestChangesDto {
|
export class RequestChangesDto {
|
||||||
@ApiProperty({ description: 'Staff note explaining what the customer must fix' })
|
@ApiProperty({ description: 'Staff note explaining what the customer must fix' })
|
||||||
@@ -8,6 +19,21 @@ export class RequestChangesDto {
|
|||||||
note!: string;
|
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 {
|
export class StaffRejectDto {
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
@IsString()
|
@IsString()
|
||||||
@@ -34,3 +60,92 @@ export class CancelBookingDto {
|
|||||||
@MinLength(1)
|
@MinLength(1)
|
||||||
reason!: string;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { BaseEntity } from '@edr/api-common';
|
import { BaseEntity } from '@edr/api-common';
|
||||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
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 { Booking } from './booking.entity';
|
||||||
import { BookingRateSnapshot } from './booking-rate-snapshot.entity';
|
import { BookingRateSnapshot } from './booking-rate-snapshot.entity';
|
||||||
|
|
||||||
@Entity({ schema: 'freight', name: 'booking_cargo_modifier' })
|
@Entity({ schema: 'freight', name: 'booking_cargo_modifier' })
|
||||||
@Index(['bookingId'])
|
@Index(['bookingId'])
|
||||||
@Index(['surchargeTypeId'])
|
@Index(['rateId'])
|
||||||
export class BookingCargoModifier extends BaseEntity {
|
export class BookingCargoModifier extends BaseEntity {
|
||||||
@Column({ name: 'booking_id', type: 'uuid' })
|
@Column({ name: 'booking_id', type: 'uuid' })
|
||||||
bookingId!: string;
|
bookingId!: string;
|
||||||
@@ -15,12 +15,17 @@ export class BookingCargoModifier extends BaseEntity {
|
|||||||
@JoinColumn({ name: 'booking_id' })
|
@JoinColumn({ name: 'booking_id' })
|
||||||
booking?: Booking;
|
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)
|
@ManyToOne(() => Rate)
|
||||||
@JoinColumn({ name: 'surcharge_type_id' })
|
@JoinColumn({ name: 'rate_id' })
|
||||||
surchargeType?: SurchargeType;
|
rate?: Rate;
|
||||||
|
|
||||||
@Column({ name: 'trigger_value', type: 'numeric', precision: 14, scale: 4, nullable: true })
|
@Column({ name: 'trigger_value', type: 'numeric', precision: 14, scale: 4, nullable: true })
|
||||||
triggerValue?: number | null;
|
triggerValue?: number | null;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -43,6 +43,20 @@ export const BOOKING_STATUSES = [
|
|||||||
'CONSOLIDATED',
|
'CONSOLIDATED',
|
||||||
'CONTRACT_ACTIVE',
|
'CONTRACT_ACTIVE',
|
||||||
'CONTRACT_CLOSED',
|
'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;
|
] as const;
|
||||||
|
|
||||||
export type BookingStatus = (typeof BOOKING_STATUSES)[number];
|
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 })
|
@Column({ name: 'total_amount', type: 'numeric', precision: 14, scale: 2, default: 0 })
|
||||||
totalAmount!: number;
|
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' })
|
@Column({ name: 'payment_status', type: 'varchar', length: 20, default: 'PENDING' })
|
||||||
paymentStatus!: string;
|
paymentStatus!: string;
|
||||||
|
|
||||||
@@ -179,9 +224,27 @@ export class Booking extends BaseEntity {
|
|||||||
@Column({ name: 'first_mile_pickup_address', type: 'text', nullable: true })
|
@Column({ name: 'first_mile_pickup_address', type: 'text', nullable: true })
|
||||||
firstMilePickupAddress?: string | null;
|
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 })
|
@Column({ name: 'last_mile_delivery_address', type: 'text', nullable: true })
|
||||||
lastMileDeliveryAddress?: string | null;
|
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 })
|
@Column({ name: 'equipment_return', type: 'varchar', length: 20 })
|
||||||
equipmentReturn!: string;
|
equipmentReturn!: string;
|
||||||
|
|
||||||
@@ -228,6 +291,15 @@ export class Booking extends BaseEntity {
|
|||||||
@Column({ name: 'is_hazardous', type: 'boolean', default: false })
|
@Column({ name: 'is_hazardous', type: 'boolean', default: false })
|
||||||
isHazardous!: boolean;
|
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 })
|
@Column({ name: 'payment_currency', type: 'varchar', length: 5 })
|
||||||
paymentCurrency!: string;
|
paymentCurrency!: string;
|
||||||
|
|
||||||
@@ -294,9 +366,6 @@ export class Booking extends BaseEntity {
|
|||||||
@Column({ name: 'priority_score', type: 'int', default: 0 })
|
@Column({ name: 'priority_score', type: 'int', default: 0 })
|
||||||
priorityScore!: number;
|
priorityScore!: number;
|
||||||
|
|
||||||
@Column({ name: 'allow_consolidation', type: 'boolean', default: false })
|
|
||||||
allowConsolidation!: boolean;
|
|
||||||
|
|
||||||
@Column({ name: 'consolidation_partner_id', type: 'uuid', nullable: true })
|
@Column({ name: 'consolidation_partner_id', type: 'uuid', nullable: true })
|
||||||
consolidationPartnerId?: string | null;
|
consolidationPartnerId?: string | null;
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import { CreateCompanyProfileDto } from "./dto/create-company-profile.dto";
|
|||||||
import { SetActiveModeDto } from "./dto/set-active-mode.dto";
|
import { SetActiveModeDto } from "./dto/set-active-mode.dto";
|
||||||
import { SetOnboardingStepDto } from "./dto/set-onboarding-step.dto";
|
import { SetOnboardingStepDto } from "./dto/set-onboarding-step.dto";
|
||||||
import { StartOnboardingDto } from "./dto/start-onboarding.dto";
|
import { StartOnboardingDto } from "./dto/start-onboarding.dto";
|
||||||
|
import { DashboardQueryDto } from "./dto/dashboard-query.dto";
|
||||||
import {
|
import {
|
||||||
ResponseCompanyDto,
|
ResponseCompanyDto,
|
||||||
ResponseCompanyProfileDto,
|
ResponseCompanyProfileDto,
|
||||||
@@ -86,8 +87,12 @@ export class CompaniesController {
|
|||||||
})
|
})
|
||||||
async getDashboard(
|
async getDashboard(
|
||||||
@CurrentUser() user: CurrentIamUser,
|
@CurrentUser() user: CurrentIamUser,
|
||||||
|
@Query() query: DashboardQueryDto,
|
||||||
): Promise<DashboardSummaryResponseDto> {
|
): Promise<DashboardSummaryResponseDto> {
|
||||||
return this.companiesService.getDashboardSummary(user.id);
|
return this.companiesService.getDashboardSummary(
|
||||||
|
user.id,
|
||||||
|
query.companyProfileId,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post("fetch-etrade-info")
|
@Post("fetch-etrade-info")
|
||||||
|
|||||||
@@ -7,7 +7,10 @@ import {
|
|||||||
import { CompaniesRepository } from "./companies.repository";
|
import { CompaniesRepository } from "./companies.repository";
|
||||||
import { CompanyProfileRepository } from "./company-profile.repository";
|
import { CompanyProfileRepository } from "./company-profile.repository";
|
||||||
import { ExternalProfileRepository } from "./external-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 { MinioService } from "../minio/minio.service";
|
||||||
import { ETradeService } from "./services/etrade.service";
|
import { ETradeService } from "./services/etrade.service";
|
||||||
import { normalizeE164 } from "../../common/validators/is-phone-number.validator";
|
import { normalizeE164 } from "../../common/validators/is-phone-number.validator";
|
||||||
@@ -321,6 +324,7 @@ export class CompaniesService {
|
|||||||
*/
|
*/
|
||||||
async getDashboardSummary(
|
async getDashboardSummary(
|
||||||
userId: string,
|
userId: string,
|
||||||
|
companyProfileId?: string,
|
||||||
): Promise<DashboardSummaryResponseDto> {
|
): Promise<DashboardSummaryResponseDto> {
|
||||||
// A user without a company profile has no bookings — return an empty summary
|
// A user without a company profile has no bookings — return an empty summary
|
||||||
// rather than 404, so the portal home still renders.
|
// rather than 404, so the portal home still renders.
|
||||||
@@ -328,17 +332,17 @@ export class CompaniesService {
|
|||||||
const companyId = profile?.company?.id ?? profile?.companyId ?? null;
|
const companyId = profile?.company?.id ?? profile?.companyId ?? null;
|
||||||
if (!companyId) return this.emptyDashboardSummary();
|
if (!companyId) return this.emptyDashboardSummary();
|
||||||
|
|
||||||
// Scope KPIs to the active operational profile (importer/exporter mode) when
|
// Company-wide by default (all services' data). An optional companyProfileId
|
||||||
// one resolves; otherwise aggregate across the whole company.
|
// (from the per-page service filter) narrows to one operational profile —
|
||||||
const companyProfileId = profile?.activeProfileType
|
// but only after we confirm it belongs to this user's company, since the
|
||||||
? ((await this.companyProfilesRepo.findByType(
|
// dashboard scope has no company guard at the repository layer.
|
||||||
companyId,
|
let scope: DashboardScope = { companyId };
|
||||||
profile.activeProfileType,
|
if (companyProfileId) {
|
||||||
)) ?? null)
|
const owned = await this.companyProfilesRepo.findByCompanyId(companyId);
|
||||||
: null;
|
if (owned.some((p) => p.id === companyProfileId)) {
|
||||||
const scope = companyProfileId
|
scope = { companyProfileId };
|
||||||
? { companyProfileId: companyProfileId.id }
|
}
|
||||||
: { companyId };
|
}
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const yearStart = new Date(now.getFullYear(), 0, 1);
|
const yearStart = new Date(now.getFullYear(), 0, 1);
|
||||||
@@ -841,8 +845,9 @@ export class CompaniesService {
|
|||||||
onboardingCompleted: true,
|
onboardingCompleted: true,
|
||||||
onboardingStep: "done",
|
onboardingStep: "done",
|
||||||
});
|
});
|
||||||
|
// Awaiting backoffice approval — stays Pending until an admin activates it.
|
||||||
await this.companiesRepo.update(companyId, {
|
await this.companiesRepo.update(companyId, {
|
||||||
status: CompanyStatus.Active,
|
status: CompanyStatus.Pending,
|
||||||
});
|
});
|
||||||
return this.getCompanyInfoByUserId(userId);
|
return this.getCompanyInfoByUserId(userId);
|
||||||
}
|
}
|
||||||
@@ -910,6 +915,18 @@ export class CompaniesService {
|
|||||||
return profile.businessLicenseFiles ?? [];
|
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
|
* Resolve which company_profile a new booking belongs to, from the company
|
||||||
* and the booking's trade direction. IMPORT → importer profile, EXPORT →
|
* and the booking's trade direction. IMPORT → importer profile, EXPORT →
|
||||||
|
|||||||
@@ -18,7 +18,9 @@ export class CreateCompanyDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
@Length(10, 10)
|
@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;
|
tin!: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -35,7 +35,9 @@ export class UpdateProfileDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@Length(10, 10)
|
@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;
|
tin?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
|||||||
@@ -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> {
|
async findById(id: string): Promise<FileRecord> {
|
||||||
const record = await this.filesRepository.findById(id);
|
const record = await this.filesRepository.findById(id);
|
||||||
if (!record) throw new NotFoundException(`File ${id} not found`);
|
if (!record) throw new NotFoundException(`File ${id} not found`);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export const OVERVIEW_URGENT_PRIORITY_THRESHOLD = 1000;
|
export const OVERVIEW_URGENT_PRIORITY_THRESHOLD = 70;
|
||||||
|
|
||||||
export const OVERVIEW_NEEDS_ACTION_STATUSES = [
|
export const OVERVIEW_NEEDS_ACTION_STATUSES = [
|
||||||
'SUBMITTED',
|
'SUBMITTED',
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
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 {
|
export class CreatePriorityConfigDto {
|
||||||
@ApiProperty({ description: 'Config type: WAGON or CURRENCY', enum: ['WAGON', 'CURRENCY'] })
|
@ApiProperty({ description: 'Config type: WAGON or CURRENCY', enum: ['WAGON', 'CURRENCY'] })
|
||||||
@@ -30,9 +30,15 @@ export class CreatePriorityConfigDto {
|
|||||||
@Min(0)
|
@Min(0)
|
||||||
maxWagonCount!: number;
|
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()
|
@IsInt()
|
||||||
@Min(0)
|
@Min(0)
|
||||||
|
@Max(50)
|
||||||
scorePoints!: number;
|
scorePoints!: number;
|
||||||
|
|
||||||
@ApiPropertyOptional({ default: false, description: 'Feature flag — toggle without code deploy' })
|
@ApiPropertyOptional({ default: false, description: 'Feature flag — toggle without code deploy' })
|
||||||
|
|||||||
@@ -1,29 +1,46 @@
|
|||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import { Transform } from 'class-transformer';
|
import { Transform } from 'class-transformer';
|
||||||
import { IsDateString, IsIn, IsNumber, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator';
|
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 TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'BOTH'] as const;
|
||||||
const CURRENCIES = ['USD'] as const;
|
const CURRENCIES = ['USD'] as const;
|
||||||
|
|
||||||
export class CreateRateDto {
|
export class CreateRateDto {
|
||||||
@ApiProperty({ enum: RATE_TYPES, description: 'Rate type identifier' })
|
@ApiProperty({ enum: RATE_APPLIES_TO, description: 'Friendly category the rate applies to' })
|
||||||
@IsIn([...RATE_TYPES])
|
@IsIn([...RATE_APPLIES_TO])
|
||||||
rateType!: string;
|
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()
|
@IsOptional()
|
||||||
@IsUUID()
|
@IsUUID()
|
||||||
containerTypeId?: string;
|
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' })
|
@ApiPropertyOptional({ enum: TRADE_DIRECTIONS, description: 'Trade direction. Null = direction-agnostic' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsIn([...TRADE_DIRECTIONS])
|
@IsIn([...TRADE_DIRECTIONS])
|
||||||
tradeDirection?: string;
|
tradeDirection?: string;
|
||||||
|
|
||||||
@ApiProperty({ enum: CURRENCIES })
|
@ApiPropertyOptional({ enum: CURRENCIES })
|
||||||
|
@IsOptional()
|
||||||
@IsIn([...CURRENCIES])
|
@IsIn([...CURRENCIES])
|
||||||
currency!: string;
|
currency?: string;
|
||||||
|
|
||||||
@ApiProperty({ description: 'Numeric rate value', minimum: 0 })
|
@ApiProperty({ description: 'Numeric rate value', minimum: 0 })
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
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 {
|
export class CreateServiceTypeDto {
|
||||||
@ApiProperty({ description: 'Service type display name', maxLength: 255 })
|
@ApiProperty({ description: 'Service type display name', maxLength: 255 })
|
||||||
@@ -32,10 +32,15 @@ export class CreateServiceTypeDto {
|
|||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
includesCustoms?: boolean;
|
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 (0–15)',
|
||||||
|
default: 0,
|
||||||
|
maximum: 15,
|
||||||
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsInt()
|
@IsInt()
|
||||||
@Min(0)
|
@Min(0)
|
||||||
|
@Max(15)
|
||||||
priorityBonusPoints?: number;
|
priorityBonusPoints?: number;
|
||||||
|
|
||||||
@ApiPropertyOptional({ default: true })
|
@ApiPropertyOptional({ default: true })
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
import { PartialType } from '@nestjs/mapped-types';
|
|
||||||
import { CreateSurchargeTypeDto } from './create-surcharge-type.dto';
|
|
||||||
|
|
||||||
export class UpdateSurchargeTypeDto extends PartialType(CreateSurchargeTypeDto) {}
|
|
||||||
@@ -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';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { BaseEntity } from '@edr/api-common';
|
import { BaseEntity } from '@edr/api-common';
|
||||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
||||||
|
import { CargoType } from './cargo-type.entity';
|
||||||
import { ContainerType } from './container-type.entity';
|
import { ContainerType } from './container-type.entity';
|
||||||
|
|
||||||
export const RATE_TYPES = [
|
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 const RATE_STATUSES = ['DRAFT', 'PENDING_APPROVAL', 'LIVE', 'SUPERSEDED'] as const;
|
||||||
export type RateStatus = typeof RATE_STATUSES[number];
|
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];
|
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' })
|
@Entity({ schema: 'freight', name: 'rates' })
|
||||||
@Index(['rateType'])
|
@Index(['rateType'])
|
||||||
@Index(['status'])
|
@Index(['status'])
|
||||||
@Index(['effectiveFrom'])
|
@Index(['effectiveFrom'])
|
||||||
@Index(['containerTypeId'])
|
@Index(['containerTypeId'])
|
||||||
|
@Index(['trigger'])
|
||||||
export class Rate extends BaseEntity {
|
export class Rate extends BaseEntity {
|
||||||
@Column({ name: 'rate_type', type: 'varchar', length: 50 })
|
@Column({ name: 'rate_type', type: 'varchar', length: 50 })
|
||||||
rateType!: RateType;
|
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 })
|
@Column({ name: 'container_type_id', type: 'uuid', nullable: true })
|
||||||
containerTypeId?: string | null;
|
containerTypeId?: string | null;
|
||||||
|
|
||||||
@@ -46,6 +101,13 @@ export class Rate extends BaseEntity {
|
|||||||
@JoinColumn({ name: 'container_type_id' })
|
@JoinColumn({ name: 'container_type_id' })
|
||||||
containerType?: ContainerType | null;
|
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 })
|
@Column({ name: 'trade_direction', type: 'varchar', length: 10, nullable: true })
|
||||||
tradeDirection?: string | null;
|
tradeDirection?: string | null;
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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');
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -8,7 +8,6 @@ import { PriorityConfigsController } from './controllers/priority-configs.contro
|
|||||||
import { RatesController } from './controllers/rates.controller';
|
import { RatesController } from './controllers/rates.controller';
|
||||||
import { ServiceTypesController } from './controllers/service-types.controller';
|
import { ServiceTypesController } from './controllers/service-types.controller';
|
||||||
import { ShippingLinesController } from './controllers/shipping-lines.controller';
|
import { ShippingLinesController } from './controllers/shipping-lines.controller';
|
||||||
import { SurchargeTypesController } from './controllers/surcharge-types.controller';
|
|
||||||
import { WeightLimitRulesController } from './controllers/weight-limit-rules.controller';
|
import { WeightLimitRulesController } from './controllers/weight-limit-rules.controller';
|
||||||
import { YardsController } from './controllers/yards.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 { Rate } from './entities/rate.entity';
|
||||||
import { ServiceType } from './entities/service-type.entity';
|
import { ServiceType } from './entities/service-type.entity';
|
||||||
import { ShippingLine } from './entities/shipping-line.entity';
|
import { ShippingLine } from './entities/shipping-line.entity';
|
||||||
import { SurchargeType } from './entities/surcharge-type.entity';
|
|
||||||
import { WeightLimitRule } from './entities/weight-limit-rule.entity';
|
import { WeightLimitRule } from './entities/weight-limit-rule.entity';
|
||||||
import { Yard } from './entities/yard.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 { RATES_REPOSITORY } from './interfaces/rates.repository.interface';
|
||||||
import { SERVICE_TYPES_REPOSITORY } from './interfaces/service-types.repository.interface';
|
import { SERVICE_TYPES_REPOSITORY } from './interfaces/service-types.repository.interface';
|
||||||
import { SHIPPING_LINES_REPOSITORY } from './interfaces/shipping-lines.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 { WEIGHT_LIMIT_RULES_REPOSITORY } from './interfaces/weight-limit-rules.repository.interface';
|
||||||
import { YARDS_REPOSITORY } from './interfaces/yards.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 { RatesRepository } from './repositories/rates.repository';
|
||||||
import { ServiceTypesRepository } from './repositories/service-types.repository';
|
import { ServiceTypesRepository } from './repositories/service-types.repository';
|
||||||
import { ShippingLinesRepository } from './repositories/shipping-lines.repository';
|
import { ShippingLinesRepository } from './repositories/shipping-lines.repository';
|
||||||
import { SurchargeTypesRepository } from './repositories/surcharge-types.repository';
|
|
||||||
import { WeightLimitRulesRepository } from './repositories/weight-limit-rules.repository';
|
import { WeightLimitRulesRepository } from './repositories/weight-limit-rules.repository';
|
||||||
import { YardsRepository } from './repositories/yards.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 { RatesService } from './services/rates.service';
|
||||||
import { ServiceTypesService } from './services/service-types.service';
|
import { ServiceTypesService } from './services/service-types.service';
|
||||||
import { ShippingLinesService } from './services/shipping-lines.service';
|
import { ShippingLinesService } from './services/shipping-lines.service';
|
||||||
import { SurchargeTypesService } from './services/surcharge-types.service';
|
|
||||||
import { WeightLimitRulesService } from './services/weight-limit-rules.service';
|
import { WeightLimitRulesService } from './services/weight-limit-rules.service';
|
||||||
import { YardsService } from './services/yards.service';
|
import { YardsService } from './services/yards.service';
|
||||||
|
|
||||||
@@ -71,7 +66,6 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.
|
|||||||
CargoType,
|
CargoType,
|
||||||
ContainerType,
|
ContainerType,
|
||||||
PriorityConfig,
|
PriorityConfig,
|
||||||
SurchargeType,
|
|
||||||
ServiceType,
|
ServiceType,
|
||||||
WeightLimitRule,
|
WeightLimitRule,
|
||||||
Yard,
|
Yard,
|
||||||
@@ -88,7 +82,6 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.
|
|||||||
CargoTypesController,
|
CargoTypesController,
|
||||||
ContainerTypesController,
|
ContainerTypesController,
|
||||||
PriorityConfigsController,
|
PriorityConfigsController,
|
||||||
SurchargeTypesController,
|
|
||||||
ServiceTypesController,
|
ServiceTypesController,
|
||||||
WeightLimitRulesController,
|
WeightLimitRulesController,
|
||||||
YardsController,
|
YardsController,
|
||||||
@@ -103,8 +96,6 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.
|
|||||||
{ provide: CONTAINER_TYPES_REPOSITORY, useExisting: ContainerTypesRepository },
|
{ provide: CONTAINER_TYPES_REPOSITORY, useExisting: ContainerTypesRepository },
|
||||||
PriorityConfigsRepository,
|
PriorityConfigsRepository,
|
||||||
{ provide: PRIORITY_CONFIGS_REPOSITORY, useExisting: PriorityConfigsRepository },
|
{ provide: PRIORITY_CONFIGS_REPOSITORY, useExisting: PriorityConfigsRepository },
|
||||||
SurchargeTypesRepository,
|
|
||||||
{ provide: SURCHARGE_TYPES_REPOSITORY, useExisting: SurchargeTypesRepository },
|
|
||||||
ServiceTypesRepository,
|
ServiceTypesRepository,
|
||||||
{ provide: SERVICE_TYPES_REPOSITORY, useExisting: ServiceTypesRepository },
|
{ provide: SERVICE_TYPES_REPOSITORY, useExisting: ServiceTypesRepository },
|
||||||
WeightLimitRulesRepository,
|
WeightLimitRulesRepository,
|
||||||
@@ -120,7 +111,6 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.
|
|||||||
CargoTypesService,
|
CargoTypesService,
|
||||||
ContainerTypesService,
|
ContainerTypesService,
|
||||||
PriorityConfigsService,
|
PriorityConfigsService,
|
||||||
SurchargeTypesService,
|
|
||||||
ServiceTypesService,
|
ServiceTypesService,
|
||||||
WeightLimitRulesService,
|
WeightLimitRulesService,
|
||||||
YardsService,
|
YardsService,
|
||||||
@@ -135,7 +125,6 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.
|
|||||||
CargoTypesService,
|
CargoTypesService,
|
||||||
ServiceTypesService,
|
ServiceTypesService,
|
||||||
ContainerTypesService,
|
ContainerTypesService,
|
||||||
SurchargeTypesService,
|
|
||||||
WeightLimitRulesService,
|
WeightLimitRulesService,
|
||||||
PriorityConfigsService,
|
PriorityConfigsService,
|
||||||
YardsService,
|
YardsService,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Inject, Injectable, BadRequestException } from '@nestjs/common';
|
|||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
import { BookingApprovalStep } from '../bookings/entities/booking-approval-step.entity';
|
import { BookingApprovalStep } from '../bookings/entities/booking-approval-step.entity';
|
||||||
import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.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 {
|
import {
|
||||||
ICargoTypesRepository,
|
ICargoTypesRepository,
|
||||||
CARGO_TYPES_REPOSITORY,
|
CARGO_TYPES_REPOSITORY,
|
||||||
@@ -19,10 +19,6 @@ import {
|
|||||||
IPriorityConfigsRepository,
|
IPriorityConfigsRepository,
|
||||||
PRIORITY_CONFIGS_REPOSITORY,
|
PRIORITY_CONFIGS_REPOSITORY,
|
||||||
} from './interfaces/priority-configs.repository.interface';
|
} from './interfaces/priority-configs.repository.interface';
|
||||||
import {
|
|
||||||
ISurchargeTypesRepository,
|
|
||||||
SURCHARGE_TYPES_REPOSITORY,
|
|
||||||
} from './interfaces/surcharge-types.repository.interface';
|
|
||||||
import {
|
import {
|
||||||
IRatesRepository,
|
IRatesRepository,
|
||||||
RATES_REPOSITORY,
|
RATES_REPOSITORY,
|
||||||
@@ -55,6 +51,8 @@ export interface BookingEvaluationInput {
|
|||||||
paymentCurrency: string;
|
paymentCurrency: string;
|
||||||
tradeDirection: string;
|
tradeDirection: string;
|
||||||
isHazardous: boolean;
|
isHazardous: boolean;
|
||||||
|
/** Booking-level reefer flag; ORed with per-container reefer. */
|
||||||
|
isReefer?: boolean;
|
||||||
isGovernment?: boolean;
|
isGovernment?: boolean;
|
||||||
allowConsolidation?: boolean;
|
allowConsolidation?: boolean;
|
||||||
shippingLineId?: string | null;
|
shippingLineId?: string | null;
|
||||||
@@ -63,11 +61,12 @@ export interface BookingEvaluationInput {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface AppliedCargoModifier {
|
export interface AppliedCargoModifier {
|
||||||
surchargeTypeId: string;
|
/** The trigger-based rate that produced this surcharge line. */
|
||||||
surchargeTypeCode: string;
|
rateId: string;
|
||||||
|
/** Stable display/audit code, derived from the rate's trigger + rateType. */
|
||||||
|
surchargeCode: string;
|
||||||
triggerValue: number | null;
|
triggerValue: number | null;
|
||||||
calculatedAmount: number;
|
calculatedAmount: number;
|
||||||
rateId: string;
|
|
||||||
currency: string;
|
currency: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,8 +97,6 @@ export class RuleEngineService {
|
|||||||
private readonly weightLimitRulesRepo: IWeightLimitRulesRepository,
|
private readonly weightLimitRulesRepo: IWeightLimitRulesRepository,
|
||||||
@Inject(PRIORITY_CONFIGS_REPOSITORY)
|
@Inject(PRIORITY_CONFIGS_REPOSITORY)
|
||||||
private readonly priorityConfigsRepo: IPriorityConfigsRepository,
|
private readonly priorityConfigsRepo: IPriorityConfigsRepository,
|
||||||
@Inject(SURCHARGE_TYPES_REPOSITORY)
|
|
||||||
private readonly surchargeTypesRepo: ISurchargeTypesRepository,
|
|
||||||
@Inject(RATES_REPOSITORY)
|
@Inject(RATES_REPOSITORY)
|
||||||
private readonly ratesRepo: IRatesRepository,
|
private readonly ratesRepo: IRatesRepository,
|
||||||
@Inject(APPROVAL_RULES_REPOSITORY)
|
@Inject(APPROVAL_RULES_REPOSITORY)
|
||||||
@@ -200,15 +197,25 @@ export class RuleEngineService {
|
|||||||
shippingLineMapped = Boolean(line?.mappedToCode);
|
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 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 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) {
|
for (const rate of surchargeRates) {
|
||||||
const triggered = this.matchesTrigger(st.triggerCondition, {
|
const triggered = this.matchesTrigger(rate.trigger, {
|
||||||
isHazardous: input.isHazardous,
|
isHazardous: input.isHazardous,
|
||||||
hasReefer,
|
hasReefer,
|
||||||
hasOverweight,
|
hasOverweight,
|
||||||
@@ -217,28 +224,28 @@ export class RuleEngineService {
|
|||||||
});
|
});
|
||||||
if (!triggered) continue;
|
if (!triggered) continue;
|
||||||
|
|
||||||
const rate = st.rate ?? rateById.get(st.rateId);
|
|
||||||
if (!rate) continue;
|
|
||||||
|
|
||||||
let triggerValue: number | null = null;
|
let triggerValue: number | null = null;
|
||||||
let calculatedAmount = Number(rate.rateValue);
|
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(
|
triggerValue = containerWeightResults.reduce(
|
||||||
(sum, r) => sum + (r.overweightExcessTons ?? 0),
|
(sum, r) => sum + (r.overweightExcessTons ?? 0),
|
||||||
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({
|
appliedModifiers.push({
|
||||||
surchargeTypeId: st.id,
|
rateId: rate.id,
|
||||||
surchargeTypeCode: st.code,
|
surchargeCode: this.surchargeCode(rate),
|
||||||
triggerValue,
|
triggerValue,
|
||||||
calculatedAmount,
|
calculatedAmount,
|
||||||
rateId: rate.id,
|
|
||||||
currency: rate.currency,
|
currency: rate.currency,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -373,7 +380,7 @@ export class RuleEngineService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private matchesTrigger(
|
private matchesTrigger(
|
||||||
condition: TriggerCondition,
|
trigger: RateTrigger,
|
||||||
state: {
|
state: {
|
||||||
isHazardous: boolean;
|
isHazardous: boolean;
|
||||||
hasReefer: boolean;
|
hasReefer: boolean;
|
||||||
@@ -382,19 +389,59 @@ export class RuleEngineService {
|
|||||||
allowConsolidation: boolean;
|
allowConsolidation: boolean;
|
||||||
},
|
},
|
||||||
): boolean {
|
): boolean {
|
||||||
switch (condition) {
|
// Coerce defensively: a flag may arrive as the string "true"/"false" (e.g.
|
||||||
case 'CARGO_FLAG_HAZARDOUS':
|
// from multipart form-data) and a non-empty "false" string is truthy.
|
||||||
return state.isHazardous;
|
const truthy = (v: unknown): boolean => v === true || v === 'true';
|
||||||
case 'CARGO_FLAG_REEFER':
|
switch (trigger) {
|
||||||
return state.hasReefer;
|
case 'HAZARDOUS':
|
||||||
case 'VGM_EXCEEDS_LIMIT':
|
return truthy(state.isHazardous);
|
||||||
return state.hasOverweight;
|
case 'REEFER':
|
||||||
case 'SHIPPING_LINE_MAPPED':
|
return truthy(state.hasReefer);
|
||||||
return state.shippingLineMapped;
|
case 'OVERWEIGHT':
|
||||||
case 'CONSOLIDATION_ENABLED':
|
return truthy(state.hasOverweight);
|
||||||
return state.allowConsolidation;
|
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:
|
default:
|
||||||
return false;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { BadRequestException, Inject, Injectable, NotFoundException } from '@nes
|
|||||||
import { CreateRateDto } from '../dto/create-rate.dto';
|
import { CreateRateDto } from '../dto/create-rate.dto';
|
||||||
import { UpdateRateDto } from '../dto/update-rate.dto';
|
import { UpdateRateDto } from '../dto/update-rate.dto';
|
||||||
import { Rate } from '../entities/rate.entity';
|
import { Rate } from '../entities/rate.entity';
|
||||||
|
import { deriveRateType } from '../entities/rate-type.util';
|
||||||
import { IRatesRepository, RATES_REPOSITORY } from '../interfaces/rates.repository.interface';
|
import { IRatesRepository, RATES_REPOSITORY } from '../interfaces/rates.repository.interface';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -47,10 +48,27 @@ export class RatesService {
|
|||||||
|
|
||||||
/** Create a rate in DRAFT status. */
|
/** Create a rate in DRAFT status. */
|
||||||
async create(dto: CreateRateDto, proposedByStaffId: string): Promise<Rate> {
|
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({
|
return this.repository.create({
|
||||||
rateType: dto.rateType as Rate['rateType'],
|
appliesTo,
|
||||||
containerTypeId: dto.containerTypeId,
|
trigger,
|
||||||
tradeDirection: dto.tradeDirection,
|
rateType: deriveRateType({
|
||||||
|
appliesTo,
|
||||||
|
trigger,
|
||||||
|
tradeDirection,
|
||||||
|
isBulk: Boolean(cargoTypeId),
|
||||||
|
}),
|
||||||
|
containerTypeId,
|
||||||
|
cargoTypeId,
|
||||||
|
tradeDirection,
|
||||||
currency: dto.currency ?? 'USD',
|
currency: dto.currency ?? 'USD',
|
||||||
rateValue: dto.rateValue,
|
rateValue: dto.rateValue,
|
||||||
rateUnit: dto.rateUnit as Rate['rateUnit'],
|
rateUnit: dto.rateUnit as Rate['rateUnit'],
|
||||||
@@ -68,9 +86,41 @@ export class RatesService {
|
|||||||
throw new BadRequestException('Only DRAFT rates can be updated');
|
throw new BadRequestException('Only DRAFT rates can be updated');
|
||||||
}
|
}
|
||||||
const updates: Partial<Rate> = {};
|
const updates: Partial<Rate> = {};
|
||||||
if (dto.rateType) updates.rateType = dto.rateType as Rate['rateType'];
|
|
||||||
if (dto.containerTypeId !== undefined) updates.containerTypeId = dto.containerTypeId;
|
const appliesTo = (dto.appliesTo as Rate['appliesTo']) ?? existing.appliesTo;
|
||||||
if (dto.tradeDirection !== undefined) updates.tradeDirection = dto.tradeDirection;
|
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';
|
updates.currency = dto.currency ?? existing.currency ?? 'USD';
|
||||||
if (dto.rateValue !== undefined) updates.rateValue = dto.rateValue;
|
if (dto.rateValue !== undefined) updates.rateValue = dto.rateValue;
|
||||||
if (dto.rateUnit) updates.rateUnit = dto.rateUnit as Rate['rateUnit'];
|
if (dto.rateUnit) updates.rateUnit = dto.rateUnit as Rate['rateUnit'];
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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. */
|
/** Resolve a schedule's (route, day) group and run the day-level pipeline. */
|
||||||
private async processRouteDayForSchedule(scheduleId: string): Promise<void> {
|
private async processRouteDayForSchedule(scheduleId: string): Promise<void> {
|
||||||
const schedule = await this.trainSchedulesRepository.findById(scheduleId);
|
const schedule = await this.trainSchedulesRepository.findById(scheduleId);
|
||||||
|
|||||||
@@ -386,9 +386,7 @@ export class DemoBookingsSeeder {
|
|||||||
shippingLineId: null,
|
shippingLineId: null,
|
||||||
cargoTotalWeightVgm: demoBooking.totalWeightTons,
|
cargoTotalWeightVgm: demoBooking.totalWeightTons,
|
||||||
isHazardous: false,
|
isHazardous: false,
|
||||||
paymentCurrency: "ETB",
|
paymentCurrency: "ETB", priorityScore: 0,
|
||||||
allowConsolidation: false,
|
|
||||||
priorityScore: 0,
|
|
||||||
versionNumber: 1,
|
versionNumber: 1,
|
||||||
},
|
},
|
||||||
{ conflictPaths: { reference: true } },
|
{ conflictPaths: { reference: true } },
|
||||||
@@ -459,9 +457,7 @@ export class DemoBookingsSeeder {
|
|||||||
shippingLineId: null,
|
shippingLineId: null,
|
||||||
cargoTotalWeightVgm: demoBulk.totalWeightTons,
|
cargoTotalWeightVgm: demoBulk.totalWeightTons,
|
||||||
isHazardous: false,
|
isHazardous: false,
|
||||||
paymentCurrency: "USD",
|
paymentCurrency: "USD", priorityScore: 10,
|
||||||
allowConsolidation: false,
|
|
||||||
priorityScore: 10,
|
|
||||||
schedulingStatus: "HOLDING",
|
schedulingStatus: "HOLDING",
|
||||||
versionNumber: 1,
|
versionNumber: 1,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -20,12 +20,13 @@ import { DEFAULT_APPROVAL_RULE_ROWS } from '../modules/rule-engine/approval-rule
|
|||||||
const EDR_ORG_KEY = 'edr_freight';
|
const EDR_ORG_KEY = 'edr_freight';
|
||||||
const MIN_WAGONS_PER_TYPE = 100;
|
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 = [
|
const DEMO_STAFF_USERS = [
|
||||||
{ email: 'marketing@edr.local', username: 'marketing', roleKey: 'edr_marketing' },
|
{ email: 'marketing@edr.local', username: 'marketing', roleKey: 'edr_marketing' },
|
||||||
{ email: 'operations@edr.local', username: 'operations', roleKey: 'edr_operations_officer' },
|
{ email: 'operations@edr.local', username: 'operations', roleKey: 'edr_operations_officer' },
|
||||||
{ email: 'director@edr.local', username: 'director', roleKey: 'edr_director' },
|
{ email: 'director@edr.local', username: 'director', roleKey: 'edr_director' },
|
||||||
{ email: 'ceo@edr.local', username: 'ceo', roleKey: 'edr_ceo' },
|
{ email: 'ceo@edr.local', username: 'ceo', roleKey: 'edr_ceo' },
|
||||||
|
{ email: 'gl@edr.local', username: 'gl', roleKey: 'edr_global_logistics' },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -237,6 +237,11 @@ export const EDR_FREIGHT_ROLES: FreightSeedRole[] = [
|
|||||||
name: { en: "EDR Marketing" },
|
name: { en: "EDR Marketing" },
|
||||||
permissionKeys: [...ROLE_PERMISSION_PRESETS.marketing],
|
permissionKeys: [...ROLE_PERMISSION_PRESETS.marketing],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "edr_global_logistics",
|
||||||
|
name: { en: "EDR Global Logistics" },
|
||||||
|
permissionKeys: [...ROLE_PERMISSION_PRESETS.globalLogistics],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: "edr_org_manager",
|
key: "edr_org_manager",
|
||||||
name: { en: "EDR Org Manager" },
|
name: { en: "EDR Org Manager" },
|
||||||
|
|||||||
@@ -192,6 +192,169 @@ const COMPANY_ONBOARDING_DOCUMENTS: OnboardingDocumentSetting[] = [
|
|||||||
const COMPANY_ONBOARDING_DESCRIPTION =
|
const COMPANY_ONBOARDING_DESCRIPTION =
|
||||||
"Required documents for external company onboarding, by company nationality.";
|
"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()
|
@Injectable()
|
||||||
export class FileUploadSettingsSeeder {
|
export class FileUploadSettingsSeeder {
|
||||||
private readonly logger = new Logger(FileUploadSettingsSeeder.name);
|
private readonly logger = new Logger(FileUploadSettingsSeeder.name);
|
||||||
@@ -203,12 +366,25 @@ export class FileUploadSettingsSeeder {
|
|||||||
const settingRepository = manager.getRepository(FileUploadSetting);
|
const settingRepository = manager.getRepository(FileUploadSetting);
|
||||||
const fieldRepository = manager.getRepository(FileUploadField);
|
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(
|
await settingRepository.upsert(
|
||||||
{
|
{
|
||||||
code: documentSetting.code,
|
code: documentSetting.code,
|
||||||
label: documentSetting.label,
|
label: documentSetting.label,
|
||||||
description: COMPANY_ONBOARDING_DESCRIPTION,
|
description: documentSetting.description,
|
||||||
entity: documentSetting.entity,
|
entity: documentSetting.entity,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -245,7 +421,7 @@ export class FileUploadSettingsSeeder {
|
|||||||
});
|
});
|
||||||
|
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
"Ensured company onboarding file upload settings for external companies",
|
"Ensured company onboarding + booking clearance file upload settings",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ export const RULE_ENGINE_RESOURCE_SLUGS = [
|
|||||||
'yards',
|
'yards',
|
||||||
'shipping-lines',
|
'shipping-lines',
|
||||||
'weight-limit-rules',
|
'weight-limit-rules',
|
||||||
'surcharge-types',
|
|
||||||
'priority-configs',
|
'priority-configs',
|
||||||
'rates',
|
'rates',
|
||||||
'approval-rules',
|
'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-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-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-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-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-000000000010', 'edr_freight_app:train_scheduling:manage', 'Manage train scheduling'),
|
||||||
perm('a1000001-0001-4000-8000-000000000011', 'edr_freight_app:fleet:view', 'View fleet'),
|
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' },
|
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' },
|
'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' },
|
'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' },
|
'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' },
|
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' },
|
'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',
|
signStaff: 'edr_freight_app:bookings:sign_staff',
|
||||||
operations: 'edr_freight_app:bookings:operations',
|
operations: 'edr_freight_app:bookings:operations',
|
||||||
cancel: 'edr_freight_app:bookings:cancel',
|
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: {
|
trainScheduling: {
|
||||||
view: 'edr_freight_app:train_scheduling:view',
|
view: 'edr_freight_app:train_scheduling:view',
|
||||||
@@ -167,6 +171,14 @@ export const ROLE_PERMISSION_PRESETS = {
|
|||||||
...allRuleEngineViewKeys(),
|
...allRuleEngineViewKeys(),
|
||||||
],
|
],
|
||||||
finance: [FREIGHT_PERMS.bookings.view],
|
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 handles intake through contract (same as line staff here).
|
||||||
marketing: [
|
marketing: [
|
||||||
FREIGHT_PERMS.bookings.view,
|
FREIGHT_PERMS.bookings.view,
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ const STAFF_USERS = [
|
|||||||
{ email: 'linestaff@edr.local', username: 'linestaff', roleKey: 'edr_line_staff' },
|
{ email: 'linestaff@edr.local', username: 'linestaff', roleKey: 'edr_line_staff' },
|
||||||
{ email: 'director@edr.local', username: 'director', roleKey: 'edr_director' },
|
{ email: 'director@edr.local', username: 'director', roleKey: 'edr_director' },
|
||||||
{ email: 'ceo@edr.local', username: 'ceo', roleKey: 'edr_ceo' },
|
{ email: 'ceo@edr.local', username: 'ceo', roleKey: 'edr_ceo' },
|
||||||
|
{ email: 'gl@edr.local', username: 'gl', roleKey: 'edr_global_logistics' },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
@Injectable()
|
@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@)',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import { PriorityConfig } from "../modules/rule-engine/entities/priority-config.
|
|||||||
import { Rate } from "../modules/rule-engine/entities/rate.entity";
|
import { Rate } from "../modules/rule-engine/entities/rate.entity";
|
||||||
import { ServiceType } from "../modules/rule-engine/entities/service-type.entity";
|
import { ServiceType } from "../modules/rule-engine/entities/service-type.entity";
|
||||||
import { ShippingLine } from "../modules/rule-engine/entities/shipping-line.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 { WeightLimitRule } from "../modules/rule-engine/entities/weight-limit-rule.entity";
|
||||||
import { Route } from "../modules/routes/entities/route.entity";
|
import { Route } from "../modules/routes/entities/route.entity";
|
||||||
import { RouteMilestone } from "../modules/routes/entities/route-milestone.entity";
|
import { RouteMilestone } from "../modules/routes/entities/route-milestone.entity";
|
||||||
@@ -40,15 +39,13 @@ export class PricingDataSeeder {
|
|||||||
const containerTypes = await ctRepo.find();
|
const containerTypes = await ctRepo.find();
|
||||||
const ctByCode = new Map(containerTypes.map((ct) => [ct.code, ct]));
|
const ctByCode = new Map(containerTypes.map((ct) => [ct.code, ct]));
|
||||||
|
|
||||||
const rates = await this.seedRates(rRepo, ctByCode);
|
// Clear booking cargo modifiers up front — they reference rate snapshots
|
||||||
const ratesByType = new Map<string, Rate[]>();
|
// that get recomputed when bookings are repriced.
|
||||||
for (const r of rates) {
|
await manager.getRepository(BookingCargoModifier).createQueryBuilder().delete().execute();
|
||||||
const key = `${r.rateType}|${r.currency}|${r.containerTypeId ?? ""}`;
|
|
||||||
if (!ratesByType.has(key)) ratesByType.set(key, []);
|
|
||||||
ratesByType.get(key)!.push(r);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 yards = await yRepo.find();
|
||||||
const yardByCode = new Map(yards.map((y) => [y.code, y]));
|
const yardByCode = new Map(yards.map((y) => [y.code, y]));
|
||||||
@@ -180,7 +177,7 @@ export class PricingDataSeeder {
|
|||||||
includesFirstMile: true,
|
includesFirstMile: true,
|
||||||
includesLastMile: true,
|
includesLastMile: true,
|
||||||
includesCustoms: true,
|
includesCustoms: true,
|
||||||
priorityBonusPoints: 100,
|
priorityBonusPoints: 15,
|
||||||
isActive: true,
|
isActive: true,
|
||||||
displayOrder: 2,
|
displayOrder: 2,
|
||||||
},
|
},
|
||||||
@@ -192,7 +189,7 @@ export class PricingDataSeeder {
|
|||||||
includesFirstMile: false,
|
includesFirstMile: false,
|
||||||
includesLastMile: false,
|
includesLastMile: false,
|
||||||
includesCustoms: false,
|
includesCustoms: false,
|
||||||
priorityBonusPoints: 50,
|
priorityBonusPoints: 10,
|
||||||
isActive: true,
|
isActive: true,
|
||||||
displayOrder: 3,
|
displayOrder: 3,
|
||||||
},
|
},
|
||||||
@@ -246,6 +243,7 @@ export class PricingDataSeeder {
|
|||||||
{
|
{
|
||||||
code: "GRAIN",
|
code: "GRAIN",
|
||||||
cargoTypeName: "Grain / Cereals",
|
cargoTypeName: "Grain / Cereals",
|
||||||
|
showFreeTextBox: false,
|
||||||
requiresDirectorApproval: false,
|
requiresDirectorApproval: false,
|
||||||
isActive: true,
|
isActive: true,
|
||||||
displayOrder: 1,
|
displayOrder: 1,
|
||||||
@@ -253,6 +251,7 @@ export class PricingDataSeeder {
|
|||||||
{
|
{
|
||||||
code: "FERTILIZER",
|
code: "FERTILIZER",
|
||||||
cargoTypeName: "Fertilizer",
|
cargoTypeName: "Fertilizer",
|
||||||
|
showFreeTextBox: false,
|
||||||
requiresDirectorApproval: false,
|
requiresDirectorApproval: false,
|
||||||
isActive: true,
|
isActive: true,
|
||||||
displayOrder: 2,
|
displayOrder: 2,
|
||||||
@@ -260,6 +259,7 @@ export class PricingDataSeeder {
|
|||||||
{
|
{
|
||||||
code: "CEMENT",
|
code: "CEMENT",
|
||||||
cargoTypeName: "Cement / Clinker",
|
cargoTypeName: "Cement / Clinker",
|
||||||
|
showFreeTextBox: false,
|
||||||
requiresDirectorApproval: false,
|
requiresDirectorApproval: false,
|
||||||
isActive: true,
|
isActive: true,
|
||||||
displayOrder: 3,
|
displayOrder: 3,
|
||||||
@@ -267,6 +267,7 @@ export class PricingDataSeeder {
|
|||||||
{
|
{
|
||||||
code: "STEEL",
|
code: "STEEL",
|
||||||
cargoTypeName: "Steel / Rebar",
|
cargoTypeName: "Steel / Rebar",
|
||||||
|
showFreeTextBox: false,
|
||||||
requiresDirectorApproval: true,
|
requiresDirectorApproval: true,
|
||||||
isActive: true,
|
isActive: true,
|
||||||
displayOrder: 4,
|
displayOrder: 4,
|
||||||
@@ -274,6 +275,7 @@ export class PricingDataSeeder {
|
|||||||
{
|
{
|
||||||
code: "MACHINERY",
|
code: "MACHINERY",
|
||||||
cargoTypeName: "Heavy Machinery",
|
cargoTypeName: "Heavy Machinery",
|
||||||
|
showFreeTextBox: false,
|
||||||
requiresDirectorApproval: true,
|
requiresDirectorApproval: true,
|
||||||
isActive: true,
|
isActive: true,
|
||||||
displayOrder: 5,
|
displayOrder: 5,
|
||||||
@@ -281,6 +283,7 @@ export class PricingDataSeeder {
|
|||||||
{
|
{
|
||||||
code: "OTHER_BULK",
|
code: "OTHER_BULK",
|
||||||
cargoTypeName: "Other Bulk Cargo",
|
cargoTypeName: "Other Bulk Cargo",
|
||||||
|
showFreeTextBox: false,
|
||||||
requiresDirectorApproval: false,
|
requiresDirectorApproval: false,
|
||||||
isActive: true,
|
isActive: true,
|
||||||
displayOrder: 6,
|
displayOrder: 6,
|
||||||
@@ -382,9 +385,11 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise<void> {
|
|||||||
this.logger.log("Seeded weight limit rules");
|
this.logger.log("Seeded weight limit rules");
|
||||||
}
|
}
|
||||||
private async seedPriorityConfigs(prRepo: any): Promise<void> {
|
private async seedPriorityConfigs(prRepo: any): Promise<void> {
|
||||||
// Wagon Count Block — independent, applies regardless of currency.
|
// Priority rule = Wagon Block + Currency Block (both additive; see RuleEngineService.evaluate).
|
||||||
// Currency Block — applies only to the matching payment currency, within the wagon range.
|
// Combined with the service-type bonus the total priority score caps at 100:
|
||||||
// Both blocks are additive (see RuleEngineService.evaluate).
|
// 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 = [
|
const rows = [
|
||||||
// ── Wagon Count Block ───────────────────────────────────────────────
|
// ── Wagon Count Block ───────────────────────────────────────────────
|
||||||
{ type: "WAGON", label: "Wagons 1–20", currency: null, minWagonCount: 1, maxWagonCount: 20, scorePoints: 0, displayOrder: 1 },
|
{ type: "WAGON", label: "Wagons 1–20", 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 31–40", currency: null, minWagonCount: 31, maxWagonCount: 40, scorePoints: 30, displayOrder: 3 },
|
{ type: "WAGON", label: "Wagons 31–40", currency: null, minWagonCount: 31, maxWagonCount: 40, scorePoints: 30, displayOrder: 3 },
|
||||||
{ type: "WAGON", label: "Wagons 41–50", currency: null, minWagonCount: 41, maxWagonCount: 50, scorePoints: 50, displayOrder: 4 },
|
{ type: "WAGON", label: "Wagons 41–50", currency: null, minWagonCount: 41, maxWagonCount: 50, scorePoints: 50, displayOrder: 4 },
|
||||||
// ── Payment Currency Block ──────────────────────────────────────────
|
// ── Payment Currency Block ──────────────────────────────────────────
|
||||||
{ type: "CURRENCY", label: "USD · Wagons 1–25", currency: "USD", minWagonCount: 1, maxWagonCount: 25, scorePoints: 17, displayOrder: 5 },
|
{ type: "CURRENCY", label: "USD · Wagons 1–25", currency: "USD", minWagonCount: 1, maxWagonCount: 25, scorePoints: 15, displayOrder: 5 },
|
||||||
{ type: "CURRENCY", label: "USD · Wagons 26–50", currency: "USD", minWagonCount: 26, maxWagonCount: 50, scorePoints: 35, displayOrder: 6 },
|
{ type: "CURRENCY", label: "USD · Wagons 26–50", currency: "USD", minWagonCount: 26, maxWagonCount: 50, scorePoints: 35, displayOrder: 6 },
|
||||||
{ type: "CURRENCY", label: "ETB · Wagons 1–50", currency: "ETB", minWagonCount: 1, maxWagonCount: 50, scorePoints: 0, displayOrder: 7 },
|
{ type: "CURRENCY", label: "ETB · Wagons 1–50", 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(
|
private async seedRates(
|
||||||
rRepo: any,
|
rRepo: any,
|
||||||
ctByCode: Map<string, any>,
|
ctByCode: Map<string, any>,
|
||||||
|
cargoByCode: Map<string, any>,
|
||||||
): Promise<Rate[]> {
|
): Promise<Rate[]> {
|
||||||
const effectiveFrom = new Date("2026-01-01");
|
const effectiveFrom = new Date("2026-01-01");
|
||||||
const now = new Date();
|
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 = [
|
const rateData = [
|
||||||
{
|
// ── Container base freight ──────────────────────────────────────────
|
||||||
rateType: "CONTAINER_IMPORT",
|
{ appliesTo: "CONTAINER", trigger: "ALWAYS", rateType: "CONTAINER_IMPORT", tradeDirection: "IMPORT", containerTypeId: ctByCode.get("20FT")!.id, rateValue: 800, rateUnit: "PER_CONTAINER" },
|
||||||
containerTypeId: ctByCode.get("20FT")!.id,
|
{ appliesTo: "CONTAINER", trigger: "ALWAYS", rateType: "CONTAINER_IMPORT", tradeDirection: "IMPORT", containerTypeId: ctByCode.get("40FT")!.id, rateValue: 1200, rateUnit: "PER_CONTAINER" },
|
||||||
currency: "USD",
|
{ appliesTo: "CONTAINER", trigger: "ALWAYS", rateType: "CONTAINER_EXPORT", tradeDirection: "EXPORT", containerTypeId: ctByCode.get("20FT")!.id, rateValue: 600, rateUnit: "PER_CONTAINER" },
|
||||||
rateValue: 800,
|
{ appliesTo: "CONTAINER", trigger: "ALWAYS", rateType: "CONTAINER_EXPORT", tradeDirection: "EXPORT", containerTypeId: ctByCode.get("40FT")!.id, rateValue: 900, rateUnit: "PER_CONTAINER" },
|
||||||
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 ──────────────────────────────────────────
|
||||||
rateType: "CONTAINER_IMPORT",
|
{ appliesTo: "INTERCITY", trigger: "ALWAYS", rateType: "INTERCITY_CONTAINER", containerTypeId: ctByCode.get("20FT")!.id, rateValue: 350, rateUnit: "PER_CONTAINER" },
|
||||||
containerTypeId: ctByCode.get("40FT")!.id,
|
{ appliesTo: "INTERCITY", trigger: "ALWAYS", rateType: "INTERCITY_CONTAINER", containerTypeId: ctByCode.get("40FT")!.id, rateValue: 550, rateUnit: "PER_CONTAINER" },
|
||||||
currency: "USD",
|
{ appliesTo: "INTERCITY", trigger: "ALWAYS", rateType: "INTERCITY_CONTAINER", containerTypeId: null, rateValue: 400, rateUnit: "PER_CONTAINER" },
|
||||||
rateValue: 1200,
|
{ appliesTo: "INTERCITY", trigger: "ALWAYS", rateType: "INTERCITY_BULK", rateValue: 35, rateUnit: "PER_TON" },
|
||||||
rateUnit: "PER_CONTAINER",
|
// ── 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" },
|
||||||
rateType: "CONTAINER_EXPORT",
|
{ appliesTo: "BULK", trigger: "ALWAYS", rateType: "BULK_IMPORT", tradeDirection: "IMPORT", cargoTypeId: null, rateValue: 50, rateUnit: "PER_TON" },
|
||||||
containerTypeId: ctByCode.get("20FT")!.id,
|
{ appliesTo: "BULK", trigger: "ALWAYS", rateType: "BULK_EXPORT", tradeDirection: "EXPORT", cargoTypeId: null, rateValue: 40, rateUnit: "PER_TON" },
|
||||||
currency: "USD",
|
// ── Surcharges (trigger-based) ──────────────────────────────────────
|
||||||
rateValue: 600,
|
{ appliesTo: "OTHER", trigger: "OVERWEIGHT", rateType: "OVERWEIGHT_PER_TON", rateValue: 25, rateUnit: "PER_TON" },
|
||||||
rateUnit: "PER_CONTAINER",
|
{ 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" },
|
||||||
rateType: "CONTAINER_EXPORT",
|
{ appliesTo: "OTHER", trigger: "CONSOLIDATION", rateType: "LASHING", rateValue: 50, rateUnit: "PER_CONTAINER" },
|
||||||
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",
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const entities = rateData.map((d) =>
|
// Idempotent: insert each canonical rate only if no row with the same
|
||||||
rRepo.create({
|
// 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,
|
...d,
|
||||||
status: "LIVE",
|
status: "LIVE" as const,
|
||||||
proposedByStaffId: STAFF_USER_ID,
|
proposedByStaffId: STAFF_USER_ID,
|
||||||
approvedByCeoId: CEO_USER_ID,
|
approvedByCeoId: CEO_USER_ID,
|
||||||
approvedAt: now,
|
approvedAt: now,
|
||||||
effectiveFrom,
|
effectiveFrom,
|
||||||
}),
|
}))
|
||||||
);
|
.filter((d) => !existingBySignature.has(signature(d)));
|
||||||
return rRepo.save(entities);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async seedSurchargeTypes(
|
if (toCreate.length === 0) {
|
||||||
manager: any,
|
this.logger.log("Rates already seeded — skipping (idempotent)");
|
||||||
ratesByType: Map<string, Rate[]>,
|
return existing;
|
||||||
): 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];
|
|
||||||
};
|
|
||||||
|
|
||||||
const hazardRateUsd = findRate("HAZARD_SURCHARGE", "USD");
|
const created = await rRepo.save(toCreate.map((d) => rRepo.create(d)));
|
||||||
const reeferRateUsd = findRate("REEFER_SURCHARGE", "USD");
|
this.logger.log(`Seeded ${created.length} new rate(s)`);
|
||||||
const overweightRateUsd = findRate("OVERWEIGHT_PER_TON", "USD");
|
return [...existing, ...created];
|
||||||
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");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async seedDraftBookings(
|
private async seedDraftBookings(
|
||||||
@@ -621,15 +506,29 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise<void> {
|
|||||||
slByCode: Map<string, any>,
|
slByCode: Map<string, any>,
|
||||||
cargoByCode: Map<string, any>,
|
cargoByCode: Map<string, any>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const djibouti = yardByCode.get("DJIBOUTI")!;
|
const djibouti = yardByCode.get("DJIBOUTI");
|
||||||
const addis = yardByCode.get("ADDIS_ABABA")!;
|
const addis = yardByCode.get("ADDIS_ABABA");
|
||||||
const railContainer = stByCode.get("RAIL_CONTAINER")!;
|
const railContainer = stByCode.get("RAIL_CONTAINER");
|
||||||
const railBulk = stByCode.get("RAIL_BULK")!;
|
const railBulk = stByCode.get("RAIL_BULK");
|
||||||
const maersk = slByCode.get("MAERSK")!;
|
const maersk = slByCode.get("MAERSK");
|
||||||
const grain = cargoByCode.get("GRAIN")!;
|
const grain = cargoByCode.get("GRAIN");
|
||||||
const twenty = ctByCode.get("20FT")!;
|
const twenty = ctByCode.get("20FT");
|
||||||
const forty = ctByCode.get("40FT")!;
|
const forty = ctByCode.get("40FT");
|
||||||
const twentyReefer = ctByCode.get("20FT_REEFER")!;
|
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 = [
|
const drafts = [
|
||||||
{
|
{
|
||||||
@@ -641,9 +540,7 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise<void> {
|
|||||||
serviceTypeId: railContainer.id,
|
serviceTypeId: railContainer.id,
|
||||||
originYardId: djibouti.id,
|
originYardId: djibouti.id,
|
||||||
destinationYardId: addis.id,
|
destinationYardId: addis.id,
|
||||||
isHazardous: false,
|
isHazardous: false, shippingLineId: null,
|
||||||
allowConsolidation: false,
|
|
||||||
shippingLineId: null,
|
|
||||||
cargoTypeId: null,
|
cargoTypeId: null,
|
||||||
cargoTotalWeightVgm: 250,
|
cargoTotalWeightVgm: 250,
|
||||||
containers: [
|
containers: [
|
||||||
@@ -661,9 +558,7 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise<void> {
|
|||||||
serviceTypeId: railContainer.id,
|
serviceTypeId: railContainer.id,
|
||||||
originYardId: djibouti.id,
|
originYardId: djibouti.id,
|
||||||
destinationYardId: addis.id,
|
destinationYardId: addis.id,
|
||||||
isHazardous: true,
|
isHazardous: true, shippingLineId: null,
|
||||||
allowConsolidation: false,
|
|
||||||
shippingLineId: null,
|
|
||||||
cargoTypeId: null,
|
cargoTypeId: null,
|
||||||
cargoTotalWeightVgm: 135,
|
cargoTotalWeightVgm: 135,
|
||||||
containers: [
|
containers: [
|
||||||
@@ -681,9 +576,7 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise<void> {
|
|||||||
serviceTypeId: railContainer.id,
|
serviceTypeId: railContainer.id,
|
||||||
originYardId: djibouti.id,
|
originYardId: djibouti.id,
|
||||||
destinationYardId: addis.id,
|
destinationYardId: addis.id,
|
||||||
isHazardous: false,
|
isHazardous: false, shippingLineId: maersk.id,
|
||||||
allowConsolidation: false,
|
|
||||||
shippingLineId: maersk.id,
|
|
||||||
cargoTypeId: null,
|
cargoTypeId: null,
|
||||||
cargoTotalWeightVgm: 480,
|
cargoTotalWeightVgm: 480,
|
||||||
containers: [
|
containers: [
|
||||||
@@ -701,9 +594,7 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise<void> {
|
|||||||
serviceTypeId: railContainer.id,
|
serviceTypeId: railContainer.id,
|
||||||
originYardId: djibouti.id,
|
originYardId: djibouti.id,
|
||||||
destinationYardId: addis.id,
|
destinationYardId: addis.id,
|
||||||
isHazardous: false,
|
isHazardous: false, shippingLineId: null,
|
||||||
allowConsolidation: true,
|
|
||||||
shippingLineId: null,
|
|
||||||
cargoTypeId: null,
|
cargoTypeId: null,
|
||||||
cargoTotalWeightVgm: 224,
|
cargoTotalWeightVgm: 224,
|
||||||
containers: [
|
containers: [
|
||||||
@@ -721,9 +612,7 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise<void> {
|
|||||||
serviceTypeId: railBulk.id,
|
serviceTypeId: railBulk.id,
|
||||||
originYardId: djibouti.id,
|
originYardId: djibouti.id,
|
||||||
destinationYardId: addis.id,
|
destinationYardId: addis.id,
|
||||||
isHazardous: false,
|
isHazardous: false, shippingLineId: null,
|
||||||
allowConsolidation: false,
|
|
||||||
shippingLineId: null,
|
|
||||||
cargoTypeId: grain.id,
|
cargoTypeId: grain.id,
|
||||||
cargoTotalWeightVgm: 500,
|
cargoTotalWeightVgm: 500,
|
||||||
containers: [],
|
containers: [],
|
||||||
@@ -739,9 +628,7 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise<void> {
|
|||||||
serviceTypeId: railContainer.id,
|
serviceTypeId: railContainer.id,
|
||||||
originYardId: djibouti.id,
|
originYardId: djibouti.id,
|
||||||
destinationYardId: addis.id,
|
destinationYardId: addis.id,
|
||||||
isHazardous: false,
|
isHazardous: false, shippingLineId: null,
|
||||||
allowConsolidation: false,
|
|
||||||
shippingLineId: null,
|
|
||||||
cargoTypeId: null,
|
cargoTypeId: null,
|
||||||
cargoTotalWeightVgm: 75,
|
cargoTotalWeightVgm: 75,
|
||||||
containers: [
|
containers: [
|
||||||
@@ -759,9 +646,7 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise<void> {
|
|||||||
serviceTypeId: railContainer.id,
|
serviceTypeId: railContainer.id,
|
||||||
originYardId: djibouti.id,
|
originYardId: djibouti.id,
|
||||||
destinationYardId: addis.id,
|
destinationYardId: addis.id,
|
||||||
isHazardous: false,
|
isHazardous: false, shippingLineId: null,
|
||||||
allowConsolidation: false,
|
|
||||||
shippingLineId: null,
|
|
||||||
cargoTypeId: null,
|
cargoTypeId: null,
|
||||||
cargoTotalWeightVgm: 300,
|
cargoTotalWeightVgm: 300,
|
||||||
containers: [
|
containers: [
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
Paperclip,
|
Paperclip,
|
||||||
Send,
|
Send,
|
||||||
Settings,
|
Settings,
|
||||||
|
ShieldCheck,
|
||||||
SlidersHorizontal,
|
SlidersHorizontal,
|
||||||
Train,
|
Train,
|
||||||
Truck,
|
Truck,
|
||||||
@@ -27,6 +28,7 @@ import LoginPage from "./pages/auth/LoginPage";
|
|||||||
import BookingContractPage from "./pages/bookings/BookingContractPage";
|
import BookingContractPage from "./pages/bookings/BookingContractPage";
|
||||||
import BookingRequestDetailPage from "./pages/bookings/BookingRequestDetailPage";
|
import BookingRequestDetailPage from "./pages/bookings/BookingRequestDetailPage";
|
||||||
import BookingRequestsPage from "./pages/bookings/BookingRequestsPage";
|
import BookingRequestsPage from "./pages/bookings/BookingRequestsPage";
|
||||||
|
import GlClearancePage from "./pages/bookings/GlClearancePage";
|
||||||
import NewBookingPage from "./pages/bookings/NewBookingPage";
|
import NewBookingPage from "./pages/bookings/NewBookingPage";
|
||||||
import CustomerDetailPage from "./pages/customers/CustomerDetailPage";
|
import CustomerDetailPage from "./pages/customers/CustomerDetailPage";
|
||||||
import CustomersPage from "./pages/customers/CustomersPage";
|
import CustomersPage from "./pages/customers/CustomersPage";
|
||||||
@@ -109,6 +111,12 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
|||||||
{
|
{
|
||||||
title: "Operations",
|
title: "Operations",
|
||||||
items: [
|
items: [
|
||||||
|
{
|
||||||
|
label: "Document Clearance",
|
||||||
|
href: "/dashboard/clearance",
|
||||||
|
icon: <ShieldCheck />,
|
||||||
|
permission: FREIGHT_PERMS.bookings.reviewDocuments,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: "Train Schedules",
|
label: "Train Schedules",
|
||||||
href: "/dashboard/operations/train-scheduling-v2",
|
href: "/dashboard/operations/train-scheduling-v2",
|
||||||
@@ -376,6 +384,14 @@ const App = () => {
|
|||||||
path="booking-requests/:id/contract"
|
path="booking-requests/:id/contract"
|
||||||
element={<BookingContractPage />}
|
element={<BookingContractPage />}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="clearance"
|
||||||
|
element={
|
||||||
|
<RequirePermission permission={FREIGHT_PERMS.bookings.reviewDocuments}>
|
||||||
|
<GlClearancePage />
|
||||||
|
</RequirePermission>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route path="warehouses" element={<WarehouseListPage />} />
|
<Route path="warehouses" element={<WarehouseListPage />} />
|
||||||
<Route path="warehouses/:id" element={<WarehouseDetailPage />} />
|
<Route path="warehouses/:id" element={<WarehouseDetailPage />} />
|
||||||
<Route path="warehouse-inventory" element={<WarehouseInventoryPage />} />
|
<Route path="warehouse-inventory" element={<WarehouseInventoryPage />} />
|
||||||
|
|||||||
@@ -8,10 +8,22 @@ import {
|
|||||||
Button,
|
Button,
|
||||||
Textarea,
|
Textarea,
|
||||||
FileInput,
|
FileInput,
|
||||||
|
NumberInput,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
|
|
||||||
import type { BookingActionDef } from "@/features/bookings/booking-actions.config";
|
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 {
|
interface BookingConfirmDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
@@ -46,8 +58,18 @@ export function BookingConfirmDialog({
|
|||||||
const Icon = action.icon;
|
const Icon = action.icon;
|
||||||
const needsTextInput = action.input === "note" || action.input === "reason";
|
const needsTextInput = action.input === "note" || action.input === "reason";
|
||||||
const needsFileInput = action.input === "file";
|
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 =
|
const inputMissing =
|
||||||
(needsTextInput && !inputValue.trim()) || (needsFileInput && !selectedFile);
|
(needsTextInput && !inputValue.trim()) ||
|
||||||
|
(needsFileInput && !selectedFile) ||
|
||||||
|
(needsDaysInput && !daysValid) ||
|
||||||
|
(needsAmountInput && !amountValid);
|
||||||
const isDestructive = action.variant === "destructive";
|
const isDestructive = action.variant === "destructive";
|
||||||
const accent = isDestructive ? "red" : "edr-green";
|
const accent = isDestructive ? "red" : "edr-green";
|
||||||
|
|
||||||
@@ -129,6 +151,40 @@ export function BookingConfirmDialog({
|
|||||||
clearable
|
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}
|
{extra}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
|
|||||||
@@ -1,50 +1,171 @@
|
|||||||
import { Banknote, Receipt } from "lucide-react";
|
import { useState } from "react";
|
||||||
import { Paper, Stack, Group, Text, Divider } from "@mantine/core";
|
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 type { BookingDetail } from "@/types/booking";
|
||||||
|
import { bookingsService } from "@/services/bookings.service";
|
||||||
|
|
||||||
import { SectionCard } from "./detail/SectionCard";
|
import { SectionCard } from "./detail/SectionCard";
|
||||||
import { detailStyles } from "./detail/booking-detail.styles";
|
import { detailStyles } from "./detail/booking-detail.styles";
|
||||||
|
|
||||||
export function BookingPricingSummary({ booking }: { booking: BookingDetail }) {
|
export function BookingPricingSummary({ booking }: { booking: BookingDetail }) {
|
||||||
const amount = Number(booking.totalAmount);
|
const qc = useQueryClient();
|
||||||
const modifiers = booking.cargoModifiers ?? [];
|
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 (
|
return (
|
||||||
<SectionCard icon={Banknote} title="Pricing & payment">
|
<SectionCard icon={Banknote} title="Pricing & payment">
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
<Paper radius="md" withBorder p="md" style={detailStyles.highlightCard}>
|
<Paper radius="md" withBorder p="md" style={detailStyles.highlightCard}>
|
||||||
<Text size="xs" c="dimmed" fw={500} tt="uppercase" lts="0.04em">
|
<Group justify="space-between" align="flex-start">
|
||||||
Total amount
|
<div>
|
||||||
</Text>
|
<Text size="xs" c="dimmed" fw={500} tt="uppercase" lts="0.04em">
|
||||||
<Text
|
{isAdjusted ? "Adjusted total" : "Total amount"}
|
||||||
size="xl"
|
</Text>
|
||||||
fw={700}
|
<Text
|
||||||
c="edr-green.9"
|
size="xl"
|
||||||
mt={4}
|
fw={700}
|
||||||
style={{ fontVariantNumeric: "tabular-nums", letterSpacing: "-0.5px" }}
|
c="edr-green.9"
|
||||||
>
|
mt={4}
|
||||||
{booking.paymentCurrency}{" "}
|
style={{ fontVariantNumeric: "tabular-nums", letterSpacing: "-0.5px" }}
|
||||||
{amount.toLocaleString(undefined, { minimumFractionDigits: 2 })}
|
>
|
||||||
</Text>
|
{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>
|
</Paper>
|
||||||
|
|
||||||
<Row label="Payment status" value={booking.paymentStatus} />
|
<Row label="Payment status" value={booking.paymentStatus} />
|
||||||
{booking.pnrCode && <Row label="PNR code" value={booking.pnrCode} mono />}
|
{booking.pnrCode && <Row label="PNR code" value={booking.pnrCode} mono />}
|
||||||
|
|
||||||
{modifiers.length > 0 && (
|
{lineItems.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<Divider color="var(--mantine-color-gray-2)" />
|
<Divider color="var(--mantine-color-gray-2)" />
|
||||||
<Group gap={6}>
|
<Group gap={6}>
|
||||||
<Receipt size={13} color="var(--mantine-color-gray-5)" />
|
<Receipt size={13} color="var(--mantine-color-gray-5)" />
|
||||||
<Text size="xs" c="dimmed" fw={500} tt="uppercase" lts="0.04em">
|
<Text size="xs" c="dimmed" fw={500} tt="uppercase" lts="0.04em">
|
||||||
Surcharges applied
|
Price breakdown
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
<Stack gap="xs">
|
<Stack gap="xs">
|
||||||
{modifiers.map((m) => (
|
{lineItems.map((li, i) => (
|
||||||
<Group
|
<Group
|
||||||
key={m.id}
|
key={`${li.code}-${i}`}
|
||||||
justify="space-between"
|
justify="space-between"
|
||||||
px="sm"
|
px="sm"
|
||||||
py={6}
|
py={6}
|
||||||
@@ -55,10 +176,10 @@ export function BookingPricingSummary({ booking }: { booking: BookingDetail }) {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
Modifier
|
{li.description}
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="sm" fw={600} style={{ fontVariantNumeric: "tabular-nums" }}>
|
<Text size="sm" fw={600} style={{ fontVariantNumeric: "tabular-nums" }}>
|
||||||
{Number(m.calculatedAmount).toLocaleString()}
|
{Number(li.amount).toLocaleString()} {li.currency}
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import { Badge } from "@mantine/core";
|
import { Badge } from "@mantine/core";
|
||||||
|
|
||||||
export function BookingPriorityBadge({ score }: { score: number }) {
|
export function BookingPriorityBadge({ score }: { score: number }) {
|
||||||
if (score >= 1000) {
|
if (score >= 70) {
|
||||||
return (
|
return (
|
||||||
<Badge color="red" variant="filled" size="sm" radius="lg" tt="uppercase">
|
<Badge color="red" variant="filled" size="sm" radius="lg" tt="uppercase">
|
||||||
Urgent
|
Urgent
|
||||||
</Badge>
|
</Badge>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (score >= 500) {
|
if (score >= 40) {
|
||||||
return (
|
return (
|
||||||
<Badge color="yellow" variant="filled" size="sm" radius="lg" tt="uppercase">
|
<Badge color="yellow" variant="filled" size="sm" radius="lg" tt="uppercase">
|
||||||
High
|
High
|
||||||
|
|||||||
@@ -9,6 +9,19 @@ import {
|
|||||||
import { useAuth } from "@/auth/useAuth";
|
import { useAuth } from "@/auth/useAuth";
|
||||||
import { useBookingDetail, useBookingMutations } from "@/hooks/bookings/useBookings";
|
import { useBookingDetail, useBookingMutations } from "@/hooks/bookings/useBookings";
|
||||||
|
|
||||||
|
/** A contract validity window must be a whole number of days, 1–365. */
|
||||||
|
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(
|
export function useBookingActionDialog(
|
||||||
bookingId: string,
|
bookingId: string,
|
||||||
context: BookingActionContext,
|
context: BookingActionContext,
|
||||||
@@ -59,15 +72,36 @@ export function useBookingActionDialog(
|
|||||||
const onSuccess = () => closeDialog();
|
const onSuccess = () => closeDialog();
|
||||||
|
|
||||||
switch (pendingAction.id) {
|
switch (pendingAction.id) {
|
||||||
case "accept":
|
case "accept": {
|
||||||
mutations.staffAccept.mutate(undefined, { onSuccess });
|
const days = Number(inputValue.trim());
|
||||||
|
if (!Number.isInteger(days) || days < 1 || days > 365) return;
|
||||||
|
mutations.staffAccept.mutate(days, { onSuccess });
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
case "requestChanges":
|
case "requestChanges":
|
||||||
mutations.requestChanges.mutate(inputValue.trim(), { onSuccess });
|
mutations.requestChanges.mutate(inputValue.trim(), { onSuccess });
|
||||||
break;
|
break;
|
||||||
case "reject":
|
case "reject":
|
||||||
mutations.staffReject.mutate(inputValue.trim(), { onSuccess });
|
mutations.staffReject.mutate(inputValue.trim(), { onSuccess });
|
||||||
break;
|
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": {
|
case "approve": {
|
||||||
const step = getNextPendingApprovalStep(mergedContext.approvalSteps);
|
const step = getNextPendingApprovalStep(mergedContext.approvalSteps);
|
||||||
if (!step) return;
|
if (!step) return;
|
||||||
@@ -116,7 +150,9 @@ export function useBookingActionDialog(
|
|||||||
!getNextPendingApprovalStep(mergedContext.approvalSteps)) ||
|
!getNextPendingApprovalStep(mergedContext.approvalSteps)) ||
|
||||||
(pendingAction?.input === "file" && !selectedFile) ||
|
(pendingAction?.input === "file" && !selectedFile) ||
|
||||||
(pendingAction?.input === "reason" && !inputValue.trim()) ||
|
(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 {
|
return {
|
||||||
actions,
|
actions,
|
||||||
|
|||||||
@@ -167,7 +167,7 @@ const ROUTE_META: Array<{ prefix: string; meta: PageMeta }> = [
|
|||||||
prefix: RULE_ENGINE_CATEGORY_BASE_PATH.configuration,
|
prefix: RULE_ENGINE_CATEGORY_BASE_PATH.configuration,
|
||||||
meta: {
|
meta: {
|
||||||
title: "Configuration",
|
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,
|
...configurationRouteMeta,
|
||||||
|
|||||||
@@ -158,11 +158,21 @@ const RuleEngineFormDialog = ({
|
|||||||
|
|
||||||
const visibleFields = useMemo(
|
const visibleFields = useMemo(
|
||||||
() =>
|
() =>
|
||||||
fields.filter(
|
fields.filter((field) => {
|
||||||
(field) =>
|
if (
|
||||||
!field.hideWhen ||
|
field.hideWhen &&
|
||||||
!field.hideWhen.equals.includes(String(values[field.hideWhen.field] ?? "")),
|
field.hideWhen.equals.includes(String(values[field.hideWhen.field] ?? ""))
|
||||||
),
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
field.showWhen &&
|
||||||
|
!field.showWhen.equals.includes(String(values[field.showWhen.field] ?? ""))
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}),
|
||||||
[fields, values],
|
[fields, values],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -244,6 +254,7 @@ const RuleEngineFormDialog = ({
|
|||||||
<Select
|
<Select
|
||||||
key={field.name}
|
key={field.name}
|
||||||
label={label}
|
label={label}
|
||||||
|
description={field.description}
|
||||||
placeholder={
|
placeholder={
|
||||||
selectOptionsLoading ? "Loading options..." : (field.placeholder ?? "Select an option")
|
selectOptionsLoading ? "Loading options..." : (field.placeholder ?? "Select an option")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -229,9 +229,6 @@ export const URL_CONSTANTS = {
|
|||||||
SERVICE_TYPES: "/service-types",
|
SERVICE_TYPES: "/service-types",
|
||||||
SERVICE_TYPE_BY_ID: (id: string) => `/service-types/${id}`,
|
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_RULES: "/weight-limit-rules",
|
||||||
WEIGHT_LIMIT_RULE_BY_ID: (id: string) => `/weight-limit-rules/${id}`,
|
WEIGHT_LIMIT_RULE_BY_ID: (id: string) => `/weight-limit-rules/${id}`,
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { LucideIcon } from "lucide-react";
|
|||||||
import {
|
import {
|
||||||
Ban,
|
Ban,
|
||||||
Check,
|
Check,
|
||||||
|
Coins,
|
||||||
FileSignature,
|
FileSignature,
|
||||||
MessageSquareWarning,
|
MessageSquareWarning,
|
||||||
Play,
|
Play,
|
||||||
@@ -34,9 +35,17 @@ export type BookingActionId =
|
|||||||
| "allocateBooking"
|
| "allocateBooking"
|
||||||
| "startTransit"
|
| "startTransit"
|
||||||
| "complete"
|
| "complete"
|
||||||
|
| "operationAccept"
|
||||||
|
| "operationRequestChanges"
|
||||||
|
| "operationAdjustPrice"
|
||||||
| "cancel";
|
| "cancel";
|
||||||
|
|
||||||
export type BookingActionInputKind = "note" | "reason" | "file";
|
export type BookingActionInputKind =
|
||||||
|
| "note"
|
||||||
|
| "reason"
|
||||||
|
| "file"
|
||||||
|
| "days"
|
||||||
|
| "amount";
|
||||||
|
|
||||||
export interface BookingActionDef {
|
export interface BookingActionDef {
|
||||||
id: BookingActionId;
|
id: BookingActionId;
|
||||||
@@ -121,10 +130,13 @@ const SUBMITTED_ACTIONS: BookingActionDef[] = [
|
|||||||
description: "Start the formal approval chain",
|
description: "Start the formal approval chain",
|
||||||
confirmTitle: "Accept submission?",
|
confirmTitle: "Accept submission?",
|
||||||
confirmDescription:
|
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",
|
variant: "default",
|
||||||
icon: ShieldCheck,
|
icon: ShieldCheck,
|
||||||
primary: true,
|
primary: true,
|
||||||
|
input: "days",
|
||||||
|
inputLabel: "Contract validity (days)",
|
||||||
|
inputPlaceholder: "e.g. 30",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "requestChanges",
|
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 = {
|
const CANCEL_ACTION: BookingActionDef = {
|
||||||
id: "cancel",
|
id: "cancel",
|
||||||
label: "Cancel booking",
|
label: "Cancel booking",
|
||||||
@@ -207,6 +263,9 @@ const ACTION_PERMISSION: Partial<Record<BookingActionId, string>> = {
|
|||||||
signContractStaff: FREIGHT_PERMS.bookings.signStaff,
|
signContractStaff: FREIGHT_PERMS.bookings.signStaff,
|
||||||
startTransit: FREIGHT_PERMS.bookings.operations,
|
startTransit: FREIGHT_PERMS.bookings.operations,
|
||||||
complete: 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,
|
allocateBooking: FREIGHT_PERMS.trainScheduling.manage,
|
||||||
cancel: FREIGHT_PERMS.bookings.cancel,
|
cancel: FREIGHT_PERMS.bookings.cancel,
|
||||||
};
|
};
|
||||||
@@ -300,6 +359,9 @@ export function getBookingActions(
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
break;
|
break;
|
||||||
|
case "OPERATION_REQUEST_PENDING":
|
||||||
|
actions = withCancel(OPERATION_REVIEW_ACTIONS);
|
||||||
|
break;
|
||||||
case "PAID":
|
case "PAID":
|
||||||
if (
|
if (
|
||||||
canAllocateBooking({ status, schedulingStatus: ctx.schedulingStatus })
|
canAllocateBooking({ status, schedulingStatus: ctx.schedulingStatus })
|
||||||
|
|||||||
@@ -86,6 +86,22 @@ export const BOOKING_STATUS_STYLES: Record<string, StatusStyle> = {
|
|||||||
label: "Consolidated",
|
label: "Consolidated",
|
||||||
color: "bg-indigo-50 text-indigo-700 border-indigo-200",
|
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 {
|
export interface StatusMeta {
|
||||||
@@ -257,10 +273,19 @@ export const BOOKING_LIST_TABS = [
|
|||||||
"EXPIRED",
|
"EXPIRED",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "ops_review",
|
||||||
|
label: "Ops review",
|
||||||
|
statuses: [
|
||||||
|
"OPERATION_REQUEST_PENDING",
|
||||||
|
"OPERATION_CHANGES_REQUESTED",
|
||||||
|
"OPERATION_PRICE_PENDING_CONFIRM",
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: "operations",
|
key: "operations",
|
||||||
label: "Operations",
|
label: "Operations",
|
||||||
statuses: ["PAID", "IN_TRANSIT"],
|
statuses: ["PAID", "IN_TRANSIT", "ROAD_DISPATCH_PENDING"],
|
||||||
},
|
},
|
||||||
{ key: "completed", label: "Completed", statuses: ["COMPLETED"] },
|
{ key: "completed", label: "Completed", statuses: ["COMPLETED"] },
|
||||||
{ key: "closed", label: "Closed", statuses: ["REJECTED", "CANCELLED"] },
|
{ key: "closed", label: "Closed", statuses: ["REJECTED", "CANCELLED"] },
|
||||||
|
|||||||
@@ -41,7 +41,8 @@ export function useBookingMutations(bookingId: string) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const staffAccept = useMutation({
|
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"),
|
onSuccess: (data) => onSuccess(data, "Booking accepted for approval"),
|
||||||
onError: () => toast.error("Failed to accept booking"),
|
onError: () => toast.error("Failed to accept booking"),
|
||||||
});
|
});
|
||||||
@@ -60,6 +61,16 @@ export function useBookingMutations(bookingId: string) {
|
|||||||
onError: () => toast.error("Failed to reject booking"),
|
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({
|
const approveStep = useMutation({
|
||||||
mutationFn: ({
|
mutationFn: ({
|
||||||
stepId,
|
stepId,
|
||||||
@@ -147,12 +158,14 @@ export function useBookingMutations(bookingId: string) {
|
|||||||
payBooking.isPending ||
|
payBooking.isPending ||
|
||||||
startTransit.isPending ||
|
startTransit.isPending ||
|
||||||
complete.isPending ||
|
complete.isPending ||
|
||||||
|
reviewOperation.isPending ||
|
||||||
cancel.isPending;
|
cancel.isPending;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
staffAccept,
|
staffAccept,
|
||||||
requestChanges,
|
requestChanges,
|
||||||
staffReject,
|
staffReject,
|
||||||
|
reviewOperation,
|
||||||
approveStep,
|
approveStep,
|
||||||
rejectStep,
|
rejectStep,
|
||||||
generateContract,
|
generateContract,
|
||||||
|
|||||||
@@ -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(
|
export function buildContainerTypeSelectOptions(
|
||||||
rows: RuleEngineRecord[],
|
rows: RuleEngineRecord[],
|
||||||
includeNone: boolean,
|
includeNone: boolean,
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ export const FREIGHT_PERMS = {
|
|||||||
signStaff: "edr_freight_app:bookings:sign_staff",
|
signStaff: "edr_freight_app:bookings:sign_staff",
|
||||||
operations: "edr_freight_app:bookings:operations",
|
operations: "edr_freight_app:bookings:operations",
|
||||||
cancel: "edr_freight_app:bookings:cancel",
|
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: {
|
trainScheduling: {
|
||||||
view: "edr_freight_app:train_scheduling:view",
|
view: "edr_freight_app:train_scheduling:view",
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
import { type FormEvent, useState } from "react";
|
import { type FormEvent, useState } from "react";
|
||||||
import { parsePhoneNumberFromString } from "libphonenumber-js";
|
|
||||||
import {
|
import {
|
||||||
Eye,
|
Eye,
|
||||||
EyeOff,
|
EyeOff,
|
||||||
Mail,
|
|
||||||
Smartphone,
|
|
||||||
UserRound,
|
|
||||||
ArrowUpRight,
|
ArrowUpRight,
|
||||||
Globe,
|
Globe,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
@@ -14,65 +10,15 @@ import { useNavigate } from "react-router-dom";
|
|||||||
|
|
||||||
import { useAuth } from "@/auth/useAuth";
|
import { useAuth } from "@/auth/useAuth";
|
||||||
|
|
||||||
type LoginMode = "email" | "phone" | "username";
|
/** Normalise Ethiopian local phone (09…/07…) to E.164; pass email through unchanged. */
|
||||||
|
const normaliseIdentifier = (raw: string): string => {
|
||||||
const loginModes: Array<{
|
const v = raw.trim();
|
||||||
value: LoginMode;
|
const digits = v.replace(/\D/g, "");
|
||||||
label: string;
|
if (digits.length >= 9 && (v.startsWith("0") || v.startsWith("+251"))) {
|
||||||
icon: typeof Mail;
|
const local = digits.startsWith("251") ? digits.slice(3) : digits.replace(/^0/, "");
|
||||||
placeholder: string;
|
return `+251${local}`;
|
||||||
}> = [
|
|
||||||
{
|
|
||||||
value: "email",
|
|
||||||
label: "Email",
|
|
||||||
icon: Mail,
|
|
||||||
placeholder: "name@company.com",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "phone",
|
|
||||||
label: "Phone",
|
|
||||||
icon: Smartphone,
|
|
||||||
placeholder: "09XXXXXXXX",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "username",
|
|
||||||
label: "Username",
|
|
||||||
icon: UserRound,
|
|
||||||
placeholder: "username",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
||||||
const usernamePattern = /^[a-zA-Z0-9._-]{3,32}$/;
|
|
||||||
|
|
||||||
const normalizeIdentifier = (mode: LoginMode, value: string) => {
|
|
||||||
const trimmed = value.trim();
|
|
||||||
|
|
||||||
if (mode === "email") {
|
|
||||||
if (!emailPattern.test(trimmed.toLowerCase())) {
|
|
||||||
throw new Error("Enter a valid email address.");
|
|
||||||
}
|
|
||||||
|
|
||||||
return trimmed.toLowerCase();
|
|
||||||
}
|
}
|
||||||
|
return v.toLowerCase();
|
||||||
if (mode === "phone") {
|
|
||||||
const parsed = parsePhoneNumberFromString(trimmed, "ET");
|
|
||||||
|
|
||||||
if (!parsed?.isValid()) {
|
|
||||||
throw new Error("Enter a valid Ethiopian phone number.");
|
|
||||||
}
|
|
||||||
|
|
||||||
return parsed.number;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!usernamePattern.test(trimmed)) {
|
|
||||||
throw new Error(
|
|
||||||
"Username must be 3-32 characters and use letters, numbers, ., _, or -.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return trimmed;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const LOGIN_IMAGE = "/assets/login.png";
|
const LOGIN_IMAGE = "/assets/login.png";
|
||||||
@@ -214,7 +160,6 @@ const FormFooter = () => (
|
|||||||
const LoginPage = () => {
|
const LoginPage = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { login, verifyMfa } = useAuth();
|
const { login, verifyMfa } = useAuth();
|
||||||
const [mode, setMode] = useState<LoginMode>("email");
|
|
||||||
const [identifier, setIdentifier] = useState("");
|
const [identifier, setIdentifier] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [otp, setOtp] = useState("");
|
const [otp, setOtp] = useState("");
|
||||||
@@ -224,15 +169,13 @@ const LoginPage = () => {
|
|||||||
const [normalizedIdentifier, setNormalizedIdentifier] = useState("");
|
const [normalizedIdentifier, setNormalizedIdentifier] = useState("");
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const currentMode = loginModes.find((item) => item.value === mode)!;
|
|
||||||
|
|
||||||
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const normalized = normalizeIdentifier(mode, identifier);
|
const normalized = normaliseIdentifier(identifier);
|
||||||
setNormalizedIdentifier(normalized);
|
setNormalizedIdentifier(normalized);
|
||||||
|
|
||||||
const result = await login({ email: normalized, password });
|
const result = await login({ email: normalized, password });
|
||||||
@@ -284,32 +227,14 @@ const LoginPage = () => {
|
|||||||
<div className="flex w-full flex-col gap-4">
|
<div className="flex w-full flex-col gap-4">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="text-sm font-medium text-gray-800">
|
<label className="text-sm font-medium text-gray-800">
|
||||||
Sign in method
|
Email or Phone <span className="text-red-500">*</span>
|
||||||
</label>
|
|
||||||
<div className="relative">
|
|
||||||
<select
|
|
||||||
value={mode}
|
|
||||||
onChange={(event) => setMode(event.target.value as LoginMode)}
|
|
||||||
className={`${fieldClass} appearance-none pr-10`}
|
|
||||||
>
|
|
||||||
{loginModes.map((item) => (
|
|
||||||
<option key={item.value} value={item.value}>
|
|
||||||
{item.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<ChevronDown className="pointer-events-none absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<label className="text-sm font-medium text-gray-800">
|
|
||||||
{currentMode.label} <span className="text-red-500">*</span>
|
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
|
type="text"
|
||||||
value={identifier}
|
value={identifier}
|
||||||
onChange={(event) => setIdentifier(event.target.value)}
|
onChange={(event) => setIdentifier(event.target.value)}
|
||||||
placeholder={currentMode.placeholder}
|
placeholder="name@company.com or 09XXXXXXXX"
|
||||||
|
autoComplete="username"
|
||||||
className={fieldClass}
|
className={fieldClass}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,765 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Badge,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
FileButton,
|
||||||
|
Group,
|
||||||
|
Loader,
|
||||||
|
Progress,
|
||||||
|
ScrollArea,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
Textarea,
|
||||||
|
ThemeIcon,
|
||||||
|
Tooltip,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import {
|
||||||
|
AlertCircle,
|
||||||
|
CheckCircle2,
|
||||||
|
Clock,
|
||||||
|
Download,
|
||||||
|
ExternalLink,
|
||||||
|
FileText,
|
||||||
|
Inbox,
|
||||||
|
MessageSquareWarning,
|
||||||
|
Search,
|
||||||
|
ShieldCheck,
|
||||||
|
Upload,
|
||||||
|
X,
|
||||||
|
} from "lucide-react";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
import type { Freight } from "@edr/types";
|
||||||
|
|
||||||
|
import { PageContainer } from "@/components/page/PageContainer";
|
||||||
|
import { PageHeader } from "@/components/page/PageHeader";
|
||||||
|
import { bookingsService } from "@/services/bookings.service";
|
||||||
|
|
||||||
|
const REVIEW_STATUS = "DOCUMENTS_UNDER_REVIEW";
|
||||||
|
|
||||||
|
export default function GlClearancePage() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
|
||||||
|
// Bookings currently awaiting GL document review.
|
||||||
|
const { data: list, isLoading } = useQuery({
|
||||||
|
queryKey: ["gl-clearance", "list"],
|
||||||
|
queryFn: () =>
|
||||||
|
bookingsService.list({ status: REVIEW_STATUS, pageSize: 100 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const bookings = list?.items ?? [];
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const q = search.trim().toLowerCase();
|
||||||
|
if (!q) return bookings;
|
||||||
|
return bookings.filter(
|
||||||
|
(b) =>
|
||||||
|
b.reference?.toLowerCase().includes(q) ||
|
||||||
|
b.tradeDirection?.toLowerCase().includes(q) ||
|
||||||
|
b.freightType?.toLowerCase().includes(q),
|
||||||
|
);
|
||||||
|
}, [bookings, search]);
|
||||||
|
|
||||||
|
const activeId =
|
||||||
|
selectedId && filtered.some((b) => b.id === selectedId)
|
||||||
|
? selectedId
|
||||||
|
: (filtered[0]?.id ?? null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageContainer>
|
||||||
|
<PageHeader
|
||||||
|
title="Document Clearance"
|
||||||
|
subtitle="Review customer documents, approve or raise a query, and finalize clearance."
|
||||||
|
meta={
|
||||||
|
<Badge
|
||||||
|
variant="light"
|
||||||
|
color="edr-green"
|
||||||
|
radius="sm"
|
||||||
|
leftSection={<ShieldCheck size={13} />}
|
||||||
|
>
|
||||||
|
{bookings.length} awaiting review
|
||||||
|
</Badge>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-5 lg:flex-row lg:items-start">
|
||||||
|
{/* ── Review queue ─────────────────────────────────────────────── */}
|
||||||
|
<Card
|
||||||
|
withBorder
|
||||||
|
shadow="sm"
|
||||||
|
radius="lg"
|
||||||
|
p="sm"
|
||||||
|
className="w-full shrink-0 lg:w-[320px]"
|
||||||
|
>
|
||||||
|
<Group justify="space-between" align="center" mb="xs" px={4}>
|
||||||
|
<Text fz="13px" fw={700} c="edr-text">
|
||||||
|
Review queue
|
||||||
|
</Text>
|
||||||
|
<Badge size="sm" variant="default" radius="sm">
|
||||||
|
{filtered.length}
|
||||||
|
</Badge>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||||
|
placeholder="Search reference…"
|
||||||
|
size="xs"
|
||||||
|
radius="md"
|
||||||
|
mb="xs"
|
||||||
|
leftSection={<Search size={14} />}
|
||||||
|
rightSection={
|
||||||
|
search ? (
|
||||||
|
<X
|
||||||
|
size={14}
|
||||||
|
style={{ cursor: "pointer" }}
|
||||||
|
onClick={() => setSearch("")}
|
||||||
|
/>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<Group justify="center" py="lg" gap={8}>
|
||||||
|
<Loader size="xs" color="edr-green" />
|
||||||
|
<Text fz="13px" c="dimmed">
|
||||||
|
Loading…
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
) : filtered.length === 0 ? (
|
||||||
|
<Stack align="center" gap={6} py="xl">
|
||||||
|
<ThemeIcon variant="light" color="gray" radius="xl" size={40}>
|
||||||
|
<Inbox size={20} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Text fz="13px" c="dimmed" ta="center">
|
||||||
|
{search
|
||||||
|
? "No bookings match your search."
|
||||||
|
: "Nothing awaiting document review."}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
) : (
|
||||||
|
<ScrollArea.Autosize mah={620} type="hover" offsetScrollbars>
|
||||||
|
<Stack gap={6}>
|
||||||
|
{filtered.map((b) => (
|
||||||
|
<QueueItem
|
||||||
|
key={b.id}
|
||||||
|
booking={b}
|
||||||
|
active={b.id === activeId}
|
||||||
|
onSelect={() => setSelectedId(b.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</ScrollArea.Autosize>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* ── Review panel ─────────────────────────────────────────────── */}
|
||||||
|
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
{activeId ? (
|
||||||
|
<ClearanceReviewPanel
|
||||||
|
key={activeId}
|
||||||
|
bookingId={activeId}
|
||||||
|
onChanged={() =>
|
||||||
|
qc.invalidateQueries({ queryKey: ["gl-clearance", "list"] })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<EmptyPanel />
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</div>
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A single booking row in the left-hand review queue. */
|
||||||
|
function QueueItem({
|
||||||
|
booking,
|
||||||
|
active,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
booking: Freight.IBooking;
|
||||||
|
active: boolean;
|
||||||
|
onSelect: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
component="button"
|
||||||
|
type="button"
|
||||||
|
onClick={onSelect}
|
||||||
|
ta="left"
|
||||||
|
p="xs"
|
||||||
|
style={{
|
||||||
|
cursor: "pointer",
|
||||||
|
borderRadius: 12,
|
||||||
|
border: "1px solid",
|
||||||
|
borderColor: active
|
||||||
|
? "var(--mantine-color-edr-green-5)"
|
||||||
|
: "var(--mantine-color-edr-border-6)",
|
||||||
|
background: active
|
||||||
|
? "var(--mantine-color-edr-green-0)"
|
||||||
|
: "var(--mantine-color-edr-card-6)",
|
||||||
|
transition: "all 120ms ease",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Group justify="space-between" wrap="nowrap" gap={8}>
|
||||||
|
<Box style={{ minWidth: 0 }}>
|
||||||
|
<Text fz="13.5px" fw={700} c="edr-text" truncate>
|
||||||
|
{booking.reference}
|
||||||
|
</Text>
|
||||||
|
<Group gap={6} mt={3} wrap="nowrap">
|
||||||
|
<Badge
|
||||||
|
size="xs"
|
||||||
|
variant="light"
|
||||||
|
radius="sm"
|
||||||
|
color={
|
||||||
|
booking.tradeDirection === "IMPORT" ? "edr-blue" : "edr-accent"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{booking.tradeDirection}
|
||||||
|
</Badge>
|
||||||
|
<Text fz="11px" c="edr-muted" truncate>
|
||||||
|
{booking.freightType}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
</Box>
|
||||||
|
</Group>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyPanel() {
|
||||||
|
return (
|
||||||
|
<Card withBorder shadow="sm" radius="lg" p={48}>
|
||||||
|
<Stack align="center" gap={10}>
|
||||||
|
<ThemeIcon variant="light" color="edr-green" radius="xl" size={56}>
|
||||||
|
<ShieldCheck size={28} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Text fw={700} c="edr-text">
|
||||||
|
No booking selected
|
||||||
|
</Text>
|
||||||
|
<Text fz="13px" c="dimmed" ta="center" maw={320}>
|
||||||
|
Pick a booking from the review queue to inspect its customer documents
|
||||||
|
and start clearance.
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ClearanceReviewPanel({
|
||||||
|
bookingId,
|
||||||
|
onChanged,
|
||||||
|
}: {
|
||||||
|
bookingId: string;
|
||||||
|
onChanged: () => void;
|
||||||
|
}) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const [queryNotes, setQueryNotes] = useState<Record<string, string>>({});
|
||||||
|
const [openQuery, setOpenQuery] = useState<Record<string, boolean>>({});
|
||||||
|
const [outputFiles, setOutputFiles] = useState<Record<string, File>>({});
|
||||||
|
|
||||||
|
const { data: clearance, isLoading } = useQuery({
|
||||||
|
queryKey: ["gl-clearance", bookingId],
|
||||||
|
queryFn: () => bookingsService.getClearance(bookingId),
|
||||||
|
});
|
||||||
|
|
||||||
|
const refresh = () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["gl-clearance", bookingId] });
|
||||||
|
onChanged();
|
||||||
|
};
|
||||||
|
|
||||||
|
const reviewMutation = useMutation({
|
||||||
|
mutationFn: (p: {
|
||||||
|
fileKey: string;
|
||||||
|
status: "APPROVED" | "QUERIED";
|
||||||
|
note?: string;
|
||||||
|
}) => bookingsService.reviewClearanceDocument(bookingId, p),
|
||||||
|
onSuccess: (_d, p) => {
|
||||||
|
toast.success(
|
||||||
|
p.status === "APPROVED" ? "Document approved" : "Query sent to customer",
|
||||||
|
);
|
||||||
|
if (p.status === "QUERIED")
|
||||||
|
setOpenQuery((o) => ({ ...o, [p.fileKey]: false }));
|
||||||
|
refresh();
|
||||||
|
},
|
||||||
|
onError: () => toast.error("Could not update document"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const outputMutation = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
bookingsService.uploadClearanceOutput(bookingId, outputFiles),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Output documents uploaded");
|
||||||
|
setOutputFiles({});
|
||||||
|
refresh();
|
||||||
|
},
|
||||||
|
onError: () => toast.error("Upload failed"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const finalizeMutation = useMutation({
|
||||||
|
mutationFn: () => bookingsService.finalizeClearance(bookingId),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Clearance finalized");
|
||||||
|
refresh();
|
||||||
|
},
|
||||||
|
onError: (e) =>
|
||||||
|
toast.error(
|
||||||
|
e instanceof Error ? e.message : "Could not finalize clearance",
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
const customerDocs = useMemo(
|
||||||
|
() => (clearance?.documents ?? []).filter((d) => d.uploadedBy === "customer"),
|
||||||
|
[clearance],
|
||||||
|
);
|
||||||
|
const glDocs = useMemo(
|
||||||
|
() => (clearance?.documents ?? []).filter((d) => d.uploadedBy === "gl"),
|
||||||
|
[clearance],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Review progress across the customer documents — drives the summary bar.
|
||||||
|
const stats = useMemo(() => {
|
||||||
|
const total = customerDocs.length;
|
||||||
|
const approved = customerDocs.filter(
|
||||||
|
(d) => d.reviewStatus === "APPROVED",
|
||||||
|
).length;
|
||||||
|
const queried = customerDocs.filter(
|
||||||
|
(d) => d.reviewStatus === "QUERIED",
|
||||||
|
).length;
|
||||||
|
const pending = total - approved - queried;
|
||||||
|
return { total, approved, queried, pending };
|
||||||
|
}, [customerDocs]);
|
||||||
|
|
||||||
|
if (isLoading || !clearance) {
|
||||||
|
return (
|
||||||
|
<Card withBorder shadow="sm" radius="lg" p={48}>
|
||||||
|
<Group justify="center" gap={10}>
|
||||||
|
<Loader size="sm" color="edr-green" />
|
||||||
|
<Text c="dimmed">Loading clearance…</Text>
|
||||||
|
</Group>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const progressPct =
|
||||||
|
stats.total === 0 ? 0 : Math.round((stats.approved / stats.total) * 100);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="md">
|
||||||
|
{/* ── Progress summary ───────────────────────────────────────────── */}
|
||||||
|
<Card withBorder shadow="sm" radius="lg" p="lg">
|
||||||
|
<Group justify="space-between" align="flex-start" mb="md">
|
||||||
|
<Box>
|
||||||
|
<Text fw={700} fz="15px" c="edr-text">
|
||||||
|
Customer documents
|
||||||
|
</Text>
|
||||||
|
<Text fz="12.5px" c="dimmed" mt={2}>
|
||||||
|
Approve each document, or open a query to tell the customer what to
|
||||||
|
fix.
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
{clearance.allApproved ? (
|
||||||
|
<Badge
|
||||||
|
variant="light"
|
||||||
|
color="edr-green"
|
||||||
|
radius="sm"
|
||||||
|
size="lg"
|
||||||
|
leftSection={<CheckCircle2 size={14} />}
|
||||||
|
>
|
||||||
|
All approved
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge
|
||||||
|
variant="light"
|
||||||
|
color="edr-blue"
|
||||||
|
radius="sm"
|
||||||
|
size="lg"
|
||||||
|
leftSection={<Clock size={14} />}
|
||||||
|
>
|
||||||
|
Review pending
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Progress
|
||||||
|
value={progressPct}
|
||||||
|
color="edr-green"
|
||||||
|
radius="xl"
|
||||||
|
size="sm"
|
||||||
|
mb="sm"
|
||||||
|
/>
|
||||||
|
<Group gap="lg">
|
||||||
|
<StatPill color="edr-green" label="Approved" value={stats.approved} />
|
||||||
|
<StatPill color="red" label="Queried" value={stats.queried} />
|
||||||
|
<StatPill color="edr-slate" label="Pending" value={stats.pending} />
|
||||||
|
<Text fz="12.5px" c="dimmed" ml="auto">
|
||||||
|
{stats.approved}/{stats.total} approved
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* ── Document review list ───────────────────────────────────────── */}
|
||||||
|
<Stack gap={12}>
|
||||||
|
{customerDocs.map((doc) => (
|
||||||
|
<DocReviewCard
|
||||||
|
key={`${doc.settingCode}:${doc.fileKey}`}
|
||||||
|
doc={doc}
|
||||||
|
note={queryNotes[doc.fileKey] ?? ""}
|
||||||
|
queryOpen={openQuery[doc.fileKey] ?? false}
|
||||||
|
onToggleQuery={(open) =>
|
||||||
|
setOpenQuery((o) => ({ ...o, [doc.fileKey]: open }))
|
||||||
|
}
|
||||||
|
onNote={(v) => setQueryNotes((n) => ({ ...n, [doc.fileKey]: v }))}
|
||||||
|
onApprove={() =>
|
||||||
|
reviewMutation.mutate({ fileKey: doc.fileKey, status: "APPROVED" })
|
||||||
|
}
|
||||||
|
onQuery={() =>
|
||||||
|
reviewMutation.mutate({
|
||||||
|
fileKey: doc.fileKey,
|
||||||
|
status: "QUERIED",
|
||||||
|
note: queryNotes[doc.fileKey],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
busy={reviewMutation.isPending}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{/* ── Customs output documents (GL-supplied) ─────────────────────── */}
|
||||||
|
{clearance.outputCode && (
|
||||||
|
<Card withBorder shadow="sm" radius="lg" p="lg">
|
||||||
|
<Group gap={8} mb="md">
|
||||||
|
<ThemeIcon variant="light" color="edr-blue" radius="md" size={28}>
|
||||||
|
<Upload size={15} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Text fw={700} c="edr-text">
|
||||||
|
Customs output documents
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<Stack gap={10}>
|
||||||
|
{glDocs.map((doc) => (
|
||||||
|
<Group key={doc.fileKey} justify="space-between" wrap="nowrap">
|
||||||
|
<Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||||
|
<FileText size={16} color="var(--mantine-color-edr-blue-6)" />
|
||||||
|
<Text fz="13px" c="edr-text" truncate>
|
||||||
|
{doc.label}
|
||||||
|
{doc.required ? " *" : ""}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<Group gap={8} wrap="nowrap">
|
||||||
|
{doc.file ? (
|
||||||
|
<Tooltip label="Download">
|
||||||
|
<Box
|
||||||
|
component="a"
|
||||||
|
href={doc.file.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
c="edr-blue"
|
||||||
|
style={{ display: "flex" }}
|
||||||
|
>
|
||||||
|
<Download size={15} />
|
||||||
|
</Box>
|
||||||
|
</Tooltip>
|
||||||
|
) : (
|
||||||
|
<Text fz="12px" c="edr-muted">
|
||||||
|
Not uploaded
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<FileButton
|
||||||
|
onChange={(f) =>
|
||||||
|
f && setOutputFiles((o) => ({ ...o, [doc.fileKey]: f }))
|
||||||
|
}
|
||||||
|
accept="application/pdf,image/*"
|
||||||
|
>
|
||||||
|
{(props) => (
|
||||||
|
<Button
|
||||||
|
{...props}
|
||||||
|
size="compact-xs"
|
||||||
|
variant="light"
|
||||||
|
color="edr-green"
|
||||||
|
leftSection={<Upload size={13} />}
|
||||||
|
>
|
||||||
|
{outputFiles[doc.fileKey] ? "Selected" : "Upload"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</FileButton>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
<Group justify="flex-end" mt="md">
|
||||||
|
<Button
|
||||||
|
variant="light"
|
||||||
|
color="edr-green"
|
||||||
|
radius="md"
|
||||||
|
leftSection={<Upload size={15} />}
|
||||||
|
disabled={Object.keys(outputFiles).length === 0}
|
||||||
|
loading={outputMutation.isPending}
|
||||||
|
onClick={() => outputMutation.mutate()}
|
||||||
|
>
|
||||||
|
Upload output documents
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{finalizeMutation.isError && (
|
||||||
|
<Alert color="red" radius="md" icon={<AlertCircle size={16} />}>
|
||||||
|
{finalizeMutation.error instanceof Error
|
||||||
|
? finalizeMutation.error.message
|
||||||
|
: "Could not finalize clearance."}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Finalize bar ───────────────────────────────────────────────── */}
|
||||||
|
<Card withBorder shadow="sm" radius="lg" p="md">
|
||||||
|
<Group justify="space-between" wrap="nowrap">
|
||||||
|
<Text fz="12.5px" c="dimmed">
|
||||||
|
{clearance.allApproved
|
||||||
|
? "All required documents are approved. You can finalize clearance."
|
||||||
|
: "Approve every required document to unlock finalization."}
|
||||||
|
</Text>
|
||||||
|
<Button
|
||||||
|
color="edr-green"
|
||||||
|
radius="md"
|
||||||
|
leftSection={<CheckCircle2 size={16} />}
|
||||||
|
disabled={!clearance.allApproved}
|
||||||
|
loading={finalizeMutation.isPending}
|
||||||
|
onClick={() => finalizeMutation.mutate()}
|
||||||
|
>
|
||||||
|
Finalize clearance
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Card>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatPill({
|
||||||
|
color,
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
}: {
|
||||||
|
color: string;
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Group gap={6} wrap="nowrap">
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
borderRadius: 999,
|
||||||
|
background: `var(--mantine-color-${color}-6)`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Text fz="12.5px" c="edr-text" fw={600}>
|
||||||
|
{value}
|
||||||
|
</Text>
|
||||||
|
<Text fz="12.5px" c="dimmed">
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Visual treatment for each document review state. */
|
||||||
|
const STATUS_META: Record<
|
||||||
|
Freight.DocumentReviewStatus,
|
||||||
|
{ label: string; color: string }
|
||||||
|
> = {
|
||||||
|
APPROVED: { label: "Approved", color: "edr-green" },
|
||||||
|
QUERIED: { label: "Queried", color: "red" },
|
||||||
|
PENDING: { label: "Pending", color: "edr-slate" },
|
||||||
|
};
|
||||||
|
|
||||||
|
function DocReviewCard({
|
||||||
|
doc,
|
||||||
|
note,
|
||||||
|
queryOpen,
|
||||||
|
onToggleQuery,
|
||||||
|
onNote,
|
||||||
|
onApprove,
|
||||||
|
onQuery,
|
||||||
|
busy,
|
||||||
|
}: {
|
||||||
|
doc: Freight.ClearanceDocument;
|
||||||
|
note: string;
|
||||||
|
queryOpen: boolean;
|
||||||
|
onToggleQuery: (open: boolean) => void;
|
||||||
|
onNote: (v: string) => void;
|
||||||
|
onApprove: () => void;
|
||||||
|
onQuery: () => void;
|
||||||
|
busy: boolean;
|
||||||
|
}) {
|
||||||
|
const status = doc.reviewStatus ?? "PENDING";
|
||||||
|
const meta = STATUS_META[status];
|
||||||
|
const hasFile = !!doc.file;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
withBorder
|
||||||
|
shadow="sm"
|
||||||
|
radius="lg"
|
||||||
|
p="md"
|
||||||
|
style={{
|
||||||
|
borderColor:
|
||||||
|
status === "QUERIED"
|
||||||
|
? "var(--mantine-color-red-2)"
|
||||||
|
: status === "APPROVED"
|
||||||
|
? "var(--mantine-color-edr-green-2)"
|
||||||
|
: "var(--mantine-color-edr-border-6)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
||||||
|
<Group gap={12} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||||
|
<ThemeIcon
|
||||||
|
variant="light"
|
||||||
|
color={hasFile ? "edr-blue" : "gray"}
|
||||||
|
radius="md"
|
||||||
|
size={40}
|
||||||
|
>
|
||||||
|
<FileText size={19} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Box style={{ minWidth: 0 }}>
|
||||||
|
<Text fz="14px" fw={700} c="edr-text" truncate>
|
||||||
|
{doc.label}
|
||||||
|
{doc.required ? " *" : ""}
|
||||||
|
</Text>
|
||||||
|
<Text fz="12px" c="edr-muted" truncate>
|
||||||
|
{hasFile ? doc.file!.name : "Not uploaded by customer"}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Group gap={8} wrap="nowrap">
|
||||||
|
<Badge variant="light" color={meta.color} radius="sm">
|
||||||
|
{meta.label}
|
||||||
|
</Badge>
|
||||||
|
{hasFile && (
|
||||||
|
<Tooltip label="Open document">
|
||||||
|
<Button
|
||||||
|
component="a"
|
||||||
|
href={doc.file!.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
size="compact-xs"
|
||||||
|
variant="default"
|
||||||
|
radius="md"
|
||||||
|
leftSection={<ExternalLink size={13} />}
|
||||||
|
>
|
||||||
|
View
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{/* Previously raised query — visible so staff see what was asked. */}
|
||||||
|
{status === "QUERIED" && doc.note && (
|
||||||
|
<Alert
|
||||||
|
mt="sm"
|
||||||
|
color="red"
|
||||||
|
variant="light"
|
||||||
|
radius="md"
|
||||||
|
icon={<MessageSquareWarning size={15} />}
|
||||||
|
p="xs"
|
||||||
|
>
|
||||||
|
<Text fz="12.5px" c="red.9">
|
||||||
|
{doc.note}
|
||||||
|
</Text>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Action row — only when the customer actually uploaded a file. */}
|
||||||
|
{hasFile && (
|
||||||
|
<Box mt="sm">
|
||||||
|
{!queryOpen ? (
|
||||||
|
<Group justify="flex-end" gap={8}>
|
||||||
|
<Button
|
||||||
|
size="compact-sm"
|
||||||
|
variant="light"
|
||||||
|
color="red"
|
||||||
|
radius="md"
|
||||||
|
leftSection={<MessageSquareWarning size={14} />}
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => onToggleQuery(true)}
|
||||||
|
>
|
||||||
|
Open query
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="compact-sm"
|
||||||
|
color="edr-green"
|
||||||
|
radius="md"
|
||||||
|
leftSection={<CheckCircle2 size={14} />}
|
||||||
|
disabled={busy}
|
||||||
|
onClick={onApprove}
|
||||||
|
>
|
||||||
|
Approve
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
) : (
|
||||||
|
<Box
|
||||||
|
p="sm"
|
||||||
|
style={{
|
||||||
|
borderRadius: 12,
|
||||||
|
background: "var(--mantine-color-red-0)",
|
||||||
|
border: "1px solid var(--mantine-color-red-2)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Group gap={6} mb={6}>
|
||||||
|
<MessageSquareWarning
|
||||||
|
size={14}
|
||||||
|
color="var(--mantine-color-red-7)"
|
||||||
|
/>
|
||||||
|
<Text fz="12.5px" fw={700} c="red.8">
|
||||||
|
Describe the problem for the customer
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<Textarea
|
||||||
|
placeholder="e.g. The commercial invoice is missing the HS code and the totals don't match the packing list."
|
||||||
|
value={note}
|
||||||
|
onChange={(e) => onNote(e.currentTarget.value)}
|
||||||
|
autosize
|
||||||
|
minRows={2}
|
||||||
|
radius="md"
|
||||||
|
size="sm"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<Group justify="flex-end" gap={8} mt={8}>
|
||||||
|
<Button
|
||||||
|
size="compact-sm"
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
radius="md"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => onToggleQuery(false)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="compact-sm"
|
||||||
|
color="red"
|
||||||
|
radius="md"
|
||||||
|
leftSection={<MessageSquareWarning size={14} />}
|
||||||
|
loading={busy}
|
||||||
|
disabled={!note.trim()}
|
||||||
|
onClick={onQuery}
|
||||||
|
>
|
||||||
|
Send query to customer
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -22,7 +22,7 @@ import {
|
|||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
Package,
|
Package,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
|
|
||||||
@@ -84,6 +84,9 @@ export default function CustomerDetailPage() {
|
|||||||
enabled: Boolean(id),
|
enabled: Boolean(id),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
const approveMutation = useMutation(
|
||||||
|
api.customers.setCompanyStatus.mutationOptions(),
|
||||||
|
);
|
||||||
const bookingsQuery = useQuery(
|
const bookingsQuery = useQuery(
|
||||||
api.customers.bookings.queryOptions({
|
api.customers.bookings.queryOptions({
|
||||||
input: { id: id ?? "" },
|
input: { id: id ?? "" },
|
||||||
@@ -398,6 +401,21 @@ export default function CustomerDetailPage() {
|
|||||||
<Group gap="xs" wrap="nowrap">
|
<Group gap="xs" wrap="nowrap">
|
||||||
<CompanyTypeBadge type={company.type} />
|
<CompanyTypeBadge type={company.type} />
|
||||||
<CompanyStatusBadge status={company.status} />
|
<CompanyStatusBadge status={company.status} />
|
||||||
|
{company.status === "pending" && (
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
color="green"
|
||||||
|
loading={approveMutation.isPending}
|
||||||
|
onClick={() =>
|
||||||
|
approveMutation.mutate({
|
||||||
|
companyId: company.id,
|
||||||
|
status: "active",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Approve
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import { canAccessRuleEngineResource } from "@/lib/permissions";
|
|||||||
import RuleEngineFormDialog from "@/components/ruleEngine/RuleEngineFormDialog";
|
import RuleEngineFormDialog from "@/components/ruleEngine/RuleEngineFormDialog";
|
||||||
import {
|
import {
|
||||||
getRuleEngineResource,
|
getRuleEngineResource,
|
||||||
|
RULE_ENGINE_SELECT_NONE,
|
||||||
type FormFieldDef,
|
type FormFieldDef,
|
||||||
} from "@/pages/ruleEngine/config/resources";
|
} from "@/pages/ruleEngine/config/resources";
|
||||||
import {
|
import {
|
||||||
@@ -52,6 +53,8 @@ interface CargoNode extends RuleEngineRecord {
|
|||||||
parentGroupId?: string | null;
|
parentGroupId?: string | null;
|
||||||
showFreeTextBox?: boolean;
|
showFreeTextBox?: boolean;
|
||||||
requiresDirectorApproval?: boolean;
|
requiresDirectorApproval?: boolean;
|
||||||
|
/** How this cargo is measured (PER_TON / PER_ITEM); null for groups/unset. */
|
||||||
|
unitOfMeasure?: string | null;
|
||||||
isActive?: boolean;
|
isActive?: boolean;
|
||||||
displayOrder?: number;
|
displayOrder?: number;
|
||||||
}
|
}
|
||||||
@@ -62,6 +65,21 @@ const orderOf = (n: CargoNode): number => Number(n.displayOrder ?? 0);
|
|||||||
/** Create/edit form fields. Parent is set from the current page, never picked. */
|
/** Create/edit form fields. Parent is set from the current page, never picked. */
|
||||||
const FORM_FIELDS: FormFieldDef[] = [
|
const FORM_FIELDS: FormFieldDef[] = [
|
||||||
{ name: "cargoTypeName", label: "Cargo type name", type: "text", required: true },
|
{ name: "cargoTypeName", label: "Cargo type name", type: "text", required: true },
|
||||||
|
{
|
||||||
|
// How this cargo is measured. Optional — leave "None" for grouping
|
||||||
|
// categories; set it on the actual commodities so bookings ask for the
|
||||||
|
// right amount (estimated tons vs. total item count).
|
||||||
|
name: "unitOfMeasure",
|
||||||
|
label: "Unit of measure",
|
||||||
|
type: "select",
|
||||||
|
optional: true,
|
||||||
|
placeholder: "Select unit (optional)",
|
||||||
|
options: [
|
||||||
|
{ label: "None", value: RULE_ENGINE_SELECT_NONE },
|
||||||
|
{ label: "Per ton (bulk)", value: "PER_TON" },
|
||||||
|
{ label: "Per item (break-bulk)", value: "PER_ITEM" },
|
||||||
|
],
|
||||||
|
},
|
||||||
{ name: "showFreeTextBox", label: "Show free text box", type: "boolean" },
|
{ name: "showFreeTextBox", label: "Show free text box", type: "boolean" },
|
||||||
{ name: "requiresDirectorApproval", label: "Requires director approval", type: "boolean" },
|
{ name: "requiresDirectorApproval", label: "Requires director approval", type: "boolean" },
|
||||||
{ name: "isActive", label: "Active", type: "boolean" },
|
{ name: "isActive", label: "Active", type: "boolean" },
|
||||||
@@ -469,6 +487,13 @@ function CargoRow({
|
|||||||
</Badge>
|
</Badge>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
) : null}
|
) : null}
|
||||||
|
{node.unitOfMeasure ? (
|
||||||
|
<Tooltip label="How bookings measure this cargo" withArrow>
|
||||||
|
<Badge size="xs" variant="light" color="teal" radius="sm">
|
||||||
|
{node.unitOfMeasure === "PER_ITEM" ? "Per item" : "Per ton"}
|
||||||
|
</Badge>
|
||||||
|
</Tooltip>
|
||||||
|
) : null}
|
||||||
{inactive ? (
|
{inactive ? (
|
||||||
<Badge size="xs" variant="light" color="gray" radius="sm">
|
<Badge size="xs" variant="light" color="gray" radius="sm">
|
||||||
Inactive
|
Inactive
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
|
|||||||
import { useRuleEngineViewMode } from "@/components/ruleEngine/useRuleEngineViewMode";
|
import { useRuleEngineViewMode } from "@/components/ruleEngine/useRuleEngineViewMode";
|
||||||
import {
|
import {
|
||||||
useApprovalChain,
|
useApprovalChain,
|
||||||
|
useCargoLeafOptions,
|
||||||
useCargoTypeParentOptions,
|
useCargoTypeParentOptions,
|
||||||
useContainerTypeOptions,
|
useContainerTypeOptions,
|
||||||
useLiveRateOptions,
|
useLiveRateOptions,
|
||||||
@@ -144,12 +145,17 @@ const RuleEngineResourcePage = () => {
|
|||||||
const usesContainerTypeField = Boolean(
|
const usesContainerTypeField = Boolean(
|
||||||
config?.formFields.some((f) => f.name === "containerTypeId"),
|
config?.formFields.some((f) => f.name === "containerTypeId"),
|
||||||
);
|
);
|
||||||
|
const usesCargoTypeField = Boolean(
|
||||||
|
config?.formFields.some((f) => f.name === "cargoTypeId"),
|
||||||
|
);
|
||||||
const usesLiveRateField = Boolean(
|
const usesLiveRateField = Boolean(
|
||||||
config?.formFields.some((f) => f.name === "rateId"),
|
config?.formFields.some((f) => f.name === "rateId"),
|
||||||
);
|
);
|
||||||
|
|
||||||
const { data: cargoParentOptions, isLoading: cargoParentOptionsLoading } =
|
const { data: cargoParentOptions, isLoading: cargoParentOptionsLoading } =
|
||||||
useCargoTypeParentOptions(editingId, config?.slug === "cargo-types");
|
useCargoTypeParentOptions(editingId, config?.slug === "cargo-types");
|
||||||
|
const { data: cargoLeafOptions, isLoading: cargoLeafOptionsLoading } =
|
||||||
|
useCargoLeafOptions(usesCargoTypeField);
|
||||||
const { data: containerTypeOptions, isLoading: containerTypeOptionsLoading } =
|
const { data: containerTypeOptions, isLoading: containerTypeOptionsLoading } =
|
||||||
useContainerTypeOptions(config?.slug === "rates", usesContainerTypeField);
|
useContainerTypeOptions(config?.slug === "rates", usesContainerTypeField);
|
||||||
const { data: liveRateOptions, isLoading: liveRateOptionsLoading } =
|
const { data: liveRateOptions, isLoading: liveRateOptionsLoading } =
|
||||||
@@ -173,6 +179,13 @@ const RuleEngineResourcePage = () => {
|
|||||||
options: containerTypeOptions ?? [],
|
options: containerTypeOptions ?? [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
if (field.name === "cargoTypeId") {
|
||||||
|
return {
|
||||||
|
...field,
|
||||||
|
type: "select" as const,
|
||||||
|
options: cargoLeafOptions ?? [],
|
||||||
|
};
|
||||||
|
}
|
||||||
if (field.name === "rateId") {
|
if (field.name === "rateId") {
|
||||||
return {
|
return {
|
||||||
...field,
|
...field,
|
||||||
@@ -182,7 +195,7 @@ const RuleEngineResourcePage = () => {
|
|||||||
}
|
}
|
||||||
return field;
|
return field;
|
||||||
});
|
});
|
||||||
}, [config, cargoParentOptions, containerTypeOptions, liveRateOptions]);
|
}, [config, cargoParentOptions, cargoLeafOptions, containerTypeOptions, liveRateOptions]);
|
||||||
|
|
||||||
const rows = data?.data ?? [];
|
const rows = data?.data ?? [];
|
||||||
const meta = data?.meta;
|
const meta = data?.meta;
|
||||||
@@ -316,7 +329,15 @@ const RuleEngineResourcePage = () => {
|
|||||||
const handleFormSubmit = (values: Record<string, unknown>) => {
|
const handleFormSubmit = (values: Record<string, unknown>) => {
|
||||||
let payload = values;
|
let payload = values;
|
||||||
if (config.slug === "rates") {
|
if (config.slug === "rates") {
|
||||||
payload = { ...values, currency: "USD" };
|
// Base-freight categories have no surcharge trigger field — the engine
|
||||||
|
// treats them as ALWAYS. Surcharges (Applies to = Other) keep their
|
||||||
|
// chosen trigger.
|
||||||
|
const isSurcharge = values.appliesTo === "OTHER";
|
||||||
|
payload = {
|
||||||
|
...values,
|
||||||
|
currency: "USD",
|
||||||
|
trigger: isSurcharge ? values.trigger : "ALWAYS",
|
||||||
|
};
|
||||||
} else if (config.slug === "priority-configs") {
|
} else if (config.slug === "priority-configs") {
|
||||||
// Label is required by the backend but hidden in the UI for now.
|
// Label is required by the backend but hidden in the UI for now.
|
||||||
payload = { ...values, label: String(Date.now()) };
|
payload = { ...values, label: String(Date.now()) };
|
||||||
@@ -476,6 +497,7 @@ const RuleEngineResourcePage = () => {
|
|||||||
selectOptionsLoading={
|
selectOptionsLoading={
|
||||||
(config.slug === "cargo-types" && cargoParentOptionsLoading) ||
|
(config.slug === "cargo-types" && cargoParentOptionsLoading) ||
|
||||||
(usesContainerTypeField && containerTypeOptionsLoading) ||
|
(usesContainerTypeField && containerTypeOptionsLoading) ||
|
||||||
|
(usesCargoTypeField && cargoLeafOptionsLoading) ||
|
||||||
(usesLiveRateField && liveRateOptionsLoading)
|
(usesLiveRateField && liveRateOptionsLoading)
|
||||||
}
|
}
|
||||||
positionOptions={!editing ? createPositionOptions : undefined}
|
positionOptions={!editing ? createPositionOptions : undefined}
|
||||||
|
|||||||
@@ -38,6 +38,12 @@ export interface FormFieldDef {
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
/** Hide this field when another field currently equals one of these values. */
|
/** Hide this field when another field currently equals one of these values. */
|
||||||
hideWhen?: { field: string; equals: string[] };
|
hideWhen?: { field: string; equals: string[] };
|
||||||
|
/**
|
||||||
|
* Show this field ONLY when another field currently equals one of these
|
||||||
|
* values (inverse of hideWhen). When both are set, the field must satisfy
|
||||||
|
* showWhen and not match hideWhen.
|
||||||
|
*/
|
||||||
|
showWhen?: { field: string; equals: string[] };
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RuleEngineOrderConfig {
|
export interface RuleEngineOrderConfig {
|
||||||
@@ -81,38 +87,38 @@ const APPROVAL_ROLES = [
|
|||||||
{ label: "CEO", value: "CEO" },
|
{ label: "CEO", value: "CEO" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const SURCHARGE_TRIGGERS = [
|
/**
|
||||||
{ label: "Hazardous cargo", value: "CARGO_FLAG_HAZARDOUS" },
|
* Friendly, admin-facing rate categories. Choosing one drives which fields the
|
||||||
{ label: "Reefer cargo", value: "CARGO_FLAG_REEFER" },
|
* Rate form shows (see the `rates` resource below). Base-freight categories
|
||||||
{ label: "VGM exceeds limit", value: "VGM_EXCEEDS_LIMIT" },
|
* carry a trade direction + container/bulk scope; OTHER is for surcharges.
|
||||||
{ label: "Shipping line mapped", value: "SHIPPING_LINE_MAPPED" },
|
*/
|
||||||
{ label: "Consolidation enabled", value: "CONSOLIDATION_ENABLED" },
|
const RATE_APPLIES_TO = [
|
||||||
|
{ label: "Bulk (base freight)", value: "BULK" },
|
||||||
|
{ label: "Container (base freight)", value: "CONTAINER" },
|
||||||
|
{ label: "Intercity (base freight)", value: "INTERCITY" },
|
||||||
|
{ label: "First mile", value: "FIRST_MILE" },
|
||||||
|
{ label: "Last mile", value: "LAST_MILE" },
|
||||||
|
{ label: "Other (surcharge)", value: "OTHER" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const RATE_TYPES = [
|
/** Surcharge triggers — only relevant when Applies to = Other. */
|
||||||
"CONTAINER_IMPORT",
|
const RATE_TRIGGERS = [
|
||||||
"CONTAINER_EXPORT",
|
{ label: "Hazardous cargo", value: "HAZARDOUS" },
|
||||||
"BULK_IMPORT",
|
{ label: "Overweight (per excess ton)", value: "OVERWEIGHT" },
|
||||||
"BULK_EXPORT",
|
{ label: "Reefer cargo", value: "REEFER" },
|
||||||
"INTERCITY_BULK",
|
{ label: "Shipping line mapped", value: "SHIPPING_LINE" },
|
||||||
"INTERCITY_CONTAINER",
|
{ label: "Consolidation", value: "CONSOLIDATION" },
|
||||||
"FIRST_MILE",
|
{ label: "Cancellation", value: "CANCELLATION" },
|
||||||
"LAST_MILE",
|
{ label: "Demurrage", value: "DEMURRAGE" },
|
||||||
"DEMURRAGE",
|
{ label: "Shipping line extra fee (PIL)", value: "PIL_EXTRA_FEE" },
|
||||||
"LASHING",
|
];
|
||||||
"DOUBLE_HANDLING",
|
|
||||||
"CONTAINER_WITH_RETURN",
|
|
||||||
"CANCELLATION_FEE",
|
|
||||||
"OVERWEIGHT_PER_TON",
|
|
||||||
"HAZARD_SURCHARGE",
|
|
||||||
"REEFER_SURCHARGE",
|
|
||||||
"PIL_EXTRA_FEE",
|
|
||||||
].map((v) => ({ label: v.replace(/_/g, " "), value: v }));
|
|
||||||
|
|
||||||
const RATE_UNITS = ["PER_WAGON", "PER_TON", "PER_CONTAINER", "PER_KM", "FLAT"].map((v) => ({
|
const RATE_UNITS =["PER_WAGON", "PER_TON", "PER_CONTAINER", "PER_KM", "PER_INVOICE", "FLAT"].map(
|
||||||
label: v.replace(/_/g, " "),
|
(v) => ({
|
||||||
value: v,
|
label: v.replace(/_/g, " "),
|
||||||
}));
|
value: v,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
const CURRENCIES = [
|
const CURRENCIES = [
|
||||||
{ label: "USD", value: "USD" },
|
{ label: "USD", value: "USD" },
|
||||||
@@ -302,38 +308,6 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
|||||||
{ name: "isActive", label: "Active", type: "boolean" },
|
{ name: "isActive", label: "Active", type: "boolean" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
|
||||||
slug: "surcharge-types",
|
|
||||||
label: "Surcharge Types",
|
|
||||||
category: "configuration",
|
|
||||||
subtitle: "Auto-applied surcharge definitions",
|
|
||||||
searchPlaceholder: "Search surcharge types...",
|
|
||||||
columns: [
|
|
||||||
codeColumn("code"),
|
|
||||||
{ id: "label", header: "Label", accessorKey: "label" },
|
|
||||||
{ id: "triggerCondition", header: "Trigger", accessorKey: "triggerCondition" },
|
|
||||||
{ id: "rateId", header: "Rate", accessorKey: "rate", format: "rateLabel" },
|
|
||||||
activeColumn,
|
|
||||||
],
|
|
||||||
formFields: [
|
|
||||||
{ name: "label", label: "Label", type: "text", required: true },
|
|
||||||
{
|
|
||||||
name: "triggerCondition",
|
|
||||||
label: "Trigger condition",
|
|
||||||
type: "select",
|
|
||||||
required: true,
|
|
||||||
options: SURCHARGE_TRIGGERS,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "rateId",
|
|
||||||
label: "Live rate",
|
|
||||||
type: "select",
|
|
||||||
required: true,
|
|
||||||
placeholder: "Select a LIVE rate",
|
|
||||||
},
|
|
||||||
{ name: "isActive", label: "Active", type: "boolean" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
slug: "weight-limit-rules",
|
slug: "weight-limit-rules",
|
||||||
label: "Weight Limit Rules",
|
label: "Weight Limit Rules",
|
||||||
@@ -424,32 +398,64 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
|||||||
slug: "rates",
|
slug: "rates",
|
||||||
label: "Rates",
|
label: "Rates",
|
||||||
category: "rules",
|
category: "rules",
|
||||||
cardTitleKey: "rateType",
|
cardTitleKey: "appliesTo",
|
||||||
cardSubtitleKey: "currency",
|
cardSubtitleKey: "currency",
|
||||||
subtitle: "Freight rates and approval workflow",
|
subtitle: "Freight rates and approval workflow",
|
||||||
searchPlaceholder: "Search rates by type or status...",
|
searchPlaceholder: "Search rates by type or status...",
|
||||||
columns: [
|
columns: [
|
||||||
{ id: "rateType", header: "Type", accessorKey: "rateType", format: "code" },
|
{ id: "appliesTo", header: "Applies to", accessorKey: "appliesTo", format: "code" },
|
||||||
{ id: "currency", header: "Currency", accessorKey: "currency" },
|
{ id: "trigger", header: "Trigger", accessorKey: "trigger" },
|
||||||
{ id: "rateValue", header: "Value", accessorKey: "rateValue", format: "number" },
|
{ id: "rateValue", header: "Value", accessorKey: "rateValue", format: "number" },
|
||||||
{ id: "rateUnit", header: "Unit", accessorKey: "rateUnit" },
|
{ id: "rateUnit", header: "Unit", accessorKey: "rateUnit" },
|
||||||
{ id: "status", header: "Status", accessorKey: "status", format: "rateStatus" },
|
{ id: "status", header: "Status", accessorKey: "status", format: "rateStatus" },
|
||||||
{ id: "effectiveFrom", header: "From", accessorKey: "effectiveFrom", format: "date" },
|
{ id: "effectiveFrom", header: "From", accessorKey: "effectiveFrom", format: "date" },
|
||||||
],
|
],
|
||||||
formFields: [
|
formFields: [
|
||||||
{ name: "rateType", label: "Rate type", type: "select", required: true, options: RATE_TYPES },
|
{
|
||||||
|
name: "appliesTo",
|
||||||
|
label: "Applies to",
|
||||||
|
type: "select",
|
||||||
|
required: true,
|
||||||
|
options: RATE_APPLIES_TO,
|
||||||
|
description:
|
||||||
|
"Pick what this rate is for. Bulk/Container/Intercity are base freight; Other is an auto-applied surcharge.",
|
||||||
|
},
|
||||||
|
// ── Surcharge trigger — only when Applies to = Other ──────────────────
|
||||||
|
{
|
||||||
|
name: "trigger",
|
||||||
|
label: "Surcharge trigger",
|
||||||
|
type: "select",
|
||||||
|
required: true,
|
||||||
|
options: RATE_TRIGGERS,
|
||||||
|
placeholder: "What makes this surcharge apply?",
|
||||||
|
showWhen: { field: "appliesTo", equals: ["OTHER"] },
|
||||||
|
},
|
||||||
|
// ── Trade direction — Bulk & Container only (intercity is domestic) ───
|
||||||
|
{
|
||||||
|
name: "tradeDirection",
|
||||||
|
label: "Trade direction",
|
||||||
|
type: "select",
|
||||||
|
required: true,
|
||||||
|
options: TRADE_DIRECTIONS.filter((d) => d.value !== "BOTH"),
|
||||||
|
showWhen: { field: "appliesTo", equals: ["BULK", "CONTAINER"] },
|
||||||
|
},
|
||||||
|
// ── Container type — Container & Intercity ────────────────────────────
|
||||||
{
|
{
|
||||||
name: "containerTypeId",
|
name: "containerTypeId",
|
||||||
label: "Container type",
|
label: "Container type",
|
||||||
type: "select",
|
type: "select",
|
||||||
optional: true,
|
optional: true,
|
||||||
placeholder: "Select container type (optional)",
|
placeholder: "Select container type (optional)",
|
||||||
|
showWhen: { field: "appliesTo", equals: ["CONTAINER", "INTERCITY"] },
|
||||||
},
|
},
|
||||||
|
// ── Bulk cargo (leaf commodity) — Bulk & Intercity ───────────────────
|
||||||
{
|
{
|
||||||
name: "tradeDirection",
|
name: "cargoTypeId",
|
||||||
label: "Trade direction",
|
label: "Bulk cargo type",
|
||||||
type: "select",
|
type: "select",
|
||||||
options: TRADE_DIRECTIONS,
|
optional: true,
|
||||||
|
placeholder: "Select bulk commodity (optional)",
|
||||||
|
showWhen: { field: "appliesTo", equals: ["BULK", "INTERCITY"] },
|
||||||
},
|
},
|
||||||
{ name: "rateValue", label: "Rate value", type: "number", required: true },
|
{ name: "rateValue", label: "Rate value", type: "number", required: true },
|
||||||
{ name: "rateUnit", label: "Rate unit", type: "select", required: true, options: RATE_UNITS },
|
{ name: "rateUnit", label: "Rate unit", type: "select", required: true, options: RATE_UNITS },
|
||||||
|
|||||||
@@ -1817,10 +1817,10 @@ export const api = {
|
|||||||
({ id }) => bookingsService.remove(id),
|
({ id }) => bookingsService.remove(id),
|
||||||
),
|
),
|
||||||
|
|
||||||
staffAccept: endpoint<{ id: string }, BookingDetail>(
|
staffAccept: endpoint<{ id: string; validityDays: number }, BookingDetail>(
|
||||||
"bookings",
|
"bookings",
|
||||||
"staffAccept",
|
"staffAccept",
|
||||||
({ id }) => bookingsService.staffAccept(id),
|
({ id, validityDays }) => bookingsService.staffAccept(id, validityDays),
|
||||||
),
|
),
|
||||||
|
|
||||||
requestChanges: endpoint<{ id: string; note: string }, BookingDetail>(
|
requestChanges: endpoint<{ id: string; note: string }, BookingDetail>(
|
||||||
@@ -1835,6 +1835,18 @@ export const api = {
|
|||||||
({ id, reason }) => bookingsService.staffReject(id, reason),
|
({ id, reason }) => bookingsService.staffReject(id, reason),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
reviewOperation: endpoint<
|
||||||
|
{
|
||||||
|
id: string;
|
||||||
|
decision: "ACCEPT" | "REQUEST_CHANGES" | "ADJUST_PRICE";
|
||||||
|
note?: string;
|
||||||
|
amount?: number;
|
||||||
|
},
|
||||||
|
BookingDetail
|
||||||
|
>("bookings", "reviewOperation", ({ id, decision, note, amount }) =>
|
||||||
|
bookingsService.reviewOperation(id, decision, { note, amount }),
|
||||||
|
),
|
||||||
|
|
||||||
approveStep: endpoint<ApproveStepPayload, BookingDetail>(
|
approveStep: endpoint<ApproveStepPayload, BookingDetail>(
|
||||||
"bookings",
|
"bookings",
|
||||||
"approveStep",
|
"approveStep",
|
||||||
@@ -1947,6 +1959,18 @@ export const api = {
|
|||||||
QUERY_KEYS.CUSTOMERS.ROOT,
|
QUERY_KEYS.CUSTOMERS.ROOT,
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
||||||
|
setCompanyStatus: endpoint<{ companyId: string; status: string }, unknown>(
|
||||||
|
"customers",
|
||||||
|
"setCompanyStatus",
|
||||||
|
({ companyId, status }) =>
|
||||||
|
customersService.setCompanyStatus(companyId, status),
|
||||||
|
undefined,
|
||||||
|
(input) => [
|
||||||
|
QUERY_KEYS.CUSTOMERS.byId(input.companyId),
|
||||||
|
QUERY_KEYS.CUSTOMERS.ROOT,
|
||||||
|
],
|
||||||
|
),
|
||||||
},
|
},
|
||||||
|
|
||||||
overview: {
|
overview: {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { api as client } from "../auth/http";
|
|||||||
import { unwrap } from "@/utils/endpoint";
|
import { unwrap } from "@/utils/endpoint";
|
||||||
import { URL_CONSTANTS } from "@/constants/URLS";
|
import { URL_CONSTANTS } from "@/constants/URLS";
|
||||||
import type { BookingDetail } from "@/types/booking";
|
import type { BookingDetail } from "@/types/booking";
|
||||||
|
import type { Freight } from "@edr/types";
|
||||||
|
|
||||||
const B = URL_CONSTANTS.BOOKINGS;
|
const B = URL_CONSTANTS.BOOKINGS;
|
||||||
|
|
||||||
@@ -169,7 +170,38 @@ export const bookingsService = {
|
|||||||
await client.delete(B.BY_ID(id));
|
await client.delete(B.BY_ID(id));
|
||||||
},
|
},
|
||||||
|
|
||||||
staffAccept: (id: string) => postBooking<BookingDetail>(B.STAFF_ACCEPT(id)),
|
// ── Document clearance (GL workflow) ──
|
||||||
|
getClearance: async (id: string): Promise<Freight.ClearanceView> => {
|
||||||
|
const response = await client.get(`/bookings/${id}/clearance`);
|
||||||
|
return unwrap(response.data) as Freight.ClearanceView;
|
||||||
|
},
|
||||||
|
|
||||||
|
reviewClearanceDocument: (
|
||||||
|
id: string,
|
||||||
|
payload: { fileKey: string; status: "APPROVED" | "QUERIED"; note?: string },
|
||||||
|
) => postBooking<BookingDetail>(`/bookings/${id}/clearance/review`, payload),
|
||||||
|
|
||||||
|
uploadClearanceOutput: async (
|
||||||
|
id: string,
|
||||||
|
files: Record<string, File | null>,
|
||||||
|
): Promise<BookingDetail> => {
|
||||||
|
const form = new FormData();
|
||||||
|
for (const [key, file] of Object.entries(files)) {
|
||||||
|
if (file) form.append(key, file);
|
||||||
|
}
|
||||||
|
const response = await client.post(
|
||||||
|
`/bookings/${id}/clearance/output-documents`,
|
||||||
|
form,
|
||||||
|
{ headers: { "Content-Type": "multipart/form-data" } },
|
||||||
|
);
|
||||||
|
return unwrap(response.data) as BookingDetail;
|
||||||
|
},
|
||||||
|
|
||||||
|
finalizeClearance: (id: string) =>
|
||||||
|
postBooking<BookingDetail>(`/bookings/${id}/clearance/finalize`),
|
||||||
|
|
||||||
|
staffAccept: (id: string, validityDays: number) =>
|
||||||
|
postBooking<BookingDetail>(B.STAFF_ACCEPT(id), { validityDays }),
|
||||||
|
|
||||||
requestChanges: (id: string, note: string) =>
|
requestChanges: (id: string, note: string) =>
|
||||||
postBooking<BookingDetail>(B.STAFF_REQUEST_CHANGES(id), { note }),
|
postBooking<BookingDetail>(B.STAFF_REQUEST_CHANGES(id), { note }),
|
||||||
@@ -177,6 +209,24 @@ export const bookingsService = {
|
|||||||
staffReject: (id: string, reason: string) =>
|
staffReject: (id: string, reason: string) =>
|
||||||
postBooking<BookingDetail>(B.STAFF_REJECT(id), { reason }),
|
postBooking<BookingDetail>(B.STAFF_REJECT(id), { reason }),
|
||||||
|
|
||||||
|
/** Marketing/operations review of a drawdown order's operation request. */
|
||||||
|
reviewOperation: (
|
||||||
|
id: string,
|
||||||
|
decision: "ACCEPT" | "REQUEST_CHANGES" | "ADJUST_PRICE",
|
||||||
|
options: { note?: string; amount?: number } = {},
|
||||||
|
) =>
|
||||||
|
postBooking<BookingDetail>(`/bookings/${id}/operation/review`, {
|
||||||
|
decision,
|
||||||
|
...options,
|
||||||
|
}),
|
||||||
|
|
||||||
|
/** Adjust a booking's total price (pass null amount to clear the adjustment). */
|
||||||
|
adjustPrice: (id: string, amount: number | null, reason?: string) =>
|
||||||
|
postBooking<BookingDetail>(`/bookings/${id}/adjust-price`, {
|
||||||
|
amount,
|
||||||
|
reason,
|
||||||
|
}),
|
||||||
|
|
||||||
approveStep: ({ id, stepId, requiredRole }: ApproveStepPayload) =>
|
approveStep: ({ id, stepId, requiredRole }: ApproveStepPayload) =>
|
||||||
postBooking<BookingDetail>(B.APPROVE_STEP(id, stepId), { requiredRole }),
|
postBooking<BookingDetail>(B.APPROVE_STEP(id, stepId), { requiredRole }),
|
||||||
|
|
||||||
|
|||||||
@@ -88,4 +88,11 @@ export const customersService = {
|
|||||||
)
|
)
|
||||||
.then((r) => r.data);
|
.then((r) => r.data);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** Approve / change a company's status (e.g. pending → active). */
|
||||||
|
setCompanyStatus(companyId: string, status: string): Promise<unknown> {
|
||||||
|
return apiClient
|
||||||
|
.patch(URL_CONSTANTS.COMPANIES.BY_ID(companyId), { status })
|
||||||
|
.then((r) => r.data);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ const RESOURCE_BASE: Record<RuleEngineResourceSlug, string> = {
|
|||||||
"wagon-types": URL_CONSTANTS.RULE_ENGINE.WAGON_TYPES,
|
"wagon-types": URL_CONSTANTS.RULE_ENGINE.WAGON_TYPES,
|
||||||
"priority-configs": URL_CONSTANTS.RULE_ENGINE.PRIORITY_CONFIGS,
|
"priority-configs": URL_CONSTANTS.RULE_ENGINE.PRIORITY_CONFIGS,
|
||||||
"service-types": URL_CONSTANTS.RULE_ENGINE.SERVICE_TYPES,
|
"service-types": URL_CONSTANTS.RULE_ENGINE.SERVICE_TYPES,
|
||||||
"surcharge-types": URL_CONSTANTS.RULE_ENGINE.SURCHARGE_TYPES,
|
|
||||||
"weight-limit-rules": URL_CONSTANTS.RULE_ENGINE.WEIGHT_LIMIT_RULES,
|
"weight-limit-rules": URL_CONSTANTS.RULE_ENGINE.WEIGHT_LIMIT_RULES,
|
||||||
yards: URL_CONSTANTS.RULE_ENGINE.YARDS,
|
yards: URL_CONSTANTS.RULE_ENGINE.YARDS,
|
||||||
"shipping-lines": URL_CONSTANTS.RULE_ENGINE.SHIPPING_LINES,
|
"shipping-lines": URL_CONSTANTS.RULE_ENGINE.SHIPPING_LINES,
|
||||||
@@ -50,8 +49,6 @@ const byIdPath = (resource: RuleEngineResourceSlug, id: string): string => {
|
|||||||
return URL_CONSTANTS.RULE_ENGINE.PRIORITY_CONFIG_BY_ID(id);
|
return URL_CONSTANTS.RULE_ENGINE.PRIORITY_CONFIG_BY_ID(id);
|
||||||
case "service-types":
|
case "service-types":
|
||||||
return URL_CONSTANTS.RULE_ENGINE.SERVICE_TYPE_BY_ID(id);
|
return URL_CONSTANTS.RULE_ENGINE.SERVICE_TYPE_BY_ID(id);
|
||||||
case "surcharge-types":
|
|
||||||
return URL_CONSTANTS.RULE_ENGINE.SURCHARGE_TYPE_BY_ID(id);
|
|
||||||
case "weight-limit-rules":
|
case "weight-limit-rules":
|
||||||
return URL_CONSTANTS.RULE_ENGINE.WEIGHT_LIMIT_RULE_BY_ID(id);
|
return URL_CONSTANTS.RULE_ENGINE.WEIGHT_LIMIT_RULE_BY_ID(id);
|
||||||
case "yards":
|
case "yards":
|
||||||
|
|||||||
@@ -119,6 +119,24 @@ export interface BookingDetail {
|
|||||||
status: BookingStatus;
|
status: BookingStatus;
|
||||||
scheduledDate: string;
|
scheduledDate: string;
|
||||||
totalAmount: number;
|
totalAmount: number;
|
||||||
|
adjustedTotalAmount?: number | null;
|
||||||
|
adjustedByStaffId?: string | null;
|
||||||
|
adjustedAt?: string | null;
|
||||||
|
adjustmentReason?: string | null;
|
||||||
|
/** Contract validity window set by the backoffice when accepting. */
|
||||||
|
contractValidityDays?: number | null;
|
||||||
|
contractValidFrom?: string | null;
|
||||||
|
contractValidUntil?: string | null;
|
||||||
|
pricingBreakdown?: {
|
||||||
|
currency: string;
|
||||||
|
totalAmount: number;
|
||||||
|
lineItems: Array<{
|
||||||
|
code: string;
|
||||||
|
description: string;
|
||||||
|
amount: number;
|
||||||
|
currency: string;
|
||||||
|
}>;
|
||||||
|
} | null;
|
||||||
paymentStatus: string;
|
paymentStatus: string;
|
||||||
paymentCurrency: string;
|
paymentCurrency: string;
|
||||||
contractType: string;
|
contractType: string;
|
||||||
@@ -126,7 +144,6 @@ export interface BookingDetail {
|
|||||||
tradeDirection: string;
|
tradeDirection: string;
|
||||||
cargoTotalWeightVgm: number;
|
cargoTotalWeightVgm: number;
|
||||||
isHazardous: boolean;
|
isHazardous: boolean;
|
||||||
allowConsolidation: boolean;
|
|
||||||
consolidationPartnerId?: string | null;
|
consolidationPartnerId?: string | null;
|
||||||
consolidationPartner?: BookingNamedRef & { reference?: string } | null;
|
consolidationPartner?: BookingNamedRef & { reference?: string } | null;
|
||||||
priorityScore: number;
|
priorityScore: number;
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ export type RuleEngineResourceSlug =
|
|||||||
| "wagon-types"
|
| "wagon-types"
|
||||||
| "priority-configs"
|
| "priority-configs"
|
||||||
| "service-types"
|
| "service-types"
|
||||||
| "surcharge-types"
|
|
||||||
| "weight-limit-rules"
|
| "weight-limit-rules"
|
||||||
| "yards"
|
| "yards"
|
||||||
| "shipping-lines"
|
| "shipping-lines"
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user