mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 12:18:11 +00:00
feat(warehouses): gate double-handling fee on a per-booking Yes/No
Double handling billed every import with a matching rule. Add bookings.double_handling (+ set_at/by), charge only when Yes, expose a PATCH endpoint (import-only, locked after invoicing, audited) and Yes/No items in the inventory row menu.
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 })
|
@Column({ name: 'customer_truck_arrived_at', type: 'timestamptz', nullable: true })
|
||||||
customerTruckArrivedAt?: Date | null;
|
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 })
|
@Column({ name: 'customs_clearing_enabled', type: 'boolean', default: false })
|
||||||
customsClearingEnabled!: boolean;
|
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;
|
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. */
|
/** 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;
|
cargoUnitOfMeasure: string | null;
|
||||||
|
/** Booking-level Yes/No recorded after unloading; only true bills double handling (null = undecided). */
|
||||||
|
doubleHandling: boolean | null;
|
||||||
facilityId: string | null;
|
facilityId: string | null;
|
||||||
warehouseId: string | null;
|
warehouseId: string | null;
|
||||||
yardId: string | null;
|
yardId: string | null;
|
||||||
@@ -253,6 +255,7 @@ export class WarehouseFeeService {
|
|||||||
w.facility_id AS "facilityId",
|
w.facility_id AS "facilityId",
|
||||||
b.freight_type AS "freightType",
|
b.freight_type AS "freightType",
|
||||||
b.trade_direction AS "tradeDirection",
|
b.trade_direction AS "tradeDirection",
|
||||||
|
b.double_handling AS "doubleHandling",
|
||||||
COALESCE(cgt.code, booking_cgt.code) AS "cargoTypeCode",
|
COALESCE(cgt.code, booking_cgt.code) AS "cargoTypeCode",
|
||||||
COALESCE(ctt.code, booking_ctt.code) AS "containerTypeCode",
|
COALESCE(ctt.code, booking_ctt.code) AS "containerTypeCode",
|
||||||
COALESCE(container_lines.container_count, 0) AS "bookingContainerCount",
|
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
|
// computes double handling once per inventory row, so a booking-wide total
|
||||||
// would double- (or triple-) bill a booking split across several rows.
|
// would double- (or triple-) bill a booking split across several rows.
|
||||||
const bulk = this.resolveBulkQuantity(item);
|
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 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 unitLabel = basis === 'PER_CONTAINER' ? 'container' : bulk.unitLabel;
|
||||||
const sourceAmount = Math.round(rate * quantity * 100) / 100;
|
const sourceAmount = Math.round(rate * quantity * 100) / 100;
|
||||||
const amount = ruleCurrency ? await this.convertAmount(sourceAmount, ruleCurrency, targetCurrency) : 0;
|
const amount = ruleCurrency ? await this.convertAmount(sourceAmount, ruleCurrency, targetCurrency) : 0;
|
||||||
@@ -860,6 +867,8 @@ export class WarehouseFeeService {
|
|||||||
inventoryWeight: 0,
|
inventoryWeight: 0,
|
||||||
bookingContainerCount: 1,
|
bookingContainerCount: 1,
|
||||||
cargoUnitOfMeasure: null,
|
cargoUnitOfMeasure: null,
|
||||||
|
// Irrelevant to detention (truck-time based, never double handling).
|
||||||
|
doubleHandling: null,
|
||||||
facilityId: null,
|
facilityId: null,
|
||||||
warehouseId: null,
|
warehouseId: null,
|
||||||
yardId: null,
|
yardId: null,
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { MoveInventoryDto } from './dto/move-inventory.dto';
|
|||||||
import { StoreInventoryDto } from './dto/store-inventory.dto';
|
import { StoreInventoryDto } from './dto/store-inventory.dto';
|
||||||
import { ReceiveWarehouseInventoryDto } from './dto/receive-inventory.dto';
|
import { ReceiveWarehouseInventoryDto } from './dto/receive-inventory.dto';
|
||||||
import { ApproveDeliveryDto } from './dto/approve-delivery.dto';
|
import { ApproveDeliveryDto } from './dto/approve-delivery.dto';
|
||||||
|
import { SetDoubleHandlingDto } from './dto/double-handling.dto';
|
||||||
import { ReleaseOrderDto } from './dto/release-order.dto';
|
import { ReleaseOrderDto } from './dto/release-order.dto';
|
||||||
import { ReserveInventoryDto } from './dto/reserve-inventory.dto';
|
import { ReserveInventoryDto } from './dto/reserve-inventory.dto';
|
||||||
import { UnloadBookingDto } from './dto/unload-booking.dto';
|
import { UnloadBookingDto } from './dto/unload-booking.dto';
|
||||||
@@ -552,6 +553,23 @@ export class WarehouseInventoryController {
|
|||||||
return res.send(buffer);
|
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')
|
@Get('bookings/:bookingId/container-items')
|
||||||
@StaffReference()
|
@StaffReference()
|
||||||
@ApiOperation({ summary: 'Per-container/bulk items of a booking with lifecycle stage + refs' })
|
@ApiOperation({ summary: 'Per-container/bulk items of a booking with lifecycle stage + refs' })
|
||||||
|
|||||||
@@ -382,6 +382,8 @@ export interface ImportUnloadedRow {
|
|||||||
customerTruckContainerNumber: string | null;
|
customerTruckContainerNumber: string | null;
|
||||||
customerTruckAssignedAt: string | null;
|
customerTruckAssignedAt: string | null;
|
||||||
hasAssignedTruck: boolean;
|
hasAssignedTruck: boolean;
|
||||||
|
/** Post-unloading Yes/No; null = not recorded yet (no double-handling charge). */
|
||||||
|
doubleHandling: boolean | null;
|
||||||
currentStatus: string;
|
currentStatus: string;
|
||||||
releaseDate: string | null;
|
releaseDate: string | null;
|
||||||
releaseOrderReference: string | null;
|
releaseOrderReference: string | null;
|
||||||
@@ -1991,6 +1993,7 @@ export class WarehouseInventoryService {
|
|||||||
WHERE lm.booking_id = b.id
|
WHERE lm.booking_id = b.id
|
||||||
AND lm.vehicle_id IS NOT NULL
|
AND lm.vehicle_id IS NOT NULL
|
||||||
AND lm.deleted_at IS NULL)) AS "hasAssignedTruck",
|
AND lm.deleted_at IS NULL)) AS "hasAssignedTruck",
|
||||||
|
b.double_handling AS "doubleHandling",
|
||||||
inv.status AS "currentStatus",
|
inv.status AS "currentStatus",
|
||||||
inv.release_date AS "releaseDate",
|
inv.release_date AS "releaseDate",
|
||||||
inv.release_order_reference AS "releaseOrderReference",
|
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). */
|
/** Resolve the primary warehouse-inventory item for a booking (most recent). */
|
||||||
private async primaryInventoryIdForBooking(bookingId: string): Promise<string> {
|
private async primaryInventoryIdForBooking(bookingId: string): Promise<string> {
|
||||||
const [inv]: Array<{ id: string }> = await this.dataSource.query(
|
const [inv]: Array<{ id: string }> = await this.dataSource.query(
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import {
|
import {
|
||||||
ArrowRightLeft,
|
ArrowRightLeft,
|
||||||
|
Check,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
ClipboardCheck,
|
ClipboardCheck,
|
||||||
@@ -29,6 +30,7 @@ import {
|
|||||||
FileText,
|
FileText,
|
||||||
History,
|
History,
|
||||||
Info,
|
Info,
|
||||||
|
Layers,
|
||||||
MapPin,
|
MapPin,
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
PackageCheck,
|
PackageCheck,
|
||||||
@@ -2721,6 +2723,40 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
|
|||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
)}
|
)}
|
||||||
<Menu.Item onClick={() => setInspectId(r.id)}>Inspect / report</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.Divider />
|
||||||
<Menu.Item leftSection={<PackageCheck size={14} />} onClick={() => setFeeItem(toInventoryItem(r))}>
|
<Menu.Item leftSection={<PackageCheck size={14} />} onClick={() => setFeeItem(toInventoryItem(r))}>
|
||||||
Storage / fee preview
|
Storage / fee preview
|
||||||
|
|||||||
@@ -334,6 +334,13 @@ export const warehouseService = {
|
|||||||
deliver: (id: string, payload: DeliverInventoryPayload) =>
|
deliver: (id: string, payload: DeliverInventoryPayload) =>
|
||||||
apiClient.post<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVENTORY.DELIVER(id), payload),
|
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) ─────────────────────────────────────────
|
// ── Receive (Import/Export bulk) ─────────────────────────────────────────
|
||||||
eligibleBookings: (direction?: 'IMPORT' | 'EXPORT') =>
|
eligibleBookings: (direction?: 'IMPORT' | 'EXPORT') =>
|
||||||
apiClient.get<EligibleBooking[]>(URL_CONSTANTS.WAREHOUSE_INVENTORY.ELIGIBLE_BOOKINGS(direction)),
|
apiClient.get<EligibleBooking[]>(URL_CONSTANTS.WAREHOUSE_INVENTORY.ELIGIBLE_BOOKINGS(direction)),
|
||||||
|
|||||||
@@ -603,6 +603,8 @@ export interface ImportUnloadedItem {
|
|||||||
customerTruckContainerNumber: string | null;
|
customerTruckContainerNumber: string | null;
|
||||||
customerTruckAssignedAt: string | null;
|
customerTruckAssignedAt: string | null;
|
||||||
hasAssignedTruck: boolean;
|
hasAssignedTruck: boolean;
|
||||||
|
/** Post-unloading Yes/No; null = not recorded yet (no double-handling charge). */
|
||||||
|
doubleHandling: boolean | null;
|
||||||
currentStatus: string;
|
currentStatus: string;
|
||||||
releaseDate: string | null;
|
releaseDate: string | null;
|
||||||
releaseOrderReference: string | null;
|
releaseOrderReference: string | null;
|
||||||
|
|||||||
Reference in New Issue
Block a user