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 + + +
+ )} +