This commit is contained in:
Roba Boru
2026-07-14 16:09:03 +03:00
35 changed files with 1104 additions and 70 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 }) @Column({ name: 'signed_at', type: 'timestamptz', nullable: true })
signedAt?: Date | null; 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 }) @Column({ name: 'signed_by_user_id', type: 'uuid', nullable: true })
signedByUserId?: string | null; 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). */ /** 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 await this.dataSource
.getRepository(BookingHandover) .getRepository(BookingHandover)
.update( .update(
{ bookingId, signedAt: IsNull() }, { bookingId, signedAt: IsNull() },
{ signedAt: new Date(), signedByUserId: userId ?? null }, {
signedAt: new Date(),
signedByUserId: userId ?? null,
signerName: signerName?.trim() || null,
},
); );
} }

View File

@@ -1,7 +1,10 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { ExchangeService } from '@edr/api-common'; import { ExchangeService } from '@edr/api-common';
import { NotificationAudience, NotificationType } from '@edr/types';
import { DataSource } from 'typeorm'; import { DataSource } from 'typeorm';
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
import { CreateFeeRuleDto, UpdateFeeRuleDto } from './dto/fee-rule.dto'; import { CreateFeeRuleDto, UpdateFeeRuleDto } from './dto/fee-rule.dto';
import { FeeRuleBasis, FeeRuleType, WarehouseFeeRule, WarehouseFeeTier } from './entities/warehouse-fee-rule.entity'; import { FeeRuleBasis, FeeRuleType, WarehouseFeeRule, WarehouseFeeTier } from './entities/warehouse-fee-rule.entity';
import { WarehouseFeeRuleRepository } from './warehouse-fee-rule.repository'; import { WarehouseFeeRuleRepository } from './warehouse-fee-rule.repository';
@@ -26,6 +29,35 @@ interface ItemAttributes {
zoneId: string | null; zoneId: string | null;
} }
export type AccrualAlert = 'OK' | 'WARNING' | 'CHARGING';
export interface AccrualDashboardRow {
inventoryId: string;
status: string;
bookingId: string | null;
companyId: string | null;
bookingReference: string | null;
customerName: string | null;
warehouseCode: string | null;
zoneCode: string | null;
receivedAt: string | null;
currency: string;
accruedAmount: number;
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;
freeDays: number;
elapsedDays: number;
chargeableDays: number;
}>;
}
export interface FeePreview { export interface FeePreview {
ruleType: FeeRuleType; ruleType: FeeRuleType;
/** Double-handling charge basis (PER_CONTAINER | PER_TON | PER_MACHINERY); null otherwise. */ /** Double-handling charge basis (PER_CONTAINER | PER_TON | PER_MACHINERY); null otherwise. */
@@ -70,12 +102,82 @@ const MS_PER_DAY = 24 * 60 * 60 * 1000;
@Injectable() @Injectable()
export class WarehouseFeeService { export class WarehouseFeeService {
private readonly logger = new Logger(WarehouseFeeService.name);
constructor( constructor(
private readonly dataSource: DataSource, private readonly dataSource: DataSource,
private readonly feeRuleRepository: WarehouseFeeRuleRepository, private readonly feeRuleRepository: WarehouseFeeRuleRepository,
private readonly exchangeService: ExchangeService, private readonly exchangeService: ExchangeService,
private readonly inbox: NotificationInboxService,
) {} ) {}
/**
* Daily accrual alerts: for every in-warehouse item that is charging or within
* its last free days, send the customer an in-app notification with the
* outstanding accrued amount so they can collect before (more) charges hit.
*/
@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' && !r.acknowledged,
);
if (!alerts.length) return;
this.logger.log(`Accrual alerts: ${alerts.length} item(s) charging or nearing charges`);
// Per-customer: notify each company about its own items.
for (const row of alerts.filter((r) => r.companyId)) {
const ref = row.bookingReference ?? row.inventoryId.slice(0, 8);
const amount = `${row.accruedAmount.toFixed(2)} ${row.currency}`;
const body = row.charging
? `Storage/demurrage is now charging on booking ${ref}${amount} accrued. Collect the cargo to stop further charges.`
: `Booking ${ref} has ${row.freeDaysLeft ?? 0} free day(s) left before storage/demurrage charges start (${amount} accrued so far).`;
try {
await this.inbox.notify({
recipients: { companyId: row.companyId! },
audience: NotificationAudience.PORTAL,
type: NotificationType.BOOKING_STATUS,
title: row.charging ? 'Storage charges accruing' : 'Free days ending soon',
body,
link: row.bookingId ? `/bookings/${row.bookingId}` : undefined,
data: {
inventoryId: row.inventoryId,
bookingId: row.bookingId,
alert: row.alert,
accruedAmount: row.accruedAmount,
action: 'ACCRUAL_ALERT',
},
});
} catch (err) {
this.logger.warn(
`Accrual alert failed for ${row.inventoryId}: ${(err as Error).message}`,
);
}
}
// Ops staff: one digest covering every alerting item.
const charging = alerts.filter((r) => r.charging).length;
const nearing = alerts.length - charging;
const currency = alerts[0]?.currency ?? 'USD';
const total = alerts.reduce((sum, r) => sum + r.accruedAmount, 0);
try {
await this.inbox.notify({
recipients: { allBackoffice: true },
audience: NotificationAudience.BACKOFFICE,
type: NotificationType.BOOKING_STATUS,
title: 'Warehouse fee accruals need attention',
body: `${charging} item(s) charging, ${nearing} nearing the free-day limit — ${total.toFixed(2)} ${currency} accruing. Review the accrual dashboard.`,
link: '/dashboard/warehouse-fee-invoices',
data: { charging, nearing, totalAccrued: Math.round(total * 100) / 100, action: 'ACCRUAL_ALERT_DIGEST' },
});
} catch (err) {
this.logger.warn(`Accrual staff digest failed: ${(err as Error).message}`);
}
} catch (err) {
this.logger.warn(`Accrual alert tick failed: ${(err as Error).message}`);
}
}
// ── Rule CRUD ────────────────────────────────────────────────────────────── // ── Rule CRUD ──────────────────────────────────────────────────────────────
listRules(): Promise<WarehouseFeeRule[]> { listRules(): Promise<WarehouseFeeRule[]> {
return this.feeRuleRepository.findAll({ order: { ruleType: 'ASC', priority: 'ASC' } }); return this.feeRuleRepository.findAll({ order: { ruleType: 'ASC', priority: 'ASC' } });
@@ -416,6 +518,138 @@ export class WarehouseFeeService {
}; };
} }
/**
* Live accrual dashboard: for every item still in the warehouse, the fees
* accruing right now (storage + demurrage + double-handling), how many free
* days remain, and an alert level so staff can act before charges land.
*/
async accrualDashboard(billingCurrency = 'USD'): Promise<AccrualDashboardRow[]> {
const items: Array<{
id: string;
status: string;
bookingId: string | null;
companyId: string | null;
bookingReference: string | null;
customerName: string | null;
warehouseCode: string | null;
zoneCode: string | null;
receivedAt: string | null;
}> = await this.dataSource.query(
`SELECT inv.id,
inv.status,
b.id AS "bookingId",
b.company_id AS "companyId",
b.reference AS "bookingReference",
c.name AS "customerName",
w.code AS "warehouseCode",
z.code AS "zoneCode",
inv.created_at AS "receivedAt"
FROM freight.warehouse_inventory inv
LEFT JOIN freight.bookings b ON b.id = inv.booking_id AND b.deleted_at IS NULL
LEFT JOIN freight.companies c ON c.id = b.company_id
LEFT JOIN freight.warehouses w ON w.id = inv.warehouse_id
LEFT JOIN freight.warehouse_zones z ON z.id = inv.zone_id
WHERE inv.deleted_at IS NULL
AND inv.status IN ('RECEIVED', 'STORED', 'READY_FOR_LOADING', 'READY_FOR_PICKUP', 'RESERVED')
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(
(p) => p.ruleId,
);
const accruedAmount =
Math.round(previews.reduce((sum, p) => sum + (p.amount ?? 0), 0) * 100) / 100;
const charging = previews.some((p) => p.chargeableDays > 0);
const freeDaysLeftVals = previews
.filter((p) => p.endIsOpen)
.map((p) => Math.max(0, p.freeDays - p.elapsedDays));
const freeDaysLeft = freeDaysLeftVals.length ? Math.min(...freeDaysLeftVals) : null;
const alert: AccrualAlert = charging
? 'CHARGING'
: freeDaysLeft != null && freeDaysLeft <= 2
? 'WARNING'
: 'OK';
return {
inventoryId: it.id,
status: it.status,
bookingId: it.bookingId,
companyId: it.companyId,
bookingReference: it.bookingReference,
customerName: it.customerName,
warehouseCode: it.warehouseCode,
zoneCode: it.zoneCode,
receivedAt: it.receivedAt,
currency: billingCurrency,
accruedAmount,
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,
freeDays: p.freeDays,
elapsedDays: p.elapsedDays,
chargeableDays: p.chargeableDays,
})),
};
}),
);
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) =>
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],
);
}
/** Preview demurrage + storage fees for an inventory item using the most specific active rules. */ /** Preview demurrage + storage fees for an inventory item using the most specific active rules. */
async previewForInventory(inventoryId: string, billingCurrency = 'USD'): Promise<FeePreview[]> { async previewForInventory(inventoryId: string, billingCurrency = 'USD'): Promise<FeePreview[]> {
const item = await this.loadItem(inventoryId); const item = await this.loadItem(inventoryId);

View File

@@ -12,6 +12,8 @@ import {
import { AnyFilesInterceptor } from '@nestjs/platform-express'; import { AnyFilesInterceptor } from '@nestjs/platform-express';
import { ApiBearerAuth, ApiConsumes, ApiOperation, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiConsumes, ApiOperation, ApiTags } from '@nestjs/swagger';
import { BookingStaff } from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { CreateInspectionReportDto } from './dto/create-inspection-report.dto'; import { CreateInspectionReportDto } from './dto/create-inspection-report.dto';
import { UpdateInspectionReportDto } from './dto/update-inspection-report.dto'; import { UpdateInspectionReportDto } from './dto/update-inspection-report.dto';
import { WarehouseInspectionService } from './warehouse-inspection.service'; import { WarehouseInspectionService } from './warehouse-inspection.service';
@@ -19,10 +21,12 @@ import { WarehouseInspectionService } from './warehouse-inspection.service';
@ApiTags('warehouse-inspection') @ApiTags('warehouse-inspection')
@ApiBearerAuth() @ApiBearerAuth()
@Controller() @Controller()
@BookingStaff(FREIGHT_PERMS.warehouseInspectionReports.view)
export class WarehouseInspectionController { export class WarehouseInspectionController {
constructor(private readonly inspectionService: WarehouseInspectionService) {} constructor(private readonly inspectionService: WarehouseInspectionService) {}
@Post('warehouse-inventory/:inventoryId/inspection-reports') @Post('warehouse-inventory/:inventoryId/inspection-reports')
@BookingStaff(FREIGHT_PERMS.warehouseInspectionReports.create)
@ApiOperation({ summary: 'Create an inspection / damage report for an inventory item' }) @ApiOperation({ summary: 'Create an inspection / damage report for an inventory item' })
create( create(
@Param('inventoryId', ParseUUIDPipe) inventoryId: string, @Param('inventoryId', ParseUUIDPipe) inventoryId: string,
@@ -46,12 +50,14 @@ export class WarehouseInspectionController {
} }
@Patch('warehouse-inspection-reports/:id') @Patch('warehouse-inspection-reports/:id')
@BookingStaff(FREIGHT_PERMS.warehouseInspectionReports.update)
@ApiOperation({ summary: 'Update an inspection report' }) @ApiOperation({ summary: 'Update an inspection report' })
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateInspectionReportDto) { update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateInspectionReportDto) {
return this.inspectionService.update(id, dto); return this.inspectionService.update(id, dto);
} }
@Post('warehouse-inspection-reports/:id/attachments') @Post('warehouse-inspection-reports/:id/attachments')
@BookingStaff(FREIGHT_PERMS.warehouseInspectionReports.update)
@UseInterceptors(AnyFilesInterceptor()) @UseInterceptors(AnyFilesInterceptor())
@ApiConsumes('multipart/form-data') @ApiConsumes('multipart/form-data')
@ApiOperation({ summary: 'Upload inspection images / documents' }) @ApiOperation({ summary: 'Upload inspection images / documents' })

View File

@@ -2,6 +2,8 @@ import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query, Reques
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import type { Response } from 'express'; import type { Response } from 'express';
import { BookingStaff } from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { BulkReceiveDto } from './dto/bulk-receive.dto'; import { BulkReceiveDto } from './dto/bulk-receive.dto';
import { BulkInspectDto } from './dto/bulk-inspect.dto'; import { BulkInspectDto } from './dto/bulk-inspect.dto';
import { DeliverInventoryDto } from './dto/deliver-inventory.dto'; import { DeliverInventoryDto } from './dto/deliver-inventory.dto';
@@ -11,6 +13,7 @@ import { LoadInventoryDto } from './dto/load-inventory.dto';
import { MoveInventoryDto } from './dto/move-inventory.dto'; 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 { 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';
@@ -29,48 +32,63 @@ export class WarehouseInventoryController {
) {} ) {}
@Get() @Get()
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
@ApiOperation({ summary: 'List warehouse inventory' }) @ApiOperation({ summary: 'List warehouse inventory' })
findAll(@Query() filter: FilterWarehouseInventoryDto) { findAll(@Query() filter: FilterWarehouseInventoryDto) {
return this.inventoryService.findAll(filter); return this.inventoryService.findAll(filter);
} }
@Get('ready-for-loading') @Get('ready-for-loading')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
@ApiOperation({ summary: 'List inventory ready for loading' }) @ApiOperation({ summary: 'List inventory ready for loading' })
findReadyForLoading(@Query() filter: FilterWarehouseInventoryDto) { findReadyForLoading(@Query() filter: FilterWarehouseInventoryDto) {
return this.inventoryService.findReadyForLoading(filter); return this.inventoryService.findReadyForLoading(filter);
} }
@Get('inquiry') @Get('inquiry')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
@ApiOperation({ summary: 'Locate any item inside the warehouse' }) @ApiOperation({ summary: 'Locate any item inside the warehouse' })
inquiry(@Query() filter: InquiryWarehouseInventoryDto) { inquiry(@Query() filter: InquiryWarehouseInventoryDto) {
return this.inventoryService.inquiry(filter); return this.inventoryService.inquiry(filter);
} }
@Get('arrival-queue') @Get('arrival-queue')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
@ApiOperation({ summary: 'Arrived bookings awaiting unload / inspection' }) @ApiOperation({ summary: 'Arrived bookings awaiting unload / inspection' })
arrivalQueue() { arrivalQueue() {
return this.inventoryService.arrivalQueue(); return this.inventoryService.arrivalQueue();
} }
@Get('ops-stats')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
@ApiOperation({ summary: 'At-a-glance warehouse ops counters for the KPI strip' })
opsStats() {
return this.inventoryService.opsStats();
}
@Get('zone-occupancy') @Get('zone-occupancy')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
@ApiOperation({ summary: 'Live occupancy per zone (rated capacity vs held) — heatmap data' }) @ApiOperation({ summary: 'Live occupancy per zone (rated capacity vs held) — heatmap data' })
zoneOccupancy(@Query('yardId') yardId?: string) { zoneOccupancy(@Query('yardId') yardId?: string) {
return this.inventoryService.zoneOccupancy(yardId); return this.inventoryService.zoneOccupancy(yardId);
} }
@Post('auto-unload-arrived') @Post('auto-unload-arrived')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.unload)
@ApiOperation({ summary: 'Bulk auto-unload all arrived bookings into the warehouse' }) @ApiOperation({ summary: 'Bulk auto-unload all arrived bookings into the warehouse' })
autoUnloadArrived() { autoUnloadArrived() {
return this.inventoryService.autoUnloadArrived(); return this.inventoryService.autoUnloadArrived();
} }
@Post('auto-load-ready') @Post('auto-load-ready')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.load)
@ApiOperation({ summary: 'Auto-load READY_FOR_LOADING inventory with PAID bookings' }) @ApiOperation({ summary: 'Auto-load READY_FOR_LOADING inventory with PAID bookings' })
autoLoadReady() { autoLoadReady() {
return this.inventoryService.autoLoadReady(); return this.inventoryService.autoLoadReady();
} }
@Get('eligible-bookings') @Get('eligible-bookings')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
@ApiOperation({ summary: 'PAID bookings not yet received, classified IMPORT/EXPORT by route; omit direction for all' }) @ApiOperation({ summary: 'PAID bookings not yet received, classified IMPORT/EXPORT by route; omit direction for all' })
eligibleBookings(@Query('direction') direction?: string) { eligibleBookings(@Query('direction') direction?: string) {
const dir = direction === 'IMPORT' || direction === 'EXPORT' ? direction : undefined; const dir = direction === 'IMPORT' || direction === 'EXPORT' ? direction : undefined;
@@ -78,6 +96,7 @@ export class WarehouseInventoryController {
} }
@Post('receive-bulk') @Post('receive-bulk')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.receive)
@ApiOperation({ summary: 'Bulk-receive selected eligible PAID bookings into a location' }) @ApiOperation({ summary: 'Bulk-receive selected eligible PAID bookings into a location' })
receiveBulk(@Body() dto: BulkReceiveDto) { receiveBulk(@Body() dto: BulkReceiveDto) {
return this.inventoryService.bulkReceive(dto); return this.inventoryService.bulkReceive(dto);
@@ -85,36 +104,42 @@ export class WarehouseInventoryController {
@Get('ready-to-load-export') @Get('ready-to-load-export')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
@ApiOperation({ summary: 'EXPORT inventory that passed inspection and is READY_FOR_LOADING' }) @ApiOperation({ summary: 'EXPORT inventory that passed inspection and is READY_FOR_LOADING' })
readyToLoadExport() { readyToLoadExport() {
return this.inventoryService.readyToLoadExport(); return this.inventoryService.readyToLoadExport();
} }
@Get('received-export') @Get('received-export')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
@ApiOperation({ summary: 'EXPORT inventory that has been received and is awaiting inspection' }) @ApiOperation({ summary: 'EXPORT inventory that has been received and is awaiting inspection' })
receivedExport() { receivedExport() {
return this.inventoryService.receivedExport(); return this.inventoryService.receivedExport();
} }
@Get('loaded-export') @Get('loaded-export')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
@ApiOperation({ summary: 'EXPORT inventory that is LOADED and queued for dispatch' }) @ApiOperation({ summary: 'EXPORT inventory that is LOADED and queued for dispatch' })
loadedExport() { loadedExport() {
return this.inventoryService.loadedExport(); return this.inventoryService.loadedExport();
} }
@Get('loadable-trains') @Get('loadable-trains')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
@ApiOperation({ summary: 'EXPORT trains (pre-dispatch) with inventory waiting to be loaded' }) @ApiOperation({ summary: 'EXPORT trains (pre-dispatch) with inventory waiting to be loaded' })
loadableTrains() { loadableTrains() {
return this.inventoryService.loadableTrains(); return this.inventoryService.loadableTrains();
} }
@Get('train/:scheduleId/loadable-items') @Get('train/:scheduleId/loadable-items')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
@ApiOperation({ summary: 'Container/cargo inventory assigned to a train, with allocated wagons' }) @ApiOperation({ summary: 'Container/cargo inventory assigned to a train, with allocated wagons' })
trainLoadableItems(@Param('scheduleId', ParseUUIDPipe) scheduleId: string) { trainLoadableItems(@Param('scheduleId', ParseUUIDPipe) scheduleId: string) {
return this.inventoryService.trainLoadableItems(scheduleId); return this.inventoryService.trainLoadableItems(scheduleId);
} }
@Post('train/:scheduleId/load') @Post('train/:scheduleId/load')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.load)
@ApiOperation({ summary: 'Load selected inventory items onto their allocated wagons for a train' }) @ApiOperation({ summary: 'Load selected inventory items onto their allocated wagons for a train' })
loadItemsOntoTrain( loadItemsOntoTrain(
@Param('scheduleId', ParseUUIDPipe) scheduleId: string, @Param('scheduleId', ParseUUIDPipe) scheduleId: string,
@@ -124,18 +149,21 @@ export class WarehouseInventoryController {
} }
@Post('bulk-dispatch-export') @Post('bulk-dispatch-export')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.dispatch)
@ApiOperation({ summary: 'Bulk-dispatch loaded EXPORT inventory (LOADED → DISPATCHED)' }) @ApiOperation({ summary: 'Bulk-dispatch loaded EXPORT inventory (LOADED → DISPATCHED)' })
bulkDispatchExport(@Body() dto: { inventoryIds: string[]; performedBy?: string }) { bulkDispatchExport(@Body() dto: { inventoryIds: string[]; performedBy?: string }) {
return this.inventoryService.bulkDispatchExport(dto.inventoryIds ?? [], dto.performedBy); return this.inventoryService.bulkDispatchExport(dto.inventoryIds ?? [], dto.performedBy);
} }
@Post('bulk-mark-inspected') @Post('bulk-mark-inspected')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.inspect)
@ApiOperation({ summary: 'Bulk mark received inventory inspection PASSED (EXPORT → READY_FOR_LOADING)' }) @ApiOperation({ summary: 'Bulk mark received inventory inspection PASSED (EXPORT → READY_FOR_LOADING)' })
bulkMarkInspected(@Body() dto: BulkInspectDto) { bulkMarkInspected(@Body() dto: BulkInspectDto) {
return this.inventoryService.bulkMarkInspected(dto); return this.inventoryService.bulkMarkInspected(dto);
} }
@Post('bookings/:bookingId/unload') @Post('bookings/:bookingId/unload')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.unload)
@ApiOperation({ summary: 'Unload a single arrived booking into a location' }) @ApiOperation({ summary: 'Unload a single arrived booking into a location' })
unloadBooking( unloadBooking(
@Param('bookingId', ParseUUIDPipe) bookingId: string, @Param('bookingId', ParseUUIDPipe) bookingId: string,
@@ -145,24 +173,28 @@ export class WarehouseInventoryController {
} }
@Post(':id/gate-clearance') @Post(':id/gate-clearance')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.gatePass)
@ApiOperation({ summary: 'Final terminal release / gate clearance (blocked while fees unpaid)' }) @ApiOperation({ summary: 'Final terminal release / gate clearance (blocked while fees unpaid)' })
gateClearance(@Param('id', ParseUUIDPipe) id: string, @Body('performedBy') performedBy?: string) { gateClearance(@Param('id', ParseUUIDPipe) id: string, @Body('performedBy') performedBy?: string) {
return this.inventoryService.gateClearance(id, performedBy); return this.inventoryService.gateClearance(id, performedBy);
} }
@Get('import/arrive-queue') @Get('import/arrive-queue')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
@ApiOperation({ summary: 'Arrived IMPORT train schedules (route-derived), read-only from scheduling' }) @ApiOperation({ summary: 'Arrived IMPORT train schedules (route-derived), read-only from scheduling' })
importArriveQueue() { importArriveQueue() {
return this.scheduling.importArriveQueue(); return this.scheduling.importArriveQueue();
} }
@Get('import/trains/:scheduleId/items') @Get('import/trains/:scheduleId/items')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
@ApiOperation({ summary: 'Assigned bookings/items for an arrived import train (read-only)' }) @ApiOperation({ summary: 'Assigned bookings/items for an arrived import train (read-only)' })
importTrainDetail(@Param('scheduleId', ParseUUIDPipe) scheduleId: string) { importTrainDetail(@Param('scheduleId', ParseUUIDPipe) scheduleId: string) {
return this.scheduling.importTrainDetail(scheduleId); return this.scheduling.importTrainDetail(scheduleId);
} }
@Post('import/auto-unload-arrived-bookings') @Post('import/auto-unload-arrived-bookings')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.unload)
@ApiOperation({ summary: 'Unload all eligible assigned bookings of an ARRIVED import train (→ UNLOADED)' }) @ApiOperation({ summary: 'Unload all eligible assigned bookings of an ARRIVED import train (→ UNLOADED)' })
autoUnloadArrivedBookings(@Body() dto: { autoUnloadArrivedBookings(@Body() dto: {
scheduleId: string; scheduleId: string;
@@ -179,12 +211,14 @@ export class WarehouseInventoryController {
} }
@Get('import/unloaded-queue') @Get('import/unloaded-queue')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
@ApiOperation({ summary: 'IMPORT inventory in the Unloaded Queue (UNLOADED / destination inspection)' }) @ApiOperation({ summary: 'IMPORT inventory in the Unloaded Queue (UNLOADED / destination inspection)' })
importUnloadedQueue() { importUnloadedQueue() {
return this.inventoryService.importUnloadedQueue(); return this.inventoryService.importUnloadedQueue();
} }
@Get('export/djibouti-arrival-queue') @Get('export/djibouti-arrival-queue')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
@ApiOperation({ summary: 'Arrived EXPORT train schedules at Djibouti-side ports, ready for unloading' }) @ApiOperation({ summary: 'Arrived EXPORT train schedules at Djibouti-side ports, ready for unloading' })
exportDjiboutiArrivalQueue( exportDjiboutiArrivalQueue(
@Query('scheduleId') scheduleId?: string, @Query('scheduleId') scheduleId?: string,
@@ -203,102 +237,119 @@ export class WarehouseInventoryController {
} }
@Get('export/djibouti-trains/:scheduleId/items') @Get('export/djibouti-trains/:scheduleId/items')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
@ApiOperation({ summary: 'Assigned export bookings/items for an arrived Djibouti-side train' }) @ApiOperation({ summary: 'Assigned export bookings/items for an arrived Djibouti-side train' })
exportDjiboutiTrainDetail(@Param('scheduleId', ParseUUIDPipe) scheduleId: string) { exportDjiboutiTrainDetail(@Param('scheduleId', ParseUUIDPipe) scheduleId: string) {
return this.scheduling.exportDjiboutiTrainDetail(scheduleId); return this.scheduling.exportDjiboutiTrainDetail(scheduleId);
} }
@Post('export/auto-unload-at-djibouti') @Post('export/auto-unload-at-djibouti')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.unload)
@ApiOperation({ summary: 'Unload all eligible export items assigned to an arrived Djibouti-side train' }) @ApiOperation({ summary: 'Unload all eligible export items assigned to an arrived Djibouti-side train' })
autoUnloadExportAtDjibouti(@Body() dto: { scheduleId: string; performedBy?: string }) { autoUnloadExportAtDjibouti(@Body() dto: { scheduleId: string; performedBy?: string }) {
return this.inventoryService.autoUnloadExportAtDjibouti(dto.scheduleId, dto.performedBy); return this.inventoryService.autoUnloadExportAtDjibouti(dto.scheduleId, dto.performedBy);
} }
@Get('import/pickup-ready-queue') @Get('import/pickup-ready-queue')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
@ApiOperation({ summary: 'IMPORT inventory that is PICKUP_READY (READY_FOR_PICKUP) awaiting pickup/dispatch' }) @ApiOperation({ summary: 'IMPORT inventory that is PICKUP_READY (READY_FOR_PICKUP) awaiting pickup/dispatch' })
importPickupReadyQueue() { importPickupReadyQueue() {
return this.inventoryService.importPickupReadyQueue(); return this.inventoryService.importPickupReadyQueue();
} }
@Get('loadable-wagons') @Get('loadable-wagons')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
@ApiOperation({ summary: 'List wagons usable for loading (read-only from scheduling)' }) @ApiOperation({ summary: 'List wagons usable for loading (read-only from scheduling)' })
loadableWagons() { loadableWagons() {
return this.scheduling.listLoadableWagons(); return this.scheduling.listLoadableWagons();
} }
@Get('booking/:bookingId/schedule') @Get('booking/:bookingId/schedule')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
@ApiOperation({ summary: 'Read-only schedule + wagon + departure status for a booking' }) @ApiOperation({ summary: 'Read-only schedule + wagon + departure status for a booking' })
bookingSchedule(@Param('bookingId', ParseUUIDPipe) bookingId: string) { bookingSchedule(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
return this.scheduling.getBookingSchedule(bookingId); return this.scheduling.getBookingSchedule(bookingId);
} }
@Post('receive') @Post('receive')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.receive)
@ApiOperation({ summary: 'Receive inventory at a warehouse location' }) @ApiOperation({ summary: 'Receive inventory at a warehouse location' })
receive(@Body() dto: ReceiveWarehouseInventoryDto) { receive(@Body() dto: ReceiveWarehouseInventoryDto) {
return this.inventoryService.receive(dto); return this.inventoryService.receive(dto);
} }
@Post('reserve') @Post('reserve')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.move)
@ApiOperation({ summary: 'Reserve stored inventory for a PAID booking' }) @ApiOperation({ summary: 'Reserve stored inventory for a PAID booking' })
reserve(@Body() dto: ReserveInventoryDto) { reserve(@Body() dto: ReserveInventoryDto) {
return this.inventoryService.reserve(dto); return this.inventoryService.reserve(dto);
} }
@Get(':id/movements') @Get(':id/movements')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
@ApiOperation({ summary: 'Inventory movement history' }) @ApiOperation({ summary: 'Inventory movement history' })
movements(@Param('id', ParseUUIDPipe) id: string) { movements(@Param('id', ParseUUIDPipe) id: string) {
return this.inventoryService.findMovements(id); return this.inventoryService.findMovements(id);
} }
@Get(':id/activity') @Get(':id/activity')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
@ApiOperation({ summary: 'Inventory activity log' }) @ApiOperation({ summary: 'Inventory activity log' })
activity(@Param('id', ParseUUIDPipe) id: string) { activity(@Param('id', ParseUUIDPipe) id: string) {
return this.inventoryService.findActivity(id); return this.inventoryService.findActivity(id);
} }
@Get(':id/loadings') @Get(':id/loadings')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
@ApiOperation({ summary: 'Loading records for an inventory item' }) @ApiOperation({ summary: 'Loading records for an inventory item' })
loadings(@Param('id', ParseUUIDPipe) id: string) { loadings(@Param('id', ParseUUIDPipe) id: string) {
return this.inventoryService.findLoadingsByInventory(id); return this.inventoryService.findLoadingsByInventory(id);
} }
@Post(':id/move') @Post(':id/move')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.move)
@ApiOperation({ summary: 'Move inventory to another warehouse/yard/zone' }) @ApiOperation({ summary: 'Move inventory to another warehouse/yard/zone' })
move(@Param('id', ParseUUIDPipe) id: string, @Body() dto: MoveInventoryDto) { move(@Param('id', ParseUUIDPipe) id: string, @Body() dto: MoveInventoryDto) {
return this.inventoryService.move(id, dto); return this.inventoryService.move(id, dto);
} }
@Post(':id/store') @Post(':id/store')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.move)
@ApiOperation({ summary: 'Mark received inventory as STORED (optional explicit warehouse/yard/zone)' }) @ApiOperation({ summary: 'Mark received inventory as STORED (optional explicit warehouse/yard/zone)' })
store(@Param('id', ParseUUIDPipe) id: string, @Body() dto: StoreInventoryDto) { store(@Param('id', ParseUUIDPipe) id: string, @Body() dto: StoreInventoryDto) {
return this.inventoryService.store(id, dto.performedBy, dto); return this.inventoryService.store(id, dto.performedBy, dto);
} }
@Post(':id/ready-for-loading') @Post(':id/ready-for-loading')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.move)
@ApiOperation({ summary: 'Mark reserved inventory READY_FOR_LOADING' }) @ApiOperation({ summary: 'Mark reserved inventory READY_FOR_LOADING' })
readyForLoading(@Param('id', ParseUUIDPipe) id: string, @Body('performedBy') performedBy?: string) { readyForLoading(@Param('id', ParseUUIDPipe) id: string, @Body('performedBy') performedBy?: string) {
return this.inventoryService.readyForLoading(id, performedBy); return this.inventoryService.readyForLoading(id, performedBy);
} }
@Post(':id/load') @Post(':id/load')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.load)
@ApiOperation({ summary: 'Load READY_FOR_LOADING inventory onto a wagon' }) @ApiOperation({ summary: 'Load READY_FOR_LOADING inventory onto a wagon' })
load(@Param('id', ParseUUIDPipe) id: string, @Body() dto: LoadInventoryDto) { load(@Param('id', ParseUUIDPipe) id: string, @Body() dto: LoadInventoryDto) {
return this.inventoryService.load(id, dto); return this.inventoryService.load(id, dto);
} }
@Post(':id/ready-for-pickup') @Post(':id/ready-for-pickup')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.move)
@ApiOperation({ summary: 'Mark inspected IMPORT inventory READY_FOR_PICKUP' }) @ApiOperation({ summary: 'Mark inspected IMPORT inventory READY_FOR_PICKUP' })
readyForPickup(@Param('id', ParseUUIDPipe) id: string, @Body('performedBy') performedBy?: string) { readyForPickup(@Param('id', ParseUUIDPipe) id: string, @Body('performedBy') performedBy?: string) {
return this.inventoryService.readyForPickup(id, performedBy); return this.inventoryService.readyForPickup(id, performedBy);
} }
@Post(':id/release') @Post(':id/release')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.release)
@ApiOperation({ summary: 'Issue a DO / release order for ready-for-pickup inventory' }) @ApiOperation({ summary: 'Issue a DO / release order for ready-for-pickup inventory' })
release(@Param('id', ParseUUIDPipe) id: string, @Body() dto: ReleaseOrderDto) { release(@Param('id', ParseUUIDPipe) id: string, @Body() dto: ReleaseOrderDto) {
return this.inventoryService.release(id, dto); return this.inventoryService.release(id, dto);
} }
@Get(':id/release-document') @Get(':id/release-document')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
@ApiOperation({ summary: 'View warehouse release / exit paper PDF' }) @ApiOperation({ summary: 'View warehouse release / exit paper PDF' })
async releaseDocument(@Param('id', ParseUUIDPipe) id: string, @Res() res: Response) { async releaseDocument(@Param('id', ParseUUIDPipe) id: string, @Res() res: Response) {
const { filename, buffer } = await this.inventoryService.releaseDocument(id); const { filename, buffer } = await this.inventoryService.releaseDocument(id);
@@ -309,6 +360,7 @@ export class WarehouseInventoryController {
} }
@Get('customer-truck-exit-paper/:assignmentId') @Get('customer-truck-exit-paper/:assignmentId')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
@ApiOperation({ summary: 'Per-truck exit paper PDF (containers loaded on one customer truck)' }) @ApiOperation({ summary: 'Per-truck exit paper PDF (containers loaded on one customer truck)' })
async truckExitPaper( async truckExitPaper(
@Param('assignmentId', ParseUUIDPipe) assignmentId: string, @Param('assignmentId', ParseUUIDPipe) assignmentId: string,
@@ -322,6 +374,7 @@ export class WarehouseInventoryController {
} }
@Get(':id/grn-document') @Get(':id/grn-document')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
@ApiOperation({ summary: 'View goods received note PDF' }) @ApiOperation({ summary: 'View goods received note PDF' })
async grnDocument(@Param('id', ParseUUIDPipe) id: string, @Res() res: Response) { async grnDocument(@Param('id', ParseUUIDPipe) id: string, @Res() res: Response) {
const { filename, buffer } = await this.inventoryService.grnDocument(id); const { filename, buffer } = await this.inventoryService.grnDocument(id);
@@ -342,12 +395,17 @@ export class WarehouseInventoryController {
} }
@Post('bookings/:bookingId/approve-delivery') @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( approveDeliveryForBooking(
@Param('bookingId', ParseUUIDPipe) bookingId: string, @Param('bookingId', ParseUUIDPipe) bookingId: string,
@Body() dto: ApproveDeliveryDto,
@Request() req: { user?: { id?: string; sub?: string } }, @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') @Get('bookings/:bookingId/handovers')
@@ -362,6 +420,26 @@ export class WarehouseInventoryController {
return this.handoverService.requestSignature(bookingId); return this.handoverService.requestSignature(bookingId);
} }
@Get('bookings/:bookingId/grn-document')
@ApiOperation({ summary: 'View GRN PDF for a booking (customer portal)' })
async bookingGrnDocument(@Param('bookingId', ParseUUIDPipe) bookingId: string, @Res() res: Response) {
const { filename, buffer } = await this.inventoryService.grnDocumentForBooking(bookingId);
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', `inline; filename="${filename}"`);
res.setHeader('Content-Length', buffer.length);
return res.send(buffer);
}
@Get('bookings/:bookingId/release-document')
@ApiOperation({ summary: 'View gate-clearance / release-order PDF for a booking (customer portal)' })
async bookingReleaseDocument(@Param('bookingId', ParseUUIDPipe) bookingId: string, @Res() res: Response) {
const { filename, buffer } = await this.inventoryService.releaseDocumentForBooking(bookingId);
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', `inline; filename="${filename}"`);
res.setHeader('Content-Length', buffer.length);
return res.send(buffer);
}
@Get('bookings/:bookingId/handover-document') @Get('bookings/:bookingId/handover-document')
@ApiOperation({ summary: 'View import goods handover document PDF (resolved by booking)' }) @ApiOperation({ summary: 'View import goods handover document PDF (resolved by booking)' })
async bookingHandoverDocument(@Param('bookingId', ParseUUIDPipe) bookingId: string, @Res() res: Response) { async bookingHandoverDocument(@Param('bookingId', ParseUUIDPipe) bookingId: string, @Res() res: Response) {
@@ -385,12 +463,14 @@ export class WarehouseInventoryController {
} }
@Post(':id/deliver') @Post(':id/deliver')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.deliver)
@ApiOperation({ summary: 'Deliver import goods to the customer + capture proof of delivery' }) @ApiOperation({ summary: 'Deliver import goods to the customer + capture proof of delivery' })
deliver(@Param('id', ParseUUIDPipe) id: string, @Body() dto: DeliverInventoryDto) { deliver(@Param('id', ParseUUIDPipe) id: string, @Body() dto: DeliverInventoryDto) {
return this.inventoryService.deliver(id, dto); return this.inventoryService.deliver(id, dto);
} }
@Patch(':id/dispatch') @Patch(':id/dispatch')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.dispatch)
@ApiOperation({ summary: 'Mark loaded inventory DISPATCHED (left the terminal)' }) @ApiOperation({ summary: 'Mark loaded inventory DISPATCHED (left the terminal)' })
dispatch(@Param('id', ParseUUIDPipe) id: string, @Body('performedBy') performedBy?: string) { dispatch(@Param('id', ParseUUIDPipe) id: string, @Body('performedBy') performedBy?: string) {
return this.inventoryService.dispatch(id, performedBy); return this.inventoryService.dispatch(id, performedBy);

View File

@@ -397,6 +397,45 @@ export class WarehouseInventoryService {
* but has no customer truck assigned yet, nudge the customer to assign one — with * but has no customer truck assigned yet, nudge the customer to assign one — with
* a deep-link to the booking's truck-assignment card. Fire-and-forget. * a deep-link to the booking's truck-assignment card. Fire-and-forget.
*/ */
/**
* At-a-glance warehouse ops counters for the KPI strip:
* - receivedToday: items received today
* - pendingInspection: RECEIVED items not yet inspected
* - trucksOnSite: customer trucks arrived but not departed
* - itemsAging: in-warehouse items older than 7 days (demurrage risk)
*/
async opsStats(): Promise<{
receivedToday: number;
pendingInspection: number;
trucksOnSite: number;
itemsAging: number;
}> {
const [row]: Array<{
receivedToday: number;
pendingInspection: number;
trucksOnSite: number;
itemsAging: number;
}> = await this.dataSource.query(
`SELECT
(SELECT count(*)::int FROM freight.warehouse_inventory
WHERE deleted_at IS NULL AND created_at::date = CURRENT_DATE) AS "receivedToday",
(SELECT count(*)::int FROM freight.warehouse_inventory
WHERE deleted_at IS NULL AND status = 'RECEIVED' AND inspection_status IS NULL) AS "pendingInspection",
(SELECT count(*)::int FROM freight.customer_truck_assignments
WHERE deleted_at IS NULL AND arrived_at IS NOT NULL AND departed_at IS NULL) AS "trucksOnSite",
(SELECT count(*)::int FROM freight.warehouse_inventory
WHERE deleted_at IS NULL
AND status IN ('RECEIVED', 'STORED', 'READY_FOR_LOADING', 'READY_FOR_PICKUP', 'RESERVED')
AND created_at < now() - interval '7 days') AS "itemsAging"`,
);
return {
receivedToday: row?.receivedToday ?? 0,
pendingInspection: row?.pendingInspection ?? 0,
trucksOnSite: row?.trucksOnSite ?? 0,
itemsAging: row?.itemsAging ?? 0,
};
}
/** /**
* Live occupancy per zone: rated capacity vs the weight/items currently held * Live occupancy per zone: rated capacity vs the weight/items currently held
* (excludes items that have left — DELIVERED/DISPATCHED). Powers the yard * (excludes items that have left — DELIVERED/DISPATCHED). Powers the yard
@@ -454,14 +493,17 @@ export class WarehouseInventoryService {
return rows.map((r) => { return rows.map((r) => {
const capWeight = r.capacityWeight != null ? Number(r.capacityWeight) : null; 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 = const byWeight =
capWeight && capWeight > 0 ? (r.usedWeight / capWeight) * 100 : null; capWeight && capWeight > 0 ? (usedWeightTons / capWeight) * 100 : null;
const byItems = const byItems =
r.capacityContainers && r.capacityContainers > 0 r.capacityContainers && r.capacityContainers > 0
? (r.usedItems / r.capacityContainers) * 100 ? (r.usedItems / r.capacityContainers) * 100
: null; : null;
// Prefer container-count occupancy (unit-consistent). Weight capacity is // Container zones use item-count occupancy; bulk zones (no container cap)
// tonnes while inventory weight is kg, so weight% is only a rough fallback. // fall back to the now unit-correct weight occupancy.
const pct = byItems ?? byWeight; const pct = byItems ?? byWeight;
return { return {
id: r.id, id: r.id,
@@ -3132,16 +3174,20 @@ export class WarehouseInventoryService {
async approveDeliveryForBooking( async approveDeliveryForBooking(
bookingId: string, bookingId: string,
userId?: string, userId?: string,
signerName?: string,
): Promise<{ bookingId: string; inventoryId: string; approvedAt: string; signerDisplayName: string }> { ): Promise<{ bookingId: string; inventoryId: string; approvedAt: string; signerDisplayName: string }> {
if (!userId) { if (!userId) {
throw new BadRequestException('Authentication is required to approve delivery'); throw new BadRequestException('Authentication is required to approve delivery');
} }
const name = signerName?.trim();
const signature = await this.signatures.getForUser(userId); if (!name) {
if (!signature?.signatureImageUrl) { throw new BadRequestException('Please enter your full name to approve delivery');
throw new BadRequestException('Please save your signature before approving 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<{ const [item]: Array<{
id: string; id: string;
warehouseId: string | null; warehouseId: string | null;
@@ -3176,8 +3222,8 @@ export class WarehouseInventoryService {
const approvedAt = new Date(); const approvedAt = new Date();
const approval = { const approval = {
approvedAt: approvedAt.toISOString(), approvedAt: approvedAt.toISOString(),
signerDisplayName: signature.signerDisplayName, signerDisplayName: name,
signatureImageUrl: signature.signatureImageUrl, signatureImageUrl: signature?.signatureImageUrl ?? null,
userId, userId,
}; };
const existingNotes = this.stripCustomerDeliveryApproval(item.notes); const existingNotes = this.stripCustomerDeliveryApproval(item.notes);
@@ -3192,8 +3238,8 @@ export class WarehouseInventoryService {
activityType: 'INVENTORY_RELEASED', activityType: 'INVENTORY_RELEASED',
inventoryId: item.id, inventoryId: item.id,
warehouseId: item.warehouseId, warehouseId: item.warehouseId,
description: `Customer approved delivery as ${signature.signerDisplayName}`, description: `Customer approved delivery as ${name}`,
performedBy: signature.signerDisplayName, performedBy: name,
}, },
manager, manager,
); );
@@ -3201,13 +3247,13 @@ export class WarehouseInventoryService {
// Sign the structured handover record(s) for this booking (self-haul: before // Sign the structured handover record(s) for this booking (self-haul: before
// the truck leaves). Kept alongside the legacy approval note. // the truck leaves). Kept alongside the legacy approval note.
await this.handover.signForBooking(bookingId, userId); await this.handover.signForBooking(bookingId, userId, name);
return { return {
bookingId, bookingId,
inventoryId: item.id, inventoryId: item.id,
approvedAt: approval.approvedAt, approvedAt: approval.approvedAt,
signerDisplayName: signature.signerDisplayName, signerDisplayName: name,
}; };
} }
@@ -3226,6 +3272,31 @@ export class WarehouseInventoryService {
return this.handoverDocument(inv.id); return this.handoverDocument(inv.id);
} }
/** 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(
`SELECT id FROM freight.warehouse_inventory
WHERE booking_id = $1 AND deleted_at IS NULL
ORDER BY updated_at DESC NULLS LAST, created_at DESC
LIMIT 1`,
[bookingId],
);
if (!inv) {
throw new NotFoundException(`No warehouse inventory found for booking ${bookingId}`);
}
return inv.id;
}
/** Booking-scoped GRN document (customer portal). */
async grnDocumentForBooking(bookingId: string): Promise<{ filename: string; buffer: Buffer }> {
return this.grnDocument(await this.primaryInventoryIdForBooking(bookingId));
}
/** Booking-scoped gate-clearance / release document (customer portal). */
async releaseDocumentForBooking(bookingId: string): Promise<{ filename: string; buffer: Buffer }> {
return this.releaseDocument(await this.primaryInventoryIdForBooking(bookingId));
}
async handoverDocument(id: string): Promise<{ filename: string; buffer: Buffer }> { async handoverDocument(id: string): Promise<{ filename: string; buffer: Buffer }> {
const [row] = await this.dataSource.query( const [row] = await this.dataSource.query(
`SELECT inv.id, `SELECT inv.id,

View File

@@ -2,6 +2,8 @@ import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query, Res }
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import type { Response } from 'express'; import type { Response } from 'express';
import { BookingStaff } from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { PayInvoiceDto as GatewayPayInvoiceDto } from '../billing/dto/pay-invoice.dto'; import { PayInvoiceDto as GatewayPayInvoiceDto } from '../billing/dto/pay-invoice.dto';
import { GenerateInvoiceDto, PayInvoiceBodyDto } from './dto/invoice.dto'; import { GenerateInvoiceDto, PayInvoiceBodyDto } from './dto/invoice.dto';
import { WarehouseInvoiceService } from './warehouse-invoice.service'; import { WarehouseInvoiceService } from './warehouse-invoice.service';
@@ -13,12 +15,14 @@ export class WarehouseInvoiceController {
constructor(private readonly invoiceService: WarehouseInvoiceService) {} constructor(private readonly invoiceService: WarehouseInvoiceService) {}
@Post('warehouse-inventory/:id/generate-fee-invoice') @Post('warehouse-inventory/:id/generate-fee-invoice')
@BookingStaff(FREIGHT_PERMS.warehouseFeeInvoices.generate)
@ApiOperation({ summary: 'Generate a warehouse fee invoice from Batch 5 fee calculation' }) @ApiOperation({ summary: 'Generate a warehouse fee invoice from Batch 5 fee calculation' })
generate(@Param('id', ParseUUIDPipe) id: string, @Body() dto: GenerateInvoiceDto) { generate(@Param('id', ParseUUIDPipe) id: string, @Body() dto: GenerateInvoiceDto) {
return this.invoiceService.generateForInventory(id, dto); return this.invoiceService.generateForInventory(id, dto);
} }
@Post('last-mile/:id/generate-truck-detention-invoice') @Post('last-mile/:id/generate-truck-detention-invoice')
@BookingStaff(FREIGHT_PERMS.warehouseFeeInvoices.generate)
@ApiOperation({ summary: 'Generate a truck-detention invoice for a last-mile leg (per truck per day)' }) @ApiOperation({ summary: 'Generate a truck-detention invoice for a last-mile leg (per truck per day)' })
generateTruckDetention( generateTruckDetention(
@Param('id', ParseUUIDPipe) id: string, @Param('id', ParseUUIDPipe) id: string,
@@ -28,6 +32,7 @@ export class WarehouseInvoiceController {
} }
@Get('warehouse-inventory/:id/fee-invoices') @Get('warehouse-inventory/:id/fee-invoices')
@BookingStaff(FREIGHT_PERMS.warehouseFeeInvoices.view)
@ApiOperation({ summary: 'List fee invoices for an inventory item' }) @ApiOperation({ summary: 'List fee invoices for an inventory item' })
listForInventory(@Param('id', ParseUUIDPipe) id: string) { listForInventory(@Param('id', ParseUUIDPipe) id: string) {
return this.invoiceService.listForInventory(id); return this.invoiceService.listForInventory(id);
@@ -40,6 +45,7 @@ export class WarehouseInvoiceController {
} }
@Get('warehouse-fee-invoices') @Get('warehouse-fee-invoices')
@BookingStaff(FREIGHT_PERMS.warehouseFeeInvoices.view)
@ApiOperation({ summary: 'List / filter warehouse fee invoices' }) @ApiOperation({ summary: 'List / filter warehouse fee invoices' })
findAll( findAll(
@Query('status') status?: string, @Query('status') status?: string,
@@ -86,12 +92,14 @@ export class WarehouseInvoiceController {
} }
@Patch('warehouse-fee-invoices/:id/cancel') @Patch('warehouse-fee-invoices/:id/cancel')
@BookingStaff(FREIGHT_PERMS.warehouseFeeInvoices.cancel)
@ApiOperation({ summary: 'Cancel a warehouse fee invoice' }) @ApiOperation({ summary: 'Cancel a warehouse fee invoice' })
cancel(@Param('id', ParseUUIDPipe) id: string) { cancel(@Param('id', ParseUUIDPipe) id: string) {
return this.invoiceService.cancel(id); return this.invoiceService.cancel(id);
} }
@Post('warehouse-fee-invoices/:id/pay') @Post('warehouse-fee-invoices/:id/pay')
@BookingStaff(FREIGHT_PERMS.warehouseFeeInvoices.pay)
@ApiOperation({ summary: 'Record a payment against a warehouse fee invoice' }) @ApiOperation({ summary: 'Record a payment against a warehouse fee invoice' })
pay(@Param('id', ParseUUIDPipe) id: string, @Body() dto: PayInvoiceBodyDto) { pay(@Param('id', ParseUUIDPipe) id: string, @Body() dto: PayInvoiceBodyDto) {
return this.invoiceService.pay(id, dto); return this.invoiceService.pay(id, dto);

View File

@@ -1,11 +1,14 @@
import { Controller, Get, Query } from '@nestjs/common'; import { Controller, Get, Query } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { BookingStaff } from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { WarehouseInventoryService } from './warehouse-inventory.service'; import { WarehouseInventoryService } from './warehouse-inventory.service';
@ApiTags('warehouse-loadings') @ApiTags('warehouse-loadings')
@ApiBearerAuth() @ApiBearerAuth()
@Controller('warehouse-loadings') @Controller('warehouse-loadings')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
export class WarehouseLoadingsController { export class WarehouseLoadingsController {
constructor(private readonly inventoryService: WarehouseInventoryService) {} constructor(private readonly inventoryService: WarehouseInventoryService) {}

View File

@@ -1,12 +1,15 @@
import { Body, Controller, Delete, Get, HttpCode, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common'; import { Body, Controller, Delete, Get, HttpCode, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { BookingStaff } from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { import {
AllocationPreviewDto, AllocationPreviewDto,
CreateAllocationRuleDto, CreateAllocationRuleDto,
UpdateAllocationRuleDto, UpdateAllocationRuleDto,
} from './dto/allocation-rule.dto'; } from './dto/allocation-rule.dto';
import { CreateFeeRuleDto, UpdateFeeRuleDto } from './dto/fee-rule.dto'; import { CreateFeeRuleDto, UpdateFeeRuleDto } from './dto/fee-rule.dto';
import { AcknowledgeAccrualDto } from './dto/acknowledge-accrual.dto';
import { WarehouseAllocationService } from './warehouse-allocation.service'; import { WarehouseAllocationService } from './warehouse-allocation.service';
import { WarehouseFeeService } from './warehouse-fee.service'; import { WarehouseFeeService } from './warehouse-fee.service';
@@ -21,18 +24,21 @@ export class WarehouseRulesController {
// ── Allocation rules ─────────────────────────────────────────────────────── // ── Allocation rules ───────────────────────────────────────────────────────
@Get('warehouse-allocation-rules') @Get('warehouse-allocation-rules')
@BookingStaff(FREIGHT_PERMS.warehouseAllocationRules.view)
@ApiOperation({ summary: 'List warehouse allocation rules' }) @ApiOperation({ summary: 'List warehouse allocation rules' })
listAllocationRules() { listAllocationRules() {
return this.allocationService.listRules(); return this.allocationService.listRules();
} }
@Post('warehouse-allocation-rules') @Post('warehouse-allocation-rules')
@BookingStaff(FREIGHT_PERMS.warehouseAllocationRules.create)
@ApiOperation({ summary: 'Create a warehouse allocation rule' }) @ApiOperation({ summary: 'Create a warehouse allocation rule' })
createAllocationRule(@Body() dto: CreateAllocationRuleDto) { createAllocationRule(@Body() dto: CreateAllocationRuleDto) {
return this.allocationService.createRule(dto); return this.allocationService.createRule(dto);
} }
@Patch('warehouse-allocation-rules/:id') @Patch('warehouse-allocation-rules/:id')
@BookingStaff(FREIGHT_PERMS.warehouseAllocationRules.update)
@ApiOperation({ summary: 'Update a warehouse allocation rule' }) @ApiOperation({ summary: 'Update a warehouse allocation rule' })
updateAllocationRule(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateAllocationRuleDto) { updateAllocationRule(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateAllocationRuleDto) {
return this.allocationService.updateRule(id, dto); return this.allocationService.updateRule(id, dto);
@@ -40,12 +46,14 @@ export class WarehouseRulesController {
@Delete('warehouse-allocation-rules/:id') @Delete('warehouse-allocation-rules/:id')
@HttpCode(204) @HttpCode(204)
@BookingStaff(FREIGHT_PERMS.warehouseAllocationRules.delete)
@ApiOperation({ summary: 'Delete a warehouse allocation rule' }) @ApiOperation({ summary: 'Delete a warehouse allocation rule' })
deleteAllocationRule(@Param('id', ParseUUIDPipe) id: string) { deleteAllocationRule(@Param('id', ParseUUIDPipe) id: string) {
return this.allocationService.deleteRule(id); return this.allocationService.deleteRule(id);
} }
@Post('warehouse-allocation/preview') @Post('warehouse-allocation/preview')
@BookingStaff(FREIGHT_PERMS.warehouseAllocationRules.view)
@ApiOperation({ summary: 'Preview the yard/warehouse/zone a booking would be allocated to' }) @ApiOperation({ summary: 'Preview the yard/warehouse/zone a booking would be allocated to' })
previewAllocation(@Body() dto: AllocationPreviewDto) { previewAllocation(@Body() dto: AllocationPreviewDto) {
return this.allocationService.resolveLocation(dto); return this.allocationService.resolveLocation(dto);
@@ -53,18 +61,21 @@ export class WarehouseRulesController {
// ── Fee rules ──────────────────────────────────────────────────────────────── // ── Fee rules ────────────────────────────────────────────────────────────────
@Get('warehouse-fee-rules') @Get('warehouse-fee-rules')
@BookingStaff(FREIGHT_PERMS.warehouseFeeRules.view)
@ApiOperation({ summary: 'List storage / demurrage fee rules' }) @ApiOperation({ summary: 'List storage / demurrage fee rules' })
listFeeRules() { listFeeRules() {
return this.feeService.listRules(); return this.feeService.listRules();
} }
@Post('warehouse-fee-rules') @Post('warehouse-fee-rules')
@BookingStaff(FREIGHT_PERMS.warehouseFeeRules.create)
@ApiOperation({ summary: 'Create a storage / demurrage fee rule' }) @ApiOperation({ summary: 'Create a storage / demurrage fee rule' })
createFeeRule(@Body() dto: CreateFeeRuleDto) { createFeeRule(@Body() dto: CreateFeeRuleDto) {
return this.feeService.createRule(dto); return this.feeService.createRule(dto);
} }
@Patch('warehouse-fee-rules/:id') @Patch('warehouse-fee-rules/:id')
@BookingStaff(FREIGHT_PERMS.warehouseFeeRules.update)
@ApiOperation({ summary: 'Update a fee rule' }) @ApiOperation({ summary: 'Update a fee rule' })
updateFeeRule(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateFeeRuleDto) { updateFeeRule(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateFeeRuleDto) {
return this.feeService.updateRule(id, dto); return this.feeService.updateRule(id, dto);
@@ -72,12 +83,42 @@ export class WarehouseRulesController {
@Delete('warehouse-fee-rules/:id') @Delete('warehouse-fee-rules/:id')
@HttpCode(204) @HttpCode(204)
@BookingStaff(FREIGHT_PERMS.warehouseFeeRules.delete)
@ApiOperation({ summary: 'Delete a fee rule' }) @ApiOperation({ summary: 'Delete a fee rule' })
deleteFeeRule(@Param('id', ParseUUIDPipe) id: string) { deleteFeeRule(@Param('id', ParseUUIDPipe) id: string) {
return this.feeService.deleteRule(id); return this.feeService.deleteRule(id);
} }
@Get('warehouse-fees/accrual-dashboard')
@BookingStaff(FREIGHT_PERMS.warehouseFeeRules.view)
@ApiOperation({ summary: 'Live per-item fee accrual (storage/demurrage) with alerts' })
accrualDashboard(@Query('billingCurrency') billingCurrency?: string) {
return this.feeService.accrualDashboard(billingCurrency);
}
@Post('warehouse-fees/accrual/:inventoryId/acknowledge')
@BookingStaff(FREIGHT_PERMS.warehouseFeeRules.update)
@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)
@BookingStaff(FREIGHT_PERMS.warehouseFeeRules.update)
@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') @Get('warehouse-inventory/:id/fee-preview')
@BookingStaff(FREIGHT_PERMS.warehouseFeeRules.view)
@ApiOperation({ summary: 'Preview demurrage + storage fees for an inventory item' }) @ApiOperation({ summary: 'Preview demurrage + storage fees for an inventory item' })
feePreview( feePreview(
@Param('id', ParseUUIDPipe) id: string, @Param('id', ParseUUIDPipe) id: string,
@@ -87,6 +128,7 @@ export class WarehouseRulesController {
} }
@Get('last-mile/:id/truck-detention-preview') @Get('last-mile/:id/truck-detention-preview')
@BookingStaff(FREIGHT_PERMS.warehouseFeeRules.view)
@ApiOperation({ summary: 'Preview truck detention for a last-mile leg (per truck per day after grace)' }) @ApiOperation({ summary: 'Preview truck detention for a last-mile leg (per truck per day after grace)' })
truckDetentionPreview( truckDetentionPreview(
@Param('id', ParseUUIDPipe) id: string, @Param('id', ParseUUIDPipe) id: string,

View File

@@ -1,6 +1,8 @@
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post } from '@nestjs/common'; import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { BookingStaff } from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { CreateWarehouseZoneDto } from './dto/create-warehouse-zone.dto'; import { CreateWarehouseZoneDto } from './dto/create-warehouse-zone.dto';
import { UpdateWarehouseYardDto } from './dto/update-warehouse-yard.dto'; import { UpdateWarehouseYardDto } from './dto/update-warehouse-yard.dto';
import { WarehouseYardsService } from './warehouse-yards.service'; import { WarehouseYardsService } from './warehouse-yards.service';
@@ -9,6 +11,7 @@ import { WarehouseZonesService } from './warehouse-zones.service';
@ApiTags('warehouse-yards') @ApiTags('warehouse-yards')
@ApiBearerAuth() @ApiBearerAuth()
@Controller('warehouse-yards') @Controller('warehouse-yards')
@BookingStaff(FREIGHT_PERMS.warehouseYards.view)
export class WarehouseYardsController { export class WarehouseYardsController {
constructor( constructor(
private readonly yardsService: WarehouseYardsService, private readonly yardsService: WarehouseYardsService,
@@ -28,18 +31,21 @@ export class WarehouseYardsController {
} }
@Patch(':id') @Patch(':id')
@BookingStaff(FREIGHT_PERMS.warehouseYards.update)
@ApiOperation({ summary: 'Update warehouse yard' }) @ApiOperation({ summary: 'Update warehouse yard' })
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateWarehouseYardDto) { update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateWarehouseYardDto) {
return this.yardsService.update(id, dto); return this.yardsService.update(id, dto);
} }
@Get(':yardId/zones') @Get(':yardId/zones')
@BookingStaff(FREIGHT_PERMS.warehouseZones.view)
@ApiOperation({ summary: 'List zones within a yard' }) @ApiOperation({ summary: 'List zones within a yard' })
listZones(@Param('yardId', ParseUUIDPipe) yardId: string) { listZones(@Param('yardId', ParseUUIDPipe) yardId: string) {
return this.zonesService.findByYard(yardId); return this.zonesService.findByYard(yardId);
} }
@Post(':yardId/zones') @Post(':yardId/zones')
@BookingStaff(FREIGHT_PERMS.warehouseZones.create)
@ApiOperation({ summary: 'Create a zone within a yard' }) @ApiOperation({ summary: 'Create a zone within a yard' })
createZone( createZone(
@Param('yardId', ParseUUIDPipe) yardId: string, @Param('yardId', ParseUUIDPipe) yardId: string,

View File

@@ -1,12 +1,15 @@
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch } from '@nestjs/common'; import { Body, Controller, Get, Param, ParseUUIDPipe, Patch } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { BookingStaff } from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { UpdateWarehouseZoneDto } from './dto/update-warehouse-zone.dto'; import { UpdateWarehouseZoneDto } from './dto/update-warehouse-zone.dto';
import { WarehouseZonesService } from './warehouse-zones.service'; import { WarehouseZonesService } from './warehouse-zones.service';
@ApiTags('warehouse-zones') @ApiTags('warehouse-zones')
@ApiBearerAuth() @ApiBearerAuth()
@Controller('warehouse-zones') @Controller('warehouse-zones')
@BookingStaff(FREIGHT_PERMS.warehouseZones.view)
export class WarehouseZonesController { export class WarehouseZonesController {
constructor(private readonly zonesService: WarehouseZonesService) {} constructor(private readonly zonesService: WarehouseZonesService) {}
@@ -23,6 +26,7 @@ export class WarehouseZonesController {
} }
@Patch(':id') @Patch(':id')
@BookingStaff(FREIGHT_PERMS.warehouseZones.update)
@ApiOperation({ summary: 'Update warehouse zone' }) @ApiOperation({ summary: 'Update warehouse zone' })
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateWarehouseZoneDto) { update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateWarehouseZoneDto) {
return this.zonesService.update(id, dto); return this.zonesService.update(id, dto);

View File

@@ -1,6 +1,8 @@
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common'; import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { BookingStaff } from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { CreateWarehouseDto } from './dto/create-warehouse.dto'; import { CreateWarehouseDto } from './dto/create-warehouse.dto';
import { CreateWarehouseYardDto } from './dto/create-warehouse-yard.dto'; import { CreateWarehouseYardDto } from './dto/create-warehouse-yard.dto';
import { FilterWarehouseDto } from './dto/filter-warehouse.dto'; import { FilterWarehouseDto } from './dto/filter-warehouse.dto';
@@ -12,6 +14,7 @@ import { WarehousesService } from './warehouses.service';
@ApiTags('warehouses') @ApiTags('warehouses')
@ApiBearerAuth() @ApiBearerAuth()
@Controller('warehouses') @Controller('warehouses')
@BookingStaff(FREIGHT_PERMS.warehouses.view)
export class WarehousesController { export class WarehousesController {
constructor( constructor(
private readonly warehousesService: WarehousesService, private readonly warehousesService: WarehousesService,
@@ -26,12 +29,14 @@ export class WarehousesController {
} }
@Get('dashboard') @Get('dashboard')
@BookingStaff(FREIGHT_PERMS.warehouseDashboard.view)
@ApiOperation({ summary: 'Warehouse dashboard metrics' }) @ApiOperation({ summary: 'Warehouse dashboard metrics' })
dashboard() { dashboard() {
return this.dashboardService.getDashboard(); return this.dashboardService.getDashboard();
} }
@Post() @Post()
@BookingStaff(FREIGHT_PERMS.warehouses.create)
@ApiOperation({ summary: 'Create warehouse' }) @ApiOperation({ summary: 'Create warehouse' })
create(@Body() dto: CreateWarehouseDto) { create(@Body() dto: CreateWarehouseDto) {
return this.warehousesService.create(dto); return this.warehousesService.create(dto);
@@ -44,18 +49,21 @@ export class WarehousesController {
} }
@Patch(':id') @Patch(':id')
@BookingStaff(FREIGHT_PERMS.warehouses.update)
@ApiOperation({ summary: 'Update warehouse' }) @ApiOperation({ summary: 'Update warehouse' })
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateWarehouseDto) { update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateWarehouseDto) {
return this.warehousesService.update(id, dto); return this.warehousesService.update(id, dto);
} }
@Get(':warehouseId/yards') @Get(':warehouseId/yards')
@BookingStaff(FREIGHT_PERMS.warehouseYards.view)
@ApiOperation({ summary: 'List yards within a warehouse' }) @ApiOperation({ summary: 'List yards within a warehouse' })
listYards(@Param('warehouseId', ParseUUIDPipe) warehouseId: string) { listYards(@Param('warehouseId', ParseUUIDPipe) warehouseId: string) {
return this.yardsService.findByWarehouse(warehouseId); return this.yardsService.findByWarehouse(warehouseId);
} }
@Post(':warehouseId/yards') @Post(':warehouseId/yards')
@BookingStaff(FREIGHT_PERMS.warehouseYards.create)
@ApiOperation({ summary: 'Create a yard within a warehouse' }) @ApiOperation({ summary: 'Create a yard within a warehouse' })
createYard( createYard(
@Param('warehouseId', ParseUUIDPipe) warehouseId: string, @Param('warehouseId', ParseUUIDPipe) warehouseId: string,

View File

@@ -0,0 +1,241 @@
import { useMemo } from 'react';
import { ActionIcon, Badge, Card, Group, Loader, Menu, SimpleGrid, Stack, Table, Text, ThemeIcon } from '@mantine/core';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { AlertTriangle, Bell, BellOff, Check, Clock, DollarSign, MoreVertical } from 'lucide-react';
import { useAccrualDashboard } from '@/hooks/useWarehouses';
import { warehouseService } from '@/services/warehouse.service';
import { useToast } from '@/hooks/use-toast';
import type { AccrualAlert, AccrualDashboardRow } from '@/types/warehouse';
const ALERT_META: Record<AccrualAlert, { color: string; label: string }> = {
CHARGING: { color: 'red', label: 'Charging' },
WARNING: { color: 'orange', label: 'Free days ending' },
OK: { color: 'teal', label: 'Within free days' },
};
function money(amount: number, currency: string): string {
return `${amount.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} ${currency}`;
}
function freeDaysLabel(row: AccrualDashboardRow): string {
if (row.charging) return 'charging now';
if (row.freeDaysLeft == null) return '—';
return `${row.freeDaysLeft} day${row.freeDaysLeft === 1 ? '' : 's'} left`;
}
/**
* Live accrual dashboard: storage / demurrage ticking per in-warehouse item,
* sorted so items already charging (or about to) surface first. Read-only.
*/
export function AccrualDashboard() {
const { data: rows = [], isLoading } = useAccrualDashboard();
const { toast } = useToast();
const qc = useQueryClient();
const refresh = () =>
qc.invalidateQueries({ queryKey: ['warehouse-fees', 'accrual-dashboard'] });
const ack = useMutation({
mutationFn: ({ id, snoozeDays }: { id: string; snoozeDays?: number }) =>
warehouseService.acknowledgeAccrual(id, snoozeDays ? { snoozeDays } : {}),
onSuccess: (_r, v) => {
toast({ title: v.snoozeDays ? `Snoozed ${v.snoozeDays} days` : 'Marked reviewed' });
void refresh();
},
onError: () => toast({ variant: 'destructive', title: 'Could not acknowledge' }),
});
const unack = useMutation({
mutationFn: (id: string) => warehouseService.unacknowledgeAccrual(id),
onSuccess: () => {
toast({ title: 'Acknowledgement removed' });
void refresh();
},
onError: () => toast({ variant: 'destructive', title: 'Could not un-acknowledge' }),
});
const summary = useMemo(() => {
const currency = rows[0]?.currency ?? 'USD';
return {
currency,
charging: rows.filter((r) => r.alert === 'CHARGING').length,
atRisk: rows.filter((r) => r.alert === 'WARNING').length,
totalAccruing: Math.round(rows.reduce((s, r) => s + r.accruedAmount, 0) * 100) / 100,
};
}, [rows]);
if (isLoading) {
return (
<Group justify="center" py="xl">
<Loader />
</Group>
);
}
return (
<Stack gap="md">
<SimpleGrid cols={{ base: 1, xs: 3 }} spacing="sm">
<StatCard
icon={<DollarSign size={18} />}
label="Accruing now"
value={money(summary.totalAccruing, summary.currency)}
color="edr-green"
/>
<StatCard
icon={<AlertTriangle size={18} />}
label="Charging"
value={summary.charging}
color={summary.charging > 0 ? 'red' : 'gray'}
/>
<StatCard
icon={<Clock size={18} />}
label="Free days ending (≤2d)"
value={summary.atRisk}
color={summary.atRisk > 0 ? 'orange' : 'gray'}
/>
</SimpleGrid>
<Card withBorder radius="md" padding={0}>
{rows.length === 0 ? (
<Text c="dimmed" ta="center" py="xl" size="sm">
No in-warehouse items are accruing fees.
</Text>
) : (
<Table.ScrollContainer minWidth={900}>
<Table verticalSpacing="sm" highlightOnHover striped>
<Table.Thead>
<Table.Tr>
<Table.Th>Booking</Table.Th>
<Table.Th>Customer</Table.Th>
<Table.Th>Location</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th ta="right">Accrued</Table.Th>
<Table.Th>Free days</Table.Th>
<Table.Th>Alert</Table.Th>
<Table.Th ta="right" />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{rows.map((row) => {
const meta = ALERT_META[row.alert];
const busy = ack.isPending || unack.isPending;
return (
<Table.Tr key={row.inventoryId} style={{ opacity: row.acknowledged ? 0.55 : 1 }}>
<Table.Td>
<Text fw={600} size="sm">
{row.bookingReference ?? row.inventoryId.slice(0, 8)}
</Text>
</Table.Td>
<Table.Td>{row.customerName ?? '—'}</Table.Td>
<Table.Td>
<Text size="sm">
{[row.warehouseCode, row.zoneCode].filter(Boolean).join(' · ') || '—'}
</Text>
</Table.Td>
<Table.Td>
<Badge variant="light" color="gray" size="sm">
{row.status}
</Badge>
</Table.Td>
<Table.Td ta="right">
<Text fw={600} size="sm" c={row.accruedAmount > 0 ? 'red' : undefined}>
{money(row.accruedAmount, row.currency)}
</Text>
</Table.Td>
<Table.Td>
<Text size="sm" c={row.charging ? 'red' : undefined}>
{freeDaysLabel(row)}
</Text>
</Table.Td>
<Table.Td>
{row.acknowledged ? (
<Badge color="gray" variant="light" size="sm" leftSection={<Check size={11} />}>
Reviewed{row.snoozeUntil ? ' (snoozed)' : ''}
</Badge>
) : (
<Badge color={meta.color} variant={row.alert === 'OK' ? 'light' : 'filled'} size="sm">
{meta.label}
</Badge>
)}
</Table.Td>
<Table.Td ta="right">
<Menu shadow="md" position="bottom-end" withinPortal>
<Menu.Target>
<ActionIcon variant="subtle" color="gray" loading={busy} aria-label="Accrual actions">
<MoreVertical size={16} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
{row.acknowledged ? (
<Menu.Item
leftSection={<Bell size={14} />}
onClick={() => unack.mutate(row.inventoryId)}
>
Un-acknowledge
</Menu.Item>
) : (
<>
<Menu.Item
leftSection={<Check size={14} />}
onClick={() => ack.mutate({ id: row.inventoryId })}
>
Mark reviewed
</Menu.Item>
<Menu.Item
leftSection={<BellOff size={14} />}
onClick={() => ack.mutate({ id: row.inventoryId, snoozeDays: 3 })}
>
Snooze 3 days
</Menu.Item>
<Menu.Item
leftSection={<BellOff size={14} />}
onClick={() => ack.mutate({ id: row.inventoryId, snoozeDays: 7 })}
>
Snooze 7 days
</Menu.Item>
</>
)}
</Menu.Dropdown>
</Menu>
</Table.Td>
</Table.Tr>
);
})}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
)}
</Card>
</Stack>
);
}
function StatCard({
icon,
label,
value,
color,
}: {
icon: React.ReactNode;
label: string;
value: React.ReactNode;
color: string;
}) {
return (
<Card withBorder radius="md" padding="md">
<Group gap="sm" wrap="nowrap">
<ThemeIcon color={color} variant="light" size={40} radius="md">
{icon}
</ThemeIcon>
<Stack gap={0} style={{ minWidth: 0 }}>
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>
{label}
</Text>
<Text fw={800} fz={20} lh={1.1} truncate>
{value}
</Text>
</Stack>
</Group>
</Card>
);
}

View File

@@ -19,7 +19,7 @@ import { MoveInventoryModal } from './MoveInventoryModal';
import { ReleaseOrderModal } from './ReleaseOrderModal'; import { ReleaseOrderModal } from './ReleaseOrderModal';
import { WarehouseInventoryTable } from './WarehouseInventoryTable'; import { WarehouseInventoryTable } from './WarehouseInventoryTable';
import { extractDownloadErrorMessage, extractErrorMessage } from './options'; import { extractDownloadErrorMessage, extractErrorMessage } from './options';
import { openPdfBlob } from './pdf'; import { openPdfBlob, saveBlob } from './pdf';
interface InventoryWorkbenchProps { interface InventoryWorkbenchProps {
items: WarehouseInventoryItem[]; items: WarehouseInventoryItem[];
@@ -138,6 +138,42 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo
} }
}; };
// One-click bundle: download every available document for the item (GRN +
// gate clearance / release order + handover). Best-effort — docs that aren't
// generatable yet for this item are skipped.
const downloadDocumentBundle = async (item: WarehouseInventoryItem) => {
setBusyId(item.id);
const ref = item.booking?.reference ?? item.bookingId ?? item.id;
const jobs: Array<{ name: string; fn: () => Promise<{ data: Blob }> }> = [
{ name: `GRN-${ref}.pdf`, fn: () => warehouseService.downloadGrnDocument(item.id) },
{ name: `gate-clearance-${ref}.pdf`, fn: () => warehouseService.downloadReleaseDocument(item.id) },
{ name: `handover-${ref}.pdf`, fn: () => warehouseService.downloadHandoverDocument(item.id) },
];
let saved = 0;
for (const job of jobs) {
try {
const response = await job.fn();
saveBlob(response.data, job.name);
saved += 1;
} catch {
// Document not available for this item yet — skip it.
}
}
setBusyId(null);
if (saved === 0) {
toast({
variant: 'destructive',
title: 'No documents available',
description: 'This item has no GRN, gate clearance or handover document yet.',
});
} else {
toast({
title: `Downloaded ${saved} document${saved !== 1 ? 's' : ''}`,
description: `Bundle for ${ref} (available documents only).`,
});
}
};
const acceptLastMile = async (item: WarehouseInventoryItem) => { const acceptLastMile = async (item: WarehouseInventoryItem) => {
const reference = item.booking?.reference; const reference = item.booking?.reference;
if (!reference) { if (!reference) {
@@ -243,6 +279,7 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo
onFeePreview={setFeeItem} onFeePreview={setFeeItem}
onReleaseDocument={downloadReleaseDocument} onReleaseDocument={downloadReleaseDocument}
onHandoverDocument={openHandoverDocument} onHandoverDocument={openHandoverDocument}
onDownloadBundle={downloadDocumentBundle}
onLastMile={onLastMile ? acceptLastMile : undefined} onLastMile={onLastMile ? acceptLastMile : undefined}
selectedIds={selected} selectedIds={selected}
onToggleSelect={toggleSelect} onToggleSelect={toggleSelect}

View File

@@ -1,6 +1,6 @@
import { useState, type MouseEvent } from 'react'; import { useState, type MouseEvent } from 'react';
import { ActionIcon, Badge, Button, Checkbox, Group, Table, Text, Tooltip } from '@mantine/core'; import { ActionIcon, Badge, Button, Checkbox, Group, Table, Text, Tooltip } from '@mantine/core';
import { ArrowRightLeft, ClipboardList, Coins, Eye, FileText, History, MapPin } from 'lucide-react'; import { ArrowRightLeft, ClipboardList, Coins, Download, Eye, FileText, History, MapPin } from 'lucide-react';
import { useToast } from '@/hooks/use-toast'; import { useToast } from '@/hooks/use-toast';
import { warehouseService } from '@/services/warehouse.service'; import { warehouseService } from '@/services/warehouse.service';
@@ -24,6 +24,7 @@ interface WarehouseInventoryTableProps {
onFeePreview?: (item: WarehouseInventoryItem) => void; onFeePreview?: (item: WarehouseInventoryItem) => void;
onReleaseDocument?: (item: WarehouseInventoryItem) => void; onReleaseDocument?: (item: WarehouseInventoryItem) => void;
onHandoverDocument?: (item: WarehouseInventoryItem) => void; onHandoverDocument?: (item: WarehouseInventoryItem) => void;
onDownloadBundle?: (item: WarehouseInventoryItem) => void;
onLastMile?: (item: WarehouseInventoryItem) => void; onLastMile?: (item: WarehouseInventoryItem) => void;
selectedIds?: Set<string>; selectedIds?: Set<string>;
onToggleSelect?: (id: string) => void; onToggleSelect?: (id: string) => void;
@@ -110,6 +111,7 @@ export function WarehouseInventoryTable({
onFeePreview, onFeePreview,
onReleaseDocument, onReleaseDocument,
onHandoverDocument, onHandoverDocument,
onDownloadBundle,
onLastMile, onLastMile,
selectedIds, selectedIds,
onToggleSelect, onToggleSelect,
@@ -285,6 +287,13 @@ export function WarehouseInventoryTable({
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
)} )}
{onDownloadBundle && item.grnNumber && (
<Tooltip label="Download document bundle (GRN + gate clearance + handover)" withArrow>
<ActionIcon variant="subtle" color="grape" onClick={() => onDownloadBundle(item)}>
<Download size={16} />
</ActionIcon>
</Tooltip>
)}
{onLastMile && item.booking?.lastMileDeliveryAddress && ( {onLastMile && item.booking?.lastMileDeliveryAddress && (
<Tooltip label="Last mile delivery" withArrow> <Tooltip label="Last mile delivery" withArrow>
<ActionIcon variant="subtle" color="blue" onClick={() => onLastMile(item)}> <ActionIcon variant="subtle" color="blue" onClick={() => onLastMile(item)}>

View File

@@ -0,0 +1,45 @@
import { AlertTriangle, ClipboardCheck, PackageCheck, Truck } from "lucide-react";
import { KpiStrip } from "@/components/page";
import { useWarehouseOpsStats } from "@/hooks/useWarehouses";
/**
* At-a-glance warehouse ops KPIs (received today, pending inspection, trucks
* on-site, items aging). Drop-in for any warehouse ops page header.
*/
export function WarehouseOpsKpiStrip() {
const { data, isLoading } = useWarehouseOpsStats();
return (
<KpiStrip
loading={isLoading}
items={[
{
label: "Received today",
value: data?.receivedToday ?? 0,
icon: PackageCheck,
color: "edr-green",
},
{
label: "Pending inspection",
value: data?.pendingInspection ?? 0,
icon: ClipboardCheck,
color: "yellow",
},
{
label: "Trucks on-site",
value: data?.trucksOnSite ?? 0,
icon: Truck,
color: "blue",
},
{
label: "Items aging (>7d)",
value: data?.itemsAging ?? 0,
icon: AlertTriangle,
color: (data?.itemsAging ?? 0) > 0 ? "red" : "edr-green",
hint: "In warehouse over 7 days",
},
]}
/>
);
}

View File

@@ -30,3 +30,5 @@ export { WarehouseDashboardCharts } from './WarehouseDashboardCharts';
export { InspectionReportModal } from './InspectionReportModal'; export { InspectionReportModal } from './InspectionReportModal';
export { FeePreviewModal } from './FeePreviewModal'; export { FeePreviewModal } from './FeePreviewModal';
export { ZoneOccupancyHeatmap } from './ZoneOccupancyHeatmap'; export { ZoneOccupancyHeatmap } from './ZoneOccupancyHeatmap';
export { WarehouseOpsKpiStrip } from './WarehouseOpsKpiStrip';
export { AccrualDashboard } from './AccrualDashboard';

View File

@@ -22,3 +22,16 @@ export function openPdfBlob(blob: Blob, filename: string, targetWindow?: Window
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
return false; return false;
} }
/** Force a browser download of a blob under the given filename (no preview tab). */
export function saveBlob(blob: Blob, filename: string) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
// Delay revoke so the download has time to start (esp. for rapid multi-saves).
setTimeout(() => URL.revokeObjectURL(url), 10_000);
}

View File

@@ -486,6 +486,7 @@ export const URL_CONSTANTS = {
MOVE: (id: string) => `/warehouse-inventory/${id}/move`, MOVE: (id: string) => `/warehouse-inventory/${id}/move`,
RESERVE: "/warehouse-inventory/reserve", RESERVE: "/warehouse-inventory/reserve",
ARRIVAL_QUEUE: "/warehouse-inventory/arrival-queue", ARRIVAL_QUEUE: "/warehouse-inventory/arrival-queue",
OPS_STATS: "/warehouse-inventory/ops-stats",
ZONE_OCCUPANCY: (yardId?: string) => ZONE_OCCUPANCY: (yardId?: string) =>
yardId yardId
? `/warehouse-inventory/zone-occupancy?yardId=${yardId}` ? `/warehouse-inventory/zone-occupancy?yardId=${yardId}`
@@ -557,6 +558,9 @@ export const URL_CONSTANTS = {
FEES_BY_ID: (id: string) => `/warehouse-fee-rules/${id}`, FEES_BY_ID: (id: string) => `/warehouse-fee-rules/${id}`,
FEE_PREVIEW: (inventoryId: string) => FEE_PREVIEW: (inventoryId: string) =>
`/warehouse-inventory/${inventoryId}/fee-preview`, `/warehouse-inventory/${inventoryId}/fee-preview`,
ACCRUAL_DASHBOARD: "/warehouse-fees/accrual-dashboard",
ACCRUAL_ACK: (inventoryId: string) =>
`/warehouse-fees/accrual/${inventoryId}/acknowledge`,
}, },
WAREHOUSE_INVOICES: { WAREHOUSE_INVOICES: {

View File

@@ -144,6 +144,22 @@ export function useZoneOccupancy(yardId?: string) {
}); });
} }
/** At-a-glance warehouse ops counters for the KPI strip. */
export function useWarehouseOpsStats() {
return useQuery({
queryKey: ['warehouse-inventory', 'ops-stats'],
queryFn: () => warehouseService.opsStats().then((r) => r.data),
});
}
/** Live per-item fee accrual (storage/demurrage) with alerts. */
export function useAccrualDashboard(billingCurrency?: 'ETB' | 'USD') {
return useQuery({
queryKey: ['warehouse-fees', 'accrual-dashboard', billingCurrency ?? 'USD'],
queryFn: () => warehouseService.accrualDashboard(billingCurrency).then((r) => r.data),
});
}
export function useCreateZone() { export function useCreateZone() {
const qc = useQueryClient(); const qc = useQueryClient();
return useMutation({ return useMutation({

View File

@@ -15,6 +15,7 @@ import { ChevronDown, ChevronRight, PackageOpen, Truck } from 'lucide-react';
import { PageContainer, PageHeader } from '@/components/page'; import { PageContainer, PageHeader } from '@/components/page';
import { import {
VisualEmptyState, VisualEmptyState,
WarehouseOpsKpiStrip,
formatDate, formatDate,
formatNumber, formatNumber,
} from '@/components/warehouses'; } from '@/components/warehouses';
@@ -291,6 +292,8 @@ export default function ArrivalQueuePage() {
breadcrumbs={[{ label: 'Arrival queue' }]} breadcrumbs={[{ label: 'Arrival queue' }]}
/> />
<WarehouseOpsKpiStrip />
<Card withBorder radius="md" padding="lg"> <Card withBorder radius="md" padding="lg">
<Group justify="space-between" mb="md"> <Group justify="space-between" mb="md">
<Stack gap={2}> <Stack gap={2}>

View File

@@ -29,6 +29,7 @@ import {
ActivityTimeline, ActivityTimeline,
InventoryMovementHistoryTable, InventoryMovementHistoryTable,
VisualEmptyState, VisualEmptyState,
WarehouseOpsKpiStrip,
formatDate, formatDate,
formatNumber, formatNumber,
} from '@/components/warehouses'; } from '@/components/warehouses';
@@ -292,6 +293,8 @@ export default function ExportDjiboutiUnloadingQueuePage() {
breadcrumbs={[{ label: 'Djibouti Arrival / Unloading Queue' }]} breadcrumbs={[{ label: 'Djibouti Arrival / Unloading Queue' }]}
/> />
<WarehouseOpsKpiStrip />
<Card withBorder radius="md" padding="lg"> <Card withBorder radius="md" padding="lg">
<Group justify="space-between" mb="md"> <Group justify="space-between" mb="md">
<Text fw={600}>{trains.length} arrived export train(s)</Text> <Text fw={600}>{trains.length} arrived export train(s)</Text>

View File

@@ -8,6 +8,7 @@ import { PageContainer, PageHeader } from '@/components/page';
import { import {
InventoryWorkbench, InventoryWorkbench,
VisualEmptyState, VisualEmptyState,
WarehouseOpsKpiStrip,
formatNumber, formatNumber,
} from '@/components/warehouses'; } from '@/components/warehouses';
import { LoadToTrainPanel } from '@/components/warehouses/LoadToTrainPanel'; import { LoadToTrainPanel } from '@/components/warehouses/LoadToTrainPanel';
@@ -74,6 +75,8 @@ export default function LoadingQueuePage() {
} }
/> />
<WarehouseOpsKpiStrip />
<Card> <Card>
<Tabs defaultValue="ready"> <Tabs defaultValue="ready">
<Tabs.List> <Tabs.List>

View File

@@ -20,6 +20,7 @@ import { useNavigate } from 'react-router-dom';
import { DataTable, type ColumnDef } from '@edr/ui-common'; import { DataTable, type ColumnDef } from '@edr/ui-common';
import { PageContainer, PageHeader } from '@/components/page'; import { PageContainer, PageHeader } from '@/components/page';
import { AccrualDashboard } from '@/components/warehouses';
import { useMutation, useQuery } from '@tanstack/react-query'; import { useMutation, useQuery } from '@tanstack/react-query';
import { api } from '@/services/api'; import { api } from '@/services/api';
@@ -122,6 +123,13 @@ export default function WarehouseInvoicesPage() {
subtitle="Demurrage & storage invoices generated from warehouse fee rules." subtitle="Demurrage & storage invoices generated from warehouse fee rules."
/> />
<Stack gap="xs">
<Text fw={700} size="sm" tt="uppercase" c="dimmed">
Accruing now
</Text>
<AccrualDashboard />
</Stack>
<Card> <Card>
<Group justify="space-between" mb="md" wrap="wrap"> <Group justify="space-between" mb="md" wrap="wrap">
<TextInput <TextInput

View File

@@ -5,6 +5,8 @@ import { api as apiClient } from '../auth/http';
import { URL_CONSTANTS } from '@/constants/URLS'; import { URL_CONSTANTS } from '@/constants/URLS';
import type { import type {
ZoneOccupancy, ZoneOccupancy,
WarehouseOpsStats,
AccrualDashboardRow,
AllocationCriteria, AllocationCriteria,
AllocationPreviewResult, AllocationPreviewResult,
AllocationRule, AllocationRule,
@@ -370,6 +372,8 @@ export const warehouseService = {
apiClient.get<ZoneOccupancy[]>( apiClient.get<ZoneOccupancy[]>(
URL_CONSTANTS.WAREHOUSE_INVENTORY.ZONE_OCCUPANCY(yardId), URL_CONSTANTS.WAREHOUSE_INVENTORY.ZONE_OCCUPANCY(yardId),
), ),
opsStats: () =>
apiClient.get<WarehouseOpsStats>(URL_CONSTANTS.WAREHOUSE_INVENTORY.OPS_STATS),
autoUnloadArrived: () => autoUnloadArrived: () =>
apiClient.post<AutoUnloadResult>(URL_CONSTANTS.WAREHOUSE_INVENTORY.AUTO_UNLOAD_ARRIVED), apiClient.post<AutoUnloadResult>(URL_CONSTANTS.WAREHOUSE_INVENTORY.AUTO_UNLOAD_ARRIVED),
autoLoadReady: () => autoLoadReady: () =>
@@ -422,6 +426,14 @@ export const warehouseService = {
apiClient.get<FeePreview[]>(URL_CONSTANTS.WAREHOUSE_RULES.FEE_PREVIEW(inventoryId), { apiClient.get<FeePreview[]>(URL_CONSTANTS.WAREHOUSE_RULES.FEE_PREVIEW(inventoryId), {
params: cleanParams({ billingCurrency }), params: cleanParams({ billingCurrency }),
}), }),
accrualDashboard: (billingCurrency?: 'ETB' | 'USD') =>
apiClient.get<AccrualDashboardRow[]>(URL_CONSTANTS.WAREHOUSE_RULES.ACCRUAL_DASHBOARD, {
params: cleanParams({ billingCurrency }),
}),
acknowledgeAccrual: (inventoryId: string, body: { snoozeDays?: number; note?: string } = {}) =>
apiClient.post(URL_CONSTANTS.WAREHOUSE_RULES.ACCRUAL_ACK(inventoryId), body),
unacknowledgeAccrual: (inventoryId: string) =>
apiClient.delete(URL_CONSTANTS.WAREHOUSE_RULES.ACCRUAL_ACK(inventoryId)),
// ── Batch 6: Warehouse fee invoices ──────────────────────────────────────── // ── Batch 6: Warehouse fee invoices ────────────────────────────────────────
listInvoices: (filter?: WarehouseInvoiceFilter) => listInvoices: (filter?: WarehouseInvoiceFilter) =>

View File

@@ -1107,3 +1107,40 @@ export interface ZoneOccupancy {
/** 0100+, container-count based (weight is a rough fallback). Null if no capacity set. */ /** 0100+, container-count based (weight is a rough fallback). Null if no capacity set. */
occupancyPct: number | null; occupancyPct: number | null;
} }
/** At-a-glance warehouse ops counters for the KPI strip. */
export interface WarehouseOpsStats {
receivedToday: number;
pendingInspection: number;
trucksOnSite: number;
itemsAging: number;
}
export type AccrualAlert = 'OK' | 'WARNING' | 'CHARGING';
/** One item's live fee accrual for the accrual dashboard. */
export interface AccrualDashboardRow {
inventoryId: string;
status: string;
bookingId: string | null;
companyId: string | null;
bookingReference: string | null;
customerName: string | null;
warehouseCode: string | null;
zoneCode: string | null;
receivedAt: string | null;
currency: string;
accruedAmount: number;
freeDaysLeft: number | null;
charging: boolean;
alert: AccrualAlert;
acknowledged: boolean;
snoozeUntil: string | null;
breakdown: Array<{
type: string;
amount: number;
freeDays: number;
elapsedDays: number;
chargeableDays: number;
}>;
}

View File

@@ -21,6 +21,10 @@ import { BookingActionModal } from "@/pages/bookings/clearance/BookingActionModa
import { getBookingNextAction } from "@/pages/bookings/clearance/bookingNextAction"; import { getBookingNextAction } from "@/pages/bookings/clearance/bookingNextAction";
import { ClearanceUploadedDocumentsPanel } from "@/components/contracts/ClearanceUploadedDocumentsPanel"; import { ClearanceUploadedDocumentsPanel } from "@/components/contracts/ClearanceUploadedDocumentsPanel";
import toast from "react-hot-toast";
import { bookingsService } from "@/services/bookings.service";
import { saveBlob } from "@/utils/download";
import { fmtDate } from "../utils"; import { fmtDate } from "../utils";
import { IconSquare } from "./Documents"; import { IconSquare } from "./Documents";
import { CardTitle, SectionCard } from "./layout"; import { CardTitle, SectionCard } from "./layout";
@@ -361,6 +365,39 @@ export function DocumentsTab({ booking }: { booking: Freight.IBooking }) {
const company = contract?.company; const company = contract?.company;
// One-click warehouse-document bundle: GRN + gate clearance + handover.
const [bundleBusy, setBundleBusy] = useState(false);
const downloadWarehouseDocuments = async () => {
setBundleBusy(true);
const ref = booking.reference ?? booking.id;
const jobs: Array<{ name: string; fn: () => Promise<Blob> }> = [
{ name: `GRN-${ref}.pdf`, fn: () => bookingsService.downloadBookingGrnDocument(booking.id) },
{
name: `gate-clearance-${ref}.pdf`,
fn: () => bookingsService.downloadBookingReleaseDocument(booking.id),
},
{
name: `handover-${ref}.pdf`,
fn: () => bookingsService.downloadBookingHandoverDocument(booking.id),
},
];
let saved = 0;
for (const job of jobs) {
try {
saveBlob(await job.fn(), job.name);
saved += 1;
} catch {
// Document not available for this booking yet — skip it.
}
}
setBundleBusy(false);
if (saved === 0) {
toast.error("No warehouse documents are available for this booking yet.");
} else {
toast.success(`Downloaded ${saved} document${saved !== 1 ? "s" : ""}.`);
}
};
return ( return (
<Stack gap="lg"> <Stack gap="lg">
{/* ── 1. Clearance documents ──────────────────────────────────────── */} {/* ── 1. Clearance documents ──────────────────────────────────────── */}
@@ -552,6 +589,23 @@ export function DocumentsTab({ booking }: { booking: Freight.IBooking }) {
</SectionCard> </SectionCard>
)} )}
{/* ── Warehouse documents (one-click bundle) ──────────────────────── */}
<SectionCard>
<CardTitle>Warehouse documents</CardTitle>
<Text fz="12.5px" c="dimmed" mt={4} mb="sm">
Goods Received Note, gate clearance / release order and handover download all
available documents for this booking in one click.
</Text>
<Button
leftSection={<Download size={16} />}
color="edr-green"
loading={bundleBusy}
onClick={downloadWarehouseDocuments}
>
Download documents
</Button>
</SectionCard>
{!hasContract && otherBookingFiles.length === 0 && ( {!hasContract && otherBookingFiles.length === 0 && (
<SectionCard> <SectionCard>
<Alert color="gray" radius="md" icon={<Info size={16} />}> <Alert color="gray" radius="md" icon={<Info size={16} />}>

View File

@@ -1,4 +1,4 @@
import { Alert, Button, Group, Loader, Modal, Stack, Text } from "@mantine/core"; import { Alert, Button, Group, Loader, Modal, Stack, Text, TextInput } from "@mantine/core";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { CheckCircle2, Info } from "lucide-react"; import { CheckCircle2, Info } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
@@ -47,6 +47,7 @@ export function ApproveDeliveryModal({
const navigate = useNavigate(); const navigate = useNavigate();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [pdfUrl, setPdfUrl] = useState<string | null>(null); const [pdfUrl, setPdfUrl] = useState<string | null>(null);
const [signerName, setSignerName] = useState("");
const { const {
data: docBlob, data: docBlob,
@@ -122,8 +123,9 @@ export function ApproveDeliveryModal({
<Stack gap="md"> <Stack gap="md">
<Alert color="blue" variant="light" icon={<Info size={16} />}> <Alert color="blue" variant="light" icon={<Info size={16} />}>
<Text size="sm"> <Text size="sm">
Review the handover document below. Approving applies your saved signature Review the handover document below, then type your full name to sign and
and confirms you received the goods. confirm you received the goods. Your saved signature is applied automatically
if you have one.
</Text> </Text>
</Alert> </Alert>
@@ -151,6 +153,15 @@ export function ApproveDeliveryModal({
/> />
)} )}
<TextInput
label="Your full name"
placeholder="e.g. Abebe Kebede"
required
value={signerName}
onChange={(e) => setSignerName(e.currentTarget.value)}
disabled={busy}
/>
<Group justify="flex-end"> <Group justify="flex-end">
<Button variant="default" onClick={onClose} disabled={busy}> <Button variant="default" onClick={onClose} disabled={busy}>
Cancel Cancel
@@ -159,8 +170,8 @@ export function ApproveDeliveryModal({
color="edr-green" color="edr-green"
leftSection={<CheckCircle2 size={16} />} leftSection={<CheckCircle2 size={16} />}
loading={busy} loading={busy}
disabled={isLoading || isError} disabled={isLoading || isError || !signerName.trim()}
onClick={() => approve.mutate({ id: bookingId })} onClick={() => approve.mutate({ id: bookingId, signerName: signerName.trim() })}
> >
Approve &amp; sign delivery Approve &amp; sign delivery
</Button> </Button>

View File

@@ -387,10 +387,10 @@ export const api = {
({ orderId }) => bookingsService.checkPayment(orderId), ({ orderId }) => bookingsService.checkPayment(orderId),
), ),
approveDelivery: endpoint<{ id: string }, ApproveDeliveryResponse>( approveDelivery: endpoint<{ id: string; signerName: string }, ApproveDeliveryResponse>(
"bookings", "bookings",
"approveDelivery", "approveDelivery",
({ id }) => bookingsService.approveDelivery(id), ({ id, signerName }) => bookingsService.approveDelivery(id, signerName),
), ),
getBookableSchedules: endpoint< getBookableSchedules: endpoint<

View File

@@ -209,6 +209,20 @@ export const bookingsService = {
); );
return data; return data;
}, },
downloadBookingGrnDocument: async (bookingId: string): Promise<Blob> => {
const { data } = await client.get(
`/api/warehouse-inventory/bookings/${bookingId}/grn-document`,
{ responseType: "blob" },
);
return data;
},
downloadBookingReleaseDocument: async (bookingId: string): Promise<Blob> => {
const { data } = await client.get(
`/api/warehouse-inventory/bookings/${bookingId}/release-document`,
{ responseType: "blob" },
);
return data;
},
tracking: async (id: string): Promise<Freight.IBookingTracking> => { tracking: async (id: string): Promise<Freight.IBookingTracking> => {
const { data } = await client.get(`/api/bookings/${id}/tracking`); const { data } = await client.get(`/api/bookings/${id}/tracking`);
return data.data; return data.data;
@@ -359,9 +373,13 @@ export const bookingsService = {
return data.data ?? data; return data.data ?? data;
}, },
approveDelivery: async (id: string): Promise<ApproveDeliveryResponse> => { approveDelivery: async (
id: string,
signerName: string,
): Promise<ApproveDeliveryResponse> => {
const { data } = await client.post( const { data } = await client.post(
`/api/warehouse-inventory/bookings/${id}/approve-delivery`, `/api/warehouse-inventory/bookings/${id}/approve-delivery`,
{ signerName },
); );
return data.data ?? data; return data.data ?? data;
}, },

View File

@@ -5,9 +5,6 @@
# Build: DOCKER_BUILDKIT=1 docker compose build # Build: DOCKER_BUILDKIT=1 docker compose build
# Run: docker compose up -d # Run: docker compose up -d
services: services:
# Message broker for payment event delivery (payment-api -> passenger/freight).
# Management UI: http://localhost:15672 (login edr / edr_secret). vhost: payment.
# In deployed envs this is a shared/managed RabbitMQ; only PAYMENT_RABBITMQ_URL changes.
freight-api: freight-api:
build: build:
context: . context: .
@@ -21,9 +18,6 @@ services:
extra_hosts: extra_hosts:
- "paymentcallback.triaplc.com:10.18.7.179" - "paymentcallback.triaplc.com:10.18.7.179"
# Standalone GT06 GPS tracker ingester (@edr/gps-tracker). Raw TCP only, no
# HTTP. Writes freight.gps_devices / freight.gps_positions in the shared
# freight DB; the /gps REST API stays in freight-api. Never runs migrations.
gps-tracker: gps-tracker:
build: build:
context: . context: .
@@ -33,14 +27,12 @@ services:
depends_on: depends_on:
- freight-api - freight-api
ports: ports:
# Raw TCP — reachable by tracker SIMs. Not HTTP; no L7 proxy can host-route it.
- "${GT06_TCP_PORT:-5023}:5023" - "${GT06_TCP_PORT:-5023}:5023"
environment: environment:
GT06_TCP_PORT: "5023" GT06_TCP_PORT: "5023"
GT06_TCP_HOST: "0.0.0.0" GT06_TCP_HOST: "0.0.0.0"
env_file: env_file:
# Reuses the freight DB credentials (DB_HOST/DB_PORT/DB_USER/DB_PASSWORD/DB_NAME). - apps/edr-gps-tracker/.env
- apps/edr-freight-api/.env
restart: unless-stopped restart: unless-stopped
passenger-api: passenger-api:
@@ -54,17 +46,7 @@ services:
extra_hosts: extra_hosts:
- "paymentcallback.triaplc.com:10.18.7.179" - "paymentcallback.triaplc.com:10.18.7.179"
restart: unless-stopped restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:4000/health/ready"]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
deploy:
resources:
limits:
cpus: '1.0'
memory: 1G
freight-portal: freight-portal:
build: build:
context: . context: .
@@ -80,6 +62,7 @@ services:
- npmrc - npmrc
ports: ports:
- "${FREIGHT_PORTAL_PORT:-5173}:80" - "${FREIGHT_PORTAL_PORT:-5173}:80"
freight-backoffice: freight-backoffice:
build: build:
context: . context: .
@@ -95,6 +78,7 @@ services:
- npmrc - npmrc
ports: ports:
- "${FREIGHT_BACKOFFICE_PORT:-5183}:80" - "${FREIGHT_BACKOFFICE_PORT:-5183}:80"
passenger-portal: passenger-portal:
build: build:
context: . context: .
@@ -110,17 +94,7 @@ services:
env_file: env_file:
- apps/edr-passenger-web/portal/.env - apps/edr-passenger-web/portal/.env
restart: unless-stopped restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:${PASSENGER_PORTAL_PORT:-5174}/api/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
deploy:
resources:
limits:
cpus: '0.5'
memory: 512M
passenger-backoffice: passenger-backoffice:
build: build:
context: . context: .
@@ -136,17 +110,6 @@ services:
env_file: env_file:
- apps/edr-passenger-web/backoffice/.env - apps/edr-passenger-web/backoffice/.env
restart: unless-stopped restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:${PASSENGER_BACKOFFICE_PORT:-5184}/api/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
deploy:
resources:
limits:
cpus: '0.5'
memory: 512M
payment-api: payment-api:
build: build: