make a contrat template

This commit is contained in:
Marshal
2026-07-09 21:13:07 +00:00
parent e4429592d3
commit 3443b79644
26 changed files with 3243 additions and 41 deletions

View File

@@ -0,0 +1,97 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
Put,
} from "@nestjs/common";
import { ApiOperation, ApiTags } from "@nestjs/swagger";
import { FreightAdmin } from "../../common/booking-guards";
import { ContractTemplatesService } from "./contract-templates.service";
import {
CreateArticleDto,
PreviewContractTemplateDto,
ReplaceArticlesDto,
UpdateArticleDto,
UpdateContractTemplateDto,
} from "./dto/contract-template.dto";
@ApiTags("contract-templates")
@Controller("contract-templates")
export class ContractTemplatesController {
constructor(private readonly service: ContractTemplatesService) {}
// Reads stay open to authenticated staff (the backoffice Templates tab);
// writes are admin-guarded like other freight configuration resources.
@Get()
@ApiOperation({ summary: "List the six contract document templates" })
list() {
return this.service.list();
}
@Get(":code")
@ApiOperation({ summary: "Get one contract template by code" })
getByCode(@Param("code") code: string) {
return this.service.getByCode(code);
}
@Patch(":code")
@FreightAdmin()
@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")
@ApiOperation({
summary: "Render an HTML preview of the template against mock contract data",
})
preview(
@Param("code") code: string,
@Body() dto: PreviewContractTemplateDto,
) {
return this.service.preview(code, dto);
}
/* ------------------------- article routes ------------------------- */
@Put(":code/articles")
@FreightAdmin()
@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")
@FreightAdmin()
@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")
@FreightAdmin()
@ApiOperation({ summary: "Update an article's title or body" })
updateArticle(
@Param("code") code: string,
@Param("articleId") articleId: string,
@Body() dto: UpdateArticleDto,
) {
return this.service.updateArticle(code, articleId, dto);
}
@Delete(":code/articles/:articleId")
@FreightAdmin()
@ApiOperation({ summary: "Remove an article from the template" })
removeArticle(
@Param("code") code: string,
@Param("articleId") articleId: string,
) {
return this.service.removeArticle(code, articleId);
}
}

View File

