Merge pull request #1263 from Tria-plc/dev

Dev Freight
This commit is contained in:
marshal
2026-08-12 16:50:24 +03:00
committed by GitHub
50 changed files with 1782 additions and 171 deletions

View File

@@ -65,10 +65,40 @@ describe("RequestLogMiddleware", () => {
originalUrl: "/api/bookings/1/submit?dry=1",
baseUrl: "/api/bookings",
route: { path: "/:id/submit" },
headers: { "user-agent": "jest", "x-request-id": "req-42" },
headers: {
"user-agent": "jest",
"x-request-id": "req-42",
authorization: "Bearer tok",
"x-client-app": "freight-backoffice",
"current-project-id": "proj-3",
},
ip: "10.0.0.1",
query: { dry: "1" },
user: { id: "u-7" },
user: {
id: "u-7",
sessionId: "sess-9",
userType: "STAFF",
status: "ACTIVE",
username: "nati",
email: "nati@example.com",
phoneNumber: "0911000000",
name: { en: "Nati" },
roles: [{ key: "freight_operations" }],
permissions: [{ key: "a" }, { key: "b" }],
employee: {
id: "emp-1",
organizationId: "org-1",
unitId: "unit-2",
position: {
id: "pos-5",
key: "ops_officer",
employeePositionId: "ep-6",
isDelegate: true,
delegatorId: "pos-1",
positionType: { key: "operations" },
},
},
},
};
const res = {
statusCode: 409,
@@ -105,6 +135,29 @@ describe("RequestLogMiddleware", () => {
bookingId: "b-1",
booking: { outcome: "REJECTED" },
});
expect(JSON.parse(lines[0]).auth).toEqual({
authenticated: true,
hasBearer: true,
clientApp: "freight-backoffice",
userId: "u-7",
sessionId: "sess-9",
userType: "STAFF",
userStatus: "ACTIVE",
roles: ["freight_operations"],
permissionCount: 2,
employeeId: "emp-1",
organizationId: "org-1",
unitId: "unit-2",
positionId: "pos-5",
positionKey: "ops_officer",
positionType: "operations",
employeePositionId: "ep-6",
isDelegate: true,
delegatorId: "pos-1",
projectId: "proj-3",
});
// No personal data reaches the line, whatever the token carried.
expect(lines[0]).not.toMatch(/nati|example\.com|0911000000/);
expect(res.setHeader).toHaveBeenCalledWith("x-request-id", "req-42");
jest.restoreAllMocks();
});

View File

@@ -44,6 +44,7 @@ const UNIT_LABELS: Record<string, string> = {
PER_CONTAINER: 'per container',
PER_KM: 'per km',
PER_TON_KM: 'per ton per km',
PER_LITER: 'per liter',
PER_INVOICE: 'per invoice',
FLAT: 'flat',
};
@@ -66,6 +67,7 @@ const TRIGGER_ROUTE_LABELS: Partial<Record<Rate['trigger'], string>> = {
DEMURRAGE: 'Demurrage / wagon detention',
PIL_EXTRA_FEE: 'PIL shipping line extra fee',
CUSTOMS_CLEARANCE: 'Customs clearance service',
FUEL: 'Fuel surcharge',
};
@Injectable()
@@ -101,6 +103,15 @@ export class ContractRateScheduleBuilder {
continue;
}
// Fuel is sold per lane + commodity — only lanes matching the contract's
// direction belong on its schedule, labeled with their leg.
if (rate.trigger === 'FUEL') {
if (this.fuelDirectionMatches(rate, direction)) {
surcharges.push(this.fuelRow(rate));
}
continue;
}
// Everything left is a trigger-based charge (surcharge / demurrage / customs).
surcharges.push(this.surchargeRow(rate));
}
@@ -176,6 +187,35 @@ export class ContractRateScheduleBuilder {
};
}
private fuelDirectionMatches(rate: Rate, direction: ContractDirection): boolean {
const want =
direction === 'IMP' ? 'IMPORT' : direction === 'EXP' ? 'EXPORT' : 'DOMESTIC';
return rate.tradeDirection === want;
}
/**
* Fuel row — the lane matters, so it rides along in the charge label.
* Per-liter collapses to one flat total (base liters × rate value); the
* customer only ever sees the final price.
*/
private fuelRow(rate: Rate): RateScheduleRow {
const origin = rate.originYard?.label ?? rate.originYard?.code ?? '—';
const destination =
rate.destinationYard?.label ?? rate.destinationYard?.code ?? '—';
const perLiter = rate.rateUnit === 'PER_LITER';
return {
route: `Fuel surcharge (${origin}${destination})`,
cargo: this.cargoLabel(rate),
currency: rate.currency,
amount: this.formatAmount(
perLiter
? Number(rate.baseLiters ?? 0) * Number(rate.rateValue)
: rate.rateValue,
),
unit: perLiter ? 'flat' : this.unitLabel(rate.rateUnit),
};
}
private surchargeRow(rate: Rate): RateScheduleRow {
return {
route: TRIGGER_ROUTE_LABELS[rate.trigger] ?? this.titleCase(rate.trigger),

View File

@@ -0,0 +1,69 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Bulk contract templates gain trade direction, so the unique key becomes
* (cargo type, direction, customs option) instead of (cargo type, customs).
*
* Intercity is domestic and crosses no border, so it has no customs variant at
* all: with_customs stays NULL there, enforced by ck_bulk_intercity_no_customs.
* The unique index coalesces that NULL so two intercity templates for the same
* cargo type still collide (plain NULLs never do).
*
* No backfill: staff-created bulk templates are keyed by cargo_type_id and no
* such row exists yet — the seeded direction-keyed bulk rows were retired by
* 3320000000000 and carry a NULL cargo_type_id. The five system container
* templates are untouched: cargo_type_id IS NULL keeps them out of both the
* index and the check.
*/
export class BulkTemplateTradeDirection3420000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.contract_templates
ADD COLUMN IF NOT EXISTS trade_direction varchar(20)
`);
await queryRunner.query(
`DROP INDEX IF EXISTS freight.uq_contract_templates_cargo_customs`,
);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS uq_contract_templates_cargo_dir_customs
ON freight.contract_templates
(cargo_type_id, trade_direction, COALESCE(with_customs, false))
WHERE deleted_at IS NULL AND cargo_type_id IS NOT NULL
`);
await queryRunner.query(`
ALTER TABLE freight.contract_templates
DROP CONSTRAINT IF EXISTS ck_bulk_intercity_no_customs
`);
await queryRunner.query(`
ALTER TABLE freight.contract_templates
ADD CONSTRAINT ck_bulk_intercity_no_customs CHECK (
cargo_type_id IS NULL
OR (
trade_direction IN ('IMPORT', 'EXPORT', 'INTERCITY')
AND (trade_direction = 'INTERCITY') = (with_customs IS NULL)
)
)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.contract_templates
DROP CONSTRAINT IF EXISTS ck_bulk_intercity_no_customs
`);
await queryRunner.query(
`DROP INDEX IF EXISTS freight.uq_contract_templates_cargo_dir_customs`,
);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS uq_contract_templates_cargo_customs
ON freight.contract_templates (cargo_type_id, with_customs)
WHERE deleted_at IS NULL AND cargo_type_id IS NOT NULL
`);
await queryRunner.query(`
ALTER TABLE freight.contract_templates DROP COLUMN IF EXISTS trade_direction
`);
}
}

View File

@@ -0,0 +1,62 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Fuel surcharge, sold per lane + commodity:
*
* - cargo_types.has_fuel marks the commodities that incur it (same shape as
* has_lashing — the booking's cargo type flag is what fires the charge).
* - rates.base_liters carries the liters a PER_LITER fuel rate bills
* (price = base_liters × rate_value, once per booking). NULL on every other
* rate shape, including PER_WAGON fuel rates (wagons × rate_value).
* - CK_rates_yard_scope gains FUEL in its yard-carrying branch: fuel is priced
* per origin → destination leg like customs clearance and container return.
*/
export class FuelSurcharge3430000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.cargo_types
ADD COLUMN IF NOT EXISTS has_fuel boolean NOT NULL DEFAULT false
`);
await queryRunner.query(`
ALTER TABLE freight.rates
ADD COLUMN IF NOT EXISTS base_liters numeric(14,4)
`);
await queryRunner.query(
`ALTER TABLE freight.rates DROP CONSTRAINT IF EXISTS "CK_rates_yard_scope"`,
);
await queryRunner.query(`
ALTER TABLE freight.rates ADD CONSTRAINT "CK_rates_yard_scope" CHECK (
deleted_at IS NOT NULL OR status = 'SUPERSEDED' OR
CASE
WHEN (trigger = 'ALWAYS' AND applies_to IN ('BULK', 'CONTAINER', 'INTERCITY'))
OR trigger IN ('CUSTOMS_CLEARANCE', 'WITH_RETURN', 'FUEL')
THEN origin_yard_id IS NOT NULL AND destination_yard_id IS NOT NULL
ELSE origin_yard_id IS NULL AND destination_yard_id IS NULL
END
)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.rates DROP CONSTRAINT IF EXISTS "CK_rates_yard_scope"`,
);
await queryRunner.query(`
ALTER TABLE freight.rates ADD CONSTRAINT "CK_rates_yard_scope" CHECK (
deleted_at IS NOT NULL OR status = 'SUPERSEDED' OR
CASE
WHEN (trigger = 'ALWAYS' AND applies_to IN ('BULK', 'CONTAINER', 'INTERCITY'))
OR trigger IN ('CUSTOMS_CLEARANCE', 'WITH_RETURN')
THEN origin_yard_id IS NOT NULL AND destination_yard_id IS NOT NULL
ELSE origin_yard_id IS NULL AND destination_yard_id IS NULL
END
)
`);
await queryRunner.query(`ALTER TABLE freight.rates DROP COLUMN IF EXISTS base_liters`);
await queryRunner.query(
`ALTER TABLE freight.cargo_types DROP COLUMN IF EXISTS has_fuel`,
);
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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()

View File

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

View File

