mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
@@ -0,0 +1,25 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Double-handling fee support. warehouse_fee_rules.basis: how a
|
||||
* DOUBLE_HANDLING_FEE rule is charged — PER_CONTAINER | PER_TON | PER_ITEM
|
||||
* (null for the day-based fee types). The PER_TON / PER_ITEM quantity comes from
|
||||
* the booking's cargo total (cargo_total_weight_vgm, expressed in the cargo's
|
||||
* unit of measure), so no new booking column is needed.
|
||||
*/
|
||||
export class AddDoubleHandlingBasisAndMachinery1990000000000 implements MigrationInterface {
|
||||
name = 'AddDoubleHandlingBasisAndMachinery1990000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.warehouse_fee_rules ADD COLUMN IF NOT EXISTS basis varchar(20)`,
|
||||
);
|
||||
// machinery_units is not used (PER_ITEM reads cargo_total_weight_vgm); drop it
|
||||
// if a prior version of this migration added it.
|
||||
await queryRunner.query(`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS machinery_units`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE freight.warehouse_fee_rules DROP COLUMN IF EXISTS basis`);
|
||||
}
|
||||
}
|
||||
@@ -121,6 +121,146 @@ export function sealOp(
|
||||
return ops.join("\n");
|
||||
}
|
||||
|
||||
/** Hard-truncate to `max` chars (no marker — keeps dense table cells tight). */
|
||||
export function clipText(value: string, max: number): string {
|
||||
const t = String(value ?? "");
|
||||
return t.length > max ? t.slice(0, Math.max(1, max)) : t;
|
||||
}
|
||||
|
||||
/** Strip HTML tags → plain text, decoding the basic entities the doc builders emit. */
|
||||
export function htmlToText(html: string): string {
|
||||
return String(html ?? "")
|
||||
.replace(/<br\s*\/?>/gi, " ")
|
||||
.replace(/<[^>]+>/g, " ")
|
||||
.replace(/&/gi, "&")
|
||||
.replace(/</gi, "<")
|
||||
.replace(/>/gi, ">")
|
||||
.replace(/"/gi, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/ /gi, " ")
|
||||
.replace(/[^\x20-\x7e]/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a "summary tiles + one <table> + notice + signature lines" document (the
|
||||
* marshalling / load-list layout the train-scheduling builders emit) and draw it as a
|
||||
* styled PDF grid. Used as the Chromium-less fallback so the manifest reads as a real
|
||||
* document, not a flat text dump. Switches to landscape when the table is wide.
|
||||
*/
|
||||
export function buildTabularFallbackPdf(html: string): Buffer {
|
||||
const pick = (re: RegExp) => html.match(re)?.[1];
|
||||
const title = htmlToText(pick(/<h1[^>]*>([\s\S]*?)<\/h1>/i) ?? "Document");
|
||||
const subtitle = htmlToText(pick(/class="subtitle"[^>]*>([\s\S]*?)<\/div>/i) ?? "");
|
||||
const metaRef = htmlToText(pick(/class="meta"[\s\S]*?<strong>([\s\S]*?)<\/strong>/i) ?? "");
|
||||
const generated = htmlToText(pick(/Generated:\s*([^<]+)/i) ?? "");
|
||||
|
||||
const tiles: Array<[string, string]> = [];
|
||||
for (const m of html.matchAll(
|
||||
/class="tile"[^>]*>\s*<span>([\s\S]*?)<\/span>\s*<strong>([\s\S]*?)<\/strong>/gi,
|
||||
)) {
|
||||
tiles.push([htmlToText(m[1]), htmlToText(m[2])]);
|
||||
}
|
||||
|
||||
const thead = pick(/<thead>([\s\S]*?)<\/thead>/i) ?? "";
|
||||
const headers = [...thead.matchAll(/<th[^>]*>([\s\S]*?)<\/th>/gi)].map((m) => htmlToText(m[1]));
|
||||
const tbody = pick(/<tbody>([\s\S]*?)<\/tbody>/i) ?? "";
|
||||
const rows: string[][] = [...tbody.matchAll(/<tr[^>]*>([\s\S]*?)<\/tr>/gi)].map((tr) =>
|
||||
[...tr[1].matchAll(/<td[^>]*>([\s\S]*?)<\/td>/gi)].map((td) => htmlToText(td[1])),
|
||||
);
|
||||
const notice = htmlToText(pick(/class="notice"[^>]*>([\s\S]*?)<\/div>/i) ?? "");
|
||||
const parsedSigs = [...html.matchAll(/class="line"[^>]*>([\s\S]*?)<\/div>/gi)]
|
||||
.map((m) => htmlToText(m[1]))
|
||||
.filter(Boolean);
|
||||
const signatures = parsedSigs.length ? parsedSigs : ["Prepared / date", "Check / date", "Authorization / date"];
|
||||
|
||||
const landscape = headers.length > 7;
|
||||
const page = landscape ? PageSize.landscape : PageSize.portrait;
|
||||
const M = 32;
|
||||
const contentW = page.width - M * 2;
|
||||
const right = page.width - M;
|
||||
const ops: string[] = [];
|
||||
|
||||
// Header
|
||||
ops.push(lineOp(M, page.height - 28, right, page.height - 28, PdfColor.teal, 2.4));
|
||||
ops.push(textOp("ETHIO-DJIBOUTI RAILWAY S.C.", M, page.height - 44, 8.5, "F2", PdfColor.gray));
|
||||
ops.push(textOp(clipText(title, landscape ? 82 : 52), M, page.height - 68, 19, "F2", PdfColor.dark));
|
||||
if (subtitle) ops.push(textOp(clipText(subtitle, 96), M, page.height - 82, 9, "F1", PdfColor.gray));
|
||||
if (metaRef) {
|
||||
ops.push(textOpRight("TRAIN / SCHEDULE", right, page.height - 42, 7.5, "F2", PdfColor.gray));
|
||||
ops.push(textOpRight(clipText(metaRef, 28), right, page.height - 58, 12, "F2", PdfColor.dark));
|
||||
}
|
||||
if (generated) {
|
||||
ops.push(textOpRight(clipText(`Generated ${generated}`, 40), right, page.height - 72, 8, "F1", PdfColor.gray));
|
||||
}
|
||||
ops.push(lineOp(M, page.height - 92, right, page.height - 92, PdfColor.line, 1));
|
||||
|
||||
// Summary tiles
|
||||
let y = page.height - 100;
|
||||
if (tiles.length) {
|
||||
const cols = landscape ? 6 : 4;
|
||||
const tileW = contentW / cols;
|
||||
const tileH = 32;
|
||||
tiles.forEach(([label, value], i) => {
|
||||
const col = i % cols;
|
||||
if (col === 0 && i > 0) y -= tileH;
|
||||
const x = M + col * tileW;
|
||||
ops.push(rectOp(x, y - tileH + 4, tileW - 4, tileH - 4, PdfColor.shade, PdfColor.line, 0.5));
|
||||
ops.push(textOp(clipText(label.toUpperCase(), Math.floor((tileW - 12) / 3.6)), x + 6, y - 8, 6.5, "F1", PdfColor.gray));
|
||||
ops.push(textOp(clipText(value, Math.floor((tileW - 12) / 4.4)), x + 6, y - 20, 9, "F2", PdfColor.dark));
|
||||
});
|
||||
y -= tileH + 12;
|
||||
}
|
||||
|
||||
// Table
|
||||
if (headers.length) {
|
||||
const colW = contentW / headers.length;
|
||||
const headerH = 16;
|
||||
const rowH = 14;
|
||||
const cellChars = Math.max(4, Math.floor(colW / 3.9));
|
||||
ops.push(rectOp(M, y - headerH, contentW, headerH, PdfColor.tint, PdfColor.line, 0.6));
|
||||
headers.forEach((h, c) =>
|
||||
ops.push(textOp(clipText(h, cellChars), M + c * colW + 4, y - 11, 7, "F2", PdfColor.teal)),
|
||||
);
|
||||
y -= headerH;
|
||||
|
||||
let shown = 0;
|
||||
for (const row of rows) {
|
||||
if (y < 96) break;
|
||||
ops.push(rectOp(M, y - rowH, contentW, rowH, "1 1 1", PdfColor.line, 0.4));
|
||||
headers.forEach((_h, c) => {
|
||||
if (c > 0) ops.push(lineOp(M + c * colW, y - rowH, M + c * colW, y, PdfColor.line, 0.3));
|
||||
const cell = row[c] ?? "";
|
||||
if (cell) ops.push(textOp(clipText(cell, cellChars), M + c * colW + 4, y - 10, 6.8, "F1", PdfColor.dark));
|
||||
});
|
||||
y -= rowH;
|
||||
shown += 1;
|
||||
}
|
||||
if (shown < rows.length) {
|
||||
ops.push(textOp(`... ${rows.length - shown} more row(s) not shown`, M, y - 10, 7, "F1", PdfColor.gray));
|
||||
}
|
||||
}
|
||||
|
||||
// Notice (verification clause)
|
||||
if (notice) {
|
||||
ops.push(lineOp(M, 78, M, 54, PdfColor.teal, 2));
|
||||
wrapText(notice, landscape ? 155 : 104)
|
||||
.slice(0, 2)
|
||||
.forEach((ln, i) => ops.push(textOp(ln, M + 8, 72 - i * 11, 7.5, "F1", PdfColor.gray)));
|
||||
}
|
||||
|
||||
// Signatures
|
||||
const sigW = contentW / signatures.length;
|
||||
signatures.forEach((s, i) => {
|
||||
const x = M + i * sigW;
|
||||
ops.push(lineOp(x, 40, x + sigW - 18, 40, PdfColor.dark, 0.7));
|
||||
ops.push(textOp(clipText(s, Math.floor((sigW - 18) / 3.6)), x, 30, 7, "F1", PdfColor.gray));
|
||||
});
|
||||
|
||||
return assembleSinglePagePdf(ops, page);
|
||||
}
|
||||
|
||||
/** Greedy word-wrap to a maximum character width. */
|
||||
export function wrapText(text: string, maxChars: number): string[] {
|
||||
const out: string[] = [];
|
||||
@@ -141,13 +281,22 @@ export function wrapText(text: string, maxChars: number): string[] {
|
||||
return out.length ? out : [""];
|
||||
}
|
||||
|
||||
/** Assemble a single-page A4 PDF from content-stream ops (Helvetica fonts). */
|
||||
export function assembleSinglePagePdf(ops: string[]): Buffer {
|
||||
/** A4 page sizes in PDF points. */
|
||||
export const PageSize = {
|
||||
portrait: { width: 595, height: 842 },
|
||||
landscape: { width: 842, height: 595 },
|
||||
} as const;
|
||||
|
||||
/** Assemble a single-page PDF from content-stream ops (Helvetica fonts). Defaults to A4 portrait. */
|
||||
export function assembleSinglePagePdf(
|
||||
ops: string[],
|
||||
page: { width: number; height: number } = PageSize.portrait,
|
||||
): Buffer {
|
||||
const stream = ops.join("\n");
|
||||
const objects = [
|
||||
"<< /Type /Catalog /Pages 2 0 R >>",
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 4 0 R /F2 5 0 R >> >> /Contents 6 0 R >>",
|
||||
`<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${page.width} ${page.height}] /Resources << /Font << /F1 4 0 R /F2 5 0 R >> >> /Contents 6 0 R >>`,
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold >>",
|
||||
`<< /Length ${Buffer.byteLength(stream, "latin1")} >>\nstream\n${stream}\nendstream`,
|
||||
|
||||
@@ -1727,9 +1727,9 @@ export class TrainSchedulingService {
|
||||
performedBy: 'DOCUMENT_GENERATION',
|
||||
});
|
||||
const html = this.buildImportLoadListHtml(loadList);
|
||||
// Generic render — NOT the release-order fallback (would mislabel this as a
|
||||
// gate-clearance / release order when Chromium is unavailable).
|
||||
const buffer = await this.pdfDocuments.renderDocumentHtml(html, 'Import marshalling / load list');
|
||||
// Styled table-aware fallback (marshalling grid) when Chromium is unavailable —
|
||||
// NOT the release-order fallback (would mislabel this as a gate-clearance order).
|
||||
const buffer = await this.pdfDocuments.renderTabularDocument(html, 'Import marshalling / load list');
|
||||
const reference = loadList.trainNumber ?? loadList.trainScheduleId;
|
||||
return {
|
||||
filename: `import-marshalling-${this.safeDocumentName(reference)}.pdf`,
|
||||
@@ -1747,8 +1747,8 @@ export class TrainSchedulingService {
|
||||
}
|
||||
|
||||
const html = this.buildExportLoadListHtml(schedule);
|
||||
// Generic render — NOT the release-order fallback (see importLoadListDocument).
|
||||
const buffer = await this.pdfDocuments.renderDocumentHtml(html, 'Export marshalling / load list');
|
||||
// Styled table-aware fallback (marshalling grid) — see importLoadListDocument.
|
||||
const buffer = await this.pdfDocuments.renderTabularDocument(html, 'Export marshalling / load list');
|
||||
const reference = schedule.trainNumber ?? schedule.id;
|
||||
return {
|
||||
filename: `export-marshalling-${this.safeDocumentName(reference)}.pdf`,
|
||||
|
||||
@@ -2,7 +2,7 @@ import { ApiProperty, ApiPropertyOptional, PartialType } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsArray, IsEnum, IsInt, IsNumber, IsOptional, IsString, IsUUID, Matches, Min, ValidateNested } from 'class-validator';
|
||||
|
||||
import { FEE_RULE_TYPES, FeeRuleType } from '../entities/warehouse-fee-rule.entity';
|
||||
import { FEE_RULE_BASES, FEE_RULE_TYPES, FeeRuleBasis, FeeRuleType } from '../entities/warehouse-fee-rule.entity';
|
||||
|
||||
export class FeeRuleTierDto {
|
||||
@ApiProperty({ example: 4 })
|
||||
@@ -82,11 +82,19 @@ export class CreateFeeRuleDto {
|
||||
@Min(0)
|
||||
freeDays!: number;
|
||||
|
||||
@ApiProperty()
|
||||
@ApiProperty({ description: 'Day-based fees: rate/day. Double handling: flat rate per basis unit.' })
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
ratePerDay!: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
enum: FEE_RULE_BASES,
|
||||
description: 'Double-handling charge basis: PER_CONTAINER | PER_TON | PER_ITEM.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsEnum(FEE_RULE_BASES)
|
||||
basis?: FeeRuleBasis;
|
||||
|
||||
@ApiPropertyOptional({ type: [FeeRuleTierDto] })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
|
||||
@@ -1,9 +1,23 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index } from 'typeorm';
|
||||
|
||||
export const FEE_RULE_TYPES = ['STORAGE_FEE', 'DEMURRAGE_FEE'] as const;
|
||||
export const FEE_RULE_TYPES = [
|
||||
'STORAGE_FEE',
|
||||
'DEMURRAGE_FEE',
|
||||
'DOUBLE_HANDLING_FEE',
|
||||
'TRUCK_DETENTION_FEE',
|
||||
] as const;
|
||||
export type FeeRuleType = (typeof FEE_RULE_TYPES)[number];
|
||||
|
||||
/**
|
||||
* Charge basis for a DOUBLE_HANDLING_FEE rule (flat rate × the chosen quantity):
|
||||
* - PER_CONTAINER: booking container count
|
||||
* - PER_TON: cargo total in tonnes (bulk cargo)
|
||||
* - PER_ITEM: cargo total item count (break-bulk cargo, e.g. machinery)
|
||||
*/
|
||||
export const FEE_RULE_BASES = ['PER_CONTAINER', 'PER_TON', 'PER_ITEM'] as const;
|
||||
export type FeeRuleBasis = (typeof FEE_RULE_BASES)[number];
|
||||
|
||||
export interface WarehouseFeeTier {
|
||||
fromDay: number;
|
||||
toDay: number | null;
|
||||
@@ -60,6 +74,12 @@ export class WarehouseFeeRule extends BaseEntity {
|
||||
@Column({ name: 'rate_per_day', type: 'numeric', precision: 14, scale: 2, default: 0 })
|
||||
ratePerDay!: number;
|
||||
|
||||
// Double-handling only: PER_CONTAINER | PER_TON | PER_MACHINERY. The flat rate
|
||||
// (rate_per_day, reused as rate-per-unit) is multiplied by the basis quantity;
|
||||
// free days and tiers do not apply. Null for the day-based fee types.
|
||||
@Column({ name: 'basis', type: 'varchar', length: 20, nullable: true })
|
||||
basis?: FeeRuleBasis | null;
|
||||
|
||||
@Column({ name: 'tiers', type: 'jsonb', default: () => "'[]'" })
|
||||
tiers!: WarehouseFeeTier[];
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { ExchangeService } from '@edr/api-common';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
import { CreateFeeRuleDto, UpdateFeeRuleDto } from './dto/fee-rule.dto';
|
||||
import { 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';
|
||||
|
||||
interface ItemAttributes {
|
||||
@@ -16,6 +16,8 @@ interface ItemAttributes {
|
||||
containerTypeCode: string | null;
|
||||
inventoryQuantity: number;
|
||||
bookingContainerCount: number;
|
||||
/** Booking cargo total in the cargo's unit of measure: tonnes (PER_TON) or item count (PER_ITEM). */
|
||||
cargoQuantity: number;
|
||||
facilityId: string | null;
|
||||
warehouseId: string | null;
|
||||
yardId: string | null;
|
||||
@@ -24,6 +26,8 @@ interface ItemAttributes {
|
||||
|
||||
export interface FeePreview {
|
||||
ruleType: FeeRuleType;
|
||||
/** Double-handling charge basis (PER_CONTAINER | PER_TON | PER_MACHINERY); null otherwise. */
|
||||
basis: FeeRuleBasis | null;
|
||||
ruleId: string | null;
|
||||
ruleName: string | null;
|
||||
freeDays: number;
|
||||
@@ -132,7 +136,8 @@ export class WarehouseFeeService {
|
||||
b.trade_direction AS "tradeDirection",
|
||||
COALESCE(cgt.code, booking_cgt.code) AS "cargoTypeCode",
|
||||
COALESCE(ctt.code, booking_ctt.code) AS "containerTypeCode",
|
||||
COALESCE(container_lines.container_count, 0) AS "bookingContainerCount"
|
||||
COALESCE(container_lines.container_count, 0) AS "bookingContainerCount",
|
||||
COALESCE(b.cargo_total_weight_vgm, 0) AS "cargoQuantity"
|
||||
FROM freight.warehouse_inventory inv
|
||||
LEFT JOIN freight.warehouses w ON w.id = inv.warehouse_id
|
||||
LEFT JOIN freight.bookings b ON b.id = inv.booking_id
|
||||
@@ -283,6 +288,10 @@ export class WarehouseFeeService {
|
||||
now: Date,
|
||||
billingCurrency: string,
|
||||
): Promise<FeePreview> {
|
||||
// Double handling is a flat charge (rate × basis quantity), not day-based.
|
||||
if (ruleType === 'DOUBLE_HANDLING_FEE') {
|
||||
return this.computeDoubleHandling(rule, item, now, billingCurrency);
|
||||
}
|
||||
const start = item.arrivedAt ? new Date(item.arrivedAt) : null;
|
||||
const endDate = item.gateClearedAt ?? item.releaseDate ?? now;
|
||||
const endIsOpen = !item.gateClearedAt && !item.releaseDate;
|
||||
@@ -321,6 +330,7 @@ export class WarehouseFeeService {
|
||||
|
||||
return {
|
||||
ruleType,
|
||||
basis: null,
|
||||
ruleId: rule?.id ?? null,
|
||||
ruleName: rule?.name ?? null,
|
||||
freeDays,
|
||||
@@ -340,13 +350,69 @@ export class WarehouseFeeService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Double handling — a flat one-time charge, not time-based. Amount = rate ×
|
||||
* the basis quantity: PER_CONTAINER (booking container count), or PER_TON /
|
||||
* PER_ITEM (the booking cargo total in the cargo's unit of measure — tonnes
|
||||
* for bulk, item count for break-bulk). No free days, no elapsed days, no tiers.
|
||||
*/
|
||||
private async computeDoubleHandling(
|
||||
rule: WarehouseFeeRule | null,
|
||||
item: ItemAttributes,
|
||||
now: Date,
|
||||
billingCurrency: string,
|
||||
): Promise<FeePreview> {
|
||||
const basis: FeeRuleBasis = rule?.basis ?? 'PER_CONTAINER';
|
||||
const rate = Number(rule?.ratePerDay ?? 0);
|
||||
const ruleCurrency = rule ? this.normalizeCurrency(rule.currency) : null;
|
||||
const targetCurrency = this.normalizeCurrency(billingCurrency);
|
||||
const isContainer = (item.freightType ?? '').toUpperCase() === 'CONTAINER';
|
||||
const inventoryQuantity = Math.max(1, Math.round(Number(item.inventoryQuantity) || 1));
|
||||
const containerCount = isContainer
|
||||
? Math.max(1, Math.round(Number(item.bookingContainerCount) || inventoryQuantity))
|
||||
: 1;
|
||||
// PER_TON (tonnes) and PER_ITEM (piece count) both read the cargo total,
|
||||
// which is stored in the cargo's own unit of measure.
|
||||
const cargoQuantity = Math.max(0, Number(item.cargoQuantity) || 0);
|
||||
const quantity = basis === 'PER_CONTAINER' ? containerCount : cargoQuantity;
|
||||
const sourceAmount = Math.round(rate * quantity * 100) / 100;
|
||||
const amount = ruleCurrency ? await this.convertAmount(sourceAmount, ruleCurrency, targetCurrency) : 0;
|
||||
const convertedRate = ruleCurrency ? await this.convertAmount(rate, ruleCurrency, targetCurrency) : 0;
|
||||
|
||||
return {
|
||||
ruleType: 'DOUBLE_HANDLING_FEE',
|
||||
basis,
|
||||
ruleId: rule?.id ?? null,
|
||||
ruleName: rule?.name ?? null,
|
||||
freeDays: 0,
|
||||
ratePerDay: convertedRate,
|
||||
currency: targetCurrency,
|
||||
ruleCurrency,
|
||||
billingCurrency: targetCurrency,
|
||||
startDate: null,
|
||||
endDate: now.toISOString(),
|
||||
endIsOpen: false,
|
||||
elapsedDays: 0,
|
||||
chargeableDays: 0,
|
||||
containerCount,
|
||||
billableUnits: quantity,
|
||||
amount,
|
||||
tiers: [],
|
||||
};
|
||||
}
|
||||
|
||||
/** 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);
|
||||
const rules = await this.feeRuleRepository.findAll({ where: { isActive: true } });
|
||||
const now = new Date();
|
||||
|
||||
const byType: FeeRuleType[] = ['DEMURRAGE_FEE', 'STORAGE_FEE'];
|
||||
const byType: FeeRuleType[] = [
|
||||
'DEMURRAGE_FEE',
|
||||
'STORAGE_FEE',
|
||||
'DOUBLE_HANDLING_FEE',
|
||||
'TRUCK_DETENTION_FEE',
|
||||
];
|
||||
return Promise.all(
|
||||
byType.map((type) =>
|
||||
this.compute(
|
||||
|
||||
@@ -182,25 +182,38 @@ export class WarehouseInvoiceService {
|
||||
const items = previews
|
||||
.filter((p) => p.amount > 0)
|
||||
.map((p) => {
|
||||
const feeType: WarehouseFeeType =
|
||||
p.ruleType === "STORAGE_FEE"
|
||||
? "STORAGE_FEE"
|
||||
: isContainer
|
||||
? "CONTAINER_DEMURRAGE"
|
||||
: "BULK_DEMURRAGE";
|
||||
const days = `${p.chargeableDays} chargeable day(s) x ${p.containerCount} container(s)`;
|
||||
const tierSuffix = p.tiers.length ? " using tiered tariff" : ` after ${p.freeDays} free`;
|
||||
let feeType: WarehouseFeeType;
|
||||
let description: string;
|
||||
switch (p.ruleType) {
|
||||
case "STORAGE_FEE":
|
||||
feeType = "STORAGE_FEE";
|
||||
description = `Storage fee - ${days}${tierSuffix}`;
|
||||
break;
|
||||
case "DOUBLE_HANDLING_FEE": {
|
||||
feeType = "DOUBLE_HANDLING";
|
||||
const unit =
|
||||
p.basis === "PER_TON"
|
||||
? "ton(s)"
|
||||
: p.basis === "PER_ITEM"
|
||||
? "item(s)"
|
||||
: "container(s)";
|
||||
description = `Double handling - ${p.billableUnits} ${unit}`;
|
||||
break;
|
||||
}
|
||||
case "TRUCK_DETENTION_FEE":
|
||||
feeType = "TRUCK_DETENTION";
|
||||
description = `Truck detention - ${days}${tierSuffix}`;
|
||||
break;
|
||||
default:
|
||||
feeType = isContainer ? "CONTAINER_DEMURRAGE" : "BULK_DEMURRAGE";
|
||||
description = `${isContainer ? "Container" : "Bulk"} demurrage - ${days}${tierSuffix}`;
|
||||
}
|
||||
return {
|
||||
feeRuleId: p.ruleId,
|
||||
feeType,
|
||||
description:
|
||||
p.ruleType === "STORAGE_FEE"
|
||||
? `Storage fee - ${p.chargeableDays} chargeable day(s) x ${p.containerCount} container(s)${p.tiers.length
|
||||
? " using tiered tariff"
|
||||
: ` after ${p.freeDays} free`
|
||||
}`
|
||||
: `${isContainer ? "Container" : "Bulk"} demurrage - ${p.chargeableDays} chargeable day(s) x ${p.containerCount} container(s)${p.tiers.length
|
||||
? " using tiered tariff"
|
||||
: ` after ${p.freeDays} free`
|
||||
}`,
|
||||
description,
|
||||
quantity: p.billableUnits,
|
||||
unitRate: p.ratePerDay,
|
||||
amount: p.amount,
|
||||
|
||||
@@ -26,6 +26,8 @@ export const WAREHOUSE_FEE_TYPES = [
|
||||
'BULK_DEMURRAGE',
|
||||
'STORAGE_FEE',
|
||||
'HANDLING_FEE',
|
||||
'DOUBLE_HANDLING',
|
||||
'TRUCK_DETENTION',
|
||||
] as const;
|
||||
export type WarehouseFeeType = (typeof WAREHOUSE_FEE_TYPES)[number];
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { PdfRenderService } from '../billing/documents/pdf-render.service';
|
||||
import { buildTabularFallbackPdf } from '../billing/documents/styled-pdf.util';
|
||||
|
||||
const MIN_VALID_PDF_BYTES = 2_000;
|
||||
|
||||
@@ -32,6 +33,19 @@ export class WarehouseReleaseDocumentService {
|
||||
return this.pdf.htmlToPdfBuffer(html, { label });
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a "summary tiles + one table + notice + signatures" document (the
|
||||
* marshalling / load-list layout) with a STYLED table-aware fallback for when
|
||||
* Chromium is unavailable — so the manifest draws as a real gridded document
|
||||
* instead of a flat plain-text dump.
|
||||
*/
|
||||
renderTabularDocument(html: string, label = 'Document'): Promise<Buffer> {
|
||||
return this.pdf.htmlToPdfBuffer(html, {
|
||||
label,
|
||||
fallback: (preparedHtml) => buildTabularFallbackPdf(preparedHtml),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Render document HTML with a STYLED hand-built fallback (the release layout,
|
||||
* but with a custom title + section heading) for when Chromium is unavailable.
|
||||
|
||||
@@ -28,6 +28,8 @@ interface FeePreviewModalProps {
|
||||
const LABELS: Record<string, { label: string; color: string }> = {
|
||||
DEMURRAGE_FEE: { label: 'Demurrage', color: 'orange' },
|
||||
STORAGE_FEE: { label: 'Storage', color: 'teal' },
|
||||
DOUBLE_HANDLING_FEE: { label: 'Double Handling', color: 'grape' },
|
||||
TRUCK_DETENTION_FEE: { label: 'Truck Detention Cost', color: 'blue' },
|
||||
};
|
||||
|
||||
function fmtDate(iso: string | null) {
|
||||
|
||||
@@ -646,9 +646,15 @@ function TruckEntranceFields({
|
||||
function LocationSelects({
|
||||
value,
|
||||
onChange,
|
||||
allowedYardTypes,
|
||||
allowedZoneTypes,
|
||||
}: {
|
||||
value: Location;
|
||||
onChange: (next: Location) => void;
|
||||
/** When non-empty, only yards of these types are offered (matched to freight). */
|
||||
allowedYardTypes?: string[];
|
||||
/** When non-empty, only zones of these types are offered. */
|
||||
allowedZoneTypes?: string[];
|
||||
}) {
|
||||
const warehousesQuery = useQuery(
|
||||
api.warehouses.list.queryOptions({ input: { filter: { status: 'ACTIVE' } } }),
|
||||
@@ -674,15 +680,17 @@ function LocationSelects({
|
||||
() =>
|
||||
(yardsQuery.data ?? [])
|
||||
.filter((y) => y.status === 'ACTIVE')
|
||||
.filter((y) => !allowedYardTypes?.length || allowedYardTypes.includes((y as { type?: string }).type ?? ''))
|
||||
.map((y) => ({ value: y.id, label: `${y.name} (${y.code})` })),
|
||||
[yardsQuery.data],
|
||||
[yardsQuery.data, allowedYardTypes],
|
||||
);
|
||||
const zoneOptions = useMemo(
|
||||
() =>
|
||||
(zonesQuery.data ?? [])
|
||||
.filter((z) => z.status === 'ACTIVE')
|
||||
.filter((z) => !allowedZoneTypes?.length || allowedZoneTypes.includes((z as { type?: string }).type ?? ''))
|
||||
.map((z) => ({ value: z.id, label: `${z.name} (${z.code})` })),
|
||||
[zonesQuery.data],
|
||||
[zonesQuery.data, allowedZoneTypes],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -1760,6 +1768,29 @@ const importLocationTypesForFreight = (freightType: string | null | undefined) =
|
||||
const isImportContainerFreight = (freightType: string | null | undefined) =>
|
||||
(freightType ?? '').toUpperCase() === 'CONTAINER';
|
||||
|
||||
/**
|
||||
* Yard/zone types valid for the freight being received — used to filter the receive
|
||||
* location pickers so the yard list matches the cargo. Container freight → container
|
||||
* yards only; bulk / break-bulk → bulk, general-cargo, hazardous, or cold-storage.
|
||||
* Union across the given freight types; empty input → no restriction (show all).
|
||||
*/
|
||||
const yardZoneTypesForFreights = (freightTypes: Array<string | null | undefined>) => {
|
||||
const yardTypes = new Set<string>();
|
||||
const zoneTypes = new Set<string>();
|
||||
for (const freightType of freightTypes) {
|
||||
const normalized = (freightType ?? '').toUpperCase();
|
||||
if (!normalized) continue;
|
||||
if (normalized === 'CONTAINER') {
|
||||
yardTypes.add('CONTAINER_YARD');
|
||||
zoneTypes.add('CONTAINER_ZONE');
|
||||
} else {
|
||||
['BULK_YARD', 'GENERAL_CARGO_YARD', 'HAZARDOUS_YARD', 'COLD_STORAGE_YARD'].forEach((t) => yardTypes.add(t));
|
||||
['BULK_ZONE', 'GENERAL_CARGO_ZONE', 'HAZARDOUS_ZONE', 'COLD_STORAGE_ZONE'].forEach((t) => zoneTypes.add(t));
|
||||
}
|
||||
}
|
||||
return { yardTypes: [...yardTypes], zoneTypes: [...zoneTypes] };
|
||||
};
|
||||
|
||||
const isImportUnloadPending = (item: ImportTrainItem) =>
|
||||
!item.currentStatus || item.currentStatus === 'RECEIVED';
|
||||
|
||||
@@ -2888,6 +2919,24 @@ export function WarehouseFlowWorkbench({
|
||||
);
|
||||
const activeDirection = direction === 'BOTH' ? tab : direction;
|
||||
|
||||
// Match the yard/zone list to the freight being received (container → container
|
||||
// yards, etc). Same query key as the export tab, so React Query dedupes it.
|
||||
const { data: eligibleForLocation = [] } = useQuery(
|
||||
api.warehouses.eligibleBookings.queryOptions({
|
||||
input: { direction: activeDirection },
|
||||
enabled: enabled && activeDirection === 'EXPORT',
|
||||
}),
|
||||
);
|
||||
const { yardTypes: allowedYardTypes, zoneTypes: allowedZoneTypes } = useMemo(
|
||||
() =>
|
||||
yardZoneTypesForFreights(
|
||||
eligibleForLocation
|
||||
.filter((r) => r.direction === activeDirection)
|
||||
.map((r) => r.freightType),
|
||||
),
|
||||
[eligibleForLocation, activeDirection],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (enabled) setLocation({ warehouseId: '', yardId: '', zoneId: '' });
|
||||
}, [enabled, direction]);
|
||||
@@ -2895,7 +2944,12 @@ export function WarehouseFlowWorkbench({
|
||||
return (
|
||||
<Stack gap="md">
|
||||
{activeDirection === 'EXPORT' && (
|
||||
<LocationSelects value={location} onChange={setLocation} />
|
||||
<LocationSelects
|
||||
value={location}
|
||||
onChange={setLocation}
|
||||
allowedYardTypes={allowedYardTypes}
|
||||
allowedZoneTypes={allowedZoneTypes}
|
||||
/>
|
||||
)}
|
||||
|
||||
{direction === 'BOTH' ? (
|
||||
|
||||
@@ -32,7 +32,21 @@ import {
|
||||
useFeeRules,
|
||||
} from '@/hooks/useWarehouses';
|
||||
import { api } from '@/services/api';
|
||||
import { FEE_RULE_TYPES, type FeeRuleType } from '@/types/warehouse';
|
||||
import {
|
||||
FEE_RULE_BASES,
|
||||
FEE_RULE_BASIS_LABELS,
|
||||
FEE_RULE_TYPES,
|
||||
FEE_RULE_TYPE_LABELS,
|
||||
type FeeRuleBasis,
|
||||
type FeeRuleType,
|
||||
} from '@/types/warehouse';
|
||||
|
||||
const RULE_TYPE_COLOR: Record<FeeRuleType, string> = {
|
||||
STORAGE_FEE: 'teal',
|
||||
DEMURRAGE_FEE: 'orange',
|
||||
DOUBLE_HANDLING_FEE: 'grape',
|
||||
TRUCK_DETENTION_FEE: 'blue',
|
||||
};
|
||||
import { extractErrorMessage, lettersOnly } from '@/components/warehouses/options';
|
||||
|
||||
const FREIGHT = [
|
||||
@@ -353,6 +367,7 @@ function FeeRules() {
|
||||
const [form, setForm] = useState({
|
||||
name: '',
|
||||
ruleType: 'DEMURRAGE_FEE' as FeeRuleType,
|
||||
basis: 'PER_CONTAINER' as FeeRuleBasis,
|
||||
freightType: '',
|
||||
tradeDirection: '',
|
||||
cargoTypeCode: '',
|
||||
@@ -367,11 +382,15 @@ function FeeRules() {
|
||||
const containerTypeOptions = codeOptions(containerTypes);
|
||||
const isBulkRule = form.freightType === 'BULK';
|
||||
const isContainerRule = form.freightType === 'CONTAINER';
|
||||
// Double handling is a flat per-unit charge (basis × rate), not day-based:
|
||||
// no free days, no progressive tiers.
|
||||
const isDoubleHandling = form.ruleType === 'DOUBLE_HANDLING_FEE';
|
||||
|
||||
const resetForm = () =>
|
||||
setForm({
|
||||
name: '',
|
||||
ruleType: 'DEMURRAGE_FEE',
|
||||
basis: 'PER_CONTAINER',
|
||||
freightType: '',
|
||||
tradeDirection: '',
|
||||
cargoTypeCode: '',
|
||||
@@ -440,10 +459,12 @@ function FeeRules() {
|
||||
tradeDirection: clean(form.tradeDirection) ?? null,
|
||||
cargoTypeCode: isBulkRule ? (clean(form.cargoTypeCode) ?? null) : null,
|
||||
containerType: isContainerRule ? (clean(form.containerType) ?? null) : null,
|
||||
freeDays: form.freeDays,
|
||||
// Double handling: flat basis × rate — no free days, no tiers.
|
||||
freeDays: isDoubleHandling ? 0 : form.freeDays,
|
||||
ratePerDay: form.ratePerDay,
|
||||
currency: form.currency || 'USD',
|
||||
...(tiers.length ? { tiers } : {}),
|
||||
...(isDoubleHandling ? { basis: form.basis } : {}),
|
||||
...(!isDoubleHandling && tiers.length ? { tiers } : {}),
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -514,8 +535,8 @@ function FeeRules() {
|
||||
{rules.map((rule) => (
|
||||
<Table.Tr key={rule.id}>
|
||||
<Table.Td>
|
||||
<Badge color={rule.ruleType === 'DEMURRAGE_FEE' ? 'orange' : 'teal'} variant="light">
|
||||
{rule.ruleType === 'DEMURRAGE_FEE' ? 'Demurrage' : 'Storage'}
|
||||
<Badge color={RULE_TYPE_COLOR[rule.ruleType] ?? 'gray'} variant="light">
|
||||
{FEE_RULE_TYPE_LABELS[rule.ruleType] ?? rule.ruleType}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>{rule.name}</Table.Td>
|
||||
@@ -577,7 +598,7 @@ function FeeRules() {
|
||||
label="Rule type"
|
||||
data={FEE_RULE_TYPES.map((type) => ({
|
||||
value: type,
|
||||
label: type === 'DEMURRAGE_FEE' ? 'Demurrage' : 'Storage',
|
||||
label: FEE_RULE_TYPE_LABELS[type],
|
||||
}))}
|
||||
value={form.ruleType}
|
||||
onChange={(value) =>
|
||||
@@ -637,14 +658,26 @@ function FeeRules() {
|
||||
/>
|
||||
)}
|
||||
<Group grow>
|
||||
{isDoubleHandling ? (
|
||||
<Select
|
||||
label="Basis"
|
||||
data={FEE_RULE_BASES.map((b) => ({ value: b, label: FEE_RULE_BASIS_LABELS[b] }))}
|
||||
value={form.basis}
|
||||
onChange={(value) =>
|
||||
setForm((f) => ({ ...f, basis: selectValue(value, 'PER_CONTAINER') as FeeRuleBasis }))
|
||||
}
|
||||
allowDeselect={false}
|
||||
/>
|
||||
) : (
|
||||
<NumberInput
|
||||
label="Free days"
|
||||
min={0}
|
||||
value={form.freeDays}
|
||||
onChange={(value) => setForm((f) => ({ ...f, freeDays: numberValue(value) }))}
|
||||
/>
|
||||
)}
|
||||
<NumberInput
|
||||
label="Free days"
|
||||
min={0}
|
||||
value={form.freeDays}
|
||||
onChange={(value) => setForm((f) => ({ ...f, freeDays: numberValue(value) }))}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Rate / day"
|
||||
label={isDoubleHandling ? 'Rate / unit' : 'Rate / day'}
|
||||
min={0}
|
||||
value={form.ratePerDay}
|
||||
onChange={(value) => setForm((f) => ({ ...f, ratePerDay: numberValue(value) }))}
|
||||
@@ -657,6 +690,13 @@ function FeeRules() {
|
||||
allowDeselect={false}
|
||||
/>
|
||||
</Group>
|
||||
{isDoubleHandling && (
|
||||
<Text size="xs" c="dimmed">
|
||||
Flat charge — the rate is multiplied by the selected basis (
|
||||
{FEE_RULE_BASIS_LABELS[form.basis]}). No free days or progressive tiers.
|
||||
</Text>
|
||||
)}
|
||||
{!isDoubleHandling && (
|
||||
<Stack gap="xs">
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" fw={600}>
|
||||
@@ -702,6 +742,7 @@ function FeeRules() {
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
<Group justify="flex-end" mt="sm">
|
||||
<Button variant="default" onClick={() => setOpen(false)}>
|
||||
Cancel
|
||||
|
||||
@@ -737,13 +737,38 @@ export interface AllocationRule {
|
||||
}
|
||||
export type SaveAllocationRulePayload = Omit<AllocationRule, 'id'>;
|
||||
|
||||
export const FEE_RULE_TYPES = ['STORAGE_FEE', 'DEMURRAGE_FEE'] as const;
|
||||
export const FEE_RULE_TYPES = [
|
||||
'STORAGE_FEE',
|
||||
'DEMURRAGE_FEE',
|
||||
'DOUBLE_HANDLING_FEE',
|
||||
'TRUCK_DETENTION_FEE',
|
||||
] as const;
|
||||
export type FeeRuleType = (typeof FEE_RULE_TYPES)[number];
|
||||
|
||||
/** Human labels for each fee rule type (dropdowns, badges). */
|
||||
export const FEE_RULE_TYPE_LABELS: Record<FeeRuleType, string> = {
|
||||
STORAGE_FEE: 'Storage',
|
||||
DEMURRAGE_FEE: 'Demurrage',
|
||||
DOUBLE_HANDLING_FEE: 'Double Handling',
|
||||
TRUCK_DETENTION_FEE: 'Truck Detention Cost',
|
||||
};
|
||||
|
||||
/** Charge basis for a Double Handling rule (flat rate × the chosen quantity). */
|
||||
export const FEE_RULE_BASES = ['PER_CONTAINER', 'PER_TON', 'PER_ITEM'] as const;
|
||||
export type FeeRuleBasis = (typeof FEE_RULE_BASES)[number];
|
||||
|
||||
export const FEE_RULE_BASIS_LABELS: Record<FeeRuleBasis, string> = {
|
||||
PER_CONTAINER: 'Per Container',
|
||||
PER_TON: 'Per Ton',
|
||||
PER_ITEM: 'Per Item',
|
||||
};
|
||||
|
||||
export interface FeeRule {
|
||||
id: string;
|
||||
name: string;
|
||||
ruleType: FeeRuleType;
|
||||
/** Double-handling charge basis; null for day-based fee types. */
|
||||
basis?: FeeRuleBasis | null;
|
||||
priority: number;
|
||||
freightType?: string | null;
|
||||
tradeDirection?: string | null;
|
||||
@@ -776,6 +801,7 @@ export interface FeePreviewTier extends FeeRuleTier {
|
||||
|
||||
export interface FeePreview {
|
||||
ruleType: FeeRuleType;
|
||||
basis?: FeeRuleBasis | null;
|
||||
ruleId: string | null;
|
||||
ruleName: string | null;
|
||||
freeDays: number;
|
||||
|
||||
Reference in New Issue
Block a user