@@ -0,0 +1,21 @@
import { Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import { ContractRendererService } from "../../contracts/contract-renderer.service";
import { ContractTemplatesController } from "./contract-templates.controller";
import { ContractTemplatesRepository } from "./contract-templates.repository";
import { ContractTemplatesService } from "./contract-templates.service";
import { ContractTemplate } from "./entities/contract-template.entity";
@Module({
imports: [TypeOrmModule.forFeature([ContractTemplate])],
controllers: [ContractTemplatesController],
providers: [
ContractTemplatesRepository,
ContractTemplatesService,
// Stateless Handlebars renderer reused from src/contracts for previews.
ContractRendererService,
],
exports: [ContractTemplatesService],
})
export class ContractTemplatesModule {}

View File

@@ -0,0 +1,31 @@
import { BaseRepository } from "@edr/api-common";
import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm";
import {
ContractTemplate,
ContractTemplateCode,
} from "./entities/contract-template.entity";
@Injectable()
export class ContractTemplatesRepository extends BaseRepository<ContractTemplate> {
constructor(
@InjectRepository(ContractTemplate)
repository: Repository<ContractTemplate>,
) {
super(repository);
}
findByCode(code: ContractTemplateCode): Promise<ContractTemplate | null> {
return this.repository.findOne({ where: { code } });
}
override findAll(): Promise<ContractTemplate[]> {
return this.repository.find({ order: { code: "ASC" } });
}
async saveTemplate(template: ContractTemplate): Promise<ContractTemplate> {
return this.repository.save(template);
}
}

View File

@@ -0,0 +1,66 @@
import { ContractRendererService } from "../../contracts/contract-renderer.service";
import { CONTRACT_TEMPLATE_DEFAULTS } from "../../seed/data/contract-template-defaults";
import { ContractTemplatesService } from "./contract-templates.service";
import { ContractTemplatesRepository } from "./contract-templates.repository";
import {
ContractTemplate,
contractTemplateCodeFor,
} from "./entities/contract-template.entity";
function seededTemplate(code: string): ContractTemplate {
const seed = CONTRACT_TEMPLATE_DEFAULTS.find((t) => t.code === code)!;
return {
id: "00000000-0000-0000-0000-000000000001",
code: seed.code,
name: seed.name,
description: seed.description,
documentTitle: seed.documentTitle,
whereasClauses: seed.whereasClauses,
articles: seed.articles.map((article, index) => ({ ...article, order: index + 1 })),
isActive: true,
createdAt: new Date(),
updatedAt: new Date(),
deletedAt: null,
} as ContractTemplate;
}
describe("contractTemplateCodeFor", () => {
it("maps every direction/freight pair to one of the six codes", () => {
expect(contractTemplateCodeFor("IMPORT", "BULK")).toBe("IMPORT_BULK");
expect(contractTemplateCodeFor("EXPORT", "CONTAINER")).toBe("EXPORT_CONTAINER");
expect(contractTemplateCodeFor("DOMESTIC", "CONTAINER")).toBe("INTERCITY_CONTAINER");
expect(contractTemplateCodeFor("DOMESTIC", "BULK")).toBe("INTERCITY_BULK");
expect(contractTemplateCodeFor(null, null)).toBe("INTERCITY_CONTAINER");
});
});
describe("ContractTemplatesService.preview", () => {
const renderer = new ContractRendererService();
renderer.onModuleInit();
const repository = {
findByCode: jest.fn((code: string) => Promise.resolve(seededTemplate(code))),
} as unknown as ContractTemplatesRepository;
const service = new ContractTemplatesService(repository, renderer);
it.each(CONTRACT_TEMPLATE_DEFAULTS.map((t) => [t.code] as const))(
"renders a complete mock preview for %s",
async (code) => {
const { html } = await service.preview(code);
expect(html).toContain("Article 1");
expect(html).toContain("Article 13");
expect(html).toContain("Abyssinia Trading PLC");
expect(html).toContain("Annex A — Commercial Schedule");
// No unrendered handlebars placeholders may leak into the document.
expect(html).not.toContain("{{");
// Greenish theme applied.
expect(html).toContain("#1b9e7a");
},
);
it("interpolates {{contractYear}} inside seeded article bodies", async () => {
const { html } = await service.preview("IMPORT_BULK");
expect(html).toContain(`August 31, ${new Date().getFullYear()}`);
});
});

View File

@@ -0,0 +1,276 @@
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
import { randomUUID } from "node:crypto";
import { ContractRendererService } from "../../contracts/contract-renderer.service";
import { getTemplateMeta } from "../../contracts/contract-template.registry";
import {
ContractDynamicTemplateView,
ContractViewModel,
} from "../../contracts/contract-view-model.builder";
import { ContractTemplatesRepository } from "./contract-templates.repository";
import {
CreateArticleDto,
PreviewContractTemplateDto,
ReplaceArticleDto,
UpdateArticleDto,
UpdateContractTemplateDto,
} from "./dto/contract-template.dto";
import {
CONTRACT_TEMPLATE_CODES,
ContractTemplate,
ContractTemplateArticle,
ContractTemplateCode,
contractTemplateCodeFor,
} from "./entities/contract-template.entity";
/** Registry keys used to derive labels for the mock preview per template code. */
const PREVIEW_TEMPLATE_KEYS: Record<ContractTemplateCode, string> = {
IMPORT_BULK: "IMP_BULK_USD_FORWARDING",
EXPORT_BULK: "EXP_BULK_USD_TRANSPORT_ONLY",
INTERCITY_BULK: "DOM_BULK_USD_TRANSPORT_ONLY",
IMPORT_CONTAINER: "IMP_CON_USD_TRANSPORT_ONLY",
EXPORT_CONTAINER: "EXP_CON_USD_FORWARDING",
INTERCITY_CONTAINER: "DOM_CON_USD_TRANSPORT_ONLY",
};
@Injectable()
export class ContractTemplatesService {
constructor(
private readonly repository: ContractTemplatesRepository,
private readonly renderer: ContractRendererService,
) {}
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),
);
}
async getByCode(code: string): Promise<ContractTemplate> {
const template = await this.repository.findByCode(this.assertCode(code));
if (!template) {
throw new NotFoundException(`Contract template ${code} not found`);
}
return template;
}
/**
* The active template used when generating a contract document for the given
* direction/freight pair; null when missing or deactivated (the renderer then
* falls back to the built-in generic layout).
*/
async findActiveForContract(
tradeDirection?: string | null,
freightType?: string | null,
): Promise<ContractTemplate | null> {
const code = contractTemplateCodeFor(tradeDirection, freightType);
const template = await this.repository.findByCode(code);
return template?.isActive ? template : null;
}
async update(code: string, dto: UpdateContractTemplateDto): Promise<ContractTemplate> {
const template = await this.getByCode(code);
if (dto.name !== undefined) template.name = dto.name;
if (dto.description !== undefined) template.description = dto.description;
if (dto.documentTitle !== undefined) template.documentTitle = dto.documentTitle;
if (dto.whereasClauses !== undefined) template.whereasClauses = dto.whereasClauses;
if (dto.isActive !== undefined) template.isActive = dto.isActive;
return this.repository.saveTemplate(template);
}
async addArticle(code: string, dto: CreateArticleDto): Promise<ContractTemplate> {
const template = await this.getByCode(code);
const articles = this.sorted(template.articles);
const article: ContractTemplateArticle = {
id: randomUUID(),
title: dto.title,
body: dto.body,
order: 0,
};
const index =
dto.position && dto.position <= articles.length ? dto.position - 1 : articles.length;
articles.splice(index, 0, article);
template.articles = this.renumber(articles);
return this.repository.saveTemplate(template);
}
async updateArticle(
code: string,
articleId: string,
dto: UpdateArticleDto,
): Promise<ContractTemplate> {
const template = await this.getByCode(code);
const article = template.articles.find((item) => item.id === articleId);
if (!article) {
throw new NotFoundException(`Article ${articleId} not found on template ${code}`);
}
if (dto.title !== undefined) article.title = dto.title;
if (dto.body !== undefined) article.body = dto.body;
template.articles = this.renumber(this.sorted(template.articles));
return this.repository.saveTemplate(template);
}
async removeArticle(code: string, articleId: string): Promise<ContractTemplate> {
const template = await this.getByCode(code);
const remaining = template.articles.filter((item) => item.id !== articleId);
if (remaining.length === template.articles.length) {
throw new NotFoundException(`Article ${articleId} not found on template ${code}`);
}
template.articles = this.renumber(this.sorted(remaining));
return this.repository.saveTemplate(template);
}
/** Replace the full ordered article list (also how the editor reorders). */
async replaceArticles(
code: string,
articles: ReplaceArticleDto[],
): Promise<ContractTemplate> {
const template = await this.getByCode(code);
template.articles = this.renumber(
articles.map((item) => ({
id: item.id ?? randomUUID(),
title: item.title,
body: item.body,
order: 0,
})),
);
return this.repository.saveTemplate(template);
}
/**
* Render the template against a representative mock contract so admins can
* see the final document without touching a real contract. Draft overrides
* allow previewing unsaved editor state.
*/
async preview(
code: string,
overrides?: PreviewContractTemplateDto,
): Promise<{ html: string }> {
const template = await this.getByCode(code);
const dynamicTemplate: ContractDynamicTemplateView = {
code: template.code,
name: overrides?.name ?? template.name,
documentTitle: overrides?.documentTitle ?? template.documentTitle,
whereasClauses: overrides?.whereasClauses ?? template.whereasClauses,
articles: overrides?.articles
? overrides.articles.map((item, index) => ({
id: item.id ?? randomUUID(),
title: item.title,
body: item.body,
order: index + 1,
}))
: this.sorted(template.articles),
};
const view = this.buildMockView(template.code, dynamicTemplate);
return { html: this.renderer.render(view) };
}
private buildMockView(
code: ContractTemplateCode,
dynamicTemplate: ContractDynamicTemplateView,
): ContractViewModel {
const meta = getTemplateMeta(PREVIEW_TEMPLATE_KEYS[code]);
const isBulk = code.endsWith("_BULK");
const now = new Date();
const unitRates = isBulk
? [
{ label: "Rail transport — per metric ton", unitPrice: 59.4, unit: "ton", currency: "USD" },
{ label: "Origin handling and documentation", unitPrice: 18, unit: "ton", currency: "USD" },
{ label: "Lashing material (when provided by EDR)", unitPrice: 150, unit: "unit", currency: "USD" },
]
: [
{ label: "Rail transport — 40ft container", unitPrice: 1916, unit: "container", currency: "USD" },
{ label: "Rail transport — 2 × 20ft containers", unitPrice: 1944, unit: "container", currency: "USD" },
{ label: "Excess tonnage surcharge", unitPrice: 10, unit: "ton", currency: "USD" },
];
return {
bookingId: "00000000-0000-0000-0000-000000000000",
reference: "EDR/CT/2026/0042",
status: "CONTRACT_READY",
templateKey: PREVIEW_TEMPLATE_KEYS[code],
template: { ...meta, title: dynamicTemplate.name, templateFile: "edr-dynamic.hbs" },
contractDate: now.toLocaleDateString("en-GB", {
day: "numeric",
month: "long",
year: "numeric",
}),
contractYear: now.getFullYear(),
client: {
companyName: "Abyssinia Trading PLC",
companyAddress: "Bole Sub-city, Woreda 03, H.No 1234, Addis Ababa",
companyLocation: "Ethiopia",
phone: "+251 91 123 4567",
email: "logistics@abyssiniatrading.et",
tinNumber: "0011223344",
vatNumber: "VAT-556677",
fanNumber: "FAN-889900",
businessLicense: "BL/AA/12/345678",
},
provider: {
name: "Ethio-Djibouti Standard Gauge Railway Share Company",
address: "Nifas Silk Lafto Sub City, Addis Ababa, Ethiopia",
phone: "+251 11 872 0000",
email: "info@edr.gov.et",
tinNumber: "—",
},
schedule: {
originLabel: isBulk ? "Nagad Railway Station" : "SGTD Freight Station",
destinationLabel: "Galaan Multipurpose Port (GMP)",
tradeDirection: code.startsWith("IMPORT")
? "IMPORT"
: code.startsWith("EXPORT")
? "EXPORT"
: "DOMESTIC",
freightType: isBulk ? "BULK" : "CONTAINER",
serviceType: "Rail transport and customs clearance",
scheduledDate: "—",
contractType: "GENERAL",
cargoDescription: isBulk ? "Steel billets — 2,800 MT" : "40ft containers — FMCG cargo",
totalWeightVgm: "—",
equipmentReturn: isBulk ? "—" : "With empty return",
hazardousLabel: "No",
firstMilePickupAddress: "—",
lastMileDeliveryAddress: "—",
},
pricing: {
displayMode: "UNIT_RATES",
unitRates,
currency: "USD",
equipmentReturn: isBulk ? "—" : "With empty return",
originLabel: isBulk ? "Nagad Railway Station" : "SGTD Freight Station",
destinationLabel: "Galaan Multipurpose Port (GMP)",
} as unknown as ContractViewModel["pricing"],
signatures: [],
canSignCustomer: false,
canSignStaff: false,
hasContractDocument: false,
hasCustomerSignature: false,
hasStaffSignature: false,
dynamicTemplate,
};
}
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));
}
private renumber(articles: ContractTemplateArticle[]): ContractTemplateArticle[] {
return articles.map((article, index) => ({ ...article, order: index + 1 }));
}
}

