From 4efd65c1057b37c7f6c9f2498c1b23a177359381 Mon Sep 17 00:00:00 2001 From: Marshal Date: Wed, 12 Aug 2026 09:20:28 +0000 Subject: [PATCH] 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 + + +
+ )} +