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