mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 13:28:11 +00:00
feat: key bulk templates by trade direction
Bulk contract templates are now unique per (cargo type, trade direction, customs option) instead of (cargo type, customs option). Intercity is domestic and crosses no border, so it carries no customs variant: with_customs stays null there, enforced by a check constraint. Contract resolution already passed the trade direction through but the bulk lookup dropped it, so one template served all three directions. Container templates are unchanged — cargo_type_id is null on those rows, which keeps them out of both the new index and the constraint. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,134 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
|
||||
import { ContractTemplatesRepository } from './contract-templates.repository';
|
||||
import { ContractTemplatesService } from './contract-templates.service';
|
||||
import {
|
||||
bulkTemplateCode,
|
||||
bulkTemplateDirectionFor,
|
||||
} from './entities/contract-template.entity';
|
||||
|
||||
describe('bulkTemplateDirectionFor', () => {
|
||||
it('maps the contract-side DOMESTIC onto the template-side INTERCITY', () => {
|
||||
expect(bulkTemplateDirectionFor('DOMESTIC')).toBe('INTERCITY');
|
||||
expect(bulkTemplateDirectionFor('INTERCITY')).toBe('INTERCITY');
|
||||
expect(bulkTemplateDirectionFor(null)).toBe('INTERCITY');
|
||||
expect(bulkTemplateDirectionFor(undefined)).toBe('INTERCITY');
|
||||
expect(bulkTemplateDirectionFor('IMPORT')).toBe('IMPORT');
|
||||
expect(bulkTemplateDirectionFor('EXPORT')).toBe('EXPORT');
|
||||
});
|
||||
});
|
||||
|
||||
describe('bulkTemplateCode', () => {
|
||||
it('gives each direction/customs combination its own code', () => {
|
||||
expect(bulkTemplateCode('STEEL', 'IMPORT', true)).toBe('BULK_IMPORT_STEEL_CUSTOMS');
|
||||
expect(bulkTemplateCode('STEEL', 'IMPORT', false)).toBe(
|
||||
'BULK_IMPORT_STEEL_NO_CUSTOMS',
|
||||
);
|
||||
expect(bulkTemplateCode('STEEL', 'EXPORT', true)).toBe('BULK_EXPORT_STEEL_CUSTOMS');
|
||||
expect(bulkTemplateCode('STEEL', 'EXPORT', false)).toBe(
|
||||
'BULK_EXPORT_STEEL_NO_CUSTOMS',
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves intercity unsuffixed — it crosses no border', () => {
|
||||
expect(bulkTemplateCode('STEEL', 'INTERCITY', null)).toBe('BULK_INTERCITY_STEEL');
|
||||
});
|
||||
|
||||
it('produces 5 distinct codes per cargo type', () => {
|
||||
const codes = [
|
||||
bulkTemplateCode('STEEL', 'IMPORT', true),
|
||||
bulkTemplateCode('STEEL', 'IMPORT', false),
|
||||
bulkTemplateCode('STEEL', 'EXPORT', true),
|
||||
bulkTemplateCode('STEEL', 'EXPORT', false),
|
||||
bulkTemplateCode('STEEL', 'INTERCITY', null),
|
||||
];
|
||||
expect(new Set(codes).size).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ContractTemplatesService bulk create/resolve', () => {
|
||||
function build() {
|
||||
const repository = {
|
||||
findCargoType: jest.fn(() =>
|
||||
Promise.resolve({
|
||||
id: 'cargo-1',
|
||||
code: 'STEEL',
|
||||
cargoTypeName: 'Steel',
|
||||
hasContractTemplate: true,
|
||||
}),
|
||||
),
|
||||
findByCargoCombo: jest.fn(() => Promise.resolve(null)),
|
||||
findActiveBulkTemplate: jest.fn(() => Promise.resolve(null)),
|
||||
findByCode: jest.fn(() => Promise.resolve(null)),
|
||||
saveTemplate: jest.fn((template) => Promise.resolve(template)),
|
||||
} as unknown as ContractTemplatesRepository;
|
||||
return {
|
||||
repository,
|
||||
service: new ContractTemplatesService(repository, {} as never),
|
||||
};
|
||||
}
|
||||
|
||||
it('stores direction and customs on an import template', async () => {
|
||||
const { service } = build();
|
||||
const created = await service.create({
|
||||
cargoTypeId: 'cargo-1',
|
||||
tradeDirection: 'EXPORT',
|
||||
withCustoms: true,
|
||||
});
|
||||
expect(created.code).toBe('BULK_EXPORT_STEEL_CUSTOMS');
|
||||
expect(created.tradeDirection).toBe('EXPORT');
|
||||
expect(created.withCustoms).toBe(true);
|
||||
expect(created.documentTitle).toBe(
|
||||
'Steel Transportation and Customs Clearance Services',
|
||||
);
|
||||
});
|
||||
|
||||
it('stores a null customs flag for intercity', async () => {
|
||||
const { service } = build();
|
||||
const created = await service.create({
|
||||
cargoTypeId: 'cargo-1',
|
||||
tradeDirection: 'INTERCITY',
|
||||
});
|
||||
expect(created.code).toBe('BULK_INTERCITY_STEEL');
|
||||
expect(created.withCustoms).toBeNull();
|
||||
expect(created.documentTitle).toBe('Steel Transportation Services');
|
||||
});
|
||||
|
||||
it('rejects a customs flag on intercity', async () => {
|
||||
const { service } = build();
|
||||
await expect(
|
||||
service.create({
|
||||
cargoTypeId: 'cargo-1',
|
||||
tradeDirection: 'INTERCITY',
|
||||
withCustoms: false,
|
||||
}),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('requires a customs flag on import and export', async () => {
|
||||
const { service } = build();
|
||||
await expect(
|
||||
service.create({ cargoTypeId: 'cargo-1', tradeDirection: 'IMPORT' }),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('resolves a domestic bulk contract to the intercity template, ignoring its customs flag', async () => {
|
||||
const { repository, service } = build();
|
||||
await service.findActiveForContract('DOMESTIC', 'BULK', true, 'cargo-1');
|
||||
expect(repository.findActiveBulkTemplate).toHaveBeenCalledWith(
|
||||
'cargo-1',
|
||||
'INTERCITY',
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
it('resolves an import bulk contract on direction and customs', async () => {
|
||||
const { repository, service } = build();
|
||||
await service.findActiveForContract('IMPORT', 'BULK', false, 'cargo-1');
|
||||
expect(repository.findActiveBulkTemplate).toHaveBeenCalledWith(
|
||||
'cargo-1',
|
||||
'IMPORT',
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -61,7 +61,7 @@ export class ContractTemplatesController {
|
||||
])
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Create a bulk contract template for a (cargo type, customs option) pair",
|
||||
"Create a bulk contract template for a (cargo type, trade direction, customs option) combination",
|
||||
})
|
||||
create(@Body() dto: CreateContractTemplateDto) {
|
||||
return this.service.create(dto);
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { BaseRepository } from "@edr/api-common";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { InjectRepository } from "@nestjs/typeorm";
|
||||
import { Repository } from "typeorm";
|
||||
import { IsNull, Repository } from "typeorm";
|
||||
|
||||
import { CargoType } from "../rule-engine/entities/cargo-type.entity";
|
||||
import { ContractTemplate } from "./entities/contract-template.entity";
|
||||
import {
|
||||
BulkTemplateDirection,
|
||||
ContractTemplate,
|
||||
} from "./entities/contract-template.entity";
|
||||
|
||||
@Injectable()
|
||||
export class ContractTemplatesRepository extends BaseRepository<ContractTemplate> {
|
||||
@@ -28,24 +31,35 @@ export class ContractTemplatesRepository extends BaseRepository<ContractTemplate
|
||||
|
||||
findByCargoCombo(
|
||||
cargoTypeId: string,
|
||||
withCustoms: boolean,
|
||||
tradeDirection: BulkTemplateDirection,
|
||||
withCustoms: boolean | null,
|
||||
): Promise<ContractTemplate | null> {
|
||||
return this.repository.findOne({ where: { cargoTypeId, withCustoms } });
|
||||
return this.repository.findOne({
|
||||
where: { cargoTypeId, tradeDirection, withCustoms: withCustoms ?? IsNull() },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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).
|
||||
* exclusive, so at most one row matches). Intercity templates carry no
|
||||
* customs variant, so they are matched on a null flag.
|
||||
*/
|
||||
findActiveBulkTemplate(
|
||||
cargoTypeId: string,
|
||||
withCustoms: boolean,
|
||||
tradeDirection: BulkTemplateDirection,
|
||||
withCustoms: boolean | null,
|
||||
): Promise<ContractTemplate | null> {
|
||||
return this.repository
|
||||
.createQueryBuilder("t")
|
||||
.where("t.is_active = true")
|
||||
.andWhere("t.with_customs = :withCustoms", { withCustoms })
|
||||
.andWhere("t.trade_direction = :tradeDirection", { tradeDirection })
|
||||
.andWhere(
|
||||
withCustoms === null
|
||||
? "t.with_customs IS NULL"
|
||||
: "t.with_customs = :withCustoms",
|
||||
withCustoms === null ? {} : { withCustoms },
|
||||
)
|
||||
.andWhere(
|
||||
`(t.cargo_type_id = :cargoTypeId OR t.cargo_type_id = (
|
||||
SELECT c.parent_group_id FROM freight.cargo_types c
|
||||
|
||||
@@ -23,6 +23,9 @@ import {
|
||||
UpdateContractTemplateDto,
|
||||
} from "./dto/contract-template.dto";
|
||||
import {
|
||||
BulkTemplateDirection,
|
||||
bulkTemplateCode,
|
||||
bulkTemplateDirectionFor,
|
||||
CONTRACT_TEMPLATE_CODES,
|
||||
ContractTemplate,
|
||||
ContractTemplateArticle,
|
||||
@@ -77,10 +80,13 @@ export class ContractTemplatesService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Staff-created bulk template for one (cargo type, direction, customs
|
||||
* option) triple. The cargo type must have hasContractTemplate enabled and
|
||||
* the combination must not already exist — the same commodity + direction +
|
||||
* customs pairing is edited, never duplicated.
|
||||
*
|
||||
* Intercity is domestic and crosses no border, so it carries no customs
|
||||
* variant: the flag must be omitted and is stored as null.
|
||||
*/
|
||||
async create(dto: CreateContractTemplateDto): Promise<ContractTemplate> {
|
||||
const cargoType = await this.repository.findCargoType(dto.cargoTypeId);
|
||||
@@ -92,31 +98,46 @@ export class ContractTemplatesService {
|
||||
`"${cargoType.cargoTypeName}" does not allow contract templates — enable "has contract template" on the cargo type first`,
|
||||
);
|
||||
}
|
||||
const variant = dto.withCustoms ? "with" : "without";
|
||||
|
||||
const direction = dto.tradeDirection;
|
||||
const intercity = direction === "INTERCITY";
|
||||
if (intercity && dto.withCustoms !== undefined) {
|
||||
throw new BadRequestException(
|
||||
"Intercity contracts are domestic and cross no border — they have no customs clearing variant",
|
||||
);
|
||||
}
|
||||
if (!intercity && dto.withCustoms === undefined) {
|
||||
throw new BadRequestException(
|
||||
`A ${direction.toLowerCase()} template must state whether customs clearing is included`,
|
||||
);
|
||||
}
|
||||
const withCustoms = intercity ? null : Boolean(dto.withCustoms);
|
||||
|
||||
const label = this.comboLabel(cargoType.cargoTypeName, direction, withCustoms);
|
||||
const existing = await this.repository.findByCargoCombo(
|
||||
dto.cargoTypeId,
|
||||
dto.withCustoms,
|
||||
direction,
|
||||
withCustoms,
|
||||
);
|
||||
if (existing) {
|
||||
throw new ConflictException(
|
||||
`A "${cargoType.cargoTypeName}" template ${variant} customs clearing already exists — edit that template instead`,
|
||||
`${label} 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.code = bulkTemplateCode(cargoType.code, direction, withCustoms);
|
||||
template.name = dto.name ?? label;
|
||||
template.description = dto.description ?? null;
|
||||
template.documentTitle = dto.withCustoms
|
||||
template.documentTitle = 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.tradeDirection = direction;
|
||||
template.withCustoms = withCustoms;
|
||||
template.isSystem = false;
|
||||
try {
|
||||
return await this.repository.saveTemplate(template);
|
||||
@@ -124,13 +145,29 @@ export class ContractTemplatesService {
|
||||
// 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`,
|
||||
`${label} already exists — edit that template instead`,
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** Human label for one bulk combination, used for names and conflict errors. */
|
||||
private comboLabel(
|
||||
cargoTypeName: string,
|
||||
direction: BulkTemplateDirection,
|
||||
withCustoms: boolean | null,
|
||||
): string {
|
||||
const dir = direction.charAt(0) + direction.slice(1).toLowerCase();
|
||||
const customs =
|
||||
withCustoms === null
|
||||
? ""
|
||||
: withCustoms
|
||||
? ", with customs clearing"
|
||||
: ", without customs clearing";
|
||||
return `${cargoTypeName} Bulk Contract (${dir}${customs})`;
|
||||
}
|
||||
|
||||
/** Bulk templates only — the five seeded container templates are permanent. */
|
||||
async remove(code: string): Promise<void> {
|
||||
const template = await this.getByCode(code);
|
||||
@@ -146,9 +183,10 @@ export class ContractTemplatesService {
|
||||
* 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).
|
||||
* (or its parent group), trade direction and customs option. A domestic
|
||||
* contract resolves to the intercity template regardless of its customs flag.
|
||||
* 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,
|
||||
@@ -159,9 +197,11 @@ export class ContractTemplatesService {
|
||||
const isBulk = (freightType ?? "").toUpperCase().includes("BULK");
|
||||
if (isBulk) {
|
||||
if (!cargoTypeId) return null;
|
||||
const direction = bulkTemplateDirectionFor(tradeDirection);
|
||||
return this.repository.findActiveBulkTemplate(
|
||||
cargoTypeId,
|
||||
Boolean(customsClearingEnabled),
|
||||
direction,
|
||||
direction === "INTERCITY" ? null : Boolean(customsClearingEnabled),
|
||||
);
|
||||
}
|
||||
const code = contractTemplateCodeFor(
|
||||
@@ -274,18 +314,31 @@ export class ContractTemplatesService {
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* templates aren't in the fixed code map — they preview against the bulk
|
||||
* pack matching their own direction and customs option.
|
||||
*/
|
||||
private previewKeyFor(template: ContractTemplate): string {
|
||||
if (template.cargoTypeId) {
|
||||
return template.withCustoms
|
||||
? "IMP_BULK_USD_FORWARDING"
|
||||
: "IMP_BULK_USD_TRANSPORT_ONLY";
|
||||
const direction = bulkTemplateDirectionFor(template.tradeDirection);
|
||||
const dir =
|
||||
direction === "IMPORT" ? "IMP" : direction === "EXPORT" ? "EXP" : "DOM";
|
||||
const scope = template.withCustoms ? "FORWARDING" : "TRANSPORT_ONLY";
|
||||
return `${dir}_BULK_USD_${scope}`;
|
||||
}
|
||||
return PREVIEW_TEMPLATE_KEYS[template.code as ContractTemplateCode];
|
||||
}
|
||||
|
||||
/**
|
||||
* Preview direction: bulk templates carry it on the row, the fixed container
|
||||
* codes carry it as the code prefix.
|
||||
*/
|
||||
private previewDirectionFor(template: ContractTemplate): BulkTemplateDirection {
|
||||
if (template.cargoTypeId) {
|
||||
return bulkTemplateDirectionFor(template.tradeDirection);
|
||||
}
|
||||
return bulkTemplateDirectionFor(template.code.split("_")[0]);
|
||||
}
|
||||
|
||||
private buildMockView(
|
||||
template: ContractTemplate,
|
||||
dynamicTemplate: ContractDynamicTemplateView,
|
||||
@@ -294,11 +347,12 @@ export class ContractTemplatesService {
|
||||
const previewKey = this.previewKeyFor(template);
|
||||
const meta = getTemplateMeta(previewKey);
|
||||
const isBulk = Boolean(template.cargoTypeId) || code.includes("BULK");
|
||||
const direction = this.previewDirectionFor(template);
|
||||
const now = new Date();
|
||||
|
||||
// Representative rate schedule so the admin preview shows the live-rate
|
||||
// table shape. Real contracts populate this from freight.rates (LIVE).
|
||||
const rateSchedule = this.mockRateSchedule(code, isBulk);
|
||||
const rateSchedule = this.mockRateSchedule(direction, isBulk);
|
||||
|
||||
return {
|
||||
bookingId: "00000000-0000-0000-0000-000000000000",
|
||||
@@ -336,11 +390,7 @@ export class ContractTemplatesService {
|
||||
schedule: {
|
||||
originLabel: isBulk ? "Nagad Railway Station" : "SGTD Freight Station",
|
||||
destinationLabel: "Galaan Multipurpose Port (GMP)",
|
||||
tradeDirection: code.startsWith("IMPORT")
|
||||
? "IMPORT"
|
||||
: code.startsWith("EXPORT")
|
||||
? "EXPORT"
|
||||
: "DOMESTIC",
|
||||
tradeDirection: direction === "INTERCITY" ? "DOMESTIC" : direction,
|
||||
freightType: isBulk ? "BULK" : "CONTAINER",
|
||||
serviceType: "Rail transport and customs clearance",
|
||||
scheduledDate: "—",
|
||||
@@ -379,16 +429,14 @@ export class ContractTemplatesService {
|
||||
}
|
||||
|
||||
/** Static, representative rate schedule for the admin preview only. */
|
||||
private mockRateSchedule(code: string, isBulk: boolean): RateSchedule {
|
||||
const dir = code.startsWith("IMPORT")
|
||||
? "import"
|
||||
: code.startsWith("EXPORT")
|
||||
? "export"
|
||||
: "domestic";
|
||||
private mockRateSchedule(
|
||||
direction: BulkTemplateDirection,
|
||||
isBulk: boolean,
|
||||
): RateSchedule {
|
||||
const lane =
|
||||
dir === "export"
|
||||
direction === "EXPORT"
|
||||
? "Galaan Multipurpose Port → SGTD"
|
||||
: dir === "domestic"
|
||||
: direction === "INTERCITY"
|
||||
? "Mojo Dry Port → Dire Dawa"
|
||||
: "Negad → Mojo Dry Port";
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Type } from "class-transformer";
|
||||
import {
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsIn,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
@@ -13,6 +14,11 @@ import {
|
||||
ValidateNested,
|
||||
} from "class-validator";
|
||||
|
||||
import {
|
||||
BULK_TEMPLATE_DIRECTIONS,
|
||||
BulkTemplateDirection,
|
||||
} from "../entities/contract-template.entity";
|
||||
|
||||
export class CreateContractTemplateDto {
|
||||
@ApiProperty({
|
||||
description:
|
||||
@@ -23,10 +29,19 @@ export class CreateContractTemplateDto {
|
||||
cargoTypeId!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: "Whether this is the with-customs-clearing variant",
|
||||
description: "Trade direction this template is written for",
|
||||
enum: BULK_TEMPLATE_DIRECTIONS,
|
||||
})
|
||||
@IsIn(BULK_TEMPLATE_DIRECTIONS as unknown as string[])
|
||||
tradeDirection!: BulkTemplateDirection;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
"Whether this is the with-customs-clearing variant. Required for IMPORT/EXPORT, rejected for INTERCITY (domestic movements cross no border)",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
withCustoms!: boolean;
|
||||
withCustoms?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ description: "Display name (derived from the cargo type when omitted)" })
|
||||
@IsOptional()
|
||||
|
||||
@@ -9,10 +9,10 @@ import { CargoType } from "../../rule-engine/entities/cargo-type.entity";
|
||||
* 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.
|
||||
* (`cargoTypeId`), trade direction (`tradeDirection`) and customs option
|
||||
* (`withCustoms`), one template per combination. Their codes are generated by
|
||||
* `bulkTemplateCode` below. 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
|
||||
@@ -82,9 +82,41 @@ export function contractTemplateCodeFor(
|
||||
return `${direction}_${freight}_${customs}` as ContractTemplateCode;
|
||||
}
|
||||
|
||||
/** The three directions a bulk template can be written for. */
|
||||
export const BULK_TEMPLATE_DIRECTIONS = ["IMPORT", "EXPORT", "INTERCITY"] as const;
|
||||
|
||||
export type BulkTemplateDirection = (typeof BULK_TEMPLATE_DIRECTIONS)[number];
|
||||
|
||||
/**
|
||||
* Contracts store DOMESTIC for intercity movements; templates use INTERCITY.
|
||||
* Anything that is not an explicit IMPORT/EXPORT is domestic, matching
|
||||
* `contractTemplateCodeFor`.
|
||||
*/
|
||||
export function bulkTemplateDirectionFor(
|
||||
tradeDirection?: string | null,
|
||||
): BulkTemplateDirection {
|
||||
const value = (tradeDirection ?? "").toUpperCase();
|
||||
return value === "IMPORT" || value === "EXPORT" ? value : "INTERCITY";
|
||||
}
|
||||
|
||||
/**
|
||||
* Generated code for a staff-created bulk template. Intercity gets no customs
|
||||
* suffix — it crosses no border, so the variant does not exist.
|
||||
*/
|
||||
export function bulkTemplateCode(
|
||||
cargoCode: string,
|
||||
direction: BulkTemplateDirection,
|
||||
withCustoms: boolean | null,
|
||||
): string {
|
||||
const suffix =
|
||||
direction === "INTERCITY" ? "" : withCustoms ? "_CUSTOMS" : "_NO_CUSTOMS";
|
||||
return `BULK_${direction}_${cargoCode}${suffix}`.toUpperCase();
|
||||
}
|
||||
|
||||
@Entity({ schema: "freight", name: "contract_templates" })
|
||||
// Uniqueness lives in partial DB indexes (live rows only): code, and
|
||||
// (cargo_type_id, with_customs) for staff-created bulk templates.
|
||||
// (cargo_type_id, trade_direction, coalesce(with_customs,false)) for
|
||||
// staff-created bulk templates.
|
||||
@Index(["code"])
|
||||
export class ContractTemplate extends BaseEntity {
|
||||
@Column({ name: "code", type: "varchar", length: 80 })
|
||||
@@ -118,7 +150,15 @@ export class ContractTemplate extends BaseEntity {
|
||||
@JoinColumn({ name: "cargo_type_id" })
|
||||
cargoType?: CargoType | null;
|
||||
|
||||
/** Bulk templates only: whether this is the with-customs-clearing variant. */
|
||||
/** Bulk templates only: IMPORT, EXPORT or INTERCITY. */
|
||||
@Column({ name: "trade_direction", type: "varchar", length: 20, nullable: true })
|
||||
tradeDirection?: BulkTemplateDirection | null;
|
||||
|
||||
/**
|
||||
* Bulk templates only: whether this is the with-customs-clearing variant.
|
||||
* Always null for INTERCITY templates — domestic movements have no customs
|
||||
* leg, so neither variant applies.
|
||||
*/
|
||||
@Column({ name: "with_customs", type: "boolean", nullable: true })
|
||||
withCustoms?: boolean | null;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user