feat(warehouse): accrual acknowledge/snooze + zone weight fix, handover signer, portal delivery

Accrual dashboard:
- warehouse_accrual_acks table (migration 2140) + acknowledge/unacknowledge
  endpoints; dashboard rows carry acknowledged/snoozeUntil, acked items sink
  and are skipped by the alert cron. Row menu: mark reviewed / snooze 3d / 7d /
  un-acknowledge; acked rows dimmed with a "Reviewed" badge.
- Fix zone weight occupancy: normalise inventory kg vs zone-capacity tonnes.

Handover (rode along, shared files):
- Require signer full name on delivery handover (signature optional);
  migration 2130 adds signer_name.

Portal delivery/docs (rode along, shared files):
- Approve-delivery name capture, booking-scoped GRN/release docs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hagernesh
2026-07-14 09:35:21 +00:00
parent d9fe79e160
commit a367306565
18 changed files with 362 additions and 33 deletions

View File

@@ -0,0 +1,23 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* The person who signs off a handover must record their full name (a signature
* is optional, especially for self-haul). Stored per handover record.
*/
export class AddHandoverSignerName2130000000000 implements MigrationInterface {
name = "AddHandoverSignerName2130000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.booking_handovers
ADD COLUMN IF NOT EXISTS signer_name varchar(160)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.booking_handovers
DROP COLUMN IF EXISTS signer_name
`);
}
}

View File

@@ -0,0 +1,29 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Accrual alert acknowledgements: ops can mark an in-warehouse item's fee
* accrual as reviewed (optionally snoozed until a date) so it stops nudging and
* drops down the accrual dashboard. One row per inventory item.
*/
export class CreateAccrualAcks2140000000000 implements MigrationInterface {
name = "CreateAccrualAcks2140000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.warehouse_accrual_acks (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
inventory_id uuid NOT NULL UNIQUE,
acknowledged_by uuid,
acknowledged_at timestamptz NOT NULL DEFAULT now(),
snooze_until timestamptz,
note text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.warehouse_accrual_acks`);
}
}

View File

@@ -0,0 +1,18 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsInt, IsOptional, IsString, Max, MaxLength, Min } from 'class-validator';
/** Acknowledge (optionally snooze) an item's fee-accrual alert. */
export class AcknowledgeAccrualDto {
@ApiPropertyOptional({ minimum: 1, maximum: 90, description: 'Days to suppress alerts; omit = indefinitely.' })
@IsOptional()
@IsInt()
@Min(1)
@Max(90)
snoozeDays?: number;
@ApiPropertyOptional({ description: 'Optional reason / note.' })
@IsOptional()
@IsString()
@MaxLength(500)
note?: string;
}

View File

@@ -0,0 +1,11 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString, MaxLength } from 'class-validator';
/** The customer approving a handover must record their full name (signature optional). */
export class ApproveDeliveryDto {
@ApiProperty({ description: 'Full name of the person approving delivery.' })
@IsString()
@IsNotEmpty()
@MaxLength(160)
signerName!: string;
}

View File

