mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 22:18:12 +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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user