View File

@@ -0,0 +1,134 @@
import { ApiPropertyOptional, ApiProperty } from "@nestjs/swagger";
import { Type } from "class-transformer";
import {
IsArray,
IsBoolean,
IsInt,
IsOptional,
IsString,
MaxLength,
Min,
MinLength,
ValidateNested,
} from "class-validator";
export class UpdateContractTemplateDto {
@ApiPropertyOptional({ description: "Display name of the template" })
@IsOptional()
@IsString()
@MinLength(3)
@MaxLength(200)
name?: string;
@ApiPropertyOptional({ description: "Short description shown on the template card" })
@IsOptional()
@IsString()
description?: string;
@ApiPropertyOptional({ description: "Cover-page service title of the generated document" })
@IsOptional()
@IsString()
@MinLength(3)
@MaxLength(300)
documentTitle?: string;
@ApiPropertyOptional({ description: "WHEREAS recitals", type: [String] })
@IsOptional()
@IsArray()
@IsString({ each: true })
whereasClauses?: string[];
@ApiPropertyOptional({ description: "Whether the template is used for generation" })
@IsOptional()
@IsBoolean()
isActive?: boolean;
}
export class CreateArticleDto {
@ApiProperty({ description: "Article heading (without the Article N prefix)" })
@IsString()
@MinLength(2)
@MaxLength(200)
title!: string;
@ApiProperty({
description:
'Article body. One clause per line; prefix a line with "- " to nest it as a bullet under the previous clause.',
})
@IsString()
@MinLength(2)
body!: string;
@ApiPropertyOptional({ description: "1-based position to insert at (appends when omitted)" })
@IsOptional()
@IsInt()
@Min(1)
position?: number;
}
export class UpdateArticleDto {
@ApiPropertyOptional()
@IsOptional()
@IsString()
@MinLength(2)
@MaxLength(200)
title?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
@MinLength(2)
body?: string;
}
export class ReplaceArticleDto {
@ApiPropertyOptional({ description: "Existing article id (new id assigned when omitted)" })
@IsOptional()
@IsString()
id?: string;
@ApiProperty()
@IsString()
@MinLength(2)
@MaxLength(200)
title!: string;
@ApiProperty()
@IsString()
@MinLength(2)
body!: string;
}
export class ReplaceArticlesDto {
@ApiProperty({ type: [ReplaceArticleDto], description: "Full ordered article list" })
@IsArray()
@ValidateNested({ each: true })
@Type(() => ReplaceArticleDto)
articles!: ReplaceArticleDto[];
}
/** Optional draft overrides so the editor can preview unsaved changes. */
export class PreviewContractTemplateDto {
@ApiPropertyOptional()
@IsOptional()
@IsString()
name?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
documentTitle?: string;
@ApiPropertyOptional({ type: [String] })
@IsOptional()
@IsArray()
@IsString({ each: true })
whereasClauses?: string[];
@ApiPropertyOptional({ type: [ReplaceArticleDto] })
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => ReplaceArticleDto)
articles?: ReplaceArticleDto[];
}

