diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index d8413bf71..7de5c5239 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -13,7 +13,7 @@ permissions: jobs: detect-changes: name: Detect changed services - runs-on: self-hosted + runs-on: ${{ fromJson(format('["self-hosted", "{0}"]', github.ref_name)) }} outputs: matrix: ${{ steps.filter.outputs.matrix }} steps: @@ -52,7 +52,7 @@ jobs: NON_DEPLOYABLE_PATTERN="^docs/|^README[.]md$|^DEPLOYMENT[.]md$|^CLAUDE[.]md$|^checkpoint[.]md$|^orgstructure[.]md$|^ITMLS_DB_Design[.]md$|.*[.]md$|^[.]eslintrc|^[.]prettierrc|^[.]editorconfig|^[.]gitignore|^[.]gitattributes|^commitlint[.]config[.]js$" - GLOBAL_PATTERN="^[.]github/|^docker-compose[.]yaml$|^turbo[.]json$|^tsconfig[.]json$|^tsconfig[.]base[.]json$|^pnpm-workspace[.]yaml$|^pnpm-lock[.]yaml$|^package[.]json$|^[.]env([.][a-z]+)?$|^packages/|^infrastructure/|^scripts/deploy/|^wagon[.][^/]*[.]ts$|^cargo[.][^/]*[.]ts$|^container[.][^/]*[.]ts$|^use-[^/]*[.]ts$|^[^/]*[.]service[.]ts$|^[^/]*[.]entity[.]ts$|^[^/]*-types[.]ts$" + GLOBAL_PATTERN="^[.]github/|^docker-compose[.]yaml$|^turbo[.]json$|^tsconfig[.]json$|^tsconfig[.]base[.]json$|^pnpm-workspace[.]yaml$|^pnpm-lock[.]yaml$|^package[.]json$|^[.]env([.][a-z]+)?$|^packages/|^local-packages/|^infrastructure/|^scripts/deploy/|^wagon[.][^/]*[.]ts$|^cargo[.][^/]*[.]ts$|^container[.][^/]*[.]ts$|^use-[^/]*[.]ts$|^[^/]*[.]service[.]ts$|^[^/]*[.]entity[.]ts$|^[^/]*-types[.]ts$" DEPLOYABLE=$(echo "$CHANGED" | grep -vE "$NON_DEPLOYABLE_PATTERN" || true) if [ -z "$DEPLOYABLE" ]; then @@ -91,7 +91,7 @@ jobs: name: Deploy ${{ matrix.service }} needs: detect-changes if: ${{ needs.detect-changes.outputs.matrix != '[]' }} - runs-on: self-hosted + runs-on: ${{ fromJson(format('["self-hosted", "{0}"]', github.ref_name)) }} strategy: fail-fast: false matrix: diff --git a/.gitignore b/.gitignore index 0e3f0986f..ffdc4b78b 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,7 @@ coverage/ .idea/ .vscode/ .npmrc +# emacs cache files +*~ +\#*\# +.\#* diff --git a/apps/edr-freight-api/src/main.ts b/apps/edr-freight-api/src/main.ts index a76378b0c..0107956e4 100644 --- a/apps/edr-freight-api/src/main.ts +++ b/apps/edr-freight-api/src/main.ts @@ -39,7 +39,16 @@ async function bootstrap() { }); app.setGlobalPrefix("api"); - app.useGlobalPipes(createValidationPipe()); + // enableImplicitConversion is OFF: class-transformer's implicit boolean + // coercion turns any non-empty multipart/form-data string (including the + // literal "false") into `true`, silently corrupting flags like isHazardous + // and isGovernment. With it off, only explicit @Transform/@Type decorators + // coerce values — every numeric/boolean DTO field in this API already has one. + app.useGlobalPipes( + createValidationPipe({ + transformOptions: { enableImplicitConversion: false }, + }), + ); app.useGlobalFilters(new HttpExceptionFilter()); app.useGlobalInterceptors(new ResponseTransformInterceptor()); diff --git a/apps/edr-freight-api/src/migrations/1820000000000-DropAllowConsolidation.ts b/apps/edr-freight-api/src/migrations/1820000000000-DropAllowConsolidation.ts new file mode 100644 index 000000000..dd232b28a --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1820000000000-DropAllowConsolidation.ts @@ -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 { + await queryRunner.query( + `ALTER TABLE freight.bookings DROP COLUMN IF EXISTS allow_consolidation;`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS allow_consolidation BOOLEAN NOT NULL DEFAULT false;`, + ); + } +} diff --git a/apps/edr-freight-api/src/migrations/1820000000001-CreateContractRouteLines.ts b/apps/edr-freight-api/src/migrations/1820000000001-CreateContractRouteLines.ts new file mode 100644 index 000000000..23eda1730 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1820000000001-CreateContractRouteLines.ts @@ -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 { + 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 { + await queryRunner.query( + `ALTER TABLE freight.booking_orders DROP COLUMN IF EXISTS route_line_id;`, + ); + await queryRunner.dropTable('freight.contract_route_lines', true); + } +} diff --git a/apps/edr-freight-api/src/migrations/1820000000002-CreateBookingDocumentReview.ts b/apps/edr-freight-api/src/migrations/1820000000002-CreateBookingDocumentReview.ts new file mode 100644 index 000000000..0237648f7 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1820000000002-CreateBookingDocumentReview.ts @@ -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 { + 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 { + await queryRunner.dropTable('freight.booking_document_review', true); + } +} diff --git a/apps/edr-freight-api/src/migrations/1820000000003-AddPriceAdjustment.ts b/apps/edr-freight-api/src/migrations/1820000000003-AddPriceAdjustment.ts new file mode 100644 index 000000000..7ad97e7bf --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1820000000003-AddPriceAdjustment.ts @@ -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 { + 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 { + 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;`, + ); + } +} diff --git a/apps/edr-freight-api/src/migrations/1820000000004-FoldSurchargeTypesIntoRates.ts b/apps/edr-freight-api/src/migrations/1820000000004-FoldSurchargeTypesIntoRates.ts new file mode 100644 index 000000000..d4eadf34e --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1820000000004-FoldSurchargeTypesIntoRates.ts @@ -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 { + // ── 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 { + // 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; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1820000000005-AddContractValidityWindow.ts b/apps/edr-freight-api/src/migrations/1820000000005-AddContractValidityWindow.ts new file mode 100644 index 000000000..56d923ff7 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1820000000005-AddContractValidityWindow.ts @@ -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 { + 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 { + 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;`, + ); + } +} diff --git a/apps/edr-freight-api/src/migrations/1820000000006-AddCustomsAgentAndMileCoordinates.ts b/apps/edr-freight-api/src/migrations/1820000000006-AddCustomsAgentAndMileCoordinates.ts new file mode 100644 index 000000000..11d1b617a --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1820000000006-AddCustomsAgentAndMileCoordinates.ts @@ -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 { + 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 { + 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; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1820000000010-AddGeneralContractOrderFields.ts b/apps/edr-freight-api/src/migrations/1820000000010-AddGeneralContractOrderFields.ts new file mode 100644 index 000000000..a23c152b5 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1820000000010-AddGeneralContractOrderFields.ts @@ -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 { + 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 { + 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;`, + ); + } +} diff --git a/apps/edr-freight-api/src/modules/booking-orders/booking-orders.controller.ts b/apps/edr-freight-api/src/modules/booking-orders/booking-orders.controller.ts index 6821c4e7d..b05895407 100644 --- a/apps/edr-freight-api/src/modules/booking-orders/booking-orders.controller.ts +++ b/apps/edr-freight-api/src/modules/booking-orders/booking-orders.controller.ts @@ -45,6 +45,15 @@ export class BookingOrdersController { return this.generalContractService.getQuantityLines(id); } + @Get('contract/:id/routes') + @ApiOperation({ + summary: + 'Per-route contracted / ordered / remaining quantities (multi-route contracts). Empty for single-route.', + }) + async routes(@Param('id', ParseUUIDPipe) id: string) { + return this.generalContractService.getRouteLines(id); + } + @Get(':id') @ApiOperation({ summary: 'Get a single booking order' }) async findOne(@Param('id', ParseUUIDPipe) id: string) { diff --git a/apps/edr-freight-api/src/modules/booking-orders/booking-orders.module.ts b/apps/edr-freight-api/src/modules/booking-orders/booking-orders.module.ts index c8ea869be..aafb04474 100644 --- a/apps/edr-freight-api/src/modules/booking-orders/booking-orders.module.ts +++ b/apps/edr-freight-api/src/modules/booking-orders/booking-orders.module.ts @@ -3,20 +3,23 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { BookingsModule } from '../bookings/bookings.module'; import { CompaniesModule } from '../companies/companies.module'; import { DropdownSettingsModule } from '../dropdown-settings/dropdown-settings.module'; +import { RuleEngineModule } from '../rule-engine/rule-engine.module'; import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module'; import { BookingOrdersController } from './booking-orders.controller'; import { BookingOrdersRepository } from './booking-orders.repository'; import { BookingOrdersService } from './booking-orders.service'; import { BookingOrder } from './entities/booking-order.entity'; import { BookingOrderLine } from './entities/booking-order-line.entity'; +import { ContractRouteLine } from './entities/contract-route-line.entity'; import { GeneralContractService } from './general-contract.service'; @Module({ imports: [ - TypeOrmModule.forFeature([BookingOrder, BookingOrderLine]), + TypeOrmModule.forFeature([BookingOrder, BookingOrderLine, ContractRouteLine]), BookingsModule, CompaniesModule, DropdownSettingsModule, + RuleEngineModule, forwardRef(() => TrainSchedulingModule), ], controllers: [BookingOrdersController], diff --git a/apps/edr-freight-api/src/modules/booking-orders/booking-orders.service.spec.ts b/apps/edr-freight-api/src/modules/booking-orders/booking-orders.service.spec.ts new file mode 100644 index 000000000..b870037f4 --- /dev/null +++ b/apps/edr-freight-api/src/modules/booking-orders/booking-orders.service.spec.ts @@ -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[] = []; + const managerUpdates: Record[] = []; + const fakeManager = { + create: (_entity: unknown, data: Record) => { + created.push(data); + return { id: 'child-1', ...data }; + }, + save: async (row: Record) => ({ id: 'child-1', ...row }), + getRepository: () => ({ + findOne: async () => ({ id: 'child-1', paymentCurrency: 'ETB', bookingContainers: [] }), + update: async (_id: string, data: Record) => { + managerUpdates.push(data); + }, + }), + }; + + const dataSource = { + transaction: async (cb: (m: unknown) => Promise) => 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/); + }); +}); diff --git a/apps/edr-freight-api/src/modules/booking-orders/booking-orders.service.ts b/apps/edr-freight-api/src/modules/booking-orders/booking-orders.service.ts index 1a75c7914..8eb258a88 100644 --- a/apps/edr-freight-api/src/modules/booking-orders/booking-orders.service.ts +++ b/apps/edr-freight-api/src/modules/booking-orders/booking-orders.service.ts @@ -8,11 +8,13 @@ import { } from '@nestjs/common'; import { DataSource } from 'typeorm'; import { BookingsRepository } from '../bookings/bookings.repository'; +import { BookingPricingService } from '../bookings/booking-pricing.service'; +import { clearanceCodesForBooking } from '../bookings/clearance.util'; import { Booking } from '../bookings/entities/booking.entity'; import { BookingContainer } from '../bookings/entities/booking-container.entity'; import { CompaniesService } from '../companies/companies.service'; import { ContainerType } from '../rule-engine/entities/container-type.entity'; -import { BookingBatchService } from '../train-scheduling/booking-batch.service'; +import { RatesService } from '../rule-engine/services/rates.service'; import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service'; import { eatDay } from '../train-scheduling/batch-window.util'; import { BookingOrdersRepository } from './booking-orders.repository'; @@ -20,6 +22,7 @@ import { CreateBookingOrderDto } from './dto/create-booking-order.dto'; import { BookingOrder } from './entities/booking-order.entity'; import { BookingOrderLine } from './entities/booking-order-line.entity'; import { GeneralContractService } from './general-contract.service'; +import { isRoadService, roadKmPrice } from './road.util'; @Injectable() export class BookingOrdersService { @@ -31,8 +34,8 @@ export class BookingOrdersService { private readonly bookingsRepository: BookingsRepository, private readonly companiesService: CompaniesService, private readonly generalContractService: GeneralContractService, - @Inject(forwardRef(() => BookingBatchService)) - private readonly bookingBatchService: BookingBatchService, + private readonly pricingService: BookingPricingService, + private readonly ratesService: RatesService, @Inject(forwardRef(() => TrainSchedulingService)) private readonly trainSchedulingService: TrainSchedulingService, ) {} @@ -79,12 +82,40 @@ export class BookingOrdersService { throw new BadRequestException('You do not have access to this contract'); } + // Resolve the route the order ships on: a chosen contract route line for a + // multi-route contract, else the contract's own origin/destination. + const routeLines = await this.generalContractService.getRouteLines( + contract.id, + ); + let originYardId = contract.originYardId; + let destinationYardId = contract.destinationYardId; + let routeLineId: string | null = null; + let routeKm: number | null = null; + + if (routeLines.length > 0) { + if (!dto.routeLineId) { + throw new BadRequestException( + 'This contract has multiple routes — select a route to draw from', + ); + } + const chosen = routeLines.find((r) => r.routeLineId === dto.routeLineId); + if (!chosen) { + throw new BadRequestException( + 'Selected route is not part of this contract', + ); + } + originYardId = chosen.originYardId; + destinationYardId = chosen.destinationYardId; + routeLineId = chosen.routeLineId; + routeKm = chosen.km ?? null; + } + // Validate the route has a departure on the chosen day. const day = eatDay(new Date(dto.scheduledDate)); const hasDeparture = await this.trainSchedulingService.existsOpenScheduleOnRouteDay( - contract.originYardId, - contract.destinationYardId, + originYardId, + destinationYardId, day, ); if (!hasDeparture) { @@ -93,44 +124,84 @@ export class BookingOrdersService { ); } - // Validate each line against the remaining pool. - const poolLines = await this.generalContractService.getQuantityLines( - contract.id, - ); const isContainer = contract.freightType === 'CONTAINER'; + const orderTotal = dto.lines.reduce((sum, l) => sum + l.quantity, 0); + + // Hazardous/reefer counts the customer entered cannot exceed the line they + // belong to. Validated for every order regardless of routing. for (const line of dto.lines) { - if (line.quantity <= 0) { - throw new BadRequestException('Order quantities must be greater than zero'); + const haz = line.hazardousQuantity ?? 0; + const reefer = line.reeferQuantity ?? 0; + if (haz < 0 || reefer < 0) { + throw new BadRequestException('Hazardous/reefer quantities cannot be negative'); } - const key = isContainer ? (line.containerTypeId ?? '') : ''; - const poolLine = poolLines.find((p) => (p.containerTypeId ?? '') === key); - if (!poolLine) { + if (haz > line.quantity || reefer > line.quantity) { throw new BadRequestException( - isContainer - ? `Container type ${line.containerTypeId} is not part of this contract` - : 'This contract has no matching quantity pool', + 'Hazardous/reefer quantity cannot exceed the line quantity', ); } - if (line.quantity > poolLine.remainingQuantity) { + } + + if (routeLineId) { + // Multi-route: validate against the chosen route line's remaining pool. + for (const line of dto.lines) { + if (line.quantity <= 0) { + throw new BadRequestException('Order quantities must be greater than zero'); + } + } + const chosen = routeLines.find((r) => r.routeLineId === routeLineId)!; + if (orderTotal > chosen.remainingQuantity) { throw new BadRequestException( - `Requested ${line.quantity} exceeds remaining ${poolLine.remainingQuantity}` + - (poolLine.containerTypeName ? ` for ${poolLine.containerTypeName}` : ''), + `Requested ${orderTotal} exceeds remaining ${chosen.remainingQuantity} for this route`, ); } + } else { + // Single-route: validate each line against the per-container-type pool. + const poolLines = await this.generalContractService.getQuantityLines( + contract.id, + ); + for (const line of dto.lines) { + if (line.quantity <= 0) { + throw new BadRequestException('Order quantities must be greater than zero'); + } + const key = isContainer ? (line.containerTypeId ?? '') : ''; + const poolLine = poolLines.find((p) => (p.containerTypeId ?? '') === key); + if (!poolLine) { + throw new BadRequestException( + isContainer + ? `Container type ${line.containerTypeId} is not part of this contract` + : 'This contract has no matching quantity pool', + ); + } + if (line.quantity > poolLine.remainingQuantity) { + throw new BadRequestException( + `Requested ${line.quantity} exceeds remaining ${poolLine.remainingQuantity}` + + (poolLine.containerTypeName ? ` for ${poolLine.containerTypeName}` : ''), + ); + } + } } // Persist the order + its child shipment booking atomically. const order = await this.dataSource.transaction(async (manager) => { - const childBooking = await this.spawnChildBooking(contract, dto, manager); + const childBooking = await this.spawnChildBooking( + contract, + dto, + { originYardId, destinationYardId, km: routeKm }, + manager, + ); const reference = await this.generateReference(); const orderRow = manager.create(BookingOrder, { reference, contractBookingId: contract.id, bookingId: childBooking.id, + routeLineId, companyId: contract.companyId ?? null, scheduledDate: new Date(dto.scheduledDate), - status: 'PAID', + // The order is a ledger row; the child booking drives the workflow + // (review → pay → allocate), so the order tracks PENDING until done. + status: 'PENDING', schedulingStatus: 'NOT_SCHEDULED', }); const savedOrder = await manager.save(orderRow); @@ -140,6 +211,8 @@ export class BookingOrdersService { orderId: savedOrder.id, containerTypeId: isContainer ? (l.containerTypeId ?? null) : null, quantity: l.quantity, + hazardousQuantity: l.hazardousQuantity ?? 0, + reeferQuantity: l.reeferQuantity ?? 0, }), ); await manager.save(lines); @@ -147,20 +220,12 @@ export class BookingOrdersService { return savedOrder; }); - // Feed the child booking into the day-pool batch so it allocates to a train. - try { - await this.bookingBatchService.processRouteDay({ - originYardId: contract.originYardId, - destinationYardId: contract.destinationYardId, - day, - }); - } catch (err) { - this.logger.error( - `Batch fill after order ${order.reference} failed: ${err instanceof Error ? err.message : String(err)}`, - ); - } + // The child does NOT enter the train batch pool here. It is priced and + // unpaid, awaiting Marketing review (OPERATION_REQUEST_PENDING) or customs + // clearance first; the batch enqueue happens only on accept. - // Close the contract once its pool is exhausted. + // Close the contract once its pool is exhausted (pending orders count, so + // the pool reserves quantity as soon as an order is placed). if (await this.generalContractService.isExhausted(contract.id)) { await this.dataSource .getRepository(Booking) @@ -175,15 +240,18 @@ export class BookingOrdersService { /** * Create the ONE_TIME child booking for an order, inheriting the contract's - * shipment context and entering the queue already PAID + FULLY_EXECUTED. + * shipment context. Unlike the contract (which is no longer paid up front), + * the child is PRICED and UNPAID and waits for Marketing review — going + * through the customs clearance gate first when the service includes customs, + * mirroring a one-time booking. It only enters the train pool on accept. */ private async spawnChildBooking( contract: Booking, dto: CreateBookingOrderDto, + route: { originYardId: string; destinationYardId: string; km: number | null }, manager: import('typeorm').EntityManager, ): Promise { const reference = await this.generateChildBookingReference(); - const now = new Date(); const isContainer = contract.freightType === 'CONTAINER'; // Sum line quantities × the contract's per-unit weight for the child total. @@ -201,6 +269,18 @@ export class BookingOrdersService { totalWeight = dto.lines.reduce((sum, l) => sum + l.quantity, 0); } + // Per-order hazardous/reefer: set the child flags from the order's line + // counts so the HAZARD_SURCHARGE / REEFER_SURCHARGE rates apply. + const hasHazardous = dto.lines.some((l) => (l.hazardousQuantity ?? 0) > 0); + const hasReefer = dto.lines.some((l) => (l.reeferQuantity ?? 0) > 0); + + // Customs orders flow through the one-time clearance gate first; others go + // straight to operations review with the chosen shipment day. + const { includesCustoms } = clearanceCodesForBooking(contract); + const spawnStatus = includesCustoms + ? 'AWAITING_DOCUMENTS' + : 'OPERATION_REQUEST_PENDING'; + const child = manager.create(Booking, { reference, companyId: contract.companyId ?? null, @@ -213,27 +293,24 @@ export class BookingOrdersService { firstMilePickupAddress: contract.firstMilePickupAddress ?? null, lastMileDeliveryAddress: contract.lastMileDeliveryAddress ?? null, equipmentReturn: contract.equipmentReturn, - originYardId: contract.originYardId, - destinationYardId: contract.destinationYardId, + originYardId: route.originYardId, + destinationYardId: route.destinationYardId, tradeDirection: contract.tradeDirection, freightType: contract.freightType, cargoTypeId: contract.cargoTypeId ?? null, cargoFreeText: contract.cargoFreeText ?? null, shippingLineId: contract.shippingLineId ?? null, cargoTotalWeightVgm: totalWeight, - isHazardous: contract.isHazardous, + isHazardous: hasHazardous, + isReefer: hasReefer, paymentCurrency: contract.paymentCurrency, bookingType: 'ONE_TIME', scheduledDate: new Date(dto.scheduledDate), - // Already covered by the contract's one-time payment: enter the pool ready - // and paid so the batch engine reserves → allocates it immediately. - status: 'FULLY_EXECUTED', - paymentStatus: 'PAID', - fullyExecutedAt: now, - customerSignedAt: now, + // Priced + unpaid: the customer pays this order on its own. + status: spawnStatus, + paymentStatus: 'PENDING', priorityScore: contract.priorityScore, totalAmount: 0, - allowConsolidation: false, schedulingStatus: 'NOT_SCHEDULED', }); const savedChild = await manager.save(child); @@ -261,9 +338,79 @@ export class BookingOrdersService { } } + // Price the order: base freight for the drawn quantity + haz/reefer + // surcharges, plus a road KM charge when the service ships by road. + const roadKm = isRoadService(contract.serviceType) ? route.km : null; + await this.priceChildBooking(savedChild.id, roadKm, manager); + return savedChild; } + /** + * Compute and persist the child order's price (base + surcharges) inside the + * order transaction. The contract is no longer paid up front, so each order + * carries its own total that the customer pays. + */ + private async priceChildBooking( + childId: string, + roadKm: number | null, + manager: import('typeorm').EntityManager, + ): Promise { + 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 { + const rates = await this.ratesService.findLiveRates(); + const rate = rates.find( + (r) => r.rateUnit === 'PER_KM' && r.currency === currency, + ); + return rate ? Number(rate.rateValue) : null; + } + private async userOwnsContract( userId: string, contract: Booking, diff --git a/apps/edr-freight-api/src/modules/booking-orders/dto/contract-view.dto.ts b/apps/edr-freight-api/src/modules/booking-orders/dto/contract-view.dto.ts index 6c0c86669..3c85ad31e 100644 --- a/apps/edr-freight-api/src/modules/booking-orders/dto/contract-view.dto.ts +++ b/apps/edr-freight-api/src/modules/booking-orders/dto/contract-view.dto.ts @@ -21,3 +21,39 @@ export class ContractQuantityLineView { @ApiProperty() remainingQuantity!: number; } + +/** A contracted/ordered/remaining pool line for one route of a general contract. */ +export class ContractRouteLineView { + @ApiProperty({ description: 'Contract route line id' }) + routeLineId!: string; + + @ApiProperty() + originYardId!: string; + + @ApiProperty({ nullable: true }) + originYardName!: string | null; + + @ApiProperty() + destinationYardId!: string; + + @ApiProperty({ nullable: true }) + destinationYardName!: string | null; + + @ApiProperty({ nullable: true, description: 'Container type id (null for bulk/break-bulk)' }) + containerTypeId!: string | null; + + @ApiProperty({ nullable: true }) + containerTypeName!: string | null; + + @ApiProperty() + contractedQuantity!: number; + + @ApiProperty() + orderedQuantity!: number; + + @ApiProperty() + remainingQuantity!: number; + + @ApiProperty({ nullable: true, description: 'Road distance (km); used to bill road orders' }) + km!: number | null; +} diff --git a/apps/edr-freight-api/src/modules/booking-orders/dto/create-booking-order.dto.spec.ts b/apps/edr-freight-api/src/modules/booking-orders/dto/create-booking-order.dto.spec.ts new file mode 100644 index 000000000..d7d226855 --- /dev/null +++ b/apps/edr-freight-api/src/modules/booking-orders/dto/create-booking-order.dto.spec.ts @@ -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) => + 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); + }); +}); diff --git a/apps/edr-freight-api/src/modules/booking-orders/dto/create-booking-order.dto.ts b/apps/edr-freight-api/src/modules/booking-orders/dto/create-booking-order.dto.ts index 7043e9704..4e7382ec4 100644 --- a/apps/edr-freight-api/src/modules/booking-orders/dto/create-booking-order.dto.ts +++ b/apps/edr-freight-api/src/modules/booking-orders/dto/create-booking-order.dto.ts @@ -25,6 +25,26 @@ export class CreateBookingOrderLineDto { @Min(0) @Transform(({ value }) => Number(value)) quantity!: number; + + @ApiPropertyOptional({ + description: 'How much of this line is hazardous (≤ quantity). Defaults to 0.', + minimum: 0, + }) + @IsOptional() + @IsNumber() + @Min(0) + @Transform(({ value }) => Number(value ?? 0)) + hazardousQuantity?: number = 0; + + @ApiPropertyOptional({ + description: 'How much of this line is refrigerated (≤ quantity). Defaults to 0.', + minimum: 0, + }) + @IsOptional() + @IsNumber() + @Min(0) + @Transform(({ value }) => Number(value ?? 0)) + reeferQuantity?: number = 0; } export class CreateBookingOrderDto { @@ -32,6 +52,16 @@ export class CreateBookingOrderDto { @IsUUID() contractBookingId!: string; + @ApiPropertyOptional({ + format: 'uuid', + description: + 'For multi-route contracts: the contract route line being drawn from. ' + + 'Determines the shipment origin/destination. Omit for single-route contracts.', + }) + @IsOptional() + @IsUUID() + routeLineId?: string; + @ApiProperty({ example: '2026-07-01T00:00:00.000Z', description: 'Shipment day for this order' }) @IsDateString() scheduledDate!: string; diff --git a/apps/edr-freight-api/src/modules/booking-orders/entities/booking-order-line.entity.ts b/apps/edr-freight-api/src/modules/booking-orders/entities/booking-order-line.entity.ts index 8716cd673..7b4728e02 100644 --- a/apps/edr-freight-api/src/modules/booking-orders/entities/booking-order-line.entity.ts +++ b/apps/edr-freight-api/src/modules/booking-orders/entities/booking-order-line.entity.ts @@ -27,4 +27,15 @@ export class BookingOrderLine extends BaseEntity { /** Containers (count), tons, or items depending on the contract's freight/UoM. */ @Column({ name: 'quantity', type: 'numeric', precision: 12, scale: 3 }) quantity!: number; + + /** + * How much of this line is hazardous / refrigerated, entered per order by the + * customer when they toggle the flag. Drives the HAZARD_SURCHARGE / + * REEFER_SURCHARGE rates on the spawned child booking. Both ≤ quantity. + */ + @Column({ name: 'hazardous_quantity', type: 'numeric', precision: 12, scale: 3, default: 0 }) + hazardousQuantity!: number; + + @Column({ name: 'reefer_quantity', type: 'numeric', precision: 12, scale: 3, default: 0 }) + reeferQuantity!: number; } diff --git a/apps/edr-freight-api/src/modules/booking-orders/entities/booking-order.entity.ts b/apps/edr-freight-api/src/modules/booking-orders/entities/booking-order.entity.ts index 5d6857051..610496d79 100644 --- a/apps/edr-freight-api/src/modules/booking-orders/entities/booking-order.entity.ts +++ b/apps/edr-freight-api/src/modules/booking-orders/entities/booking-order.entity.ts @@ -40,6 +40,14 @@ export class BookingOrder extends BaseEntity { @JoinColumn({ name: 'company_id' }) company?: Company | null; + /** + * The contract route line this order drew down (multi-route general contracts). + * Null for legacy/single-route contracts that have no route lines — the order + * then uses the contract's own origin/destination. + */ + @Column({ name: 'route_line_id', type: 'uuid', nullable: true }) + routeLineId?: string | null; + @Column({ name: 'scheduled_date', type: 'timestamptz' }) scheduledDate!: Date; diff --git a/apps/edr-freight-api/src/modules/booking-orders/entities/contract-route-line.entity.ts b/apps/edr-freight-api/src/modules/booking-orders/entities/contract-route-line.entity.ts new file mode 100644 index 000000000..e05af758b --- /dev/null +++ b/apps/edr-freight-api/src/modules/booking-orders/entities/contract-route-line.entity.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/booking-orders/general-contract.service.ts b/apps/edr-freight-api/src/modules/booking-orders/general-contract.service.ts index ba382d085..4cf5dfe59 100644 --- a/apps/edr-freight-api/src/modules/booking-orders/general-contract.service.ts +++ b/apps/edr-freight-api/src/modules/booking-orders/general-contract.service.ts @@ -4,7 +4,11 @@ import { DataSource } from 'typeorm'; import { DropdownSettingsService } from '../dropdown-settings/dropdown-settings.service'; import { Booking } from '../bookings/entities/booking.entity'; import { BookingOrder } from './entities/booking-order.entity'; -import { ContractQuantityLineView } from './dto/contract-view.dto'; +import { ContractRouteLine } from './entities/contract-route-line.entity'; +import { + ContractQuantityLineView, + ContractRouteLineView, +} from './dto/contract-view.dto'; /** Setting code holding the global ordering window (in months) for general contracts. */ export const CONTRACT_PERIOD_SETTING_CODE = 'general_contract_period'; @@ -120,6 +124,70 @@ export class GeneralContractService { ]; } + /** + * Per-route drawdown pool for a multi-route general contract: contracted vs. + * ordered vs. remaining, one entry per contracted route line. Returns [] for + * single-route contracts (no route lines) — callers fall back to + * {@link getQuantityLines}. + */ + async getRouteLines( + contractBookingId: string, + ): Promise { + 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> { + 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(); + for (const row of rows) if (row.key) map.set(row.key, Number(row.total)); + return map; + } + /** Sum of non-cancelled order line quantities, keyed by container type id ('' = bulk). */ private async orderedByContainerType( contractBookingId: string, @@ -155,6 +223,12 @@ export class GeneralContractService { /** True once every contracted line is fully drawn down. */ async isExhausted(contractBookingId: string): Promise { + // Multi-route contracts are exhausted when every route line is drawn down; + // single-route contracts fall back to the per-container-type pool. + const routeLines = await this.getRouteLines(contractBookingId); + if (routeLines.length > 0) { + return routeLines.every((l) => l.remainingQuantity <= 0); + } const lines = await this.getQuantityLines(contractBookingId); return lines.every((l) => l.remainingQuantity <= 0); } diff --git a/apps/edr-freight-api/src/modules/booking-orders/road.util.spec.ts b/apps/edr-freight-api/src/modules/booking-orders/road.util.spec.ts new file mode 100644 index 000000000..10258e3b4 --- /dev/null +++ b/apps/edr-freight-api/src/modules/booking-orders/road.util.spec.ts @@ -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); + }); + }); +}); diff --git a/apps/edr-freight-api/src/modules/booking-orders/road.util.ts b/apps/edr-freight-api/src/modules/booking-orders/road.util.ts new file mode 100644 index 000000000..cbaad7a9c --- /dev/null +++ b/apps/edr-freight-api/src/modules/booking-orders/road.util.ts @@ -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 | 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; +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-contract.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-contract.service.ts index 6601ca704..fabd3dbca 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-contract.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-contract.service.ts @@ -19,9 +19,17 @@ import { FileRecord } from '../files/entities/file.entity'; import { BookingsRepository } from './bookings.repository'; import { Booking } from './entities/booking.entity'; import { assertBookingStatus } from './booking-status.util'; +import { clearanceSettingCode } from './clearance.util'; import { ContractViewDto } from './dto/contract-view.dto'; import { SignContractDto } from './dto/sign-contract.dto'; import { ContractSignerRole } from './entities/booking-contract-signature.entity'; + +/** + * Default ordering window (months) for a general contract activated on + * counter-sign. Mirrors GeneralContractService.DEFAULT_CONTRACT_PERIOD_MONTHS; + * defined locally to avoid a circular module dependency on booking-orders. + */ +const DEFAULT_CONTRACT_PERIOD_MONTHS = 3; import { BookingBatchService } from '../train-scheduling/booking-batch.service'; import { SignaturesService } from '../signatures/signatures.service'; @@ -222,19 +230,45 @@ export class BookingContractService { const updates: Record = {}; + // Whether a document-clearance gate applies (IMPORT/EXPORT bookings). When it + // does, the counter-signed booking goes to AWAITING_DOCUMENTS for the customer + // to upload clearance documents instead of straight into the batch pipeline. + const includesCustoms = booking.serviceType?.includesCustoms ?? false; + const clearanceCode = clearanceSettingCode( + booking.tradeDirection, + booking.freightType, + includesCustoms, + ); + + const isGeneralContract = booking.bookingType === 'GENERAL_CONTRACT'; + if (role === 'CUSTOMER') { updates.status = 'SIGNED_CUSTOMER'; updates.customerSignedAt = now; - } else { - updates.status = 'FULLY_EXECUTED'; + } else if (isGeneralContract) { + // A general contract is NOT paid up front — each drawdown order is priced + // and paid on its own. So on counter-sign it becomes ACTIVE directly and + // opens its ordering window; orders spawn their own priced child bookings. + const expiresAt = new Date(now); + expiresAt.setMonth(expiresAt.getMonth() + DEFAULT_CONTRACT_PERIOD_MONTHS); updates.fullyExecutedAt = now; updates.marketingApprovedAt = now; updates.marketingApprovedById = options.signerUserId ?? null; updates.lockedAt = now; + updates.status = 'CONTRACT_ACTIVE'; + updates.expiresAt = expiresAt; + } else { + updates.fullyExecutedAt = now; + updates.marketingApprovedAt = now; + updates.marketingApprovedById = options.signerUserId ?? null; + updates.lockedAt = now; + updates.status = clearanceCode ? 'AWAITING_DOCUMENTS' : 'FULLY_EXECUTED'; } const updated = await this.bookingsRepository.update(bookingId, updates as never); - if (role === 'STAFF' && updated?.trainScheduleId) { + // Only the non-clearance (legacy/domestic) path enters the batch pipeline now; + // clearance bookings enter operations after the GL document gate. + if (role === 'STAFF' && !clearanceCode && updated?.trainScheduleId) { this.bookingBatchService.enqueueScheduleProcessing(updated.trainScheduleId); } try { diff --git a/apps/edr-freight-api/src/modules/bookings/booking-next-step.util.ts b/apps/edr-freight-api/src/modules/bookings/booking-next-step.util.ts index b79c4ef20..ac6e35b56 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-next-step.util.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-next-step.util.ts @@ -57,6 +57,45 @@ export function computeNextStep( action: 'AWAIT_PAYMENT', description: 'Awaiting customer payment', }; + case 'AWAITING_DOCUMENTS': + return { + action: 'UPLOAD_DOCUMENTS', + description: 'Upload the clearance documents for your shipment', + }; + case 'DOCUMENTS_UNDER_REVIEW': + return { + action: 'AWAIT_DOCUMENT_REVIEW', + description: 'Global Logistics is reviewing your documents', + }; + case 'CLEARANCE_READY': + return { + action: 'PROCEED_TO_OPERATION', + description: + 'Clearance is ready — pick a schedule day and request operation', + }; + case 'OPERATION_REQUEST_PENDING': + return { + action: 'AWAIT_OPERATION_REVIEW', + description: + 'Operations is reviewing your request (capacity, documents, route)', + }; + case 'OPERATION_CHANGES_REQUESTED': + return { + action: 'RESUBMIT_OPERATION', + description: + 'Operations requested changes — update and resubmit your operation request', + }; + case 'OPERATION_PRICE_PENDING_CONFIRM': + return { + action: 'CONFIRM_OPERATION_PRICE', + description: + 'Operations adjusted the price — confirm the new total to proceed', + }; + case 'OPERATION_REQUESTED': + return { + action: 'AWAIT_OPERATION', + description: 'Operation requested; an operator will take it forward', + }; case 'PAID': return { action: 'START_TRANSIT', diff --git a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts index 471fcb6f2..1c1b490dd 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts @@ -44,7 +44,6 @@ describe('BookingPricingService — domestic corridor', () => { {} as never, {} as never, ratesService as never, - {} as never, exchangeService as never, ); }); diff --git a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts index fbafaa911..0315de60d 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts @@ -2,7 +2,6 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { ContainerTypesService } from '../rule-engine/services/container-types.service'; import { RatesService } from '../rule-engine/services/rates.service'; -import { ServiceTypesService } from '../rule-engine/services/service-types.service'; import { Rate } from '../rule-engine/entities/rate.entity'; import { ExchangeService } from '@edr/api-common'; import { @@ -11,6 +10,10 @@ import { RuleEngineService, } from '../rule-engine/rule-engine.service'; import { BookingsRepository } from './bookings.repository'; +import { + containersPerWagon, + wagonRemainder, +} from './consolidation.service'; import { GeneratePriceResponseDto, PriceLineItemDto } from './dto/generate-price-response.dto'; import { Booking } from './entities/booking.entity'; import { assertBookingStatus } from './booking-status.util'; @@ -33,6 +36,29 @@ type StoredPricingBreakdown = { generatedAt?: string; } | null; +/** Friendly labels for the per-unit rate card shown at the confirm step. */ +const SURCHARGE_LABELS: Record = { + HAZARD_SURCHARGE: 'Hazardous cargo', + HAZARDOUS_CARGO: 'Hazardous cargo', + REEFER_SURCHARGE: 'Refrigerated (reefer)', + REEFER_CARGO: 'Refrigerated (reefer)', + OVERWEIGHT_PER_TON: 'Overweight excess', + DOUBLE_HANDLING: 'Double handling', + LASHING: 'Lashing', + PIL_EXTRA_FEE: 'Shipping line fee', +}; + +function surchargeLabel(code: string): string { + return ( + SURCHARGE_LABELS[code] ?? + code + .toLowerCase() + .split('_') + .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) + .join(' ') + ); +} + @Injectable() export class BookingPricingService { constructor( @@ -40,7 +66,6 @@ export class BookingPricingService { private readonly ruleEngineService: RuleEngineService, private readonly containerTypesService: ContainerTypesService, private readonly ratesService: RatesService, - private readonly serviceTypesService: ServiceTypesService, private readonly exchangeService: ExchangeService, ) {} @@ -103,16 +128,35 @@ export class BookingPricingService { for (const mod of ruleResult.appliedModifiers) { const usdAmount = mod.calculatedAmount; const convertedAmount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount; + + const rate = rateById.get(mod.rateId); + const unit = rate?.rateUnit ?? 'FLAT'; + const unitUsd = rate ? Number(rate.rateValue) : usdAmount; + const unitAmount = isEtbBooking ? Math.round(unitUsd * usdToEtb) : unitUsd; + // Per-unit count: FLAT and PER_INVOICE are billed once (qty 1); an + // explicit trigger (e.g. overweight tons) wins when present; otherwise + // derive from total ÷ unit price. + const quantity = + unit === 'FLAT' || unit === 'PER_INVOICE' + ? 1 + : mod.triggerValue != null && mod.triggerValue > 0 + ? mod.triggerValue + : unitUsd > 0 + ? Math.max(1, Math.round(usdAmount / unitUsd)) + : 1; + const item: PriceLineItemDto = { - code: mod.surchargeTypeCode, - description: `Surcharge: ${mod.surchargeTypeCode}`, + code: mod.surchargeCode, + description: surchargeLabel(mod.surchargeCode), amount: convertedAmount, + unitAmount, + unit, + quantity, currency: paymentCurrency, }; lineItems.push(item); total += convertedAmount; - const rate = rateById.get(mod.rateId); if (rate) usedRatesMap.set(rate.id, rate); } @@ -152,7 +196,7 @@ export class BookingPricingService { if (!snapshotId) return null; return { bookingId, - surchargeTypeId: m.surchargeTypeId, + rateId: m.rateId, triggerValue: m.triggerValue, calculatedAmount: m.calculatedAmount, rateSnapshotId: snapshotId, @@ -166,7 +210,7 @@ export class BookingPricingService { } async buildEvalInputForBooking(booking: Booking): Promise { - const containers = await Promise.all( + const lines = await Promise.all( (booking.bookingContainers ?? []) .filter((bc): bc is typeof bc & { containerTypeId: string } => bc.containerTypeId != null) .map(async (bc) => { @@ -174,14 +218,19 @@ export class BookingPricingService { const vgm = Number(bc.vgmPerUnitTons); const qty = bc.quantity; return { - containerTypeId: bc.containerTypeId, + container: { + containerTypeId: bc.containerTypeId, + quantity: qty, + vgmPerUnitTons: vgm, + totalVgmTons: qty * vgm, + isReefer: ct.isReefer, + }, + perWagon: containersPerWagon(Number(ct.wagonsPerUnit)), quantity: qty, - vgmPerUnitTons: vgm, - totalVgmTons: qty * vgm, - isReefer: ct.isReefer, }; }), ); + const containers = lines.map((l) => l.container); // Wagon count is persisted per container line at booking creation; sum it. const totalWagons = booking.freightType === 'CONTAINER' @@ -193,15 +242,38 @@ export class BookingPricingService { ) : 0; + // Consolidation is system-managed: the CONSOLIDATION surcharge fires whenever + // a container type leaves a wagon partially filled. Aggregate by type first — + // two lines of the same type share wagons, so 2× 20FT (= one full wagon) must + // NOT count as a partial wagon. Mirrors ConsolidationService.slotsFromContainerLines. + const remainderByType = new Map(); + for (const l of lines) { + const prev = remainderByType.get(l.container.containerTypeId); + remainderByType.set(l.container.containerTypeId, { + quantity: (prev?.quantity ?? 0) + Number(l.quantity || 0), + perWagon: l.perWagon, + }); + } + const allowConsolidation = + booking.freightType === 'CONTAINER' && + [...remainderByType.values()].some( + (t) => wagonRemainder(t.quantity, t.perWagon) > 0, + ); + return { freightType: booking.freightType as 'CONTAINER' | 'BULK', cargoTypeId: booking.cargoTypeId ?? null, serviceTypeId: booking.serviceTypeId, paymentCurrency: booking.paymentCurrency, tradeDirection: booking.tradeDirection, - isHazardous: booking.isHazardous, + // Coerce defensively in case the stored flag is a string ("true"/"false"). + isHazardous: booking.isHazardous === true || (booking.isHazardous as unknown) === 'true', + // Booking-level reefer flag (set by contract drawdown orders that carry a + // reefer quantity) applies the REEFER surcharge even for non-reefer + // container types. ORed with per-container reefer in the engine. + isReefer: booking.isReefer === true || (booking.isReefer as unknown) === 'true', isGovernment: booking.isGovernment, - allowConsolidation: booking.allowConsolidation, + allowConsolidation, shippingLineId: booking.shippingLineId, totalWagons, containers, @@ -240,6 +312,9 @@ export class BookingPricingService { code: 'TOTAL', description: 'Contract total', amount: total, + unitAmount: total, + unit: 'FLAT', + quantity: 1, currency: booking.paymentCurrency, }, ], @@ -255,27 +330,18 @@ export class BookingPricingService { }; } - /** Recompute priority on submit (USD + service tier). */ + /** + * Recompute priority on submit. + * + * The full priority model is additive and capped at 100: + * service-type bonus (≤ 15) + wagon block (≤ 50) + currency block (≤ 35). + * All three components are produced by RuleEngineService.evaluate, so submit + * simply re-runs the engine — there is no extra submit-time inflation. + */ async computeSubmitPriorityScore(booking: Booking): Promise { const evalInput = await this.buildEvalInputForBooking(booking); const ruleResult = await this.ruleEngineService.evaluate(evalInput); - let score = ruleResult.priorityScore; - - const serviceType = await this.serviceTypesService.findById(booking.serviceTypeId); - if (booking.paymentCurrency === 'USD' && serviceType) { - const code = (serviceType.code ?? '').toUpperCase(); - const hasForwarding = - serviceType.includesFirstMile || - serviceType.includesLastMile || - code.includes('FORWARD') || - code.includes('Y'); - const railOnly = code.includes('RAIL') && !hasForwarding; - - if (hasForwarding) score += 1000; - else if (railOnly || code.includes('X')) score += 500; - } - - return score; + return ruleResult.priorityScore; } private async computeBaseRailLinesWithRates( @@ -312,10 +378,15 @@ export class BookingPricingService { usedRatesMap.set(rate.id, rate); const usdAmount = this.amountForRate(rate, container.quantity, wagonCount); const amount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount; + const unitUsd = Number(rate.rateValue); + const label = await this.containerTypeLabel(container.containerTypeId); lines.push({ code: rateType, - description: `Base rail (${rateType})`, + description: `${label} rail freight`, amount, + unitAmount: isEtbBooking ? Math.round(unitUsd * usdToEtb) : unitUsd, + unit: rate.rateUnit, + quantity: this.effectiveUnitQuantity(rate.rateUnit, container.quantity, wagonCount), currency: paymentCurrency, }); } @@ -331,10 +402,14 @@ export class BookingPricingService { isBulk && fallback.rateUnit === 'PER_TON' ? Math.max(bulkTons, 0) : 1; const usdAmount = this.amountForRate(fallback, quantity, wagonCount); const amount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount; + const unitUsd = Number(fallback.rateValue); lines.push({ code: rateType, - description: `Base rail (${rateType})`, + description: isBulk ? 'Bulk rail freight' : 'Container rail freight', amount, + unitAmount: isEtbBooking ? Math.round(unitUsd * usdToEtb) : unitUsd, + unit: fallback.rateUnit, + quantity: this.effectiveUnitQuantity(fallback.rateUnit, quantity, wagonCount), currency: paymentCurrency, }); } @@ -343,6 +418,34 @@ export class BookingPricingService { return { lineItems: lines, usedRates: [...usedRatesMap.values()] }; } + /** Friendly container-type label for the per-unit card; degrades to "Container". */ + private async containerTypeLabel(containerTypeId: string): Promise { + try { + const ct = await this.containerTypesService?.findById?.(containerTypeId); + return ct?.label ?? 'Container'; + } catch { + return 'Container'; + } + } + + /** How many units a rate's total is divided into, by rate unit (for the per-unit card). */ + private effectiveUnitQuantity( + rateUnit: string, + quantity: number, + wagonCount: number, + ): number { + switch (rateUnit) { + case 'PER_WAGON': + return wagonCount; + case 'FLAT': + return 1; + case 'PER_CONTAINER': + case 'PER_TON': + default: + return quantity; + } + } + private pickRate( rates: Rate[], rateType: string, diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.accept.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.accept.spec.ts new file mode 100644 index 000000000..676d1b5d4 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.accept.spec.ts @@ -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' }), + ); + }); +}); diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.clearance.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.clearance.spec.ts new file mode 100644 index 000000000..12ce77d6d --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.clearance.spec.ts @@ -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' }), + ); + }); +}); diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.operation.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.operation.spec.ts new file mode 100644 index 000000000..92ab8769e --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.operation.spec.ts @@ -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', + }), + ); + }); +}); diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts index c41013ddb..2c2e0ce0e 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts @@ -7,11 +7,17 @@ import { import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; import { assertCanApproveBookingStep } from '../../common/freight-permission.util'; +import { BookingBatchService } from '../train-scheduling/booking-batch.service'; +import { eatDay } from '../train-scheduling/batch-window.util'; +import { isRoadService } from '../booking-orders/road.util'; import { RuleEngineService } from '../rule-engine/rule-engine.service'; +import { FilesService } from '../files/files.service'; +import { FileUploadSettingsService } from '../file-upload-settings/file-upload-settings.service'; import { BookingContractService } from './booking-contract.service'; import { BookingPricingService } from './booking-pricing.service'; import { BookingsRepository } from './bookings.repository'; import { assertBookingStatus } from './booking-status.util'; +import { clearanceCodesForBooking } from './clearance.util'; import { computeNextStep, type BookingNextStep } from './booking-next-step.util'; import { SubmitBookingResponseDto } from './dto/submit-booking-response.dto'; import { PriceLineItemDto } from './dto/generate-price-response.dto'; @@ -25,6 +31,10 @@ export class BookingTransitionService { private readonly ruleEngineService: RuleEngineService, private readonly pricingService: BookingPricingService, private readonly contractService: BookingContractService, + private readonly filesService: FilesService, + private readonly fileUploadSettingsService: FileUploadSettingsService, + @Inject(forwardRef(() => BookingBatchService)) + private readonly bookingBatchService: BookingBatchService, @Inject(forwardRef(() => BookingsService)) private readonly bookingsService: BookingsService, ) {} @@ -193,13 +203,31 @@ export class BookingTransitionService { }); } - async acceptIntake(bookingId: string, actorId: string): Promise { + async acceptIntake( + bookingId: string, + actorId: string, + validityDays: number, + ): Promise { const booking = await this.bookingsService.findById(bookingId); // Only SUBMITTED bookings are acceptable. A booking that still needs // consolidation sits in PENDING_CONSOLIDATION (resolved at submit time) and // is therefore never offered for accept until a partner moves it to SUBMITTED. assertBookingStatus(booking, ['SUBMITTED']); + // The backoffice must define how long the accepted contract stays valid. + // Without a window the contract has no end date and cannot be relied on, so + // accept is blocked until a positive number of days is supplied. + if (!Number.isInteger(validityDays) || validityDays < 1) { + throw new BadRequestException( + 'A contract validity (in days) is required to accept this booking.', + ); + } + + // Validity runs from the accept moment through accept + N days. + const validFrom = new Date(); + const validUntil = new Date(validFrom); + validUntil.setDate(validUntil.getDate() + validityDays); + await this.ruleEngineService.instantiateApprovalSteps(bookingId, { freightType: booking.freightType as 'CONTAINER' | 'BULK', cargoTypeId: booking.cargoTypeId, @@ -208,7 +236,10 @@ export class BookingTransitionService { const updated = await this.bookingsRepository.update(bookingId, { status: 'PENDING_APPROVAL', approvedByStaffId: actorId, - approvedByStaffAt: new Date(), + approvedByStaffAt: validFrom, + contractValidityDays: validityDays, + contractValidFrom: validFrom, + contractValidUntil: validUntil, } as never); return this.bookingsService.findById(updated!.id); } @@ -420,6 +451,472 @@ export class BookingTransitionService { return this.bookingsService.findById(updated!.id); } + /** + * Customer rejects the priced booking at the confirm step. The booking becomes + * REJECTED (terminal) — the customer starts a new booking rather than editing + * this one. Only a not-yet-committed booking can be rejected this way. + */ + async reject(bookingId: string, reason?: string): Promise { + 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 { + 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 + >['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 { + 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_ 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { .innerJoinAndSelect('b.bookingContainers', 'bc') .innerJoin('bc.containerType', 'ct') .where('b.id != :bookingId', { bookingId: booking.id }) - .andWhere('b.allowConsolidation = true') .andWhere('b.consolidationPartnerId IS NULL') // Only pair bookings the customer has committed (SUBMITTED) or that are // already waiting (PENDING_CONSOLIDATION). DRAFT bookings are excluded so @@ -315,11 +317,87 @@ export class BookingsRepository extends BaseRepository { return pending === 0; } + // ── Clearance document reviews ──────────────────────────────────────────── + + findDocumentReviews(bookingId: string): Promise { + return this.dataSource.getRepository(BookingDocumentReview).find({ + where: { bookingId }, + order: { createdAt: 'ASC' }, + }); + } + + findDocumentReview( + bookingId: string, + settingCode: string, + fileKey: string, + ): Promise { + 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 { + 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 { + const repo = this.dataSource.getRepository(BookingDocumentReview); + const existing = await repo.findOne({ + where: { bookingId, settingCode, fileKey }, + }); + const patch = { + status, + note: note ?? null, + reviewedByStaffId: staffId, + reviewedAt: new Date(), + }; + if (existing) { + await repo.update(existing.id, patch); + return; + } + await repo.save(repo.create({ bookingId, settingCode, fileKey, ...patch })); + } + /** Persist cargo modifiers linked to rate snapshots. */ async createCargoModifiers( rows: Array<{ bookingId: string; - surchargeTypeId: string; + rateId: string; triggerValue: number | null; calculatedAmount: number; rateSnapshotId: string; @@ -650,11 +728,6 @@ export class BookingsRepository extends BaseRepository { excludePaymentStatus: options.excludePaymentStatus, }); } - if (options.allowConsolidation !== undefined) { - qb.andWhere('booking.allow_consolidation = :allowConsolidation', { - allowConsolidation: options.allowConsolidation, - }); - } if (options.consolidationPaired === 'true') { qb.andWhere('booking.consolidation_partner_id IS NOT NULL'); } else if (options.consolidationPaired === 'false') { diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index b6f3ee220..45d2fbf7c 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -11,6 +11,7 @@ import { Freight, SchedulingStatus } from '@edr/types'; // import { CustomersService } from '../customers/customers.service'; import { CompaniesService } from '../companies/companies.service'; import { ProfileType } from '../companies/entities/company-profile.entity'; +import { CompanyStatus } from '../companies/entities/company.entity'; import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service'; import { eatDay } from '../train-scheduling/batch-window.util'; import { FilesService } from '../files/files.service'; @@ -25,6 +26,7 @@ import { DataSource, In } from 'typeorm'; import { deriveTradeDirection } from '../../common/derive-trade-direction.util'; import { Yard } from '../rule-engine/entities/yard.entity'; +import { ContractRouteLine } from '../booking-orders/entities/contract-route-line.entity'; import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; import { BookingsRepository } from './bookings.repository'; import { ConsolidationService } from './consolidation.service'; @@ -125,7 +127,6 @@ export class BookingsService { tradeDirection: string; isHazardous?: boolean; isGovernment?: boolean; - allowConsolidation?: boolean; shippingLineId?: string | null; containers: CreateBookingContainerDto[]; }): Promise { @@ -150,6 +151,14 @@ export class BookingsService { containers.reduce((sum, c) => sum + c.wagonsRequired, 0), ); + // Consolidation is system-managed: the CONSOLIDATION_ENABLED rule trigger + // fires whenever a container line leaves a wagon partially filled. There is + // no customer opt-in — partial-wagon cargo always consolidates. + const allowConsolidation = + dto.freightType === 'CONTAINER' + ? await this.needsConsolidation(dto.containers) + : false; + return { freightType: dto.freightType, cargoTypeId: dto.cargoTypeId ?? null, @@ -158,8 +167,7 @@ export class BookingsService { tradeDirection: dto.tradeDirection, isHazardous: dto.isHazardous ?? false, isGovernment: dto.isGovernment ?? false, - allowConsolidation: - dto.freightType === 'CONTAINER' ? dto.allowConsolidation : false, + allowConsolidation, shippingLineId: dto.shippingLineId, totalWagons, containers, @@ -167,26 +175,20 @@ export class BookingsService { } /** - * Enable consolidation when any container line leaves a wagon partially filled - * (e.g. 1×20ft on a 2-slot wagon, 1×10ft on a 4-slot wagon). - * - * Partial-wagon cargo ALWAYS consolidates — the customer cannot opt out of a - * half-empty wagon, so `explicit === false` is ignored when consolidation is - * actually needed. The opt-in flag only matters for cargo that already fills - * whole wagons (where consolidation is moot anyway). + * True when any container line leaves a wagon partially filled (e.g. 1×20ft on + * a 2-slot wagon). Partial-wagon cargo must consolidate before it can finalize; + * cargo that already fills whole wagons never does. This is computed from the + * container quantities alone — there is no customer-facing opt-in flag. */ - private async resolveConsolidation( + private async needsConsolidation( containers: CreateBookingContainerDto[], - explicit?: boolean, ): Promise { - const needs = await this.consolidationService.needsConsolidation( + return this.consolidationService.needsConsolidation( containers.map((c) => ({ containerTypeId: c.containerTypeId, quantity: c.quantity, })), ); - if (needs) return true; - return explicit ?? false; } /** Search for a complementary partner; pair or queue as PENDING_CONSOLIDATION. */ @@ -196,10 +198,12 @@ export class BookingsService { }> { const messages: string[] = []; - if (!booking.allowConsolidation || booking.consolidationPartnerId) { + if (booking.consolidationPartnerId) { return { booking, messages }; } + // Only partial-wagon container lines produce slots; full-wagon (and bulk) + // bookings return none and need no consolidation. const slots = await this.consolidationService.slotsFromBooking(booking); if (slots.length === 0) { return { booking, messages }; @@ -287,6 +291,12 @@ export class BookingsService { ); } const { company } = await this.companiesService.getCompanyInfoByUserId(userId); + // A customer can only book once their company has been approved. + if (company.status !== CompanyStatus.Active) { + throw new ForbiddenException( + "Your company is awaiting approval — you can't create bookings yet.", + ); + } companyId = company.id; } @@ -363,9 +373,9 @@ export class BookingsService { ); } - const allowConsolidation = + const needsConsolidation = dto.freightType === 'CONTAINER' - ? await this.resolveConsolidation(containers, dto.allowConsolidation) + ? await this.needsConsolidation(containers) : false; const evalInput = await this.buildEvalInput({ @@ -376,7 +386,6 @@ export class BookingsService { tradeDirection, isHazardous: dto.isHazardous, isGovernment, - allowConsolidation, shippingLineId: dto.shippingLineId, containers, }); @@ -397,7 +406,13 @@ export class BookingsService { previousContractId: dto.previousContractId, serviceTypeId: dto.serviceTypeId, firstMilePickupAddress: dto.firstMilePickupAddress, + firstMilePickupLat: dto.firstMilePickupLat ?? null, + firstMilePickupLng: dto.firstMilePickupLng ?? null, lastMileDeliveryAddress: dto.lastMileDeliveryAddress, + lastMileDeliveryLat: dto.lastMileDeliveryLat ?? null, + lastMileDeliveryLng: dto.lastMileDeliveryLng ?? null, + customsClearingEnabled: dto.customsClearingEnabled ?? false, + customsClearingAgent: dto.customsClearingAgent ?? null, equipmentReturn: dto.equipmentReturn, originYardId: dto.originYardId, destinationYardId: dto.destinationYardId, @@ -416,7 +431,6 @@ export class BookingsService { startDate: dto.startDate ? new Date(dto.startDate) : undefined, endDate: dto.endDate ? new Date(dto.endDate) : undefined, status: 'DRAFT', - allowConsolidation, priorityScore: ruleResult.priorityScore, totalAmount: 0, paymentStatus: 'PENDING', @@ -436,6 +450,25 @@ export class BookingsService { warnings.push(`Estimated wagons required: ${wagonCount}`); } + // Multi-route general contracts: persist the contracted routes + quantities. + // Each drawdown order later draws from one of these route lines. + if (isGeneralContract && dto.routes?.length) { + const routeRepo = this.dataSource.getRepository(ContractRouteLine); + await routeRepo.save( + dto.routes.map((r) => + routeRepo.create({ + contractBookingId: booking.id, + originYardId: r.originYardId, + destinationYardId: r.destinationYardId, + containerTypeId: + dto.freightType === 'CONTAINER' ? (r.containerTypeId ?? null) : null, + quantity: r.quantity, + km: r.km ?? null, + }), + ), + ); + } + if (files.length > 0) { try { await this.filesService.uploadMany(booking.id, 'bookings', files); @@ -444,9 +477,36 @@ export class BookingsService { } } + // Reuse the booking profile's onboarding documents instead of asking the + // customer to re-upload. Snapshot them onto the booking now (by reference), + // so a later active-profile switch never changes this booking's documents. + if (companyProfileId) { + try { + const onboardingFiles = + await this.companiesService.getProfileOnboardingFiles(companyProfileId); + if (onboardingFiles.length > 0) { + await this.filesService.attachExistingFiles( + booking.id, + 'bookings', + onboardingFiles.map((f, i) => ({ + code: `onboarding_document_${i + 1}`, + name: f.name, + url: f.url, + size: f.size, + mimeType: f.mimeType, + })), + ); + } + } catch { + warnings.push( + 'Could not attach onboarding documents — they can be added from the booking page.', + ); + } + } + let full = await this.findById(booking.id); - if (allowConsolidation) { + if (needsConsolidation) { const consolidation = await this.tryAutoConsolidate(full); full = consolidation.booking; warnings.push(...consolidation.messages); @@ -505,12 +565,9 @@ export class BookingsService { dto.tradeDirection, ); - const allowConsolidation = + const needsConsolidation = freightType === 'CONTAINER' - ? await this.resolveConsolidation( - containers, - dto.allowConsolidation ?? existing.allowConsolidation, - ) + ? await this.needsConsolidation(containers) : false; const evalInput = await this.buildEvalInput({ @@ -520,7 +577,6 @@ export class BookingsService { paymentCurrency: dto.paymentCurrency ?? existing.paymentCurrency, tradeDirection, isHazardous: dto.isHazardous ?? existing.isHazardous, - allowConsolidation, shippingLineId: dto.shippingLineId ?? existing.shippingLineId ?? undefined, containers, }); @@ -529,12 +585,11 @@ export class BookingsService { this.ruleEngineService.assertNoHardBlocks(ruleResult); warnings.push(...ruleResult.warnings); - const pricingFieldsChanged = this.pricingRelevantFieldsChanged( + const pricingFieldsChanged = await this.pricingRelevantFieldsChanged( existing, dto, freightType, cargoTypeId, - allowConsolidation, containers, ); @@ -542,7 +597,6 @@ export class BookingsService { ...dto, freightType, cargoTypeId: freightType === 'BULK' ? cargoTypeId : null, - allowConsolidation, priorityScore: ruleResult.priorityScore, tradeDirection, }; @@ -592,7 +646,7 @@ export class BookingsService { let booking = await this.findById(id); - if (allowConsolidation && !booking.consolidationPartnerId) { + if (needsConsolidation && !booking.consolidationPartnerId) { const consolidation = await this.tryAutoConsolidate(booking); booking = consolidation.booking; warnings.push(...consolidation.messages); @@ -656,10 +710,11 @@ export class BookingsService { assignedToSchedule: filter.assignedToSchedule, // A forced company scope (portal/customer) overrides any caller-provided // companyId so a customer can only ever see their own company's bookings. - // When an active profile resolves, scope to it; otherwise fall back to the - // company so nothing breaks for not-yet-onboarded customers. - companyId: forceCompanyProfileId ? undefined : forceCompanyId ?? filter.companyId, - companyProfileId: forceCompanyProfileId, + // The company guard always applies; the optional companyProfileId filter + // (from the per-page service filter) narrows WITHIN the company — the repo + // ANDs both, so cross-company access is impossible. + companyId: forceCompanyId ?? filter.companyId, + companyProfileId: forceCompanyProfileId ?? filter.companyProfileId, contractType: filter.contractType, serviceTypeId: filter.serviceTypeId, cargoTypeId: filter.cargoTypeId, @@ -670,7 +725,6 @@ export class BookingsService { paymentStatus: filter.paymentStatus, createdFrom: filter.createdFrom, createdTo: filter.createdTo, - allowConsolidation: filter.allowConsolidation, consolidationPaired: filter.consolidationPaired, sortBy: filter.sortBy, sortOrder: filter.sortOrder, @@ -694,18 +748,15 @@ export class BookingsService { filter: FilterBookingDto, ): Promise { const { company } = await this.companiesService.getCompanyInfoByUserId(userId); - // Scope to the active operational profile when one resolves; fall back to - // company-level so not-yet-onboarded customers still see their payables. - const companyProfileId = - await this.companiesService.resolveActiveCompanyProfileId(userId); return this.bookingsRepository.findAllPaginated({ page: filter.page ?? 1, pageSize: filter.pageSize ?? 20, statuses: BookingsService.PAYABLE_STATUSES, excludePaymentStatus: 'PAID', - companyId: companyProfileId ? undefined : company.id, - companyProfileId: companyProfileId ?? undefined, + // Company-wide: payables span all of the customer's services. + companyId: company.id, + companyProfileId: filter.companyProfileId, sortBy: filter.sortBy, sortOrder: filter.sortOrder, }); @@ -841,7 +892,6 @@ export class BookingsService { paymentStatus: filter.paymentStatus, createdFrom: filter.createdFrom, createdTo: filter.createdTo, - allowConsolidation: filter.allowConsolidation, consolidationPaired: filter.consolidationPaired, }; @@ -950,10 +1000,6 @@ export class BookingsService { }> { const booking = await this.findById(id); - if (!booking.allowConsolidation) { - throw new BadRequestException('Booking is not eligible for consolidation'); - } - const needs = await this.consolidationService.needsConsolidationFromBooking( booking, ); @@ -1037,14 +1083,13 @@ export class BookingsService { }; } - private pricingRelevantFieldsChanged( + private async pricingRelevantFieldsChanged( existing: Booking, dto: UpdateBookingDto, freightType: FreightType, cargoTypeId: string | null | undefined, - allowConsolidation: boolean, containers: CreateBookingContainerDto[], - ): boolean { + ): Promise { if (dto.freightType !== undefined && dto.freightType !== existing.freightType) { return true; } @@ -1057,18 +1102,14 @@ export class BookingsService { if (dto.isHazardous !== undefined && dto.isHazardous !== existing.isHazardous) { return true; } - if ( - dto.allowConsolidation !== undefined && - dto.allowConsolidation !== existing.allowConsolidation - ) { - return true; - } if (dto.shippingLineId !== undefined && dto.shippingLineId !== existing.shippingLineId) { return true; } if (dto.cargoTypeId !== undefined && dto.cargoTypeId !== existing.cargoTypeId) { return true; } + // Container lines drive both the base price and the consolidation surcharge + // (CONSOLIDATION_ENABLED fires on partial wagons), so any line change re-prices. if (dto.containers !== undefined) { const existingContainers = (existing.bookingContainers ?? []) .filter((bc): bc is typeof bc & { containerTypeId: string } => bc.containerTypeId != null) @@ -1083,8 +1124,7 @@ export class BookingsService { } if ( freightType !== existing.freightType || - (cargoTypeId ?? null) !== (existing.cargoTypeId ?? null) || - allowConsolidation !== existing.allowConsolidation + (cargoTypeId ?? null) !== (existing.cargoTypeId ?? null) ) { return true; } diff --git a/apps/edr-freight-api/src/modules/bookings/clearance.util.spec.ts b/apps/edr-freight-api/src/modules/bookings/clearance.util.spec.ts new file mode 100644 index 000000000..a7bd13c28 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/clearance.util.spec.ts @@ -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(); + }); +}); diff --git a/apps/edr-freight-api/src/modules/bookings/clearance.util.ts b/apps/edr-freight-api/src/modules/bookings/clearance.util.ts new file mode 100644 index 000000000..69a8232d7 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/clearance.util.ts @@ -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, + }; +} diff --git a/apps/edr-freight-api/src/modules/bookings/consolidation.service.ts b/apps/edr-freight-api/src/modules/bookings/consolidation.service.ts index 541d5d09f..e16b97997 100644 --- a/apps/edr-freight-api/src/modules/bookings/consolidation.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/consolidation.service.ts @@ -57,16 +57,29 @@ export class ConsolidationService { async slotsFromContainerLines( lines: Array<{ containerTypeId: string; quantity: number }>, ): Promise { - 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(); for (const line of lines) { - const ct = await this.containerTypesService.findById(line.containerTypeId); + if (!line.containerTypeId) continue; + quantityByType.set( + line.containerTypeId, + (quantityByType.get(line.containerTypeId) ?? 0) + Number(line.quantity || 0), + ); + } + + const slots: ConsolidationSlot[] = []; + for (const [containerTypeId, quantity] of quantityByType) { + const ct = await this.containerTypesService.findById(containerTypeId); const perWagon = containersPerWagon(Number(ct.wagonsPerUnit)); - const remainder = wagonRemainder(line.quantity, perWagon); + const remainder = wagonRemainder(quantity, perWagon); if (remainder === 0) continue; slots.push({ - containerTypeId: line.containerTypeId, + containerTypeId, containerTypeCode: ct.code, - quantity: line.quantity, + quantity, containersPerWagon: perWagon, remainder, slotsNeeded: perWagon - remainder, diff --git a/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.spec.ts b/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.spec.ts new file mode 100644 index 000000000..e6a4b717b --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.spec.ts @@ -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) => + 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'); + }); +}); diff --git a/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts index b7d5ea14d..e66c85522 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts @@ -11,6 +11,8 @@ import { IsOptional, IsString, IsUUID, + Max, + MaxLength, Min, MinLength, Validate, @@ -53,6 +55,42 @@ export class CreateBookingContainerDto { vgmPerUnitTons!: number; } +export class CreateContractRouteDto { + @ApiProperty({ format: 'uuid', description: 'FK to yards.id (origin)' }) + @IsUUID() + originYardId!: string; + + @ApiProperty({ format: 'uuid', description: 'FK to yards.id (destination)' }) + @IsUUID() + destinationYardId!: string; + + @ApiPropertyOptional({ + format: 'uuid', + description: 'Container type for CONTAINER contracts; omit for BULK', + }) + @IsOptional() + @IsUUID() + containerTypeId?: string; + + @ApiProperty({ description: 'Contracted quantity for this route', minimum: 1 }) + @IsNumber() + @Min(0) + @Transform(({ value }) => Number(value)) + quantity!: number; + + @ApiPropertyOptional({ + description: 'Road distance (km) for this route; used to bill road orders.', + minimum: 0, + }) + @IsOptional() + @IsNumber() + @Min(0) + @Transform(({ value }) => + value === undefined || value === null || value === '' ? undefined : Number(value), + ) + km?: number; +} + export class CreateBookingDto { /** Class-level freight shape check (not a request field). */ @Validate(BookingFreightShapeConstraint) @@ -144,11 +182,55 @@ export class CreateBookingDto { @IsString() firstMilePickupAddress?: string; + @ApiPropertyOptional({ description: 'First-mile pickup latitude (-90..90)' }) + @IsOptional() + @IsNumber() + @Min(-90) + @Max(90) + @Transform(({ value }) => (value == null || value === '' ? undefined : Number(value))) + firstMilePickupLat?: number; + + @ApiPropertyOptional({ description: 'First-mile pickup longitude (-180..180)' }) + @IsOptional() + @IsNumber() + @Min(-180) + @Max(180) + @Transform(({ value }) => (value == null || value === '' ? undefined : Number(value))) + firstMilePickupLng?: number; + @ApiPropertyOptional() @IsOptional() @IsString() lastMileDeliveryAddress?: string; + @ApiPropertyOptional({ description: 'Last-mile delivery latitude (-90..90)' }) + @IsOptional() + @IsNumber() + @Min(-90) + @Max(90) + @Transform(({ value }) => (value == null || value === '' ? undefined : Number(value))) + lastMileDeliveryLat?: number; + + @ApiPropertyOptional({ description: 'Last-mile delivery longitude (-180..180)' }) + @IsOptional() + @IsNumber() + @Min(-180) + @Max(180) + @Transform(({ value }) => (value == null || value === '' ? undefined : Number(value))) + lastMileDeliveryLng?: number; + + @ApiPropertyOptional({ description: 'Whether EDR handles customs clearance' }) + @IsOptional() + @IsBoolean() + @Transform(({ value }) => value === 'true' || value === true) + customsClearingEnabled?: boolean; + + @ApiPropertyOptional({ maxLength: 200, description: 'Customs clearing agent name (when customs is enabled)' }) + @IsOptional() + @IsString() + @MaxLength(200) + customsClearingAgent?: string; + @ApiProperty({ enum: EQUIPMENT_RETURNS }) @IsIn([...EQUIPMENT_RETURNS]) equipmentReturn!: string; @@ -161,6 +243,21 @@ export class CreateBookingDto { @IsUUID() destinationYardId!: string; + /** + * GENERAL_CONTRACT only: the routes this contract reserves quantity across. + * Each entry has its own origin/destination and quantity; the first entry also + * matches the booking's originYardId/destinationYardId. Omitted for one-time + * bookings, which use the single origin/destination above. + */ + @ApiPropertyOptional({ type: [CreateContractRouteDto] }) + @ValidateIf((o) => o.bookingType === 'GENERAL_CONTRACT') + @IsOptional() + @IsArray() + @ArrayMinSize(1) + @ValidateNested({ each: true }) + @Type(() => CreateContractRouteDto) + routes?: CreateContractRouteDto[]; + @ApiProperty({ enum: TRADE_DIRECTIONS }) @IsIn([...TRADE_DIRECTIONS]) tradeDirection!: string; @@ -233,10 +330,4 @@ export class CreateBookingDto { @ValidateNested({ each: true }) @Type(() => CreateBookingContainerDto) containers?: CreateBookingContainerDto[]; - - @ApiPropertyOptional({ default: false }) - @IsOptional() - @IsBoolean() - @Transform(({ value }) => value === 'true' || value === true) - allowConsolidation?: boolean; } diff --git a/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts index d52473813..d189f5448 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts @@ -38,6 +38,15 @@ export class FilterBookingDto { @IsUUID() companyId?: string; + @ApiPropertyOptional({ + format: 'uuid', + description: + 'Narrow to a single operational profile (importer/exporter/freight_forwarder) within the company.', + }) + @IsOptional() + @IsUUID() + companyProfileId?: string; + @ApiPropertyOptional() @IsOptional() contractType?: string; @@ -87,11 +96,6 @@ export class FilterBookingDto { @IsIn([...PAYMENT_STATUSES]) paymentStatus?: string; - @ApiPropertyOptional() - @IsOptional() - @Transform(({ value }) => value === 'true' || value === true) - allowConsolidation?: boolean; - @ApiPropertyOptional({ description: 'true | false — filter paired consolidation' }) @IsOptional() consolidationPaired?: string; diff --git a/apps/edr-freight-api/src/modules/bookings/dto/generate-price-response.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/generate-price-response.dto.ts index 3474bec74..532d6b1a7 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/generate-price-response.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/generate-price-response.dto.ts @@ -7,9 +7,22 @@ export class PriceLineItemDto { @ApiProperty() description!: string; + /** Computed line total (unitAmount × quantity). Retained for totals elsewhere. */ @ApiProperty() amount!: number; + /** Price for a single unit of this charge (e.g. one 20ft container, one ton). */ + @ApiProperty() + unitAmount!: number; + + /** Unit the rate is charged per: PER_CONTAINER | PER_TON | PER_WAGON | PER_KM | FLAT. */ + @ApiProperty() + unit!: string; + + /** How many units this charge applies to (containers, tons, wagons; 1 for FLAT). */ + @ApiProperty() + quantity!: number; + @ApiProperty() currency!: string; } diff --git a/apps/edr-freight-api/src/modules/bookings/dto/request-changes.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/request-changes.dto.ts index 99855d49f..217353987 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/request-changes.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/request-changes.dto.ts @@ -1,5 +1,16 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { IsString, MinLength } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { + IsBoolean, + IsDateString, + IsIn, + IsInt, + IsNumber, + IsOptional, + IsString, + Max, + Min, + MinLength, +} from 'class-validator'; export class RequestChangesDto { @ApiProperty({ description: 'Staff note explaining what the customer must fix' }) @@ -8,6 +19,21 @@ export class RequestChangesDto { note!: string; } +export class AcceptIntakeDto { + @ApiProperty({ + description: + 'How many days the contract stays valid, counted from the accept date. ' + + 'The contract is valid from now through now + validityDays.', + minimum: 1, + maximum: 365, + example: 30, + }) + @IsInt() + @Min(1) + @Max(365) + validityDays!: number; +} + export class StaffRejectDto { @ApiProperty() @IsString() @@ -34,3 +60,92 @@ export class CancelBookingDto { @MinLength(1) reason!: string; } + +export class RejectBookingDto { + @ApiPropertyOptional({ + description: 'Optional reason the customer rejected the price estimate', + }) + @IsOptional() + @IsString() + reason?: string; +} + +export class AdjustPriceDto { + @ApiPropertyOptional({ + description: + 'New total price. Omit or send null to clear a previous adjustment.', + }) + @IsOptional() + @IsNumber() + @Min(0) + amount?: number | null; + + @ApiPropertyOptional({ description: 'Reason for the adjustment' }) + @IsOptional() + @IsString() + reason?: string; +} + +export class ReviewDocumentDto { + @ApiProperty({ description: 'The document fileKey being reviewed' }) + @IsString() + @MinLength(1) + fileKey!: string; + + @ApiProperty({ enum: ['APPROVED', 'QUERIED'] }) + @IsIn(['APPROVED', 'QUERIED']) + status!: 'APPROVED' | 'QUERIED'; + + @ApiPropertyOptional({ description: 'Required when querying a document' }) + @IsOptional() + @IsString() + note?: string; +} + +export class RequestOperationDto { + @ApiProperty({ + description: + 'The schedule day (train departure day) the customer selects for this ' + + 'shipment. ISO date — the booking enters the batch pool for this route + day.', + example: '2026-07-15', + }) + @IsDateString() + scheduledDate!: string; +} + +export class OperationReviewDto { + @ApiProperty({ + description: + 'The operations decision: ACCEPT enters the batch pool; REQUEST_CHANGES ' + + 'returns it to the customer with a note; ADJUST_PRICE sets a new total the ' + + 'customer must re-confirm before it proceeds.', + enum: ['ACCEPT', 'REQUEST_CHANGES', 'ADJUST_PRICE'], + }) + @IsIn(['ACCEPT', 'REQUEST_CHANGES', 'ADJUST_PRICE']) + decision!: 'ACCEPT' | 'REQUEST_CHANGES' | 'ADJUST_PRICE'; + + @ApiPropertyOptional({ + description: 'Required for REQUEST_CHANGES (what the customer must fix).', + }) + @IsOptional() + @IsString() + note?: string; + + @ApiPropertyOptional({ + description: 'New total price — required for ADJUST_PRICE.', + }) + @IsOptional() + @IsNumber() + @Min(0) + amount?: number; +} + +export class ConfirmOperationPriceDto { + @ApiProperty({ + description: + 'true to accept the operations price adjustment and proceed to the ' + + 'batch pool; false to reject it (returns to operation changes requested).', + }) + @IsBoolean() + accept!: boolean; +} diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking-cargo-modifier.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking-cargo-modifier.entity.ts index 5933abae2..ed5b05b36 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/booking-cargo-modifier.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking-cargo-modifier.entity.ts @@ -1,12 +1,12 @@ import { BaseEntity } from '@edr/api-common'; import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; -import { SurchargeType } from '../../rule-engine/entities/surcharge-type.entity'; +import { Rate } from '../../rule-engine/entities/rate.entity'; import { Booking } from './booking.entity'; import { BookingRateSnapshot } from './booking-rate-snapshot.entity'; @Entity({ schema: 'freight', name: 'booking_cargo_modifier' }) @Index(['bookingId']) -@Index(['surchargeTypeId']) +@Index(['rateId']) export class BookingCargoModifier extends BaseEntity { @Column({ name: 'booking_id', type: 'uuid' }) bookingId!: string; @@ -15,12 +15,17 @@ export class BookingCargoModifier extends BaseEntity { @JoinColumn({ name: 'booking_id' }) booking?: Booking; - @Column({ name: 'surcharge_type_id', type: 'uuid' }) - surchargeTypeId!: string; + /** + * The trigger-based rate (hazard, reefer, overweight …) that produced this + * surcharge line. Replaces the former surcharge_type link now that rates are + * self-describing. + */ + @Column({ name: 'rate_id', type: 'uuid' }) + rateId!: string; - @ManyToOne(() => SurchargeType) - @JoinColumn({ name: 'surcharge_type_id' }) - surchargeType?: SurchargeType; + @ManyToOne(() => Rate) + @JoinColumn({ name: 'rate_id' }) + rate?: Rate; @Column({ name: 'trigger_value', type: 'numeric', precision: 14, scale: 4, nullable: true }) triggerValue?: number | null; diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking-document-review.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking-document-review.entity.ts new file mode 100644 index 000000000..532e46610 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking-document-review.entity.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts index d5f358e5f..5693cfaf5 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts @@ -43,6 +43,20 @@ export const BOOKING_STATUSES = [ 'CONSOLIDATED', 'CONTRACT_ACTIVE', 'CONTRACT_CLOSED', + // Post counter-sign document-clearance gate (GL workflow). + 'AWAITING_DOCUMENTS', + 'DOCUMENTS_UNDER_REVIEW', + 'CLEARANCE_READY', + // Road (truck) drawdown orders skip the train batch pool and wait here for + // truck dispatch after Marketing accepts; billed by KM, not wagons. + 'ROAD_DISPATCH_PENDING', + 'OPERATION_REQUESTED', + // Operations review gate: customer picks a schedule day and submits the + // operation request; the operations team reviews capacity/docs/route before + // the booking enters the batch holding pool. + 'OPERATION_REQUEST_PENDING', + 'OPERATION_CHANGES_REQUESTED', + 'OPERATION_PRICE_PENDING_CONFIRM', ] as const; export type BookingStatus = (typeof BOOKING_STATUSES)[number]; @@ -156,6 +170,37 @@ export class Booking extends BaseEntity { @Column({ name: 'total_amount', type: 'numeric', precision: 14, scale: 2, default: 0 }) totalAmount!: number; + /** + * Staff-adjusted total price. When set, it overrides the computed totalAmount + * for the customer, who is shown an "Adjusted by EDR" badge. + */ + @Column({ name: 'adjusted_total_amount', type: 'numeric', precision: 14, scale: 2, nullable: true }) + adjustedTotalAmount?: number | null; + + @Column({ name: 'adjusted_by_staff_id', type: 'uuid', nullable: true }) + adjustedByStaffId?: string | null; + + @Column({ name: 'adjusted_at', type: 'timestamptz', nullable: true }) + adjustedAt?: Date | null; + + @Column({ name: 'adjustment_reason', type: 'text', nullable: true }) + adjustmentReason?: string | null; + + /** + * Contract validity window, set by the backoffice at the accept step. The + * staff enter a number of days; the contract is valid from contractValidFrom + * (the accept moment) through contractValidUntil (validFrom + N days). Outside + * this window the contract is expired and the booking cannot proceed. + */ + @Column({ name: 'contract_validity_days', type: 'int', nullable: true }) + contractValidityDays?: number | null; + + @Column({ name: 'contract_valid_from', type: 'timestamptz', nullable: true }) + contractValidFrom?: Date | null; + + @Column({ name: 'contract_valid_until', type: 'timestamptz', nullable: true }) + contractValidUntil?: Date | null; + @Column({ name: 'payment_status', type: 'varchar', length: 20, default: 'PENDING' }) paymentStatus!: string; @@ -179,9 +224,27 @@ export class Booking extends BaseEntity { @Column({ name: 'first_mile_pickup_address', type: 'text', nullable: true }) firstMilePickupAddress?: string | null; + @Column({ name: 'first_mile_pickup_lat', type: 'numeric', precision: 10, scale: 7, nullable: true }) + firstMilePickupLat?: number | null; + + @Column({ name: 'first_mile_pickup_lng', type: 'numeric', precision: 10, scale: 7, nullable: true }) + firstMilePickupLng?: number | null; + @Column({ name: 'last_mile_delivery_address', type: 'text', nullable: true }) lastMileDeliveryAddress?: string | null; + @Column({ name: 'last_mile_delivery_lat', type: 'numeric', precision: 10, scale: 7, nullable: true }) + lastMileDeliveryLat?: number | null; + + @Column({ name: 'last_mile_delivery_lng', type: 'numeric', precision: 10, scale: 7, nullable: true }) + lastMileDeliveryLng?: number | null; + + @Column({ name: 'customs_clearing_enabled', type: 'boolean', default: false }) + customsClearingEnabled!: boolean; + + @Column({ name: 'customs_clearing_agent', type: 'varchar', length: 200, nullable: true }) + customsClearingAgent?: string | null; + @Column({ name: 'equipment_return', type: 'varchar', length: 20 }) equipmentReturn!: string; @@ -228,6 +291,15 @@ export class Booking extends BaseEntity { @Column({ name: 'is_hazardous', type: 'boolean', default: false }) isHazardous!: boolean; + /** + * Refrigerated cargo flag. For one-time bookings reefer is derived from the + * container type; for general-contract drawdown orders the customer enters a + * reefer quantity per order, which sets this flag on the spawned child so the + * REEFER_SURCHARGE rate applies even when the container type is not a reefer. + */ + @Column({ name: 'is_reefer', type: 'boolean', default: false }) + isReefer!: boolean; + @Column({ name: 'payment_currency', type: 'varchar', length: 5 }) paymentCurrency!: string; @@ -294,9 +366,6 @@ export class Booking extends BaseEntity { @Column({ name: 'priority_score', type: 'int', default: 0 }) priorityScore!: number; - @Column({ name: 'allow_consolidation', type: 'boolean', default: false }) - allowConsolidation!: boolean; - @Column({ name: 'consolidation_partner_id', type: 'uuid', nullable: true }) consolidationPartnerId?: string | null; diff --git a/apps/edr-freight-api/src/modules/companies/companies.controller.ts b/apps/edr-freight-api/src/modules/companies/companies.controller.ts index fff8fc7e5..2fba1e878 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.controller.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.controller.ts @@ -28,6 +28,7 @@ import { CreateCompanyProfileDto } from "./dto/create-company-profile.dto"; import { SetActiveModeDto } from "./dto/set-active-mode.dto"; import { SetOnboardingStepDto } from "./dto/set-onboarding-step.dto"; import { StartOnboardingDto } from "./dto/start-onboarding.dto"; +import { DashboardQueryDto } from "./dto/dashboard-query.dto"; import { ResponseCompanyDto, ResponseCompanyProfileDto, @@ -86,8 +87,12 @@ export class CompaniesController { }) async getDashboard( @CurrentUser() user: CurrentIamUser, + @Query() query: DashboardQueryDto, ): Promise { - return this.companiesService.getDashboardSummary(user.id); + return this.companiesService.getDashboardSummary( + user.id, + query.companyProfileId, + ); } @Post("fetch-etrade-info") diff --git a/apps/edr-freight-api/src/modules/companies/companies.service.ts b/apps/edr-freight-api/src/modules/companies/companies.service.ts index 12fcb6238..9c1e290be 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -7,7 +7,10 @@ import { import { CompaniesRepository } from "./companies.repository"; import { CompanyProfileRepository } from "./company-profile.repository"; import { ExternalProfileRepository } from "./external-profile.repository"; -import { CompanyDashboardRepository } from "./company-dashboard.repository"; +import { + CompanyDashboardRepository, + DashboardScope, +} from "./company-dashboard.repository"; import { MinioService } from "../minio/minio.service"; import { ETradeService } from "./services/etrade.service"; import { normalizeE164 } from "../../common/validators/is-phone-number.validator"; @@ -321,6 +324,7 @@ export class CompaniesService { */ async getDashboardSummary( userId: string, + companyProfileId?: string, ): Promise { // A user without a company profile has no bookings — return an empty summary // rather than 404, so the portal home still renders. @@ -328,17 +332,17 @@ export class CompaniesService { const companyId = profile?.company?.id ?? profile?.companyId ?? null; if (!companyId) return this.emptyDashboardSummary(); - // Scope KPIs to the active operational profile (importer/exporter mode) when - // one resolves; otherwise aggregate across the whole company. - const companyProfileId = profile?.activeProfileType - ? ((await this.companyProfilesRepo.findByType( - companyId, - profile.activeProfileType, - )) ?? null) - : null; - const scope = companyProfileId - ? { companyProfileId: companyProfileId.id } - : { companyId }; + // Company-wide by default (all services' data). An optional companyProfileId + // (from the per-page service filter) narrows to one operational profile — + // but only after we confirm it belongs to this user's company, since the + // dashboard scope has no company guard at the repository layer. + let scope: DashboardScope = { companyId }; + if (companyProfileId) { + const owned = await this.companyProfilesRepo.findByCompanyId(companyId); + if (owned.some((p) => p.id === companyProfileId)) { + scope = { companyProfileId }; + } + } const now = new Date(); const yearStart = new Date(now.getFullYear(), 0, 1); @@ -841,8 +845,9 @@ export class CompaniesService { onboardingCompleted: true, onboardingStep: "done", }); + // Awaiting backoffice approval — stays Pending until an admin activates it. await this.companiesRepo.update(companyId, { - status: CompanyStatus.Active, + status: CompanyStatus.Pending, }); return this.getCompanyInfoByUserId(userId); } @@ -910,6 +915,18 @@ export class CompaniesService { return profile.businessLicenseFiles ?? []; } + /** + * Onboarding documents stored on a company profile, fetched by profile id. + * Internal helper (no ownership check) used when a booking reuses the active + * profile's onboarding documents. Returns [] when the profile is unknown. + */ + async getProfileOnboardingFiles( + profileId: string, + ): Promise { + const profile = await this.companyProfilesRepo.findById(profileId); + return profile?.businessLicenseFiles ?? []; + } + /** * Resolve which company_profile a new booking belongs to, from the company * and the booking's trade direction. IMPORT → importer profile, EXPORT → diff --git a/apps/edr-freight-api/src/modules/companies/dto/create-company.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/create-company.dto.ts index 0a699fe5e..e5b686d11 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/create-company.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/create-company.dto.ts @@ -18,7 +18,9 @@ export class CreateCompanyDto { @IsString() @IsNotEmpty() @Length(10, 10) - @Matches(/^\d+$/, { message: 'TIN must contain only digits' }) + @Matches(/^00\d{8}$/, { + message: 'TIN must be 10 digits starting with 00', + }) tin!: string; @IsOptional() diff --git a/apps/edr-freight-api/src/modules/companies/dto/dashboard-query.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/dashboard-query.dto.ts new file mode 100644 index 000000000..c41c3279a --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/dto/dashboard-query.dto.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts index d94cb5f35..c99ef9d3c 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts @@ -35,7 +35,9 @@ export class UpdateProfileDto { @IsOptional() @IsString() @Length(10, 10) - @Matches(/^\d+$/, { message: 'TIN must contain only digits' }) + @Matches(/^00\d{8}$/, { + message: 'TIN must be 10 digits starting with 00', + }) tin?: string; @IsOptional() diff --git a/apps/edr-freight-api/src/modules/files/files.service.ts b/apps/edr-freight-api/src/modules/files/files.service.ts index 97a5e9e34..31cf87dd7 100644 --- a/apps/edr-freight-api/src/modules/files/files.service.ts +++ b/apps/edr-freight-api/src/modules/files/files.service.ts @@ -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 { + 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 { const record = await this.filesRepository.findById(id); if (!record) throw new NotFoundException(`File ${id} not found`); diff --git a/apps/edr-freight-api/src/modules/first-mile/dto/create-first-mile.dto.ts b/apps/edr-freight-api/src/modules/first-mile/dto/create-first-mile.dto.ts index bc0394eb6..e2535083e 100644 --- a/apps/edr-freight-api/src/modules/first-mile/dto/create-first-mile.dto.ts +++ b/apps/edr-freight-api/src/modules/first-mile/dto/create-first-mile.dto.ts @@ -55,6 +55,7 @@ export class CreateFirstMileDto { nullable: true, }) @IsOptional() + @Transform(({ value }) => (value === '' ? undefined : value)) @IsUUID() vehicleId?: string | null; } diff --git a/apps/edr-freight-api/src/modules/first-mile/first-mile.controller.ts b/apps/edr-freight-api/src/modules/first-mile/first-mile.controller.ts index 315e70447..161604e81 100644 --- a/apps/edr-freight-api/src/modules/first-mile/first-mile.controller.ts +++ b/apps/edr-freight-api/src/modules/first-mile/first-mile.controller.ts @@ -55,6 +55,13 @@ export class FirstMileController { return this.firstMileService.findById(id); } + @Post('accept/:reference') + @TrainSchedulingManage() + @ApiOperation({ summary: 'Accept a paid booking and create a first-mile leg' }) + acceptBooking(@Param('reference') reference: string) { + return this.firstMileService.acceptBooking(reference); + } + @Post() @TrainSchedulingManage() @ApiOperation({ summary: 'Create a first-mile leg' }) diff --git a/apps/edr-freight-api/src/modules/first-mile/first-mile.module.ts b/apps/edr-freight-api/src/modules/first-mile/first-mile.module.ts index b9b59845c..713efa52d 100644 --- a/apps/edr-freight-api/src/modules/first-mile/first-mile.module.ts +++ b/apps/edr-freight-api/src/modules/first-mile/first-mile.module.ts @@ -1,13 +1,14 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { BookingsModule } from '../bookings/bookings.module'; import { FirstMile } from './entities/first-mile.entity'; import { FirstMileController } from './first-mile.controller'; import { FirstMileRepository } from './first-mile.repository'; import { FirstMileService } from './first-mile.service'; @Module({ - imports: [TypeOrmModule.forFeature([FirstMile])], + imports: [TypeOrmModule.forFeature([FirstMile]), BookingsModule], controllers: [FirstMileController], providers: [FirstMileRepository, FirstMileService], exports: [FirstMileRepository, FirstMileService], diff --git a/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts b/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts index a1ee4d364..a4ead8c2a 100644 --- a/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts +++ b/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts @@ -1,6 +1,7 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { FindOptionsWhere } from 'typeorm'; +import { BookingsRepository } from '../bookings/bookings.repository'; import { CreateFirstMileDto } from './dto/create-first-mile.dto'; import { UpdateFirstMileDto } from './dto/update-first-mile.dto'; import { FirstMile, FirstMileStatus } from './entities/first-mile.entity'; @@ -25,7 +26,32 @@ const SORTABLE_FIELDS: (keyof FirstMile)[] = [ @Injectable() export class FirstMileService { - constructor(private readonly firstMileRepository: FirstMileRepository) {} + constructor( + private readonly firstMileRepository: FirstMileRepository, + private readonly bookingsRepository: BookingsRepository, + ) {} + + /** + * Look up a booking by its human-readable reference and confirm it has been + * paid before any first-mile work proceeds. Throws if the reference is + * unknown or the booking has not reached PAID status. + */ + async acceptBooking(bookingReference: string): Promise { + const booking = await this.bookingsRepository.findByReference(bookingReference); + + if (!booking) { + return null; + } + + if (booking.paymentStatus !== 'PAID') { + return null; + } + + return this.create({ + bookingId: booking.id, + advancedPayment: 0, + }); + } async findAll(filter: FirstMileListFilter = {}): Promise<{ data: FirstMile[]; @@ -45,7 +71,10 @@ export class FirstMileService { const [data, total] = await this.firstMileRepository.findAndCount({ where, - relations: { booking: true, vehicle: true }, + relations: { + booking: { company: true, serviceType: true, originYard: true, destinationYard: true, cargoType: true }, + vehicle: true, + }, order: { [sortBy]: sortOrder }, skip: (page - 1) * pageSize, take: pageSize, @@ -64,7 +93,10 @@ export class FirstMileService { async findById(id: string): Promise { const record = await this.firstMileRepository.findById(id, { - relations: { booking: true, vehicle: true }, + relations: { + booking: { company: true, serviceType: true, originYard: true, destinationYard: true, cargoType: true }, + vehicle: true, + }, }); if (!record) { diff --git a/apps/edr-freight-api/src/modules/last-mile/dto/create-last-mile.dto.ts b/apps/edr-freight-api/src/modules/last-mile/dto/create-last-mile.dto.ts index b47eb0479..4f6f5fc8f 100644 --- a/apps/edr-freight-api/src/modules/last-mile/dto/create-last-mile.dto.ts +++ b/apps/edr-freight-api/src/modules/last-mile/dto/create-last-mile.dto.ts @@ -55,6 +55,7 @@ export class CreateLastMileDto { nullable: true, }) @IsOptional() + @Transform(({ value }) => (value === '' ? undefined : value)) @IsUUID() vehicleId?: string | null; } diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts index e6cc1e7ee..ea1e29a3d 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts @@ -55,6 +55,13 @@ export class LastMileController { return this.lastMileService.findById(id); } + @Post('accept/:reference') + @TrainSchedulingManage() + @ApiOperation({ summary: 'Accept a paid booking and create a last-mile leg' }) + acceptBooking(@Param('reference') reference: string) { + return this.lastMileService.acceptBooking(reference); + } + @Post() @TrainSchedulingManage() @ApiOperation({ summary: 'Create a last-mile leg' }) diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.module.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.module.ts index a3662debe..fa654f6ec 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.module.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.module.ts @@ -1,13 +1,14 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { BookingsModule } from '../bookings/bookings.module'; import { LastMile } from './entities/last-mile.entity'; import { LastMileController } from './last-mile.controller'; import { LastMileRepository } from './last-mile.repository'; import { LastMileService } from './last-mile.service'; @Module({ - imports: [TypeOrmModule.forFeature([LastMile])], + imports: [TypeOrmModule.forFeature([LastMile]), BookingsModule], controllers: [LastMileController], providers: [LastMileRepository, LastMileService], exports: [LastMileRepository, LastMileService], diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts index 55b8fee24..d25729324 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts @@ -1,6 +1,7 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { FindOptionsWhere } from 'typeorm'; +import { BookingsRepository } from '../bookings/bookings.repository'; import { CreateLastMileDto } from './dto/create-last-mile.dto'; import { UpdateLastMileDto } from './dto/update-last-mile.dto'; import { LastMile, LastMileStatus } from './entities/last-mile.entity'; @@ -25,7 +26,29 @@ const SORTABLE_FIELDS: (keyof LastMile)[] = [ @Injectable() export class LastMileService { - constructor(private readonly lastMileRepository: LastMileRepository) {} + constructor( + private readonly lastMileRepository: LastMileRepository, + private readonly bookingsRepository: BookingsRepository, + ) {} + + async acceptBooking(bookingReference: string): Promise { + const booking = await this.bookingsRepository.findByReference(bookingReference); + + if (!booking) { + throw new NotFoundException(`Booking ${bookingReference} not found`); + } + + if (booking.paymentStatus !== 'PAID') { + throw new BadRequestException( + `Booking ${bookingReference} is not paid (payment status: ${booking.paymentStatus})`, + ); + } + + return this.create({ + bookingId: booking.id, + advancedPayment: booking.totalAmount, + }); + } async findAll(filter: LastMileListFilter = {}): Promise<{ data: LastMile[]; @@ -45,7 +68,10 @@ export class LastMileService { const [data, total] = await this.lastMileRepository.findAndCount({ where, - relations: { booking: true, vehicle: true }, + relations: { + booking: { company: true, serviceType: true, originYard: true, destinationYard: true, cargoType: true }, + vehicle: true, + }, order: { [sortBy]: sortOrder }, skip: (page - 1) * pageSize, take: pageSize, @@ -64,7 +90,10 @@ export class LastMileService { async findById(id: string): Promise { const record = await this.lastMileRepository.findById(id, { - relations: { booking: true, vehicle: true }, + relations: { + booking: { company: true, serviceType: true, originYard: true, destinationYard: true, cargoType: true }, + vehicle: true, + }, }); if (!record) { diff --git a/apps/edr-freight-api/src/modules/overview/overview.constants.ts b/apps/edr-freight-api/src/modules/overview/overview.constants.ts index fed9a76c7..6181d446e 100644 --- a/apps/edr-freight-api/src/modules/overview/overview.constants.ts +++ b/apps/edr-freight-api/src/modules/overview/overview.constants.ts @@ -1,4 +1,4 @@ -export const OVERVIEW_URGENT_PRIORITY_THRESHOLD = 1000; +export const OVERVIEW_URGENT_PRIORITY_THRESHOLD = 70; export const OVERVIEW_NEEDS_ACTION_STATUSES = [ 'SUBMITTED', diff --git a/apps/edr-freight-api/src/modules/payment/payment.service.ts b/apps/edr-freight-api/src/modules/payment/payment.service.ts index 1f95c6253..582e7467e 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.service.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.service.ts @@ -35,6 +35,7 @@ import { } from "./payments.dto"; import { BookingBatchService } from "../train-scheduling/booking-batch.service"; import { DropdownSettingsService } from "../dropdown-settings/dropdown-settings.service"; +import { FirstMileService } from "../first-mile/first-mile.service"; /** Setting code holding the global ordering window (months) for general contracts. */ const CONTRACT_PERIOD_SETTING_CODE = "general_contract_period"; @@ -60,6 +61,7 @@ export class PaymentService { @Inject(forwardRef(() => BookingBatchService)) private readonly bookingBatchService: BookingBatchService, private readonly dropdownSettings: DropdownSettingsService, + private readonly firstMileService: FirstMileService, ) { } /** Configured general-contract ordering window in months (defaults to 3). */ @@ -342,6 +344,8 @@ export class PaymentService { ? { paymentStatus: "PAID", status: "CONTRACT_ACTIVE", expiresAt: contractExpiresAt } : { paymentStatus: "PAID", status: "PAID" }, ); + await this.firstMileService.acceptBooking(input.bookingId); + }); if (isGeneralContract) { diff --git a/apps/edr-freight-api/src/modules/rule-engine/controllers/surcharge-types.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/surcharge-types.controller.ts deleted file mode 100644 index be4c3011a..000000000 --- a/apps/edr-freight-api/src/modules/rule-engine/controllers/surcharge-types.controller.ts +++ /dev/null @@ -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) { - 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); - } -} diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-priority-config.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-priority-config.dto.ts index 140954e83..d2ca44d93 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/dto/create-priority-config.dto.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-priority-config.dto.ts @@ -1,5 +1,5 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { IsBoolean, IsIn, IsInt, IsOptional, IsString, MaxLength, Min } from 'class-validator'; +import { IsBoolean, IsIn, IsInt, IsOptional, IsString, Max, MaxLength, Min } from 'class-validator'; export class CreatePriorityConfigDto { @ApiProperty({ description: 'Config type: WAGON or CURRENCY', enum: ['WAGON', 'CURRENCY'] }) @@ -30,9 +30,15 @@ export class CreatePriorityConfigDto { @Min(0) maxWagonCount!: number; - @ApiProperty({ description: 'Points awarded when booking matches this rule', default: 0 }) + @ApiProperty({ + description: + 'Points awarded when booking matches this rule. Capped so the priority blocks sum to ≤ 100 alongside the service-type bonus (service ≤ 15 + wagon ≤ 50 + currency ≤ 35). WAGON configs should not exceed 50; CURRENCY configs should not exceed 35.', + default: 0, + maximum: 50, + }) @IsInt() @Min(0) + @Max(50) scorePoints!: number; @ApiPropertyOptional({ default: false, description: 'Feature flag — toggle without code deploy' }) diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-rate.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-rate.dto.ts index 969c08876..995718135 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/dto/create-rate.dto.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-rate.dto.ts @@ -1,29 +1,46 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Transform } from 'class-transformer'; import { IsDateString, IsIn, IsNumber, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator'; -import { RATE_TYPES, RATE_UNITS } from '../entities/rate.entity'; +import { + RATE_APPLIES_TO, + RATE_TRIGGERS, + RATE_UNITS, +} from '../entities/rate.entity'; const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'BOTH'] as const; const CURRENCIES = ['USD'] as const; export class CreateRateDto { - @ApiProperty({ enum: RATE_TYPES, description: 'Rate type identifier' }) - @IsIn([...RATE_TYPES]) - rateType!: string; + @ApiProperty({ enum: RATE_APPLIES_TO, description: 'Friendly category the rate applies to' }) + @IsIn([...RATE_APPLIES_TO]) + appliesTo!: string; - @ApiPropertyOptional({ description: 'FK to container_types.id — null for non-container rates' }) + @ApiProperty({ + enum: RATE_TRIGGERS, + description: 'What makes this rate apply. ALWAYS = base freight; anything else is a surcharge.', + }) + @IsIn([...RATE_TRIGGERS]) + trigger!: string; + + @ApiPropertyOptional({ description: 'FK to container_types.id — set for container/intercity-container rates' }) @IsOptional() @IsUUID() containerTypeId?: string; + @ApiPropertyOptional({ description: 'FK to cargo_types.id (bulk leaf commodity) — set for bulk/intercity-bulk rates' }) + @IsOptional() + @IsUUID() + cargoTypeId?: string; + @ApiPropertyOptional({ enum: TRADE_DIRECTIONS, description: 'Trade direction. Null = direction-agnostic' }) @IsOptional() @IsIn([...TRADE_DIRECTIONS]) tradeDirection?: string; - @ApiProperty({ enum: CURRENCIES }) + @ApiPropertyOptional({ enum: CURRENCIES }) + @IsOptional() @IsIn([...CURRENCIES]) - currency!: string; + currency?: string; @ApiProperty({ description: 'Numeric rate value', minimum: 0 }) @IsNumber() diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-service-type.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-service-type.dto.ts index 4683d448d..d68625fdc 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/dto/create-service-type.dto.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-service-type.dto.ts @@ -1,5 +1,5 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { IsBoolean, IsInt, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator'; +import { IsBoolean, IsInt, IsOptional, IsString, IsUUID, Max, MaxLength, Min } from 'class-validator'; export class CreateServiceTypeDto { @ApiProperty({ description: 'Service type display name', maxLength: 255 }) @@ -32,10 +32,15 @@ export class CreateServiceTypeDto { @IsBoolean() includesCustoms?: boolean; - @ApiPropertyOptional({ description: 'Priority bonus points awarded when this service is used', default: 0 }) + @ApiPropertyOptional({ + description: 'Priority bonus points awarded when this service is used (0–15)', + default: 0, + maximum: 15, + }) @IsOptional() @IsInt() @Min(0) + @Max(15) priorityBonusPoints?: number; @ApiPropertyOptional({ default: true }) diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-surcharge-type.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-surcharge-type.dto.ts deleted file mode 100644 index 7aef9bbde..000000000 --- a/apps/edr-freight-api/src/modules/rule-engine/dto/create-surcharge-type.dto.ts +++ /dev/null @@ -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; -} diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/update-surcharge-type.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/update-surcharge-type.dto.ts deleted file mode 100644 index cb9be80eb..000000000 --- a/apps/edr-freight-api/src/modules/rule-engine/dto/update-surcharge-type.dto.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { PartialType } from '@nestjs/mapped-types'; -import { CreateSurchargeTypeDto } from './create-surcharge-type.dto'; - -export class UpdateSurchargeTypeDto extends PartialType(CreateSurchargeTypeDto) {} diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/rate-type.util.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-type.util.ts new file mode 100644 index 000000000..579f7da9d --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-type.util.ts @@ -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'; + } +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/rate.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/rate.entity.ts index 33030e95f..b57b48cd8 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/rate.entity.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/rate.entity.ts @@ -1,5 +1,6 @@ import { BaseEntity } from '@edr/api-common'; import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; +import { CargoType } from './cargo-type.entity'; import { ContainerType } from './container-type.entity'; export const RATE_TYPES = [ @@ -27,18 +28,72 @@ export type RateType = typeof RATE_TYPES[number]; export const RATE_STATUSES = ['DRAFT', 'PENDING_APPROVAL', 'LIVE', 'SUPERSEDED'] as const; export type RateStatus = typeof RATE_STATUSES[number]; -export const RATE_UNITS = ['PER_WAGON', 'PER_TON', 'PER_CONTAINER', 'PER_KM', 'FLAT'] as const; +export const RATE_UNITS = [ + 'PER_WAGON', + 'PER_TON', + 'PER_CONTAINER', + 'PER_KM', + 'PER_INVOICE', + 'FLAT', +] as const; export type RateUnit = typeof RATE_UNITS[number]; +/** + * Friendly, admin-facing category that determines how the rate is used in + * pricing and which fields the rate form shows. Replaces the cryptic + * `rateType` matrix for the configuration UI (rateType is still persisted and + * derived from `appliesTo` + `tradeDirection` + `trigger` for base-freight + * lookup and snapshots). + * + * - BULK / CONTAINER / INTERCITY : base rail freight (trigger = ALWAYS) + * - FIRST_MILE / LAST_MILE : pickup / delivery legs + * - OTHER : trigger-based surcharges (hazard, reefer …) + */ +export const RATE_APPLIES_TO = [ + 'BULK', + 'CONTAINER', + 'INTERCITY', + 'FIRST_MILE', + 'LAST_MILE', + 'OTHER', +] as const; +export type RateAppliesTo = typeof RATE_APPLIES_TO[number]; + +/** + * What makes a rate apply to a booking. `ALWAYS` is base freight (matched by + * direction + container/bulk scope). Everything else is a surcharge that the + * rule engine adds on top, additively, when the booking matches the trigger — + * so hazard stacks on container/bulk with each line's own unit. + */ +export const RATE_TRIGGERS = [ + 'ALWAYS', + 'HAZARDOUS', + 'OVERWEIGHT', + 'REEFER', + 'SHIPPING_LINE', + 'CONSOLIDATION', + 'CANCELLATION', + 'DEMURRAGE', + 'PIL_EXTRA_FEE', +] as const; +export type RateTrigger = typeof RATE_TRIGGERS[number]; + @Entity({ schema: 'freight', name: 'rates' }) @Index(['rateType']) @Index(['status']) @Index(['effectiveFrom']) @Index(['containerTypeId']) +@Index(['trigger']) export class Rate extends BaseEntity { @Column({ name: 'rate_type', type: 'varchar', length: 50 }) rateType!: RateType; + @Column({ name: 'applies_to', type: 'varchar', length: 20, default: 'OTHER' }) + appliesTo!: RateAppliesTo; + + @Column({ name: 'trigger', type: 'varchar', length: 20, default: 'ALWAYS' }) + trigger!: RateTrigger; + @Column({ name: 'container_type_id', type: 'uuid', nullable: true }) containerTypeId?: string | null; @@ -46,6 +101,13 @@ export class Rate extends BaseEntity { @JoinColumn({ name: 'container_type_id' }) containerType?: ContainerType | null; + @Column({ name: 'cargo_type_id', type: 'uuid', nullable: true }) + cargoTypeId?: string | null; + + @ManyToOne(() => CargoType, { nullable: true, eager: false }) + @JoinColumn({ name: 'cargo_type_id' }) + cargoType?: CargoType | null; + @Column({ name: 'trade_direction', type: 'varchar', length: 10, nullable: true }) tradeDirection?: string | null; diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/surcharge-type.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/surcharge-type.entity.ts deleted file mode 100644 index 2b9934a1e..000000000 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/surcharge-type.entity.ts +++ /dev/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; -} diff --git a/apps/edr-freight-api/src/modules/rule-engine/interfaces/surcharge-types.repository.interface.ts b/apps/edr-freight-api/src/modules/rule-engine/interfaces/surcharge-types.repository.interface.ts deleted file mode 100644 index a6931aaf2..000000000 --- a/apps/edr-freight-api/src/modules/rule-engine/interfaces/surcharge-types.repository.interface.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { FindManyOptions } from 'typeorm'; -import { SurchargeType } from '../entities/surcharge-type.entity'; - -export interface ISurchargeTypesRepository { - findById(id: string): Promise; - findByCode(code: string): Promise; - findAllActiveWithRate(): Promise; - findAll(options?: FindManyOptions): Promise; - findAndCount(options?: FindManyOptions): Promise<[SurchargeType[], number]>; - create(data: Partial): Promise; - update(id: string, data: Partial): Promise; - softDelete(id: string): Promise; -} - -export const SURCHARGE_TYPES_REPOSITORY = Symbol('SURCHARGE_TYPES_REPOSITORY'); diff --git a/apps/edr-freight-api/src/modules/rule-engine/repositories/surcharge-types.repository.ts b/apps/edr-freight-api/src/modules/rule-engine/repositories/surcharge-types.repository.ts deleted file mode 100644 index 7b44e73e1..000000000 --- a/apps/edr-freight-api/src/modules/rule-engine/repositories/surcharge-types.repository.ts +++ /dev/null @@ -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; - - constructor(private readonly dataSource: DataSource) { - this.repo = this.dataSource.getRepository(SurchargeType); - } - - findById(id: string): Promise { - return this.repo.findOne({ where: { id } }); - } - - findByCode(code: string): Promise { - return this.repo.findOne({ where: { code } }); - } - - findAllActiveWithRate(): Promise { - return this.repo.find({ - where: { isActive: true }, - relations: { rate: true }, - }); - } - - findAll(options?: FindManyOptions): Promise { - return this.repo.find(options); - } - - findAndCount(options?: FindManyOptions): Promise<[SurchargeType[], number]> { - return this.repo.findAndCount(options); - } - - async create(data: Partial): Promise { - const entity = this.repo.create(data); - return this.repo.save(entity); - } - - async update(id: string, data: Partial): Promise { - await this.repo.update(id, data); - return this.findById(id); - } - - async softDelete(id: string): Promise { - await this.repo.softDelete(id); - } -} diff --git a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.module.ts b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.module.ts index 49f1c446c..34dc9f982 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.module.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.module.ts @@ -8,7 +8,6 @@ import { PriorityConfigsController } from './controllers/priority-configs.contro import { RatesController } from './controllers/rates.controller'; import { ServiceTypesController } from './controllers/service-types.controller'; import { ShippingLinesController } from './controllers/shipping-lines.controller'; -import { SurchargeTypesController } from './controllers/surcharge-types.controller'; import { WeightLimitRulesController } from './controllers/weight-limit-rules.controller'; import { YardsController } from './controllers/yards.controller'; @@ -19,7 +18,6 @@ import { PriorityConfig } from './entities/priority-config.entity'; import { Rate } from './entities/rate.entity'; import { ServiceType } from './entities/service-type.entity'; import { ShippingLine } from './entities/shipping-line.entity'; -import { SurchargeType } from './entities/surcharge-type.entity'; import { WeightLimitRule } from './entities/weight-limit-rule.entity'; import { Yard } from './entities/yard.entity'; @@ -30,7 +28,6 @@ import { PRIORITY_CONFIGS_REPOSITORY } from './interfaces/priority-configs.repos import { RATES_REPOSITORY } from './interfaces/rates.repository.interface'; import { SERVICE_TYPES_REPOSITORY } from './interfaces/service-types.repository.interface'; import { SHIPPING_LINES_REPOSITORY } from './interfaces/shipping-lines.repository.interface'; -import { SURCHARGE_TYPES_REPOSITORY } from './interfaces/surcharge-types.repository.interface'; import { WEIGHT_LIMIT_RULES_REPOSITORY } from './interfaces/weight-limit-rules.repository.interface'; import { YARDS_REPOSITORY } from './interfaces/yards.repository.interface'; @@ -41,7 +38,6 @@ import { PriorityConfigsRepository } from './repositories/priority-configs.repos import { RatesRepository } from './repositories/rates.repository'; import { ServiceTypesRepository } from './repositories/service-types.repository'; import { ShippingLinesRepository } from './repositories/shipping-lines.repository'; -import { SurchargeTypesRepository } from './repositories/surcharge-types.repository'; import { WeightLimitRulesRepository } from './repositories/weight-limit-rules.repository'; import { YardsRepository } from './repositories/yards.repository'; @@ -53,7 +49,6 @@ import { PriorityConfigsService } from './services/priority-configs.service'; import { RatesService } from './services/rates.service'; import { ServiceTypesService } from './services/service-types.service'; import { ShippingLinesService } from './services/shipping-lines.service'; -import { SurchargeTypesService } from './services/surcharge-types.service'; import { WeightLimitRulesService } from './services/weight-limit-rules.service'; import { YardsService } from './services/yards.service'; @@ -71,7 +66,6 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot. CargoType, ContainerType, PriorityConfig, - SurchargeType, ServiceType, WeightLimitRule, Yard, @@ -88,7 +82,6 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot. CargoTypesController, ContainerTypesController, PriorityConfigsController, - SurchargeTypesController, ServiceTypesController, WeightLimitRulesController, YardsController, @@ -103,8 +96,6 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot. { provide: CONTAINER_TYPES_REPOSITORY, useExisting: ContainerTypesRepository }, PriorityConfigsRepository, { provide: PRIORITY_CONFIGS_REPOSITORY, useExisting: PriorityConfigsRepository }, - SurchargeTypesRepository, - { provide: SURCHARGE_TYPES_REPOSITORY, useExisting: SurchargeTypesRepository }, ServiceTypesRepository, { provide: SERVICE_TYPES_REPOSITORY, useExisting: ServiceTypesRepository }, WeightLimitRulesRepository, @@ -120,7 +111,6 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot. CargoTypesService, ContainerTypesService, PriorityConfigsService, - SurchargeTypesService, ServiceTypesService, WeightLimitRulesService, YardsService, @@ -135,7 +125,6 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot. CargoTypesService, ServiceTypesService, ContainerTypesService, - SurchargeTypesService, WeightLimitRulesService, PriorityConfigsService, YardsService, diff --git a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts index 35dbef868..ce0082f83 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts @@ -2,7 +2,7 @@ import { Inject, Injectable, BadRequestException } from '@nestjs/common'; import { DataSource } from 'typeorm'; import { BookingApprovalStep } from '../bookings/entities/booking-approval-step.entity'; import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.entity'; -import { TriggerCondition } from './entities/surcharge-type.entity'; +import { Rate, RateTrigger } from './entities/rate.entity'; import { ICargoTypesRepository, CARGO_TYPES_REPOSITORY, @@ -19,10 +19,6 @@ import { IPriorityConfigsRepository, PRIORITY_CONFIGS_REPOSITORY, } from './interfaces/priority-configs.repository.interface'; -import { - ISurchargeTypesRepository, - SURCHARGE_TYPES_REPOSITORY, -} from './interfaces/surcharge-types.repository.interface'; import { IRatesRepository, RATES_REPOSITORY, @@ -55,6 +51,8 @@ export interface BookingEvaluationInput { paymentCurrency: string; tradeDirection: string; isHazardous: boolean; + /** Booking-level reefer flag; ORed with per-container reefer. */ + isReefer?: boolean; isGovernment?: boolean; allowConsolidation?: boolean; shippingLineId?: string | null; @@ -63,11 +61,12 @@ export interface BookingEvaluationInput { } export interface AppliedCargoModifier { - surchargeTypeId: string; - surchargeTypeCode: string; + /** The trigger-based rate that produced this surcharge line. */ + rateId: string; + /** Stable display/audit code, derived from the rate's trigger + rateType. */ + surchargeCode: string; triggerValue: number | null; calculatedAmount: number; - rateId: string; currency: string; } @@ -98,8 +97,6 @@ export class RuleEngineService { private readonly weightLimitRulesRepo: IWeightLimitRulesRepository, @Inject(PRIORITY_CONFIGS_REPOSITORY) private readonly priorityConfigsRepo: IPriorityConfigsRepository, - @Inject(SURCHARGE_TYPES_REPOSITORY) - private readonly surchargeTypesRepo: ISurchargeTypesRepository, @Inject(RATES_REPOSITORY) private readonly ratesRepo: IRatesRepository, @Inject(APPROVAL_RULES_REPOSITORY) @@ -200,15 +197,25 @@ export class RuleEngineService { shippingLineMapped = Boolean(line?.mappedToCode); } - const hasReefer = input.containers.some((c) => c.isReefer); + const hasReefer = + input.isReefer === true || input.containers.some((c) => c.isReefer); const hasOverweight = containerWeightResults.some((r) => r.isOverweight); - const surchargeTypes = await this.surchargeTypesRepo.findAllActiveWithRate(); + // Surcharges are now self-describing rates: any LIVE rate whose `trigger` + // is not ALWAYS. Each fires independently and stacks on top of base freight + // — hazard + reefer + overweight all add together, each with its own unit. + // + // A given surcharge identity (same trigger + rateType + unit + value + + // scope) must contribute exactly ONE line. Duplicate LIVE rate rows — e.g. + // from a non-idempotent seeder — would otherwise repeat the same surcharge + // many times and inflate the total, so we collapse them to one row each. const liveRates = await this.ratesRepo.findLiveRates(); - const rateById = new Map(liveRates.map((r) => [r.id, r])); + const surchargeRates = this.dedupeRatesBySignature( + liveRates.filter((r) => r.trigger && r.trigger !== 'ALWAYS'), + ); - for (const st of surchargeTypes) { - const triggered = this.matchesTrigger(st.triggerCondition, { + for (const rate of surchargeRates) { + const triggered = this.matchesTrigger(rate.trigger, { isHazardous: input.isHazardous, hasReefer, hasOverweight, @@ -217,28 +224,28 @@ export class RuleEngineService { }); if (!triggered) continue; - const rate = st.rate ?? rateById.get(st.rateId); - if (!rate) continue; - let triggerValue: number | null = null; let calculatedAmount = Number(rate.rateValue); - if (st.triggerCondition === 'VGM_EXCEEDS_LIMIT') { + // Per-ton surcharges (typically OVERWEIGHT) bill against the excess tons. + if (rate.rateUnit === 'PER_TON' && rate.trigger === 'OVERWEIGHT') { triggerValue = containerWeightResults.reduce( (sum, r) => sum + (r.overweightExcessTons ?? 0), 0, ); - if (rate.rateUnit === 'PER_TON') { - calculatedAmount = triggerValue * Number(rate.rateValue); - } + calculatedAmount = triggerValue * Number(rate.rateValue); } + // Safety guard: never include a surcharge with a non-positive amount (a + // zero-rate or zero-trigger line would otherwise show as a confusing + // "free" surcharge on the breakdown). + if (!(calculatedAmount > 0)) continue; + appliedModifiers.push({ - surchargeTypeId: st.id, - surchargeTypeCode: st.code, + rateId: rate.id, + surchargeCode: this.surchargeCode(rate), triggerValue, calculatedAmount, - rateId: rate.id, currency: rate.currency, }); } @@ -373,7 +380,7 @@ export class RuleEngineService { } private matchesTrigger( - condition: TriggerCondition, + trigger: RateTrigger, state: { isHazardous: boolean; hasReefer: boolean; @@ -382,19 +389,59 @@ export class RuleEngineService { allowConsolidation: boolean; }, ): boolean { - switch (condition) { - case 'CARGO_FLAG_HAZARDOUS': - return state.isHazardous; - case 'CARGO_FLAG_REEFER': - return state.hasReefer; - case 'VGM_EXCEEDS_LIMIT': - return state.hasOverweight; - case 'SHIPPING_LINE_MAPPED': - return state.shippingLineMapped; - case 'CONSOLIDATION_ENABLED': - return state.allowConsolidation; + // Coerce defensively: a flag may arrive as the string "true"/"false" (e.g. + // from multipart form-data) and a non-empty "false" string is truthy. + const truthy = (v: unknown): boolean => v === true || v === 'true'; + switch (trigger) { + case 'HAZARDOUS': + return truthy(state.isHazardous); + case 'REEFER': + return truthy(state.hasReefer); + case 'OVERWEIGHT': + return truthy(state.hasOverweight); + case 'SHIPPING_LINE': + return truthy(state.shippingLineMapped); + case 'CONSOLIDATION': + return truthy(state.allowConsolidation); + // CANCELLATION / DEMURRAGE / PIL_EXTRA_FEE are contextual charges applied + // explicitly elsewhere (not auto-triggered by a booking's cargo flags). default: return false; } } + + /** Stable surcharge code for display + audit, derived from the rate. */ + private surchargeCode(rate: Rate): string { + return rate.rateType ?? rate.trigger; + } + + /** + * Collapse rates that describe the same charge to a single representative. + * + * Two rates are "the same" when they would produce an identical price line: + * same trigger, rateType, unit, value, currency, and scoping (container / + * cargo type). Duplicate rows (e.g. a seeder run more than once) therefore + * stack into one line instead of repeating — keeping the breakdown clean and + * the total correct. The first row of each signature is kept so an existing + * rateId is preserved for snapshotting. + */ + private dedupeRatesBySignature(rates: Rate[]): Rate[] { + const seen = new Set(); + 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; + } } diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts index 291d1959d..c3ab6bab0 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts @@ -2,6 +2,7 @@ import { BadRequestException, Inject, Injectable, NotFoundException } from '@nes import { CreateRateDto } from '../dto/create-rate.dto'; import { UpdateRateDto } from '../dto/update-rate.dto'; import { Rate } from '../entities/rate.entity'; +import { deriveRateType } from '../entities/rate-type.util'; import { IRatesRepository, RATES_REPOSITORY } from '../interfaces/rates.repository.interface'; @Injectable() @@ -47,10 +48,27 @@ export class RatesService { /** Create a rate in DRAFT status. */ async create(dto: CreateRateDto, proposedByStaffId: string): Promise { + const appliesTo = dto.appliesTo as Rate['appliesTo']; + const trigger = dto.trigger as Rate['trigger']; + // Surcharges (trigger ≠ ALWAYS) carry no direction/scope — clear them so + // the engine never accidentally narrows a surcharge by container/direction. + const isSurcharge = trigger !== 'ALWAYS'; + const containerTypeId = isSurcharge ? null : (dto.containerTypeId ?? null); + const cargoTypeId = isSurcharge ? null : (dto.cargoTypeId ?? null); + const tradeDirection = isSurcharge ? null : (dto.tradeDirection ?? null); + return this.repository.create({ - rateType: dto.rateType as Rate['rateType'], - containerTypeId: dto.containerTypeId, - tradeDirection: dto.tradeDirection, + appliesTo, + trigger, + rateType: deriveRateType({ + appliesTo, + trigger, + tradeDirection, + isBulk: Boolean(cargoTypeId), + }), + containerTypeId, + cargoTypeId, + tradeDirection, currency: dto.currency ?? 'USD', rateValue: dto.rateValue, rateUnit: dto.rateUnit as Rate['rateUnit'], @@ -68,9 +86,41 @@ export class RatesService { throw new BadRequestException('Only DRAFT rates can be updated'); } const updates: Partial = {}; - if (dto.rateType) updates.rateType = dto.rateType as Rate['rateType']; - if (dto.containerTypeId !== undefined) updates.containerTypeId = dto.containerTypeId; - if (dto.tradeDirection !== undefined) updates.tradeDirection = dto.tradeDirection; + + const appliesTo = (dto.appliesTo as Rate['appliesTo']) ?? existing.appliesTo; + const trigger = (dto.trigger as Rate['trigger']) ?? existing.trigger; + const isSurcharge = trigger !== 'ALWAYS'; + + if (dto.appliesTo) updates.appliesTo = appliesTo; + if (dto.trigger) updates.trigger = trigger; + + const containerTypeId = isSurcharge + ? null + : dto.containerTypeId !== undefined + ? dto.containerTypeId + : existing.containerTypeId; + const cargoTypeId = isSurcharge + ? null + : dto.cargoTypeId !== undefined + ? dto.cargoTypeId + : existing.cargoTypeId; + const tradeDirection = isSurcharge + ? null + : dto.tradeDirection !== undefined + ? dto.tradeDirection + : existing.tradeDirection; + + updates.containerTypeId = containerTypeId; + updates.cargoTypeId = cargoTypeId; + updates.tradeDirection = tradeDirection; + // Keep the derived rateType in sync with whatever changed. + updates.rateType = deriveRateType({ + appliesTo, + trigger, + tradeDirection, + isBulk: Boolean(cargoTypeId), + }); + updates.currency = dto.currency ?? existing.currency ?? 'USD'; if (dto.rateValue !== undefined) updates.rateValue = dto.rateValue; if (dto.rateUnit) updates.rateUnit = dto.rateUnit as Rate['rateUnit']; diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/surcharge-types.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/surcharge-types.service.ts deleted file mode 100644 index 387e26ba9..000000000 --- a/apps/edr-freight-api/src/modules/rule-engine/services/surcharge-types.service.ts +++ /dev/null @@ -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 = {}; - 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 { - 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 { - 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 { - await this.findById(id); - const patch: Partial = {}; - 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 { - await this.findById(id); - await this.repository.softDelete(id); - } -} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts index 514f8572d..f31d8561d 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts @@ -216,6 +216,26 @@ export class BookingBatchService implements OnModuleInit { ); } + /** + * Fire-and-forget batch pipeline for a (route, day) directly — used when a + * booking enters the pool without a target train yet (e.g. after the + * operations team accepts an operation request). The booking is already + * FULLY_EXECUTED with its scheduled_date set, so the day-level fill will pick + * it up; this just runs that fill immediately instead of waiting for the cron. + */ + enqueueRouteDayProcessing( + originYardId: string, + destinationYardId: string, + day: string, + ): void { + void this.processRouteDay({ originYardId, destinationYardId, day }).catch( + (err) => + this.logger.error( + `processRouteDay for ${originYardId}→${destinationYardId} on ${day} failed: ${(err as Error).message}`, + ), + ); + } + /** Resolve a schedule's (route, day) group and run the day-level pipeline. */ private async processRouteDayForSchedule(scheduleId: string): Promise { const schedule = await this.trainSchedulesRepository.findById(scheduleId); diff --git a/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts b/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts index 736c1fbf9..9670243c9 100644 --- a/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts +++ b/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts @@ -386,9 +386,7 @@ export class DemoBookingsSeeder { shippingLineId: null, cargoTotalWeightVgm: demoBooking.totalWeightTons, isHazardous: false, - paymentCurrency: "ETB", - allowConsolidation: false, - priorityScore: 0, + paymentCurrency: "ETB", priorityScore: 0, versionNumber: 1, }, { conflictPaths: { reference: true } }, @@ -459,9 +457,7 @@ export class DemoBookingsSeeder { shippingLineId: null, cargoTotalWeightVgm: demoBulk.totalWeightTons, isHazardous: false, - paymentCurrency: "USD", - allowConsolidation: false, - priorityScore: 10, + paymentCurrency: "USD", priorityScore: 10, schedulingStatus: "HOLDING", versionNumber: 1, }, diff --git a/apps/edr-freight-api/src/seed/demo-freight-data.seeder.ts b/apps/edr-freight-api/src/seed/demo-freight-data.seeder.ts index b391d3f9e..5936e7c93 100644 --- a/apps/edr-freight-api/src/seed/demo-freight-data.seeder.ts +++ b/apps/edr-freight-api/src/seed/demo-freight-data.seeder.ts @@ -20,12 +20,13 @@ import { DEFAULT_APPROVAL_RULE_ROWS } from '../modules/rule-engine/approval-rule const EDR_ORG_KEY = 'edr_freight'; const MIN_WAGONS_PER_TYPE = 100; -/** The four demo staff users, each mapped to a seeded freight role. */ +/** The demo staff users, each mapped to a seeded freight role. */ const DEMO_STAFF_USERS = [ { email: 'marketing@edr.local', username: 'marketing', roleKey: 'edr_marketing' }, { email: 'operations@edr.local', username: 'operations', roleKey: 'edr_operations_officer' }, { email: 'director@edr.local', username: 'director', roleKey: 'edr_director' }, { email: 'ceo@edr.local', username: 'ceo', roleKey: 'edr_ceo' }, + { email: 'gl@edr.local', username: 'gl', roleKey: 'edr_global_logistics' }, ] as const; /** diff --git a/apps/edr-freight-api/src/seed/edr-freight.seed.ts b/apps/edr-freight-api/src/seed/edr-freight.seed.ts index a88ee8f89..9c0e62001 100644 --- a/apps/edr-freight-api/src/seed/edr-freight.seed.ts +++ b/apps/edr-freight-api/src/seed/edr-freight.seed.ts @@ -237,6 +237,11 @@ export const EDR_FREIGHT_ROLES: FreightSeedRole[] = [ name: { en: "EDR Marketing" }, permissionKeys: [...ROLE_PERMISSION_PRESETS.marketing], }, + { + key: "edr_global_logistics", + name: { en: "EDR Global Logistics" }, + permissionKeys: [...ROLE_PERMISSION_PRESETS.globalLogistics], + }, { key: "edr_org_manager", name: { en: "EDR Org Manager" }, diff --git a/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts b/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts index 340a291db..bb497f8f8 100644 --- a/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts +++ b/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts @@ -192,6 +192,169 @@ const COMPANY_ONBOARDING_DOCUMENTS: OnboardingDocumentSetting[] = [ const COMPANY_ONBOARDING_DESCRIPTION = "Required documents for external company onboarding, by company nationality."; +// ── Clearance document settings ──────────────────────────────────────────── +// Operation/clearance documents collected after contract counter-sign, resolved +// at runtime from (operationType, freightType, includesCustoms). The `entity` +// is "booking_clearance" so the backoffice file-settings editor can filter them. +// Two kinds of set per customs category: a CUSTOMER-INPUT set (the customer +// uploads) and a GL-OUTPUT set (Global Logistics uploads the customs outputs). + +const JPG_EXTENSIONS = ["jpg", "jpeg", "png", "pdf"]; +const CLEARANCE_ENTITY = "booking_clearance"; + +/** Build a clearance field with sensible defaults; `critical` marks isRequired. */ +function clearanceField( + fileKey: string, + fileLabel: string, + displayOrder: number, + opts?: { required?: boolean; help?: string; extensions?: string[] }, +): OnboardingField { + return { + fileKey, + fileLabel, + helpText: opts?.help ?? "", + isRequired: opts?.required ?? true, + isMultiple: false, + maxFiles: 1, + allowedExtensions: opts?.extensions ?? DOC_EXTENSIONS, + maxSizeMb: 10, + displayOrder, + }; +} + +/** Documents shared by every container import category (with/without customs). */ +const IMPORT_CONTAINER_FIELDS: OnboardingField[] = [ + clearanceField("commercial_invoice", "Commercial Invoice", 1), + clearanceField("packing_list", "Packing List", 2), + clearanceField("import_license", "Import License", 3), + clearanceField("certificate_of_origin", "Certificate of Origin", 4), + clearanceField( + "external_freight_cost", + "External Freight Cost / Checkup Documentation", + 5, + ), + clearanceField("bill_of_lading", "Bill of Lading / Railway Bill", 6), + clearanceField("vgm", "Verified Gross Mass (VGM)", 7, { required: true }), + clearanceField("release_order", "Release Order", 8, { required: true }), +]; + +/** Documents shared by every container export category (with/without customs). */ +const EXPORT_CONTAINER_FIELDS: OnboardingField[] = [ + clearanceField("booking_confirmation", "Booking Confirmation", 1), + clearanceField("commercial_invoice", "Commercial Invoice", 2), + clearanceField("packing_list", "Packing List", 3), + clearanceField("shipping_instruction", "Shipping Instruction", 4), + clearanceField("bank_permit", "Bank Permit", 5), + clearanceField("export_license", "Export License", 6), + clearanceField("vgm_letter", "VGM Letter", 7, { required: true }), + clearanceField("railway_bill", "Railway Bill", 8), + clearanceField("delegation_letter", "Delegation Letter / POA", 9, { + required: false, + help: "Required only if EDR manages all transit activity.", + }), +]; + +/** Bulk import documents (shorter, transit-focused set). */ +const IMPORT_BULK_FIELDS: OnboardingField[] = [ + clearanceField("packing_list", "Packing List", 1, { required: true }), + clearanceField("bill_of_loading", "Bill of Loading", 2, { required: true }), + clearanceField("port_invoice", "Port Invoice", 3), +]; + +/** Bulk export documents (transit/customs corridor docs). */ +const EXPORT_BULK_FIELDS: OnboardingField[] = [ + clearanceField("release_order_djibouti", "Release Order (Djibouti)", 1), + clearanceField("port_gate_pass", "Port Gate Pass", 2), + clearanceField("port_invoice", "Port Invoice", 3), +]; + +/** GL-uploaded customs output documents (import container). */ +const IMPORT_CONTAINER_OUTPUT_FIELDS: OnboardingField[] = [ + clearanceField("im4", "IM4 — Permanent Import Document", 1), + clearanceField("im5", "IM5 — Temporary Import Document", 2, { + required: false, + }), + clearanceField("transit_permitted", "Transit Permitted Screenshot", 3, { + extensions: JPG_EXTENSIONS, + }), +]; + +/** GL-uploaded customs output documents (export container). */ +const EXPORT_CONTAINER_OUTPUT_FIELDS: OnboardingField[] = [ + clearanceField("ex3", "EX3 — Permanent Export Document", 1), + clearanceField("ex8", "EX8 — Export Transit Document", 2), + clearanceField("export_release", "Export Release", 3), + clearanceField("t1", "T1 — Transport Document", 4), +]; + +const CLEARANCE_DOCUMENT_SETTINGS: OnboardingDocumentSetting[] = [ + // ── Customer-input sets ── + { + code: "clearance_import_container_with_customs", + label: "Import container clearance documents (with customs)", + entity: CLEARANCE_ENTITY, + fields: IMPORT_CONTAINER_FIELDS, + }, + { + code: "clearance_import_container_without_customs", + label: "Import container documents (without customs)", + entity: CLEARANCE_ENTITY, + fields: IMPORT_CONTAINER_FIELDS, + }, + { + code: "clearance_export_container_with_customs", + label: "Export container clearance documents (with customs)", + entity: CLEARANCE_ENTITY, + fields: EXPORT_CONTAINER_FIELDS, + }, + { + code: "clearance_export_container_without_customs", + label: "Export container documents (without customs)", + entity: CLEARANCE_ENTITY, + fields: EXPORT_CONTAINER_FIELDS, + }, + { + code: "clearance_import_bulk_with_customs", + label: "Import bulk clearance documents (with customs)", + entity: CLEARANCE_ENTITY, + fields: IMPORT_BULK_FIELDS, + }, + { + code: "clearance_import_bulk_without_customs", + label: "Import bulk documents (without customs)", + entity: CLEARANCE_ENTITY, + fields: IMPORT_BULK_FIELDS, + }, + { + code: "clearance_export_bulk_with_customs", + label: "Export bulk clearance documents (with customs)", + entity: CLEARANCE_ENTITY, + fields: EXPORT_BULK_FIELDS, + }, + { + code: "clearance_export_bulk_without_customs", + label: "Export bulk documents (without customs)", + entity: CLEARANCE_ENTITY, + fields: EXPORT_BULK_FIELDS, + }, + // ── GL-output sets (customs only) ── + { + code: "clearance_output_import_container", + label: "Customs output documents (import container)", + entity: CLEARANCE_ENTITY, + fields: IMPORT_CONTAINER_OUTPUT_FIELDS, + }, + { + code: "clearance_output_export_container", + label: "Customs output documents (export container)", + entity: CLEARANCE_ENTITY, + fields: EXPORT_CONTAINER_OUTPUT_FIELDS, + }, +]; + +const CLEARANCE_DESCRIPTION = + "Operation/clearance documents collected after contract execution, by operation, freight type and customs."; + @Injectable() export class FileUploadSettingsSeeder { private readonly logger = new Logger(FileUploadSettingsSeeder.name); @@ -203,12 +366,25 @@ export class FileUploadSettingsSeeder { const settingRepository = manager.getRepository(FileUploadSetting); const fieldRepository = manager.getRepository(FileUploadField); - for (const documentSetting of COMPANY_ONBOARDING_DOCUMENTS) { + const allSettings: Array< + OnboardingDocumentSetting & { description: string } + > = [ + ...COMPANY_ONBOARDING_DOCUMENTS.map((s) => ({ + ...s, + description: COMPANY_ONBOARDING_DESCRIPTION, + })), + ...CLEARANCE_DOCUMENT_SETTINGS.map((s) => ({ + ...s, + description: CLEARANCE_DESCRIPTION, + })), + ]; + + for (const documentSetting of allSettings) { await settingRepository.upsert( { code: documentSetting.code, label: documentSetting.label, - description: COMPANY_ONBOARDING_DESCRIPTION, + description: documentSetting.description, entity: documentSetting.entity, }, { @@ -245,7 +421,7 @@ export class FileUploadSettingsSeeder { }); this.logger.log( - "Ensured company onboarding file upload settings for external companies", + "Ensured company onboarding + booking clearance file upload settings", ); } } diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index ed0a494ab..669ea2153 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -15,7 +15,6 @@ export const RULE_ENGINE_RESOURCE_SLUGS = [ 'yards', 'shipping-lines', 'weight-limit-rules', - 'surcharge-types', 'priority-configs', 'rates', 'approval-rules', @@ -52,6 +51,9 @@ export const BOOKING_PERMISSIONS: FreightPermissionSeed[] = [ perm('a1000001-0001-4000-8000-00000000000c', 'edr_freight_app:bookings:payment_verify', 'Verify payment'), perm('a1000001-0001-4000-8000-00000000000d', 'edr_freight_app:bookings:operations', 'Booking operations'), perm('a1000001-0001-4000-8000-00000000000e', 'edr_freight_app:bookings:cancel', 'Cancel booking'), + perm('a1000001-0001-4000-8000-000000000020', 'edr_freight_app:bookings:review_documents', 'Review clearance documents'), + perm('a1000001-0001-4000-8000-000000000021', 'edr_freight_app:bookings:upload_clearance_output', 'Upload customs output documents'), + perm('a1000001-0001-4000-8000-000000000022', 'edr_freight_app:bookings:finalize_clearance', 'Finalize document clearance'), perm('a1000001-0001-4000-8000-00000000000f', 'edr_freight_app:train_scheduling:view', 'View train scheduling'), perm('a1000001-0001-4000-8000-000000000010', 'edr_freight_app:train_scheduling:manage', 'Manage train scheduling'), perm('a1000001-0001-4000-8000-000000000011', 'edr_freight_app:fleet:view', 'View fleet'), @@ -67,7 +69,6 @@ const RULE_ENGINE_PERMISSION_IDS: Record [ct.code, ct])); - const rates = await this.seedRates(rRepo, ctByCode); - const ratesByType = new Map(); - for (const r of rates) { - const key = `${r.rateType}|${r.currency}|${r.containerTypeId ?? ""}`; - if (!ratesByType.has(key)) ratesByType.set(key, []); - ratesByType.get(key)!.push(r); - } + // Clear booking cargo modifiers up front — they reference rate snapshots + // that get recomputed when bookings are repriced. + await manager.getRepository(BookingCargoModifier).createQueryBuilder().delete().execute(); - await this.seedSurchargeTypes(manager, ratesByType); + const cargoTypesForRates = await manager.getRepository(CargoType).find(); + const cargoForRatesByCode = new Map(cargoTypesForRates.map((c) => [c.code, c])); + await this.seedRates(rRepo, ctByCode, cargoForRatesByCode); const yards = await yRepo.find(); const yardByCode = new Map(yards.map((y) => [y.code, y])); @@ -180,7 +177,7 @@ export class PricingDataSeeder { includesFirstMile: true, includesLastMile: true, includesCustoms: true, - priorityBonusPoints: 100, + priorityBonusPoints: 15, isActive: true, displayOrder: 2, }, @@ -192,7 +189,7 @@ export class PricingDataSeeder { includesFirstMile: false, includesLastMile: false, includesCustoms: false, - priorityBonusPoints: 50, + priorityBonusPoints: 10, isActive: true, displayOrder: 3, }, @@ -246,6 +243,7 @@ export class PricingDataSeeder { { code: "GRAIN", cargoTypeName: "Grain / Cereals", + showFreeTextBox: false, requiresDirectorApproval: false, isActive: true, displayOrder: 1, @@ -253,6 +251,7 @@ export class PricingDataSeeder { { code: "FERTILIZER", cargoTypeName: "Fertilizer", + showFreeTextBox: false, requiresDirectorApproval: false, isActive: true, displayOrder: 2, @@ -260,6 +259,7 @@ export class PricingDataSeeder { { code: "CEMENT", cargoTypeName: "Cement / Clinker", + showFreeTextBox: false, requiresDirectorApproval: false, isActive: true, displayOrder: 3, @@ -267,6 +267,7 @@ export class PricingDataSeeder { { code: "STEEL", cargoTypeName: "Steel / Rebar", + showFreeTextBox: false, requiresDirectorApproval: true, isActive: true, displayOrder: 4, @@ -274,6 +275,7 @@ export class PricingDataSeeder { { code: "MACHINERY", cargoTypeName: "Heavy Machinery", + showFreeTextBox: false, requiresDirectorApproval: true, isActive: true, displayOrder: 5, @@ -281,6 +283,7 @@ export class PricingDataSeeder { { code: "OTHER_BULK", cargoTypeName: "Other Bulk Cargo", + showFreeTextBox: false, requiresDirectorApproval: false, isActive: true, displayOrder: 6, @@ -382,9 +385,11 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise { this.logger.log("Seeded weight limit rules"); } private async seedPriorityConfigs(prRepo: any): Promise { - // Wagon Count Block — independent, applies regardless of currency. - // Currency Block — applies only to the matching payment currency, within the wagon range. - // Both blocks are additive (see RuleEngineService.evaluate). + // Priority rule = Wagon Block + Currency Block (both additive; see RuleEngineService.evaluate). + // Combined with the service-type bonus the total priority score caps at 100: + // service-type bonus (≤ 15) + wagon block (≤ 50) + currency block (≤ 35) = 100. + // Wagon Count Block — independent, applies regardless of currency. Max 50. + // Currency Block — applies only to the matching payment currency, within the wagon range. Max 35. const rows = [ // ── Wagon Count Block ─────────────────────────────────────────────── { type: "WAGON", label: "Wagons 1–20", currency: null, minWagonCount: 1, maxWagonCount: 20, scorePoints: 0, displayOrder: 1 }, @@ -392,7 +397,7 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise { { 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 }, // ── 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: "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 { private async seedRates( rRepo: any, ctByCode: Map, + cargoByCode: Map, ): Promise { const effectiveFrom = new Date("2026-01-01"); const now = new Date(); - // await rRepo.createQueryBuilder().delete().execute(); + // Each rate is self-describing: `appliesTo` + `trigger` decide how the + // engine uses it. trigger=ALWAYS → base freight; anything else → a + // surcharge that stacks additively when the booking matches. const rateData = [ - { - rateType: "CONTAINER_IMPORT", - containerTypeId: ctByCode.get("20FT")!.id, - currency: "USD", - rateValue: 800, - rateUnit: "PER_CONTAINER", - }, - { - rateType: "CONTAINER_IMPORT", - containerTypeId: ctByCode.get("40FT")!.id, - currency: "USD", - rateValue: 1200, - rateUnit: "PER_CONTAINER", - }, - { - rateType: "CONTAINER_EXPORT", - containerTypeId: ctByCode.get("20FT")!.id, - currency: "USD", - rateValue: 600, - rateUnit: "PER_CONTAINER", - }, - { - rateType: "CONTAINER_EXPORT", - containerTypeId: ctByCode.get("40FT")!.id, - currency: "USD", - rateValue: 900, - rateUnit: "PER_CONTAINER", - }, - { - rateType: "CONTAINER_IMPORT", - containerTypeId: null, - currency: "USD", - rateValue: 1000, - rateUnit: "PER_CONTAINER", - }, - { - rateType: "CONTAINER_EXPORT", - containerTypeId: null, - currency: "USD", - rateValue: 750, - rateUnit: "PER_CONTAINER", - }, - { - rateType: "INTERCITY_CONTAINER", - containerTypeId: ctByCode.get("20FT")!.id, - currency: "USD", - rateValue: 350, - rateUnit: "PER_CONTAINER", - }, - { - rateType: "INTERCITY_CONTAINER", - containerTypeId: ctByCode.get("40FT")!.id, - currency: "USD", - rateValue: 550, - rateUnit: "PER_CONTAINER", - }, - { - rateType: "INTERCITY_CONTAINER", - containerTypeId: null, - currency: "USD", - rateValue: 400, - rateUnit: "PER_CONTAINER", - }, - { - rateType: "INTERCITY_BULK", - containerTypeId: null, - currency: "USD", - rateValue: 35, - rateUnit: "PER_TON", - }, - { - rateType: "BULK_IMPORT", - containerTypeId: null, - currency: "USD", - rateValue: 50, - rateUnit: "PER_TON", - }, - { - rateType: "BULK_EXPORT", - containerTypeId: null, - currency: "USD", - rateValue: 40, - rateUnit: "PER_TON", - }, - { - rateType: "OVERWEIGHT_PER_TON", - containerTypeId: null, - currency: "USD", - rateValue: 25, - rateUnit: "PER_TON", - }, - { - rateType: "HAZARD_SURCHARGE", - containerTypeId: null, - currency: "USD", - rateValue: 150, - rateUnit: "FLAT", - }, - { - rateType: "REEFER_SURCHARGE", - containerTypeId: null, - currency: "USD", - rateValue: 200, - rateUnit: "FLAT", - }, - { - rateType: "DOUBLE_HANDLING", - containerTypeId: null, - currency: "USD", - rateValue: 100, - rateUnit: "PER_CONTAINER", - }, - { - rateType: "LASHING", - containerTypeId: null, - currency: "USD", - rateValue: 50, - rateUnit: "PER_CONTAINER", - }, + // ── Container base freight ────────────────────────────────────────── + { appliesTo: "CONTAINER", trigger: "ALWAYS", rateType: "CONTAINER_IMPORT", tradeDirection: "IMPORT", containerTypeId: ctByCode.get("20FT")!.id, rateValue: 800, rateUnit: "PER_CONTAINER" }, + { appliesTo: "CONTAINER", trigger: "ALWAYS", rateType: "CONTAINER_IMPORT", tradeDirection: "IMPORT", containerTypeId: ctByCode.get("40FT")!.id, rateValue: 1200, rateUnit: "PER_CONTAINER" }, + { appliesTo: "CONTAINER", trigger: "ALWAYS", rateType: "CONTAINER_EXPORT", tradeDirection: "EXPORT", containerTypeId: ctByCode.get("20FT")!.id, rateValue: 600, rateUnit: "PER_CONTAINER" }, + { appliesTo: "CONTAINER", trigger: "ALWAYS", rateType: "CONTAINER_EXPORT", tradeDirection: "EXPORT", containerTypeId: ctByCode.get("40FT")!.id, rateValue: 900, rateUnit: "PER_CONTAINER" }, + { appliesTo: "CONTAINER", trigger: "ALWAYS", rateType: "CONTAINER_IMPORT", tradeDirection: "IMPORT", containerTypeId: null, rateValue: 1000, rateUnit: "PER_CONTAINER" }, + { appliesTo: "CONTAINER", trigger: "ALWAYS", rateType: "CONTAINER_EXPORT", tradeDirection: "EXPORT", containerTypeId: null, rateValue: 750, rateUnit: "PER_CONTAINER" }, + // ── Intercity base freight ────────────────────────────────────────── + { appliesTo: "INTERCITY", trigger: "ALWAYS", rateType: "INTERCITY_CONTAINER", containerTypeId: ctByCode.get("20FT")!.id, rateValue: 350, rateUnit: "PER_CONTAINER" }, + { appliesTo: "INTERCITY", trigger: "ALWAYS", rateType: "INTERCITY_CONTAINER", containerTypeId: ctByCode.get("40FT")!.id, rateValue: 550, rateUnit: "PER_CONTAINER" }, + { appliesTo: "INTERCITY", trigger: "ALWAYS", rateType: "INTERCITY_CONTAINER", containerTypeId: null, rateValue: 400, rateUnit: "PER_CONTAINER" }, + { appliesTo: "INTERCITY", trigger: "ALWAYS", rateType: "INTERCITY_BULK", rateValue: 35, rateUnit: "PER_TON" }, + // ── Bulk base freight (by leaf cargo type where known) ────────────── + { appliesTo: "BULK", trigger: "ALWAYS", rateType: "BULK_IMPORT", tradeDirection: "IMPORT", cargoTypeId: cargoByCode.get("GRAIN")?.id ?? null, rateValue: 50, rateUnit: "PER_TON" }, + { appliesTo: "BULK", trigger: "ALWAYS", rateType: "BULK_EXPORT", tradeDirection: "EXPORT", cargoTypeId: cargoByCode.get("GRAIN")?.id ?? null, rateValue: 40, rateUnit: "PER_TON" }, + { appliesTo: "BULK", trigger: "ALWAYS", rateType: "BULK_IMPORT", tradeDirection: "IMPORT", cargoTypeId: null, rateValue: 50, rateUnit: "PER_TON" }, + { appliesTo: "BULK", trigger: "ALWAYS", rateType: "BULK_EXPORT", tradeDirection: "EXPORT", cargoTypeId: null, rateValue: 40, rateUnit: "PER_TON" }, + // ── Surcharges (trigger-based) ────────────────────────────────────── + { appliesTo: "OTHER", trigger: "OVERWEIGHT", rateType: "OVERWEIGHT_PER_TON", rateValue: 25, rateUnit: "PER_TON" }, + { appliesTo: "OTHER", trigger: "HAZARDOUS", rateType: "HAZARD_SURCHARGE", rateValue: 150, rateUnit: "FLAT" }, + { appliesTo: "OTHER", trigger: "REEFER", rateType: "REEFER_SURCHARGE", rateValue: 200, rateUnit: "FLAT" }, + { appliesTo: "OTHER", trigger: "SHIPPING_LINE", rateType: "DOUBLE_HANDLING", rateValue: 100, rateUnit: "PER_CONTAINER" }, + { appliesTo: "OTHER", trigger: "CONSOLIDATION", rateType: "LASHING", rateValue: 50, rateUnit: "PER_CONTAINER" }, ]; - const entities = rateData.map((d) => - rRepo.create({ + // Idempotent: insert each canonical rate only if no row with the same + // signature already exists. Re-running the seeder must NOT accumulate + // duplicate rows — duplicated surcharge rates would otherwise repeat on + // every booking's price breakdown. + const signature = (r: { + rateType: string; + rateUnit: string; + rateValue: number; + currency: string; + containerTypeId?: string | null; + cargoTypeId?: string | null; + }) => + [ + r.rateType, + r.rateUnit, + Number(r.rateValue), + r.currency, + r.containerTypeId ?? "", + r.cargoTypeId ?? "", + ].join("|"); + + const existing: Rate[] = await rRepo.find(); + const existingBySignature = new Set(existing.map((r) => signature(r))); + + const toCreate = rateData + .map((d) => ({ + currency: "USD", ...d, - status: "LIVE", + status: "LIVE" as const, proposedByStaffId: STAFF_USER_ID, approvedByCeoId: CEO_USER_ID, approvedAt: now, effectiveFrom, - }), - ); - return rRepo.save(entities); - } + })) + .filter((d) => !existingBySignature.has(signature(d))); - private async seedSurchargeTypes( - manager: any, - ratesByType: Map, - ): Promise { - const surRepo = manager.getRepository(SurchargeType); - const bcmRepo = manager.getRepository(BookingCargoModifier); - await bcmRepo.createQueryBuilder().delete().execute(); - const findRate = (rateType: string, currency: string) => { - const key = `${rateType}|${currency}|`; - const rates = ratesByType.get(key); - return rates?.[0]; - }; + if (toCreate.length === 0) { + this.logger.log("Rates already seeded — skipping (idempotent)"); + return existing; + } - const hazardRateUsd = findRate("HAZARD_SURCHARGE", "USD"); - const reeferRateUsd = findRate("REEFER_SURCHARGE", "USD"); - const overweightRateUsd = findRate("OVERWEIGHT_PER_TON", "USD"); - const shipLineRateUsd = findRate("DOUBLE_HANDLING", "USD"); - const consolidRateUsd = findRate("LASHING", "USD"); - - await surRepo.createQueryBuilder().delete().execute(); - await surRepo.save([ - surRepo.create({ - code: "HAZARDOUS_CARGO", - label: "Hazardous Cargo", - triggerCondition: "CARGO_FLAG_HAZARDOUS", - rateId: hazardRateUsd?.id, - isActive: true, - }), - surRepo.create({ - code: "REEFER_CARGO", - label: "Reefer Cargo", - triggerCondition: "CARGO_FLAG_REEFER", - rateId: reeferRateUsd?.id, - isActive: true, - }), - surRepo.create({ - code: "OVERWEIGHT_CARGO", - label: "Overweight Cargo", - triggerCondition: "VGM_EXCEEDS_LIMIT", - rateId: overweightRateUsd?.id, - isActive: true, - }), - surRepo.create({ - code: "SHIPPING_LINE_FEE", - label: "Shipping Line Fee", - triggerCondition: "SHIPPING_LINE_MAPPED", - rateId: shipLineRateUsd?.id, - isActive: true, - }), - surRepo.create({ - code: "CONSOLIDATION_FEE", - label: "Consolidation Fee", - triggerCondition: "CONSOLIDATION_ENABLED", - rateId: consolidRateUsd?.id, - isActive: true, - }), - ]); - this.logger.log("Seeded surcharge types"); + const created = await rRepo.save(toCreate.map((d) => rRepo.create(d))); + this.logger.log(`Seeded ${created.length} new rate(s)`); + return [...existing, ...created]; } private async seedDraftBookings( @@ -621,15 +506,29 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise { slByCode: Map, cargoByCode: Map, ): Promise { - const djibouti = yardByCode.get("DJIBOUTI")!; - const addis = yardByCode.get("ADDIS_ABABA")!; - const railContainer = stByCode.get("RAIL_CONTAINER")!; - const railBulk = stByCode.get("RAIL_BULK")!; - const maersk = slByCode.get("MAERSK")!; - const grain = cargoByCode.get("GRAIN")!; - const twenty = ctByCode.get("20FT")!; - const forty = ctByCode.get("40FT")!; - const twentyReefer = ctByCode.get("20FT_REEFER")!; + const djibouti = yardByCode.get("DJIBOUTI"); + const addis = yardByCode.get("ADDIS_ABABA"); + const railContainer = stByCode.get("RAIL_CONTAINER"); + const railBulk = stByCode.get("RAIL_BULK"); + const maersk = slByCode.get("MAERSK"); + const grain = cargoByCode.get("GRAIN"); + const twenty = ctByCode.get("20FT"); + const forty = ctByCode.get("40FT"); + const twentyReefer = ctByCode.get("20FT_REEFER"); + + const missing: string[] = []; + if (!djibouti) missing.push("yard:DJIBOUTI"); + if (!addis) missing.push("yard:ADDIS_ABABA"); + if (!railContainer) missing.push("serviceType:RAIL_CONTAINER"); + if (!railBulk) missing.push("serviceType:RAIL_BULK"); + if (!grain) missing.push("cargoType:GRAIN"); + if (!twenty) missing.push("containerType:20FT"); + if (!forty) missing.push("containerType:40FT"); + if (!twentyReefer) missing.push("containerType:20FT_REEFER"); + if (missing.length > 0) { + this.logger.warn(`seedDraftBookings: skipping — missing reference data: ${missing.join(", ")}`); + return; + } const drafts = [ { @@ -641,9 +540,7 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise { serviceTypeId: railContainer.id, originYardId: djibouti.id, destinationYardId: addis.id, - isHazardous: false, - allowConsolidation: false, - shippingLineId: null, + isHazardous: false, shippingLineId: null, cargoTypeId: null, cargoTotalWeightVgm: 250, containers: [ @@ -661,9 +558,7 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise { serviceTypeId: railContainer.id, originYardId: djibouti.id, destinationYardId: addis.id, - isHazardous: true, - allowConsolidation: false, - shippingLineId: null, + isHazardous: true, shippingLineId: null, cargoTypeId: null, cargoTotalWeightVgm: 135, containers: [ @@ -681,9 +576,7 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise { serviceTypeId: railContainer.id, originYardId: djibouti.id, destinationYardId: addis.id, - isHazardous: false, - allowConsolidation: false, - shippingLineId: maersk.id, + isHazardous: false, shippingLineId: maersk.id, cargoTypeId: null, cargoTotalWeightVgm: 480, containers: [ @@ -701,9 +594,7 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise { serviceTypeId: railContainer.id, originYardId: djibouti.id, destinationYardId: addis.id, - isHazardous: false, - allowConsolidation: true, - shippingLineId: null, + isHazardous: false, shippingLineId: null, cargoTypeId: null, cargoTotalWeightVgm: 224, containers: [ @@ -721,9 +612,7 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise { serviceTypeId: railBulk.id, originYardId: djibouti.id, destinationYardId: addis.id, - isHazardous: false, - allowConsolidation: false, - shippingLineId: null, + isHazardous: false, shippingLineId: null, cargoTypeId: grain.id, cargoTotalWeightVgm: 500, containers: [], @@ -739,9 +628,7 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise { serviceTypeId: railContainer.id, originYardId: djibouti.id, destinationYardId: addis.id, - isHazardous: false, - allowConsolidation: false, - shippingLineId: null, + isHazardous: false, shippingLineId: null, cargoTypeId: null, cargoTotalWeightVgm: 75, containers: [ @@ -759,9 +646,7 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise { serviceTypeId: railContainer.id, originYardId: djibouti.id, destinationYardId: addis.id, - isHazardous: false, - allowConsolidation: false, - shippingLineId: null, + isHazardous: false, shippingLineId: null, cargoTypeId: null, cargoTotalWeightVgm: 300, containers: [ diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 4d0c8c9d0..680e86858 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -1,3 +1,4 @@ +<<<<<<< HEAD import { Navigate, Outlet, Route, Routes, useLocation, useNavigate } from "react-router-dom"; import { Boxes, @@ -562,3 +563,710 @@ const App = () => { }; export default App; +======= +import { + Boxes, + Building2, + Container, + FileText, + LayoutDashboard, + LayoutGrid, + Network, + Package, + PackageCheck, + PackageOpen, + Paperclip, + Send, + Settings, + ShieldCheck, + SlidersHorizontal, + Train, + Truck, + Users, + Wallet, +} from "lucide-react"; +import { Navigate, Outlet, Route, Routes, useLocation, useNavigate } from "react-router-dom"; + +import { FreightDashboardLayout, type SidebarItem, type SidebarSection } from "@/components/layout"; +import { useAuth } from "./auth/useAuth"; +import LoadingScreen from "./components/LoadingScreen"; +import LoginPage from "./pages/auth/LoginPage"; +import BookingContractPage from "./pages/bookings/BookingContractPage"; +import BookingRequestDetailPage from "./pages/bookings/BookingRequestDetailPage"; +import BookingRequestsPage from "./pages/bookings/BookingRequestsPage"; +import GlClearancePage from "./pages/bookings/GlClearancePage"; +import NewBookingPage from "./pages/bookings/NewBookingPage"; +import CustomerDetailPage from "./pages/customers/CustomerDetailPage"; +import CustomersPage from "./pages/customers/CustomersPage"; +import DemoUser1Page from "./pages/dashboard/demo/DemoUser1Page"; +import DemoUser2Page from "./pages/dashboard/demo/DemoUser2Page"; +import MyProfilePage from "./pages/dashboard/MyProfilePage"; +import OverviewPage from "./pages/dashboard/OverviewPage"; +import UserManagementHostPage from "./pages/dashboard/user-management/UserManagementHostPage"; +import PaymentsPage from "./pages/payments/PaymentsPage"; +//import EmployeesPage from "./pages/dashboard/user-management/EmployeesPage"; +import { RequirePermission } from "./components/auth/RequirePermission"; +import { FREIGHT_PERMS, hasPermission as hasFreightPermission } from "./lib/permissions"; +import PermissionsPage from "./pages/dashboard/user-management/PermissionsPage"; +import PositionTypesPage from "./pages/dashboard/user-management/PositionTypesPage"; +import RolesPage from "./pages/dashboard/user-management/RolesPage"; +import UserManagementPage from "./pages/dashboard/user-management/UserManagementPage"; +import UsersPage from "./pages/dashboard/user-management/UsersPage"; +import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage"; +import DropdownSettingsPage from "./pages/dropdown_settings/DropdownSettingsPage"; +import FleetResourcePage from "./pages/fleet/FleetResourcePage"; +import RoutesPage from "./pages/fleet/RoutesPage"; +import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources"; +import RuleEngineLegacyRedirect from "./pages/ruleEngine/RuleEngineLegacyRedirect"; +import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage"; +import CargoTypesPage from "./pages/ruleEngine/CargoTypesPage"; +import TrainScheduleV2ListPage from "./pages/trainScheduling/TrainScheduleV2ListPage"; +import BatchBoardPage from "./pages/trainScheduling/BatchBoardPage"; +import BatchScheduleDetailPage from "./pages/trainScheduling/BatchScheduleDetailPage"; +import TrainScheduleV2DetailPage from "./pages/trainScheduling/TrainScheduleV2DetailPage"; +import TrainScheduleTrackPage from "./pages/trainScheduling/TrainScheduleTrackPage"; +import TrainSchedulingGlobalRulesPage from "./pages/trainScheduling/TrainSchedulingGlobalRulesPage"; +import FirstMilePage from "./pages/operations/FirstMilePage"; +import LastMilePage from "./pages/operations/LastMilePage"; +import TrainDetailPage from "./pages/trains/TrainDetailPage"; +import ArrivalQueuePage from "./pages/warehouses/ArrivalQueuePage"; +import DispatchQueuePage from "./pages/warehouses/DispatchQueuePage"; +import InventoryInquiryPage from "./pages/warehouses/InventoryInquiryPage"; +import LoadedInventoryPage from "./pages/warehouses/LoadedInventoryPage"; +import LoadingQueuePage from "./pages/warehouses/LoadingQueuePage"; +import WarehouseDashboardPage from "./pages/warehouses/WarehouseDashboardPage"; +import WarehouseDetailPage from "./pages/warehouses/WarehouseDetailPage"; +import WarehouseInventoryPage from "./pages/warehouses/WarehouseInventoryPage"; +import WarehouseInvoicesPage from "./pages/warehouses/WarehouseInvoicesPage"; +import WarehouseListPage from "./pages/warehouses/WarehouseListPage"; +import WarehouseRulesPage from "./pages/warehouses/WarehouseRulesPage"; + +const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ + { + title: "Main menu", + items: [ + { + label: "Overview", + href: "/dashboard/overview", + icon: , + }, + { + label: "UM", + href: "/um", + icon: , + }, + { + label: "Booking requests", + href: "/dashboard/booking-requests", + icon: , + }, + { + label: "Customers", + href: "/dashboard/customers", + icon: , + }, + { + label: "Payments", + href: "/dashboard/payments", + icon: , + permission: FREIGHT_PERMS.bookings.view, + }, + ...demoItems, + ], + }, + { + title: "Operations", + items: [ + { + label: "Document Clearance", + href: "/dashboard/clearance", + icon: , + permission: FREIGHT_PERMS.bookings.reviewDocuments, + }, + { + label: "Train Schedules", + href: "/dashboard/operations/train-scheduling-v2", + icon: , + permission: FREIGHT_PERMS.trainScheduling.view, + }, + { + label: "Batch Board", + href: "/dashboard/operations/batch-board", + icon: , + permission: FREIGHT_PERMS.trainScheduling.view, + }, + { + label: "First Mile", + href: "/dashboard/operations/first-mile", + icon: , + permission: FREIGHT_PERMS.trainScheduling.view, + }, + { + label: "Last Mile", + href: "/dashboard/operations/last-mile", + icon: , + permission: FREIGHT_PERMS.trainScheduling.view, + }, + ], + }, + { + title: "Fleet Management", + items: [ + { + label: "Routes", + href: "/dashboard/routes", + icon: , + permission: FREIGHT_PERMS.fleet.view, + }, + { + label: "Locomotives", + href: "/dashboard/locomotives", + icon: , + permission: FREIGHT_PERMS.fleet.view, + }, + + // { + // label: "Wagon types", + // href: "/dashboard/wagon-types", + // icon: , + // }, + { + label: "Wagons", + href: "/dashboard/wagons", + icon: , + permission: FREIGHT_PERMS.fleet.view, + }, + { + label: "Vehicles", + href: "/dashboard/vehicles", + icon: , + permission: FREIGHT_PERMS.fleet.view, + }, + { + label: "Drivers", + href: "/dashboard/drivers", + icon: , + permission: FREIGHT_PERMS.fleet.view, + }, + // { + // label: "Containers", + // href: "/dashboard/containers", + // icon: , + // }, + // { + // label: "Cargoes", + // href: "/dashboard/cargoes", + // icon: , + // }, + ], + }, + { + title: "Warehouse Management", + items: [ + { + label: "Warehouse Dashboard", + href: "/dashboard/warehouse-dashboard", + icon: , + }, + { + label: "Warehouses", + href: "/dashboard/warehouses", + icon: , + }, + { + label: "Inventory", + href: "/dashboard/warehouse-inventory", + icon: , + }, + { + label: "Arrival Queue", + href: "/dashboard/arrival-queue", + icon: , + }, + { + label: "Loading Queue", + href: "/dashboard/loading-queue", + icon: , + }, + { + label: "Loaded Inventory", + href: "/dashboard/loaded-inventory", + icon: , + }, + { + label: "Dispatch Queue", + href: "/dashboard/dispatch-queue", + icon: , + }, + { + label: "Inventory Inquiry", + href: "/dashboard/inventory-inquiry", + icon: , + }, + { + label: "Allocation & Fees", + href: "/dashboard/warehouse-rules", + icon: , + }, + { + label: "Fee Invoices", + href: "/dashboard/warehouse-fee-invoices", + icon: , + }, + ], + }, + { + title: "Administration", + items: [ + { + label: "File settings", + href: "/dashboard/file-settings", + icon: , + permission: FREIGHT_PERMS.admin, + }, + { + label: "Dropdown settings", + href: "/dashboard/dropdown-settings", + icon: , + permission: FREIGHT_PERMS.admin, + }, + ], + }, + { + title: "Freight configuration", + mutedTitle: true, + items: [ + { + label: "Configuration", + href: "/dashboard/configuration", + icon: , + children: [ + ...getCategorySidebarChildren("configuration"), + // { + // label: "Train scheduling rules", + // href: "/dashboard/configuration/train-scheduling-rules", + // }, + ], + }, + { + label: "Rules", + href: "/dashboard/rules", + icon: , + children: getCategorySidebarChildren("rules"), + }, + ], + }, +]; + +/** Keep only items the user is permitted to see; drop now-empty sections. */ +const filterSidebarByPermission = ( + sections: SidebarSection[], + user: ReturnType["user"], +): SidebarSection[] => { + const itemAllowed = (item: SidebarItem): boolean => { + if (!item.permission) return true; + const keys = Array.isArray(item.permission) + ? item.permission + : [item.permission]; + return keys.some((key) => hasFreightPermission(user, key)); + }; + + return sections + .map((section) => ({ + ...section, + items: section.items.filter(itemAllowed), + })) + .filter((section) => section.items.length > 0); +}; + +const DashboardShell = () => { + const navigate = useNavigate(); + const location = useLocation(); + const { user, logout } = useAuth(); + + const demoItems: SidebarItem[] = []; + + const sidebarSections = filterSidebarByPermission( + buildSidebarSections(demoItems), + user, + ); + const displayName = user?.name?.en || user?.username || user?.email || "User"; + + return ( + + + + ); +}; + +const App = () => { + const { user, loading } = useAuth(); + + if (loading) { + return ; + } + + if (!user) { + return ( + + } /> + } /> + } /> + + ); + } + + return ( + + } /> + } /> + } /> + }> + } /> + } /> + + } /> + + + + } + /> + } /> + } /> + } /> + } /> + } + /> + + + + } + /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + {/* Legacy embedded user management routes */} + } /> + } /> + } /> + {/* } /> */} + } /> + } /> + + + + + } + /> + + + + } + /> + + } + /> + + + + } + /> + } /> + } /> + } /> + + } + /> + } /> + + } + /> + } /> + + } /> + } /> + + } /> + } /> + + + } /> + + ); +}; + +export default App; +>>>>>>> ee94833a9ead8a825391bb18604e405ac76c70ca diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/BookingConfirmDialog.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/BookingConfirmDialog.tsx index bfc489e77..6b8e4b650 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/BookingConfirmDialog.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/BookingConfirmDialog.tsx @@ -8,10 +8,22 @@ import { Button, Textarea, FileInput, + NumberInput, } from "@mantine/core"; import type { BookingActionDef } from "@/features/bookings/booking-actions.config"; +/** Today + `days`, formatted as a readable date for the validity preview. */ +function validUntilLabel(days: number): string { + const until = new Date(); + until.setDate(until.getDate() + days); + return until.toLocaleDateString(undefined, { + year: "numeric", + month: "short", + day: "numeric", + }); +} + interface BookingConfirmDialogProps { open: boolean; onOpenChange: (open: boolean) => void; @@ -46,8 +58,18 @@ export function BookingConfirmDialog({ const Icon = action.icon; const needsTextInput = action.input === "note" || action.input === "reason"; const needsFileInput = action.input === "file"; + const needsDaysInput = action.input === "days"; + const needsAmountInput = action.input === "amount"; + const daysValue = Number(inputValue.trim()); + const daysValid = + Number.isInteger(daysValue) && daysValue >= 1 && daysValue <= 365; + const amountValue = Number(inputValue.trim()); + const amountValid = !!inputValue.trim() && Number.isFinite(amountValue) && amountValue >= 0; const inputMissing = - (needsTextInput && !inputValue.trim()) || (needsFileInput && !selectedFile); + (needsTextInput && !inputValue.trim()) || + (needsFileInput && !selectedFile) || + (needsDaysInput && !daysValid) || + (needsAmountInput && !amountValid); const isDestructive = action.variant === "destructive"; const accent = isDestructive ? "red" : "edr-green"; @@ -129,6 +151,40 @@ export function BookingConfirmDialog({ clearable /> )} + {needsDaysInput && ( + + onInputChange(value === "" ? "" : String(value))} + /> + + {daysValid + ? `Contract valid from today until ${validUntilLabel(daysValue)} (${daysValue} day${daysValue === 1 ? "" : "s"}).` + : "Enter a whole number of days between 1 and 365."} + + + )} + {needsAmountInput && ( + onInputChange(value === "" ? "" : String(value))} + /> + )} {extra} diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/BookingPricingSummary.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/BookingPricingSummary.tsx index 1342df085..c0dd56f74 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/BookingPricingSummary.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/BookingPricingSummary.tsx @@ -1,50 +1,171 @@ -import { Banknote, Receipt } from "lucide-react"; -import { Paper, Stack, Group, Text, Divider } from "@mantine/core"; +import { useState } from "react"; +import { Banknote, Pencil, Receipt } from "lucide-react"; +import { + Button, + Divider, + Group, + NumberInput, + Paper, + Stack, + Text, + Textarea, +} from "@mantine/core"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import toast from "react-hot-toast"; import type { BookingDetail } from "@/types/booking"; +import { bookingsService } from "@/services/bookings.service"; import { SectionCard } from "./detail/SectionCard"; import { detailStyles } from "./detail/booking-detail.styles"; export function BookingPricingSummary({ booking }: { booking: BookingDetail }) { - const amount = Number(booking.totalAmount); - const modifiers = booking.cargoModifiers ?? []; + const qc = useQueryClient(); + const computed = Number(booking.totalAmount); + const isAdjusted = + booking.adjustedTotalAmount !== null && + booking.adjustedTotalAmount !== undefined; + const effective = isAdjusted ? Number(booking.adjustedTotalAmount) : computed; + + const lineItems = booking.pricingBreakdown?.lineItems ?? []; + + const [editing, setEditing] = useState(false); + const [amount, setAmount] = useState(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 ( - - Total amount - - - {booking.paymentCurrency}{" "} - {amount.toLocaleString(undefined, { minimumFractionDigits: 2 })} - + +
+ + {isAdjusted ? "Adjusted total" : "Total amount"} + + + {fmt(effective)} + + {isAdjusted && ( + + Computed: {fmt(computed)} + {booking.adjustmentReason ? ` · ${booking.adjustmentReason}` : ""} + + )} +
+ {!editing && ( + + )} +
+ + {editing && ( + + setAmount(v === "" ? "" : Number(v))} + min={0} + radius="md" + prefix={`${booking.paymentCurrency} `} + thousandSeparator="," + /> +