mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 19:00:55 +00:00
make a contrat template
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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()}`);
|
||||
});
|
||||
});
|
||||
@@ -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 }));
|
||||
}
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user