View File

@@ -0,0 +1,77 @@
import { BaseEntity } from "@edr/api-common";
import { Column, Entity, Index } from "typeorm";
/**
* The six canonical contract document templates, one per
* (trade direction × freight type) combination. Contracts store DOMESTIC for
* intercity movements; the template layer labels those INTERCITY to match the
* commercial vocabulary used on the printed documents.
*/
export const CONTRACT_TEMPLATE_CODES = [
"IMPORT_BULK",
"EXPORT_BULK",
"INTERCITY_BULK",
"IMPORT_CONTAINER",
"EXPORT_CONTAINER",
"INTERCITY_CONTAINER",
] as const;
export type ContractTemplateCode = (typeof CONTRACT_TEMPLATE_CODES)[number];
/**
* One dynamic article on a contract template. `body` is plain multiline text:
* each non-empty line renders as a numbered clause; lines prefixed with "- "
* render as bullet points nested under the preceding clause. A single-line
* body renders as an unnumbered paragraph. Handlebars placeholders (e.g.
* {{client.companyName}}, {{contractDate}}, {{contractYear}}, {{reference}})
* are interpolated against the contract view model at render time.
*/
export interface ContractTemplateArticle {
id: string;
title: string;
body: string;
order: number;
}
/** Map a contract's stored direction/freight pair onto a template code. */
export function contractTemplateCodeFor(
tradeDirection?: string | null,
freightType?: string | null,
): ContractTemplateCode {
const direction =
tradeDirection === "IMPORT"
? "IMPORT"
: tradeDirection === "EXPORT"
? "EXPORT"
: "INTERCITY";
const freight =
(freightType ?? "").toUpperCase().includes("BULK") ? "BULK" : "CONTAINER";
return `${direction}_${freight}` as ContractTemplateCode;
}
@Entity({ schema: "freight", name: "contract_templates" })
@Index(["code"], { unique: true })
export class ContractTemplate extends BaseEntity {
@Column({ name: "code", type: "varchar", length: 40, unique: true })
code!: ContractTemplateCode;
@Column({ name: "name", type: "varchar", length: 200 })
name!: string;
@Column({ name: "description", type: "text", nullable: true })
description?: string | null;
/** Cover-page service line, e.g. "Steel Billet Transportation and Customs Clearance Services". */
@Column({ name: "document_title", type: "varchar", length: 300 })
documentTitle!: string;
/** WHEREAS recitals rendered between the parties block and the articles. */
@Column({ name: "whereas_clauses", type: "jsonb", default: () => "'[]'" })
whereasClauses!: string[];
@Column({ name: "articles", type: "jsonb", default: () => "'[]'" })
articles!: ContractTemplateArticle[];
@Column({ name: "is_active", type: "boolean", default: true })
isActive!: boolean;
}

