mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-02 17:23:38 +00:00
202 lines
7.2 KiB
TypeScript
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);
|
|
});
|
|
});
|
|
});
|