feat(contracts): per-cargo bulk contract templates

This commit is contained in:
Marshal
2026-08-07 23:50:27 +00:00
parent d2eb47d14b
commit cd90ccb0f5
18 changed files with 802 additions and 107 deletions

View File

@@ -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
? {

View File

@@ -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<void> {
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_<cargo code>_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<void> {
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
`);
}
}

View File

@@ -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,

View File

@@ -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<ContractTemplate> {
@@ -17,12 +15,51 @@ export class ContractTemplatesRepository extends BaseRepository<ContractTemplate
super(repository);
}
findByCode(code: ContractTemplateCode): Promise<ContractTemplate | null> {
findByCode(code: string): Promise<ContractTemplate | null> {
return this.repository.findOne({ where: { code } });
}
override findAll(): Promise<ContractTemplate[]> {
return this.repository.find({ order: { code: "ASC" } });
return this.repository.find({
relations: { cargoType: true },
order: { code: "ASC" },
});
}
findByCargoCombo(
cargoTypeId: string,
withCustoms: boolean,
): Promise<ContractTemplate | null> {
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<ContractTemplate | null> {
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<CargoType | null> {
return this.repository.manager
.getRepository(CargoType)
.findOne({ where: { id } });
}
async saveTemplate(template: ContractTemplate): Promise<ContractTemplate> {

View File

@@ -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<ContractTemplate[]> {
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<ContractTemplate> {
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<ContractTemplate> {
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<void> {
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<ContractTemplate | null> {
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));
}

View File

@@ -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()

View File

@@ -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_<cargo code>_(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;
}

View File

@@ -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 {

View File

@@ -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()

View File

@@ -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;

View File

@@ -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<void> {
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<CargoType> {
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,

View File

@@ -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: {

View File

@@ -590,7 +590,11 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
label: "Contract templates",
href: "/dashboard/contract-templates",
icon: <ScrollText />,
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 = () => {
<Route
path="contract-templates"
element={
<RequirePermission permission={FREIGHT_PERMS.admin}>
<RequirePermission
permission={[
FREIGHT_PERMS.settings.contractTemplates.view,
FREIGHT_PERMS.admin,
]}
>
<ContractTemplatesPage />
</RequirePermission>
}
@@ -1478,7 +1487,12 @@ const App = () => {
<Route
path="contract-templates/:code"
element={
<RequirePermission permission={FREIGHT_PERMS.admin}>
<RequirePermission
permission={[
FREIGHT_PERMS.settings.contractTemplates.view,
FREIGHT_PERMS.admin,
]}
>
<ContractTemplateEditorPage />
</RequirePermission>
}

View File

@@ -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<TVariables>(
});
}
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) =>

View File

@@ -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: {

View File

@@ -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<string, string> = {
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<string | null>(null);
const [createOpen, setCreateOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<ContractTemplate | null>(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() {
<PageContainer>
<PageHeader
title="Contract templates"
subtitle="The ten contract documents generated when a contract is approved — one per trade direction, freight type, and customs-clearing option. Intercity is domestic, so it has no customs variant. Articles are fully editable."
subtitle="The five container contract documents are built in — one per trade direction and customs-clearing option. Bulk contracts are written per cargo type: create one template per commodity and customs option. Articles are fully editable."
action={
canCreate ? (
<Button
color="edr-green"
leftSection={<Plus size={16} />}
onClick={() => setCreateOpen(true)}
>
New bulk template
</Button>
) : undefined
}
/>
<SimpleGrid cols={{ base: 1, md: 2, xl: 3 }} spacing="lg">
{isLoading
? Array.from({ length: 10 }, (_, i) => <TemplateCardSkeleton key={i} />)
? Array.from({ length: 6 }, (_, i) => <TemplateCardSkeleton key={i} />)
: (templates ?? []).map((template) => (
<TemplateCard
key={template.code}
template={template}
canUpdate={canUpdate}
canDelete={canDelete}
onPreview={() => setPreviewCode(template.code)}
onEdit={() =>
navigate(`/dashboard/contract-templates/${template.code}`)
}
onDelete={() => setDeleteTarget(template)}
/>
))}
</SimpleGrid>
@@ -100,22 +142,174 @@ export default function ContractTemplatesPage() {
title={previewTemplate ? `${previewTemplate.name} — preview` : undefined}
onClose={() => setPreviewCode(null)}
/>
<CreateTemplateModal
opened={createOpen}
onClose={() => setCreateOpen(false)}
onCreated={(code) => {
setCreateOpen(false);
navigate(`/dashboard/contract-templates/${code}`);
}}
/>
{/* ── Delete confirm ─────────────────────────────────────── */}
<Modal
opened={Boolean(deleteTarget)}
onClose={() => setDeleteTarget(null)}
title="Delete contract template?"
centered
size="sm"
>
<Stack gap="md">
<Text size="sm">
This will delete{" "}
<Text span fw={600}>
{deleteTarget?.name}
</Text>{" "}
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.
</Text>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={() => setDeleteTarget(null)}>
Cancel
</Button>
<Button
color="red"
loading={deleteTemplate.isPending}
onClick={() => {
if (!deleteTarget) return;
deleteTemplate.mutate(deleteTarget.code, {
onSuccess: () => setDeleteTarget(null),
});
}}
>
Delete
</Button>
</Group>
</Stack>
</Modal>
</PageContainer>
);
}
/**
* 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<string>("true");
const [cargoTypeId, setCargoTypeId] = useState<string | null>(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 (
<Modal opened={opened} onClose={close} title="New bulk contract template" centered>
<Stack gap="md">
<div>
<Text size="sm" fw={500} mb={6}>
Customs clearing
</Text>
<SegmentedControl
fullWidth
value={withCustoms}
onChange={setWithCustoms}
data={[
{ value: "true", label: "With customs clearing" },
{ value: "false", label: "Without customs clearing" },
]}
/>
</div>
<Select
label="Bulk cargo type"
description="Only cargo types with “has contract template” enabled are listed"
placeholder={isLoading ? "Loading…" : "Select a cargo type"}
data={options}
value={cargoTypeId}
onChange={setCargoTypeId}
searchable
nothingFoundMessage="No cargo type allows contract templates yet — enable the flag on the cargo type first"
/>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={close}>
Cancel
</Button>
<Button
color="edr-green"
disabled={!cargoTypeId}
loading={create.isPending}
onClick={() => {
if (!cargoTypeId) return;
create.mutate(
{ cargoTypeId, withCustoms: withCustoms === "true" },
{
onSuccess: (template) =>
onCreated((template as ContractTemplate).code),
},
);
}}
>
Create template
</Button>
</Group>
</Stack>
</Modal>
);
}
function TemplateCard({
template,
canUpdate,
canDelete,
onPreview,
onEdit,
onDelete,
}: {
template: ContractTemplate;
canUpdate: boolean;
canDelete: boolean;
onPreview: () => void;
onEdit: () => void;
onDelete: () => void;
}) {
const direction = templateDirection(template.code);
const bulk = isBulk(template.code);
const customs = customsVariant(template.code);
const bulk = isBulk(template);
const direction = template.code.split("_")[0];
const customs = customsVariant(template);
const kicker = bulk
? `${template.cargoType?.cargoTypeName ?? "Bulk cargo"} · Bulk`
: `${DIRECTION_LABEL[direction] ?? direction} · Container`;
return (
<Card
@@ -142,12 +336,13 @@ function TemplateCard({
style={{
borderRadius: 999,
flexShrink: 0,
background: DIRECTION_DOT[direction] ?? "var(--mantine-color-gray-5)",
background: bulk
? "var(--mantine-color-teal-5)"
: DIRECTION_DOT[direction] ?? "var(--mantine-color-gray-5)",
}}
/>
<Text size="xs" fw={600} tt="uppercase" lts="0.06em" c="dimmed">
{DIRECTION_LABEL[direction] ?? direction} ·{" "}
{bulk ? "Bulk" : "Container"}
{kicker}
</Text>
</Group>
</Group>
@@ -166,6 +361,18 @@ function TemplateCard({
</Badge>
</Tooltip>
)}
{template.isSystem && (
<Tooltip label="Built-in template — cannot be deleted" withArrow>
<Badge
size="sm"
variant="light"
color="gray"
leftSection={<Lock size={11} />}
>
System
</Badge>
</Tooltip>
)}
{!template.isActive && (
<Tooltip label="Not used for new contracts" withArrow>
<Badge size="sm" variant="light" color="red">
@@ -221,16 +428,34 @@ function TemplateCard({
>
Preview
</Button>
<Button
variant="light"
color="edr-green"
size="compact-sm"
radius="md"
leftSection={<Pencil size={14} />}
onClick={onEdit}
>
Edit articles
</Button>
<Group gap={6} wrap="nowrap">
{canDelete && !template.isSystem && (
<Tooltip label="Delete template" withArrow>
<Button
variant="subtle"
color="red"
size="compact-sm"
radius="md"
px={8}
onClick={onDelete}
>
<Trash2 size={14} />
</Button>
</Tooltip>
)}
{canUpdate && (
<Button
variant="light"
color="edr-green"
size="compact-sm"
radius="md"
leftSection={<Pencil size={14} />}
onClick={onEdit}
>
Edit articles
</Button>
)}
</Group>
</Group>
</Box>
</Card>

View File

@@ -54,6 +54,8 @@ interface CargoNode extends RuleEngineRecord {
requiresDirectorApproval?: boolean;
/** When true, bookings of this cargo type incur the flat LASHING surcharge. */
hasLashing?: boolean;
/** Staff may write bulk contract templates for this cargo type (parent XOR children). */
hasContractTemplate?: boolean;
/** How this cargo is measured (PER_TON / PER_ITEM); null for groups/unset. */
unitOfMeasure?: string | null;
/** Wagon types that can carry this bulk cargo during scheduling; empty if unset. */
@@ -111,6 +113,10 @@ const FORM_FIELDS: FormFieldDef[] = [
// When on, every booking of this cargo type is charged the flat LASHING
// surcharge (a rate with trigger = Lashing).
{ name: "hasLashing", label: "Charge lashing fee", type: "boolean" },
// Lets staff write bulk contract templates for this cargo type. The API
// rejects the save when the parent group (or a child) already has it on —
// the template must live on exactly one level.
{ name: "hasContractTemplate", label: "Has contract template", type: "boolean" },
{ name: "isActive", label: "Active", type: "boolean" },
];
@@ -575,6 +581,13 @@ function CargoRow({
</Badge>
</Tooltip>
) : null}
{node.hasContractTemplate ? (
<Tooltip label="Bulk contract templates are written for this cargo type" withArrow>
<Badge size="xs" variant="light" color="grape" radius="sm">
Contract
</Badge>
</Tooltip>
) : null}
{node.unitOfMeasure ? (
<Tooltip label="How bookings measure this cargo" withArrow>
<Badge size="xs" variant="light" color="teal" radius="sm">

View File

@@ -11,29 +11,33 @@ export interface ContractTemplateArticle {
export interface ContractTemplate {
id: string;
// Import/export split by customs clearing; intercity is domestic, crosses no
// border, and so has a single template.
code:
| "IMPORT_BULK_CUSTOMS"
| "IMPORT_BULK_NO_CUSTOMS"
| "EXPORT_BULK_CUSTOMS"
| "EXPORT_BULK_NO_CUSTOMS"
| "INTERCITY_BULK"
| "IMPORT_CONTAINER_CUSTOMS"
| "IMPORT_CONTAINER_NO_CUSTOMS"
| "EXPORT_CONTAINER_CUSTOMS"
| "EXPORT_CONTAINER_NO_CUSTOMS"
| "INTERCITY_CONTAINER";
// System container templates use the fixed DIRECTION_CONTAINER(_CUSTOMS)
// codes; staff-created bulk templates get generated BULK_<cargo>_* codes.
code: string;
name: string;
description?: string | null;
documentTitle: string;
whereasClauses: string[];
articles: ContractTemplateArticle[];
isActive: boolean;
/** Bulk templates only: the cargo type this template is written for. */
cargoTypeId?: string | null;
cargoType?: { id: string; cargoTypeName: string } | null;
/** Bulk templates only: whether this is the with-customs-clearing variant. */
withCustoms?: boolean | null;
/** The five seeded container templates — cannot be deleted. */
isSystem: boolean;
createdAt: string;
updatedAt: string;
}
export interface CreateContractTemplatePayload {
cargoTypeId: string;
withCustoms: boolean;
name?: string;
description?: string;
}
export interface UpdateContractTemplatePayload {
name?: string;
description?: string;
@@ -54,6 +58,15 @@ export const contractTemplatesService = {
return data;
},
async create(payload: CreateContractTemplatePayload): Promise<ContractTemplate> {
const { data } = await client.post<ContractTemplate>(BASE, payload);
return data;
},
async remove(code: string): Promise<void> {
await client.delete(`${BASE}/${code}`);
},
async getByCode(code: string): Promise<ContractTemplate> {
const { data } = await client.get<ContractTemplate>(`${BASE}/${code}`);
return data;