mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 00:38:11 +00:00
feat: enhance train scheduling and contract management features
- Added StationWorkControls to manage loading/unloading phases in TrainScheduleV2DetailPage. - Implemented API endpoints for recording station work and managing wagon detach requests. - Updated contract templates to include Ethiopian customs handling options. - Enhanced shipment forms to collect customs clearing agent details for without-customs bookings. - Introduced NUMBER_OF_WAGONS as a unit of measure for bulk cargo, allowing customers to specify wagon counts. - Improved validation for customs clearing agent information in shipment forms. - Updated various components and services to accommodate new features and ensure data integrity.
This commit is contained in:
@@ -34,15 +34,27 @@ describe('bulkTemplateCode', () => {
|
||||
expect(bulkTemplateCode('STEEL', 'INTERCITY', null)).toBe('BULK_INTERCITY_STEEL');
|
||||
});
|
||||
|
||||
it('produces 5 distinct codes per cargo type', () => {
|
||||
it('gives the Ethiopian-customs-only variant its own suffix', () => {
|
||||
expect(bulkTemplateCode('STEEL', 'IMPORT', true, true)).toBe(
|
||||
'BULK_IMPORT_STEEL_ETHIOPIAN_CUSTOMS',
|
||||
);
|
||||
// The flag is meaningless without customs clearing.
|
||||
expect(bulkTemplateCode('STEEL', 'IMPORT', false, true)).toBe(
|
||||
'BULK_IMPORT_STEEL_NO_CUSTOMS',
|
||||
);
|
||||
});
|
||||
|
||||
it('produces 7 distinct codes per cargo type', () => {
|
||||
const codes = [
|
||||
bulkTemplateCode('STEEL', 'IMPORT', true),
|
||||
bulkTemplateCode('STEEL', 'IMPORT', true, true),
|
||||
bulkTemplateCode('STEEL', 'IMPORT', false),
|
||||
bulkTemplateCode('STEEL', 'EXPORT', true),
|
||||
bulkTemplateCode('STEEL', 'EXPORT', true, true),
|
||||
bulkTemplateCode('STEEL', 'EXPORT', false),
|
||||
bulkTemplateCode('STEEL', 'INTERCITY', null),
|
||||
];
|
||||
expect(new Set(codes).size).toBe(5);
|
||||
expect(new Set(codes).size).toBe(7);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -112,6 +124,33 @@ describe('ContractTemplatesService bulk create/resolve', () => {
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('creates the Ethiopian-customs-only variant alongside the full-customs one', async () => {
|
||||
const { service } = build();
|
||||
const created = await service.create({
|
||||
cargoTypeId: 'cargo-1',
|
||||
tradeDirection: 'IMPORT',
|
||||
withCustoms: true,
|
||||
ethiopianCustomsOnly: true,
|
||||
});
|
||||
expect(created.code).toBe('BULK_IMPORT_STEEL_ETHIOPIAN_CUSTOMS');
|
||||
expect(created.ethiopianCustomsOnly).toBe(true);
|
||||
expect(created.documentTitle).toBe(
|
||||
'Steel Transportation and Ethiopian Customs Clearance Services',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects Ethiopian-customs-only without customs clearing', async () => {
|
||||
const { service } = build();
|
||||
await expect(
|
||||
service.create({
|
||||
cargoTypeId: 'cargo-1',
|
||||
tradeDirection: 'IMPORT',
|
||||
withCustoms: false,
|
||||
ethiopianCustomsOnly: true,
|
||||
}),
|
||||
).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');
|
||||
@@ -119,6 +158,7 @@ describe('ContractTemplatesService bulk create/resolve', () => {
|
||||
'cargo-1',
|
||||
'INTERCITY',
|
||||
null,
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -129,6 +169,18 @@ describe('ContractTemplatesService bulk create/resolve', () => {
|
||||
'cargo-1',
|
||||
'IMPORT',
|
||||
false,
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('resolves an Ethiopian-customs-only contract to the Ethiopian variant', async () => {
|
||||
const { repository, service } = build();
|
||||
await service.findActiveForContract('IMPORT', 'BULK', true, 'cargo-1', true);
|
||||
expect(repository.findActiveBulkTemplate).toHaveBeenCalledWith(
|
||||
'cargo-1',
|
||||
'IMPORT',
|
||||
true,
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,6 +16,22 @@ describe('contractTemplateCodeFor', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('resolves the Ethiopian variant only when customs clearing is enabled', () => {
|
||||
expect(contractTemplateCodeFor('IMPORT', 'CONTAINER', true, true)).toBe(
|
||||
'IMPORT_CONTAINER_ETHIOPIAN_CUSTOMS',
|
||||
);
|
||||
expect(contractTemplateCodeFor('EXPORT', 'BULK', true, true)).toBe(
|
||||
'EXPORT_BULK_ETHIOPIAN_CUSTOMS',
|
||||
);
|
||||
// Without customs clearing the Ethiopian flag is meaningless.
|
||||
expect(contractTemplateCodeFor('IMPORT', 'CONTAINER', false, true)).toBe(
|
||||
'IMPORT_CONTAINER_NO_CUSTOMS',
|
||||
);
|
||||
expect(contractTemplateCodeFor('DOMESTIC', 'CONTAINER', true, true)).toBe(
|
||||
'INTERCITY_CONTAINER',
|
||||
);
|
||||
});
|
||||
|
||||
it('never gives intercity a customs variant — it crosses no border', () => {
|
||||
for (const flag of [true, false, null, undefined]) {
|
||||
expect(contractTemplateCodeFor('DOMESTIC', 'BULK', flag)).toBe('INTERCITY_BULK');
|
||||
@@ -40,7 +56,11 @@ describe('contractTemplateCodeFor', () => {
|
||||
for (const d of directions) {
|
||||
for (const f of freights) {
|
||||
for (const c of [true, false]) {
|
||||
expect(CONTRACT_TEMPLATE_CODES).toContain(contractTemplateCodeFor(d, f, c));
|
||||
for (const e of [true, false, undefined]) {
|
||||
expect(CONTRACT_TEMPLATE_CODES).toContain(
|
||||
contractTemplateCodeFor(d, f, c, e),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -48,9 +68,9 @@ describe('contractTemplateCodeFor', () => {
|
||||
});
|
||||
|
||||
describe('CONTRACT_TEMPLATE_DEFAULTS', () => {
|
||||
it('seeds exactly the ten declared codes, once each', () => {
|
||||
it('seeds exactly the fourteen declared codes, once each', () => {
|
||||
const seeded = CONTRACT_TEMPLATE_DEFAULTS.map((t) => t.code).sort();
|
||||
expect(seeded).toHaveLength(10);
|
||||
expect(seeded).toHaveLength(14);
|
||||
expect(seeded).toEqual([...CONTRACT_TEMPLATE_CODES].sort());
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { BaseRepository } from "@edr/api-common";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { InjectRepository } from "@nestjs/typeorm";
|
||||
import { IsNull, Repository } from "typeorm";
|
||||
import { Repository } from "typeorm";
|
||||
|
||||
import { CargoType } from "../rule-engine/entities/cargo-type.entity";
|
||||
import {
|
||||
@@ -33,10 +33,22 @@ export class ContractTemplatesRepository extends BaseRepository<ContractTemplate
|
||||
cargoTypeId: string,
|
||||
tradeDirection: BulkTemplateDirection,
|
||||
withCustoms: boolean | null,
|
||||
ethiopianCustomsOnly = false,
|
||||
): Promise<ContractTemplate | null> {
|
||||
return this.repository.findOne({
|
||||
where: { cargoTypeId, tradeDirection, withCustoms: withCustoms ?? IsNull() },
|
||||
});
|
||||
return this.repository
|
||||
.createQueryBuilder("t")
|
||||
.where("t.cargo_type_id = :cargoTypeId", { cargoTypeId })
|
||||
.andWhere("t.trade_direction = :tradeDirection", { tradeDirection })
|
||||
.andWhere(
|
||||
withCustoms === null
|
||||
? "t.with_customs IS NULL"
|
||||
: "t.with_customs = :withCustoms",
|
||||
withCustoms === null ? {} : { withCustoms },
|
||||
)
|
||||
.andWhere("COALESCE(t.ethiopian_customs_only, false) = :ethiopianCustomsOnly", {
|
||||
ethiopianCustomsOnly,
|
||||
})
|
||||
.getOne();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -49,6 +61,7 @@ export class ContractTemplatesRepository extends BaseRepository<ContractTemplate
|
||||
cargoTypeId: string,
|
||||
tradeDirection: BulkTemplateDirection,
|
||||
withCustoms: boolean | null,
|
||||
ethiopianCustomsOnly = false,
|
||||
): Promise<ContractTemplate | null> {
|
||||
return this.repository
|
||||
.createQueryBuilder("t")
|
||||
@@ -60,6 +73,9 @@ export class ContractTemplatesRepository extends BaseRepository<ContractTemplate
|
||||
: "t.with_customs = :withCustoms",
|
||||
withCustoms === null ? {} : { withCustoms },
|
||||
)
|
||||
.andWhere("COALESCE(t.ethiopian_customs_only, false) = :ethiopianCustomsOnly", {
|
||||
ethiopianCustomsOnly,
|
||||
})
|
||||
.andWhere(
|
||||
`(t.cargo_type_id = :cargoTypeId OR t.cargo_type_id = (
|
||||
SELECT c.parent_group_id FROM freight.cargo_types c
|
||||
|
||||
@@ -41,13 +41,17 @@ import {
|
||||
*/
|
||||
const PREVIEW_TEMPLATE_KEYS: Record<ContractTemplateCode, string> = {
|
||||
IMPORT_BULK_CUSTOMS: "IMP_BULK_USD_FORWARDING",
|
||||
IMPORT_BULK_ETHIOPIAN_CUSTOMS: "IMP_BULK_USD_FORWARDING",
|
||||
IMPORT_BULK_NO_CUSTOMS: "IMP_BULK_USD_TRANSPORT_ONLY",
|
||||
EXPORT_BULK_CUSTOMS: "EXP_BULK_USD_FORWARDING",
|
||||
EXPORT_BULK_ETHIOPIAN_CUSTOMS: "EXP_BULK_USD_FORWARDING",
|
||||
EXPORT_BULK_NO_CUSTOMS: "EXP_BULK_USD_TRANSPORT_ONLY",
|
||||
INTERCITY_BULK: "DOM_BULK_USD_TRANSPORT_ONLY",
|
||||
IMPORT_CONTAINER_CUSTOMS: "IMP_CON_USD_FORWARDING",
|
||||
IMPORT_CONTAINER_ETHIOPIAN_CUSTOMS: "IMP_CON_USD_FORWARDING",
|
||||
IMPORT_CONTAINER_NO_CUSTOMS: "IMP_CON_USD_TRANSPORT_ONLY",
|
||||
EXPORT_CONTAINER_CUSTOMS: "EXP_CON_USD_FORWARDING",
|
||||
EXPORT_CONTAINER_ETHIOPIAN_CUSTOMS: "EXP_CON_USD_FORWARDING",
|
||||
EXPORT_CONTAINER_NO_CUSTOMS: "EXP_CON_USD_TRANSPORT_ONLY",
|
||||
INTERCITY_CONTAINER: "DOM_CON_USD_TRANSPORT_ONLY",
|
||||
};
|
||||
@@ -101,7 +105,7 @@ export class ContractTemplatesService {
|
||||
|
||||
const direction = dto.tradeDirection;
|
||||
const intercity = direction === "INTERCITY";
|
||||
if (intercity && dto.withCustoms !== undefined) {
|
||||
if (intercity && (dto.withCustoms !== undefined || dto.ethiopianCustomsOnly)) {
|
||||
throw new BadRequestException(
|
||||
"Intercity contracts are domestic and cross no border — they have no customs clearing variant",
|
||||
);
|
||||
@@ -112,12 +116,24 @@ export class ContractTemplatesService {
|
||||
);
|
||||
}
|
||||
const withCustoms = intercity ? null : Boolean(dto.withCustoms);
|
||||
const ethiopianOnly = Boolean(dto.ethiopianCustomsOnly) && !intercity;
|
||||
if (ethiopianOnly && !withCustoms) {
|
||||
throw new BadRequestException(
|
||||
"Ethiopian-customs-only is a customs clearing variant — it requires withCustoms to be true",
|
||||
);
|
||||
}
|
||||
|
||||
const label = this.comboLabel(cargoType.cargoTypeName, direction, withCustoms);
|
||||
const label = this.comboLabel(
|
||||
cargoType.cargoTypeName,
|
||||
direction,
|
||||
withCustoms,
|
||||
ethiopianOnly,
|
||||
);
|
||||
const existing = await this.repository.findByCargoCombo(
|
||||
dto.cargoTypeId,
|
||||
direction,
|
||||
withCustoms,
|
||||
ethiopianOnly,
|
||||
);
|
||||
if (existing) {
|
||||
throw new ConflictException(
|
||||
@@ -126,11 +142,13 @@ export class ContractTemplatesService {
|
||||
}
|
||||
|
||||
const template = new ContractTemplate();
|
||||
template.code = bulkTemplateCode(cargoType.code, direction, withCustoms);
|
||||
template.code = bulkTemplateCode(cargoType.code, direction, withCustoms, ethiopianOnly);
|
||||
template.name = dto.name ?? label;
|
||||
template.description = dto.description ?? null;
|
||||
template.documentTitle = withCustoms
|
||||
? `${cargoType.cargoTypeName} Transportation and Customs Clearance Services`
|
||||
? ethiopianOnly
|
||||
? `${cargoType.cargoTypeName} Transportation and Ethiopian Customs Clearance Services`
|
||||
: `${cargoType.cargoTypeName} Transportation and Customs Clearance Services`
|
||||
: `${cargoType.cargoTypeName} Transportation Services`;
|
||||
template.whereasClauses = [];
|
||||
template.articles = [];
|
||||
@@ -138,6 +156,7 @@ export class ContractTemplatesService {
|
||||
template.cargoTypeId = cargoType.id;
|
||||
template.tradeDirection = direction;
|
||||
template.withCustoms = withCustoms;
|
||||
template.ethiopianCustomsOnly = intercity ? null : ethiopianOnly;
|
||||
template.isSystem = false;
|
||||
try {
|
||||
return await this.repository.saveTemplate(template);
|
||||
@@ -157,18 +176,21 @@ export class ContractTemplatesService {
|
||||
cargoTypeName: string,
|
||||
direction: BulkTemplateDirection,
|
||||
withCustoms: boolean | null,
|
||||
ethiopianCustomsOnly = false,
|
||||
): string {
|
||||
const dir = direction.charAt(0) + direction.slice(1).toLowerCase();
|
||||
const customs =
|
||||
withCustoms === null
|
||||
? ""
|
||||
: withCustoms
|
||||
? ", with customs clearing"
|
||||
? ethiopianCustomsOnly
|
||||
? ", with Ethiopian customs clearing only"
|
||||
: ", with customs clearing"
|
||||
: ", without customs clearing";
|
||||
return `${cargoTypeName} Bulk Contract (${dir}${customs})`;
|
||||
}
|
||||
|
||||
/** Bulk templates only — the five seeded container templates are permanent. */
|
||||
/** Bulk templates only — the seeded container templates are permanent. */
|
||||
async remove(code: string): Promise<void> {
|
||||
const template = await this.getByCode(code);
|
||||
if (template.isSystem) {
|
||||
@@ -193,6 +215,7 @@ export class ContractTemplatesService {
|
||||
freightType?: string | null,
|
||||
customsClearingEnabled?: boolean | null,
|
||||
cargoTypeId?: string | null,
|
||||
ethiopianCustomsOnly?: boolean | null,
|
||||
): Promise<ContractTemplate | null> {
|
||||
const isBulk = (freightType ?? "").toUpperCase().includes("BULK");
|
||||
if (isBulk) {
|
||||
@@ -202,12 +225,16 @@ export class ContractTemplatesService {
|
||||
cargoTypeId,
|
||||
direction,
|
||||
direction === "INTERCITY" ? null : Boolean(customsClearingEnabled),
|
||||
direction === "INTERCITY"
|
||||
? false
|
||||
: Boolean(customsClearingEnabled && ethiopianCustomsOnly),
|
||||
);
|
||||
}
|
||||
const code = contractTemplateCodeFor(
|
||||
tradeDirection,
|
||||
freightType,
|
||||
customsClearingEnabled,
|
||||
ethiopianCustomsOnly,
|
||||
);
|
||||
const template = await this.repository.findByCode(code);
|
||||
return template?.isActive ? template : null;
|
||||
|
||||
@@ -43,6 +43,14 @@ export class CreateContractTemplateDto {
|
||||
@IsBoolean()
|
||||
withCustoms?: boolean;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
"Restricts the with-customs variant to Ethiopian-side clearing only (Djibouti stays with the Client). Requires withCustoms=true; rejected for INTERCITY",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
ethiopianCustomsOnly?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ description: "Display name (derived from the cargo type when omitted)" })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
|
||||
@@ -4,9 +4,10 @@ import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
|
||||
import { CargoType } from "../../rule-engine/entities/cargo-type.entity";
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* The seeded container templates (import/export split by customs-clearing
|
||||
* option — full, Ethiopian-only, none; 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`), trade direction (`tradeDirection`) and customs option
|
||||
@@ -20,18 +21,24 @@ import { CargoType } from "../../rule-engine/entities/cargo-type.entity";
|
||||
*
|
||||
* The `_CUSTOMS` variant is issued when the contract has customs clearing
|
||||
* enabled (the Service Provider clears in Djibouti/Ethiopia on the Client's
|
||||
* behalf); `_NO_CUSTOMS` is the transport-only paper, where the Client handles
|
||||
* behalf); `_ETHIOPIAN_CUSTOMS` when the service type is Ethiopian-customs-only
|
||||
* (the Service Provider clears the Ethiopian side only, Djibouti stays with the
|
||||
* Client); `_NO_CUSTOMS` is the transport-only paper, where the Client handles
|
||||
* its own declarations.
|
||||
*/
|
||||
export const CONTRACT_TEMPLATE_CODES = [
|
||||
"IMPORT_BULK_CUSTOMS",
|
||||
"IMPORT_BULK_ETHIOPIAN_CUSTOMS",
|
||||
"IMPORT_BULK_NO_CUSTOMS",
|
||||
"EXPORT_BULK_CUSTOMS",
|
||||
"EXPORT_BULK_ETHIOPIAN_CUSTOMS",
|
||||
"EXPORT_BULK_NO_CUSTOMS",
|
||||
"INTERCITY_BULK",
|
||||
"IMPORT_CONTAINER_CUSTOMS",
|
||||
"IMPORT_CONTAINER_ETHIOPIAN_CUSTOMS",
|
||||
"IMPORT_CONTAINER_NO_CUSTOMS",
|
||||
"EXPORT_CONTAINER_CUSTOMS",
|
||||
"EXPORT_CONTAINER_ETHIOPIAN_CUSTOMS",
|
||||
"EXPORT_CONTAINER_NO_CUSTOMS",
|
||||
"INTERCITY_CONTAINER",
|
||||
] as const;
|
||||
@@ -66,6 +73,7 @@ export function contractTemplateCodeFor(
|
||||
tradeDirection?: string | null,
|
||||
freightType?: string | null,
|
||||
customsClearingEnabled?: boolean | null,
|
||||
ethiopianCustomsOnly?: boolean | null,
|
||||
): ContractTemplateCode {
|
||||
const direction =
|
||||
tradeDirection === "IMPORT"
|
||||
@@ -78,7 +86,11 @@ export function contractTemplateCodeFor(
|
||||
if (direction === "INTERCITY") {
|
||||
return `INTERCITY_${freight}` as ContractTemplateCode;
|
||||
}
|
||||
const customs = customsClearingEnabled ? "CUSTOMS" : "NO_CUSTOMS";
|
||||
const customs = customsClearingEnabled
|
||||
? ethiopianCustomsOnly
|
||||
? "ETHIOPIAN_CUSTOMS"
|
||||
: "CUSTOMS"
|
||||
: "NO_CUSTOMS";
|
||||
return `${direction}_${freight}_${customs}` as ContractTemplateCode;
|
||||
}
|
||||
|
||||
@@ -107,9 +119,16 @@ export function bulkTemplateCode(
|
||||
cargoCode: string,
|
||||
direction: BulkTemplateDirection,
|
||||
withCustoms: boolean | null,
|
||||
ethiopianCustomsOnly = false,
|
||||
): string {
|
||||
const suffix =
|
||||
direction === "INTERCITY" ? "" : withCustoms ? "_CUSTOMS" : "_NO_CUSTOMS";
|
||||
direction === "INTERCITY"
|
||||
? ""
|
||||
: withCustoms
|
||||
? ethiopianCustomsOnly
|
||||
? "_ETHIOPIAN_CUSTOMS"
|
||||
: "_CUSTOMS"
|
||||
: "_NO_CUSTOMS";
|
||||
return `BULK_${direction}_${cargoCode}${suffix}`.toUpperCase();
|
||||
}
|
||||
|
||||
@@ -162,7 +181,15 @@ export class ContractTemplate extends BaseEntity {
|
||||
@Column({ name: "with_customs", type: "boolean", nullable: true })
|
||||
withCustoms?: boolean | null;
|
||||
|
||||
/** The five seeded container templates — cannot be deleted. */
|
||||
/**
|
||||
* Bulk templates only: the with-customs variant restricted to Ethiopian-side
|
||||
* clearing (Djibouti stays with the Client). Only meaningful when
|
||||
* `withCustoms` is true; null/false otherwise.
|
||||
*/
|
||||
@Column({ name: "ethiopian_customs_only", type: "boolean", nullable: true })
|
||||
ethiopianCustomsOnly?: boolean | null;
|
||||
|
||||
/** The seeded container templates — cannot be deleted. */
|
||||
@Column({ name: "is_system", type: "boolean", default: false })
|
||||
isSystem!: boolean;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user