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

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