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

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