Merge pull request #961 from Tria-plc/milesfixes

feat(warehouses): gate double-handling fee on a per-booking Yes/No
This commit is contained in:
Hagernesh Tadesse
2026-07-25 13:47:27 +03:00
committed by GitHub
10 changed files with 280 additions and 2 deletions

View File

@@ -0,0 +1,39 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Double handling becomes an explicit per-booking decision instead of an
* implicit "every import" charge. Warehouse staff record Yes/No after
* unloading (whether the goods actually had to be re-handled); the
* DOUBLE_HANDLING_FEE rule only bills when the answer is Yes.
*
* NULL = not decided yet → no charge, and the UI shows "not set" so the
* operator is prompted. Existing rows stay NULL deliberately: back-billing a
* fee nobody confirmed would be wrong.
*/
export class AddBookingDoubleHandling2850000000000 implements MigrationInterface {
name = 'AddBookingDoubleHandling2850000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS double_handling boolean;`,
);
await queryRunner.query(
`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS double_handling_set_at timestamptz;`,
);
await queryRunner.query(
`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS double_handling_set_by varchar(160);`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS double_handling_set_by;`,
);
await queryRunner.query(
`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS double_handling_set_at;`,
);
await queryRunner.query(
`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS double_handling;`,
);
}
}

View File

@@ -302,6 +302,20 @@ export class Booking extends BaseEntity {
@Column({ name: 'customer_truck_arrived_at', type: 'timestamptz', nullable: true })
customerTruckArrivedAt?: Date | null;
/**
* Did the goods need re-handling in the warehouse? Recorded by warehouse
* staff after unloading. Only `true` bills the DOUBLE_HANDLING_FEE rule;
* null = not yet decided (no charge).
*/
@Column({ name: 'double_handling', type: 'boolean', nullable: true })
doubleHandling?: boolean | null;
@Column({ name: 'double_handling_set_at', type: 'timestamptz', nullable: true })
doubleHandlingSetAt?: Date | null;
@Column({ name: 'double_handling_set_by', type: 'varchar', length: 160, nullable: true })
doubleHandlingSetBy?: string | null;
@Column({ name: 'customs_clearing_enabled', type: 'boolean', default: false })
customsClearingEnabled!: boolean;

View File

@@ -0,0 +1,61 @@
import { WarehouseFeeService } from './warehouse-fee.service';
/**
* Double handling bills ONLY when warehouse staff answered Yes after
* unloading. Undecided (null) or No must produce a zero charge even when a
* matching DOUBLE_HANDLING_FEE rule exists.
*/
type Item = Parameters<WarehouseFeeService['previewForInventory']> extends unknown
? Record<string, unknown>
: never;
const svc = Object.create(WarehouseFeeService.prototype) as {
computeDoubleHandling: (
rule: Record<string, unknown> | null,
item: Item,
now: Date,
billingCurrency: string,
) => Promise<{ amount: number; billableUnits: number }>;
normalizeCurrency: (c?: string | null) => string;
convertAmount: (a: number, from: string, to: string) => Promise<number>;
resolveBulkQuantity: (item: Item) => { quantity: number; unitLabel: string };
};
// No exchange service on a bare prototype — bill in the rule's own currency.
svc.normalizeCurrency = (c) => (c ? String(c).toUpperCase() : 'USD');
svc.convertAmount = async (a) => a;
const rule = { basis: 'PER_CONTAINER', ratePerDay: 100, currency: 'USD', id: 'r1', name: 'DH' };
const item = (doubleHandling: boolean | null) => ({
tradeDirection: 'IMPORT',
freightType: 'CONTAINER',
inventoryQuantity: 2,
bookingContainerCount: 3,
inventoryWeight: 10,
cargoUnitOfMeasure: 'PER_TON',
doubleHandling,
}) as unknown as Item;
describe('double handling gate', () => {
it('bills rate x containers when the booking is flagged Yes', async () => {
const out = await svc.computeDoubleHandling(rule, item(true), new Date(), 'USD');
expect(out.billableUnits).toBe(3);
expect(out.amount).toBe(300);
});
it('charges nothing when the answer is No', async () => {
const out = await svc.computeDoubleHandling(rule, item(false), new Date(), 'USD');
expect(out.billableUnits).toBe(0);
expect(out.amount).toBe(0);
});
it('charges nothing while the answer is undecided', async () => {
const out = await svc.computeDoubleHandling(rule, item(null), new Date(), 'USD');
expect(out.amount).toBe(0);
});
it('charges nothing for export even when flagged Yes', async () => {
const exportItem = { ...(item(true) as Record<string, unknown>), tradeDirection: 'EXPORT' } as Item;
const out = await svc.computeDoubleHandling(rule, exportItem, new Date(), 'USD');
expect(out.amount).toBe(0);
});
});

View File

@@ -0,0 +1,14 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsBoolean } from 'class-validator';
/**
* Warehouse staff's post-unloading answer: did these goods have to be
* re-handled? Only `true` makes the DOUBLE_HANDLING_FEE rule bill the booking.
*/
export class SetDoubleHandlingDto {
@ApiProperty({
description: 'Yes (true) applies the double-handling fee rule; No (false) does not.',
})
@IsBoolean()
doubleHandling!: boolean;
}

View File

@@ -25,6 +25,8 @@ interface ItemAttributes {
bookingContainerCount: 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;
/** Booking-level Yes/No recorded after unloading; only true bills double handling (null = undecided). */
doubleHandling: boolean | null;
facilityId: string | null;
warehouseId: string | null;
yardId: string | null;
@@ -253,6 +255,7 @@ export class WarehouseFeeService {
w.facility_id AS "facilityId",
b.freight_type AS "freightType",
b.trade_direction AS "tradeDirection",
b.double_handling AS "doubleHandling",
COALESCE(cgt.code, booking_cgt.code) AS "cargoTypeCode",
COALESCE(ctt.code, booking_ctt.code) AS "containerTypeCode",
COALESCE(container_lines.container_count, 0) AS "bookingContainerCount",
@@ -589,9 +592,13 @@ export class WarehouseFeeService {
// 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.
// Double handling applies to IMPORT only — no charge for export/domestic
// AND only when warehouse staff recorded that the goods were actually
// re-handled (booking flag = Yes after unloading). Undecided (null) or No
// means no charge, so the rule can exist without billing every import.
const isImport = (item.tradeDirection ?? '').toUpperCase() === 'IMPORT';
const quantity = !isImport ? 0 : basis === 'PER_CONTAINER' ? containerCount : bulk.quantity;
const applies = isImport && item.doubleHandling === true;
const quantity = !applies ? 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;
@@ -860,6 +867,8 @@ export class WarehouseFeeService {
inventoryWeight: 0,
bookingContainerCount: 1,
cargoUnitOfMeasure: null,
// Irrelevant to detention (truck-time based, never double handling).
doubleHandling: null,
facilityId: null,
warehouseId: null,
yardId: null,

View File

@@ -17,6 +17,7 @@ import { MoveInventoryDto } from './dto/move-inventory.dto';
import { StoreInventoryDto } from './dto/store-inventory.dto';
import { ReceiveWarehouseInventoryDto } from './dto/receive-inventory.dto';
import { ApproveDeliveryDto } from './dto/approve-delivery.dto';
import { SetDoubleHandlingDto } from './dto/double-handling.dto';
import { ReleaseOrderDto } from './dto/release-order.dto';
import { ReserveInventoryDto } from './dto/reserve-inventory.dto';
import { UnloadBookingDto } from './dto/unload-booking.dto';
@@ -552,6 +553,23 @@ export class WarehouseInventoryController {
return res.send(buffer);
}
@Patch('bookings/:bookingId/double-handling')
@BookingStaff([FREIGHT_PERMS.warehouseInventory.unload, FREIGHT_PERMS.warehouseInventory.inspect])
@ApiOperation({
summary: 'Record Yes/No double handling after unloading (Yes applies the double-handling fee rule)',
})
setDoubleHandling(
@Param('bookingId', ParseUUIDPipe) bookingId: string,
@Body() dto: SetDoubleHandlingDto,
@CurrentUser() user: TCurrentUser,
) {
return this.inventoryService.setDoubleHandling(
bookingId,
dto.doubleHandling,
actorLabel(user),
);
}
@Get('bookings/:bookingId/container-items')
@StaffReference()
@ApiOperation({ summary: 'Per-container/bulk items of a booking with lifecycle stage + refs' })

View File

@@ -382,6 +382,8 @@ export interface ImportUnloadedRow {
customerTruckContainerNumber: string | null;
customerTruckAssignedAt: string | null;
hasAssignedTruck: boolean;
/** Post-unloading Yes/No; null = not recorded yet (no double-handling charge). */
doubleHandling: boolean | null;
currentStatus: string;
releaseDate: string | null;
releaseOrderReference: string | null;
@@ -1991,6 +1993,7 @@ export class WarehouseInventoryService {
WHERE lm.booking_id = b.id
AND lm.vehicle_id IS NOT NULL
AND lm.deleted_at IS NULL)) AS "hasAssignedTruck",
b.double_handling AS "doubleHandling",
inv.status AS "currentStatus",
inv.release_date AS "releaseDate",
inv.release_order_reference AS "releaseOrderReference",
@@ -4438,6 +4441,81 @@ export class WarehouseInventoryService {
});
}
/**
* Record whether a booking's goods needed double handling. Answered by
* warehouse staff once the goods are unloaded — only Yes bills the
* DOUBLE_HANDLING_FEE rule (see WarehouseFeeService.computeDoubleHandling).
* Locked once the fee has been invoiced, so a billed charge can't be
* retro-cancelled from the operations screen.
*/
async setDoubleHandling(
bookingId: string,
doubleHandling: boolean,
performedBy?: string,
): Promise<{ bookingId: string; doubleHandling: boolean; setAt: string }> {
const [booking]: Array<{ id: string; tradeDirection: string | null; reference: string | null }> =
await this.dataSource.query(
`SELECT id, trade_direction AS "tradeDirection", reference
FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`,
[bookingId],
);
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
if ((booking.tradeDirection ?? '').toUpperCase() !== 'IMPORT') {
throw new BadRequestException('Double handling applies to import bookings only');
}
// Warehouse fees are billed per inventory row (invoices.source = 'warehouse',
// source_id = the inventory id), with the fee type on the line's charge_type.
const [invoiced]: Array<{ one: number }> = await this.dataSource.query(
`SELECT 1 AS one
FROM freight.invoices i
JOIN freight.invoice_lines il ON il.invoice_id = i.id AND il.deleted_at IS NULL
JOIN freight.warehouse_inventory inv
ON inv.id::text = i.source_id AND inv.deleted_at IS NULL
WHERE inv.booking_id = $1
AND i.source = 'warehouse'
AND i.deleted_at IS NULL
AND i.status <> 'CANCELLED'
AND il.charge_type = 'DOUBLE_HANDLING'
LIMIT 1`,
[bookingId],
);
if (invoiced) {
throw new BadRequestException(
'Double handling has already been invoiced for this booking — cancel the invoice to change it',
);
}
const setAt = new Date();
await this.dataSource.query(
`UPDATE freight.bookings
SET double_handling = $2,
double_handling_set_at = $3,
double_handling_set_by = $4,
updated_at = NOW()
WHERE id = $1`,
[bookingId, doubleHandling, setAt, performedBy ?? null],
);
// Audit on the booking's inventory rows so it shows in warehouse history.
const items: Array<{ id: string; warehouseId: string | null }> = await this.dataSource.query(
`SELECT id, warehouse_id AS "warehouseId" FROM freight.warehouse_inventory
WHERE booking_id = $1 AND deleted_at IS NULL`,
[bookingId],
);
for (const item of items) {
await this.activityLog.record({
activityType: 'INVENTORY_STORED',
inventoryId: item.id,
warehouseId: item.warehouseId,
description: `Double handling set to ${doubleHandling ? 'YES — fee rule applies' : 'NO'}`,
performedBy,
});
}
return { bookingId, doubleHandling, setAt: setAt.toISOString() };
}
/** Resolve the primary warehouse-inventory item for a booking (most recent). */
private async primaryInventoryIdForBooking(bookingId: string): Promise<string> {
const [inv]: Array<{ id: string }> = await this.dataSource.query(

View File

@@ -22,6 +22,7 @@ import {
} from '@mantine/core';
import {
ArrowRightLeft,
Check,
ChevronDown,
ChevronRight,
ClipboardCheck,
@@ -29,6 +30,7 @@ import {
FileText,
History,
Info,
Layers,
MapPin,
MoreHorizontal,
PackageCheck,
@@ -2721,6 +2723,40 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
</Menu.Item>
)}
<Menu.Item onClick={() => setInspectId(r.id)}>Inspect / report</Menu.Item>
{/* Double handling is decided once the goods are off
the wagon (every row here is unloaded) — Yes is
what makes the fee rule bill this booking. */}
<Menu.Divider />
<Menu.Label>
Double handling {' '}
{r.doubleHandling == null ? 'not set' : r.doubleHandling ? 'Yes' : 'No'}
</Menu.Label>
<Menu.Item
leftSection={
r.doubleHandling === true ? <Check size={14} /> : <Layers size={14} />
}
disabled={!r.bookingId || r.doubleHandling === true}
onClick={() =>
runRowAction(r, 'Double handling: Yes — fee rule applies', () =>
warehouseService.setDoubleHandling(r.bookingId as string, true),
)
}
>
Yes apply fee
</Menu.Item>
<Menu.Item
leftSection={
r.doubleHandling === false ? <Check size={14} /> : <Layers size={14} />
}
disabled={!r.bookingId || r.doubleHandling === false}
onClick={() =>
runRowAction(r, 'Double handling: No', () =>
warehouseService.setDoubleHandling(r.bookingId as string, false),
)
}
>
No
</Menu.Item>
<Menu.Divider />
<Menu.Item leftSection={<PackageCheck size={14} />} onClick={() => setFeeItem(toInventoryItem(r))}>
Storage / fee preview

View File

@@ -334,6 +334,13 @@ export const warehouseService = {
deliver: (id: string, payload: DeliverInventoryPayload) =>
apiClient.post<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVENTORY.DELIVER(id), payload),
/** Post-unloading Yes/No — Yes makes the double-handling fee rule bill this booking. */
setDoubleHandling: (bookingId: string, doubleHandling: boolean) =>
apiClient.patch<{ bookingId: string; doubleHandling: boolean; setAt: string }>(
`/warehouse-inventory/bookings/${bookingId}/double-handling`,
{ doubleHandling },
),
// ── Receive (Import/Export bulk) ─────────────────────────────────────────
eligibleBookings: (direction?: 'IMPORT' | 'EXPORT') =>
apiClient.get<EligibleBooking[]>(URL_CONSTANTS.WAREHOUSE_INVENTORY.ELIGIBLE_BOOKINGS(direction)),

View File

@@ -603,6 +603,8 @@ export interface ImportUnloadedItem {
customerTruckContainerNumber: string | null;
customerTruckAssignedAt: string | null;
hasAssignedTruck: boolean;
/** Post-unloading Yes/No; null = not recorded yet (no double-handling charge). */
doubleHandling: boolean | null;
currentStatus: string;
releaseDate: string | null;
releaseOrderReference: string | null;