diff --git a/apps/edr-freight-api/src/migrations/1990000000000-AddDoubleHandlingBasisAndMachinery.ts b/apps/edr-freight-api/src/migrations/1990000000000-AddDoubleHandlingBasisAndMachinery.ts new file mode 100644 index 000000000..be4ed060a --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1990000000000-AddDoubleHandlingBasisAndMachinery.ts @@ -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 { + 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 { + await queryRunner.query(`ALTER TABLE freight.warehouse_fee_rules DROP COLUMN IF EXISTS basis`); + } +} diff --git a/apps/edr-freight-api/src/modules/billing/documents/styled-pdf.util.ts b/apps/edr-freight-api/src/modules/billing/documents/styled-pdf.util.ts index 8eb90172e..b4f168e4e 100644 --- a/apps/edr-freight-api/src/modules/billing/documents/styled-pdf.util.ts +++ b/apps/edr-freight-api/src/modules/billing/documents/styled-pdf.util.ts @@ -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(//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 + 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(/]*>([\s\S]*?)<\/h1>/i) ?? "Document"); + const subtitle = htmlToText(pick(/class="subtitle"[^>]*>([\s\S]*?)<\/div>/i) ?? ""); + const metaRef = htmlToText(pick(/class="meta"[\s\S]*?([\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*([\s\S]*?)<\/span>\s*([\s\S]*?)<\/strong>/gi, + )) { + tiles.push([htmlToText(m[1]), htmlToText(m[2])]); + } + + const thead = pick(/([\s\S]*?)<\/thead>/i) ?? ""; + const headers = [...thead.matchAll(/]*>([\s\S]*?)<\/th>/gi)].map((m) => htmlToText(m[1])); + const tbody = pick(/([\s\S]*?)<\/tbody>/i) ?? ""; + const rows: string[][] = [...tbody.matchAll(/]*>([\s\S]*?)<\/tr>/gi)].map((tr) => + [...tr[1].matchAll(/]*>([\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`, diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index 7211d63fd..67eac7bd1 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -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`, diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/fee-rule.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/fee-rule.dto.ts index 22e9f9491..1213b1177 100644 --- a/apps/edr-freight-api/src/modules/warehouses/dto/fee-rule.dto.ts +++ b/apps/edr-freight-api/src/modules/warehouses/dto/fee-rule.dto.ts @@ -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() diff --git a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-fee-rule.entity.ts b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-fee-rule.entity.ts index 38dffd235..a8bc7bf23 100644 --- a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-fee-rule.entity.ts +++ b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-fee-rule.entity.ts @@ -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[]; diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts index cfc7fbf6c..16eca7849 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts @@ -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 { + // 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 { + 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 { 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( diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts index 45591638a..b7cd3628e 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts @@ -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, diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.types.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.types.ts index e201241ba..418816beb 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.types.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.types.ts @@ -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]; diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-release-document.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-release-document.service.ts index 1d8e3d9ea..1d02633d5 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-release-document.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-release-document.service.ts @@ -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 { + 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. diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/FeePreviewModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/FeePreviewModal.tsx index 1d192e2b2..78e09a7e0 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/FeePreviewModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/FeePreviewModal.tsx @@ -28,6 +28,8 @@ interface FeePreviewModalProps { const LABELS: Record = { 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) { diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx index 83c7fcbcd..3e3877236 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx @@ -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) => { + const yardTypes = new Set(); + const zoneTypes = new Set(); + 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 ( {activeDirection === 'EXPORT' && ( - + )} {direction === 'BOTH' ? ( diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseRulesPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseRulesPage.tsx index b5c44e4d4..458e71de6 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseRulesPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseRulesPage.tsx @@ -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 = { + 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) => ( - - {rule.ruleType === 'DEMURRAGE_FEE' ? 'Demurrage' : 'Storage'} + + {FEE_RULE_TYPE_LABELS[rule.ruleType] ?? rule.ruleType} {rule.name} @@ -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() { /> )} + {isDoubleHandling ? ( +