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