Merge pull request #962 from Tria-plc/freight_feature/usermanagement

Freight feature/usermanagement
This commit is contained in:
marshal
2026-07-26 11:12:23 +03:00
committed by GitHub
95 changed files with 3521 additions and 388 deletions

View File

@@ -0,0 +1,76 @@
import { BookingLifecycleNotifierService } from './booking-lifecycle-notifier.service';
import type { Booking } from './entities/booking.entity';
/**
* Who hears "Operations wants changes" depends on who owns the booking. A
* customs (Path B) booking is created BY GL Ethiopia on the customer's behalf —
* the customer can neither edit nor resubmit it, so the note has to reach the GL
* who made it, not the portal.
*/
describe('BookingLifecycleNotifierService — operation changes requested', () => {
const booking = (over: Partial<Booking> = {}): Booking =>
({
id: 'b-1',
reference: 'BKG-0001',
companyId: 'co-1',
contractId: 'ctr-1',
createdByRole: 'CUSTOMER',
company: { email: 'customer@example.com' },
...over,
}) as Booking;
let notifications: { directSend: jest.Mock };
let inbox: { notify: jest.Mock };
let service: BookingLifecycleNotifierService;
const flush = () => new Promise((resolve) => setImmediate(resolve));
beforeEach(() => {
notifications = { directSend: jest.fn().mockResolvedValue(undefined) };
inbox = { notify: jest.fn().mockResolvedValue(undefined) };
service = new BookingLifecycleNotifierService(
notifications as never,
inbox as never,
{ query: jest.fn().mockResolvedValue([{ phone: '+251900000000' }]) } as never,
);
});
it('sends a GL-created booking back to the GL who created it, not the customer', async () => {
service.operationChangesRequested(
booking({ createdByRole: 'GL_ET', createdByUserId: 'gl-user-1' }),
'Cargo weight does not match the declaration',
);
await flush();
expect(inbox.notify).toHaveBeenCalledTimes(1);
const sent = inbox.notify.mock.calls[0][0];
expect(sent.recipients).toEqual({ userIds: ['gl-user-1'] });
expect(sent.audience).toBe('BACKOFFICE');
expect(sent.body).toContain('Cargo weight does not match the declaration');
// Deep-links the clearance page GL works from, not the portal booking.
expect(sent.link).toBe('/dashboard/contracts/clearance/ctr-1');
// The customer is not told to fix something they cannot touch.
expect(notifications.directSend).not.toHaveBeenCalled();
});
it('still tells the customer when the booking is their own', async () => {
service.operationChangesRequested(booking(), 'Please attach the packing list');
await flush();
const sent = inbox.notify.mock.calls[0][0];
expect(sent.recipients).toEqual({ companyId: 'co-1' });
expect(sent.audience).toBe('PORTAL');
expect(sent.link).toBe('/bookings/b-1');
expect(notifications.directSend).toHaveBeenCalled();
});
it('falls back to the customer when the GL creator is unknown (legacy rows)', async () => {
service.operationChangesRequested(
booking({ createdByRole: 'GL_ET', createdByUserId: null }),
'Fix the declaration',
);
await flush();
expect(inbox.notify.mock.calls[0][0].recipients).toEqual({ companyId: 'co-1' });
});
});

View File