@@ -157,13 +157,9 @@ export class ContractBookingService {
actorPermissions != null &&
hasFreightPermission(actorPermissions, FREIGHT_PERMS.contracts.createBooking);
await this.assertNotExpired(contract);
const createdByRole = await this.assertGate(contract, isGlActor);
// Validity window must still be open.
if (contract.contractValidUntil && contract.contractValidUntil.getTime() < Date.now()) {
throw new BadRequestException('Contract validity has expired — no new bookings.');
}
// ONE_TIME: a single shipment at a time. The slot frees only if the prior
// booking reached a terminal state (e.g. payment expired without shipping),
// letting the customer re-book within contract validity (doc §10.4).
@@ -441,12 +437,9 @@ export class ContractBookingService {
// The customer initiates his own shipment instance on ONE_TIME contracts
// (customs or self-clearance); GL may also initiate on a customs contract.
// GENERAL customs instances come from a shipment request, not from here.
await this.assertNotExpired(contract);
const createdByRole = await this.assertGate(contract, isGlActor, true);
if (contract.contractValidUntil && contract.contractValidUntil.getTime() < Date.now()) {
throw new BadRequestException('Contract validity has expired — no new bookings.');
}
// ONE_TIME carries a single shipment at a time; a bare instance occupies the
// slot from the moment it is initiated (it is not a terminal status). The
// split chain is the one exception — a paid partial frees the slot and
@@ -545,9 +538,7 @@ export class ContractBookingService {
'Shipment-request initiation applies only to general customs contracts.',
);
}
if (contract.contractValidUntil && contract.contractValidUntil.getTime() < Date.now()) {
throw new BadRequestException('Contract validity has expired — no new bookings.');
}
await this.assertNotExpired(contract);
const route = await this.resolveRoute(contract, opts.contractRouteId);
@@ -681,9 +672,11 @@ export class ContractBookingService {
if (!dto.scheduledDate) {
throw new BadRequestException('A binding shipment day is required');
}
if (contract.contractValidUntil && contract.contractValidUntil.getTime() < Date.now()) {
throw new BadRequestException('Contract validity has expired — no new bookings.');
}
// No expiry gate here on purpose: this booking was already initiated
// before the contract lapsed (createUnderContract/initiateUnderContract
// already checked expiry at start). Finishing an in-flight booking must
// proceed even if the contract expires meanwhile — only starting a NEW
// booking is blocked (see assertNotExpired).
// Completion is booking time: the route's booking window must be open —
// the same config-driven gate a direct one-time booking passes at create.
@@ -1019,6 +1012,22 @@ export class ContractBookingService {
* Returns the role to stamp on the booking, or throws if the caller is not
* allowed to create one for this contract's execution path.
*/
/**
* Blocks starting a NEW booking (create/initiate) once the contract has
* lapsed, and lazily flips the stored status to EXPIRED so it doesn't wait
* for the nightly sweep. Only for the "start something new" entry points —
* a booking already underway (completeUnderContract) must be allowed to
* finish even if the contract expires mid-flight.
*/
private async assertNotExpired(contract: Contract): Promise<void> {
if (!isEffectivelyExpired(contract)) return;
if (contract.status !== 'EXPIRED') {
const flipped = await this.contractsRepository.expireIfLapsed(contract.id);
if (flipped) contract.status = 'EXPIRED';
}
throw new BadRequestException('Contract validity has expired — no new bookings.');
}
private async assertGate(
contract: Contract,
isGlActor: boolean,

View File

@@ -11,7 +11,14 @@ import { Contract } from './entities/contract.entity';
export interface ContractUnitRateLineItem {
code: string;
label: string;
unit: 'per_container' | 'per_wagon' | 'per_ton' | 'per_item' | 'per_km' | 'flat';
unit:
| 'per_container'
| 'per_wagon'
| 'per_ton'
| 'per_item'
| 'per_km'
| 'per_liter'
| 'flat';
unitPrice: number;
containerSize?: string | null;
conditionalOn?: string | null;
@@ -44,6 +51,8 @@ function toContractUnit(rateUnit: string): ContractUnitRateLineItem['unit'] {
return 'per_wagon';
case 'PER_CONTAINER':
return 'per_container';
case 'PER_LITER':
return 'per_liter';
default:
return 'flat';
}
@@ -276,6 +285,42 @@ export class ContractPricingService {
}
}
// Fuel surcharge — shown when the contract's commodity incurs fuel
// (cargoType.hasFuel), sold per lane + commodity. Billed at booking on the
// frozen/live rate (per wagon × wagons, or per liter × base liters, once);
// this line freezes the agreed unit price.
{
const scope = (contract.cargoScope ?? []).find((c) => c.cargoTypeId);
if (scope?.cargoType?.hasFuel && route) {
const fuel = liveRates.find(
(r) =>
r.trigger === 'FUEL' &&
r.currency === 'USD' &&
r.tradeDirection === contract.tradeDirection &&
r.originYardId === route.originYardId &&
r.destinationYardId === route.destinationYardId &&
r.cargoTypeId === scope.cargoTypeId,
);
if (fuel && Number(fuel.rateValue) > 0) {
// Per-liter collapses to one flat total (base liters × rate value) —
// the customer only sees the final price, and booking pricing bills
// the same flat figure once (see RuleEngineService.fuelCharges).
const perLiter = fuel.rateUnit === 'PER_LITER';
const total = perLiter
? Number(fuel.baseLiters ?? 0) * Number(fuel.rateValue)
: Number(fuel.rateValue);
lineItems.push({
code: 'FUEL_SURCHARGE',
label: `Fuel surcharge (${scope.cargoType.cargoTypeName})`,
unit: perLiter ? 'flat' : toContractUnit(fuel.rateUnit),
unitPrice: convert(total),
cargoTypeCode: scope.cargoType.code ?? null,
conditionalOn: 'has_fuel',
});
}
}
}
// Empty-container return service — container contracts only, toggled on the
// contract like hazard/reefer. Billed at booking per WITH_RETURN container.
if (

View File

@@ -140,6 +140,27 @@ export class ContractsRepository extends BaseRepository<Contract> {
return result.affected ?? 0;
}
/**
* Same-row version of expireLapsedContracts, for lazy flips on read/booking
* paths — flips this one contract to EXPIRED if it's lapsed and not already
* terminal. No-op (returns false) if the contract isn't actually lapsed, so
* callers can call this unconditionally without a pre-check.
*/
async expireIfLapsed(id: string): Promise<boolean> {
const result = await this.repository
.createQueryBuilder()
.update(Contract)
.set({ status: 'EXPIRED' })
.where('id = :id', { id })
.andWhere('deleted_at IS NULL')
.andWhere('status NOT IN (:...terminal)', { terminal: TERMINAL_CONTRACT_STATUSES })
.andWhere('contract_valid_until IS NOT NULL AND contract_valid_until < :now', {
now: new Date(),
})
.execute();
return (result.affected ?? 0) > 0;
}
/**
* Live contracts whose validity ends between `days` and `days + 1` days from
* now — the slice the daily expiry-reminder cron warns about. The window is

View File

@@ -811,6 +811,15 @@ export class ContractsService {
throw new NotFoundException(`Contract ${id} not found`);
}
// Lazy expiry flip: the nightly cron only sweeps once a day, so a
// contract can be past contract_valid_until for hours before it shows
// EXPIRED. Flip it here so the detail page never shows a stale status.
if (isEffectivelyExpired(contract) && contract.status !== 'EXPIRED') {
const flipped = await this.contractsRepository.expireIfLapsed(id);
if (flipped) {
contract.status = 'EXPIRED';
}
}
// Entry state for every contract flow (submit, approve, sign, suspend…) —
// see the equivalent in BookingsService.findById.
logCtx(

View File

@@ -70,6 +70,15 @@ export class CreateCargoTypeDto {
@IsBoolean()
hasLashing?: boolean;
@ApiPropertyOptional({
default: false,
description:
'When true, bookings of this cargo type incur the lane-scoped FUEL surcharge.',
})
@IsOptional()
@IsBoolean()
hasFuel?: boolean;
@ApiPropertyOptional({
default: false,
description:

View File

@@ -7,7 +7,8 @@ import {
RATE_UNITS,
} from '../entities/rate.entity';
const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'BOTH'] as const;
// DOMESTIC is accepted only for FUEL rates (an intercity fuel lane).
const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'BOTH', 'DOMESTIC'] as const;
// ETB is accepted only for last-mile rates; the service forces USD elsewhere.
const CURRENCIES = ['USD', 'ETB'] as const;
export const INTERCITY_KINDS = ['CONTAINER', 'BULK'] as const;
@@ -94,6 +95,17 @@ export class CreateRateDto {
@IsIn([...RATE_UNITS])
rateUnit?: string;
@ApiPropertyOptional({
description:
'FUEL rates billed PER_LITER only: liters the surcharge covers — price = baseLiters × rateValue, once per booking. Required there, rejected elsewhere.',
minimum: 0,
})
@IsOptional()
@IsNumber()
@Min(0)
@Transform(({ value }) => (value === null || value === undefined || value === '' ? undefined : Number(value)))
baseLiters?: number;
@ApiPropertyOptional({
description:
'Distance band start (km, inclusive). Container last-mile rates only (rateUnit = PER_KM).',

View File

@@ -82,6 +82,14 @@ export class CargoType extends BaseEntity {
@Column({ name: 'has_lashing', type: 'boolean', default: false })
hasLashing!: boolean;
/**
* Whether bookings of this cargo incur the fuel surcharge. Billed off the
* lane-scoped FUEL rate for the booking's direction + route + this cargo
* type (per liter or per wagon).
*/
@Column({ name: 'has_fuel', type: 'boolean', default: false })
hasFuel!: boolean;
/**
* Whether staff may write bulk contract templates against this cargo type.
* Mutually exclusive between a parent group and its children: if the parent

View File

@@ -46,6 +46,8 @@ export function deriveRateType(input: {
return 'PIL_EXTRA_FEE';
case 'CUSTOMS_CLEARANCE':
return 'CUSTOMS_CLEARANCE';
case 'FUEL':
return 'FUEL_SURCHARGE';
}
}

View File

@@ -74,6 +74,9 @@ function unitsForShape(input: {
case 'LASHING':
// Bulk-only cargo securing — per ton or per wagon.
return ['PER_TON', 'PER_WAGON'];
case 'FUEL':
// Per wagon (wagons × rate) or per liter (baseLiters × rate, once).
return ['PER_WAGON', 'PER_LITER'];
case 'CONSOLIDATION':
return ['PER_CONTAINER', 'FLAT'];
case 'SHIPPING_LINE':

View File

@@ -24,6 +24,7 @@ export const RATE_TYPES = [
'RETURN_SURCHARGE',
'PIL_EXTRA_FEE',
'CUSTOMS_CLEARANCE',
'FUEL_SURCHARGE',
] as const;
export type RateType = typeof RATE_TYPES[number];
@@ -41,6 +42,8 @@ export const RATE_UNITS = [
'PER_KM',
// Last-mile bulk: price = tons × km × rateValue.
'PER_TON_KM',
// Fuel surcharge only: price = baseLiters × rateValue, once per booking.
'PER_LITER',
'PER_INVOICE',
'FLAT',
] as const;
@@ -92,6 +95,9 @@ export const RATE_TRIGGERS = [
// Customs clearance service fee — billed up front via a clearance invoice,
// never auto-applied to booking pricing (matchesTrigger returns false).
'CUSTOMS_CLEARANCE',
// Fuel surcharge — fires when the booking's cargo type has hasFuel = true,
// billed off the lane-scoped rate (direction + route + cargo type).
'FUEL',
] as const;
export type RateTrigger = typeof RATE_TRIGGERS[number];
@@ -163,6 +169,14 @@ export class Rate extends BaseEntity {
* containerTypeId): the rate applies when minKm <= km < maxKm (maxKm NULL =
* open-ended). NULL on every other rate shape.
*/
/**
* FUEL rates billed PER_LITER only: the liters the surcharge covers —
* price = baseLiters × rateValue, once per booking. NULL on every other
* rate shape (a PER_WAGON fuel rate bills wagons × rateValue instead).
*/
@Column({ name: 'base_liters', type: 'numeric', precision: 14, scale: 4, nullable: true })
baseLiters?: number | null;
@Column({ name: 'min_km', type: 'numeric', precision: 10, scale: 2, nullable: true })
minKm?: number | null;

View File

@@ -422,3 +422,108 @@ describe('RuleEngineService — lashing (bulk-only, per direction + commodity)',
expect(lashingMods(result)).toHaveLength(0);
});
});
describe('RuleEngineService — fuel surcharge (per lane + cargo type)', () => {
const fuelPerLiter: Rate = {
id: 'rate-fuel-liter',
rateType: 'FUEL_SURCHARGE',
trigger: 'FUEL',
rateValue: 2,
rateUnit: 'PER_LITER',
baseLiters: 100,
currency: 'USD',
status: 'LIVE',
containerTypeId: null,
cargoTypeId: 'cargo-steel',
tradeDirection: 'IMPORT',
originYardId: 'yard-nagad',
destinationYardId: 'yard-mojo',
} as Rate;
const buildService = (rates: Rate[], hasFuel = true): RuleEngineService =>
new RuleEngineService(
{
findById: jest
.fn()
.mockResolvedValue({ hasFuel, hasLashing: false, requiresDirectorApproval: false }),
} as never,
{ findById: jest.fn().mockResolvedValue(null) } as never,
{ findActiveByContainerTypeId: jest.fn().mockResolvedValue([]) } as never,
{ findAllActive: jest.fn().mockResolvedValue([]) } as never,
{ findLiveRates: jest.fn().mockResolvedValue(rates) } as never,
{ findById: jest.fn().mockResolvedValue(null) } as never,
{} as never,
);
const fuelInput = (
overrides: Partial<BookingEvaluationInput> = {},
): BookingEvaluationInput => ({
serviceTypeId: 'svc-1',
paymentCurrency: 'USD',
tradeDirection: 'IMPORT',
isHazardous: false,
cargoTypeId: 'cargo-steel',
originYardId: 'yard-nagad',
destinationYardId: 'yard-mojo',
totalWagons: 0,
bulkTons: 100,
bulkWagons: 4,
containers: [],
...overrides,
});
const fuelMods = (result: Awaited<ReturnType<RuleEngineService['evaluate']>>) =>
result.appliedModifiers.filter((m) => m.surchargeCode === 'FUEL_SURCHARGE');
it('PER_LITER collapses to one flat total (base liters × rate value), regardless of wagons', async () => {
const result = await buildService([fuelPerLiter]).evaluate(fuelInput());
const mods = fuelMods(result);
expect(mods).toHaveLength(1);
// Flat: the customer sees only the total, and a frozen contract snapshot
// (also stored flat) multiplies it by quantity 1 — never by the liters.
expect(mods[0].triggerValue).toBe(1);
expect(mods[0].unitPriceUsd).toBe(200);
expect(mods[0].calculatedAmount).toBe(200);
expect(mods[0].billingUnit).toBe('FLAT');
});
it('PER_WAGON bills the wagons the cargo occupies', async () => {
const result = await buildService([
{ ...fuelPerLiter, rateUnit: 'PER_WAGON', baseLiters: null, rateValue: 50 } as Rate,
]).evaluate(fuelInput());
const mods = fuelMods(result);
expect(mods[0].triggerValue).toBe(4);
expect(mods[0].calculatedAmount).toBe(200);
});
it('a rate for another lane, direction or commodity never bills', async () => {
for (const wrong of [
{ tradeDirection: 'EXPORT' },
{ originYardId: 'yard-other' },
{ destinationYardId: 'yard-other' },
{ cargoTypeId: 'cargo-wheat' },
]) {
const result = await buildService([{ ...fuelPerLiter, ...wrong } as Rate]).evaluate(
fuelInput(),
);
expect(fuelMods(result)).toHaveLength(0);
}
});
it('a domestic booking bills the DOMESTIC fuel lane', async () => {
const result = await buildService([
{ ...fuelPerLiter, tradeDirection: 'DOMESTIC' } as Rate,
]).evaluate(fuelInput({ tradeDirection: 'DOMESTIC' }));
expect(fuelMods(result)).toHaveLength(1);
});
it('no fuel charge when the cargo type does not have hasFuel', async () => {
const result = await buildService([fuelPerLiter], false).evaluate(fuelInput());
expect(fuelMods(result)).toHaveLength(0);
});
it('no matching lane rate bills nothing (lenient, like lashing)', async () => {
const result = await buildService([]).evaluate(fuelInput());
expect(fuelMods(result)).toHaveLength(0);
});
});

View File

@@ -175,6 +175,9 @@ export class RuleEngineService {
// matchesTrigger can fire the LASHING rate. Falls back to an explicit
// input flag when no cargo type is set (e.g. container bookings).
let hasLashing = input.hasLashing === true;
// Fuel is likewise a cargo-type property (hasFuel), billed off the
// lane-scoped FUEL rate — see fuelCharges.
let hasFuel = false;
if (input.cargoTypeId) {
const cargoType = await this.cargoTypesRepo.findById(input.cargoTypeId);
if (!cargoType) {
@@ -186,6 +189,9 @@ export class RuleEngineService {
if (cargoType.hasLashing) {
hasLashing = true;
}
if (cargoType.hasFuel) {
hasFuel = true;
}
}
}
@@ -328,6 +334,9 @@ export class RuleEngineService {
// Lashing is sold per cargo kind + container type — billed by the
// kind-aware block below, never by this generic loop.
if (rate.trigger === 'LASHING') continue;
// Fuel is sold per lane + cargo type — billed by the route-matched
// block below, never by this route-agnostic loop.
if (rate.trigger === 'FUEL') continue;
const triggered = this.matchesTrigger(rate.trigger, {
isHazardous: input.isHazardous,
hasReefer,
@@ -442,6 +451,10 @@ export class RuleEngineService {
appliedModifiers.push(...this.lashingCharges(input, liveRates));
}
if (hasFuel) {
appliedModifiers.push(...this.fuelCharges(input, liveRates));
}
return {
priorityScore,
appliedModifiers,
@@ -632,6 +645,56 @@ export class RuleEngineService {
return modifiers;
}
/**
* Fuel surcharge — fires when the booking's cargo type has hasFuel = true,
* billed off the FUEL rate matching the booking's lane (trade direction +
* origin + destination) and cargo type. PER_LITER collapses to one FLAT
* amount (baseLiters × rateValue, once per booking) — the customer only ever
* sees the total, and the frozen contract snapshot stores that same flat
* figure so the snapshot-override math bills it exactly once. PER_WAGON
* bills the wagons the cargo occupies. No matching lane rate simply bills
* nothing — same leniency as lashing.
*/
private fuelCharges(
input: BookingEvaluationInput,
liveRates: Rate[],
): AppliedCargoModifier[] {
const modifiers: AppliedCargoModifier[] = [];
const rate = liveRates.find(
(r) =>
r.trigger === 'FUEL' &&
r.currency === 'USD' &&
r.tradeDirection === input.tradeDirection &&
r.originYardId === input.originYardId &&
r.destinationYardId === input.destinationYardId &&
r.cargoTypeId === input.cargoTypeId,
);
if (!rate) return modifiers;
const rateValue = Number(rate.rateValue);
const wagons = Math.max(
0,
Number(input.bulkWagons ?? 0) || Number(input.totalWagons ?? 0),
);
const perLiter = rate.rateUnit === 'PER_LITER';
const billedQty = perLiter ? 1 : wagons;
const unitPrice = perLiter
? Number(rate.baseLiters ?? 0) * rateValue
: rateValue;
const amount = billedQty * unitPrice;
if (!(amount > 0)) return modifiers;
modifiers.push({
rateId: rate.id,
surchargeCode: this.surchargeCode(rate),
triggerValue: billedQty,
calculatedAmount: amount,
currency: rate.currency,
unitPriceUsd: unitPrice,
billingUnit: perLiter ? 'FLAT' : rate.rateUnit,
});
return modifiers;
}
/**
* Messages for container lines whose total weight exceeds the hard capacity
* ceiling (weight_limit_rules.max_capacity_tons). Non-empty ⇒ the booking

View File

@@ -118,6 +118,19 @@ describe('RateChangeRequestsService', () => {
expect(request.payload).toEqual({ destinationYardId: 'yard-c' });
});
it('carries baseLiters — a switch to PER_LITER keeps its billing base', async () => {
const { service } = build({
rate: liveRate({ rateUnit: 'PER_WAGON', baseLiters: null }),
});
const request = await service.submit({
rateId: 'rate-1',
update: { rateUnit: 'PER_LITER', baseLiters: 3 },
});
expect(request.payload).toEqual({ rateUnit: 'PER_LITER', baseLiters: 3 });
});
it('rejects a no-op — 100 posted against a live 100.0000 is not a change', async () => {
const { service } = build();
await expect(

View File

@@ -42,6 +42,10 @@ const DIFFABLE_FIELDS = [
// LIVE last-mile rate would diff to "nothing changed".
'minKm',
'maxKm',
// PER_LITER fuel surcharge billing base. Missing here, a switch to PER_LITER
// dropped the submitted liters and validation failed with "needs a base
// liters amount" even though the payload carried one.
'baseLiters',
] as const;
/**

View File

@@ -121,15 +121,16 @@ export class RatesService {
}
/**
* Rates sold per direction + route. Base freight always; customs clearance
* and empty-container return are the surcharges that are too — their fee
* depends on the lane (and, for returns, the container type).
* Rates sold per direction + route. Base freight always; customs clearance,
* empty-container return and fuel are the surcharges that are too — their
* fee depends on the lane (and, for returns, the container type).
*/
private isRouteScoped(appliesTo: Rate['appliesTo'], trigger: Rate['trigger']): boolean {
return (
this.isBaseFreight(appliesTo, trigger) ||
trigger === 'CUSTOMS_CLEARANCE' ||
trigger === 'WITH_RETURN'
trigger === 'WITH_RETURN' ||
trigger === 'FUEL'
);
}
@@ -160,7 +161,9 @@ export class RatesService {
appliesTo: Rate['appliesTo'],
tradeDirection: string | null,
): { origin: YardCountry; destination: YardCountry } {
if (appliesTo === 'INTERCITY') {
// DOMESTIC only reaches here on a FUEL rate's intercity lane — it stays
// inside Ethiopia exactly like intercity base freight.
if (appliesTo === 'INTERCITY' || tradeDirection === 'DOMESTIC') {
return { origin: YardCountry.ETHIOPIA, destination: YardCountry.ETHIOPIA };
}
return tradeDirection === 'EXPORT'
@@ -291,6 +294,31 @@ export class RatesService {
}
return;
}
if (trigger === 'FUEL') {
// Fuel is sold per lane + commodity: the direction says which countries
// the leg spans (DOMESTIC = intercity, inside Ethiopia) and the cargo
// type names the commodity — different commodities price differently.
if (
tradeDirection !== 'IMPORT' &&
tradeDirection !== 'EXPORT' &&
tradeDirection !== 'DOMESTIC'
) {
throw new BadRequestException(
'A fuel rate must say whether it covers IMPORT, EXPORT or DOMESTIC (intercity).',
);
}
if (containerTypeId) {
throw new BadRequestException(
'A fuel rate cannot be scoped to a container type.',
);
}
if (!cargoTypeId) {
throw new BadRequestException(
'A fuel rate must name the cargo type it covers.',
);
}
return;
}
if (trigger === 'WITH_RETURN') {
// Returning the empty box only exists on imports (the box goes back to
// the port) — export return rates are rejected until the business sells
@@ -502,15 +530,21 @@ export class RatesService {
: (dto.containerTypeId ?? null);
const cargoTypeId =
(trigger === 'CUSTOMS_CLEARANCE' && cargoKind === 'BULK') ||
trigger === 'LASHING'
trigger === 'LASHING' ||
trigger === 'FUEL'
? (dto.cargoTypeId ?? null)
: isSurcharge
? null
: (dto.cargoTypeId ?? null);
// Intercity never leaves Ethiopia, so it has no trade direction to store —
// its yard pair already says where it runs.
// its yard pair already says where it runs. (Fuel is the exception: its
// intercity lane is stored as DOMESTIC, since appliesTo = OTHER says
// nothing about the direction.)
const tradeDirection =
trigger === 'CUSTOMS_CLEARANCE' || trigger === 'WITH_RETURN' || trigger === 'LASHING'
trigger === 'CUSTOMS_CLEARANCE' ||
trigger === 'WITH_RETURN' ||
trigger === 'LASHING' ||
trigger === 'FUEL'
? (dto.tradeDirection ?? null)
: isSurcharge || appliesTo === 'INTERCITY'
? null
@@ -563,6 +597,8 @@ export class RatesService {
await this.assertNoBandOverlap({ rateUnit, containerTypeId, minKm, maxKm });
}
const baseLiters = this.resolveBaseLiters(rateUnit, dto.baseLiters);
await this.assertNoDuplicatePattern({
rateType,
...(this.resolvesSingleRate(appliesTo, trigger) ? {} : { rateUnit }),
@@ -588,6 +624,7 @@ export class RatesService {
currency: appliesTo === 'LAST_MILE' ? (dto.currency ?? 'ETB') : 'USD',
rateValue: dto.rateValue,
rateUnit,
baseLiters,
minKm,
maxKm,
status: 'DRAFT',
@@ -595,6 +632,25 @@ export class RatesService {
});
}
/**
* The liters a PER_LITER fuel rate bills (price = baseLiters × rateValue,
* once per booking). Required there; cleared on every other rate shape —
* a PER_WAGON fuel rate bills wagons × rateValue and carries none.
*/
private resolveBaseLiters(
rateUnit: Rate['rateUnit'],
baseLiters?: number | null,
): number | null {
if (rateUnit !== 'PER_LITER') return null;
const liters = Number(baseLiters);
if (!(liters > 0)) {
throw new BadRequestException(
'A per-liter fuel rate needs a base liters amount — the price is base liters × rate value.',
);
}
return liters;
}
/**
* Update a DRAFT rate in place. Nothing prices off a draft, so a direct edit
* is safe. A LIVE rate cannot take this path — see `applyApprovedUpdate`.
@@ -680,14 +736,18 @@ export class RatesService {
const keepsCargoType =
!isSurcharge ||
(trigger === 'CUSTOMS_CLEARANCE' && cargoKind === 'BULK') ||
trigger === 'LASHING';
trigger === 'LASHING' ||
trigger === 'FUEL';
const cargoTypeId = !keepsCargoType
? null
: dto.cargoTypeId !== undefined
? dto.cargoTypeId
: existing.cargoTypeId;
const tradeDirection =
trigger === 'CUSTOMS_CLEARANCE' || trigger === 'WITH_RETURN' || trigger === 'LASHING'
trigger === 'CUSTOMS_CLEARANCE' ||
trigger === 'WITH_RETURN' ||
trigger === 'LASHING' ||
trigger === 'FUEL'
? dto.tradeDirection !== undefined
? dto.tradeDirection
: existing.tradeDirection
@@ -788,6 +848,11 @@ export class RatesService {
ignoreId: id,
});
updates.baseLiters = this.resolveBaseLiters(
rateUnit,
dto.baseLiters !== undefined ? dto.baseLiters : existing.baseLiters,
);
updates.currency =
appliesTo === 'LAST_MILE'
? (dto.currency ?? existing.currency ?? 'ETB')

View File

@@ -330,6 +330,8 @@ const DEFAULT_TRAIN_LIMITS: Required<TrainLimitConfig> = {
interface BookingWindowRow {
schedule_id: string;
reference: string | null;
/** Operational run number (e.g. 8001 import / 8002 export), typed by staff. */
train_number: string | null;
contract_id: string | null;
contract_kind: string | null;
direction: string | null;
@@ -5872,6 +5874,7 @@ export class TrainSchedulingService {
wagonSlots: schedule.trainSet?.wagons,
storedWagonCount: schedule.trainSet?.wagonCount,
scheduleBookings: schedule.scheduleBookings,
maxWagons: schedule.maxWagons,
});
return {
@@ -6847,6 +6850,7 @@ export class TrainSchedulingService {
`SELECT DISTINCT ON (ts.id)
ts.id AS schedule_id,
ts.reference AS reference,
ts.train_number,
cr.contract_id AS contract_id,
c.contract_kind AS contract_kind,
ts.direction,
@@ -6903,6 +6907,7 @@ export class TrainSchedulingService {
const rows: Array<BookingWindowRow> = await this.dataSource.query(
`SELECT DISTINCT ts.id AS schedule_id,
ts.reference AS reference,
ts.train_number,
cr.contract_id AS contract_id,
c.contract_kind AS contract_kind,
ts.direction,
@@ -6948,9 +6953,7 @@ export class TrainSchedulingService {
*/
async listAllBookingWindows() {
const rows: Array<
Omit<BookingWindowRow, 'contract_id' | 'contract_kind'> & {
train_number: string | null;
}
Omit<BookingWindowRow, 'contract_id' | 'contract_kind'>
> = await this.dataSource.query(
`SELECT ts.id AS schedule_id,
ts.reference AS reference,
@@ -6980,14 +6983,13 @@ export class TrainSchedulingService {
AND ts.scheduled_departure_date >= now()
ORDER BY ts.scheduled_departure_date ASC NULLS LAST`,
);
return rows.map((r) => ({
...this.mapBookingWindowRow({
return rows.map((r) =>
this.mapBookingWindowRow({
...r,
contract_id: null,
contract_kind: null,
}),
trainNumber: r.train_number,
}));
);
}
private mapBookingWindowRow(r: BookingWindowRow) {
@@ -7005,6 +7007,7 @@ export class TrainSchedulingService {
return {
scheduleId: r.schedule_id,
reference: r.reference ?? null,
trainNumber: r.train_number ?? null,
contractId: r.contract_id,
contractKind: r.contract_kind,
direction: r.direction,
@@ -9301,9 +9304,14 @@ export class TrainSchedulingService {
}
// Every schedule the target train is committed to, via its train sets.
// Locomotives come along: the merged train is pulled by the union of this
// schedule's locos and the target train's, so capacity checks need both.
const targetSets = await this.dataSource
.getRepository(TrainSet)
.find({ where: { trainId: targetTrainId } });
.find({
where: { trainId: targetTrainId },
relations: { locomotives: { locomotive: true }, locomotive: true },
});
const targetSetIds = targetSets.map((s) => s.id);
const targetSchedules = targetSetIds.length
? await this.dataSource.getRepository(TrainSchedule).find({
@@ -9346,6 +9354,24 @@ export class TrainSchedulingService {
.getRepository(Wagon)
.find({ where: { trainId: targetTrainId }, order: { wagonNumber: 'ASC' } });
// EVERY wagon physically on the source train moves with the merge — not
// just the ones coupled into this schedule's set. A wagon left behind
// would strand on the deactivated train. "Loose" = on the train but not
// backing a set slot; it joins the counts and the capacity math.
const sourceWagons = sourceTrainId
? await this.dataSource
.getRepository(Wagon)
.find({ where: { trainId: sourceTrainId }, order: { wagonNumber: 'ASC' } })
: [];
const coupledPhysicalIds = new Set(
(schedule.trainSet?.wagons ?? [])
.map((w) => w.physicalWagonId)
.filter(Boolean),
);
const looseSourceWagons = sourceWagons.filter(
(w) => !coupledPhysicalIds.has(w.id),
);
const movingBookings = absorbed
? await this.dataSource.getRepository(TrainScheduleBooking).find({
where: { trainScheduleId: absorbed.id },
@@ -9357,10 +9383,12 @@ export class TrainSchedulingService {
schedule,
sourceTrainId,
targetTrain,
targetSets,
absorbed,
affectedOthers,
untouched,
incomingWagons,
looseSourceWagons,
movingBookings,
};
}
@@ -9382,14 +9410,20 @@ export class TrainSchedulingService {
);
}
// ── Capacity: the merged consist must fit this schedule's locomotives ────
// ── Capacity: the merged consist must fit the merged train's locomotives ─
// Existing side = coupled set slots PLUS loose wagons riding the source
// train without a slot — they all move, so they all count.
const existingSlots = (schedule.trainSet?.wagons ?? []).map((w) => ({
lengthMeters: Number(w.lengthMeters) || 0,
tareWeightTons: Number(w.wagonType?.tareWeightTons) || 0,
cargoTons: 0,
}));
const wagonTypeIds = [
...new Set(incomingWagons.map((w) => w.wagonTypeId).filter(Boolean)),
...new Set(
[...incomingWagons, ...plan.looseSourceWagons]
.map((w) => w.wagonTypeId)
.filter(Boolean),
),
];
const wagonTypes = wagonTypeIds.length
? await this.dataSource
@@ -9397,16 +9431,27 @@ export class TrainSchedulingService {
.find({ where: { id: In(wagonTypeIds) } })
: [];
const typeById = new Map(wagonTypes.map((t) => [t.id, t]));
const incomingSlots = incomingWagons.map((w) => {
const slotFromWagon = (w: Wagon) => {
const t = typeById.get(w.wagonTypeId);
return {
lengthMeters: Number(t?.lengthMeters) || 0,
tareWeightTons: Number(t?.tareWeightTons) || 0,
cargoTons: 0,
};
});
};
const incomingSlots = incomingWagons.map(slotFromWagon);
const looseSlots = plan.looseSourceWagons.map(slotFromWagon);
const limits = trainSetLocomotiveLimits(schedule.trainSet);
// The merged train is pulled by the union of this schedule's locomotives
// and whatever already pulls the target train (its sets keep their locos).
// Pull weight adds up across the pool; length stays the tightest cap.
const locoPool = [
...this.locomotivesOfTrainSet(schedule.trainSet),
...plan.targetSets.flatMap((set) => this.locomotivesOfTrainSet(set)),
];
const limits = combinedLocomotiveLimits([
...new Map(locoPool.map((l) => [l.id, l])).values(),
]);
if (limits) {
const rules = await this.dataSource
.getRepository(TrainSchedulingGlobalRules)
@@ -9415,13 +9460,17 @@ export class TrainSchedulingService {
maxTrainWeightTons: rules[0]?.maxTrainWeightTons ?? undefined,
maxTrainLengthMeters: rules[0]?.maxTrainLengthMeters ?? undefined,
});
const merged = [...existingSlots, ...incomingSlots];
// maxWagons is the schedule's own slot ceiling; fall back to the consist
// size when it is unset so the count axis never blocks spuriously.
const merged = [...existingSlots, ...looseSlots, ...incomingSlots];
// Merge is a physical consist move, so only the physical axes gate it:
// can this schedule's locomotives pull the merged weight and length.
// `schedule.maxWagons` is the booking-window planning ceiling — using it
// as a slot cap here blocked every merge into a bigger train (e.g. a
// 3-wagon plan absorbing a 47-wagon train). The commit raises the
// ceiling to the merged size instead.
const violations = consistViolations(merged, {
maxWeightTons: caps.maxWeightTons,
maxLengthMeters: caps.maxLengthMeters,
maxWagonSlots: schedule.maxWagons || merged.length,
maxWagonSlots: merged.length,
});
blockers.push(...violations);
}
@@ -9481,7 +9530,10 @@ export class TrainSchedulingService {
const plan = await this.planMerge(scheduleId, targetTrainId);
const blockers = await this.mergeBlockers(plan);
const existingCount = plan.schedule.trainSet?.wagons?.length ?? 0;
// Coupled slots plus loose wagons on the source train — everything moves.
const existingCount =
(plan.schedule.trainSet?.wagons?.length ?? 0) +
plan.looseSourceWagons.length;
return {
canMerge: blockers.length === 0,
blockers,
@@ -9555,13 +9607,20 @@ export class TrainSchedulingService {
trainId: targetTrain.id,
});
// 2. The physical wagons follow the train.
// 2. The physical wagons follow the train — the target's stay put, and
// EVERY wagon on the source train (coupled or loose) moves across so
// nothing strands on the deactivated train.
if (incomingWagons.length) {
await manager.getRepository(Wagon).update(
{ id: In(incomingWagons.map((w) => w.id)) },
{ trainId: targetTrain.id },
);
}
if (sourceTrainId) {
await manager
.getRepository(Wagon)
.update({ trainId: sourceTrainId }, { trainId: targetTrain.id });
}
// 3. Carry the target's train-set wagon rows into THIS consist, appended
// after the existing wagons. Sequence is provisional — staff reorder
@@ -9611,6 +9670,14 @@ export class TrainSchedulingService {
await manager
.getRepository(TrainSet)
.update(trainSetId, { wagonCount: mergedCount });
// 8. Booking capacity follows the consist: raise (never lower) the
// planning ceiling so the merged wagons are actually sellable.
if (mergedCount > (schedule.maxWagons ?? 0)) {
await manager
.getRepository(TrainSchedule)
.update(schedule.id, { maxWagons: mergedCount });
}
});
this.logger.log(

View File

@@ -68,6 +68,23 @@ describe('computeScheduleWagonUsage', () => {
expect(usage.wagonsRemaining).toBe(0);
});
it('sells against the planned ceiling, not the partially coupled consist', () => {
// S-2026-00003: planned 3 wagons, 2 coupled + allocated for a paid booking
// that reserved 2 — the list read "2/2 used, 0 bookable" while the detail
// page and the booking gate (remainingWagonsForLeg vs maxWagons) both said
// 1 wagon was still free. Wagons couple on demand; the ceiling is capacity.
const usage = computeScheduleWagonUsage({
wagonSlots: Array(2).fill(slot(true)),
storedWagonCount: 2,
scheduleBookings: [booking(2)],
maxWagons: 3,
});
expect(usage.wagonsUsed).toBe(2);
expect(usage.wagonsTotal).toBe(3);
expect(usage.wagonsRemaining).toBe(1);
});
it('falls back to the stored counter when slot rows were not loaded', () => {
const usage = computeScheduleWagonUsage({
wagonSlots: [],

View File

@@ -20,11 +20,11 @@ export interface ScheduleBookingLike {
export interface ScheduleWagonUsage {
/** Coupled slots carrying at least one booking allocation. */
wagonsUsed: number;
/** Coupled consist size — the denominator of `wagonsUsed`. */
/** Schedule capacity — the larger of coupled consist and planned ceiling. */
wagonsTotal: number;
/** Wagons claimed by bookings, including bookings that have not paid. */
wagonsReserved: number;
/** Consist minus what bookings have claimed — what is still bookable. */
/** Capacity minus what bookings have claimed — what is still bookable. */
wagonsRemaining: number;
}
@@ -33,6 +33,8 @@ export function computeScheduleWagonUsage(input: {
/** Stored counter; used only when the slot rows were not loaded. */
storedWagonCount?: number | null;
scheduleBookings?: ScheduleBookingLike[] | null;
/** Planned wagon ceiling (`maxWagons`) — what booking capacity is sold against. */
maxWagons?: number | null;
}): ScheduleWagonUsage {
const slots = input.wagonSlots ?? [];
@@ -42,7 +44,14 @@ export function computeScheduleWagonUsage(input: {
// Prefer live slot rows; the stored counter drifts when a consist is edited
// without a recompute, which is why the list and detail disagreed on totals.
const wagonsTotal = slots.length || (input.storedWagonCount ?? 0);
const coupled = slots.length || (input.storedWagonCount ?? 0);
// Wagons are coupled on demand as bookings are allocated, so a partially
// built consist does not cap what is bookable — the planned ceiling does
// (remainingWagonsForLeg sells against maxWagons). Without this, a schedule
// planned for 3 wagons with 2 coupled+allocated read "2/2 used, 0 bookable"
// while its detail page and the booking gate both said 1 wagon was free.
const wagonsTotal = Math.max(coupled, input.maxWagons ?? 0);
// An unpaid booking still holds its wagons, so reserved space is NOT bookable.
const wagonsReserved = (input.scheduleBookings ?? []).reduce(

View File

@@ -0,0 +1,172 @@
import { useState } from "react";
import { Mail, Loader2 } from "lucide-react";
import { useMutation } from "@tanstack/react-query";
import toast from "react-hot-toast";
import { api } from "@/services/api";
import { useAuth } from "@/auth/useAuth";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Button,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
Input,
Label,
} from "@edr/ui-common";
/**
* Lets the signed-in backoffice user change their own email. Goes through
* /me/contact/otp + /me/contact rather than the generic (unverified)
* /auth/update-profile route, so the new address is proven before it's
* written — see account.controller.ts on the API side.
*/
export function ChangeEmailCard() {
const { user } = useAuth();
const sendOtpMutation = useMutation(
api.account.sendContactOtp.mutationOptions(),
);
const updateContactMutation = useMutation(
api.account.updateContact.mutationOptions(),
);
const [open, setOpen] = useState(false);
const [step, setStep] = useState<"enterEmail" | "enterOtp">("enterEmail");
const [newEmail, setNewEmail] = useState("");
const [otp, setOtp] = useState("");
const [formError, setFormError] = useState("");
const closeDialog = () => {
setOpen(false);
setStep("enterEmail");
setNewEmail("");
setOtp("");
setFormError("");
};
const sendOtp = () => {
setFormError("");
if (!newEmail.trim()) {
setFormError("Enter the new email address.");
return;
}
sendOtpMutation.mutate(
{ channel: "email", value: newEmail.trim() },
{
onSuccess: (result) => {
toast.success(`Verification code sent to ${result.sentTo}`);
setStep("enterOtp");
},
},
);
};
const confirmOtp = () => {
setFormError("");
if (!otp.trim()) {
setFormError("Enter the verification code.");
return;
}
updateContactMutation.mutate(
{ channel: "email", value: newEmail.trim(), otp: otp.trim() },
{
onSuccess: () => {
toast.success("Email updated.");
closeDialog();
// Refetches the session so the new email shows everywhere — simplest
// way to refresh the cached user without a dedicated context method.
window.location.reload();
},
},
);
};
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Mail className="size-4" />
Email
</CardTitle>
<CardDescription>
{user?.email ? `Current email: ${user.email}` : "Change your account email."}
</CardDescription>
</CardHeader>
<CardContent>
<Button variant="outline" size="sm" onClick={() => setOpen(true)}>
Change email
</Button>
</CardContent>
<Dialog open={open} onOpenChange={(next) => !next && closeDialog()}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Change email</DialogTitle>
<DialogDescription>
{step === "enterEmail"
? "We'll send a verification code to the new address."
: `Enter the code sent to ${newEmail}.`}
</DialogDescription>
</DialogHeader>
{step === "enterEmail" ? (
<div className="space-y-2">
<Label htmlFor="newEmail">New email</Label>
<Input
id="newEmail"
type="email"
value={newEmail}
onChange={(e) => setNewEmail(e.target.value)}
/>
{formError && <p className="text-sm text-destructive">{formError}</p>}
</div>
) : (
<div className="space-y-2">
<Label htmlFor="otp">Verification code</Label>
<Input
id="otp"
value={otp}
onChange={(e) => setOtp(e.target.value)}
/>
{formError && <p className="text-sm text-destructive">{formError}</p>}
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={closeDialog}>
Cancel
</Button>
{step === "enterEmail" ? (
<Button disabled={sendOtpMutation.isPending} onClick={sendOtp}>
{sendOtpMutation.isPending ? (
<Loader2 className="size-4 animate-spin" />
) : (
"Send code"
)}
</Button>
) : (
<Button disabled={updateContactMutation.isPending} onClick={confirmOtp}>
{updateContactMutation.isPending ? (
<Loader2 className="size-4 animate-spin" />
) : (
"Confirm"
)}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
</Card>
);
}

View File

@@ -0,0 +1,154 @@
import { useState } from "react";
import { KeyRound, Loader2 } from "lucide-react";
import { useMutation } from "@tanstack/react-query";
import toast from "react-hot-toast";
import { api } from "@/services/api";
import { useAuth } from "@/auth/useAuth";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Button,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
Input,
Label,
} from "@edr/ui-common";
/**
* Lets the signed-in backoffice user change their own password. The account
* is logged out on success — the old token was issued under the old
* password, and this forces a clean re-login rather than trusting the
* server to keep the existing session valid.
*/
export function ChangePasswordCard() {
const { logout } = useAuth();
const changePasswordMutation = useMutation(
api.account.changePassword.mutationOptions(),
);
const [open, setOpen] = useState(false);
const [oldPassword, setOldPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [formError, setFormError] = useState("");
const closeDialog = () => {
setOpen(false);
setOldPassword("");
setNewPassword("");
setConfirmPassword("");
setFormError("");
};
const submit = () => {
setFormError("");
if (!oldPassword || !newPassword || !confirmPassword) {
setFormError("All fields are required.");
return;
}
if (newPassword.length < 8) {
setFormError("New password must be at least 8 characters.");
return;
}
if (newPassword === oldPassword) {
setFormError("New password must be different from the current one.");
return;
}
if (newPassword !== confirmPassword) {
setFormError("New password and confirmation do not match.");
return;
}
changePasswordMutation.mutate(
{ oldPassword, newPassword, confirmPassword },
{
onSuccess: () => {
toast.success("Password changed. Please sign in again.");
closeDialog();
setTimeout(logout, 1200);
},
},
);
};
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<KeyRound className="size-4" />
Password
</CardTitle>
<CardDescription>Change the password for your account.</CardDescription>
</CardHeader>
<CardContent>
<Button variant="outline" size="sm" onClick={() => setOpen(true)}>
Change password
</Button>
</CardContent>
<Dialog open={open} onOpenChange={(next) => !next && closeDialog()}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Change password</DialogTitle>
<DialogDescription>
You'll be signed out and asked to log in again once it's changed.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="oldPassword">Current password</Label>
<Input
id="oldPassword"
type="password"
value={oldPassword}
onChange={(e) => setOldPassword(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="newPassword">New password</Label>
<Input
id="newPassword"
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="confirmPassword">Confirm new password</Label>
<Input
id="confirmPassword"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
/>
</div>
{formError && <p className="text-sm text-destructive">{formError}</p>}
</div>
<DialogFooter>
<Button variant="outline" onClick={closeDialog}>
Cancel
</Button>
<Button disabled={changePasswordMutation.isPending} onClick={submit}>
{changePasswordMutation.isPending ? (
<Loader2 className="size-4 animate-spin" />
) : (
"Change password"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</Card>
);
}

View File

@@ -319,7 +319,7 @@ export const TopBar = () => {
{t("header.viewProfile")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => navigate("/change-password")}
onClick={() => navigate("/dashboard/profile")}
className="cursor-pointer hover:bg-primary-50 dark:hover:bg-primary-900/30 hover:text-primary-700 dark:hover:text-primary-400 py-2.5">
<Key className="w-4 h-4 mr-3" />
{t("header.changePassword")}

View File

@@ -502,7 +502,7 @@ const Header = () => {
<DropdownMenuItem
className="flex items-center gap-3 px-3 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-primary-50 dark:hover:bg-primary-900/30 hover:text-primary-700 dark:hover:text-primary-400 rounded-lg cursor-pointer transition-colors"
onClick={() => navigate("/change-password")}
onClick={() => navigate("/dashboard/profile")}
>
<Key className="w-4 h-4 text-primary-600 dark:text-primary-400" />
<span className="font-medium">

View File

@@ -38,7 +38,10 @@ import {
} from "@/hooks/contract-templates/useContractTemplates";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import { cargoTypesService } from "@/services/cargo-types.service";
import type { ContractTemplate } from "@/services/contract-templates.service";
import type {
BulkTemplateDirection,
ContractTemplate,
} from "@/services/contract-templates.service";
import TemplatePreviewModal from "./TemplatePreviewModal";
const DIRECTION_LABEL: Record<string, string> = {
@@ -68,6 +71,14 @@ function customsVariant(template: ContractTemplate): boolean | null {
return null;
}
// Bulk templates carry the direction on the row; the fixed container codes
// carry it as the code prefix.
function directionOf(template: ContractTemplate): string {
return isBulk(template)
? template.tradeDirection ?? "INTERCITY"
: template.code.split("_")[0];
}
function formatUpdated(value: string): string {
return new Date(value).toLocaleDateString("en-GB", {
day: "numeric",
@@ -105,7 +116,7 @@ export default function ContractTemplatesPage() {
<PageContainer>
<PageHeader
title="Contract templates"
subtitle="The five container contract documents are built in — one per trade direction and customs-clearing option. Bulk contracts are written per cargo type: create one template per commodity and customs option. Articles are fully editable."
subtitle="The five container contract documents are built in — one per trade direction and customs-clearing option. Bulk contracts are written per cargo type: create one template per trade direction, customs option and commodity. Articles are fully editable."
action={
canCreate ? (
<Button
@@ -194,9 +205,13 @@ export default function ContractTemplatesPage() {
}
/**
* Staff pick the customs option first, then a bulk cargo type that has
* "has contract template" enabled. One template per combination — the API
* rejects duplicates, so an existing pairing must be edited instead.
* Staff pick the trade direction, then the customs option, then a bulk cargo
* type that has "has contract template" enabled. One template per
* (direction, customs, cargo type) combination — the API rejects duplicates,
* so an existing combination must be edited instead.
*
* Intercity is domestic and crosses no border, so the customs choice does not
* apply there and is hidden.
*/
function CreateTemplateModal({
opened,
@@ -207,9 +222,11 @@ function CreateTemplateModal({
onClose: () => void;
onCreated: (code: string) => void;
}) {
const [direction, setDirection] = useState<BulkTemplateDirection>("IMPORT");
const [withCustoms, setWithCustoms] = useState<string>("true");
const [cargoTypeId, setCargoTypeId] = useState<string | null>(null);
const create = useCreateContractTemplate();
const intercity = direction === "INTERCITY";
const { data: cargoTypes, isLoading } = useQuery({
queryKey: ["cargo-types", "contract-template-options"],
@@ -238,19 +255,42 @@ function CreateTemplateModal({
<Stack gap="md">
<div>
<Text size="sm" fw={500} mb={6}>
Customs clearing
Trade direction
</Text>
<SegmentedControl
fullWidth
value={withCustoms}
onChange={setWithCustoms}
value={direction}
onChange={(value) => setDirection(value as BulkTemplateDirection)}
data={[
{ value: "true", label: "With customs clearing" },
{ value: "false", label: "Without customs clearing" },
{ value: "IMPORT", label: "Import" },
{ value: "EXPORT", label: "Export" },
{ value: "INTERCITY", label: "Intercity" },
]}
/>
</div>
{intercity ? (
<Text size="xs" c="dimmed">
Intercity contracts are domestic and cross no border, so they have
no customs clearing variant one template per cargo type.
</Text>
) : (
<div>
<Text size="sm" fw={500} mb={6}>
Customs clearing
</Text>
<SegmentedControl
fullWidth
value={withCustoms}
onChange={setWithCustoms}
data={[
{ value: "true", label: "With customs clearing" },
{ value: "false", label: "Without customs clearing" },
]}
/>
</div>
)}
<Select
label="Bulk cargo type"
description="Only cargo types with “has contract template” enabled are listed"
@@ -273,7 +313,12 @@ function CreateTemplateModal({
onClick={() => {
if (!cargoTypeId) return;
create.mutate(
{ cargoTypeId, withCustoms: withCustoms === "true" },
{
cargoTypeId,
tradeDirection: direction,
// Omitted for intercity — the API rejects the flag there.
...(intercity ? {} : { withCustoms: withCustoms === "true" }),
},
{
onSuccess: (template) =>
onCreated((template as ContractTemplate).code),
@@ -305,10 +350,12 @@ function TemplateCard({
onDelete: () => void;
}) {
const bulk = isBulk(template);
const direction = template.code.split("_")[0];
const direction = directionOf(template);
const customs = customsVariant(template);
const kicker = bulk
? `${template.cargoType?.cargoTypeName ?? "Bulk cargo"} · Bulk`
? `${template.cargoType?.cargoTypeName ?? "Bulk cargo"} · ${
DIRECTION_LABEL[direction] ?? direction
} · Bulk`
: `${DIRECTION_LABEL[direction] ?? direction} · Container`;
return (
@@ -336,9 +383,8 @@ function TemplateCard({
style={{
borderRadius: 999,
flexShrink: 0,
background: bulk
? "var(--mantine-color-teal-5)"
: DIRECTION_DOT[direction] ?? "var(--mantine-color-gray-5)",
background:
DIRECTION_DOT[direction] ?? "var(--mantine-color-gray-5)",
}}
/>
<Text size="xs" fw={600} tt="uppercase" lts="0.06em" c="dimmed">

View File

@@ -1,8 +1,12 @@
import { MySignatureCard } from "@/components/profile/MySignatureCard";
import { ChangePasswordCard } from "@/components/profile/ChangePasswordCard";
import { ChangeEmailCard } from "@/components/profile/ChangeEmailCard";
export default function MyProfilePage() {
return (
<div className="mx-auto w-full max-w-2xl space-y-6 p-4">
<ChangeEmailCard />
<ChangePasswordCard />
<div id="signature">
<MySignatureCard />
</div>

View File

@@ -55,6 +55,8 @@ interface CargoNode extends RuleEngineRecord {
requiresDirectorApproval?: boolean;
/** When true, bookings of this cargo type incur the flat LASHING surcharge. */
hasLashing?: boolean;
/** When true, bookings of this cargo type incur the lane-scoped FUEL surcharge. */
hasFuel?: boolean;
/** Staff may write bulk contract templates for this cargo type (parent XOR children). */
hasContractTemplate?: boolean;
/** How this cargo is measured (PER_TON / PER_ITEM); null for groups/unset. */
@@ -114,6 +116,9 @@ const FORM_FIELDS: FormFieldDef[] = [
// When on, every booking of this cargo type is charged the flat LASHING
// surcharge (a rate with trigger = Lashing).
{ name: "hasLashing", label: "Charge lashing fee", type: "boolean" },
// When on, bookings of this cargo type incur the fuel surcharge, billed off
// the lane-scoped FUEL rate (direction + route + cargo type) under Rates.
{ name: "hasFuel", label: "Charge fuel fee", type: "boolean" },
// Lets staff write bulk contract templates for this cargo type. The API
// rejects the save when the parent group (or a child) already has it on —
// the template must live on exactly one level.

View File

@@ -100,19 +100,27 @@ const yardOptionsForLegEnd = (
} else if (
appliesTo === "CONTAINER" ||
appliesTo === "BULK" ||
// Customs clearance + empty-container return are sold per direction +
// route, so their yard dropdowns narrow exactly like base freight.
// Customs clearance, empty-container return and fuel are sold per
// direction + route, so their yard dropdowns narrow exactly like base
// freight.
(appliesTo === "OTHER" &&
["CUSTOMS_CLEARANCE", "WITH_RETURN"].includes(String(values.trigger ?? "")))
["CUSTOMS_CLEARANCE", "WITH_RETURN", "FUEL"].includes(
String(values.trigger ?? ""),
))
) {
const direction = String(values.tradeDirection ?? "");
// Direction is what decides the countries, so offer nothing until it is set
// rather than defaulting to one and letting it read as a real choice.
if (direction !== "IMPORT" && direction !== "EXPORT") return [];
const startsInEthiopia = direction === "EXPORT";
country = (end === "origin" ? startsInEthiopia : !startsInEthiopia)
? "Ethiopia"
: "Djibouti";
if (direction === "DOMESTIC") {
// A fuel rate's intercity lane — stays inside Ethiopia.
country = "Ethiopia";
} else {
if (direction !== "IMPORT" && direction !== "EXPORT") return [];
const startsInEthiopia = direction === "EXPORT";
country = (end === "origin" ? startsInEthiopia : !startsInEthiopia)
? "Ethiopia"
: "Djibouti";
}
}
if (!country) return [];
return yards

View File

@@ -181,6 +181,17 @@ const RATE_TRIGGERS = [
{ label: "Cancellation", value: "CANCELLATION" },
{ label: "Shipping line extra fee (PIL)", value: "PIL_EXTRA_FEE" },
{ label: "Customs clearance service fee (billed with the booking)", value: "CUSTOMS_CLEARANCE" },
{ label: "Fuel (per lane + cargo type)", value: "FUEL" },
];
/**
* Fuel lanes: import/export like base freight, plus a domestic intercity lane
* (stored as DOMESTIC — matches the booking's own trade direction).
*/
const FUEL_TRADE_DIRECTIONS = [
{ label: "Import", value: "IMPORT" },
{ label: "Export", value: "EXPORT" },
{ label: "Intercity", value: "DOMESTIC" },
];
/**
@@ -205,7 +216,7 @@ const isBaseFreightRate = (values: Record<string, unknown>) =>
const isRouteScopedRate = (values: Record<string, unknown>) =>
isBaseFreightRate(values) ||
(String(values.appliesTo ?? "") === "OTHER" &&
["CUSTOMS_CLEARANCE", "WITH_RETURN"].includes(String(values.trigger ?? "")));
["CUSTOMS_CLEARANCE", "WITH_RETURN", "FUEL"].includes(String(values.trigger ?? "")));
const unitOption = (value: string) => ({ label: value.replace(/_/g, " "), value });
@@ -255,6 +266,9 @@ const unitsForShape = (
case "LASHING":
// Bulk-only cargo securing — per ton or per wagon.
return ["PER_TON", "PER_WAGON"];
case "FUEL":
// Per wagon (wagons × rate) or per liter (base liters × rate, once).
return ["PER_WAGON", "PER_LITER"];
case "CONSOLIDATION":
case "SHIPPING_LINE":
case "PIL_EXTRA_FEE":
@@ -371,6 +385,13 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
},
{ name: "requiresDirectorApproval", label: "Requires director approval", type: "boolean" },
{ name: "hasLashing", label: "Charge lashing fee", type: "boolean" },
{
name: "hasFuel",
label: "Charge fuel fee",
type: "boolean",
description:
"Bookings of this cargo incur the fuel surcharge (configure the FUEL rate per lane under Rates).",
},
{ name: "isActive", label: "Active", type: "boolean" },
],
},
@@ -834,7 +855,7 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
filters: {
appliesTo: "OTHER",
trigger:
"HAZARDOUS,OVERWEIGHT,REEFER,SHIPPING_LINE,CONSOLIDATION,LASHING,CANCELLATION,PIL_EXTRA_FEE",
"HAZARDOUS,OVERWEIGHT,REEFER,SHIPPING_LINE,CONSOLIDATION,LASHING,CANCELLATION,PIL_EXTRA_FEE,FUEL",
},
},
],
@@ -886,14 +907,17 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
type: "select",
required: true,
optionsFromValues: (v: Record<string, unknown>) =>
String(v.trigger ?? "") === "WITH_RETURN" &&
String(v.appliesTo ?? "") === "OTHER"
String(v.appliesTo ?? "") === "OTHER" &&
String(v.trigger ?? "") === "WITH_RETURN"
? TRADE_DIRECTIONS.filter((d) => d.value === "IMPORT")
: TRADE_DIRECTIONS.filter((d) => d.value !== "BOTH"),
: String(v.appliesTo ?? "") === "OTHER" &&
String(v.trigger ?? "") === "FUEL"
? FUEL_TRADE_DIRECTIONS
: TRADE_DIRECTIONS.filter((d) => d.value !== "BOTH"),
showIf: (v) =>
["BULK", "CONTAINER"].includes(String(v.appliesTo ?? "")) ||
(String(v.appliesTo ?? "") === "OTHER" &&
["CUSTOMS_CLEARANCE", "WITH_RETURN", "LASHING"].includes(
["CUSTOMS_CLEARANCE", "WITH_RETURN", "LASHING", "FUEL"].includes(
String(v.trigger ?? ""),
)),
},
@@ -939,6 +963,18 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
v.trigger === "CUSTOMS_CLEARANCE" &&
v.cargoKind === "BULK",
},
// ── Cargo type — a fuel rate names the commodity it covers (different
// commodities price differently on the same lane) ──────────────────────
{
name: "cargoTypeId",
label: "Cargo type",
type: "select",
required: true,
placeholder: "Which cargo type this fuel rate covers",
description:
"Fuel is charged for bookings of this cargo type (needs “Charge fuel fee” enabled on the cargo type).",
showIf: (v) => v.appliesTo === "OTHER" && v.trigger === "FUEL",
},
// ── Bulk cargo type — lashing is bulk-only; may narrow to one leaf
// commodity (specific wins over the commodity-wide catch-all) ──────────
{
@@ -1123,6 +1159,20 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
String(v.trigger ?? "") !== "OVERWEIGHT" &&
String(v.appliesTo ?? "") !== "LAST_MILE",
},
// ── Base liters — per-liter fuel rates only ────────────────────────────
{
name: "baseLiters",
label: "Base (liters)",
type: "number",
required: true,
placeholder: "e.g. 100",
description:
"Liters the surcharge covers — price = base liters × rate value, charged once per booking.",
showIf: (v) =>
v.appliesTo === "OTHER" &&
v.trigger === "FUEL" &&
v.rateUnit === "PER_LITER",
},
],
},
{

View File

@@ -1019,14 +1019,11 @@ export default function TrainScheduleV2ListPage() {
/**
* The row's wagon chips, matching the detail page's wagon plan: used is slots
* carrying a booking allocation (never the coupled consist size), and remaining
* excludes wagons reserved by bookings that have not paid yet — that space is
* claimed, so it is not bookable.
*
* Schedules whose train set has not been built yet have no consist to measure,
* so both figures fall back to the schedule's planned `maxWagons` ceiling.
* Without that fallback an unbuilt 37-wagon schedule reads "0 bookable" even
* though every one of its wagons is still free.
* carrying a booking allocation, the denominator is the schedule's capacity
* (API-computed: the larger of coupled consist and planned `maxWagons`, since
* wagons are coupled on demand), and remaining excludes wagons reserved by
* bookings that have not paid yet — that space is claimed, so it is not
* bookable.
*/
function WagonChips({ schedule }: { schedule: TrainScheduleListItem }) {
// Pre-deploy API rows carry only wagonCount; fall back so the chip still
@@ -1034,17 +1031,7 @@ function WagonChips({ schedule }: { schedule: TrainScheduleListItem }) {
const total = schedule.wagonsTotal ?? schedule.wagonCount;
const used = schedule.wagonsUsed;
const reserved = schedule.wagonsReserved ?? 0;
// Until the train set is built there is no consist to measure against, so
// `wagonsRemaining` (consist minus claimed) is 0 on every unbuilt schedule —
// which reads as "fully booked" when in fact nothing is booked at all. Before
// a consist exists, capacity is the planned ceiling minus what bookings have
// already claimed.
const planCeiling = schedule.maxWagons ?? 0;
const remaining =
total === 0 && planCeiling > 0
? Math.max(0, planCeiling - Math.max(used ?? 0, reserved))
: schedule.wagonsRemaining;
const remaining = schedule.wagonsRemaining;
if (used == null) {
return <MetricChip value={total} label="wgn" subtle />;
@@ -1052,11 +1039,9 @@ function WagonChips({ schedule }: { schedule: TrainScheduleListItem }) {
return (
<>
{/* An unbuilt consist has no "used out of coupled" to show; the plan
ceiling is the only meaningful denominator at that point. */}
<MetricChip
value={total === 0 && planCeiling > 0 ? `${used}/${planCeiling}` : `${used}/${total}`}
label={total === 0 && planCeiling > 0 ? "wgn planned" : "wgn used"}
value={`${used}/${total}`}
label={schedule.wagonCount === 0 ? "wgn planned" : "wgn used"}
/>
{reserved > used ? (
<MetricChip value={reserved} label="reserved" subtle />

View File

@@ -551,7 +551,7 @@ const Top: React.FC<HeaderProps> = ({
<DropdownMenuItem
className="flex cursor-pointer items-center rounded-lg px-3 py-2.5 text-sm text-gray-700 transition-colors hover:bg-primary-50 dark:text-gray-200 dark:hover:bg-primary-900/20"
onClick={() => navigate("/change-password")}
onClick={() => navigate("/dashboard/profile")}
>
<Key className="mr-2.5 h-4 w-4 text-primary-600 dark:text-primary-400" />
<span>{t("header.changePassword")}</span>

View File

@@ -0,0 +1,50 @@
import { api as client } from "../auth/http";
import { unwrap } from "@/utils/endpoint";
export type ContactChannel = "email" | "phone";
export interface ChangePasswordPayload {
oldPassword: string;
newPassword: string;
confirmPassword: string;
}
export interface SendContactOtpPayload {
channel: ContactChannel;
/** The NEW email/phone to verify — the OTP is sent here, not to the current one. */
value: string;
}
export interface UpdateContactPayload extends SendContactOtpPayload {
otp: string;
}
export const accountService = {
/** PATCH /auth/change-password — generic IAM route, works for any user type. */
changePassword: async (payload: ChangePasswordPayload): Promise<void> => {
const response = await client.patch("/auth/change-password", payload);
unwrap(response.data);
},
/** POST /me/contact/otp — sends a code to the new email/phone to prove ownership. */
sendContactOtp: async (
payload: SendContactOtpPayload,
): Promise<{ sentTo: string }> => {
const response = await client.post<{ sentTo: string }>(
"/me/contact/otp",
payload,
);
return unwrap(response.data);
},
/** PATCH /me/contact — verifies the OTP and writes the new email/phone. */
updateContact: async (
payload: UpdateContactPayload,
): Promise<{ success: true; value: string }> => {
const response = await client.patch<{ success: true; value: string }>(
"/me/contact",
payload,
);
return unwrap(response.data);
},
};

View File

@@ -136,6 +136,12 @@ import type {
WarehouseZone,
} from "@/types/warehouse";
import { endpoint } from "@/utils/endpoint";
import {
accountService,
type ChangePasswordPayload,
type SendContactOtpPayload,
type UpdateContactPayload,
} from "./account.service";
import {
BookingListFilter,
bookingsService,
@@ -2182,6 +2188,29 @@ export const api = {
),
},
account: {
changePassword: endpoint<ChangePasswordPayload, void>(
"me",
"change-password",
(payload) => accountService.changePassword(payload),
),
sendContactOtp: endpoint<SendContactOtpPayload, { sentTo: string }>(
"me",
"send-contact-otp",
(payload) => accountService.sendContactOtp(payload),
),
updateContact: endpoint<
UpdateContactPayload,
{ success: true; value: string }
>(
"me",
"update-contact",
(payload) => accountService.updateContact(payload),
),
},
signatures: {
mySignature: endpoint<void, SavedSignature | null>(
"me",

View File

@@ -2,6 +2,10 @@ import { api as client } from "../auth/http";
const BASE = "/contract-templates";
export const BULK_TEMPLATE_DIRECTIONS = ["IMPORT", "EXPORT", "INTERCITY"] as const;
export type BulkTemplateDirection = (typeof BULK_TEMPLATE_DIRECTIONS)[number];
export interface ContractTemplateArticle {
id: string;
title: string;
@@ -23,7 +27,12 @@ export interface ContractTemplate {
/** Bulk templates only: the cargo type this template is written for. */
cargoTypeId?: string | null;
cargoType?: { id: string; cargoTypeName: string } | null;
/** Bulk templates only: whether this is the with-customs-clearing variant. */
/** Bulk templates only: IMPORT, EXPORT or INTERCITY. */
tradeDirection?: BulkTemplateDirection | null;
/**
* Bulk templates only: whether this is the with-customs-clearing variant.
* Null for intercity — domestic movements have no customs leg.
*/
withCustoms?: boolean | null;
/** The five seeded container templates — cannot be deleted. */
isSystem: boolean;
@@ -33,7 +42,9 @@ export interface ContractTemplate {
export interface CreateContractTemplatePayload {
cargoTypeId: string;
withCustoms: boolean;
tradeDirection: BulkTemplateDirection;
/** Omitted for INTERCITY — the API rejects the flag there. */
withCustoms?: boolean;
name?: string;
description?: string;
}

View File

@@ -11,6 +11,8 @@ export interface Rate {
currency: string;
rateValue: string;
rateUnit: string;
/** PER_LITER fuel rates only: price = baseLiters × rateValue, once per booking. */
baseLiters?: string | null;
status: string;
proposedByStaffId: string;
approvedByCeoId: string | null;

View File

@@ -321,6 +321,19 @@ export const UpcomingWindowsSection = memo(function UpcomingWindowsSection({
</Text>
</Fragment>
))}
{w.trainNumber && (
<Text
fz={13}
fw={700}
style={{
color: INK,
flexShrink: 0,
fontVariantNumeric: "tabular-nums",
}}
>
Train {w.trainNumber}
</Text>
)}
{w.reference && (
<Text
fz={11}
@@ -338,7 +351,8 @@ export const UpcomingWindowsSection = memo(function UpcomingWindowsSection({
<Group gap={5} wrap="nowrap" mt={2}>
<CalendarClock size={12} color={MUTED} style={{ flexShrink: 0 }} />
<Text fz={12} style={{ color: MUTED }} truncate>
{windowLabel(w)} · Departs {fmtDay(w.departureDate)}
{windowLabel(w)} · Departs {fmtDay(w.departureDate)},{" "}
{fmtTime(w.departureDate)}
</Text>
</Group>
{(() => {

View File

@@ -18,6 +18,7 @@ import { invoicesService, type PortalInvoice } from "@/services/invoices.service
import { InvoiceStatusBadge, titleCase } from "@/pages/billing/invoice-ui";
import { paymentStatusLabel } from "@/pages/bookings/booking-display";
import { isUsdOfflineBooking } from "@/pages/bookings/payments/offline-payment";
import { PayerAccountNote } from "@/pages/bookings/payments/PayerAccountNote";
import { payWindowState } from "@/pages/bookings/payments/payment-drain";
import { PaymentProcessingNotice } from "@/pages/bookings/payments/PaymentProcessingNotice";
import { saveBlob } from "@/utils/download";
@@ -321,6 +322,7 @@ export function BookingPaymentPanel({
onPay={offlineUsd ? undefined : onPay}
paying={paying}
/>
{!paid && <PayerAccountNote />}
{showConsolidationNote && (
<Text mt={12} fz="12px" c="#9AA8B5" lh={1.5}>
This shipment shares a wagon with a consolidation partner both

View File

@@ -13,6 +13,7 @@ import { Check, Landmark, ShieldCheck } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import type { InvoicePaymentFlow } from "@/hooks/useInvoicePayment";
import { PayerAccountNote } from "@/pages/bookings/payments/PayerAccountNote";
import type { PaymentMethod } from "@/services/payments.service";
interface ProviderOption {
@@ -306,6 +307,8 @@ export function PaymentMethodModal({
</Text>
)}
<PayerAccountNote />
<Text mt={14} fz="11.5px" c="#9AA8B5">
This page updates automatically once CBE confirms your payment.
</Text>
@@ -449,6 +452,8 @@ export function PaymentMethodModal({
</Text>
</Group>
)}
<PayerAccountNote />
</Box>
{/* Provider options */}

View File

@@ -0,0 +1,39 @@
import { Box, Text } from "@mantine/core";
import useAuth from "@/hooks/useAuth";
/**
* Red payer-identity warning shown wherever the customer is about to pay:
* the transfer must come from a bank account held in the company's registered
* name, otherwise Finance will not recognize the payment as paid.
*/
export function PayerAccountNote() {
const { company } = useAuth();
const name = company?.company?.name;
return (
<Box
mt={12}
p={12}
style={{
borderRadius: 10,
backgroundColor: "#FDEDEB",
border: "1px solid #F5C6C0",
}}
>
<Text fz="12.5px" fw={700} c="#C0392B" lh={1.55}>
Pay only from a bank account registered under your company name
{name ? (
<>
{" — "}
<Text span fw={800} c="#A93226">
{name}
</Text>
</>
) : null}
. A payment sent from an account under any other name will not be
recognized as paid.
</Text>
</Box>
);
}

View File

@@ -211,6 +211,19 @@ function WindowCard({ w }: { w: MyBookingWindow }) {
</Text>
</Fragment>
))}
{w.trainNumber ? (
<Text
fz={13}
fw={700}
style={{
color: INK,
flexShrink: 0,
fontVariantNumeric: "tabular-nums",
}}
>
Train {w.trainNumber}
</Text>
) : null}
</Group>
<Group gap={6} wrap="nowrap" mt={8}>
@@ -221,7 +234,7 @@ function WindowCard({ w }: { w: MyBookingWindow }) {
</Group>
{w.departureDate ? (
<Text fz={12} style={{ color: MUTED }}>
Departs {fmtDay(w.departureDate)}
Departs {fmtDay(w.departureDate)}, {fmtTime(w.departureDate)}
</Text>
) : null}
</Box>

View File

@@ -77,6 +77,8 @@ export interface MyBookingWindow {
scheduleId: string;
/** Train schedule reference (e.g. TS-2026-000123), shown on the window card. */
reference: string | null;
/** Operational run number (e.g. 8001 import / 8002 export), when staff set one. */
trainNumber: string | null;
/**
* The customer's active contract on this lane, when they hold one — enables
* "Book now" to target it. Null for lanes they have no contract on.

View File

@@ -20,7 +20,39 @@ interface LoggedRequest {
headers: Record<string, string | string[] | undefined>;
ip?: string;
query?: Record<string, unknown>;
user?: Record<string, unknown> | null;
/** Set by the IAM JwtGuard AFTER this middleware runs — read at emit time. */
user?: AuthenticatedUser | null;
currentUnitId?: string;
}
/**
* The subset of `TCurrentUser` (@tria-plc/api-common) the log line reads.
* Everything here is an identifier, a key or a status — the personal fields on
* that type (name, email, username, phoneNumber) are deliberately absent so
* they cannot be picked up by accident.
*/
interface AuthenticatedUser {
id?: string;
sub?: string;
userId?: string;
sessionId?: string;
userType?: string;
status?: string;
roles?: { key?: string }[];
permissions?: unknown[];
employee?: {
id?: string;
organizationId?: string;
unitId?: string;
position?: {
id?: string;
key?: string;
employeePositionId?: string;
isDelegate?: boolean;
delegatorId?: string;
positionType?: { key?: string };
};
};
}
interface LoggedResponse {
@@ -35,13 +67,48 @@ const header = (req: LoggedRequest, name: string): string | undefined => {
return Array.isArray(value) ? value[0] : value;
};
const userId = (req: LoggedRequest): string | undefined => {
const userId = (req: LoggedRequest): string | undefined =>
req.user?.id ?? req.user?.sub ?? req.user?.userId;
/**
* Who the caller was acting as — WITHOUT any personal data. Ids, role/position
* keys and statuses only: enough to answer "which desk did this", "was it a
* delegate", "which tenant", and to spot an authorization problem, with nothing
* that identifies the human behind the account beyond the opaque user id.
*
* `authenticated: false` with `hasBearer: true` is the signature of a rejected
* token (expired session, bad signature) as opposed to a missing one.
*/
const authContext = (req: LoggedRequest): Record<string, unknown> => {
const user = req.user;
if (!user) return undefined;
const id = user.id ?? user.sub ?? user.userId;
return typeof id === "string" || typeof id === "number"
? String(id)
: undefined;
const position = user?.employee?.position;
return {
authenticated: Boolean(user),
hasBearer: header(req, "authorization")?.startsWith("Bearer ") ?? false,
// Which frontend called — /auth/login rejects cross-audience credentials on it.
clientApp: header(req, "x-client-app"),
userId: userId(req),
sessionId: user?.sessionId,
userType: user?.userType,
userStatus: user?.status,
roles: user?.roles?.map((role) => role.key).filter(Boolean),
// Count only: the full grant list is hundreds of keys and would dwarf the line.
permissionCount: user?.permissions?.length,
employeeId: user?.employee?.id,
organizationId: user?.employee?.organizationId,
unitId: user?.employee?.unitId ?? req.currentUnitId,
positionId: position?.id,
positionKey: position?.key,
positionType: position?.positionType?.key,
employeePositionId: position?.employeePositionId,
// Acting on someone else's behalf — the first thing to check when a staff
// action lands under an unexpected desk.
isDelegate: position?.isDelegate,
delegatorId: position?.delegatorId,
// Tenant/scope headers the frontends send alongside the token.
projectId:
header(req, "current-project-id") ?? header(req, "x-current-project-id"),
};
};
/**
@@ -101,6 +168,9 @@ export class RequestLogMiddleware implements NestMiddleware {
status,
durationMs,
userId: userId(req),
// Read at emit time on purpose: the guard populates req.user long
// after this middleware handed control on.
auth: authContext(req),
ip: req.ip,
userAgent: header(req, "user-agent"),
query: