mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 04:20:55 +00:00
accrual dashboard for port and terminal or warehouse related documents
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user