@@ -179,8 +179,35 @@ export class BookingLifecycleNotifierService {
});
}
/** Operations returned the operation request for changes. */
/**
* Operations returned the operation request for changes.
*
* A customs (Path B) booking was created BY GL Ethiopia on the customer's
* behalf — the customer cannot edit or resubmit it, so telling them to "update
* from the portal" is a dead end. Those go to the GL who created it, linking
* the contract clearance page they work from. Everything else (customer-made
* bookings) keeps the portal message.
*/
operationChangesRequested(b: Booking, note: string): void {
if (b.createdByRole === 'GL_ET' && b.createdByUserId) {
const msg =
`Operations returned booking ${b.reference} for changes: ${note}. ` +
`Address it on the contract clearance page and resubmit to Operations.`;
this.logger.log(`OPERATION CHANGES REQUESTED (to GL) — ${this.ref(b)}`);
void this.inbox.notify({
recipients: { userIds: [b.createdByUserId] },
audience: NotificationAudience.BACKOFFICE,
type: NotificationType.BOOKING_STATUS,
title: `Booking ${b.reference} needs changes`,
body: msg,
link: b.contractId
? `/dashboard/contracts/clearance/${b.contractId}`
: `/dashboard/bookings/${b.id}/clearance`,
data: { bookingId: b.id, reference: b.reference, note },
});
return;
}
const msg =
`Your operation request for booking ${b.reference} needs changes: ${note}. ` +
`Please update and resubmit from the portal.`;

View File

@@ -473,3 +473,124 @@ describe('BookingPricingService — customs clearance fee billed on the booking
expect(result.lineItems.some((l) => l.code.startsWith('CUSTOMS_CLEARANCE'))).toBe(false);
});
});
/**
* Bulk freight bills in the commodity's own unit: tonnage for a weighed
* commodity (PER_TON), item count for a counted one (PER_ITEM). Both read the
* booking's cargo amount; PER_WAGON bills the wagons the cargo occupies.
*/
describe('BookingPricingService — bulk base freight units', () => {
const DJ = 'yard-dj-bulk';
const DIRE_B = 'yard-dire-bulk';
const bulkRate = (overrides: Partial<Rate> = {}): Rate =>
({
id: 'rate-bulk',
rateType: 'BULK_IMPORT',
appliesTo: 'BULK',
trigger: 'ALWAYS',
currency: 'USD',
rateValue: 200,
rateUnit: 'PER_ITEM',
status: 'LIVE',
containerTypeId: null,
cargoTypeId: null,
tradeDirection: 'IMPORT',
originYardId: DJ,
destinationYardId: DIRE_B,
...overrides,
}) as Rate;
const makeService = (liveRates: Rate[], wagonCapacity?: number) =>
new BookingPricingService(
{
calculateWagonCount: jest.fn().mockResolvedValue(0),
findContractRateSnapshots: jest.fn().mockResolvedValue([]),
} as never,
{
evaluate: jest.fn().mockResolvedValue({
priorityScore: 0,
appliedModifiers: [],
containerWeightResults: [],
warnings: [],
hardBlocked: [],
requiresDirectorApproval: false,
}),
} as never,
{ findById: jest.fn() } as never,
{ findLiveRates: jest.fn().mockResolvedValue(liveRates) } as never,
{ getRate: jest.fn().mockResolvedValue(MOCK_CBE_RATE) } as never,
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
{
findById: jest.fn().mockResolvedValue({
wagonTypes: wagonCapacity !== undefined ? [{ capacityTons: wagonCapacity }] : [],
}),
} as never,
);
// 12 machines, not 12 tonnes — a PER_ITEM commodity records its count here.
const booking = (overrides: Record<string, unknown> = {}) =>
({
id: 'b-bulk',
freightType: 'BULK',
tradeDirection: 'IMPORT',
paymentCurrency: 'USD',
cargoTypeId: 'cargo-machinery',
cargoTotalWeightVgm: 12,
originYardId: DJ,
destinationYardId: DIRE_B,
bookingContainers: [],
...overrides,
}) as unknown as Booking;
it('bills a PER_ITEM rate on the item count', async () => {
const result = await makeService([bulkRate()]).computePriceForBooking(booking());
const line = result.lineItems.find((l) => l.code === 'BULK_IMPORT');
expect(line!.unit).toBe('PER_ITEM');
expect(line!.quantity).toBe(12);
expect(line!.amount).toBe(2400);
});
it('bills a PER_TON rate on the tonnage', async () => {
const result = await makeService([
bulkRate({ rateUnit: 'PER_TON', rateValue: 35 }),
]).computePriceForBooking(booking({ cargoTotalWeightVgm: 120 }));
const line = result.lineItems.find((l) => l.code === 'BULK_IMPORT');
expect(line!.unit).toBe('PER_TON');
expect(line!.amount).toBe(35 * 120);
});
it('bills a PER_WAGON rate on the wagons the cargo occupies, not zero', async () => {
const result = await makeService(
[bulkRate({ rateUnit: 'PER_WAGON', rateValue: 500 })],
60,
).computePriceForBooking(booking({ cargoTotalWeightVgm: 120 }));
const line = result.lineItems.find((l) => l.code === 'BULK_IMPORT');
expect(line!.unit).toBe('PER_WAGON');
expect(line!.quantity).toBe(2); // 120 t ÷ 60 t per wagon
expect(line!.amount).toBe(1000);
});
it('prices off the rate scoped to the booking commodity, not another one', async () => {
const result = await makeService([
bulkRate({ id: 'rate-wheat', cargoTypeId: 'cargo-wheat', rateUnit: 'PER_TON', rateValue: 35 }),
bulkRate({ id: 'rate-machinery', cargoTypeId: 'cargo-machinery', rateValue: 200 }),
]).computePriceForBooking(booking());
const line = result.lineItems.find((l) => l.code === 'BULK_IMPORT');
expect(line!.unit).toBe('PER_ITEM');
expect(line!.amount).toBe(2400);
});
it('hard-blocks when the leg only carries another commoditys rate', async () => {
const result = await makeService([
bulkRate({ id: 'rate-wheat', cargoTypeId: 'cargo-wheat' }),
]).computePriceForBooking(booking());
expect(result.lineItems.some((l) => l.code === 'BULK_IMPORT')).toBe(false);
expect(result.hardBlocked.some((m) => m.includes('rate is configured'))).toBe(true);
});
});

