accrual dashboard for port and terminal or warehouse related documents

This commit is contained in:
Hagernesh
2026-07-14 08:07:46 +00:00
parent a542a13893
commit d9fe79e160
15 changed files with 526 additions and 3 deletions

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 { NotificationAudience, NotificationType } from '@edr/types';
import { DataSource } from 'typeorm';
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
import { CreateFeeRuleDto, UpdateFeeRuleDto } from './dto/fee-rule.dto';
import { FeeRuleBasis, FeeRuleType, WarehouseFeeRule, WarehouseFeeTier } from './entities/warehouse-fee-rule.entity';
import { WarehouseFeeRuleRepository } from './warehouse-fee-rule.repository';
@@ -26,6 +29,32 @@ interface ItemAttributes {
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;
breakdown: Array<{
type: FeeRuleType;
amount: number;
freeDays: number;
elapsedDays: number;
chargeableDays: number;
}>;
}
export interface FeePreview {
ruleType: FeeRuleType;
/** Double-handling charge basis (PER_CONTAINER | PER_TON | PER_MACHINERY); null otherwise. */
@@ -70,12 +99,80 @@ const MS_PER_DAY = 24 * 60 * 60 * 1000;
@Injectable()
export class WarehouseFeeService {
private readonly logger = new Logger(WarehouseFeeService.name);
constructor(
private readonly dataSource: DataSource,
private readonly feeRuleRepository: WarehouseFeeRuleRepository,
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');
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 ──────────────────────────────────────────────────────────────
listRules(): Promise<WarehouseFeeRule[]> {
return this.feeRuleRepository.findAll({ order: { ruleType: 'ASC', priority: 'ASC' } });
@@ -416,6 +513,91 @@ 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 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,
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);
return rows.sort(
(a, b) => rank(a.alert) - rank(b.alert) || b.accruedAmount - a.accruedAmount,
);
}
/** Preview demurrage + storage fees for an inventory item using the most specific active rules. */
async previewForInventory(inventoryId: string, billingCurrency = 'USD'): Promise<FeePreview[]> {
const item = await this.loadItem(inventoryId);

View File

@@ -368,6 +368,26 @@ export class WarehouseInventoryController {
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')
@ApiOperation({ summary: 'View import goods handover document PDF (resolved by booking)' })
async bookingHandoverDocument(@Param('bookingId', ParseUUIDPipe) bookingId: string, @Res() res: Response) {

View File

@@ -3265,6 +3265,31 @@ export class WarehouseInventoryService {
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 }> {
const [row] = await this.dataSource.query(
`SELECT inv.id,

View File

@@ -77,6 +77,12 @@ export class WarehouseRulesController {
return this.feeService.deleteRule(id);
}
@Get('warehouse-fees/accrual-dashboard')
@ApiOperation({ summary: 'Live per-item fee accrual (storage/demurrage) with alerts' })
accrualDashboard(@Query('billingCurrency') billingCurrency?: string) {
return this.feeService.accrualDashboard(billingCurrency);
}
@Get('warehouse-inventory/:id/fee-preview')
@ApiOperation({ summary: 'Preview demurrage + storage fees for an inventory item' })
feePreview(