From cd90ccb0f50ff92c451cb820c110c351363ac68b Mon Sep 17 00:00:00 2001 From: Marshal Date: Fri, 7 Aug 2026 23:50:27 +0000 Subject: [PATCH] feat(contracts): per-cargo bulk contract templates --- .../contract-document-view-model.builder.ts | 2 + .../3320000000000-BulkContractTemplates.ts | 100 ++++++ .../contract-templates.controller.ts | 70 +++-- .../contract-templates.repository.ts | 49 ++- .../contract-templates.service.ts | 142 +++++++-- .../dto/contract-template.dto.ts | 29 ++ .../entities/contract-template.entity.ts | 41 ++- .../contracts/contract-transition.service.ts | 2 + .../rule-engine/dto/create-cargo-type.dto.ts | 10 + .../rule-engine/entities/cargo-type.entity.ts | 8 + .../services/cargo-types.service.ts | 45 +++ .../src/seed/freight-permissions.registry.ts | 26 ++ apps/edr-freight-web/backoffice/src/App.tsx | 20 +- .../useContractTemplates.ts | 16 + .../backoffice/src/lib/permissions.ts | 4 + .../ContractTemplatesPage.tsx | 293 ++++++++++++++++-- .../src/pages/ruleEngine/CargoTypesPage.tsx | 13 + .../services/contract-templates.service.ts | 39 ++- 18 files changed, 802 insertions(+), 107 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/3320000000000-BulkContractTemplates.ts diff --git a/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts b/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts index d4f0c4039..36e4e34d0 100644 --- a/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts +++ b/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts @@ -120,6 +120,8 @@ export class ContractDocumentViewModelBuilder { contract.tradeDirection, contract.freightType, contract.customsClearingEnabled, + // Bulk templates are keyed by the contract's cargo type. + (contract.cargoScope ?? []).find((c) => c.cargoTypeId)?.cargoTypeId, ); dynamicTemplate = dynamicSource ? { diff --git a/apps/edr-freight-api/src/migrations/3320000000000-BulkContractTemplates.ts b/apps/edr-freight-api/src/migrations/3320000000000-BulkContractTemplates.ts new file mode 100644 index 000000000..39a10ca90 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3320000000000-BulkContractTemplates.ts @@ -0,0 +1,100 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +const CONTAINER_CODES = [ + 'IMPORT_CONTAINER_CUSTOMS', + 'IMPORT_CONTAINER_NO_CUSTOMS', + 'EXPORT_CONTAINER_CUSTOMS', + 'EXPORT_CONTAINER_NO_CUSTOMS', + 'INTERCITY_CONTAINER', +]; + +const BULK_CODES = [ + 'IMPORT_BULK_CUSTOMS', + 'IMPORT_BULK_NO_CUSTOMS', + 'EXPORT_BULK_CUSTOMS', + 'EXPORT_BULK_NO_CUSTOMS', + 'INTERCITY_BULK', +]; + +/** + * Bulk contract templates become staff-created, keyed by (cargo type, customs + * clearing) instead of the fixed direction codes. The five container templates + * stay seeded and become undeletable system rows; the five seeded bulk rows are + * retired (soft-deleted). cargo_types gains has_contract_template, marking + * which bulk commodities may carry their own template. + */ +export class BulkContractTemplates3320000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.cargo_types + ADD COLUMN IF NOT EXISTS has_contract_template boolean NOT NULL DEFAULT false + `); + + await queryRunner.query(` + ALTER TABLE freight.contract_templates + ADD COLUMN IF NOT EXISTS cargo_type_id uuid REFERENCES freight.cargo_types(id), + ADD COLUMN IF NOT EXISTS with_customs boolean, + ADD COLUMN IF NOT EXISTS is_system boolean NOT NULL DEFAULT false + `); + + // Generated bulk codes (BULK__NO_CUSTOMS) outgrow varchar(40). + await queryRunner.query(` + ALTER TABLE freight.contract_templates + ALTER COLUMN code TYPE varchar(80) + `); + + await queryRunner.query( + `UPDATE freight.contract_templates SET is_system = true WHERE code = ANY($1)`, + [CONTAINER_CODES], + ); + + // Retire the fixed bulk templates; staff recreate them per cargo type. + await queryRunner.query( + `UPDATE freight.contract_templates SET deleted_at = now() + WHERE code = ANY($1) AND deleted_at IS NULL`, + [BULK_CODES], + ); + + // Code stays unique among live rows only, so a deleted combo can be + // recreated under the same generated code. + await queryRunner.query( + `ALTER TABLE freight.contract_templates DROP CONSTRAINT IF EXISTS uq_contract_templates_code`, + ); + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS uq_contract_templates_code + ON freight.contract_templates (code) WHERE deleted_at IS NULL + `); + + // One template per (bulk cargo type, customs option) — the "same + // combination" rule, enforced even under concurrent creates. + 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 + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP INDEX IF EXISTS freight.uq_contract_templates_cargo_customs`, + ); + await queryRunner.query(`DROP INDEX IF EXISTS freight.uq_contract_templates_code`); + await queryRunner.query(` + ALTER TABLE freight.contract_templates + ADD CONSTRAINT uq_contract_templates_code UNIQUE (code) + `); + await queryRunner.query( + `UPDATE freight.contract_templates SET deleted_at = NULL WHERE code = ANY($1)`, + [BULK_CODES], + ); + await queryRunner.query(` + ALTER TABLE freight.contract_templates + DROP COLUMN IF EXISTS cargo_type_id, + DROP COLUMN IF EXISTS with_customs, + DROP COLUMN IF EXISTS is_system + `); + await queryRunner.query(` + ALTER TABLE freight.cargo_types DROP COLUMN IF EXISTS has_contract_template + `); + } +} 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 6cba17f61..7053d224f 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 @@ -3,6 +3,8 @@ import { Controller, Delete, Get, + HttpCode, + HttpStatus, Param, Patch, Post, @@ -15,55 +17,85 @@ import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry"; import { ContractTemplatesService } from "./contract-templates.service"; import { CreateArticleDto, + CreateContractTemplateDto, PreviewContractTemplateDto, ReplaceArticlesDto, UpdateArticleDto, UpdateContractTemplateDto, } from "./dto/contract-template.dto"; +// `view` opens the Templates page; `read` is API-read-only for other pages +// that show template data; create/update/delete gate each write. `manage` is +// the legacy write key and keeps working for roles that already hold it. +const TEMPLATE_READ = [ + FREIGHT_PERMS.settings.contractTemplates.view, + FREIGHT_PERMS.settings.contractTemplates.read, + FREIGHT_PERMS.settings.contractTemplates.update, + FREIGHT_PERMS.settings.contractTemplates.manage, + FREIGHT_PERMS.admin, +]; + +const TEMPLATE_UPDATE = [ + FREIGHT_PERMS.settings.contractTemplates.update, + FREIGHT_PERMS.settings.contractTemplates.manage, + FREIGHT_PERMS.admin, +]; + @ApiTags("contract-templates") @Controller("contract-templates") export class ContractTemplatesController { constructor(private readonly service: ContractTemplatesService) {} - // Reads are staff-only (the backoffice Templates tab is the only consumer); - // writes are admin-guarded like other freight configuration resources. - @Get() - @BookingStaff([ - FREIGHT_PERMS.settings.contractTemplates.view, - FREIGHT_PERMS.settings.contractTemplates.manage, - FREIGHT_PERMS.admin, - ]) - @ApiOperation({ summary: "List the six contract document templates" }) + @BookingStaff(TEMPLATE_READ) + @ApiOperation({ summary: "List contract templates (system container + staff-created bulk)" }) list() { return this.service.list(); } - @Get(":code") + @Post() @BookingStaff([ - FREIGHT_PERMS.settings.contractTemplates.view, + FREIGHT_PERMS.settings.contractTemplates.create, FREIGHT_PERMS.settings.contractTemplates.manage, FREIGHT_PERMS.admin, ]) + @ApiOperation({ + summary: + "Create a bulk contract template for a (cargo type, customs option) pair", + }) + create(@Body() dto: CreateContractTemplateDto) { + return this.service.create(dto); + } + + @Get(":code") + @BookingStaff(TEMPLATE_READ) @ApiOperation({ summary: "Get one contract template by code" }) getByCode(@Param("code") code: string) { return this.service.getByCode(code); } @Patch(":code") - @BookingStaff([FREIGHT_PERMS.settings.contractTemplates.manage, FREIGHT_PERMS.admin]) + @BookingStaff(TEMPLATE_UPDATE) @ApiOperation({ summary: "Update template metadata (name, title, recitals, active flag)" }) update(@Param("code") code: string, @Body() dto: UpdateContractTemplateDto) { return this.service.update(code, dto); } - @Post(":code/preview") + @Delete(":code") @BookingStaff([ - FREIGHT_PERMS.settings.contractTemplates.view, - FREIGHT_PERMS.settings.contractTemplates.manage, + FREIGHT_PERMS.settings.contractTemplates.delete, FREIGHT_PERMS.admin, ]) + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ + summary: "Delete a staff-created bulk template (system templates refuse)", + }) + remove(@Param("code") code: string) { + return this.service.remove(code); + } + + @Post(":code/preview") + @BookingStaff(TEMPLATE_READ) @ApiOperation({ summary: "Render an HTML preview of the template against mock contract data", }) @@ -77,21 +109,21 @@ export class ContractTemplatesController { /* ------------------------- article routes ------------------------- */ @Put(":code/articles") - @BookingStaff([FREIGHT_PERMS.settings.contractTemplates.manage, FREIGHT_PERMS.admin]) + @BookingStaff(TEMPLATE_UPDATE) @ApiOperation({ summary: "Replace the full ordered article list (used for reorder)" }) replaceArticles(@Param("code") code: string, @Body() dto: ReplaceArticlesDto) { return this.service.replaceArticles(code, dto.articles); } @Post(":code/articles") - @BookingStaff([FREIGHT_PERMS.settings.contractTemplates.manage, FREIGHT_PERMS.admin]) + @BookingStaff(TEMPLATE_UPDATE) @ApiOperation({ summary: "Add an article to the template" }) addArticle(@Param("code") code: string, @Body() dto: CreateArticleDto) { return this.service.addArticle(code, dto); } @Patch(":code/articles/:articleId") - @BookingStaff([FREIGHT_PERMS.settings.contractTemplates.manage, FREIGHT_PERMS.admin]) + @BookingStaff(TEMPLATE_UPDATE) @ApiOperation({ summary: "Update an article's title or body" }) updateArticle( @Param("code") code: string, @@ -102,7 +134,7 @@ export class ContractTemplatesController { } @Delete(":code/articles/:articleId") - @BookingStaff([FREIGHT_PERMS.settings.contractTemplates.manage, FREIGHT_PERMS.admin]) + @BookingStaff(TEMPLATE_UPDATE) @ApiOperation({ summary: "Remove an article from the template" }) removeArticle( @Param("code") code: string, 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 2f4fb0117..9d50b1677 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 @@ -3,10 +3,8 @@ import { Injectable } from "@nestjs/common"; import { InjectRepository } from "@nestjs/typeorm"; import { Repository } from "typeorm"; -import { - ContractTemplate, - ContractTemplateCode, -} from "./entities/contract-template.entity"; +import { CargoType } from "../rule-engine/entities/cargo-type.entity"; +import { ContractTemplate } from "./entities/contract-template.entity"; @Injectable() export class ContractTemplatesRepository extends BaseRepository { @@ -17,12 +15,51 @@ export class ContractTemplatesRepository extends BaseRepository { + findByCode(code: string): Promise { return this.repository.findOne({ where: { code } }); } override findAll(): Promise { - return this.repository.find({ order: { code: "ASC" } }); + return this.repository.find({ + relations: { cargoType: true }, + order: { code: "ASC" }, + }); + } + + findByCargoCombo( + cargoTypeId: string, + withCustoms: boolean, + ): Promise { + return this.repository.findOne({ where: { cargoTypeId, withCustoms } }); + } + + /** + * 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). + */ + findActiveBulkTemplate( + cargoTypeId: string, + withCustoms: boolean, + ): Promise { + return this.repository + .createQueryBuilder("t") + .where("t.is_active = true") + .andWhere("t.with_customs = :withCustoms", { withCustoms }) + .andWhere( + `(t.cargo_type_id = :cargoTypeId OR t.cargo_type_id = ( + SELECT c.parent_group_id FROM freight.cargo_types c + WHERE c.id = :cargoTypeId AND c.deleted_at IS NULL + ))`, + { cargoTypeId }, + ) + .getOne(); + } + + findCargoType(id: string): Promise { + return this.repository.manager + .getRepository(CargoType) + .findOne({ where: { id } }); } async saveTemplate(template: ContractTemplate): Promise { 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 ea41b57a2..da9c849f7 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 @@ -1,4 +1,9 @@ -import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common"; +import { + BadRequestException, + ConflictException, + Injectable, + NotFoundException, +} from "@nestjs/common"; import { randomUUID } from "node:crypto"; import { ContractRendererService } from "../../contracts/contract-renderer.service"; @@ -11,6 +16,7 @@ import { import { ContractTemplatesRepository } from "./contract-templates.repository"; import { CreateArticleDto, + CreateContractTemplateDto, PreviewContractTemplateDto, ReplaceArticleDto, UpdateArticleDto, @@ -53,13 +59,17 @@ export class ContractTemplatesService { async list(): Promise { const templates = await this.repository.findAll(); const rank = new Map(CONTRACT_TEMPLATE_CODES.map((code, i) => [code, i] as const)); - return templates.sort( - (a, b) => (rank.get(a.code) ?? 99) - (rank.get(b.code) ?? 99), - ); + // Seeded container templates first in canonical order, then staff-created + // bulk templates alphabetically. + return templates.sort((a, b) => { + const ra = rank.get(a.code as ContractTemplateCode) ?? 99; + const rb = rank.get(b.code as ContractTemplateCode) ?? 99; + return ra !== rb ? ra - rb : a.name.localeCompare(b.name); + }); } async getByCode(code: string): Promise { - const template = await this.repository.findByCode(this.assertCode(code)); + const template = await this.repository.findByCode(code?.toUpperCase() ?? ""); if (!template) { throw new NotFoundException(`Contract template ${code} not found`); } @@ -67,15 +77,93 @@ export class ContractTemplatesService { } /** - * The active template used when generating a contract document for the given - * direction/freight/customs triple; null when missing or deactivated (the - * renderer then falls back to the built-in generic layout). + * 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. + */ + async create(dto: CreateContractTemplateDto): Promise { + const cargoType = await this.repository.findCargoType(dto.cargoTypeId); + if (!cargoType) { + throw new NotFoundException(`Cargo type ${dto.cargoTypeId} not found`); + } + if (!cargoType.hasContractTemplate) { + throw new BadRequestException( + `"${cargoType.cargoTypeName}" does not allow contract templates — enable "has contract template" on the cargo type first`, + ); + } + const variant = dto.withCustoms ? "with" : "without"; + const existing = await this.repository.findByCargoCombo( + dto.cargoTypeId, + dto.withCustoms, + ); + if (existing) { + throw new ConflictException( + `A "${cargoType.cargoTypeName}" template ${variant} customs clearing 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.description = dto.description ?? null; + template.documentTitle = dto.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.isSystem = false; + try { + return await this.repository.saveTemplate(template); + } catch (error) { + // 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`, + ); + } + throw error; + } + } + + /** Bulk templates only — the five seeded container templates are permanent. */ + async remove(code: string): Promise { + const template = await this.getByCode(code); + if (template.isSystem) { + throw new BadRequestException( + "System container templates cannot be deleted", + ); + } + await this.repository.softDelete(template.id); + } + + /** + * 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). */ async findActiveForContract( tradeDirection?: string | null, freightType?: string | null, customsClearingEnabled?: boolean | null, + cargoTypeId?: string | null, ): Promise { + const isBulk = (freightType ?? "").toUpperCase().includes("BULK"); + if (isBulk) { + if (!cargoTypeId) return null; + return this.repository.findActiveBulkTemplate( + cargoTypeId, + Boolean(customsClearingEnabled), + ); + } const code = contractTemplateCodeFor( tradeDirection, freightType, @@ -180,16 +268,32 @@ export class ContractTemplatesService { : this.sorted(template.articles), }; - const view = this.buildMockView(template.code, dynamicTemplate); + const view = this.buildMockView(template, dynamicTemplate); return { html: this.renderer.render(view) }; } + /** + * 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. + */ + private previewKeyFor(template: ContractTemplate): string { + if (template.cargoTypeId) { + return template.withCustoms + ? "IMP_BULK_USD_FORWARDING" + : "IMP_BULK_USD_TRANSPORT_ONLY"; + } + return PREVIEW_TEMPLATE_KEYS[template.code as ContractTemplateCode]; + } + private buildMockView( - code: ContractTemplateCode, + template: ContractTemplate, dynamicTemplate: ContractDynamicTemplateView, ): ContractViewModel { - const meta = getTemplateMeta(PREVIEW_TEMPLATE_KEYS[code]); - const isBulk = code.endsWith("_BULK"); + const code = template.code; + const previewKey = this.previewKeyFor(template); + const meta = getTemplateMeta(previewKey); + const isBulk = Boolean(template.cargoTypeId) || code.includes("BULK"); const now = new Date(); // Representative rate schedule so the admin preview shows the live-rate @@ -200,7 +304,7 @@ export class ContractTemplatesService { bookingId: "00000000-0000-0000-0000-000000000000", reference: "EDR/CT/2026/0042", status: "CONTRACT_READY", - templateKey: PREVIEW_TEMPLATE_KEYS[code], + templateKey: previewKey, template: { ...meta, title: dynamicTemplate.name, templateFile: "edr-dynamic.hbs" }, contractDate: now.toLocaleDateString("en-GB", { day: "numeric", @@ -275,7 +379,7 @@ export class ContractTemplatesService { } /** Static, representative rate schedule for the admin preview only. */ - private mockRateSchedule(code: ContractTemplateCode, isBulk: boolean): RateSchedule { + private mockRateSchedule(code: string, isBulk: boolean): RateSchedule { const dir = code.startsWith("IMPORT") ? "import" : code.startsWith("EXPORT") @@ -311,16 +415,6 @@ export class ContractTemplatesService { }; } - private assertCode(code: string): ContractTemplateCode { - const upper = code?.toUpperCase() as ContractTemplateCode; - if (!CONTRACT_TEMPLATE_CODES.includes(upper)) { - throw new BadRequestException( - `Unknown contract template code "${code}". Valid codes: ${CONTRACT_TEMPLATE_CODES.join(", ")}`, - ); - } - return upper; - } - private sorted(articles: ContractTemplateArticle[]): ContractTemplateArticle[] { return [...(articles ?? [])].sort((a, b) => (a.order ?? 0) - (b.order ?? 0)); } 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 0ea69f262..c8911d8e5 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 @@ -6,12 +6,41 @@ import { IsInt, IsOptional, IsString, + IsUUID, MaxLength, Min, MinLength, ValidateNested, } from "class-validator"; +export class CreateContractTemplateDto { + @ApiProperty({ + description: + "Bulk cargo type this template is written for (must have hasContractTemplate enabled)", + format: "uuid", + }) + @IsUUID() + cargoTypeId!: string; + + @ApiProperty({ + description: "Whether this is the with-customs-clearing variant", + }) + @IsBoolean() + withCustoms!: boolean; + + @ApiPropertyOptional({ description: "Display name (derived from the cargo type when omitted)" }) + @IsOptional() + @IsString() + @MinLength(3) + @MaxLength(200) + name?: string; + + @ApiPropertyOptional({ description: "Short description shown on the template card" }) + @IsOptional() + @IsString() + description?: string; +} + export class UpdateContractTemplateDto { @ApiPropertyOptional({ description: "Display name of the template" }) @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 c322c20a4..af0721572 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 @@ -1,11 +1,18 @@ import { BaseEntity } from "@edr/api-common"; -import { Column, Entity, Index } from "typeorm"; +import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm"; + +import { CargoType } from "../../rule-engine/entities/cargo-type.entity"; /** - * The ten canonical contract document templates. Import and export split by - * customs clearing (× freight type = 8); intercity does not, because it is a - * purely domestic Ethiopian movement that crosses no border and therefore has - * no customs leg at all (× freight type = 2). + * The five seeded container templates (import/export split by customs + * clearing; intercity is domestic, crosses no border, so it has a single + * 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. * * Contracts store DOMESTIC for intercity movements; the template layer labels * those INTERCITY to match the commercial vocabulary used on the printed @@ -76,10 +83,12 @@ export function contractTemplateCodeFor( } @Entity({ schema: "freight", name: "contract_templates" }) -@Index(["code"], { unique: true }) +// Uniqueness lives in partial DB indexes (live rows only): code, and +// (cargo_type_id, with_customs) for staff-created bulk templates. +@Index(["code"]) export class ContractTemplate extends BaseEntity { - @Column({ name: "code", type: "varchar", length: 40, unique: true }) - code!: ContractTemplateCode; + @Column({ name: "code", type: "varchar", length: 80 }) + code!: string; @Column({ name: "name", type: "varchar", length: 200 }) name!: string; @@ -100,4 +109,20 @@ export class ContractTemplate extends BaseEntity { @Column({ name: "is_active", type: "boolean", default: true }) isActive!: boolean; + + /** Bulk templates only: the cargo type this template is written for. */ + @Column({ name: "cargo_type_id", type: "uuid", nullable: true }) + cargoTypeId?: string | null; + + @ManyToOne(() => CargoType, { nullable: true }) + @JoinColumn({ name: "cargo_type_id" }) + cargoType?: CargoType | null; + + /** Bulk templates only: whether this is the with-customs-clearing variant. */ + @Column({ name: "with_customs", type: "boolean", nullable: true }) + withCustoms?: boolean | null; + + /** The five seeded container templates — cannot be deleted. */ + @Column({ name: "is_system", type: "boolean", default: false }) + isSystem!: boolean; } diff --git a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts index 0a073c9f8..ba3e3fd8b 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts @@ -424,6 +424,8 @@ export class ContractTransitionService { contract.tradeDirection, contract.freightType, contract.customsClearingEnabled, + // Bulk templates are keyed by the contract's cargo type. + (contract.cargoScope ?? []).find((c) => c.cargoTypeId)?.cargoTypeId, ); if (!active) return null; return { diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts index eefc560e8..d116a4b4a 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts @@ -70,6 +70,16 @@ export class CreateCargoTypeDto { @IsBoolean() hasLashing?: boolean; + @ApiPropertyOptional({ + default: false, + description: + 'Allow staff to write bulk contract templates for this cargo type. ' + + 'Mutually exclusive with the parent group / children having it.', + }) + @IsOptional() + @IsBoolean() + hasContractTemplate?: boolean; + @ApiPropertyOptional({ default: true }) @IsOptional() @IsBoolean() diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/cargo-type.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/cargo-type.entity.ts index e685f3a2d..b7717684a 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/cargo-type.entity.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/cargo-type.entity.ts @@ -82,6 +82,14 @@ export class CargoType extends BaseEntity { @Column({ name: 'has_lashing', type: 'boolean', default: false }) hasLashing!: boolean; + /** + * Whether staff may write bulk contract templates against this cargo type. + * Mutually exclusive between a parent group and its children: if the parent + * provides the template, no child may, and vice versa. + */ + @Column({ name: 'has_contract_template', type: 'boolean', default: false }) + hasContractTemplate!: boolean; + @Column({ name: 'is_active', type: 'boolean', default: true }) isActive!: boolean; diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/cargo-types.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/cargo-types.service.ts index 1f25b0823..9c11005e9 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/cargo-types.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/cargo-types.service.ts @@ -135,6 +135,35 @@ export class CargoTypesService { return map; } + /** + * A cargo type and its parent group may not BOTH offer a contract template — + * the template would be ambiguous for bookings of the child. To enable the + * child, the parent must be turned off first (and vice versa). + */ + private async assertContractTemplateExclusive(input: { + id?: string; + parentGroupId?: string | null; + }): Promise { + if (input.parentGroupId) { + const parent = await this.repository.findById(input.parentGroupId); + if (parent?.hasContractTemplate) { + throw new BadRequestException( + `Parent group "${parent.cargoTypeName}" already has a contract template — turn it off there first`, + ); + } + } + if (input.id) { + const children = await this.repository.findAll({ + where: { parentGroupId: input.id, hasContractTemplate: true }, + }); + if (children.length) { + throw new BadRequestException( + `Child cargo type(s) ${children.map((c) => `"${c.cargoTypeName}"`).join(', ')} already have their own contract template — turn those off first`, + ); + } + } + } + /** Create a new cargo type. */ async create(dto: CreateCargoTypeDto): Promise { const code = generateCode(dto.cargoTypeName); @@ -144,6 +173,9 @@ export class CargoTypesService { const parent = await this.repository.findById(dto.parentGroupId); if (!parent) throw new NotFoundException(`Parent cargo type ${dto.parentGroupId} not found`); } + if (dto.hasContractTemplate) { + await this.assertContractTemplateExclusive({ parentGroupId: dto.parentGroupId }); + } const displayOrder = await this.displayOrder.resolveCreateOrder(CargoType, 'displayOrder', { explicitOrder: dto.displayOrder, @@ -160,6 +192,7 @@ export class CargoTypesService { code, cargoTypeName: dto.cargoTypeName, parentGroupId: dto.parentGroupId ?? null, + hasContractTemplate: dto.hasContractTemplate ?? false, requiresDirectorApproval: dto.requiresDirectorApproval ?? false, isActive: dto.isActive ?? true, unitOfMeasure: dto.unitOfMeasure ?? null, @@ -183,6 +216,18 @@ export class CargoTypesService { const parent = await this.repository.findById(dto.parentGroupId); if (!parent) throw new NotFoundException(`Parent cargo type ${dto.parentGroupId} not found`); } + // Re-check the parent/child template exclusivity whenever the flag or the + // parent moves and the row ends up flagged. + const willHaveTemplate = dto.hasContractTemplate ?? existing.hasContractTemplate; + if ( + willHaveTemplate && + (dto.hasContractTemplate !== undefined || dto.parentGroupId !== undefined) + ) { + await this.assertContractTemplateExclusive({ + id, + parentGroupId: dto.parentGroupId ?? existing.parentGroupId, + }); + } const { wagonTypeIds, itemsPerWagonMap, diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index b103dead4..e119c0fe2 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -1249,6 +1249,28 @@ export const GRANULAR_SPLIT_PERMISSIONS: FreightPermissionSeed[] = [ "edr_freight_app:settings:contract_templates:manage", "Edit contract templates & articles", ), + // Granular split of contract-template access. `view` opens the sidebar page; + // `read` is API-read-only for other pages that display template data. + perm( + "b4e00001-0001-4000-8000-000000000003", + "edr_freight_app:settings:contract_templates:create", + "Create bulk contract templates", + ), + perm( + "b4e00001-0001-4000-8000-000000000004", + "edr_freight_app:settings:contract_templates:update", + "Update contract templates & articles", + ), + perm( + "b4e00001-0001-4000-8000-000000000005", + "edr_freight_app:settings:contract_templates:delete", + "Delete bulk contract templates", + ), + perm( + "b4e00001-0001-4000-8000-000000000006", + "edr_freight_app:settings:contract_templates:read", + "Read contract template data (API only)", + ), ]; // N. Previously-ungated staff surfaces (support inbox, procurement, compliance, @@ -1670,6 +1692,10 @@ export const FREIGHT_PERMS = { contractTemplates: { view: "edr_freight_app:settings:contract_templates:view", manage: "edr_freight_app:settings:contract_templates:manage", + create: "edr_freight_app:settings:contract_templates:create", + update: "edr_freight_app:settings:contract_templates:update", + delete: "edr_freight_app:settings:contract_templates:delete", + read: "edr_freight_app:settings:contract_templates:read", }, }, audit: { diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 6fd666a8d..15aa1987f 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -590,7 +590,11 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ label: "Contract templates", href: "/dashboard/contract-templates", icon: , - permission: FREIGHT_PERMS.admin, + // `view` opens the page; `read` alone is API-only and shows no menu. + permission: [ + FREIGHT_PERMS.settings.contractTemplates.view, + FREIGHT_PERMS.admin, + ], }, { label: "Audit logs", @@ -1470,7 +1474,12 @@ const App = () => { + } @@ -1478,7 +1487,12 @@ const App = () => { + } diff --git a/apps/edr-freight-web/backoffice/src/hooks/contract-templates/useContractTemplates.ts b/apps/edr-freight-web/backoffice/src/hooks/contract-templates/useContractTemplates.ts index 7e01d883c..0d7d78b56 100644 --- a/apps/edr-freight-web/backoffice/src/hooks/contract-templates/useContractTemplates.ts +++ b/apps/edr-freight-web/backoffice/src/hooks/contract-templates/useContractTemplates.ts @@ -4,6 +4,7 @@ import toast from "react-hot-toast"; import { contractTemplatesService, type ArticlePayload, + type CreateContractTemplatePayload, type UpdateContractTemplatePayload, } from "@/services/contract-templates.service"; @@ -59,6 +60,21 @@ function useTemplateMutation( }); } +export function useCreateContractTemplate() { + return useTemplateMutation( + (payload: CreateContractTemplatePayload) => + contractTemplatesService.create(payload), + "Template created", + ); +} + +export function useDeleteContractTemplate() { + return useTemplateMutation( + (code: string) => contractTemplatesService.remove(code), + "Template deleted", + ); +} + export function useUpdateContractTemplate(code: string) { return useTemplateMutation( (payload: UpdateContractTemplatePayload) => diff --git a/apps/edr-freight-web/backoffice/src/lib/permissions.ts b/apps/edr-freight-web/backoffice/src/lib/permissions.ts index 368e9e74b..8e58d642d 100644 --- a/apps/edr-freight-web/backoffice/src/lib/permissions.ts +++ b/apps/edr-freight-web/backoffice/src/lib/permissions.ts @@ -317,6 +317,10 @@ export const FREIGHT_PERMS = { contractTemplates: { view: "edr_freight_app:settings:contract_templates:view", manage: "edr_freight_app:settings:contract_templates:manage", + create: "edr_freight_app:settings:contract_templates:create", + update: "edr_freight_app:settings:contract_templates:update", + delete: "edr_freight_app:settings:contract_templates:delete", + read: "edr_freight_app:settings:contract_templates:read", }, }, audit: { 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 30b31f54d..1a66bbdd5 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 @@ -1,11 +1,15 @@ -import { useState } from "react"; +import { useMemo, useState } from "react"; import { useNavigate } from "react-router-dom"; +import { useQuery } from "@tanstack/react-query"; import { Badge, Box, Button, Card, Group, + Modal, + SegmentedControl, + Select, SimpleGrid, Skeleton, Stack, @@ -19,11 +23,21 @@ import { Container, Eye, FileText, + Lock, Pencil, + Plus, + Trash2, } from "lucide-react"; +import { useAuth } from "@/auth/useAuth"; import { PageContainer, PageHeader } from "@/components/page"; -import { useContractTemplates } from "@/hooks/contract-templates/useContractTemplates"; +import { + useContractTemplates, + useCreateContractTemplate, + useDeleteContractTemplate, +} 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 TemplatePreviewModal from "./TemplatePreviewModal"; @@ -39,22 +53,18 @@ const DIRECTION_DOT: Record = { INTERCITY: "var(--mantine-color-orange-5)", }; -function templateDirection(code: ContractTemplate["code"]): string { - return code.split("_")[0]; +function isBulk(template: ContractTemplate): boolean { + return Boolean(template.cargoTypeId); } -// Codes are DIRECTION_FREIGHT_{CUSTOMS,NO_CUSTOMS}, so the freight segment is -// the second one — never the suffix. -function isBulk(code: ContractTemplate["code"]): boolean { - return code.split("_")[1] === "BULK"; -} - -// Intercity is domestic and crosses no border, so it has no customs variant at -// all — hence null rather than false, which would wrongly read as a deliberate -// "client clears its own customs" choice. -function customsVariant(code: ContractTemplate["code"]): boolean | null { - if (code.endsWith("_NO_CUSTOMS")) return false; - if (code.endsWith("_CUSTOMS")) return true; +// System container codes are DIRECTION_CONTAINER(_CUSTOMS); intercity is +// domestic and crosses no border, so it has no customs variant at all — hence +// null rather than false, which would wrongly read as a deliberate "client +// clears its own customs" choice. +function customsVariant(template: ContractTemplate): boolean | null { + if (isBulk(template)) return template.withCustoms ?? null; + if (template.code.endsWith("_NO_CUSTOMS")) return false; + if (template.code.endsWith("_CUSTOMS")) return true; return null; } @@ -66,10 +76,28 @@ function formatUpdated(value: string): string { }); } +interface CargoTypeOption { + id: string; + cargoTypeName?: string; + hasContractTemplate?: boolean; +} + export default function ContractTemplatesPage() { const navigate = useNavigate(); + const { user } = useAuth(); const { data: templates, isLoading } = useContractTemplates(); const [previewCode, setPreviewCode] = useState(null); + const [createOpen, setCreateOpen] = useState(false); + const [deleteTarget, setDeleteTarget] = useState(null); + + const perms = FREIGHT_PERMS.settings.contractTemplates; + const isAdmin = hasPermission(user, FREIGHT_PERMS.admin); + const canManage = isAdmin || hasPermission(user, perms.manage); + const canCreate = canManage || hasPermission(user, perms.create); + const canUpdate = canManage || hasPermission(user, perms.update); + const canDelete = isAdmin || hasPermission(user, perms.delete); + + const deleteTemplate = useDeleteContractTemplate(); const previewTemplate = templates?.find((t) => t.code === previewCode); @@ -77,20 +105,34 @@ export default function ContractTemplatesPage() { } + onClick={() => setCreateOpen(true)} + > + New bulk template + + ) : undefined + } /> {isLoading - ? Array.from({ length: 10 }, (_, i) => ) + ? Array.from({ length: 6 }, (_, i) => ) : (templates ?? []).map((template) => ( setPreviewCode(template.code)} onEdit={() => navigate(`/dashboard/contract-templates/${template.code}`) } + onDelete={() => setDeleteTarget(template)} /> ))} @@ -100,22 +142,174 @@ export default function ContractTemplatesPage() { title={previewTemplate ? `${previewTemplate.name} — preview` : undefined} onClose={() => setPreviewCode(null)} /> + + setCreateOpen(false)} + onCreated={(code) => { + setCreateOpen(false); + navigate(`/dashboard/contract-templates/${code}`); + }} + /> + + {/* ── Delete confirm ─────────────────────────────────────── */} + setDeleteTarget(null)} + title="Delete contract template?" + centered + size="sm" + > + + + This will delete{" "} + + {deleteTarget?.name} + {" "} + and its articles. Contracts already generated keep their frozen + document; new contracts for this combination fall back to the + generic layout until a new template is created. + + + + + + + ); } +/** + * Staff pick the customs option first, then a bulk cargo type that has + * "has contract template" enabled. One template per combination — the API + * rejects duplicates, so an existing pairing must be edited instead. + */ +function CreateTemplateModal({ + opened, + onClose, + onCreated, +}: { + opened: boolean; + onClose: () => void; + onCreated: (code: string) => void; +}) { + const [withCustoms, setWithCustoms] = useState("true"); + const [cargoTypeId, setCargoTypeId] = useState(null); + const create = useCreateContractTemplate(); + + const { data: cargoTypes, isLoading } = useQuery({ + queryKey: ["cargo-types", "contract-template-options"], + queryFn: () => cargoTypesService.getCargoTypes(), + enabled: opened, + }); + + const options = useMemo( + () => + ((cargoTypes ?? []) as CargoTypeOption[]) + .filter((cargoType) => cargoType.hasContractTemplate) + .map((cargoType) => ({ + value: cargoType.id, + label: cargoType.cargoTypeName ?? "Untitled", + })), + [cargoTypes], + ); + + const close = () => { + setCargoTypeId(null); + onClose(); + }; + + return ( + + +
+ + Customs clearing + + +
+ +