@@ -37,6 +37,10 @@ export class BookingHandover extends BaseEntity {
@Column({ name: 'signed_at', type: 'timestamptz', nullable: true })
signedAt?: Date | null;
/** Full name of the person who signed off the handover (required at sign time). */
@Column({ name: 'signer_name', type: 'varchar', length: 160, nullable: true })
signerName?: string | null;
@Column({ name: 'signed_by_user_id', type: 'uuid', nullable: true })
signedByUserId?: string | null;

View File

@@ -183,12 +183,20 @@ export class HandoverService {
}
/** Sign all unsigned handovers on a booking (self-haul: before the truck leaves). */
async signForBooking(bookingId: string, userId?: string | null): Promise<void> {
async signForBooking(
bookingId: string,
userId?: string | null,
signerName?: string | null,
): Promise<void> {
await this.dataSource
.getRepository(BookingHandover)
.update(
{ bookingId, signedAt: IsNull() },
{ signedAt: new Date(), signedByUserId: userId ?? null },
{
signedAt: new Date(),
signedByUserId: userId ?? null,
signerName: signerName?.trim() || null,
},
);
}

View File

@@ -46,6 +46,9 @@ export interface AccrualDashboardRow {
freeDaysLeft: number | null;
charging: boolean;
alert: AccrualAlert;
/** Reviewed by ops — suppressed from alerts (snoozed until snoozeUntil, or indefinitely). */
acknowledged: boolean;
snoozeUntil: string | null;
breakdown: Array<{
type: FeeRuleType;
amount: number;
@@ -116,7 +119,9 @@ export class WarehouseFeeService {
@Cron(CronExpression.EVERY_DAY_AT_6AM, { name: 'warehouse-accrual-alert' })
async sendAccrualAlerts(): Promise<void> {
try {
const alerts = (await this.accrualDashboard()).filter((r) => r.alert !== 'OK');
const alerts = (await this.accrualDashboard()).filter(
(r) => r.alert !== 'OK' && !r.acknowledged,
);
if (!alerts.length) return;
this.logger.log(`Accrual alerts: ${alerts.length} item(s) charging or nearing charges`);
@@ -549,6 +554,14 @@ export class WarehouseFeeService {
ORDER BY inv.created_at ASC`,
);
const ackRows: Array<{ inventoryId: string; snoozeUntil: string | null }> =
await this.dataSource.query(
`SELECT inventory_id AS "inventoryId", snooze_until AS "snoozeUntil"
FROM freight.warehouse_accrual_acks`,
);
const now = new Date();
const acks = new Map(ackRows.map((a) => [a.inventoryId, a.snoozeUntil]));
const rows = await Promise.all(
items.map(async (it): Promise<AccrualDashboardRow> => {
const previews = (await this.previewForInventory(it.id, billingCurrency)).filter(
@@ -581,6 +594,10 @@ export class WarehouseFeeService {
freeDaysLeft,
charging,
alert,
acknowledged:
acks.has(it.id) &&
(acks.get(it.id) == null || new Date(acks.get(it.id) as string) > now),
snoozeUntil: acks.get(it.id) ?? null,
breakdown: previews.map((p) => ({
type: p.ruleType,
amount: p.amount,
@@ -593,8 +610,43 @@ export class WarehouseFeeService {
);
const rank = (a: AccrualAlert) => (a === 'CHARGING' ? 0 : a === 'WARNING' ? 1 : 2);
// Acknowledged items sink to the bottom; among the rest, worst alert first.
return rows.sort(
(a, b) => rank(a.alert) - rank(b.alert) || b.accruedAmount - a.accruedAmount,
(a, b) =>
Number(a.acknowledged) - Number(b.acknowledged) ||
rank(a.alert) - rank(b.alert) ||
b.accruedAmount - a.accruedAmount,
);
}
/** Mark an item's accrual reviewed. `snoozeDays` > 0 suppresses alerts until then; omitted = indefinitely. */
async acknowledgeAccrual(
inventoryId: string,
opts: { snoozeDays?: number; note?: string; userId?: string } = {},
): Promise<void> {
const snoozeUntil =
opts.snoozeDays && opts.snoozeDays > 0
? new Date(Date.now() + opts.snoozeDays * 24 * 60 * 60 * 1000)
: null;
await this.dataSource.query(
`INSERT INTO freight.warehouse_accrual_acks
(inventory_id, acknowledged_by, acknowledged_at, snooze_until, note, updated_at)
VALUES ($1, $2, now(), $3, $4, now())
ON CONFLICT (inventory_id) DO UPDATE
SET acknowledged_by = EXCLUDED.acknowledged_by,
acknowledged_at = now(),
snooze_until = EXCLUDED.snooze_until,
note = EXCLUDED.note,
updated_at = now()`,
[inventoryId, opts.userId ?? null, snoozeUntil, opts.note?.trim() || null],
);
}
/** Remove an acknowledgement so the item re-surfaces for alerts. */
async unacknowledgeAccrual(inventoryId: string): Promise<void> {
await this.dataSource.query(
`DELETE FROM freight.warehouse_accrual_acks WHERE inventory_id = $1`,
[inventoryId],
);
}

View File

@@ -11,6 +11,7 @@ import { LoadInventoryDto } from './dto/load-inventory.dto';
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 { ReleaseOrderDto } from './dto/release-order.dto';
import { ReserveInventoryDto } from './dto/reserve-inventory.dto';
import { UnloadBookingDto } from './dto/unload-booking.dto';
@@ -348,12 +349,17 @@ export class WarehouseInventoryController {
}
@Post('bookings/:bookingId/approve-delivery')
@ApiOperation({ summary: "Approve delivery using the current customer's saved signature" })
@ApiOperation({ summary: "Approve delivery — customer records their full name (signature optional)" })
approveDeliveryForBooking(
@Param('bookingId', ParseUUIDPipe) bookingId: string,
@Body() dto: ApproveDeliveryDto,
@Request() req: { user?: { id?: string; sub?: string } },
) {
return this.inventoryService.approveDeliveryForBooking(bookingId, req.user?.id ?? req.user?.sub);
return this.inventoryService.approveDeliveryForBooking(
bookingId,
req.user?.id ?? req.user?.sub,
dto.signerName,
);
}
@Get('bookings/:bookingId/handovers')

View File

@@ -493,14 +493,17 @@ export class WarehouseInventoryService {
return rows.map((r) => {
const capWeight = r.capacityWeight != null ? Number(r.capacityWeight) : null;
// Zone capacity_weight is in TONNES; inventory weight is in KG — normalise
// used weight to tonnes before comparing so weight occupancy is correct.
const usedWeightTons = r.usedWeight / 1000;
const byWeight =
capWeight && capWeight > 0 ? (r.usedWeight / capWeight) * 100 : null;
capWeight && capWeight > 0 ? (usedWeightTons / capWeight) * 100 : null;
const byItems =
r.capacityContainers && r.capacityContainers > 0
? (r.usedItems / r.capacityContainers) * 100
: null;
// Prefer container-count occupancy (unit-consistent). Weight capacity is
// tonnes while inventory weight is kg, so weight% is only a rough fallback.
// Container zones use item-count occupancy; bulk zones (no container cap)
// fall back to the now unit-correct weight occupancy.
const pct = byItems ?? byWeight;
return {
id: r.id,
@@ -3171,16 +3174,20 @@ export class WarehouseInventoryService {
async approveDeliveryForBooking(
bookingId: string,
userId?: string,
signerName?: string,
): Promise<{ bookingId: string; inventoryId: string; approvedAt: string; signerDisplayName: string }> {
if (!userId) {
throw new BadRequestException('Authentication is required to approve delivery');
}
const signature = await this.signatures.getForUser(userId);
if (!signature?.signatureImageUrl) {
throw new BadRequestException('Please save your signature before approving delivery');
const name = signerName?.trim();
if (!name) {
throw new BadRequestException('Please enter your full name to approve delivery');
}
// A saved signature is applied when available; otherwise the typed full name
// is the record of who approved (self-haul customers may have no signature).
const signature = await this.signatures.getForUser(userId).catch(() => null);
const [item]: Array<{
id: string;
warehouseId: string | null;
@@ -3215,8 +3222,8 @@ export class WarehouseInventoryService {
const approvedAt = new Date();
const approval = {
approvedAt: approvedAt.toISOString(),
signerDisplayName: signature.signerDisplayName,
signatureImageUrl: signature.signatureImageUrl,
signerDisplayName: name,
signatureImageUrl: signature?.signatureImageUrl ?? null,
userId,
};
const existingNotes = this.stripCustomerDeliveryApproval(item.notes);
@@ -3231,8 +3238,8 @@ export class WarehouseInventoryService {
activityType: 'INVENTORY_RELEASED',
inventoryId: item.id,
warehouseId: item.warehouseId,
description: `Customer approved delivery as ${signature.signerDisplayName}`,
performedBy: signature.signerDisplayName,
description: `Customer approved delivery as ${name}`,
performedBy: name,
},
manager,
);
@@ -3240,13 +3247,13 @@ export class WarehouseInventoryService {
// Sign the structured handover record(s) for this booking (self-haul: before
// the truck leaves). Kept alongside the legacy approval note.
await this.handover.signForBooking(bookingId, userId);
await this.handover.signForBooking(bookingId, userId, name);
return {
bookingId,
inventoryId: item.id,
approvedAt: approval.approvedAt,
signerDisplayName: signature.signerDisplayName,
signerDisplayName: name,
};
}

View File

@@ -7,6 +7,7 @@ import {
UpdateAllocationRuleDto,
} from './dto/allocation-rule.dto';
import { CreateFeeRuleDto, UpdateFeeRuleDto } from './dto/fee-rule.dto';
import { AcknowledgeAccrualDto } from './dto/acknowledge-accrual.dto';
import { WarehouseAllocationService } from './warehouse-allocation.service';
import { WarehouseFeeService } from './warehouse-fee.service';
@@ -83,6 +84,25 @@ export class WarehouseRulesController {
return this.feeService.accrualDashboard(billingCurrency);
}
@Post('warehouse-fees/accrual/:inventoryId/acknowledge')
@ApiOperation({ summary: 'Acknowledge / snooze an item fee-accrual alert' })
acknowledgeAccrual(
@Param('inventoryId', ParseUUIDPipe) inventoryId: string,
@Body() dto: AcknowledgeAccrualDto,
) {
return this.feeService.acknowledgeAccrual(inventoryId, {
snoozeDays: dto.snoozeDays,
note: dto.note,
});
}
@Delete('warehouse-fees/accrual/:inventoryId/acknowledge')
@HttpCode(204)
@ApiOperation({ summary: 'Remove an accrual acknowledgement (re-surface for alerts)' })
unacknowledgeAccrual(@Param('inventoryId', ParseUUIDPipe) inventoryId: string) {
return this.feeService.unacknowledgeAccrual(inventoryId);
}
@Get('warehouse-inventory/:id/fee-preview')
@ApiOperation({ summary: 'Preview demurrage + storage fees for an inventory item' })
feePreview(