View File

@@ -350,6 +350,17 @@ export class ContractTransitionService {
const updated = await this.contractsService.findById(contractId);
if (allDone) {
this.notifier.approved(updated);
// Final approval step also generates the contract document from the
// template matching the contract's direction/freight pair. Best-effort:
// a rendering hiccup must not roll back the approval — the document can
// still be generated manually or lazily on view/download.
try {
return await this.generateContract(contractId);
} catch (err) {
this.logger.warn(
`Auto contract generation after final approval failed for ${updated.reference}: ${err}`,
);
}
}
return updated;
}

View File

@@ -16,6 +16,7 @@ import { NotificationsModule } from '../notifications/notifications.module';
import { NotificationInboxModule } from '../notification-inbox/notification-inbox.module';
import { BookingsModule } from '../bookings/bookings.module';
import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module';
import { ContractTemplatesModule } from '../contract-templates/contract-templates.module';
import { ContractsController } from './contracts.controller';
import { ContractsService } from './contracts.service';
@@ -81,6 +82,9 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum
NotificationsModule,
NotificationInboxModule,
CompaniesModule,
// Provides the admin-editable contract document templates consumed by
// ContractDocumentViewModelBuilder when rendering contract PDFs.
ContractTemplatesModule,
// BookingsModule provides BookingsRepository/BookingPricingService used by the
// contract PDF builders (they read a Booking today — see docs/new-doc.md §3.3).
forwardRef(() => BookingsModule),