feat: Implement container hazardous-cargo surcharge logic

- Added support for container hazardous-cargo surcharge (HAZARDOUS billed PER_CONTAINER) in the rule engine.
- Introduced new method  in  to calculate and apply container hazard charges based on booking details.
- Updated  to handle per-container hazard rates, ensuring they are scoped by trade direction and lane.
- Enhanced tests to cover scenarios for container hazard rates, including validation for required fields and conflict checks.
- Created a migration to update existing rates and enforce new constraints for container hazard rates in the database.
This commit is contained in:
marshal
2026-09-06 22:28:57 +00:00
parent 1eb9f10354
commit 00c0d86a8a
11 changed files with 789 additions and 98 deletions

View File

@@ -0,0 +1,78 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* The container hazardous-cargo surcharge (HAZARDOUS billed PER_CONTAINER) is
* now sold per trade direction + origin → destination lane, optionally per
* container type (20ft / 40ft) — the same shape as the empty-return service.
* The per-ton (bulk) hazard rate keeps its global, unscoped shape.
*
* - CK_rates_yard_scope gains the per-container hazard rate in its
* yard-carrying branch. Drop-and-recreate is the established shape for this
* constraint — see 3890000000000-EmptyContainerRateScope.
* - Existing lane-less per-container hazard rows cannot satisfy the new
* branch and no longer match the way the engine prices container hazard
* (per lane + size), so they are SUPERSEDED — kept for the audit trail, out
* of the unique pattern index and out of pricing. The rates team re-enters
* the surcharge per lane; until then a hazardous container booking on that
* lane hard-blocks rather than shipping the service for free.
*/
export class ContainerHazardRateScope3960000000000 implements MigrationInterface {
name = "ContainerHazardRateScope3960000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
UPDATE freight.rates
SET status = 'SUPERSEDED'
WHERE trigger = 'HAZARDOUS'
AND rate_unit = 'PER_CONTAINER'
AND (origin_yard_id IS NULL OR destination_yard_id IS NULL)
AND deleted_at IS NULL
AND status <> 'SUPERSEDED'
`);
await queryRunner.query(
`ALTER TABLE freight.rates DROP CONSTRAINT IF EXISTS "CK_rates_yard_scope"`,
);
await queryRunner.query(`
ALTER TABLE freight.rates ADD CONSTRAINT "CK_rates_yard_scope" CHECK (
deleted_at IS NOT NULL OR status = 'SUPERSEDED' OR
CASE
WHEN (trigger = 'ALWAYS' AND applies_to IN ('BULK', 'CONTAINER', 'EMPTY_CONTAINER', 'INTERCITY'))
OR trigger IN ('CUSTOMS_CLEARANCE', 'ETHIOPIAN_CUSTOMS_CLEARANCE', 'WITH_RETURN', 'FUEL')
OR (trigger = 'HAZARDOUS' AND rate_unit = 'PER_CONTAINER')
THEN origin_yard_id IS NOT NULL AND destination_yard_id IS NOT NULL
ELSE origin_yard_id IS NULL AND destination_yard_id IS NULL
END
)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
// Lane-scoped per-container hazard rows have no place under the old
// constraint (surcharges carried no yards) — retire them the same way.
await queryRunner.query(`
UPDATE freight.rates
SET status = 'SUPERSEDED'
WHERE trigger = 'HAZARDOUS'
AND rate_unit = 'PER_CONTAINER'
AND (origin_yard_id IS NOT NULL OR destination_yard_id IS NOT NULL)
AND deleted_at IS NULL
AND status <> 'SUPERSEDED'
`);
await queryRunner.query(
`ALTER TABLE freight.rates DROP CONSTRAINT IF EXISTS "CK_rates_yard_scope"`,
);
await queryRunner.query(`
ALTER TABLE freight.rates ADD CONSTRAINT "CK_rates_yard_scope" CHECK (
deleted_at IS NOT NULL OR status = 'SUPERSEDED' OR
CASE
WHEN (trigger = 'ALWAYS' AND applies_to IN ('BULK', 'CONTAINER', 'EMPTY_CONTAINER', 'INTERCITY'))
OR trigger IN ('CUSTOMS_CLEARANCE', 'ETHIOPIAN_CUSTOMS_CLEARANCE', 'WITH_RETURN', 'FUEL')
THEN origin_yard_id IS NOT NULL AND destination_yard_id IS NOT NULL
ELSE origin_yard_id IS NULL AND destination_yard_id IS NULL
END
)
`);
}
}