View File

@@ -4,6 +4,7 @@ import { CargoTypesService } from '../rule-engine/services/cargo-types.service';
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
import { RatesService } from '../rule-engine/services/rates.service';
import { Rate } from '../rule-engine/entities/rate.entity';
import { isBulkQuantityUnit } from '../rule-engine/entities/rate-unit.util';
import { ContractRateSnapshot } from '../contracts/entities/contract-rate-snapshot.entity';
import { ExchangeService } from '@edr/api-common';
import {
@@ -529,7 +530,13 @@ export class BookingPricingService {
const usedRatesMap = new Map<string, Rate>();
const warnings: string[] = [];
const blocked: string[] = [];
const wagonCount = await this.resolveWagonCount(booking);
// Bulk bookings carry no container lines, so the container-based wagon
// aggregate is 0 for them — a PER_WAGON bulk rate would bill nothing. Use
// the tonnage-derived estimate instead (the eval input already carries it
// for saved bookings; a preview derives it here).
const wagonCount = isBulk
? Number(evalInput.bulkWagons ?? 0) || (await this.bulkWagonCount(booking)) || 0
: await this.resolveWagonCount(booking);
for (const container of evalInput.containers) {
const rate = this.pickRate(
@@ -600,7 +607,10 @@ export class BookingPricingService {
// container type above or stay unpriced with a warning — falling back to
// a corridor rate of a DIFFERENT container type billed once (qty 1) is
// how a 38-container booking was invoiced 40 USD instead of 1900.
const fallback = liveRates.find(
// Within the leg, the rate scoped to the booking's own commodity wins over
// the commodity-wide catch-all — a per-item machinery rate must never
// price a per-ton wheat booking (or the reverse).
const onLeg = liveRates.filter(
(r) =>
r.rateType === rateType &&
r.currency === 'USD' &&
@@ -608,11 +618,17 @@ export class BookingPricingService {
r.originYardId === booking.originYardId &&
r.destinationYardId === booking.destinationYardId,
);
const fallback =
(booking.cargoTypeId
? onLeg.find((r) => r.cargoTypeId === booking.cargoTypeId)
: undefined) ?? onLeg.find((r) => !r.cargoTypeId);
if (fallback) {
usedRatesMap.set(fallback.id, fallback);
const bulkTons = Number(booking.cargoTotalWeightVgm ?? 0);
// Bulk quantity is stored in the commodity's own unit — tonnes for a
// PER_TON commodity, item count for a PER_ITEM one.
const bulkQuantity = Number(booking.cargoTotalWeightVgm ?? 0);
const quantity =
isBulk && fallback.rateUnit === 'PER_TON' ? Math.max(bulkTons, 0) : 1;
isBulk && isBulkQuantityUnit(fallback.rateUnit) ? Math.max(bulkQuantity, 0) : 1;
const unitUsd = Number(fallback.rateValue);
// H15: bulk freight uses the frozen BULK_FREIGHT snapshot when present.
const frozen = isBulk
@@ -719,6 +735,7 @@ export class BookingPricingService {
quantity = containerCount;
break;
case 'PER_TON':
case 'PER_ITEM':
quantity = bulkTons;
break;
case 'FLAT':
@@ -804,6 +821,7 @@ export class BookingPricingService {
return 1;
case 'PER_CONTAINER':
case 'PER_TON':
case 'PER_ITEM':
default:
return quantity;
}
@@ -860,6 +878,7 @@ export class BookingPricingService {
case 'PER_WAGON':
return unitValue * wagonCount;
case 'PER_TON':
case 'PER_ITEM':
return unitValue * quantity;
case 'FLAT':
return unitValue;
@@ -1044,7 +1063,7 @@ export class BookingPricingService {
const unit = frozen ? this.rateUnitFromSnapshot(frozen.unitOfMeasure) : live!.rateUnit;
const unitAmount = frozen ? Number(frozen.unitPrice) : convert(Number(live!.rateValue));
let billedQty = 1;
if (unit === 'PER_TON') {
if (isBulkQuantityUnit(unit)) {
billedQty = Math.max(0, Number(booking.cargoTotalWeightVgm ?? 0));
} else if (unit === 'PER_WAGON') {
const wagons = await this.bulkWagonCount(booking);
@@ -1081,6 +1100,8 @@ export class BookingPricingService {
return 'PER_WAGON';
case 'per_ton':
return 'PER_TON';
case 'per_item':
return 'PER_ITEM';
case 'per_container':
return 'PER_CONTAINER';
default:

View File

@@ -33,41 +33,86 @@ import {
BookingReferenceYardDto,
} from "./dto/booking-reference-data.dto";
/**
* Reference cargo tree: top-level groups, each carrying its selectable
* commodities.
*
* `cargo_types` is an arbitrary-depth tree (Bulk → Steel Billet → S1 → …), but
* only a LEAF is a real commodity — an intermediate node is a container for
* finer types, and booking against it would be ambiguous. So each group's
* `children` are all of its leaf descendants, flattened, whatever the depth.
* Deep leaves carry their path below the group ("Steel Billet → S1") so a
* generically-named leaf still reads unambiguously in a dropdown.
*
* A group with no active descendants is its own leaf and is emitted as its
* single child — otherwise it is selectable as a group but offers no commodity,
* which dead-ends every form that requires one.
*/
export function buildCargoTypeTree(
rows: CargoType[],
): BookingReferenceCargoTypeGroupDto[] {
const active = rows.filter((r) => r.isActive);
const parents = active
.filter((r) => !r.parentGroupId)
.sort(
(a, b) => a.displayOrder - b.displayOrder || a.code.localeCompare(b.code),
);
const byOrder = (a: CargoType, b: CargoType) =>
a.displayOrder - b.displayOrder || a.code.localeCompare(b.code);
const childrenOf = new Map<string, CargoType[]>();
for (const row of active) {
if (!row.parentGroupId) continue;
const siblings = childrenOf.get(row.parentGroupId) ?? [];
siblings.push(row);
childrenOf.set(row.parentGroupId, siblings);
}
for (const siblings of childrenOf.values()) siblings.sort(byOrder);
const parents = active.filter((r) => !r.parentGroupId).sort(byOrder);
/** Depth-first leaf walk; `trail` is the path below the group. */
const collectLeaves = (
node: CargoType,
trail: string[],
seen: Set<string>,
): BookingReferenceCargoTypeChildDto[] => {
// Admin-entered parent pointers could in principle cycle — never loop.
if (seen.has(node.id)) return [];
seen.add(node.id);
const kids = childrenOf.get(node.id) ?? [];
if (kids.length === 0) {
return [
{
id: node.id,
name: [...trail, node.cargoTypeName].join(" → "),
code: node.code,
unit_of_measure: node.unitOfMeasure ?? null,
},
];
}
const nextTrail = [...trail, node.cargoTypeName];
return kids.flatMap((kid) => collectLeaves(kid, nextTrail, seen));
};
return parents.map((parent) => {
const children = active
.filter((r) => r.parentGroupId === parent.id)
.sort(
(a, b) =>
a.displayOrder - b.displayOrder || a.code.localeCompare(b.code),
)
.map(
(child): BookingReferenceCargoTypeChildDto => ({
id: child.id,
name: child.cargoTypeName,
code: child.code,
unit_of_measure: child.unitOfMeasure ?? null,
}),
);
const kids = childrenOf.get(parent.id) ?? [];
const children =
kids.length === 0
? // The group itself is the commodity.
[
{
id: parent.id,
name: parent.cargoTypeName,
code: parent.code,
unit_of_measure: parent.unitOfMeasure ?? null,
},
]
: kids.flatMap((kid) => collectLeaves(kid, [], new Set<string>()));
const group: BookingReferenceCargoTypeGroupDto = {
return {
id: parent.id,
name: parent.cargoTypeName,
code: parent.code,
children,
};
if (children.length > 0) {
group.children = children;
}
return group;
});
}

View File

@@ -0,0 +1,72 @@
import { buildCargoTypeTree } from './booking-reference-data.service';
import type { CargoType } from '../rule-engine/entities/cargo-type.entity';
const node = (
id: string,
name: string,
parentGroupId: string | null,
isActive = true,
): CargoType =>
({
id,
cargoTypeName: name,
code: name.toUpperCase().replace(/\s+/g, '_'),
parentGroupId,
displayOrder: 0,
isActive,
unitOfMeasure: 'PER_TON',
}) as unknown as CargoType;
describe('buildCargoTypeTree', () => {
// Bulk ──┬─ Wheat (leaf, depth 2)
// └─ Steel Billet ──┬─ S1 (leaf, depth 3)
// └─ S2 ─ S2a (leaf, depth 4)
const rows = [
node('bulk', 'Bulk', null),
node('wheat', 'Wheat', 'bulk'),
node('steel', 'Steel Billet', 'bulk'),
node('s1', 'S1', 'steel'),
node('s2', 'S2', 'steel'),
node('s2a', 'S2a', 's2'),
node('general', 'General Cargo', null),
];
it('offers only leaves as commodities, at any depth', () => {
const [bulk] = buildCargoTypeTree(rows);
// Leaves stay grouped under their branch (siblings ordered by
// displayOrder then code — STEEL_BILLET before WHEAT here).
expect(bulk.children?.map((c) => c.id)).toEqual(['s1', 's2a', 'wheat']);
// "Steel Billet" is a container for finer types, never bookable itself.
expect(bulk.children?.some((c) => c.id === 'steel')).toBe(false);
});
it('labels deep leaves with their path below the group', () => {
const [bulk] = buildCargoTypeTree(rows);
const byId = new Map(bulk.children?.map((c) => [c.id, c.name]));
expect(byId.get('wheat')).toBe('Wheat');
expect(byId.get('s1')).toBe('Steel Billet → S1');
expect(byId.get('s2a')).toBe('Steel Billet → S2 → S2a');
});
it('emits a childless group as its own commodity', () => {
const general = buildCargoTypeTree(rows).find((g) => g.id === 'general');
expect(general?.children).toEqual([
expect.objectContaining({ id: 'general', name: 'General Cargo' }),
]);
});
it('skips inactive nodes and their descendants', () => {
const withRetired = [
...rows,
node('retired', 'Retired', 'bulk', false),
node('retiredKid', 'Retired Kid', 'retired', false),
];
const [bulk] = buildCargoTypeTree(withRetired);
expect(bulk.children?.map((c) => c.id)).not.toContain('retired');
expect(bulk.children?.map((c) => c.id)).not.toContain('retiredKid');
});
});