Files
edr-platform/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.bulk-quantity.spec.ts

202 lines
7.2 KiB
TypeScript

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,
// Double handling now bills only when staff answered Yes after unloading;
// these quantity-basis cases assume that answer (the gate itself is covered
// in double-handling-gate.spec.ts).
doubleHandling: true,
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);
});
});
});