fix(operations): last-mile assign until load fully trucked + 40ft cap

Assign was gated on any truck assigned, blocking multi-truck
deliveries. Gate on remaining containers (or bulk tonnage) instead, show
covered/total in the modal, cap a 40ft container to one truck with no
size mixing (mirrors assertTruckLoad), and lock arrived/departed rows.
This commit is contained in:
Hagernesh
2026-07-25 10:29:14 +00:00
parent d4695ba9eb
commit a544bebc51
10 changed files with 492 additions and 34 deletions

View File

@@ -0,0 +1,28 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Livestock is billed and counted per head, not per ton — line it up with the
* other break-bulk cargo types (Machinery, Truck, Automobile) so bulk
* storage/demurrage fees charge per item instead of per ton for it.
*/
export class LivestockPerItem2900000000000 implements MigrationInterface {
name = "LivestockPerItem2900000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
UPDATE freight.cargo_types
SET unit_of_measure = 'PER_ITEM'
WHERE code = 'LIVESTOCK'
AND unit_of_measure IS DISTINCT FROM 'PER_ITEM'
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
UPDATE freight.cargo_types
SET unit_of_measure = 'PER_TON'
WHERE code = 'LIVESTOCK'
AND unit_of_measure IS DISTINCT FROM 'PER_TON'
`);
}
}

View File

@@ -67,6 +67,21 @@ export class FacilityHandlingService {
inventoryId = inv?.id ?? null;
}
// The handed-over weight: the booking's declared VGM, else what its
// containers actually carry. A GRN without a weight is not a receipt.
let weightTons = Number(booking.cargoTotalWeightVgm) || null;
if (!weightTons) {
const [sum]: Array<{ tons: string | null }> = await manager.query(
`SELECT SUM(bcu.vgm_tons) AS tons
FROM freight.booking_container_units bcu
JOIN freight.booking_container bc
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
WHERE bc.booking_id = $1 AND bcu.deleted_at IS NULL`,
[booking.id],
);
weightTons = Number(sum?.tons) || null;
}
const repo = manager.getRepository(FacilityHandlingEvent);
await repo.save(
repo.create({
@@ -75,7 +90,7 @@ export class FacilityHandlingService {
trainScheduleId: input.trainScheduleId ?? null,
eventType,
grnNumber,
weightTons: Number(booking.cargoTotalWeightVgm) || null,
weightTons,
inventoryId,
performedBy: input.performedBy ?? null,
occurredAt,

View File

@@ -0,0 +1,197 @@
import { WarehouseFeeService } from './warehouse-fee.service';
import { WarehouseFeeRule } from './entities/warehouse-fee-rule.entity';
// Bulk storage/demurrage used to bill a flat rate per day regardless of cargo
// quantity. It now scales by the cargo type's own unit of measure — tons for
// PER_TON cargo, item count for PER_ITEM cargo (Machinery, Truck, Automobile,
// Livestock…) — read from THIS inventory row, not the whole booking's total.
describe('WarehouseFeeService bulk quantity billing', () => {
const makeService = () =>
// compute() only touches its own arguments plus this.convertAmount, which
// short-circuits when rule.currency === billingCurrency — none of the
// constructor deps are exercised.
new WarehouseFeeService({} as any, {} as any, {} as any, {} as any);
const rule = (overrides: Partial<WarehouseFeeRule> = {}): WarehouseFeeRule =>
({
id: 'rule-1',
name: 'Bulk storage',
ruleType: 'STORAGE_FEE',
freeDays: 0,
ratePerDay: 10,
currency: 'USD',
tiers: [],
...overrides,
}) as WarehouseFeeRule;
const baseItem = (overrides: Record<string, unknown> = {}) => ({
arrivedAt: new Date('2026-01-01T00:00:00Z'),
gateClearedAt: null,
releaseDate: null,
freightType: 'BULK',
tradeDirection: 'IMPORT',
cargoTypeCode: 'WHEAT',
containerTypeCode: null,
vehicleType: null,
inventoryQuantity: 3,
inventoryWeight: 25,
bookingContainerCount: 0,
cargoUnitOfMeasure: null,
facilityId: null,
warehouseId: null,
yardId: null,
zoneId: null,
...overrides,
});
// 5 elapsed days, 0 free days -> 5 chargeable days throughout.
const now = new Date('2026-01-06T00:00:00Z');
it('bills PER_TON bulk cargo by this row\'s weight, not a flat day rate', async () => {
const service = makeService();
const preview = await (service as any).compute(
'STORAGE_FEE',
rule(),
baseItem({ cargoUnitOfMeasure: 'PER_TON', inventoryWeight: 25 }),
now,
'USD',
);
expect(preview.unitLabel).toBe('ton');
expect(preview.containerCount).toBe(25);
expect(preview.billableUnits).toBe(5 * 25);
expect(preview.amount).toBe(5 * 25 * 10);
});
it('bills PER_ITEM bulk cargo (Machinery/Truck/Automobile/Livestock) by unit count', async () => {
const service = makeService();
const preview = await (service as any).compute(
'STORAGE_FEE',
rule(),
baseItem({ cargoUnitOfMeasure: 'PER_ITEM', inventoryQuantity: 3, cargoTypeCode: 'MACHINERY' }),
now,
'USD',
);
expect(preview.unitLabel).toBe('item');
expect(preview.containerCount).toBe(3);
expect(preview.billableUnits).toBe(5 * 3);
expect(preview.amount).toBe(5 * 3 * 10);
});
it('defaults to PER_TON when the cargo type has no unit of measure set', async () => {
const service = makeService();
const preview = await (service as any).compute(
'STORAGE_FEE',
rule(),
baseItem({ cargoUnitOfMeasure: null, inventoryWeight: 12 }),
now,
'USD',
);
expect(preview.unitLabel).toBe('ton');
expect(preview.containerCount).toBe(12);
});
it('charges nothing yet when the row has not been weighed/counted (0 is legitimate, not floored to 1)', async () => {
const service = makeService();
const preview = await (service as any).compute(
'STORAGE_FEE',
rule(),
baseItem({ cargoUnitOfMeasure: 'PER_TON', inventoryWeight: 0 }),
now,
'USD',
);
expect(preview.containerCount).toBe(0);
expect(preview.billableUnits).toBe(0);
expect(preview.amount).toBe(0);
});
it('leaves CONTAINER freight billing untouched by the new bulk fields', async () => {
const service = makeService();
const preview = await (service as any).compute(
'DEMURRAGE_FEE',
rule({ ruleType: 'DEMURRAGE_FEE' }),
baseItem({
freightType: 'CONTAINER',
bookingContainerCount: 4,
cargoUnitOfMeasure: 'PER_ITEM', // must be ignored for container freight
inventoryWeight: 999,
}),
now,
'USD',
);
expect(preview.unitLabel).toBe('container');
expect(preview.containerCount).toBe(4);
expect(preview.billableUnits).toBe(5 * 4);
});
// Double handling is a flat one-time charge, but previewForInventory() calls
// it once per warehouse_inventory ROW. Before this fix it read the whole
// booking's total on every row, so a booking split across N rows was billed
// N times against its full quantity. Reading each row's own weight/count
// fixes that: summing the rows now reproduces the booking total exactly once.
describe('double handling (row-level, not booking-wide)', () => {
const doubleHandlingRule = (basis: 'PER_CONTAINER' | 'PER_TON' | 'PER_ITEM') =>
rule({ ruleType: 'DOUBLE_HANDLING_FEE', basis, ratePerDay: 20 });
it('bills PER_TON by this row\'s own weight', async () => {
const service = makeService();
const preview = await (service as any).compute(
'DOUBLE_HANDLING_FEE',
doubleHandlingRule('PER_TON'),
baseItem({ cargoUnitOfMeasure: 'PER_TON', inventoryWeight: 10 }),
now,
'USD',
);
expect(preview.unitLabel).toBe('ton');
expect(preview.billableUnits).toBe(10);
expect(preview.amount).toBe(10 * 20);
});
it('bills PER_ITEM by this row\'s own unit count', async () => {
const service = makeService();
const preview = await (service as any).compute(
'DOUBLE_HANDLING_FEE',
doubleHandlingRule('PER_ITEM'),
baseItem({ cargoUnitOfMeasure: 'PER_ITEM', inventoryQuantity: 2, cargoTypeCode: 'TRUCK' }),
now,
'USD',
);
expect(preview.unitLabel).toBe('item');
expect(preview.billableUnits).toBe(2);
expect(preview.amount).toBe(2 * 20);
});
it('two rows of one booking sum to the booking total exactly once (no N-times overcount)', async () => {
const service = makeService();
const ruleDef = doubleHandlingRule('PER_TON');
const rowA = await (service as any).compute(
'DOUBLE_HANDLING_FEE',
ruleDef,
baseItem({ cargoUnitOfMeasure: 'PER_TON', inventoryWeight: 6 }),
now,
'USD',
);
const rowB = await (service as any).compute(
'DOUBLE_HANDLING_FEE',
ruleDef,
baseItem({ cargoUnitOfMeasure: 'PER_TON', inventoryWeight: 4 }),
now,
'USD',
);
// Booking total is 10 tons across the two rows — billed once in total,
// not 10 tons charged against EACH row (which the old booking-wide read did).
expect(rowA.amount + rowB.amount).toBe(10 * 20);
});
it('no charge for export/domestic regardless of basis', async () => {
const service = makeService();
const preview = await (service as any).compute(
'DOUBLE_HANDLING_FEE',
doubleHandlingRule('PER_TON'),
baseItem({ tradeDirection: 'EXPORT', cargoUnitOfMeasure: 'PER_TON', inventoryWeight: 10 }),
now,
'USD',
);
expect(preview.amount).toBe(0);
});
});
});

View File

@@ -20,9 +20,11 @@ interface ItemAttributes {
/** Vehicle type of the truck (truck detention scoping); null otherwise. */
vehicleType: string | null;
inventoryQuantity: number;
/** This inventory row's own net weight (tonnes) — bulk STORAGE/DEMURRAGE for PER_TON cargo bills against this, not the booking-wide total. */
inventoryWeight: number;
bookingContainerCount: number;
/** Booking cargo total in the cargo's unit of measure: tonnes (PER_TON) or item count (PER_ITEM). */
cargoQuantity: number;
/** This item's cargo type unit of measure (PER_TON | PER_ITEM); null defaults to PER_TON. Decides whether bulk day-based fees bill by weight or item count. */
cargoUnitOfMeasure: string | null;
facilityId: string | null;
warehouseId: string | null;
yardId: string | null;
@@ -60,7 +62,7 @@ export interface AccrualDashboardRow {
export interface FeePreview {
ruleType: FeeRuleType;
/** Double-handling charge basis (PER_CONTAINER | PER_TON | PER_MACHINERY); null otherwise. */
/** Double-handling charge basis (PER_CONTAINER | PER_TON | PER_ITEM); null otherwise. */
basis: FeeRuleBasis | null;
ruleId: string | null;
ruleName: string | null;
@@ -75,6 +77,8 @@ export interface FeePreview {
elapsedDays: number;
chargeableDays: number;
containerCount: number;
/** What `containerCount`/`billableUnits` are counted in — 'container' | 'truck' | 'ton' | 'item'. Bulk cargo bills by weight (ton) or item count depending on the cargo type's unit of measure. */
unitLabel: string;
billableUnits: number;
amount: number;
tiers: Array<{
@@ -242,6 +246,7 @@ export class WarehouseFeeService {
inv.gate_cleared_at AS "gateClearedAt",
inv.release_date AS "releaseDate",
inv.quantity AS "inventoryQuantity",
inv.weight AS "inventoryWeight",
inv.warehouse_id AS "warehouseId",
inv.yard_id AS "yardId",
inv.zone_id AS "zoneId",
@@ -251,7 +256,7 @@ export class WarehouseFeeService {
COALESCE(cgt.code, booking_cgt.code) AS "cargoTypeCode",
COALESCE(ctt.code, booking_ctt.code) AS "containerTypeCode",
COALESCE(container_lines.container_count, 0) AS "bookingContainerCount",
COALESCE(b.cargo_total_weight_vgm, 0) AS "cargoQuantity"
COALESCE(cgt.unit_of_measure, booking_cgt.unit_of_measure) AS "cargoUnitOfMeasure"
FROM freight.warehouse_inventory inv
LEFT JOIN freight.warehouses w ON w.id = inv.warehouse_id
LEFT JOIN freight.bookings b ON b.id = inv.booking_id
@@ -470,6 +475,22 @@ export class WarehouseFeeService {
};
}
/**
* Bulk's own billing quantity for THIS inventory row — weight (tons) for
* PER_TON cargo, unit count for PER_ITEM cargo (Machinery, Truck, Automobile,
* Livestock…). Shared by every cargo-scoped fee type (storage, demurrage,
* double handling) so a booking split across several rows is never billed
* more than once against its full total. 0 is a legitimate charge (nothing
* weighed/counted yet), so no forced floor.
*/
private resolveBulkQuantity(item: ItemAttributes): { quantity: number; unitLabel: string } {
const cargoUnit = (item.cargoUnitOfMeasure ?? 'PER_TON').toUpperCase();
if (cargoUnit === 'PER_ITEM') {
return { quantity: Math.max(0, Number(item.inventoryQuantity) || 0), unitLabel: 'item' };
}
return { quantity: Math.max(0, Number(item.inventoryWeight) || 0), unitLabel: 'ton' };
}
private async compute(
ruleType: FeeRuleType,
rule: WarehouseFeeRule | null,
@@ -490,9 +511,11 @@ export class WarehouseFeeService {
const targetCurrency = this.normalizeCurrency(billingCurrency);
const isContainer = (item.freightType ?? '').toUpperCase() === 'CONTAINER';
const inventoryQuantity = Math.max(1, Math.round(Number(item.inventoryQuantity) || 1));
const bulk = this.resolveBulkQuantity(item);
const containerCount = isContainer
? Math.max(1, Math.round(Number(item.bookingContainerCount) || inventoryQuantity))
: 1;
: bulk.quantity;
const unitLabel = isContainer ? 'container' : bulk.unitLabel;
const elapsedDays = start
? Math.max(0, Math.ceil((new Date(endDate).getTime() - start.getTime()) / MS_PER_DAY))
@@ -533,6 +556,7 @@ export class WarehouseFeeService {
elapsedDays,
chargeableDays,
containerCount,
unitLabel,
billableUnits,
amount,
tiers: hasTiers ? convertedTiers : [],
@@ -560,12 +584,15 @@ export class WarehouseFeeService {
const containerCount = isContainer
? Math.max(1, Math.round(Number(item.bookingContainerCount) || inventoryQuantity))
: 1;
// PER_TON (tonnes) and PER_ITEM (piece count) both read the cargo total,
// which is stored in the cargo's own unit of measure.
const cargoQuantity = Math.max(0, Number(item.cargoQuantity) || 0);
// PER_TON (tonnes) and PER_ITEM (piece count) both read THIS row's own
// weight/count — never the whole booking's total. previewForInventory()
// computes double handling once per inventory row, so a booking-wide total
// would double- (or triple-) bill a booking split across several rows.
const bulk = this.resolveBulkQuantity(item);
// Double handling applies to IMPORT only — no charge for export/domestic.
const isImport = (item.tradeDirection ?? '').toUpperCase() === 'IMPORT';
const quantity = !isImport ? 0 : basis === 'PER_CONTAINER' ? containerCount : cargoQuantity;
const quantity = !isImport ? 0 : basis === 'PER_CONTAINER' ? containerCount : bulk.quantity;
const unitLabel = basis === 'PER_CONTAINER' ? 'container' : bulk.unitLabel;
const sourceAmount = Math.round(rate * quantity * 100) / 100;
const amount = ruleCurrency ? await this.convertAmount(sourceAmount, ruleCurrency, targetCurrency) : 0;
const convertedRate = ruleCurrency ? await this.convertAmount(rate, ruleCurrency, targetCurrency) : 0;
@@ -586,6 +613,7 @@ export class WarehouseFeeService {
elapsedDays: 0,
chargeableDays: 0,
containerCount,
unitLabel,
billableUnits: quantity,
amount,
tiers: [],
@@ -771,6 +799,7 @@ export class WarehouseFeeService {
return {
ruleType: 'TRUCK_DETENTION_FEE',
basis: null,
unitLabel: 'truck',
ruleId: null,
ruleName: null,
freeDays: 0,
@@ -828,8 +857,9 @@ export class WarehouseFeeService {
containerTypeCode: null,
vehicleType: g.vehicleType ?? null,
inventoryQuantity: 1,
inventoryWeight: 0,
bookingContainerCount: 1,
cargoQuantity: 0,
cargoUnitOfMeasure: null,
facilityId: null,
warehouseId: null,
yardId: null,
@@ -856,6 +886,7 @@ export class WarehouseFeeService {
return {
ruleType: 'TRUCK_DETENTION_FEE',
basis: null,
unitLabel: 'truck',
ruleId: single?.ruleId ?? null,
ruleName: single ? single.ruleName : computed.length > 1 && anyRuleName ? 'Per truck-type rules' : anyRuleName,
freeDays: 0,
@@ -927,6 +958,7 @@ export class WarehouseFeeService {
return {
ruleType: 'TRUCK_DETENTION_FEE',
basis: null,
unitLabel: 'truck',
ruleId: rule?.id ?? null,
ruleName: rule?.name ?? null,
freeDays: 0,

View File

@@ -1144,7 +1144,17 @@ export class WarehouseInventoryService {
tradeDirection: string | null;
cargoTypeCode: string | null;
}[] = await this.dataSource.query(
`SELECT b.id, b.cargo_total_weight_vgm AS weight,
`SELECT b.id,
-- Received weight must land on the inventory row: a booking with no
-- declared VGM still has per-container VGM to record.
COALESCE(
NULLIF(b.cargo_total_weight_vgm, 0),
(SELECT SUM(bcu.vgm_tons)
FROM freight.booking_container_units bcu
JOIN freight.booking_container bc2
ON bc2.id = bcu.booking_container_id AND bc2.deleted_at IS NULL
WHERE bc2.booking_id = b.id AND bcu.deleted_at IS NULL)
) AS weight,
b.freight_type AS "freightType", b.trade_direction AS "tradeDirection",
cgt.code AS "cargoTypeCode"
FROM freight.bookings b
@@ -2140,7 +2150,17 @@ export class WarehouseInventoryService {
// at an intermediate yard was already unloaded there by the checkpoint
// auto-unload; without this filter it would be mis-located into the final
// yard's inventory too.
`SELECT b.id, b.status, b.cargo_total_weight_vgm AS weight,
`SELECT b.id, b.status,
-- Same fallback as autoUnloadArrived: never land a 0 t receipt when
-- the booking's containers carry a VGM.
COALESCE(
NULLIF(b.cargo_total_weight_vgm, 0),
(SELECT SUM(bcu.vgm_tons)
FROM freight.booking_container_units bcu
JOIN freight.booking_container bc2
ON bc2.id = bcu.booking_container_id AND bc2.deleted_at IS NULL
WHERE bc2.booking_id = b.id AND bcu.deleted_at IS NULL)
) AS weight,
b.freight_type AS "freightType", b.trade_direction AS "tradeDirection",
cgt.code AS "cargoTypeCode"
FROM freight.train_schedule_bookings tsb
@@ -2203,6 +2223,11 @@ export class WarehouseInventoryService {
zoneId: unloadLocation.zoneId,
}
: {}),
// Record the received weight on a row that never carried one — the
// GRN prints this, and an existing non-zero weight is left alone.
...(Number(existing.weight) > 0 || !(Number(booking.weight) > 0)
? {}
: { weight: Number(booking.weight) }),
status: 'UNLOADED',
unloadedAt: now,
arrivedAt: existing.arrivedAt ?? now,
@@ -2216,6 +2241,22 @@ export class WarehouseInventoryService {
description: 'Unloaded from arrived import train',
performedBy,
});
// Capacity follows the recorded weight: deliver() decrements by the
// item's weight, so a weight written here must be counted here too.
const addedWeight = Number(booking.weight) - Number(existing.weight ?? 0);
if (addedWeight > 0) {
await this.applyCapacityDelta(
this.dataSource.manager,
{
warehouseId: unloadLocation?.warehouseId ?? existing.warehouseId,
yardId: unloadLocation?.yardId ?? existing.yardId,
zoneId: unloadLocation?.zoneId ?? existing.zoneId,
},
addedWeight,
0,
0,
);
}
result.unloadedCount += 1;
result.results.push({ bookingId: booking.id, inventoryId: existing.id, status: 'UNLOADED' });
continue;
@@ -2253,6 +2294,11 @@ export class WarehouseInventoryService {
description: 'Unloaded from arrived import train',
performedBy,
});
// New goods physically in the warehouse — count them, or deliver() would
// later free capacity that was never taken.
if (Number(saved.weight) > 0) {
await this.applyCapacityDelta(this.dataSource.manager, location, Number(saved.weight), 0, 0);
}
result.unloadedCount += 1;
result.results.push({ bookingId: booking.id, inventoryId: saved.id, status: 'UNLOADED' });
} catch (error) {
@@ -3944,7 +3990,10 @@ export class WarehouseInventoryService {
COALESCE(inv.grn_number, substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)')) AS "grnNumber",
COALESCE(inv.arrived_at, inv.created_at) AS "receivedAt",
inv.quantity,
inv.weight,
-- An unweighed item still reports the cargo weight it holds: fall
-- back to the item's container VGM, then the booking's declared
-- weight, so a GRN never prints "0 t" for goods that are present.
COALESCE(NULLIF(inv.weight, 0), item_vgm.tons, b.cargo_total_weight_vgm, 0) AS weight,
inv.volume,
inv.status,
inv.notes,
@@ -3992,6 +4041,16 @@ export class WarehouseInventoryService {
WHERE bc.booking_id = b.id
AND bc.deleted_at IS NULL
) booking_container ON true
LEFT JOIN LATERAL (
SELECT SUM(bcu.vgm_tons) AS tons
FROM freight.booking_container_units bcu
JOIN freight.booking_container bc2
ON bc2.id = bcu.booking_container_id AND bc2.deleted_at IS NULL
WHERE bc2.booking_id = b.id
AND bcu.deleted_at IS NULL
AND (container.container_number IS NULL
OR bcu.container_number = container.container_number)
) item_vgm ON true
LEFT JOIN freight.cargoes cargo ON cargo.id = inv.cargo_id AND cargo.deleted_at IS NULL
LEFT JOIN freight.cargo_types cargo_type ON cargo_type.id = COALESCE(cargo.cargo_type_id, b.cargo_type_id)
WHERE inv.id = $1 AND inv.deleted_at IS NULL

View File

@@ -37,6 +37,14 @@ function fmtDate(iso: string | null) {
return new Date(iso).toLocaleDateString();
}
const UNIT_LABEL_PLURAL: Record<string, string> = {
container: 'Containers',
truck: 'Trucks',
ton: 'Tons',
item: 'Items',
};
const unitLabelPlural = (unitLabel?: string) => UNIT_LABEL_PLURAL[unitLabel ?? 'container'] ?? 'Containers';
const money = (amount: number, currency: string) =>
`${Number(amount).toLocaleString()} ${currency === 'ETB' ? 'Birr (ETB)' : currency}`;
@@ -72,8 +80,11 @@ function FeeCard({ fee }: { fee: FeePreview }) {
<Row label="Period" value={`${fmtDate(fee.startDate)}${fmtDate(fee.endDate)}${fee.endIsOpen ? ' (today)' : ''}`} />
<Row label="Elapsed days" value={String(fee.elapsedDays)} />
<Row label="Chargeable days" value={`${fee.chargeableDays} (after ${fee.freeDays} free)`} />
<Row label="Containers" value={String(fee.containerCount ?? 1)} />
<Row label="Billable units" value={`${fee.billableUnits ?? fee.chargeableDays} container-day(s)`} />
<Row label={unitLabelPlural(fee.unitLabel)} value={String(fee.containerCount ?? 1)} />
<Row
label="Billable units"
value={`${fee.billableUnits ?? fee.chargeableDays} ${fee.unitLabel ?? 'container'}-day(s)`}
/>
{(fee.tiers ?? []).map((tier) => (
<Row
key={`${tier.appliedFromDay}-${tier.appliedToDay}-${tier.ratePerDay}`}

View File

@@ -945,6 +945,45 @@ const LastMilePage = () => {
return out.length ? out : activeRecord ? bookingContainerNumbers(activeRecord) : [];
}, [assignBooking, activeRecord]);
// Container number → size ("20ft"/"40ft"), driving the per-truck cap: a 40ft
// fills the truck alone; two 20ft may share (no size mixing).
const sizeByNumber = useMemo(() => {
const map = new Map<string, string>();
const lines = assignBooking?.bookingContainers?.length
? assignBooking.bookingContainers
: activeRecord?.booking?.bookingContainers ?? [];
for (const line of lines) {
// The two payload shapes differ: the list record carries `containerSize`,
// the booking detail exposes the size on its container type.
const c = line as {
containerSize?: string | null;
containerNumber?: string | null;
containerType?: { code?: string; label?: string; sizeFt?: number };
units?: Array<{ containerNumber?: string | null }>;
};
const size = String(
c.containerSize ?? c.containerType?.sizeFt ?? c.containerType?.code ?? c.containerType?.label ?? "",
);
for (const u of c.units ?? []) {
if (u.containerNumber) map.set(u.containerNumber, size);
}
if (c.containerNumber) map.set(c.containerNumber, size);
}
return map;
}, [assignBooking, activeRecord]);
const is40 = (n: string) => (sizeByNumber.get(n) ?? "").includes("40");
// Trucks that already arrived/left keep their load locked — the API rejects
// changing or removing them; the modal greys those rows out.
const lockedVehicles = useMemo(() => {
const map = new Map<string, string>();
for (const a of activeRecord?.vehicleAssignments ?? []) {
if (a.departedAt) map.set(a.vehicleId, "left the warehouse");
else if (a.arrivedAt) map.set(a.vehicleId, "arrived at the warehouse");
}
return map;
}, [activeRecord]);
const pickupReadyByBooking = useMemo(() => {
const map = new Map<string, ImportUnloadedItem>();
for (const row of pickupReadyRows) {
@@ -1101,6 +1140,22 @@ const LastMilePage = () => {
if (!targetIds.length) return;
// A 40ft container fills its truck — backstop for pre-filled reassignment
// rows the MultiSelect guard never saw.
const overloaded = vehicles.filter(
(v) => v.containerNumbers.length > 1 && v.containerNumbers.some(is40),
);
if (overloaded.length) {
toast({
title: "40ft fills the truck",
description: `${overloaded
.map((v) => vehicleLabelFor(v.vehicleId))
.join("; ")} — a 40ft container travels alone.`,
variant: "destructive",
});
return;
}
// Backstop for rows the Select guard never saw (pre-filled reassignments).
const unpriced = vehicles
.map((v) => ({ label: vehicleLabelFor(v.vehicleId), gap: pricingGapById.get(v.vehicleId) }))
@@ -1385,7 +1440,21 @@ const LastMilePage = () => {
status === "PAYMENT_PENDING" ||
(status === "READY_TO_TRANSIT" && assigned) ||
(status === "IN_TRANSIT" && hasDistance);
const canAssignStep = !assigned && status !== "DELIVERED";
// Assign stays active until the whole load has trucks: container
// bookings until every container is on a truck; bulk until the
// tonnage is drawn down (trucks depart one by one). Already-departed
// trucks keep their rows locked in the modal.
const totalContainers = containerCount(row.original);
const assignedContainers = (row.original.vehicleAssignments ?? []).reduce(
(s, a) => s + (a.containers?.length ?? (a.containerNumber ? 1 : 0)),
0,
);
const containersRemain = totalContainers > 0 && assignedContainers < totalContainers;
const bulkCargo = totalContainers === 0;
const canAssignStep =
status !== "DELIVERED" &&
!row.original.invoice &&
(!assigned || containersRemain || (bulkCargo && status !== "IN_TRANSIT"));
const canDistance = status === "IN_TRANSIT";
// Truck arrival/leaving are independent — each driven by its own
// warehouse state — but both are done once the leg is IN_TRANSIT/DELIVERED.
@@ -1822,16 +1891,22 @@ const LastMilePage = () => {
</Alert>
);
}
const ok = picked === needed;
const coveredContainers = vehicleRows.reduce(
(s, r) => s + (r.vehicleId ? r.containerNumbers.length : 0),
0,
);
const ok = picked === needed && coveredContainers === containers;
return (
<Alert
variant="light"
color={ok ? "green" : "yellow"}
title={`${containers} container${containers === 1 ? "" : "s"} · needs ${needed} vehicle${needed === 1 ? "" : "s"}`}
title={`${coveredContainers} of ${containers} container${containers === 1 ? "" : "s"} on trucks · needs ${needed} vehicle${needed === 1 ? "" : "s"}`}
>
One truck (with trailer) carries {CONTAINERS_PER_VEHICLE} containers.
{picked > 0 && !ok &&
` You've selected ${picked}${picked < needed ? "add more" : "that's more than needed"}.`}
One 40ft container fills a truck; two 20ft share one (no size mixing).
{containers - coveredContainers > 0 &&
` ${containers - coveredContainers} container${containers - coveredContainers === 1 ? "" : "s"} still unassigned — keep adding trucks.`}
{picked > 0 && picked !== needed &&
` You've selected ${picked} vehicle${picked === 1 ? "" : "s"}${picked < needed ? "add more" : "that's more than needed"}.`}
</Alert>
);
})()}
@@ -1851,7 +1926,11 @@ const LastMilePage = () => {
)}
<Divider />
<Stack gap="xs">
{vehicleRows.map((row, i) => (
{vehicleRows.map((row, i) => {
const lockReason = row.vehicleId ? lockedVehicles.get(row.vehicleId) : undefined;
const rowLocked = Boolean(lockReason);
const rowHas40 = row.containerNumbers.some(is40);
return (
<Group key={i} gap="xs" wrap="nowrap" align="flex-end">
<Select
style={{ flex: 1.4 }}
@@ -1875,35 +1954,49 @@ const LastMilePage = () => {
}}
searchable
clearable
disabled={assignVehicleOptions.length === 0}
disabled={assignVehicleOptions.length === 0 || rowLocked}
/>
<MultiSelect
style={{ flex: 1 }}
label={i === 0 ? "Containers (1x40ft or 2x20ft)" : undefined}
placeholder={containerOptions.length ? "Select containers" : "No container numbers"}
// A truck takes at most two containers; a 40ft fills it (the
// API rejects a 40ft paired with anything).
maxValues={2}
description={rowLocked ? `Locked — truck ${lockReason}` : undefined}
// A 40ft container fills the truck alone; two 20ft may share.
maxValues={rowHas40 ? 1 : 2}
data={[
...containerOptions.filter(
(n) =>
row.containerNumbers.includes(n) ||
// a container rides exactly one truck
!vehicleRows.some((r, idx) => idx !== i && r.containerNumbers.includes(n)),
// a container rides exactly one truck
(!vehicleRows.some((r, idx) => idx !== i && r.containerNumbers.includes(n)) &&
// … and no size mixing: once a 20ft is picked a 40ft
// can't join it, and a 40ft truck is already full.
!(rowHas40 || (row.containerNumbers.length > 0 && is40(n)))),
),
// keep manual/legacy values selectable even if not in the booking
...row.containerNumbers.filter((n) => !containerOptions.includes(n)),
]}
value={row.containerNumbers}
onChange={(value) =>
onChange={(value) => {
// Guard the paste/keyboard path too — data filtering only
// covers the dropdown.
if (value.filter(is40).length > 0 && value.length > 1) {
toast({
title: "40ft fills the truck",
description: "A 40ft container travels alone — remove the other container.",
variant: "destructive",
});
return;
}
setVehicleRows((prev) =>
prev.map((x, idx) => (idx === i ? { ...x, containerNumbers: value } : x)),
)
}
);
}}
searchable
clearable
disabled={rowLocked}
/>
{vehicleRows.length > 1 && (
{vehicleRows.length > 1 && !rowLocked && (
<ActionIcon
variant="subtle"
color="red"
@@ -1914,7 +2007,8 @@ const LastMilePage = () => {
</ActionIcon>
)}
</Group>
))}
);
})}
<Button
variant="light"
size="xs"

View File

@@ -307,6 +307,7 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
columns: [
codeColumn("code"),
{ id: "cargoTypeName", header: "Name", accessorKey: "cargoTypeName" },
{ id: "unitOfMeasure", header: "Billed by", accessorKey: "unitOfMeasure" },
{
id: "requiresDirectorApproval",
header: "Director approval",
@@ -318,6 +319,19 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
],
formFields: [
{ name: "cargoTypeName", label: "Cargo type name", type: "text", required: true },
{
name: "unitOfMeasure",
label: "Billed by",
type: "select",
optional: true,
placeholder: "Not set (container/legacy cargo)",
description:
"Bulk storage/demurrage bills per ton for Tonnage cargo, per unit for Countable cargo (e.g. Machinery, Truck, Automobile, Livestock).",
options: [
{ label: "Tonnage (per ton)", value: "PER_TON" },
{ label: "Countable (per item)", value: "PER_ITEM" },
],
},
{
name: "parentGroupId",
label: "Parent group",

View File

@@ -835,6 +835,12 @@ function FeeRules() {
{FEE_RULE_BASIS_LABELS[form.basis]}). No free days or progressive tiers.
</Text>
)}
{isBulkRule && !isDoubleHandling && (
<Text size="xs" c="dimmed">
Bulk rate is per day, scaled by quantity tons for tonnage cargo, item count for
countable cargo (Machinery, Truck, Automobile, Livestock), set on each cargo type.
</Text>
)}
{!isDoubleHandling && (
<Stack gap="xs">
<Group justify="space-between">

View File

@@ -849,6 +849,8 @@ export interface FeePreview {
elapsedDays: number;
chargeableDays: number;
containerCount: number;
/** What containerCount/billableUnits are counted in: 'container' | 'truck' | 'ton' | 'item'. */
unitLabel?: string;
billableUnits: number;
amount: number;
tiers?: FeePreviewTier[];