diff --git a/apps/edr-freight-api/src/common/request-log-context.spec.ts b/apps/edr-freight-api/src/common/request-log-context.spec.ts index 403b1a137..e898cb930 100644 --- a/apps/edr-freight-api/src/common/request-log-context.spec.ts +++ b/apps/edr-freight-api/src/common/request-log-context.spec.ts @@ -65,10 +65,40 @@ describe("RequestLogMiddleware", () => { originalUrl: "/api/bookings/1/submit?dry=1", baseUrl: "/api/bookings", route: { path: "/:id/submit" }, - headers: { "user-agent": "jest", "x-request-id": "req-42" }, + headers: { + "user-agent": "jest", + "x-request-id": "req-42", + authorization: "Bearer tok", + "x-client-app": "freight-backoffice", + "current-project-id": "proj-3", + }, ip: "10.0.0.1", query: { dry: "1" }, - user: { id: "u-7" }, + user: { + id: "u-7", + sessionId: "sess-9", + userType: "STAFF", + status: "ACTIVE", + username: "nati", + email: "nati@example.com", + phoneNumber: "0911000000", + name: { en: "Nati" }, + roles: [{ key: "freight_operations" }], + permissions: [{ key: "a" }, { key: "b" }], + employee: { + id: "emp-1", + organizationId: "org-1", + unitId: "unit-2", + position: { + id: "pos-5", + key: "ops_officer", + employeePositionId: "ep-6", + isDelegate: true, + delegatorId: "pos-1", + positionType: { key: "operations" }, + }, + }, + }, }; const res = { statusCode: 409, @@ -105,6 +135,29 @@ describe("RequestLogMiddleware", () => { bookingId: "b-1", booking: { outcome: "REJECTED" }, }); + expect(JSON.parse(lines[0]).auth).toEqual({ + authenticated: true, + hasBearer: true, + clientApp: "freight-backoffice", + userId: "u-7", + sessionId: "sess-9", + userType: "STAFF", + userStatus: "ACTIVE", + roles: ["freight_operations"], + permissionCount: 2, + employeeId: "emp-1", + organizationId: "org-1", + unitId: "unit-2", + positionId: "pos-5", + positionKey: "ops_officer", + positionType: "operations", + employeePositionId: "ep-6", + isDelegate: true, + delegatorId: "pos-1", + projectId: "proj-3", + }); + // No personal data reaches the line, whatever the token carried. + expect(lines[0]).not.toMatch(/nati|example\.com|0911000000/); expect(res.setHeader).toHaveBeenCalledWith("x-request-id", "req-42"); jest.restoreAllMocks(); }); diff --git a/apps/edr-freight-api/src/contracts/contract-rate-schedule.builder.ts b/apps/edr-freight-api/src/contracts/contract-rate-schedule.builder.ts index c62a875bf..2f1991df2 100644 --- a/apps/edr-freight-api/src/contracts/contract-rate-schedule.builder.ts +++ b/apps/edr-freight-api/src/contracts/contract-rate-schedule.builder.ts @@ -44,6 +44,7 @@ const UNIT_LABELS: Record = { PER_CONTAINER: 'per container', PER_KM: 'per km', PER_TON_KM: 'per ton per km', + PER_LITER: 'per liter', PER_INVOICE: 'per invoice', FLAT: 'flat', }; @@ -66,6 +67,7 @@ const TRIGGER_ROUTE_LABELS: Partial> = { DEMURRAGE: 'Demurrage / wagon detention', PIL_EXTRA_FEE: 'PIL shipping line extra fee', CUSTOMS_CLEARANCE: 'Customs clearance service', + FUEL: 'Fuel surcharge', }; @Injectable() @@ -101,6 +103,15 @@ export class ContractRateScheduleBuilder { continue; } + // Fuel is sold per lane + commodity — only lanes matching the contract's + // direction belong on its schedule, labeled with their leg. + if (rate.trigger === 'FUEL') { + if (this.fuelDirectionMatches(rate, direction)) { + surcharges.push(this.fuelRow(rate)); + } + continue; + } + // Everything left is a trigger-based charge (surcharge / demurrage / customs). surcharges.push(this.surchargeRow(rate)); } @@ -176,6 +187,35 @@ export class ContractRateScheduleBuilder { }; } + private fuelDirectionMatches(rate: Rate, direction: ContractDirection): boolean { + const want = + direction === 'IMP' ? 'IMPORT' : direction === 'EXP' ? 'EXPORT' : 'DOMESTIC'; + return rate.tradeDirection === want; + } + + /** + * Fuel row — the lane matters, so it rides along in the charge label. + * Per-liter collapses to one flat total (base liters × rate value); the + * customer only ever sees the final price. + */ + private fuelRow(rate: Rate): RateScheduleRow { + const origin = rate.originYard?.label ?? rate.originYard?.code ?? '—'; + const destination = + rate.destinationYard?.label ?? rate.destinationYard?.code ?? '—'; + const perLiter = rate.rateUnit === 'PER_LITER'; + return { + route: `Fuel surcharge (${origin} → ${destination})`, + cargo: this.cargoLabel(rate), + currency: rate.currency, + amount: this.formatAmount( + perLiter + ? Number(rate.baseLiters ?? 0) * Number(rate.rateValue) + : rate.rateValue, + ), + unit: perLiter ? 'flat' : this.unitLabel(rate.rateUnit), + }; + } + private surchargeRow(rate: Rate): RateScheduleRow { return { route: TRIGGER_ROUTE_LABELS[rate.trigger] ?? this.titleCase(rate.trigger), diff --git a/apps/edr-freight-api/src/migrations/3420000000000-BulkTemplateTradeDirection.ts b/apps/edr-freight-api/src/migrations/3420000000000-BulkTemplateTradeDirection.ts new file mode 100644 index 000000000..e76591205 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3420000000000-BulkTemplateTradeDirection.ts @@ -0,0 +1,69 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Bulk contract templates gain trade direction, so the unique key becomes + * (cargo type, direction, customs option) instead of (cargo type, customs). + * + * Intercity is domestic and crosses no border, so it has no customs variant at + * all: with_customs stays NULL there, enforced by ck_bulk_intercity_no_customs. + * The unique index coalesces that NULL so two intercity templates for the same + * cargo type still collide (plain NULLs never do). + * + * No backfill: staff-created bulk templates are keyed by cargo_type_id and no + * such row exists yet — the seeded direction-keyed bulk rows were retired by + * 3320000000000 and carry a NULL cargo_type_id. The five system container + * templates are untouched: cargo_type_id IS NULL keeps them out of both the + * index and the check. + */ +export class BulkTemplateTradeDirection3420000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.contract_templates + ADD COLUMN IF NOT EXISTS trade_direction varchar(20) + `); + + await queryRunner.query( + `DROP INDEX IF EXISTS freight.uq_contract_templates_cargo_customs`, + ); + + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS uq_contract_templates_cargo_dir_customs + ON freight.contract_templates + (cargo_type_id, trade_direction, COALESCE(with_customs, false)) + WHERE deleted_at IS NULL AND cargo_type_id IS NOT NULL + `); + + await queryRunner.query(` + ALTER TABLE freight.contract_templates + DROP CONSTRAINT IF EXISTS ck_bulk_intercity_no_customs + `); + await queryRunner.query(` + ALTER TABLE freight.contract_templates + ADD CONSTRAINT ck_bulk_intercity_no_customs CHECK ( + cargo_type_id IS NULL + OR ( + trade_direction IN ('IMPORT', 'EXPORT', 'INTERCITY') + AND (trade_direction = 'INTERCITY') = (with_customs IS NULL) + ) + ) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.contract_templates + DROP CONSTRAINT IF EXISTS ck_bulk_intercity_no_customs + `); + await queryRunner.query( + `DROP INDEX IF EXISTS freight.uq_contract_templates_cargo_dir_customs`, + ); + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS uq_contract_templates_cargo_customs + ON freight.contract_templates (cargo_type_id, with_customs) + WHERE deleted_at IS NULL AND cargo_type_id IS NOT NULL + `); + await queryRunner.query(` + ALTER TABLE freight.contract_templates DROP COLUMN IF EXISTS trade_direction + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/3430000000000-FuelSurcharge.ts b/apps/edr-freight-api/src/migrations/3430000000000-FuelSurcharge.ts new file mode 100644 index 000000000..dfd0d25cf --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3430000000000-FuelSurcharge.ts @@ -0,0 +1,62 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Fuel surcharge, sold per lane + commodity: + * + * - cargo_types.has_fuel marks the commodities that incur it (same shape as + * has_lashing — the booking's cargo type flag is what fires the charge). + * - rates.base_liters carries the liters a PER_LITER fuel rate bills + * (price = base_liters × rate_value, once per booking). NULL on every other + * rate shape, including PER_WAGON fuel rates (wagons × rate_value). + * - CK_rates_yard_scope gains FUEL in its yard-carrying branch: fuel is priced + * per origin → destination leg like customs clearance and container return. + */ +export class FuelSurcharge3430000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.cargo_types + ADD COLUMN IF NOT EXISTS has_fuel boolean NOT NULL DEFAULT false + `); + + await queryRunner.query(` + ALTER TABLE freight.rates + ADD COLUMN IF NOT EXISTS base_liters numeric(14,4) + `); + + 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', 'INTERCITY')) + OR trigger IN ('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 + ) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + 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', 'INTERCITY')) + OR trigger IN ('CUSTOMS_CLEARANCE', 'WITH_RETURN') + 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 + ) + `); + await queryRunner.query(`ALTER TABLE freight.rates DROP COLUMN IF EXISTS base_liters`); + await queryRunner.query( + `ALTER TABLE freight.cargo_types DROP COLUMN IF EXISTS has_fuel`, + ); + } +} diff --git a/apps/edr-freight-api/src/modules/contract-templates/bulk-template-direction.spec.ts b/apps/edr-freight-api/src/modules/contract-templates/bulk-template-direction.spec.ts new file mode 100644 index 000000000..b822387da --- /dev/null +++ b/apps/edr-freight-api/src/modules/contract-templates/bulk-template-direction.spec.ts @@ -0,0 +1,134 @@ +import { BadRequestException } from '@nestjs/common'; + +import { ContractTemplatesRepository } from './contract-templates.repository'; +import { ContractTemplatesService } from './contract-templates.service'; +import { + bulkTemplateCode, + bulkTemplateDirectionFor, +} from './entities/contract-template.entity'; + +describe('bulkTemplateDirectionFor', () => { + it('maps the contract-side DOMESTIC onto the template-side INTERCITY', () => { + expect(bulkTemplateDirectionFor('DOMESTIC')).toBe('INTERCITY'); + expect(bulkTemplateDirectionFor('INTERCITY')).toBe('INTERCITY'); + expect(bulkTemplateDirectionFor(null)).toBe('INTERCITY'); + expect(bulkTemplateDirectionFor(undefined)).toBe('INTERCITY'); + expect(bulkTemplateDirectionFor('IMPORT')).toBe('IMPORT'); + expect(bulkTemplateDirectionFor('EXPORT')).toBe('EXPORT'); + }); +}); + +describe('bulkTemplateCode', () => { + it('gives each direction/customs combination its own code', () => { + expect(bulkTemplateCode('STEEL', 'IMPORT', true)).toBe('BULK_IMPORT_STEEL_CUSTOMS'); + expect(bulkTemplateCode('STEEL', 'IMPORT', false)).toBe( + 'BULK_IMPORT_STEEL_NO_CUSTOMS', + ); + expect(bulkTemplateCode('STEEL', 'EXPORT', true)).toBe('BULK_EXPORT_STEEL_CUSTOMS'); + expect(bulkTemplateCode('STEEL', 'EXPORT', false)).toBe( + 'BULK_EXPORT_STEEL_NO_CUSTOMS', + ); + }); + + it('leaves intercity unsuffixed — it crosses no border', () => { + expect(bulkTemplateCode('STEEL', 'INTERCITY', null)).toBe('BULK_INTERCITY_STEEL'); + }); + + it('produces 5 distinct codes per cargo type', () => { + const codes = [ + bulkTemplateCode('STEEL', 'IMPORT', true), + bulkTemplateCode('STEEL', 'IMPORT', false), + bulkTemplateCode('STEEL', 'EXPORT', true), + bulkTemplateCode('STEEL', 'EXPORT', false), + bulkTemplateCode('STEEL', 'INTERCITY', null), + ]; + expect(new Set(codes).size).toBe(5); + }); +}); + +describe('ContractTemplatesService bulk create/resolve', () => { + function build() { + const repository = { + findCargoType: jest.fn(() => + Promise.resolve({ + id: 'cargo-1', + code: 'STEEL', + cargoTypeName: 'Steel', + hasContractTemplate: true, + }), + ), + findByCargoCombo: jest.fn(() => Promise.resolve(null)), + findActiveBulkTemplate: jest.fn(() => Promise.resolve(null)), + findByCode: jest.fn(() => Promise.resolve(null)), + saveTemplate: jest.fn((template) => Promise.resolve(template)), + } as unknown as ContractTemplatesRepository; + return { + repository, + service: new ContractTemplatesService(repository, {} as never), + }; + } + + it('stores direction and customs on an import template', async () => { + const { service } = build(); + const created = await service.create({ + cargoTypeId: 'cargo-1', + tradeDirection: 'EXPORT', + withCustoms: true, + }); + expect(created.code).toBe('BULK_EXPORT_STEEL_CUSTOMS'); + expect(created.tradeDirection).toBe('EXPORT'); + expect(created.withCustoms).toBe(true); + expect(created.documentTitle).toBe( + 'Steel Transportation and Customs Clearance Services', + ); + }); + + it('stores a null customs flag for intercity', async () => { + const { service } = build(); + const created = await service.create({ + cargoTypeId: 'cargo-1', + tradeDirection: 'INTERCITY', + }); + expect(created.code).toBe('BULK_INTERCITY_STEEL'); + expect(created.withCustoms).toBeNull(); + expect(created.documentTitle).toBe('Steel Transportation Services'); + }); + + it('rejects a customs flag on intercity', async () => { + const { service } = build(); + await expect( + service.create({ + cargoTypeId: 'cargo-1', + tradeDirection: 'INTERCITY', + withCustoms: false, + }), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('requires a customs flag on import and export', async () => { + const { service } = build(); + await expect( + service.create({ cargoTypeId: 'cargo-1', tradeDirection: 'IMPORT' }), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('resolves a domestic bulk contract to the intercity template, ignoring its customs flag', async () => { + const { repository, service } = build(); + await service.findActiveForContract('DOMESTIC', 'BULK', true, 'cargo-1'); + expect(repository.findActiveBulkTemplate).toHaveBeenCalledWith( + 'cargo-1', + 'INTERCITY', + null, + ); + }); + + it('resolves an import bulk contract on direction and customs', async () => { + const { repository, service } = build(); + await service.findActiveForContract('IMPORT', 'BULK', false, 'cargo-1'); + expect(repository.findActiveBulkTemplate).toHaveBeenCalledWith( + 'cargo-1', + 'IMPORT', + false, + ); + }); +}); diff --git a/apps/edr-freight-api/src/modules/contract-templates/contract-templates.controller.ts b/apps/edr-freight-api/src/modules/contract-templates/contract-templates.controller.ts index 7053d224f..cce30c187 100644 --- a/apps/edr-freight-api/src/modules/contract-templates/contract-templates.controller.ts +++ b/apps/edr-freight-api/src/modules/contract-templates/contract-templates.controller.ts @@ -61,7 +61,7 @@ export class ContractTemplatesController { ]) @ApiOperation({ summary: - "Create a bulk contract template for a (cargo type, customs option) pair", + "Create a bulk contract template for a (cargo type, trade direction, customs option) combination", }) create(@Body() dto: CreateContractTemplateDto) { return this.service.create(dto); diff --git a/apps/edr-freight-api/src/modules/contract-templates/contract-templates.repository.ts b/apps/edr-freight-api/src/modules/contract-templates/contract-templates.repository.ts index 9d50b1677..1df3b1302 100644 --- a/apps/edr-freight-api/src/modules/contract-templates/contract-templates.repository.ts +++ b/apps/edr-freight-api/src/modules/contract-templates/contract-templates.repository.ts @@ -1,10 +1,13 @@ import { BaseRepository } from "@edr/api-common"; import { Injectable } from "@nestjs/common"; import { InjectRepository } from "@nestjs/typeorm"; -import { Repository } from "typeorm"; +import { IsNull, Repository } from "typeorm"; import { CargoType } from "../rule-engine/entities/cargo-type.entity"; -import { ContractTemplate } from "./entities/contract-template.entity"; +import { + BulkTemplateDirection, + ContractTemplate, +} from "./entities/contract-template.entity"; @Injectable() export class ContractTemplatesRepository extends BaseRepository { @@ -28,24 +31,35 @@ export class ContractTemplatesRepository extends BaseRepository { - return this.repository.findOne({ where: { cargoTypeId, withCustoms } }); + return this.repository.findOne({ + where: { cargoTypeId, tradeDirection, withCustoms: withCustoms ?? IsNull() }, + }); } /** * The active bulk template covering this cargo type: written against the * cargo type itself or against its parent group (the two are mutually - * exclusive, so at most one row matches). + * exclusive, so at most one row matches). Intercity templates carry no + * customs variant, so they are matched on a null flag. */ findActiveBulkTemplate( cargoTypeId: string, - withCustoms: boolean, + tradeDirection: BulkTemplateDirection, + withCustoms: boolean | null, ): Promise { return this.repository .createQueryBuilder("t") .where("t.is_active = true") - .andWhere("t.with_customs = :withCustoms", { withCustoms }) + .andWhere("t.trade_direction = :tradeDirection", { tradeDirection }) + .andWhere( + withCustoms === null + ? "t.with_customs IS NULL" + : "t.with_customs = :withCustoms", + withCustoms === null ? {} : { withCustoms }, + ) .andWhere( `(t.cargo_type_id = :cargoTypeId OR t.cargo_type_id = ( SELECT c.parent_group_id FROM freight.cargo_types c diff --git a/apps/edr-freight-api/src/modules/contract-templates/contract-templates.service.ts b/apps/edr-freight-api/src/modules/contract-templates/contract-templates.service.ts index da9c849f7..3b2e43cff 100644 --- a/apps/edr-freight-api/src/modules/contract-templates/contract-templates.service.ts +++ b/apps/edr-freight-api/src/modules/contract-templates/contract-templates.service.ts @@ -23,6 +23,9 @@ import { UpdateContractTemplateDto, } from "./dto/contract-template.dto"; import { + BulkTemplateDirection, + bulkTemplateCode, + bulkTemplateDirectionFor, CONTRACT_TEMPLATE_CODES, ContractTemplate, ContractTemplateArticle, @@ -77,10 +80,13 @@ export class ContractTemplatesService { } /** - * Staff-created bulk template for one (cargo type, customs option) pair. - * The cargo type must have hasContractTemplate enabled and the combination - * must not already exist — the same commodity + customs pairing is edited, - * never duplicated. + * Staff-created bulk template for one (cargo type, direction, customs + * option) triple. The cargo type must have hasContractTemplate enabled and + * the combination must not already exist — the same commodity + direction + + * customs pairing is edited, never duplicated. + * + * Intercity is domestic and crosses no border, so it carries no customs + * variant: the flag must be omitted and is stored as null. */ async create(dto: CreateContractTemplateDto): Promise { const cargoType = await this.repository.findCargoType(dto.cargoTypeId); @@ -92,31 +98,46 @@ export class ContractTemplatesService { `"${cargoType.cargoTypeName}" does not allow contract templates — enable "has contract template" on the cargo type first`, ); } - const variant = dto.withCustoms ? "with" : "without"; + + const direction = dto.tradeDirection; + const intercity = direction === "INTERCITY"; + if (intercity && dto.withCustoms !== undefined) { + throw new BadRequestException( + "Intercity contracts are domestic and cross no border — they have no customs clearing variant", + ); + } + if (!intercity && dto.withCustoms === undefined) { + throw new BadRequestException( + `A ${direction.toLowerCase()} template must state whether customs clearing is included`, + ); + } + const withCustoms = intercity ? null : Boolean(dto.withCustoms); + + const label = this.comboLabel(cargoType.cargoTypeName, direction, withCustoms); const existing = await this.repository.findByCargoCombo( dto.cargoTypeId, - dto.withCustoms, + direction, + withCustoms, ); if (existing) { throw new ConflictException( - `A "${cargoType.cargoTypeName}" template ${variant} customs clearing already exists — edit that template instead`, + `${label} already exists — edit that template instead`, ); } const template = new ContractTemplate(); - template.code = `BULK_${cargoType.code}_${dto.withCustoms ? "CUSTOMS" : "NO_CUSTOMS"}`.toUpperCase(); - template.name = - dto.name ?? - `${cargoType.cargoTypeName} Bulk Contract (${variant} customs clearing)`; + template.code = bulkTemplateCode(cargoType.code, direction, withCustoms); + template.name = dto.name ?? label; template.description = dto.description ?? null; - template.documentTitle = dto.withCustoms + template.documentTitle = withCustoms ? `${cargoType.cargoTypeName} Transportation and Customs Clearance Services` : `${cargoType.cargoTypeName} Transportation Services`; template.whereasClauses = []; template.articles = []; template.isActive = true; template.cargoTypeId = cargoType.id; - template.withCustoms = dto.withCustoms; + template.tradeDirection = direction; + template.withCustoms = withCustoms; template.isSystem = false; try { return await this.repository.saveTemplate(template); @@ -124,13 +145,29 @@ export class ContractTemplatesService { // Partial unique index backstop for concurrent creates of the same combo. if ((error as { code?: string })?.code === "23505") { throw new ConflictException( - `A "${cargoType.cargoTypeName}" template ${variant} customs clearing already exists — edit that template instead`, + `${label} already exists — edit that template instead`, ); } throw error; } } + /** Human label for one bulk combination, used for names and conflict errors. */ + private comboLabel( + cargoTypeName: string, + direction: BulkTemplateDirection, + withCustoms: boolean | null, + ): string { + const dir = direction.charAt(0) + direction.slice(1).toLowerCase(); + const customs = + withCustoms === null + ? "" + : withCustoms + ? ", with customs clearing" + : ", without customs clearing"; + return `${cargoTypeName} Bulk Contract (${dir}${customs})`; + } + /** Bulk templates only — the five seeded container templates are permanent. */ async remove(code: string): Promise { const template = await this.getByCode(code); @@ -146,9 +183,10 @@ export class ContractTemplatesService { * The active template used when generating a contract document. Container * contracts resolve through the fixed direction/customs codes; bulk contracts * resolve through the staff-created template for the contract's cargo type - * (or its parent group) and customs option. Null when nothing matches or the - * match is deactivated (the renderer then falls back to the built-in generic - * layout). + * (or its parent group), trade direction and customs option. A domestic + * contract resolves to the intercity template regardless of its customs flag. + * Null when nothing matches or the match is deactivated (the renderer then + * falls back to the built-in generic layout). */ async findActiveForContract( tradeDirection?: string | null, @@ -159,9 +197,11 @@ export class ContractTemplatesService { const isBulk = (freightType ?? "").toUpperCase().includes("BULK"); if (isBulk) { if (!cargoTypeId) return null; + const direction = bulkTemplateDirectionFor(tradeDirection); return this.repository.findActiveBulkTemplate( cargoTypeId, - Boolean(customsClearingEnabled), + direction, + direction === "INTERCITY" ? null : Boolean(customsClearingEnabled), ); } const code = contractTemplateCodeFor( @@ -274,18 +314,31 @@ export class ContractTemplatesService { /** * Registry key the mock preview renders against. Staff-created bulk - * templates aren't in the fixed code map — they preview against the - * representative bulk import pack matching their customs option. + * templates aren't in the fixed code map — they preview against the bulk + * pack matching their own direction and customs option. */ private previewKeyFor(template: ContractTemplate): string { if (template.cargoTypeId) { - return template.withCustoms - ? "IMP_BULK_USD_FORWARDING" - : "IMP_BULK_USD_TRANSPORT_ONLY"; + const direction = bulkTemplateDirectionFor(template.tradeDirection); + const dir = + direction === "IMPORT" ? "IMP" : direction === "EXPORT" ? "EXP" : "DOM"; + const scope = template.withCustoms ? "FORWARDING" : "TRANSPORT_ONLY"; + return `${dir}_BULK_USD_${scope}`; } return PREVIEW_TEMPLATE_KEYS[template.code as ContractTemplateCode]; } + /** + * Preview direction: bulk templates carry it on the row, the fixed container + * codes carry it as the code prefix. + */ + private previewDirectionFor(template: ContractTemplate): BulkTemplateDirection { + if (template.cargoTypeId) { + return bulkTemplateDirectionFor(template.tradeDirection); + } + return bulkTemplateDirectionFor(template.code.split("_")[0]); + } + private buildMockView( template: ContractTemplate, dynamicTemplate: ContractDynamicTemplateView, @@ -294,11 +347,12 @@ export class ContractTemplatesService { const previewKey = this.previewKeyFor(template); const meta = getTemplateMeta(previewKey); const isBulk = Boolean(template.cargoTypeId) || code.includes("BULK"); + const direction = this.previewDirectionFor(template); const now = new Date(); // Representative rate schedule so the admin preview shows the live-rate // table shape. Real contracts populate this from freight.rates (LIVE). - const rateSchedule = this.mockRateSchedule(code, isBulk); + const rateSchedule = this.mockRateSchedule(direction, isBulk); return { bookingId: "00000000-0000-0000-0000-000000000000", @@ -336,11 +390,7 @@ export class ContractTemplatesService { schedule: { originLabel: isBulk ? "Nagad Railway Station" : "SGTD Freight Station", destinationLabel: "Galaan Multipurpose Port (GMP)", - tradeDirection: code.startsWith("IMPORT") - ? "IMPORT" - : code.startsWith("EXPORT") - ? "EXPORT" - : "DOMESTIC", + tradeDirection: direction === "INTERCITY" ? "DOMESTIC" : direction, freightType: isBulk ? "BULK" : "CONTAINER", serviceType: "Rail transport and customs clearance", scheduledDate: "—", @@ -379,16 +429,14 @@ export class ContractTemplatesService { } /** Static, representative rate schedule for the admin preview only. */ - private mockRateSchedule(code: string, isBulk: boolean): RateSchedule { - const dir = code.startsWith("IMPORT") - ? "import" - : code.startsWith("EXPORT") - ? "export" - : "domestic"; + private mockRateSchedule( + direction: BulkTemplateDirection, + isBulk: boolean, + ): RateSchedule { const lane = - dir === "export" + direction === "EXPORT" ? "Galaan Multipurpose Port → SGTD" - : dir === "domestic" + : direction === "INTERCITY" ? "Mojo Dry Port → Dire Dawa" : "Negad → Mojo Dry Port"; diff --git a/apps/edr-freight-api/src/modules/contract-templates/dto/contract-template.dto.ts b/apps/edr-freight-api/src/modules/contract-templates/dto/contract-template.dto.ts index c8911d8e5..9f7579dbd 100644 --- a/apps/edr-freight-api/src/modules/contract-templates/dto/contract-template.dto.ts +++ b/apps/edr-freight-api/src/modules/contract-templates/dto/contract-template.dto.ts @@ -3,6 +3,7 @@ import { Type } from "class-transformer"; import { IsArray, IsBoolean, + IsIn, IsInt, IsOptional, IsString, @@ -13,6 +14,11 @@ import { ValidateNested, } from "class-validator"; +import { + BULK_TEMPLATE_DIRECTIONS, + BulkTemplateDirection, +} from "../entities/contract-template.entity"; + export class CreateContractTemplateDto { @ApiProperty({ description: @@ -23,10 +29,19 @@ export class CreateContractTemplateDto { cargoTypeId!: string; @ApiProperty({ - description: "Whether this is the with-customs-clearing variant", + description: "Trade direction this template is written for", + enum: BULK_TEMPLATE_DIRECTIONS, }) + @IsIn(BULK_TEMPLATE_DIRECTIONS as unknown as string[]) + tradeDirection!: BulkTemplateDirection; + + @ApiPropertyOptional({ + description: + "Whether this is the with-customs-clearing variant. Required for IMPORT/EXPORT, rejected for INTERCITY (domestic movements cross no border)", + }) + @IsOptional() @IsBoolean() - withCustoms!: boolean; + withCustoms?: boolean; @ApiPropertyOptional({ description: "Display name (derived from the cargo type when omitted)" }) @IsOptional() diff --git a/apps/edr-freight-api/src/modules/contract-templates/entities/contract-template.entity.ts b/apps/edr-freight-api/src/modules/contract-templates/entities/contract-template.entity.ts index af0721572..e070aa1e4 100644 --- a/apps/edr-freight-api/src/modules/contract-templates/entities/contract-template.entity.ts +++ b/apps/edr-freight-api/src/modules/contract-templates/entities/contract-template.entity.ts @@ -9,10 +9,10 @@ import { CargoType } from "../../rule-engine/entities/cargo-type.entity"; * template). These are system rows: always present, never deletable. * * Bulk templates are NOT seeded — staff create them per bulk cargo type - * (`cargoTypeId`) and customs option (`withCustoms`), one template per - * combination. Their codes are generated as BULK__(NO_)CUSTOMS. - * The retired direction-keyed bulk codes remain listed so old frozen document - * snapshots still label correctly. + * (`cargoTypeId`), trade direction (`tradeDirection`) and customs option + * (`withCustoms`), one template per combination. Their codes are generated by + * `bulkTemplateCode` below. The retired direction-keyed bulk codes remain + * listed so old frozen document snapshots still label correctly. * * Contracts store DOMESTIC for intercity movements; the template layer labels * those INTERCITY to match the commercial vocabulary used on the printed @@ -82,9 +82,41 @@ export function contractTemplateCodeFor( return `${direction}_${freight}_${customs}` as ContractTemplateCode; } +/** The three directions a bulk template can be written for. */ +export const BULK_TEMPLATE_DIRECTIONS = ["IMPORT", "EXPORT", "INTERCITY"] as const; + +export type BulkTemplateDirection = (typeof BULK_TEMPLATE_DIRECTIONS)[number]; + +/** + * Contracts store DOMESTIC for intercity movements; templates use INTERCITY. + * Anything that is not an explicit IMPORT/EXPORT is domestic, matching + * `contractTemplateCodeFor`. + */ +export function bulkTemplateDirectionFor( + tradeDirection?: string | null, +): BulkTemplateDirection { + const value = (tradeDirection ?? "").toUpperCase(); + return value === "IMPORT" || value === "EXPORT" ? value : "INTERCITY"; +} + +/** + * Generated code for a staff-created bulk template. Intercity gets no customs + * suffix — it crosses no border, so the variant does not exist. + */ +export function bulkTemplateCode( + cargoCode: string, + direction: BulkTemplateDirection, + withCustoms: boolean | null, +): string { + const suffix = + direction === "INTERCITY" ? "" : withCustoms ? "_CUSTOMS" : "_NO_CUSTOMS"; + return `BULK_${direction}_${cargoCode}${suffix}`.toUpperCase(); +} + @Entity({ schema: "freight", name: "contract_templates" }) // Uniqueness lives in partial DB indexes (live rows only): code, and -// (cargo_type_id, with_customs) for staff-created bulk templates. +// (cargo_type_id, trade_direction, coalesce(with_customs,false)) for +// staff-created bulk templates. @Index(["code"]) export class ContractTemplate extends BaseEntity { @Column({ name: "code", type: "varchar", length: 80 }) @@ -118,7 +150,15 @@ export class ContractTemplate extends BaseEntity { @JoinColumn({ name: "cargo_type_id" }) cargoType?: CargoType | null; - /** Bulk templates only: whether this is the with-customs-clearing variant. */ + /** Bulk templates only: IMPORT, EXPORT or INTERCITY. */ + @Column({ name: "trade_direction", type: "varchar", length: 20, nullable: true }) + tradeDirection?: BulkTemplateDirection | null; + + /** + * Bulk templates only: whether this is the with-customs-clearing variant. + * Always null for INTERCITY templates — domestic movements have no customs + * leg, so neither variant applies. + */ @Column({ name: "with_customs", type: "boolean", nullable: true }) withCustoms?: boolean | null; diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts index d5989f2ac..3d362d53f 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts @@ -157,13 +157,9 @@ export class ContractBookingService { actorPermissions != null && hasFreightPermission(actorPermissions, FREIGHT_PERMS.contracts.createBooking); + await this.assertNotExpired(contract); const createdByRole = await this.assertGate(contract, isGlActor); - // Validity window must still be open. - if (contract.contractValidUntil && contract.contractValidUntil.getTime() < Date.now()) { - throw new BadRequestException('Contract validity has expired — no new bookings.'); - } - // ONE_TIME: a single shipment at a time. The slot frees only if the prior // booking reached a terminal state (e.g. payment expired without shipping), // letting the customer re-book within contract validity (doc §10.4). @@ -441,12 +437,9 @@ export class ContractBookingService { // The customer initiates his own shipment instance on ONE_TIME contracts // (customs or self-clearance); GL may also initiate on a customs contract. // GENERAL customs instances come from a shipment request, not from here. + await this.assertNotExpired(contract); const createdByRole = await this.assertGate(contract, isGlActor, true); - if (contract.contractValidUntil && contract.contractValidUntil.getTime() < Date.now()) { - throw new BadRequestException('Contract validity has expired — no new bookings.'); - } - // ONE_TIME carries a single shipment at a time; a bare instance occupies the // slot from the moment it is initiated (it is not a terminal status). The // split chain is the one exception — a paid partial frees the slot and @@ -545,9 +538,7 @@ export class ContractBookingService { 'Shipment-request initiation applies only to general customs contracts.', ); } - if (contract.contractValidUntil && contract.contractValidUntil.getTime() < Date.now()) { - throw new BadRequestException('Contract validity has expired — no new bookings.'); - } + await this.assertNotExpired(contract); const route = await this.resolveRoute(contract, opts.contractRouteId); @@ -681,9 +672,11 @@ export class ContractBookingService { if (!dto.scheduledDate) { throw new BadRequestException('A binding shipment day is required'); } - if (contract.contractValidUntil && contract.contractValidUntil.getTime() < Date.now()) { - throw new BadRequestException('Contract validity has expired — no new bookings.'); - } + // No expiry gate here on purpose: this booking was already initiated + // before the contract lapsed (createUnderContract/initiateUnderContract + // already checked expiry at start). Finishing an in-flight booking must + // proceed even if the contract expires meanwhile — only starting a NEW + // booking is blocked (see assertNotExpired). // Completion is booking time: the route's booking window must be open — // the same config-driven gate a direct one-time booking passes at create. @@ -1019,6 +1012,22 @@ export class ContractBookingService { * Returns the role to stamp on the booking, or throws if the caller is not * allowed to create one for this contract's execution path. */ + /** + * Blocks starting a NEW booking (create/initiate) once the contract has + * lapsed, and lazily flips the stored status to EXPIRED so it doesn't wait + * for the nightly sweep. Only for the "start something new" entry points — + * a booking already underway (completeUnderContract) must be allowed to + * finish even if the contract expires mid-flight. + */ + private async assertNotExpired(contract: Contract): Promise { + if (!isEffectivelyExpired(contract)) return; + if (contract.status !== 'EXPIRED') { + const flipped = await this.contractsRepository.expireIfLapsed(contract.id); + if (flipped) contract.status = 'EXPIRED'; + } + throw new BadRequestException('Contract validity has expired — no new bookings.'); + } + private async assertGate( contract: Contract, isGlActor: boolean, diff --git a/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts index 924c1a72d..af0a149d7 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts @@ -11,7 +11,14 @@ import { Contract } from './entities/contract.entity'; export interface ContractUnitRateLineItem { code: string; label: string; - unit: 'per_container' | 'per_wagon' | 'per_ton' | 'per_item' | 'per_km' | 'flat'; + unit: + | 'per_container' + | 'per_wagon' + | 'per_ton' + | 'per_item' + | 'per_km' + | 'per_liter' + | 'flat'; unitPrice: number; containerSize?: string | null; conditionalOn?: string | null; @@ -44,6 +51,8 @@ function toContractUnit(rateUnit: string): ContractUnitRateLineItem['unit'] { return 'per_wagon'; case 'PER_CONTAINER': return 'per_container'; + case 'PER_LITER': + return 'per_liter'; default: return 'flat'; } @@ -276,6 +285,42 @@ export class ContractPricingService { } } + // Fuel surcharge — shown when the contract's commodity incurs fuel + // (cargoType.hasFuel), sold per lane + commodity. Billed at booking on the + // frozen/live rate (per wagon × wagons, or per liter × base liters, once); + // this line freezes the agreed unit price. + { + const scope = (contract.cargoScope ?? []).find((c) => c.cargoTypeId); + if (scope?.cargoType?.hasFuel && route) { + const fuel = liveRates.find( + (r) => + r.trigger === 'FUEL' && + r.currency === 'USD' && + r.tradeDirection === contract.tradeDirection && + r.originYardId === route.originYardId && + r.destinationYardId === route.destinationYardId && + r.cargoTypeId === scope.cargoTypeId, + ); + if (fuel && Number(fuel.rateValue) > 0) { + // Per-liter collapses to one flat total (base liters × rate value) — + // the customer only sees the final price, and booking pricing bills + // the same flat figure once (see RuleEngineService.fuelCharges). + const perLiter = fuel.rateUnit === 'PER_LITER'; + const total = perLiter + ? Number(fuel.baseLiters ?? 0) * Number(fuel.rateValue) + : Number(fuel.rateValue); + lineItems.push({ + code: 'FUEL_SURCHARGE', + label: `Fuel surcharge (${scope.cargoType.cargoTypeName})`, + unit: perLiter ? 'flat' : toContractUnit(fuel.rateUnit), + unitPrice: convert(total), + cargoTypeCode: scope.cargoType.code ?? null, + conditionalOn: 'has_fuel', + }); + } + } + } + // Empty-container return service — container contracts only, toggled on the // contract like hazard/reefer. Billed at booking per WITH_RETURN container. if ( diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts b/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts index 2ab7e3d7e..3e76581a8 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts @@ -140,6 +140,27 @@ export class ContractsRepository extends BaseRepository { return result.affected ?? 0; } + /** + * Same-row version of expireLapsedContracts, for lazy flips on read/booking + * paths — flips this one contract to EXPIRED if it's lapsed and not already + * terminal. No-op (returns false) if the contract isn't actually lapsed, so + * callers can call this unconditionally without a pre-check. + */ + async expireIfLapsed(id: string): Promise { + const result = await this.repository + .createQueryBuilder() + .update(Contract) + .set({ status: 'EXPIRED' }) + .where('id = :id', { id }) + .andWhere('deleted_at IS NULL') + .andWhere('status NOT IN (:...terminal)', { terminal: TERMINAL_CONTRACT_STATUSES }) + .andWhere('contract_valid_until IS NOT NULL AND contract_valid_until < :now', { + now: new Date(), + }) + .execute(); + return (result.affected ?? 0) > 0; + } + /** * Live contracts whose validity ends between `days` and `days + 1` days from * now — the slice the daily expiry-reminder cron warns about. The window is diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.service.ts b/apps/edr-freight-api/src/modules/contracts/contracts.service.ts index 607a6f525..cacfb5732 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.service.ts @@ -811,6 +811,15 @@ export class ContractsService { throw new NotFoundException(`Contract ${id} not found`); } + // Lazy expiry flip: the nightly cron only sweeps once a day, so a + // contract can be past contract_valid_until for hours before it shows + // EXPIRED. Flip it here so the detail page never shows a stale status. + if (isEffectivelyExpired(contract) && contract.status !== 'EXPIRED') { + const flipped = await this.contractsRepository.expireIfLapsed(id); + if (flipped) { + contract.status = 'EXPIRED'; + } + } // Entry state for every contract flow (submit, approve, sign, suspend…) — // see the equivalent in BookingsService.findById. logCtx( diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts index d116a4b4a..67f5d2547 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts @@ -70,6 +70,15 @@ export class CreateCargoTypeDto { @IsBoolean() hasLashing?: boolean; + @ApiPropertyOptional({ + default: false, + description: + 'When true, bookings of this cargo type incur the lane-scoped FUEL surcharge.', + }) + @IsOptional() + @IsBoolean() + hasFuel?: boolean; + @ApiPropertyOptional({ default: false, description: 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 45a5838bd..eccb14017 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 @@ -7,7 +7,8 @@ import { RATE_UNITS, } from '../entities/rate.entity'; -const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'BOTH'] as const; +// DOMESTIC is accepted only for FUEL rates (an intercity fuel lane). +const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'BOTH', 'DOMESTIC'] as const; // ETB is accepted only for last-mile rates; the service forces USD elsewhere. const CURRENCIES = ['USD', 'ETB'] as const; export const INTERCITY_KINDS = ['CONTAINER', 'BULK'] as const; @@ -94,6 +95,17 @@ export class CreateRateDto { @IsIn([...RATE_UNITS]) rateUnit?: string; + @ApiPropertyOptional({ + description: + 'FUEL rates billed PER_LITER only: liters the surcharge covers — price = baseLiters × rateValue, once per booking. Required there, rejected elsewhere.', + minimum: 0, + }) + @IsOptional() + @IsNumber() + @Min(0) + @Transform(({ value }) => (value === null || value === undefined || value === '' ? undefined : Number(value))) + baseLiters?: number; + @ApiPropertyOptional({ description: 'Distance band start (km, inclusive). Container last-mile rates only (rateUnit = PER_KM).', diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/cargo-type.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/cargo-type.entity.ts index b7717684a..7084e233a 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/cargo-type.entity.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/cargo-type.entity.ts @@ -82,6 +82,14 @@ export class CargoType extends BaseEntity { @Column({ name: 'has_lashing', type: 'boolean', default: false }) hasLashing!: boolean; + /** + * Whether bookings of this cargo incur the fuel surcharge. Billed off the + * lane-scoped FUEL rate for the booking's direction + route + this cargo + * type (per liter or per wagon). + */ + @Column({ name: 'has_fuel', type: 'boolean', default: false }) + hasFuel!: boolean; + /** * Whether staff may write bulk contract templates against this cargo type. * Mutually exclusive between a parent group and its children: if the parent 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 index a5b5bfc30..536e35527 100644 --- 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 @@ -46,6 +46,8 @@ export function deriveRateType(input: { return 'PIL_EXTRA_FEE'; case 'CUSTOMS_CLEARANCE': return 'CUSTOMS_CLEARANCE'; + case 'FUEL': + return 'FUEL_SURCHARGE'; } } diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.ts index a3d336605..3d5b1401e 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.ts @@ -74,6 +74,9 @@ function unitsForShape(input: { case 'LASHING': // Bulk-only cargo securing — per ton or per wagon. return ['PER_TON', 'PER_WAGON']; + case 'FUEL': + // Per wagon (wagons × rate) or per liter (baseLiters × rate, once). + return ['PER_WAGON', 'PER_LITER']; case 'CONSOLIDATION': return ['PER_CONTAINER', 'FLAT']; case 'SHIPPING_LINE': 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 e7089f08c..925a3555a 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 @@ -24,6 +24,7 @@ export const RATE_TYPES = [ 'RETURN_SURCHARGE', 'PIL_EXTRA_FEE', 'CUSTOMS_CLEARANCE', + 'FUEL_SURCHARGE', ] as const; export type RateType = typeof RATE_TYPES[number]; @@ -41,6 +42,8 @@ export const RATE_UNITS = [ 'PER_KM', // Last-mile bulk: price = tons × km × rateValue. 'PER_TON_KM', + // Fuel surcharge only: price = baseLiters × rateValue, once per booking. + 'PER_LITER', 'PER_INVOICE', 'FLAT', ] as const; @@ -92,6 +95,9 @@ export const RATE_TRIGGERS = [ // Customs clearance service fee — billed up front via a clearance invoice, // never auto-applied to booking pricing (matchesTrigger returns false). 'CUSTOMS_CLEARANCE', + // Fuel surcharge — fires when the booking's cargo type has hasFuel = true, + // billed off the lane-scoped rate (direction + route + cargo type). + 'FUEL', ] as const; export type RateTrigger = typeof RATE_TRIGGERS[number]; @@ -163,6 +169,14 @@ export class Rate extends BaseEntity { * containerTypeId): the rate applies when minKm <= km < maxKm (maxKm NULL = * open-ended). NULL on every other rate shape. */ + /** + * FUEL rates billed PER_LITER only: the liters the surcharge covers — + * price = baseLiters × rateValue, once per booking. NULL on every other + * rate shape (a PER_WAGON fuel rate bills wagons × rateValue instead). + */ + @Column({ name: 'base_liters', type: 'numeric', precision: 14, scale: 4, nullable: true }) + baseLiters?: number | null; + @Column({ name: 'min_km', type: 'numeric', precision: 10, scale: 2, nullable: true }) minKm?: number | null; diff --git a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.spec.ts b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.spec.ts index a736d4b05..281f2d1b2 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.spec.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.spec.ts @@ -422,3 +422,108 @@ describe('RuleEngineService — lashing (bulk-only, per direction + commodity)', expect(lashingMods(result)).toHaveLength(0); }); }); + +describe('RuleEngineService — fuel surcharge (per lane + cargo type)', () => { + const fuelPerLiter: Rate = { + id: 'rate-fuel-liter', + rateType: 'FUEL_SURCHARGE', + trigger: 'FUEL', + rateValue: 2, + rateUnit: 'PER_LITER', + baseLiters: 100, + currency: 'USD', + status: 'LIVE', + containerTypeId: null, + cargoTypeId: 'cargo-steel', + tradeDirection: 'IMPORT', + originYardId: 'yard-nagad', + destinationYardId: 'yard-mojo', + } as Rate; + + const buildService = (rates: Rate[], hasFuel = true): RuleEngineService => + new RuleEngineService( + { + findById: jest + .fn() + .mockResolvedValue({ hasFuel, hasLashing: false, requiresDirectorApproval: false }), + } as never, + { findById: jest.fn().mockResolvedValue(null) } as never, + { findActiveByContainerTypeId: jest.fn().mockResolvedValue([]) } as never, + { findAllActive: jest.fn().mockResolvedValue([]) } as never, + { findLiveRates: jest.fn().mockResolvedValue(rates) } as never, + { findById: jest.fn().mockResolvedValue(null) } as never, + {} as never, + ); + + const fuelInput = ( + overrides: Partial = {}, + ): BookingEvaluationInput => ({ + serviceTypeId: 'svc-1', + paymentCurrency: 'USD', + tradeDirection: 'IMPORT', + isHazardous: false, + cargoTypeId: 'cargo-steel', + originYardId: 'yard-nagad', + destinationYardId: 'yard-mojo', + totalWagons: 0, + bulkTons: 100, + bulkWagons: 4, + containers: [], + ...overrides, + }); + + const fuelMods = (result: Awaited>) => + result.appliedModifiers.filter((m) => m.surchargeCode === 'FUEL_SURCHARGE'); + + it('PER_LITER collapses to one flat total (base liters × rate value), regardless of wagons', async () => { + const result = await buildService([fuelPerLiter]).evaluate(fuelInput()); + const mods = fuelMods(result); + expect(mods).toHaveLength(1); + // Flat: the customer sees only the total, and a frozen contract snapshot + // (also stored flat) multiplies it by quantity 1 — never by the liters. + expect(mods[0].triggerValue).toBe(1); + expect(mods[0].unitPriceUsd).toBe(200); + expect(mods[0].calculatedAmount).toBe(200); + expect(mods[0].billingUnit).toBe('FLAT'); + }); + + it('PER_WAGON bills the wagons the cargo occupies', async () => { + const result = await buildService([ + { ...fuelPerLiter, rateUnit: 'PER_WAGON', baseLiters: null, rateValue: 50 } as Rate, + ]).evaluate(fuelInput()); + const mods = fuelMods(result); + expect(mods[0].triggerValue).toBe(4); + expect(mods[0].calculatedAmount).toBe(200); + }); + + it('a rate for another lane, direction or commodity never bills', async () => { + for (const wrong of [ + { tradeDirection: 'EXPORT' }, + { originYardId: 'yard-other' }, + { destinationYardId: 'yard-other' }, + { cargoTypeId: 'cargo-wheat' }, + ]) { + const result = await buildService([{ ...fuelPerLiter, ...wrong } as Rate]).evaluate( + fuelInput(), + ); + expect(fuelMods(result)).toHaveLength(0); + } + }); + + it('a domestic booking bills the DOMESTIC fuel lane', async () => { + const result = await buildService([ + { ...fuelPerLiter, tradeDirection: 'DOMESTIC' } as Rate, + ]).evaluate(fuelInput({ tradeDirection: 'DOMESTIC' })); + expect(fuelMods(result)).toHaveLength(1); + }); + + it('no fuel charge when the cargo type does not have hasFuel', async () => { + const result = await buildService([fuelPerLiter], false).evaluate(fuelInput()); + expect(fuelMods(result)).toHaveLength(0); + }); + + it('no matching lane rate bills nothing (lenient, like lashing)', async () => { + const result = await buildService([]).evaluate(fuelInput()); + expect(fuelMods(result)).toHaveLength(0); + }); +}); 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 ac2e854e4..908d00ecc 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 @@ -175,6 +175,9 @@ export class RuleEngineService { // matchesTrigger can fire the LASHING rate. Falls back to an explicit // input flag when no cargo type is set (e.g. container bookings). let hasLashing = input.hasLashing === true; + // Fuel is likewise a cargo-type property (hasFuel), billed off the + // lane-scoped FUEL rate — see fuelCharges. + let hasFuel = false; if (input.cargoTypeId) { const cargoType = await this.cargoTypesRepo.findById(input.cargoTypeId); if (!cargoType) { @@ -186,6 +189,9 @@ export class RuleEngineService { if (cargoType.hasLashing) { hasLashing = true; } + if (cargoType.hasFuel) { + hasFuel = true; + } } } @@ -328,6 +334,9 @@ export class RuleEngineService { // Lashing is sold per cargo kind + container type — billed by the // kind-aware block below, never by this generic loop. if (rate.trigger === 'LASHING') continue; + // Fuel is sold per lane + cargo type — billed by the route-matched + // block below, never by this route-agnostic loop. + if (rate.trigger === 'FUEL') continue; const triggered = this.matchesTrigger(rate.trigger, { isHazardous: input.isHazardous, hasReefer, @@ -442,6 +451,10 @@ export class RuleEngineService { appliedModifiers.push(...this.lashingCharges(input, liveRates)); } + if (hasFuel) { + appliedModifiers.push(...this.fuelCharges(input, liveRates)); + } + return { priorityScore, appliedModifiers, @@ -632,6 +645,56 @@ export class RuleEngineService { return modifiers; } + /** + * Fuel surcharge — fires when the booking's cargo type has hasFuel = true, + * billed off the FUEL rate matching the booking's lane (trade direction + + * origin + destination) and cargo type. PER_LITER collapses to one FLAT + * amount (baseLiters × rateValue, once per booking) — the customer only ever + * sees the total, and the frozen contract snapshot stores that same flat + * figure so the snapshot-override math bills it exactly once. PER_WAGON + * bills the wagons the cargo occupies. No matching lane rate simply bills + * nothing — same leniency as lashing. + */ + private fuelCharges( + input: BookingEvaluationInput, + liveRates: Rate[], + ): AppliedCargoModifier[] { + const modifiers: AppliedCargoModifier[] = []; + const rate = liveRates.find( + (r) => + r.trigger === 'FUEL' && + r.currency === 'USD' && + r.tradeDirection === input.tradeDirection && + r.originYardId === input.originYardId && + r.destinationYardId === input.destinationYardId && + r.cargoTypeId === input.cargoTypeId, + ); + if (!rate) return modifiers; + + const rateValue = Number(rate.rateValue); + const wagons = Math.max( + 0, + Number(input.bulkWagons ?? 0) || Number(input.totalWagons ?? 0), + ); + const perLiter = rate.rateUnit === 'PER_LITER'; + const billedQty = perLiter ? 1 : wagons; + const unitPrice = perLiter + ? Number(rate.baseLiters ?? 0) * rateValue + : rateValue; + const amount = billedQty * unitPrice; + if (!(amount > 0)) return modifiers; + modifiers.push({ + rateId: rate.id, + surchargeCode: this.surchargeCode(rate), + triggerValue: billedQty, + calculatedAmount: amount, + currency: rate.currency, + unitPriceUsd: unitPrice, + billingUnit: perLiter ? 'FLAT' : rate.rateUnit, + }); + return modifiers; + } + /** * Messages for container lines whose total weight exceeds the hard capacity * ceiling (weight_limit_rules.max_capacity_tons). Non-empty ⇒ the booking diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/rate-change-requests.service.spec.ts b/apps/edr-freight-api/src/modules/rule-engine/services/rate-change-requests.service.spec.ts index be7c8984c..f8617cd65 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/rate-change-requests.service.spec.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/rate-change-requests.service.spec.ts @@ -118,6 +118,19 @@ describe('RateChangeRequestsService', () => { expect(request.payload).toEqual({ destinationYardId: 'yard-c' }); }); + it('carries baseLiters — a switch to PER_LITER keeps its billing base', async () => { + const { service } = build({ + rate: liveRate({ rateUnit: 'PER_WAGON', baseLiters: null }), + }); + + const request = await service.submit({ + rateId: 'rate-1', + update: { rateUnit: 'PER_LITER', baseLiters: 3 }, + }); + + expect(request.payload).toEqual({ rateUnit: 'PER_LITER', baseLiters: 3 }); + }); + it('rejects a no-op — 100 posted against a live 100.0000 is not a change', async () => { const { service } = build(); await expect( diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/rate-change-requests.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/rate-change-requests.service.ts index 8cc97f343..8913c9ef9 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/rate-change-requests.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/rate-change-requests.service.ts @@ -42,6 +42,10 @@ const DIFFABLE_FIELDS = [ // LIVE last-mile rate would diff to "nothing changed". 'minKm', 'maxKm', + // PER_LITER fuel surcharge billing base. Missing here, a switch to PER_LITER + // dropped the submitted liters and validation failed with "needs a base + // liters amount" even though the payload carried one. + 'baseLiters', ] as const; /** 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 971097377..232cd9299 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 @@ -121,15 +121,16 @@ export class RatesService { } /** - * Rates sold per direction + route. Base freight always; customs clearance - * and empty-container return are the surcharges that are too — their fee - * depends on the lane (and, for returns, the container type). + * Rates sold per direction + route. Base freight always; customs clearance, + * empty-container return and fuel are the surcharges that are too — their + * fee depends on the lane (and, for returns, the container type). */ private isRouteScoped(appliesTo: Rate['appliesTo'], trigger: Rate['trigger']): boolean { return ( this.isBaseFreight(appliesTo, trigger) || trigger === 'CUSTOMS_CLEARANCE' || - trigger === 'WITH_RETURN' + trigger === 'WITH_RETURN' || + trigger === 'FUEL' ); } @@ -160,7 +161,9 @@ export class RatesService { appliesTo: Rate['appliesTo'], tradeDirection: string | null, ): { origin: YardCountry; destination: YardCountry } { - if (appliesTo === 'INTERCITY') { + // DOMESTIC only reaches here on a FUEL rate's intercity lane — it stays + // inside Ethiopia exactly like intercity base freight. + if (appliesTo === 'INTERCITY' || tradeDirection === 'DOMESTIC') { return { origin: YardCountry.ETHIOPIA, destination: YardCountry.ETHIOPIA }; } return tradeDirection === 'EXPORT' @@ -291,6 +294,31 @@ export class RatesService { } return; } + if (trigger === 'FUEL') { + // Fuel is sold per lane + commodity: the direction says which countries + // the leg spans (DOMESTIC = intercity, inside Ethiopia) and the cargo + // type names the commodity — different commodities price differently. + if ( + tradeDirection !== 'IMPORT' && + tradeDirection !== 'EXPORT' && + tradeDirection !== 'DOMESTIC' + ) { + throw new BadRequestException( + 'A fuel rate must say whether it covers IMPORT, EXPORT or DOMESTIC (intercity).', + ); + } + if (containerTypeId) { + throw new BadRequestException( + 'A fuel rate cannot be scoped to a container type.', + ); + } + if (!cargoTypeId) { + throw new BadRequestException( + 'A fuel rate must name the cargo type it covers.', + ); + } + return; + } if (trigger === 'WITH_RETURN') { // Returning the empty box only exists on imports (the box goes back to // the port) — export return rates are rejected until the business sells @@ -502,15 +530,21 @@ export class RatesService { : (dto.containerTypeId ?? null); const cargoTypeId = (trigger === 'CUSTOMS_CLEARANCE' && cargoKind === 'BULK') || - trigger === 'LASHING' + trigger === 'LASHING' || + trigger === 'FUEL' ? (dto.cargoTypeId ?? null) : isSurcharge ? null : (dto.cargoTypeId ?? null); // Intercity never leaves Ethiopia, so it has no trade direction to store — - // its yard pair already says where it runs. + // its yard pair already says where it runs. (Fuel is the exception: its + // intercity lane is stored as DOMESTIC, since appliesTo = OTHER says + // nothing about the direction.) const tradeDirection = - trigger === 'CUSTOMS_CLEARANCE' || trigger === 'WITH_RETURN' || trigger === 'LASHING' + trigger === 'CUSTOMS_CLEARANCE' || + trigger === 'WITH_RETURN' || + trigger === 'LASHING' || + trigger === 'FUEL' ? (dto.tradeDirection ?? null) : isSurcharge || appliesTo === 'INTERCITY' ? null @@ -563,6 +597,8 @@ export class RatesService { await this.assertNoBandOverlap({ rateUnit, containerTypeId, minKm, maxKm }); } + const baseLiters = this.resolveBaseLiters(rateUnit, dto.baseLiters); + await this.assertNoDuplicatePattern({ rateType, ...(this.resolvesSingleRate(appliesTo, trigger) ? {} : { rateUnit }), @@ -588,6 +624,7 @@ export class RatesService { currency: appliesTo === 'LAST_MILE' ? (dto.currency ?? 'ETB') : 'USD', rateValue: dto.rateValue, rateUnit, + baseLiters, minKm, maxKm, status: 'DRAFT', @@ -595,6 +632,25 @@ export class RatesService { }); } + /** + * The liters a PER_LITER fuel rate bills (price = baseLiters × rateValue, + * once per booking). Required there; cleared on every other rate shape — + * a PER_WAGON fuel rate bills wagons × rateValue and carries none. + */ + private resolveBaseLiters( + rateUnit: Rate['rateUnit'], + baseLiters?: number | null, + ): number | null { + if (rateUnit !== 'PER_LITER') return null; + const liters = Number(baseLiters); + if (!(liters > 0)) { + throw new BadRequestException( + 'A per-liter fuel rate needs a base liters amount — the price is base liters × rate value.', + ); + } + return liters; + } + /** * Update a DRAFT rate in place. Nothing prices off a draft, so a direct edit * is safe. A LIVE rate cannot take this path — see `applyApprovedUpdate`. @@ -680,14 +736,18 @@ export class RatesService { const keepsCargoType = !isSurcharge || (trigger === 'CUSTOMS_CLEARANCE' && cargoKind === 'BULK') || - trigger === 'LASHING'; + trigger === 'LASHING' || + trigger === 'FUEL'; const cargoTypeId = !keepsCargoType ? null : dto.cargoTypeId !== undefined ? dto.cargoTypeId : existing.cargoTypeId; const tradeDirection = - trigger === 'CUSTOMS_CLEARANCE' || trigger === 'WITH_RETURN' || trigger === 'LASHING' + trigger === 'CUSTOMS_CLEARANCE' || + trigger === 'WITH_RETURN' || + trigger === 'LASHING' || + trigger === 'FUEL' ? dto.tradeDirection !== undefined ? dto.tradeDirection : existing.tradeDirection @@ -788,6 +848,11 @@ export class RatesService { ignoreId: id, }); + updates.baseLiters = this.resolveBaseLiters( + rateUnit, + dto.baseLiters !== undefined ? dto.baseLiters : existing.baseLiters, + ); + updates.currency = appliesTo === 'LAST_MILE' ? (dto.currency ?? existing.currency ?? 'ETB') diff --git a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts index a740dc932..cdc43c5b2 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts @@ -330,6 +330,8 @@ const DEFAULT_TRAIN_LIMITS: Required = { interface BookingWindowRow { schedule_id: string; reference: string | null; + /** Operational run number (e.g. 8001 import / 8002 export), typed by staff. */ + train_number: string | null; contract_id: string | null; contract_kind: string | null; direction: string | null; @@ -5872,6 +5874,7 @@ export class TrainSchedulingService { wagonSlots: schedule.trainSet?.wagons, storedWagonCount: schedule.trainSet?.wagonCount, scheduleBookings: schedule.scheduleBookings, + maxWagons: schedule.maxWagons, }); return { @@ -6847,6 +6850,7 @@ export class TrainSchedulingService { `SELECT DISTINCT ON (ts.id) ts.id AS schedule_id, ts.reference AS reference, + ts.train_number, cr.contract_id AS contract_id, c.contract_kind AS contract_kind, ts.direction, @@ -6903,6 +6907,7 @@ export class TrainSchedulingService { const rows: Array = await this.dataSource.query( `SELECT DISTINCT ts.id AS schedule_id, ts.reference AS reference, + ts.train_number, cr.contract_id AS contract_id, c.contract_kind AS contract_kind, ts.direction, @@ -6948,9 +6953,7 @@ export class TrainSchedulingService { */ async listAllBookingWindows() { const rows: Array< - Omit & { - train_number: string | null; - } + Omit > = await this.dataSource.query( `SELECT ts.id AS schedule_id, ts.reference AS reference, @@ -6980,14 +6983,13 @@ export class TrainSchedulingService { AND ts.scheduled_departure_date >= now() ORDER BY ts.scheduled_departure_date ASC NULLS LAST`, ); - return rows.map((r) => ({ - ...this.mapBookingWindowRow({ + return rows.map((r) => + this.mapBookingWindowRow({ ...r, contract_id: null, contract_kind: null, }), - trainNumber: r.train_number, - })); + ); } private mapBookingWindowRow(r: BookingWindowRow) { @@ -7005,6 +7007,7 @@ export class TrainSchedulingService { return { scheduleId: r.schedule_id, reference: r.reference ?? null, + trainNumber: r.train_number ?? null, contractId: r.contract_id, contractKind: r.contract_kind, direction: r.direction, @@ -9301,9 +9304,14 @@ export class TrainSchedulingService { } // Every schedule the target train is committed to, via its train sets. + // Locomotives come along: the merged train is pulled by the union of this + // schedule's locos and the target train's, so capacity checks need both. const targetSets = await this.dataSource .getRepository(TrainSet) - .find({ where: { trainId: targetTrainId } }); + .find({ + where: { trainId: targetTrainId }, + relations: { locomotives: { locomotive: true }, locomotive: true }, + }); const targetSetIds = targetSets.map((s) => s.id); const targetSchedules = targetSetIds.length ? await this.dataSource.getRepository(TrainSchedule).find({ @@ -9346,6 +9354,24 @@ export class TrainSchedulingService { .getRepository(Wagon) .find({ where: { trainId: targetTrainId }, order: { wagonNumber: 'ASC' } }); + // EVERY wagon physically on the source train moves with the merge — not + // just the ones coupled into this schedule's set. A wagon left behind + // would strand on the deactivated train. "Loose" = on the train but not + // backing a set slot; it joins the counts and the capacity math. + const sourceWagons = sourceTrainId + ? await this.dataSource + .getRepository(Wagon) + .find({ where: { trainId: sourceTrainId }, order: { wagonNumber: 'ASC' } }) + : []; + const coupledPhysicalIds = new Set( + (schedule.trainSet?.wagons ?? []) + .map((w) => w.physicalWagonId) + .filter(Boolean), + ); + const looseSourceWagons = sourceWagons.filter( + (w) => !coupledPhysicalIds.has(w.id), + ); + const movingBookings = absorbed ? await this.dataSource.getRepository(TrainScheduleBooking).find({ where: { trainScheduleId: absorbed.id }, @@ -9357,10 +9383,12 @@ export class TrainSchedulingService { schedule, sourceTrainId, targetTrain, + targetSets, absorbed, affectedOthers, untouched, incomingWagons, + looseSourceWagons, movingBookings, }; } @@ -9382,14 +9410,20 @@ export class TrainSchedulingService { ); } - // ── Capacity: the merged consist must fit this schedule's locomotives ──── + // ── Capacity: the merged consist must fit the merged train's locomotives ─ + // Existing side = coupled set slots PLUS loose wagons riding the source + // train without a slot — they all move, so they all count. const existingSlots = (schedule.trainSet?.wagons ?? []).map((w) => ({ lengthMeters: Number(w.lengthMeters) || 0, tareWeightTons: Number(w.wagonType?.tareWeightTons) || 0, cargoTons: 0, })); const wagonTypeIds = [ - ...new Set(incomingWagons.map((w) => w.wagonTypeId).filter(Boolean)), + ...new Set( + [...incomingWagons, ...plan.looseSourceWagons] + .map((w) => w.wagonTypeId) + .filter(Boolean), + ), ]; const wagonTypes = wagonTypeIds.length ? await this.dataSource @@ -9397,16 +9431,27 @@ export class TrainSchedulingService { .find({ where: { id: In(wagonTypeIds) } }) : []; const typeById = new Map(wagonTypes.map((t) => [t.id, t])); - const incomingSlots = incomingWagons.map((w) => { + const slotFromWagon = (w: Wagon) => { const t = typeById.get(w.wagonTypeId); return { lengthMeters: Number(t?.lengthMeters) || 0, tareWeightTons: Number(t?.tareWeightTons) || 0, cargoTons: 0, }; - }); + }; + const incomingSlots = incomingWagons.map(slotFromWagon); + const looseSlots = plan.looseSourceWagons.map(slotFromWagon); - const limits = trainSetLocomotiveLimits(schedule.trainSet); + // The merged train is pulled by the union of this schedule's locomotives + // and whatever already pulls the target train (its sets keep their locos). + // Pull weight adds up across the pool; length stays the tightest cap. + const locoPool = [ + ...this.locomotivesOfTrainSet(schedule.trainSet), + ...plan.targetSets.flatMap((set) => this.locomotivesOfTrainSet(set)), + ]; + const limits = combinedLocomotiveLimits([ + ...new Map(locoPool.map((l) => [l.id, l])).values(), + ]); if (limits) { const rules = await this.dataSource .getRepository(TrainSchedulingGlobalRules) @@ -9415,13 +9460,17 @@ export class TrainSchedulingService { maxTrainWeightTons: rules[0]?.maxTrainWeightTons ?? undefined, maxTrainLengthMeters: rules[0]?.maxTrainLengthMeters ?? undefined, }); - const merged = [...existingSlots, ...incomingSlots]; - // maxWagons is the schedule's own slot ceiling; fall back to the consist - // size when it is unset so the count axis never blocks spuriously. + const merged = [...existingSlots, ...looseSlots, ...incomingSlots]; + // Merge is a physical consist move, so only the physical axes gate it: + // can this schedule's locomotives pull the merged weight and length. + // `schedule.maxWagons` is the booking-window planning ceiling — using it + // as a slot cap here blocked every merge into a bigger train (e.g. a + // 3-wagon plan absorbing a 47-wagon train). The commit raises the + // ceiling to the merged size instead. const violations = consistViolations(merged, { maxWeightTons: caps.maxWeightTons, maxLengthMeters: caps.maxLengthMeters, - maxWagonSlots: schedule.maxWagons || merged.length, + maxWagonSlots: merged.length, }); blockers.push(...violations); } @@ -9481,7 +9530,10 @@ export class TrainSchedulingService { const plan = await this.planMerge(scheduleId, targetTrainId); const blockers = await this.mergeBlockers(plan); - const existingCount = plan.schedule.trainSet?.wagons?.length ?? 0; + // Coupled slots plus loose wagons on the source train — everything moves. + const existingCount = + (plan.schedule.trainSet?.wagons?.length ?? 0) + + plan.looseSourceWagons.length; return { canMerge: blockers.length === 0, blockers, @@ -9555,13 +9607,20 @@ export class TrainSchedulingService { trainId: targetTrain.id, }); - // 2. The physical wagons follow the train. + // 2. The physical wagons follow the train — the target's stay put, and + // EVERY wagon on the source train (coupled or loose) moves across so + // nothing strands on the deactivated train. if (incomingWagons.length) { await manager.getRepository(Wagon).update( { id: In(incomingWagons.map((w) => w.id)) }, { trainId: targetTrain.id }, ); } + if (sourceTrainId) { + await manager + .getRepository(Wagon) + .update({ trainId: sourceTrainId }, { trainId: targetTrain.id }); + } // 3. Carry the target's train-set wagon rows into THIS consist, appended // after the existing wagons. Sequence is provisional — staff reorder @@ -9611,6 +9670,14 @@ export class TrainSchedulingService { await manager .getRepository(TrainSet) .update(trainSetId, { wagonCount: mergedCount }); + + // 8. Booking capacity follows the consist: raise (never lower) the + // planning ceiling so the merged wagons are actually sellable. + if (mergedCount > (schedule.maxWagons ?? 0)) { + await manager + .getRepository(TrainSchedule) + .update(schedule.id, { maxWagons: mergedCount }); + } }); this.logger.log( diff --git a/apps/edr-freight-api/src/modules/train-scheduling/utils/schedule-wagon-usage.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/utils/schedule-wagon-usage.util.spec.ts index 8fa9c1260..6507b8a8c 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/utils/schedule-wagon-usage.util.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/utils/schedule-wagon-usage.util.spec.ts @@ -68,6 +68,23 @@ describe('computeScheduleWagonUsage', () => { expect(usage.wagonsRemaining).toBe(0); }); + it('sells against the planned ceiling, not the partially coupled consist', () => { + // S-2026-00003: planned 3 wagons, 2 coupled + allocated for a paid booking + // that reserved 2 — the list read "2/2 used, 0 bookable" while the detail + // page and the booking gate (remainingWagonsForLeg vs maxWagons) both said + // 1 wagon was still free. Wagons couple on demand; the ceiling is capacity. + const usage = computeScheduleWagonUsage({ + wagonSlots: Array(2).fill(slot(true)), + storedWagonCount: 2, + scheduleBookings: [booking(2)], + maxWagons: 3, + }); + + expect(usage.wagonsUsed).toBe(2); + expect(usage.wagonsTotal).toBe(3); + expect(usage.wagonsRemaining).toBe(1); + }); + it('falls back to the stored counter when slot rows were not loaded', () => { const usage = computeScheduleWagonUsage({ wagonSlots: [], diff --git a/apps/edr-freight-api/src/modules/train-scheduling/utils/schedule-wagon-usage.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/utils/schedule-wagon-usage.util.ts index f48d60f9a..899e4c066 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/utils/schedule-wagon-usage.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/utils/schedule-wagon-usage.util.ts @@ -20,11 +20,11 @@ export interface ScheduleBookingLike { export interface ScheduleWagonUsage { /** Coupled slots carrying at least one booking allocation. */ wagonsUsed: number; - /** Coupled consist size — the denominator of `wagonsUsed`. */ + /** Schedule capacity — the larger of coupled consist and planned ceiling. */ wagonsTotal: number; /** Wagons claimed by bookings, including bookings that have not paid. */ wagonsReserved: number; - /** Consist minus what bookings have claimed — what is still bookable. */ + /** Capacity minus what bookings have claimed — what is still bookable. */ wagonsRemaining: number; } @@ -33,6 +33,8 @@ export function computeScheduleWagonUsage(input: { /** Stored counter; used only when the slot rows were not loaded. */ storedWagonCount?: number | null; scheduleBookings?: ScheduleBookingLike[] | null; + /** Planned wagon ceiling (`maxWagons`) — what booking capacity is sold against. */ + maxWagons?: number | null; }): ScheduleWagonUsage { const slots = input.wagonSlots ?? []; @@ -42,7 +44,14 @@ export function computeScheduleWagonUsage(input: { // Prefer live slot rows; the stored counter drifts when a consist is edited // without a recompute, which is why the list and detail disagreed on totals. - const wagonsTotal = slots.length || (input.storedWagonCount ?? 0); + const coupled = slots.length || (input.storedWagonCount ?? 0); + + // Wagons are coupled on demand as bookings are allocated, so a partially + // built consist does not cap what is bookable — the planned ceiling does + // (remainingWagonsForLeg sells against maxWagons). Without this, a schedule + // planned for 3 wagons with 2 coupled+allocated read "2/2 used, 0 bookable" + // while its detail page and the booking gate both said 1 wagon was free. + const wagonsTotal = Math.max(coupled, input.maxWagons ?? 0); // An unpaid booking still holds its wagons, so reserved space is NOT bookable. const wagonsReserved = (input.scheduleBookings ?? []).reduce( diff --git a/apps/edr-freight-web/backoffice/src/components/profile/ChangeEmailCard.tsx b/apps/edr-freight-web/backoffice/src/components/profile/ChangeEmailCard.tsx new file mode 100644 index 000000000..650616049 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/profile/ChangeEmailCard.tsx @@ -0,0 +1,172 @@ +import { useState } from "react"; +import { Mail, Loader2 } from "lucide-react"; +import { useMutation } from "@tanstack/react-query"; +import toast from "react-hot-toast"; + +import { api } from "@/services/api"; +import { useAuth } from "@/auth/useAuth"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + Button, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + Input, + Label, +} from "@edr/ui-common"; + +/** + * Lets the signed-in backoffice user change their own email. Goes through + * /me/contact/otp + /me/contact rather than the generic (unverified) + * /auth/update-profile route, so the new address is proven before it's + * written — see account.controller.ts on the API side. + */ +export function ChangeEmailCard() { + const { user } = useAuth(); + const sendOtpMutation = useMutation( + api.account.sendContactOtp.mutationOptions(), + ); + const updateContactMutation = useMutation( + api.account.updateContact.mutationOptions(), + ); + + const [open, setOpen] = useState(false); + const [step, setStep] = useState<"enterEmail" | "enterOtp">("enterEmail"); + const [newEmail, setNewEmail] = useState(""); + const [otp, setOtp] = useState(""); + const [formError, setFormError] = useState(""); + + const closeDialog = () => { + setOpen(false); + setStep("enterEmail"); + setNewEmail(""); + setOtp(""); + setFormError(""); + }; + + const sendOtp = () => { + setFormError(""); + if (!newEmail.trim()) { + setFormError("Enter the new email address."); + return; + } + + sendOtpMutation.mutate( + { channel: "email", value: newEmail.trim() }, + { + onSuccess: (result) => { + toast.success(`Verification code sent to ${result.sentTo}`); + setStep("enterOtp"); + }, + }, + ); + }; + + const confirmOtp = () => { + setFormError(""); + if (!otp.trim()) { + setFormError("Enter the verification code."); + return; + } + + updateContactMutation.mutate( + { channel: "email", value: newEmail.trim(), otp: otp.trim() }, + { + onSuccess: () => { + toast.success("Email updated."); + closeDialog(); + // Refetches the session so the new email shows everywhere — simplest + // way to refresh the cached user without a dedicated context method. + window.location.reload(); + }, + }, + ); + }; + + return ( + + + + + Email + + + {user?.email ? `Current email: ${user.email}` : "Change your account email."} + + + + + + + !next && closeDialog()}> + + + Change email + + {step === "enterEmail" + ? "We'll send a verification code to the new address." + : `Enter the code sent to ${newEmail}.`} + + + + {step === "enterEmail" ? ( +
+ + setNewEmail(e.target.value)} + /> + {formError &&

{formError}

} +
+ ) : ( +
+ + setOtp(e.target.value)} + /> + {formError &&

{formError}

} +
+ )} + + + + {step === "enterEmail" ? ( + + ) : ( + + )} + +
+
+
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/profile/ChangePasswordCard.tsx b/apps/edr-freight-web/backoffice/src/components/profile/ChangePasswordCard.tsx new file mode 100644 index 000000000..fb5f9086e --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/profile/ChangePasswordCard.tsx @@ -0,0 +1,154 @@ +import { useState } from "react"; +import { KeyRound, Loader2 } from "lucide-react"; +import { useMutation } from "@tanstack/react-query"; +import toast from "react-hot-toast"; + +import { api } from "@/services/api"; +import { useAuth } from "@/auth/useAuth"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + Button, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + Input, + Label, +} from "@edr/ui-common"; + +/** + * Lets the signed-in backoffice user change their own password. The account + * is logged out on success — the old token was issued under the old + * password, and this forces a clean re-login rather than trusting the + * server to keep the existing session valid. + */ +export function ChangePasswordCard() { + const { logout } = useAuth(); + const changePasswordMutation = useMutation( + api.account.changePassword.mutationOptions(), + ); + + const [open, setOpen] = useState(false); + const [oldPassword, setOldPassword] = useState(""); + const [newPassword, setNewPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [formError, setFormError] = useState(""); + + const closeDialog = () => { + setOpen(false); + setOldPassword(""); + setNewPassword(""); + setConfirmPassword(""); + setFormError(""); + }; + + const submit = () => { + setFormError(""); + + if (!oldPassword || !newPassword || !confirmPassword) { + setFormError("All fields are required."); + return; + } + if (newPassword.length < 8) { + setFormError("New password must be at least 8 characters."); + return; + } + if (newPassword === oldPassword) { + setFormError("New password must be different from the current one."); + return; + } + if (newPassword !== confirmPassword) { + setFormError("New password and confirmation do not match."); + return; + } + + changePasswordMutation.mutate( + { oldPassword, newPassword, confirmPassword }, + { + onSuccess: () => { + toast.success("Password changed. Please sign in again."); + closeDialog(); + setTimeout(logout, 1200); + }, + }, + ); + }; + + return ( + + + + + Password + + Change the password for your account. + + + + + + !next && closeDialog()}> + + + Change password + + You'll be signed out and asked to log in again once it's changed. + + +
+
+ + setOldPassword(e.target.value)} + /> +
+
+ + setNewPassword(e.target.value)} + /> +
+
+ + setConfirmPassword(e.target.value)} + /> +
+ {formError &&

{formError}

} +
+ + + + +
+
+
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/layout/TopBar.tsx b/apps/edr-freight-web/backoffice/src/layout/TopBar.tsx index 708a5a3ca..c7bd1b197 100644 --- a/apps/edr-freight-web/backoffice/src/layout/TopBar.tsx +++ b/apps/edr-freight-web/backoffice/src/layout/TopBar.tsx @@ -319,7 +319,7 @@ export const TopBar = () => { {t("header.viewProfile")} navigate("/change-password")} + onClick={() => navigate("/dashboard/profile")} className="cursor-pointer hover:bg-primary-50 dark:hover:bg-primary-900/30 hover:text-primary-700 dark:hover:text-primary-400 py-2.5"> {t("header.changePassword")} diff --git a/apps/edr-freight-web/backoffice/src/pages/HomePage/Header.tsx b/apps/edr-freight-web/backoffice/src/pages/HomePage/Header.tsx index ed569a83d..46ddcfe7d 100644 --- a/apps/edr-freight-web/backoffice/src/pages/HomePage/Header.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/HomePage/Header.tsx @@ -502,7 +502,7 @@ const Header = () => { navigate("/change-password")} + onClick={() => navigate("/dashboard/profile")} > diff --git a/apps/edr-freight-web/backoffice/src/pages/contract_templates/ContractTemplatesPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contract_templates/ContractTemplatesPage.tsx index 1a66bbdd5..1adcd2e6c 100644 --- a/apps/edr-freight-web/backoffice/src/pages/contract_templates/ContractTemplatesPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/contract_templates/ContractTemplatesPage.tsx @@ -38,7 +38,10 @@ import { } from "@/hooks/contract-templates/useContractTemplates"; import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions"; import { cargoTypesService } from "@/services/cargo-types.service"; -import type { ContractTemplate } from "@/services/contract-templates.service"; +import type { + BulkTemplateDirection, + ContractTemplate, +} from "@/services/contract-templates.service"; import TemplatePreviewModal from "./TemplatePreviewModal"; const DIRECTION_LABEL: Record = { @@ -68,6 +71,14 @@ function customsVariant(template: ContractTemplate): boolean | null { return null; } +// Bulk templates carry the direction on the row; the fixed container codes +// carry it as the code prefix. +function directionOf(template: ContractTemplate): string { + return isBulk(template) + ? template.tradeDirection ?? "INTERCITY" + : template.code.split("_")[0]; +} + function formatUpdated(value: string): string { return new Date(value).toLocaleDateString("en-GB", { day: "numeric", @@ -105,7 +116,7 @@ export default function ContractTemplatesPage() { void; onCreated: (code: string) => void; }) { + const [direction, setDirection] = useState("IMPORT"); const [withCustoms, setWithCustoms] = useState("true"); const [cargoTypeId, setCargoTypeId] = useState(null); const create = useCreateContractTemplate(); + const intercity = direction === "INTERCITY"; const { data: cargoTypes, isLoading } = useQuery({ queryKey: ["cargo-types", "contract-template-options"], @@ -238,19 +255,42 @@ function CreateTemplateModal({
- Customs clearing + Trade direction setDirection(value as BulkTemplateDirection)} data={[ - { value: "true", label: "With customs clearing" }, - { value: "false", label: "Without customs clearing" }, + { value: "IMPORT", label: "Import" }, + { value: "EXPORT", label: "Export" }, + { value: "INTERCITY", label: "Intercity" }, ]} />
+ {intercity ? ( + + Intercity contracts are domestic and cross no border, so they have + no customs clearing variant — one template per cargo type. + + ) : ( +
+ + Customs clearing + + +
+ )} +