mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
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:
@@ -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'
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}`}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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[];
|
||||
|
||||
Reference in New Issue
Block a user