From 4efd65c1057b37c7f6c9f2498c1b23a177359381 Mon Sep 17 00:00:00 2001 From: Marshal Date: Wed, 12 Aug 2026 09:20:28 +0000 Subject: [PATCH 1/9] feat: key bulk templates by trade direction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bulk contract templates are now unique per (cargo type, trade direction, customs option) instead of (cargo type, customs option). Intercity is domestic and crosses no border, so it carries no customs variant: with_customs stays null there, enforced by a check constraint. Contract resolution already passed the trade direction through but the bulk lookup dropped it, so one template served all three directions. Container templates are unchanged — cargo_type_id is null on those rows, which keeps them out of both the new index and the constraint. Co-Authored-By: Claude Opus 5 (1M context) --- ...420000000000-BulkTemplateTradeDirection.ts | 69 +++++++++ .../bulk-template-direction.spec.ts | 134 ++++++++++++++++++ .../contract-templates.controller.ts | 2 +- .../contract-templates.repository.ts | 28 +++- .../contract-templates.service.ts | 122 +++++++++++----- .../dto/contract-template.dto.ts | 19 ++- .../entities/contract-template.entity.ts | 52 ++++++- .../ContractTemplatesPage.tsx | 78 +++++++--- .../services/contract-templates.service.ts | 15 +- 9 files changed, 448 insertions(+), 71 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/3420000000000-BulkTemplateTradeDirection.ts create mode 100644 apps/edr-freight-api/src/modules/contract-templates/bulk-template-direction.spec.ts 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/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-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 + + +
+ )} + 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/dashboard/MyProfilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/dashboard/MyProfilePage.tsx index baf548d92..37f91909e 100644 --- a/apps/edr-freight-web/backoffice/src/pages/dashboard/MyProfilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/dashboard/MyProfilePage.tsx @@ -1,8 +1,12 @@ import { MySignatureCard } from "@/components/profile/MySignatureCard"; +import { ChangePasswordCard } from "@/components/profile/ChangePasswordCard"; +import { ChangeEmailCard } from "@/components/profile/ChangeEmailCard"; export default function MyProfilePage() { return (
+ +
diff --git a/apps/edr-freight-web/backoffice/src/record-management/components/common/Top.tsx b/apps/edr-freight-web/backoffice/src/record-management/components/common/Top.tsx index ff7678856..4f58ae6b7 100644 --- a/apps/edr-freight-web/backoffice/src/record-management/components/common/Top.tsx +++ b/apps/edr-freight-web/backoffice/src/record-management/components/common/Top.tsx @@ -551,7 +551,7 @@ const Top: React.FC = ({ navigate("/change-password")} + onClick={() => navigate("/dashboard/profile")} > {t("header.changePassword")} diff --git a/apps/edr-freight-web/backoffice/src/services/account.service.ts b/apps/edr-freight-web/backoffice/src/services/account.service.ts new file mode 100644 index 000000000..40511bf79 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/services/account.service.ts @@ -0,0 +1,50 @@ +import { api as client } from "../auth/http"; +import { unwrap } from "@/utils/endpoint"; + +export type ContactChannel = "email" | "phone"; + +export interface ChangePasswordPayload { + oldPassword: string; + newPassword: string; + confirmPassword: string; +} + +export interface SendContactOtpPayload { + channel: ContactChannel; + /** The NEW email/phone to verify — the OTP is sent here, not to the current one. */ + value: string; +} + +export interface UpdateContactPayload extends SendContactOtpPayload { + otp: string; +} + +export const accountService = { + /** PATCH /auth/change-password — generic IAM route, works for any user type. */ + changePassword: async (payload: ChangePasswordPayload): Promise => { + const response = await client.patch("/auth/change-password", payload); + unwrap(response.data); + }, + + /** POST /me/contact/otp — sends a code to the new email/phone to prove ownership. */ + sendContactOtp: async ( + payload: SendContactOtpPayload, + ): Promise<{ sentTo: string }> => { + const response = await client.post<{ sentTo: string }>( + "/me/contact/otp", + payload, + ); + return unwrap(response.data); + }, + + /** PATCH /me/contact — verifies the OTP and writes the new email/phone. */ + updateContact: async ( + payload: UpdateContactPayload, + ): Promise<{ success: true; value: string }> => { + const response = await client.patch<{ success: true; value: string }>( + "/me/contact", + payload, + ); + return unwrap(response.data); + }, +}; diff --git a/apps/edr-freight-web/backoffice/src/services/api.ts b/apps/edr-freight-web/backoffice/src/services/api.ts index 6c9c69788..2a1963f8d 100644 --- a/apps/edr-freight-web/backoffice/src/services/api.ts +++ b/apps/edr-freight-web/backoffice/src/services/api.ts @@ -136,6 +136,12 @@ import type { WarehouseZone, } from "@/types/warehouse"; import { endpoint } from "@/utils/endpoint"; +import { + accountService, + type ChangePasswordPayload, + type SendContactOtpPayload, + type UpdateContactPayload, +} from "./account.service"; import { BookingListFilter, bookingsService, @@ -2182,6 +2188,29 @@ export const api = { ), }, + account: { + changePassword: endpoint( + "me", + "change-password", + (payload) => accountService.changePassword(payload), + ), + + sendContactOtp: endpoint( + "me", + "send-contact-otp", + (payload) => accountService.sendContactOtp(payload), + ), + + updateContact: endpoint< + UpdateContactPayload, + { success: true; value: string } + >( + "me", + "update-contact", + (payload) => accountService.updateContact(payload), + ), + }, + signatures: { mySignature: endpoint( "me", From dd39c2be7c220f0eac96b2d2d944815005a965fc Mon Sep 17 00:00:00 2001 From: Marshal Date: Wed, 12 Aug 2026 13:29:31 +0000 Subject: [PATCH 8/9] baseLiters --- .../services/rate-change-requests.service.spec.ts | 13 +++++++++++++ .../services/rate-change-requests.service.ts | 4 ++++ 2 files changed, 17 insertions(+) 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; /** From ff3cee12e316b759daa75aa67fcde5bc5c724cad Mon Sep 17 00:00:00 2001 From: Marshal Date: Wed, 12 Aug 2026 13:47:53 +0000 Subject: [PATCH 9/9] feat: fuel surcharge per lane and cargo type --- .../contract-rate-schedule.builder.ts | 15 ++++++++++--- .../contracts/contract-pricing.service.ts | 17 ++++++++------ .../rule-engine/rule-engine.service.spec.ts | 9 +++++--- .../rule-engine/rule-engine.service.ts | 22 ++++++++++++------- 4 files changed, 42 insertions(+), 21 deletions(-) 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 c644d6117..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 @@ -193,17 +193,26 @@ export class ContractRateScheduleBuilder { return rate.tradeDirection === want; } - /** Fuel row — the lane matters, so it rides along in the charge label. */ + /** + * 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(rate.rateValue), - unit: this.unitLabel(rate.rateUnit), + amount: this.formatAmount( + perLiter + ? Number(rate.baseLiters ?? 0) * Number(rate.rateValue) + : rate.rateValue, + ), + unit: perLiter ? 'flat' : this.unitLabel(rate.rateUnit), }; } 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 3f49b127f..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 @@ -302,15 +302,18 @@ export class ContractPricingService { r.cargoTypeId === scope.cargoTypeId, ); if (fuel && Number(fuel.rateValue) > 0) { - const base = Number(fuel.baseLiters ?? 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.rateUnit === 'PER_LITER' - ? `Fuel surcharge (${scope.cargoType.cargoTypeName}, ${base} liters)` - : `Fuel surcharge (${scope.cargoType.cargoTypeName})`, - unit: toContractUnit(fuel.rateUnit), - unitPrice: convert(Number(fuel.rateValue)), + label: `Fuel surcharge (${scope.cargoType.cargoTypeName})`, + unit: perLiter ? 'flat' : toContractUnit(fuel.rateUnit), + unitPrice: convert(total), cargoTypeCode: scope.cargoType.code ?? null, conditionalOn: 'has_fuel', }); 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 440616298..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 @@ -475,13 +475,16 @@ describe('RuleEngineService — fuel surcharge (per lane + cargo type)', () => { const fuelMods = (result: Awaited>) => result.appliedModifiers.filter((m) => m.surchargeCode === 'FUEL_SURCHARGE'); - it('PER_LITER bills base liters × rate value once, regardless of wagons', async () => { + 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); - expect(mods[0].triggerValue).toBe(100); + // 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('PER_LITER'); + expect(mods[0].billingUnit).toBe('FLAT'); }); it('PER_WAGON bills the wagons the cargo occupies', async () => { 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 a290aeb33..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 @@ -648,9 +648,12 @@ export class RuleEngineService { /** * 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 bills baseLiters × - * rateValue once per booking; PER_WAGON bills the wagons the cargo occupies. - * No matching lane rate simply bills nothing — same leniency as lashing. + * 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, @@ -673,9 +676,12 @@ export class RuleEngineService { 0, Number(input.bulkWagons ?? 0) || Number(input.totalWagons ?? 0), ); - const billedQty = - rate.rateUnit === 'PER_LITER' ? Number(rate.baseLiters ?? 0) : wagons; - const amount = billedQty * rateValue; + 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, @@ -683,8 +689,8 @@ export class RuleEngineService { triggerValue: billedQty, calculatedAmount: amount, currency: rate.currency, - unitPriceUsd: rateValue, - billingUnit: rate.rateUnit, + unitPriceUsd: unitPrice, + billingUnit: perLiter ? 'FLAT' : rate.rateUnit, }); return modifiers; }