This commit is contained in:
Marshal
2026-07-14 13:11:38 +00:00
1915 changed files with 241099 additions and 165123 deletions

View File

@@ -0,0 +1,31 @@
import { Body, Controller, HttpCode, HttpStatus, Post } from '@nestjs/common';
import { ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
import { Public } from '@edr/api-common';
import { AiBookingRequestDto } from './dto/ai-booking-request.dto';
import { AiBookingResult } from './types/ai-booking-result.type';
import { MockAiService } from './mock-ai.service';
// @Public() — TODO: swap for real guard when this leaves dev/testing.
// Safe while public: extracts + validates text only, never creates or
// dispatches anything.
@Public()
@ApiTags('AI Assistant (mock)')
@Controller('ai')
export class AiController {
constructor(private readonly mockAiService: MockAiService) {}
@Post('booking/extract')
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary:
'Mock AI: extract structured booking fields from free-text request',
})
@ApiOkResponse({
description:
'Extracted fields, validation result, and next-step recommendation',
})
extractBooking(@Body() dto: AiBookingRequestDto): AiBookingResult {
return this.mockAiService.extractBooking(dto.text);
}
}

View File

@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { AiController } from './ai.controller';
import { MockAiService } from './mock-ai.service';
@Module({
controllers: [AiController],
providers: [MockAiService],
exports: [MockAiService],
})
export class AiModule {}

View File

@@ -0,0 +1,15 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString, MinLength } from 'class-validator';
export class AiBookingRequestDto {
@ApiProperty({
description: 'Free-text customer booking request to extract fields from',
example:
'Book 2x40ft containers from Djibouti to Indode. Cargo electronics. Customer ABC Logistics.',
minLength: 5,
})
@IsString()
@IsNotEmpty({ message: 'text must not be empty' })
@MinLength(5, { message: 'text must be at least 5 characters' })
text!: string;
}

View File

@@ -0,0 +1,277 @@
import { Injectable } from '@nestjs/common';
import {
AiBookingResult,
AiContainerType,
AiDirection,
AiExtractedBooking,
AiRecommendation,
AiValidationResult,
} from './types/ai-booking-result.type';
/**
* Deterministic keyword/regex "AI" for the booking assistant workflow.
* No external AI calls — this class is the single seam to swap for a real
* provider later (OllamaAiService / ClaudeAiService / OpenAiService): keep
* the `extractBooking(text): AiBookingResult` contract and replace the body.
*/
const KNOWN_LOCATIONS = [
'Djibouti',
'Indode',
'Modjo',
'Adama',
'Dire Dawa',
'Addis Ababa',
] as const;
const INLAND_LOCATIONS = new Set<string>([
'Indode',
'Modjo',
'Adama',
'Dire Dawa',
'Addis Ababa',
]);
// Longest names first so "Dire Dawa" wins before a shorter partial could.
const LOCATION_ALTERNATION = [...KNOWN_LOCATIONS]
.sort((a, b) => b.length - a.length)
.map((name) => name.replace(/\s+/g, '\\s+'))
.join('|');
// Checked in order; first hit wins, so specific cargo words beat the
// generic "refrigerated" fallback.
const CARGO_KEYWORDS: ReadonlyArray<readonly [RegExp, string]> = [
[/\belectronics\b/i, 'electronics'],
[/\bcoffee\b/i, 'coffee'],
[/\bwheat\b/i, 'wheat'],
[/\bfertilizers?\b/i, 'fertilizer'],
[/\bchemicals?\b/i, 'chemical'],
[/\bmachinery\b/i, 'machinery'],
[/\bmedicines?\b/i, 'medicine'],
[/\bsesame\b/i, 'sesame'],
[/\b(?:vehicles?|cars?)\b/i, 'vehicles'],
[/\brefrigerated\b/i, 'refrigerated cargo'],
];
const WORD_NUMBERS: Record<string, number> = {
one: 1,
two: 2,
three: 3,
four: 4,
five: 5,
six: 6,
seven: 7,
eight: 8,
nine: 9,
ten: 10,
};
// A capitalized-word run: "ABC Logistics", "Auto Import PLC", "Ethio Coffee
// Export". Stops at the first lowercase word ("wants", "needs", …).
const NAME_CAPTURE = String.raw`([A-Z][A-Za-z0-9&.'-]*(?:\s+[A-Z][A-Za-z0-9&.'-]*)*)`;
// No `i` flag: the capture relies on case ([A-Z] word starts) to know where
// the company name ends ("Customer ABC Logistics wants…" → "ABC Logistics").
const CUSTOMER_PATTERNS: ReadonlyArray<RegExp> = [
new RegExp(String.raw`\b[Cc]ustomer(?:\s+is)?\s*:?\s+${NAME_CAPTURE}`),
new RegExp(String.raw`\b[Ff]or\s+${NAME_CAPTURE}`),
];
const RECOMMEND_CREATE: AiRecommendation = {
action: 'CREATE_DRAFT_BOOKING',
message:
'Booking data looks complete. User can review and create a draft booking.',
confidence: 0.85,
};
const RECOMMEND_MISSING: AiRecommendation = {
action: 'REQUEST_MISSING_INFORMATION',
message:
'Some required booking information is missing. Ask the customer for the missing fields before creating a draft booking.',
confidence: 0.45,
};
@Injectable()
export class MockAiService {
extractBooking(text: string): AiBookingResult {
const input = text.trim();
const { origin, destination } = this.extractRoute(input);
const extracted: AiExtractedBooking = {
customerName: this.extractCustomerName(input),
origin,
destination,
cargoType: this.extractCargoType(input),
containerType: this.extractContainerType(input),
quantity: this.extractQuantity(input),
direction: this.resolveDirection(origin, destination),
weightKg: this.extractWeightKg(input),
pickupRequired: this.extractFlag(input, 'pickup'),
deliveryRequired: this.extractFlag(input, 'delivery'),
};
const validation = this.validate(extracted);
return {
provider: 'mock',
extracted,
validation,
recommendation: validation.valid ? RECOMMEND_CREATE : RECOMMEND_MISSING,
};
}
private extractCustomerName(text: string): string | null {
for (const pattern of CUSTOMER_PATTERNS) {
const match = text.match(pattern);
if (match?.[1]) {
const name = match[1].replace(/[.,;:!?]+$/, '').trim();
if (name) return name;
}
}
return null;
}
private extractRoute(text: string): {
origin: string | null;
destination: string | null;
} {
const fromMatch = text.match(
new RegExp(String.raw`\bfrom\s+(${LOCATION_ALTERNATION})\b`, 'i'),
);
const toMatch = text.match(
new RegExp(String.raw`\bto\s+(${LOCATION_ALTERNATION})\b`, 'i'),
);
let origin = fromMatch ? this.canonicalLocation(fromMatch[1]) : null;
let destination = toMatch ? this.canonicalLocation(toMatch[1]) : null;
if (!origin || !destination) {
// Fall back to order of appearance ("Djibouti to Indode" without
// "from", or a bare location mention).
const mentions: string[] = [];
const all = text.matchAll(
new RegExp(String.raw`\b(${LOCATION_ALTERNATION})\b`, 'gi'),
);
for (const m of all) {
const canonical = this.canonicalLocation(m[1]);
if (canonical && !mentions.includes(canonical)) mentions.push(canonical);
}
if (!origin && !destination) {
origin = mentions[0] ?? null;
destination = mentions[1] ?? null;
} else if (!origin) {
origin = mentions.find((loc) => loc !== destination) ?? null;
} else {
destination = mentions.find((loc) => loc !== origin) ?? null;
}
}
return { origin, destination };
}
private canonicalLocation(raw: string): string | null {
const normalized = raw.replace(/\s+/g, ' ').toLowerCase();
return (
KNOWN_LOCATIONS.find((loc) => loc.toLowerCase() === normalized) ?? null
);
}
private resolveDirection(
origin: string | null,
destination: string | null,
): AiDirection | null {
if (!origin || !destination) return null;
if (origin === 'Djibouti' && INLAND_LOCATIONS.has(destination)) {
return 'IMPORT';
}
if (INLAND_LOCATIONS.has(origin) && destination === 'Djibouti') {
return 'EXPORT';
}
return null;
}
private extractCargoType(text: string): string | null {
for (const [pattern, cargo] of CARGO_KEYWORDS) {
if (pattern.test(text)) return cargo;
}
return null;
}
private extractContainerType(text: string): AiContainerType | null {
// Lookbehind instead of \b: "2x40ft" has no word boundary before "40",
// but "140ft" must not read as a 40ft container.
if (/(?<!\d)40[\s-]?(?:ft|foot)\b/i.test(text)) return '40FT';
if (/(?<!\d)20[\s-]?(?:ft|foot)\b/i.test(text)) return '20FT';
if (/\bbulk\b/i.test(text)) return 'BULK';
if (/\b(?:vehicles?|cars?)\b/i.test(text)) return 'RO_RO';
return null;
}
private extractQuantity(text: string): number | null {
// "2x40ft", "2 x 40ft", "3x20ft", "1x20ft"
let match = text.match(/(\d+)\s*x\s*\d+\s*-?\s*(?:ft|foot)\b/i);
if (match) return parseInt(match[1], 10);
// "one 40ft container", "two containers"
match = text.match(
new RegExp(
String.raw`\b(${Object.keys(WORD_NUMBERS).join('|')})\s+(?:\d+\s*-?\s*(?:ft|foot)\s+)?containers?\b`,
'i',
),
);
if (match) return WORD_NUMBERS[match[1].toLowerCase()];
// "3 containers", "2 refrigerated containers"
match = text.match(/(\d+)\s+(?:[a-z]+\s+)?containers?\b/i);
if (match) return parseInt(match[1], 10);
// "5 vehicles", "3 cars"
match = text.match(/(\d+)\s+(?:vehicles?|cars?)\b/i);
if (match) return parseInt(match[1], 10);
return null;
}
private extractWeightKg(text: string): number | null {
const tons = text.match(/([\d,]+(?:\.\d+)?)\s*(?:tons?|tonnes?)\b/i);
if (tons) return Math.round(this.parseNumber(tons[1]) * 1000);
const kg = text.match(/([\d,]+(?:\.\d+)?)\s*kgs?\b/i);
if (kg) return Math.round(this.parseNumber(kg[1]));
return null;
}
private parseNumber(raw: string): number {
return parseFloat(raw.replace(/,/g, ''));
}
private extractFlag(
text: string,
kind: 'pickup' | 'delivery',
): boolean | null {
// "no pickup required" must read as false, so the negative wins.
if (new RegExp(String.raw`\bno\s+${kind}\b`, 'i').test(text)) return false;
if (new RegExp(String.raw`\b${kind}\s+required\b`, 'i').test(text)) {
return true;
}
return null;
}
private validate(extracted: AiExtractedBooking): AiValidationResult {
const errors: string[] = [];
if (!extracted.customerName) errors.push('Customer name is missing');
if (!extracted.origin) errors.push('Origin is missing');
if (!extracted.destination) errors.push('Destination is missing');
if (!extracted.cargoType) errors.push('Cargo type is missing');
if (!extracted.containerType) errors.push('Container type is missing');
if (extracted.quantity === null) errors.push('Quantity is missing');
if (!extracted.direction) errors.push('Direction is missing');
return { valid: errors.length === 0, errors };
}
}

View File

@@ -0,0 +1,47 @@
export const AI_CONTAINER_TYPES = ['20FT', '40FT', 'BULK', 'RO_RO'] as const;
export type AiContainerType = (typeof AI_CONTAINER_TYPES)[number];
export const AI_DIRECTIONS = ['IMPORT', 'EXPORT'] as const;
export type AiDirection = (typeof AI_DIRECTIONS)[number];
export const AI_RECOMMENDATION_ACTIONS = [
'CREATE_DRAFT_BOOKING',
'REQUEST_MISSING_INFORMATION',
] as const;
export type AiRecommendationAction = (typeof AI_RECOMMENDATION_ACTIONS)[number];
export interface AiExtractedBooking {
customerName: string | null;
origin: string | null;
destination: string | null;
cargoType: string | null;
containerType: AiContainerType | null;
quantity: number | null;
direction: AiDirection | null;
weightKg: number | null;
pickupRequired: boolean | null;
deliveryRequired: boolean | null;
}
export interface AiValidationResult {
valid: boolean;
errors: string[];
}
export interface AiRecommendation {
action: AiRecommendationAction;
message: string;
confidence: number;
}
/**
* Payload returned by the extract endpoint. The global
* ResponseTransformInterceptor wraps it as
* `{ success: true, data: AiBookingResult, timestamp }` on the wire.
*/
export interface AiBookingResult {
provider: 'mock';
extracted: AiExtractedBooking;
validation: AiValidationResult;
recommendation: AiRecommendation;
}

View File

@@ -143,6 +143,20 @@ export function htmlToText(html: string): string {
.trim();
}
/**
* Large rotated light-gray copy label (e.g. "Copy 1: Port Operations Copy"),
* drawn FIRST so the page content sits on top of it. 30-degree rotation via a
* text matrix; roughly centered on the page.
*/
export function watermarkOp(text: string, page: { width: number; height: number }): string {
const label = clipText(text, 46);
const size = 34;
const w = textWidth(label, size);
const x = page.width / 2 - (w * 0.866) / 2;
const y = page.height / 2 - (w * 0.5) / 2;
return `q BT 0.93 0.93 0.93 rg /F2 ${size} Tf 0.866 0.5 -0.5 0.866 ${x.toFixed(1)} ${y.toFixed(1)} Tm (${escapePdfText(label)}) Tj ET Q`;
}
/**
* 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
@@ -150,11 +164,26 @@ export function htmlToText(html: string): string {
* document, not a flat text dump. Switches to landscape when the table is wide.
*/
export function buildTabularFallbackPdf(html: string): Buffer {
// Documents printed in duplicate wrap each copy in <section class="copy">
// (freight order: Port Operations copy + Gate Security copy). Render one
// page per copy, each with its own watermark and tile set — parsing the
// whole HTML at once would merge both copies' tiles and drop the watermarks.
const copies = [...html.matchAll(/<section class="copy">([\s\S]*?)<\/section>/gi)].map((m) => m[1]);
const fragments = copies.length ? copies : [html];
return assemblePdf(fragments.flatMap((fragment) => buildTabularPageOps(fragment)));
}
function buildTabularPageOps(
html: string,
): Array<{ ops: string[]; page: { width: number; height: number } }> {
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 metaLabel =
htmlToText(pick(/class="meta"[^>]*>([\s\S]*?)<strong/i) ?? "").toUpperCase() || "REFERENCE";
const generated = htmlToText(pick(/Generated:\s*([^<]+)/i) ?? "");
const watermark = htmlToText(pick(/class="watermark"[^>]*>([\s\S]*?)<\/div>/i) ?? "");
const tiles: Array<[string, string]> = [];
for (const m of html.matchAll(
@@ -180,24 +209,49 @@ export function buildTabularFallbackPdf(html: string): Buffer {
const M = 32;
const contentW = page.width - M * 2;
const right = page.width - M;
const ops: string[] = [];
const MAX_PAGES = 12;
// 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));
const pagesOut: Array<{ ops: string[]; page: { width: number; height: number } }> = [];
let ops: string[] = [];
let y = 0;
// Summary tiles
let y = page.height - 100;
const drawFullHeader = () => {
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(clipText(metaLabel, 26), 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));
y = page.height - 100;
};
const drawContinuationHeader = (pageNo: number) => {
ops.push(lineOp(M, page.height - 24, right, page.height - 24, PdfColor.teal, 1.6));
ops.push(
textOp(clipText(`${title} (continued — page ${pageNo})`, landscape ? 100 : 68), M, page.height - 42, 11, "F2", PdfColor.dark),
);
if (metaRef) ops.push(textOpRight(clipText(metaRef, 28), right, page.height - 42, 10, "F2", PdfColor.gray));
y = page.height - 54;
};
const startPage = (first: boolean) => {
ops = [];
if (watermark) ops.push(watermarkOp(watermark, page));
if (first) drawFullHeader();
else drawContinuationHeader(pagesOut.length + 1);
};
const finishPage = () => pagesOut.push({ ops, page });
startPage(true);
// Summary tiles (first page only)
if (tiles.length) {
const cols = landscape ? 6 : 4;
const tileW = contentW / cols;
@@ -213,21 +267,34 @@ export function buildTabularFallbackPdf(html: string): Buffer {
y -= tileH + 12;
}
// Table
// Table, paginated across as many pages as the rows need.
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;
const bottomReserve = 46; // keep clear of the page edge on row-only pages
let shown = 0;
for (const row of rows) {
if (y < 96) break;
const drawTableHeader = () => {
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;
};
drawTableHeader();
let truncated = 0;
for (const [index, row] of rows.entries()) {
if (y - rowH < bottomReserve) {
if (pagesOut.length + 1 >= MAX_PAGES) {
truncated = rows.length - index;
break;
}
finishPage();
startPage(false);
drawTableHeader();
}
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));
@@ -235,30 +302,33 @@ export function buildTabularFallbackPdf(html: string): Buffer {
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));
if (truncated > 0) {
ops.push(textOp(`... ${truncated} more row(s) not shown`, M, y - 10, 7, "F1", PdfColor.gray));
}
}
// Notice (verification clause)
// Notice + signatures live on the final page; give them a fresh page when the
// rows ran too deep for the fixed bottom band.
if (y < 110 && (notice || signatures.length)) {
finishPage();
startPage(false);
}
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) => {
signatures.forEach((sig, 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));
ops.push(textOp(clipText(sig, Math.floor((sigW - 18) / 3.6)), x, 30, 7, "F1", PdfColor.gray));
});
finishPage();
return assembleSinglePagePdf(ops, page);
return pagesOut;
}
/** Greedy word-wrap to a maximum character width. */
@@ -320,3 +390,41 @@ export function assembleSinglePagePdf(
pdf += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF\n`;
return Buffer.from(pdf, "latin1");
}
/** Assemble a multi-page PDF; one content stream per page, shared Helvetica fonts. */
export function assemblePdf(
pages: Array<{ ops: string[]; page: { width: number; height: number } }>,
): Buffer {
const kids = pages.map((_, i) => `${5 + i * 2} 0 R`).join(" ");
const objects: string[] = [
"<< /Type /Catalog /Pages 2 0 R >>",
`<< /Type /Pages /Kids [${kids}] /Count ${pages.length} >>`,
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold >>",
];
for (const [i, p] of pages.entries()) {
const stream = p.ops.join("\n");
objects.push(
`<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${p.page.width} ${p.page.height}] /Resources << /Font << /F1 3 0 R /F2 4 0 R >> >> /Contents ${6 + i * 2} 0 R >>`,
);
objects.push(`<< /Length ${Buffer.byteLength(stream, "latin1")} >>\nstream\n${stream}\nendstream`);
}
let pdf = "%PDF-1.4\n";
const offsets: number[] = [0];
objects.forEach((object, index) => {
offsets.push(Buffer.byteLength(pdf, "latin1"));
pdf += `${index + 1} 0 obj\n${object}\nendobj\n`;
});
while (Buffer.byteLength(pdf, "latin1") < MIN_VALID_PDF_BYTES) {
pdf += "% fallback padding\n";
}
const xrefOffset = Buffer.byteLength(pdf, "latin1");
pdf += `xref\n0 ${objects.length + 1}\n`;
pdf += "0000000000 65535 f \n";
for (const offset of offsets.slice(1)) {
pdf += `${String(offset).padStart(10, "0")} 00000 n \n`;
}
pdf += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF\n`;
return Buffer.from(pdf, "latin1");
}

View File

@@ -2,18 +2,24 @@ import {
BadRequestException,
ConflictException,
Injectable,
Logger,
NotFoundException,
} from '@nestjs/common';
import { DataSource, EntityManager, IsNull } from 'typeorm';
import { NotificationAudience, NotificationType } from '@edr/types';
import { AddCustomerTruckDto } from './dto/add-customer-truck.dto';
import { DepartCustomerTruckDto } from './dto/depart-customer-truck.dto';
import { CustomerTruckAssignment } from './entities/customer-truck-assignment.entity';
import { CustomerTruckContainer } from './entities/customer-truck-container.entity';
import { CustomerTruckAssignmentsRepository } from './customer-truck-assignments.repository';
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
import { NotificationsService } from '../notifications/notifications.service';
import { sendCompanyChannels } from '../notifications/notify-company.util';
interface BookingGuardRow {
tradeDirection: string | null;
freightType: string | null;
firstMile: string | null;
lastMile: string | null;
paymentStatus: string | null;
@@ -29,9 +35,13 @@ interface BookingGuardRow {
*/
@Injectable()
export class CustomerTruckService {
private readonly logger = new Logger(CustomerTruckService.name);
constructor(
private readonly dataSource: DataSource,
private readonly assignments: CustomerTruckAssignmentsRepository,
private readonly inbox: NotificationInboxService,
private readonly notifications: NotificationsService,
) {}
listTrucks(bookingId: string): Promise<CustomerTruckAssignment[]> {
@@ -41,14 +51,20 @@ export class CustomerTruckService {
async addTruck(bookingId: string, dto: AddCustomerTruckDto): Promise<CustomerTruckAssignment[]> {
const booking = await this.loadBookingGuard(bookingId);
this.assertSelfHaulPaid(booking);
this.assertAssignmentWindow(booking);
const requested = (dto.containerNumbers ?? []).map((n) => n.trim().toUpperCase());
// Bulk bookings have no containers — the truck hauls loose tonnage and is
// weighed out on departure (gross_weight_kg). Container bookings assign the
// 12 specific containers each truck carries.
const isBulk = booking.freightType === 'BULK';
const requested = isBulk
? []
: (dto.containerNumbers ?? []).map((n) => n.trim().toUpperCase());
// Both import and export specify the containers each truck carries. Capacity
// is size-based: a 40ft container fills the truck (max 1); two 20ft containers
// fit (max 2), no size mixing. #trucks <= #containers follows naturally since
// each container is assigned to exactly one truck.
if (requested.length < 1) {
// Container capacity is size-based: a 40ft container fills the truck (max 1);
// two 20ft containers fit (max 2), no size mixing. #trucks <= #containers
// follows naturally since each container is assigned to exactly one truck.
if (!isBulk && requested.length < 1) {
throw new BadRequestException('Select at least one container for this truck');
}
if (requested.length > 2) {
@@ -395,20 +411,74 @@ export class CustomerTruckService {
});
if (!container) return;
const assignment = await m
.getRepository(CustomerTruckAssignment)
.findOne({ where: { id: container.assignmentId } });
const justArrived = Boolean(assignment) && !assignment?.arrivedAt;
await m
.getRepository(CustomerTruckAssignment)
.update({ id: container.assignmentId, arrivedAt: IsNull() }, { arrivedAt: new Date() });
await this.syncBookingArrival(bookingId, m);
if (justArrived && assignment) {
await this.notifyTruckArrival(bookingId, assignment.plateNumber, m);
}
}
/** Mark every truck on the booking arrived (fallback when no container is known). */
async markAllArrived(bookingId: string, manager?: EntityManager): Promise<void> {
const m = manager ?? this.dataSource.manager;
const justArrived = await m
.getRepository(CustomerTruckAssignment)
.find({ where: { bookingId, arrivedAt: IsNull() } });
await m
.getRepository(CustomerTruckAssignment)
.update({ bookingId, arrivedAt: IsNull() }, { arrivedAt: new Date() });
await this.syncBookingArrival(bookingId, m);
for (const truck of justArrived) {
await this.notifyTruckArrival(bookingId, truck.plateNumber, m);
}
}
/**
* Best-effort truck-arrival notification to the booking's company across every
* channel: in-app (portal inbox) + SMS + email. Never throws — a missing
* provider or contact must not break the arrival flow.
*/
private async notifyTruckArrival(
bookingId: string,
plateNumber: string | null,
m: EntityManager,
): Promise<void> {
try {
const [booking]: Array<{ companyId: string | null; reference: string | null }> =
await m.query(
`SELECT company_id AS "companyId", reference
FROM freight.bookings
WHERE id = $1 AND deleted_at IS NULL`,
[bookingId],
);
if (!booking?.companyId) return;
const ref = booking.reference ?? bookingId;
const truck = plateNumber ? `Truck ${plateNumber}` : 'A customer truck';
const body = `${truck} has arrived at the terminal for booking ${ref}.`;
await this.inbox.notify({
recipients: { companyId: booking.companyId },
audience: NotificationAudience.PORTAL,
type: NotificationType.BOOKING_STATUS,
title: 'Truck arrived',
body,
link: `/bookings/${bookingId}`,
data: { bookingId, plateNumber, action: 'TRUCK_ARRIVED' },
});
await sendCompanyChannels(this.dataSource, this.notifications, booking.companyId, body);
} catch (err) {
this.logger.warn(
`Truck-arrival notify failed for ${bookingId}: ${(err as Error).message}`,
);
}
}
/**
@@ -430,6 +500,7 @@ export class CustomerTruckService {
private async loadBookingGuard(bookingId: string): Promise<BookingGuardRow> {
const [row]: BookingGuardRow[] = await this.dataSource.query(
`SELECT trade_direction AS "tradeDirection",
freight_type AS "freightType",
first_mile_pickup_address AS "firstMile",
last_mile_delivery_address AS "lastMile",
payment_status AS "paymentStatus",
@@ -463,6 +534,30 @@ export class CustomerTruckService {
}
}
/**
* Assignment window by direction:
* - IMPORT: pickup trucks are assigned only AFTER the train has arrived.
* - EXPORT / DOMESTIC: delivery trucks are assigned only BEFORE the cargo is
* loaded onto the train (booking still PAID / TRUCK_ASSIGNED). Once loaded
* (IN_TRANSIT and beyond) assignment is closed.
*/
private assertAssignmentWindow(booking: BookingGuardRow): void {
const status = booking.status ?? '';
if (booking.tradeDirection === 'IMPORT') {
if (status !== 'ARRIVED') {
throw new BadRequestException(
'Import pickup trucks can only be assigned after the train has arrived',
);
}
return;
}
if (!['PAID', 'TRUCK_ASSIGNED'].includes(status)) {
throw new BadRequestException(
'Export delivery trucks can only be assigned before the cargo is loaded onto the train',
);
}
}
private async bookingContainerNumbers(bookingId: string): Promise<string[]> {
const rows: Array<{ containerNumber: string }> = await this.dataSource.query(
`SELECT bcu.container_number AS "containerNumber"

View File

@@ -221,7 +221,7 @@ export class CompaniesController {
@Post("company-profile")
@ApiOperation({
summary:
"Create a single operational profile for the current user's company and make it the active mode",
"Create a single operational profile for the current user's company. The role starts pending and does not become the active mode",
})
async createCompanyProfile(
@CurrentUser() user: CurrentIamUser,

View File

@@ -1,9 +1,11 @@
import { Module } from "@nestjs/common";
import { Module, forwardRef } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import { HttpModule } from "@nestjs/axios";
import { FilesModule } from "../files/files.module";
import { FileUploadSettingsModule } from "../file-upload-settings/file-upload-settings.module";
import { MinioModule } from "../minio/minio.module";
import { NotificationsModule } from "../notifications/notifications.module";
import { NotificationInboxModule } from "../notification-inbox/notification-inbox.module";
import { CompaniesController } from "./companies.controller";
import { CompaniesService } from "./companies.service";
import { CompaniesRepository } from "./companies.repository";
@@ -17,6 +19,7 @@ import { Booking } from "../bookings/entities/booking.entity";
import { CompanyProfileRepository } from "./company-profile.repository";
import { CompanyChangeRequestRepository } from "./company-change-request.repository";
import { ETradeService } from "./services/etrade.service";
import { CompanyNotifierService } from "./company-notifier.service";
@Module({
imports: [
@@ -31,6 +34,10 @@ import { ETradeService } from "./services/etrade.service";
FilesModule,
FileUploadSettingsModule,
MinioModule,
// Account-status notifications (CompanyNotifierService). The inbox module
// imports this module back for portal recipient targeting, hence forwardRef.
NotificationsModule,
forwardRef(() => NotificationInboxModule),
],
controllers: [CompaniesController],
providers: [
@@ -41,6 +48,7 @@ import { ETradeService } from "./services/etrade.service";
CompanyChangeRequestRepository,
CompanyDashboardRepository,
ETradeService,
CompanyNotifierService,
],
exports: [
CompaniesService,

View File

@@ -17,6 +17,7 @@ import { FilesService } from "../files/files.service";
import { FileRecord } from "../files/entities/file.entity";
import { FileUploadSettingsService } from "../file-upload-settings/file-upload-settings.service";
import { ETradeService } from "./services/etrade.service";
import { CompanyNotifierService } from "./company-notifier.service";
import { OnboardingRequirementsResponseDto } from "./dto/onboarding-requirements-response.dto";
import { normalizeE164 } from "../../common/validators/is-phone-number.validator";
import { CreateCompanyDto } from "./dto/create-company.dto";
@@ -96,6 +97,7 @@ export class CompaniesService {
private readonly filesService: FilesService,
private readonly fileUploadSettingsService: FileUploadSettingsService,
private readonly etradeService: ETradeService,
private readonly companyNotifier: CompanyNotifierService,
) { }
/**
@@ -585,9 +587,13 @@ export class CompaniesService {
}
async updateCompany(id: string, dto: UpdateCompanyDto): Promise<Company> {
await this.findCompanyById(id);
const before = await this.findCompanyById(id);
const updated = await this.companiesRepo.update(id, dto);
if (!updated) throw new NotFoundException(`Company ${id} not found`);
// Suspending or blacklisting locks the customer out, so they must be told.
// This is the only path that writes those statuses.
this.companyNotifier.statusChanged(updated, before.status);
return updated;
}
@@ -1051,13 +1057,12 @@ export class CompaniesService {
);
}
const reference = await this.companyProfilesRepo.generateReference(type);
// No reference is minted here: it is issued by setCompanyProfileStatus when
// a reviewer approves the role. Creating it Active would bypass that review.
return this.companyProfilesRepo.create({
companyId,
type,
reference,
status: ProfileStatus.Active,
status: ProfileStatus.Pending,
});
}
@@ -1131,9 +1136,11 @@ export class CompaniesService {
}
/**
* Create a single operational profile for the current user's company and
* make it the active mode in the same call. Powers the header "Switch to
* Exporter/Importer" flow when the target profile doesn't exist yet.
* Create a single operational profile for the current user's company. The new
* role starts Pending, so it deliberately does NOT become the active mode:
* switching onto an unapproved profile would strip the user of `canBook` and
* block them from creating contracts under the role they already had approved.
* Callers switch explicitly via {@link setActiveMode} once the role is Active.
*/
async createCompanyProfileForUser(
userId: string,
@@ -1156,8 +1163,7 @@ export class CompaniesService {
let created = await this.companyProfilesRepo.findByType(companyId, type);
if (!created) {
// New self-service roles start Pending (awaiting backoffice approval) and
// carry no reference until approved. The customer can select this mode but
// can't book under it until it's cleared.
// carry no reference until approved.
created = await this.companyProfilesRepo.create({
companyId,
type,
@@ -1166,8 +1172,6 @@ export class CompaniesService {
});
}
await this.profilesRepo.update(profile.id, { activeProfileType: type });
return created;
}
@@ -1442,10 +1446,12 @@ export class CompaniesService {
// browser (which fails on the internal bucket endpoint).
/**
* Upload business-license file(s) for one of the user's profiles. During
* onboarding (company not yet Active) they go live immediately; for an Active
* company they're staged under the pending code and recorded as `add` intents
* on a pending change request for backoffice review. Returns the updated view.
* Upload business-license file(s) for one of the user's profiles. For a role
* not yet approved (a fresh onboarding profile, or a newly added service on an
* already-active company) they go live immediately and are reviewed together
* with the role itself. Only for an already-approved role are they staged under
* the pending code and recorded as `add` intents on a pending change request —
* a licence swap on a live role is a change; a licence on a new role is not.
*/
async addProfileLicenseFiles(
userId: string,
@@ -1454,7 +1460,7 @@ export class CompaniesService {
): Promise<ProfileLicenseFileView[]> {
const profile = await this.resolveOwnedProfile(userId, profileId);
const company = await this.findCompanyById(profile.companyId);
const gated = company.status === CompanyStatus.Active;
const gated = profile.status === ProfileStatus.Active;
const code = gated ? LICENSE_PENDING_CODE : LICENSE_CODE;
const uploaded = await Promise.all(
@@ -1486,9 +1492,9 @@ export class CompaniesService {
/**
* Remove a license file. A staged (pending) file is withdrawn outright
* (soft-deleted, its `add` intent dropped). A live file on an Active company
* is kept and recorded as a `remove` intent for review; during onboarding it
* is deleted immediately.
* (soft-deleted, its `add` intent dropped). A live file on an already-approved
* role is kept and recorded as a `remove` intent for review; on a role still
* awaiting approval it is deleted immediately.
*/
async removeProfileLicenseFile(
userId: string,
@@ -1504,7 +1510,7 @@ export class CompaniesService {
throw new NotFoundException(`License file ${fileId} not found`);
}
const company = await this.findCompanyById(profile.companyId);
const gated = company.status === CompanyStatus.Active;
const gated = profile.status === ProfileStatus.Active;
if (record.code === LICENSE_PENDING_CODE) {
// Withdraw a not-yet-approved upload: delete it and drop its add intent.
@@ -1526,7 +1532,7 @@ export class CompaniesService {
/**
* Replace a live license file with a freshly uploaded one — recorded as a
* `remove` of the old file plus an `add` of the new, so approval swaps them
* atomically. During onboarding the swap is applied immediately.
* atomically. On a role still awaiting approval the swap is applied immediately.
*/
async replaceProfileLicenseFile(
userId: string,
@@ -1540,7 +1546,7 @@ export class CompaniesService {
throw new NotFoundException(`License file ${fileId} not found`);
}
const company = await this.findCompanyById(profile.companyId);
const gated = company.status === CompanyStatus.Active;
const gated = profile.status === ProfileStatus.Active;
const created = await this.filesService.upload({
resourceId: profileId,
@@ -2044,13 +2050,17 @@ export class CompaniesService {
}
async fetchETradeData(tin: string) {
const { businessInfo } = await this.etradeService.resolveCompanyData(tin);
const { businessInfo, companyInfo } =
await this.etradeService.resolveCompanyData(tin);
if (!businessInfo) {
throw new BadRequestException(
"We couldn't find a business license for this TIN with eTrade. Please double-check the number and try again.",
);
}
const registrationData = this.etradeService.extractRegistrationData(businessInfo);
const registrationData = this.etradeService.extractRegistrationData(
businessInfo,
companyInfo,
);
const tinTaken = await this.companiesRepo.existsByTin(tin);
return { ...registrationData, tinTaken };
}

View File

@@ -0,0 +1,86 @@
import { Injectable, Logger } from "@nestjs/common";
import {
NotificationAudience,
NotificationPriority,
NotificationType,
} from "@edr/types";
import { Company, CompanyStatus } from "./entities/company.entity";
import { NotificationsService } from "../notifications/notifications.service";
import { NotificationInboxService } from "../notification-inbox/notification-inbox.service";
/** Account statuses that lock the customer out and therefore must be told to them. */
const PUNITIVE_STATUSES: readonly CompanyStatus[] = [
CompanyStatus.Suspended,
CompanyStatus.Blacklisted,
];
/**
* Customer notifications for company account-status changes. Mirrors
* {@link ContractNotifierService}: SMS + email direct to the company contact,
* plus a persisted in-app item. Every send is fire-and-forget and never throws —
* a notification failure must not roll back the status change itself.
*/
@Injectable()
export class CompanyNotifierService {
private readonly logger = new Logger(CompanyNotifierService.name);
constructor(
private readonly notifications: NotificationsService,
private readonly inbox: NotificationInboxService,
) {}
/** Send SMS + email to the company contact; log-only on failure. */
private async notifyContact(company: Company, message: string): Promise<void> {
const phone = company.contactPersonPhone ?? company.phone ?? null;
const email = company.email ?? company.generalManagerEmail ?? null;
if (phone) {
try {
await this.notifications.directSend("sms", phone, message);
} catch (err) {
this.logger.warn(`SMS failed for ${company.id}: ${(err as Error).message}`);
}
}
if (email) {
try {
await this.notifications.directSend("email", email, message);
} catch (err) {
this.logger.warn(`Email failed for ${company.id}: ${(err as Error).message}`);
}
}
if (!phone && !email) {
this.logger.warn(`No contact on file for ${company.id} — not notified`);
}
}
/**
* Tell the customer their account was suspended or blacklisted. Called only on
* a real transition into one of those statuses; other status writes are silent.
*/
statusChanged(company: Company, previous: CompanyStatus): void {
const status = company.status;
if (status === previous) return;
if (!PUNITIVE_STATUSES.includes(status)) return;
const label = status === CompanyStatus.Suspended ? "suspended" : "blacklisted";
const title = `Account ${label}`;
const body =
`Your company account has been ${label}. ` +
`You will not be able to submit new contracts or bookings. ` +
`Please contact EDR support for assistance.`;
this.logger.log(`ACCOUNT_${label.toUpperCase()}${company.id}`);
void this.notifyContact(company, `${title}. ${body}`);
void this.inbox.notify({
recipients: { companyId: company.id },
audience: NotificationAudience.PORTAL,
type: NotificationType.ACCOUNT_STATUS,
title,
body,
link: "/settings",
data: { companyId: company.id, status },
priority: NotificationPriority.HIGH,
});
}
}

View File

@@ -1,6 +1,7 @@
import { CompanyRegistrationData } from "@edr/types";
export class ETradeResponseDto implements CompanyRegistrationData {
companyName!: string;
licenceNumber!: string;
statusDescription!: string;
dateRegistered!: string;
@@ -20,6 +21,7 @@ export class ETradeResponseDto implements CompanyRegistrationData {
tinTaken?: boolean;
constructor(data: CompanyRegistrationData) {
this.companyName = data.companyName;
this.licenceNumber = data.licenceNumber;
this.statusDescription = data.statusDescription;
this.dateRegistered = data.dateRegistered;

View File

@@ -84,11 +84,17 @@ export class CompanyProfile extends BaseEntity {
})
reference!: string | null;
/**
* A newly requested operational role is unreviewed, so it defaults to Pending.
* Only {@link CompaniesService.setCompanyProfileStatus} may promote it to
* Active — an approved-by-default role would let a customer self-grant a
* service (e.g. importer) without any documentation review.
*/
@Column({
name: "status",
type: "varchar",
length: 32,
default: ProfileStatus.Active,
default: ProfileStatus.Pending,
})
status!: ProfileStatus;

View File

@@ -87,12 +87,21 @@ export class ETradeService {
}
}
/**
* `companyInfo` carries the registered organization name (`BusinessName`);
* `businessInfo` only carries the licence's `TradeName`. Pass both so the
* company name resolves to the legal entity rather than the trade name — and
* never to `ManagerNameEng`, which is the manager's personal name.
*/
extractRegistrationData(
businessInfo: ETradeBusinessInfo,
companyInfo?: ETradeCompanyInfo,
): CompanyRegistrationData {
const primaryManager = businessInfo.AssociateShortInfos?.[0];
return {
companyName:
companyInfo?.BusinessName?.trim() || businessInfo.TradeName?.trim() || "",
licenceNumber: businessInfo.LicenceNumber,
statusDescription: businessInfo.StatusDescription,
dateRegistered: businessInfo.DateRegistered,

View File

@@ -6,12 +6,15 @@ import { GpsPosition } from './entities/gps-position.entity';
import { GpsDeviceRepository, GpsPositionRepository } from './gps-tracking.repository';
import { GpsTrackingService } from './gps-tracking.service';
import { GpsTrackingController } from './gps-tracking.controller';
import { Gt06Server } from './gt06/gt06.server';
// NOTE: the GT06 TCP listener now lives in the standalone @edr/gps-tracker app.
// This module is REST-only — it reads gps_devices / gps_positions that the
// tracker app writes to the shared DB. Do not re-add Gt06Server here, or two
// processes would fight for the tracker socket.
@Module({
imports: [TypeOrmModule.forFeature([GpsDevice, GpsPosition])],
controllers: [GpsTrackingController],
providers: [GpsDeviceRepository, GpsPositionRepository, GpsTrackingService, Gt06Server],
providers: [GpsDeviceRepository, GpsPositionRepository, GpsTrackingService],
exports: [GpsTrackingService],
})
export class GpsTrackingModule {}

View File

@@ -1,97 +0,0 @@
import { Injectable, Logger, OnApplicationBootstrap, OnModuleDestroy } from '@nestjs/common';
import * as net from 'net';
import { GpsTrackingService } from '../gps-tracking.service';
import { buildAck, GT06_PROTOCOL, parseStream } from './gt06.codec';
interface Session {
buffer: Buffer;
imei: string | null;
}
const MAX_BUFFER = 64 * 1024;
/**
* Raw TCP listener for GT06 GPS trackers. Trackers open a socket, send a login
* (IMEI), then stream location/heartbeat/alarm packets; we decode, persist via
* {@link GpsTrackingService}, and ACK login/heartbeat/alarm so the device keeps
* the connection alive. Disabled when GT06_TCP_PORT=0.
*/
@Injectable()
export class Gt06Server implements OnApplicationBootstrap, OnModuleDestroy {
private readonly logger = new Logger(Gt06Server.name);
private server?: net.Server;
private readonly sessions = new Map<net.Socket, Session>();
constructor(private readonly gps: GpsTrackingService) {}
onApplicationBootstrap(): void {
const port = Number(process.env.GT06_TCP_PORT ?? 5023);
if (!port) {
this.logger.log('GT06 TCP listener disabled (GT06_TCP_PORT=0)');
return;
}
const host = process.env.GT06_TCP_HOST ?? '0.0.0.0';
this.server = net.createServer((socket) => this.onConnection(socket));
this.server.on('error', (err) => this.logger.error(`GT06 server error: ${String(err)}`));
this.server.listen(port, host, () => this.logger.log(`GT06 GPS tracker listener on ${host}:${port}`));
}
onModuleDestroy(): void {
for (const socket of this.sessions.keys()) socket.destroy();
this.sessions.clear();
this.server?.close();
}
private onConnection(socket: net.Socket): void {
this.sessions.set(socket, { buffer: Buffer.alloc(0), imei: null });
socket.on('data', (chunk) => void this.onData(socket, chunk));
socket.on('error', () => this.sessions.delete(socket));
socket.on('close', () => this.sessions.delete(socket));
}
private async onData(socket: net.Socket, chunk: Buffer): Promise<void> {
const session = this.sessions.get(socket);
if (!session) return;
session.buffer = Buffer.concat([session.buffer, chunk]);
if (session.buffer.length > MAX_BUFFER) session.buffer = Buffer.alloc(0); // drop garbage
const { packets, rest } = parseStream(session.buffer);
session.buffer = rest;
for (const pkt of packets) {
try {
await this.handle(socket, session, pkt);
} catch (err) {
this.logger.error(`Failed to handle GT06 packet (${pkt.type}): ${String(err)}`);
}
}
}
private async handle(
socket: net.Socket,
session: Session,
pkt: ReturnType<typeof parseStream>['packets'][number],
): Promise<void> {
switch (pkt.type) {
case 'login':
session.imei = pkt.imei;
await this.gps.handleLogin(pkt.imei);
socket.write(buildAck(GT06_PROTOCOL.LOGIN, pkt.serial));
break;
case 'heartbeat':
if (session.imei) await this.gps.handleHeartbeat(session.imei, pkt.status);
socket.write(buildAck(GT06_PROTOCOL.HEARTBEAT, pkt.serial));
break;
case 'location':
if (session.imei) await this.gps.handleFix(session.imei, pkt.gps);
break;
case 'alarm':
if (session.imei) await this.gps.handleFix(session.imei, pkt.gps, pkt.status.alarm, pkt.status);
socket.write(buildAck(GT06_PROTOCOL.ALARM, pkt.serial));
break;
default:
break;
}
}
}

View File

@@ -0,0 +1,22 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator';
/**
* Proof of delivery captured by the EDR driver when a last-mile leg is
* completed. Sent as multipart/form-data — the recipient's signature (field
* `signature`) and proof photos (field `photos`) are uploaded alongside these
* text fields.
*/
export class RecordProofOfDeliveryDto {
@ApiProperty({ description: 'Name of the person who received the cargo.' })
@IsString()
@IsNotEmpty()
@MaxLength(160)
recipientName!: string;
@ApiPropertyOptional({ description: 'Optional delivery notes.' })
@IsOptional()
@IsString()
@MaxLength(1000)
notes?: string;
}

View File

@@ -70,4 +70,22 @@ export class LastMile extends BaseEntity {
@OneToMany(() => LastMileVehicleAssignment, (va) => va.lastMile)
vehicleAssignments?: LastMileVehicleAssignment[];
// ── Proof of delivery (captured by the EDR driver on completion) ──────────
@Column({ name: 'pod_recipient_name', type: 'varchar', length: 160, nullable: true })
podRecipientName?: string | null;
/** File id of the recipient's captured signature (PNG). */
@Column({ name: 'pod_signature_file_id', type: 'uuid', nullable: true })
podSignatureFileId?: string | null;
/** File ids of the delivery proof photos. */
@Column({ name: 'pod_photo_file_ids', type: 'text', array: true, default: '{}' })
podPhotoFileIds!: string[];
@Column({ name: 'pod_notes', type: 'text', nullable: true })
podNotes?: string | null;
@Column({ name: 'pod_captured_at', type: 'timestamptz', nullable: true })
podCapturedAt?: Date | null;
}

View File

@@ -11,8 +11,11 @@ import {
Patch,
Post,
Query,
UploadedFiles,
UseInterceptors,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { AnyFilesInterceptor } from '@nestjs/platform-express';
import { ApiBearerAuth, ApiConsumes, ApiOperation, ApiTags } from '@nestjs/swagger';
import { BookingStaff } from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
@@ -21,6 +24,7 @@ import { CreateLastMileDto } from './dto/create-last-mile.dto';
import { UpdateLastMileDto } from './dto/update-last-mile.dto';
import { SetVehiclesDto } from './dto/set-vehicles.dto';
import { SetDistancesDto } from './dto/set-distances.dto';
import { RecordProofOfDeliveryDto } from './dto/record-proof-of-delivery.dto';
import { LastMileStatus } from './entities/last-mile.entity';
import { LastMileService } from './last-mile.service';
import { LastMileInvoiceService } from './last-mile-invoice.service';
@@ -115,6 +119,19 @@ export class LastMileController {
return this.lastMileService.setDistances(id, dto.distances, dto.remainingPayment);
}
@Post(':id/proof-of-delivery')
@BookingStaff(FREIGHT_PERMS.lastMile.update)
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes('multipart/form-data')
@ApiOperation({ summary: 'Record proof of delivery (signature + photos) and complete the leg' })
async recordProofOfDelivery(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: RecordProofOfDeliveryDto,
@UploadedFiles() files: Express.Multer.File[],
) {
return this.lastMileService.recordProofOfDelivery(id, dto, files ?? []);
}
@Post(':id/invoice')
@BookingStaff(FREIGHT_PERMS.lastMile.generateInvoice)
@ApiOperation({ summary: 'Generate the delivery-fee invoice for a last-mile leg' })

View File

@@ -3,6 +3,7 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { BillingModule } from '../billing/billing.module';
import { BookingsModule } from '../bookings/bookings.module';
import { FilesModule } from '../files/files.module';
import { DriversModule } from '../drivers/drivers.module';
import { NotificationsModule } from '../notifications/notifications.module';
import { VehiclesModule } from '../vehicles/vehicles.module';
@@ -22,6 +23,7 @@ import { LastMileService } from './last-mile.service';
VehiclesModule,
DriversModule,
NotificationsModule,
FilesModule,
],
controllers: [LastMileController],
providers: [LastMileRepository, LastMileService, LastMileInvoiceService],

View File

@@ -8,10 +8,12 @@ import { VehiclesService } from '../vehicles/vehicles.service';
import { VehicleAvailability } from '../vehicles/entities/vehicle.entity';
import { CreateLastMileDto } from './dto/create-last-mile.dto';
import { UpdateLastMileDto } from './dto/update-last-mile.dto';
import { RecordProofOfDeliveryDto } from './dto/record-proof-of-delivery.dto';
import { LastMile, LastMileStatus } from './entities/last-mile.entity';
import { LastMileContainerAllocation } from './entities/last-mile-container-allocation.entity';
import { LastMileVehicleAssignment } from './entities/last-mile-vehicle-assignment.entity';
import { LastMileRepository } from './last-mile.repository';
import { FilesService } from '../files/files.service';
import { BillingService, InvoiceEventPayload } from '../billing/billing.service';
import { OnEvent } from '@nestjs/event-emitter';
import { FleetHistoryService } from '../fleet-history/fleet-history.service';
@@ -47,6 +49,7 @@ export class LastMileService {
private readonly dataSource: DataSource,
private readonly history: FleetHistoryService,
private readonly billing: BillingService,
private readonly filesService: FilesService,
) {}
/** Attach real invoice info (number/status) to records so the UI can show an
@@ -210,6 +213,49 @@ export class LastMileService {
return record;
}
/**
* Record proof of delivery (recipient signature + photos + notes) and complete
* the leg. Marking DELIVERED reuses {@link update}'s side effects (deliveredAt,
* vehicle release, history).
*/
async recordProofOfDelivery(
id: string,
dto: RecordProofOfDeliveryDto,
files: Express.Multer.File[],
): Promise<LastMile> {
const existing = await this.findById(id);
const signature = files.find((f) => f.fieldname === 'signature');
const photos = files.filter((f) => f.fieldname === 'photos');
const signatureFileId = signature
? (
await this.filesService.upload({
resourceId: id,
resource: 'last-mile',
code: 'pod-signature',
file: signature,
})
).id
: null;
const photoFileIds = photos.length
? (await this.filesService.uploadMany(id, 'last-mile', photos)).map((r) => r.id)
: [];
await this.lastMileRepository.update(id, {
podRecipientName: dto.recipientName.trim(),
podSignatureFileId: signatureFileId,
podPhotoFileIds: photoFileIds,
podNotes: dto.notes?.trim() || null,
podCapturedAt: new Date(),
} as never);
if (existing.status !== 'DELIVERED') {
return this.update(id, { status: 'DELIVERED' } as UpdateLastMileDto);
}
return this.findById(id);
}
async create(dto: CreateLastMileDto): Promise<LastMile> {
const record = await this.lastMileRepository.create({
bookingId: dto.bookingId,

View File

@@ -1,4 +1,4 @@
import { Module } from "@nestjs/common";
import { Module, forwardRef } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import { Session } from "@tria-plc/iamapi-common/entities/iam/user/session.entity";
import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity";
@@ -17,8 +17,9 @@ import { WsAuthService } from "./ws-auth.service";
@Module({
imports: [
TypeOrmModule.forFeature([Notification, User, Session]),
// ExternalProfileRepository + CompanyProfileRepository (portal targeting)
CompaniesModule,
// ExternalProfileRepository + CompanyProfileRepository (portal targeting).
// CompaniesModule imports this module back for CompanyNotifierService.
forwardRef(() => CompaniesModule),
// BackofficeService.getOrganizationEmployees (staff targeting)
BackofficeModule,
// EmailClientService + SmsClientService (HIGH-priority fan-out)

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 })
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 })
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). */
async signForBooking(bookingId: string, userId?: string | null): Promise<void> {
async signForBooking(
bookingId: string,
userId?: string | null,
signerName?: string | null,
): Promise<void> {
await this.dataSource
.getRepository(BookingHandover)
.update(
{ 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 { 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,35 @@ 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;
/** 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 {
ruleType: FeeRuleType;
/** 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()
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' && !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 ──────────────────────────────────────────────────────────────
listRules(): Promise<WarehouseFeeRule[]> {
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. */
async previewForInventory(inventoryId: string, billingCurrency = 'USD'): Promise<FeePreview[]> {
const item = await this.loadItem(inventoryId);

View File

@@ -12,6 +12,8 @@ import {
import { AnyFilesInterceptor } from '@nestjs/platform-express';
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 { UpdateInspectionReportDto } from './dto/update-inspection-report.dto';
import { WarehouseInspectionService } from './warehouse-inspection.service';
@@ -19,10 +21,12 @@ import { WarehouseInspectionService } from './warehouse-inspection.service';
@ApiTags('warehouse-inspection')
@ApiBearerAuth()
@Controller()
@BookingStaff(FREIGHT_PERMS.warehouseInspectionReports.view)
export class WarehouseInspectionController {
constructor(private readonly inspectionService: WarehouseInspectionService) {}
@Post('warehouse-inventory/:inventoryId/inspection-reports')
@BookingStaff(FREIGHT_PERMS.warehouseInspectionReports.create)
@ApiOperation({ summary: 'Create an inspection / damage report for an inventory item' })
create(
@Param('inventoryId', ParseUUIDPipe) inventoryId: string,
@@ -46,12 +50,14 @@ export class WarehouseInspectionController {
}
@Patch('warehouse-inspection-reports/:id')
@BookingStaff(FREIGHT_PERMS.warehouseInspectionReports.update)
@ApiOperation({ summary: 'Update an inspection report' })
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateInspectionReportDto) {
return this.inspectionService.update(id, dto);
}
@Post('warehouse-inspection-reports/:id/attachments')
@BookingStaff(FREIGHT_PERMS.warehouseInspectionReports.update)
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes('multipart/form-data')
@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 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 { BulkInspectDto } from './dto/bulk-inspect.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 { StoreInventoryDto } from './dto/store-inventory.dto';
import { ReceiveWarehouseInventoryDto } from './dto/receive-inventory.dto';
import { ApproveDeliveryDto } from './dto/approve-delivery.dto';
import { ReleaseOrderDto } from './dto/release-order.dto';
import { ReserveInventoryDto } from './dto/reserve-inventory.dto';
import { UnloadBookingDto } from './dto/unload-booking.dto';
@@ -29,42 +32,63 @@ export class WarehouseInventoryController {
) {}
@Get()
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
@ApiOperation({ summary: 'List warehouse inventory' })
findAll(@Query() filter: FilterWarehouseInventoryDto) {
return this.inventoryService.findAll(filter);
}
@Get('ready-for-loading')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
@ApiOperation({ summary: 'List inventory ready for loading' })
findReadyForLoading(@Query() filter: FilterWarehouseInventoryDto) {
return this.inventoryService.findReadyForLoading(filter);
}
@Get('inquiry')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
@ApiOperation({ summary: 'Locate any item inside the warehouse' })
inquiry(@Query() filter: InquiryWarehouseInventoryDto) {
return this.inventoryService.inquiry(filter);
}
@Get('arrival-queue')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
@ApiOperation({ summary: 'Arrived bookings awaiting unload / inspection' })
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')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
@ApiOperation({ summary: 'Live occupancy per zone (rated capacity vs held) — heatmap data' })
zoneOccupancy(@Query('yardId') yardId?: string) {
return this.inventoryService.zoneOccupancy(yardId);
}
@Post('auto-unload-arrived')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.unload)
@ApiOperation({ summary: 'Bulk auto-unload all arrived bookings into the warehouse' })
autoUnloadArrived() {
return this.inventoryService.autoUnloadArrived();
}
@Post('auto-load-ready')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.load)
@ApiOperation({ summary: 'Auto-load READY_FOR_LOADING inventory with PAID bookings' })
autoLoadReady() {
return this.inventoryService.autoLoadReady();
}
@Get('eligible-bookings')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
@ApiOperation({ summary: 'PAID bookings not yet received, classified IMPORT/EXPORT by route; omit direction for all' })
eligibleBookings(@Query('direction') direction?: string) {
const dir = direction === 'IMPORT' || direction === 'EXPORT' ? direction : undefined;
@@ -72,6 +96,7 @@ export class WarehouseInventoryController {
}
@Post('receive-bulk')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.receive)
@ApiOperation({ summary: 'Bulk-receive selected eligible PAID bookings into a location' })
receiveBulk(@Body() dto: BulkReceiveDto) {
return this.inventoryService.bulkReceive(dto);
@@ -79,36 +104,42 @@ export class WarehouseInventoryController {
@Get('ready-to-load-export')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
@ApiOperation({ summary: 'EXPORT inventory that passed inspection and is READY_FOR_LOADING' })
readyToLoadExport() {
return this.inventoryService.readyToLoadExport();
}
@Get('received-export')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
@ApiOperation({ summary: 'EXPORT inventory that has been received and is awaiting inspection' })
receivedExport() {
return this.inventoryService.receivedExport();
}
@Get('loaded-export')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
@ApiOperation({ summary: 'EXPORT inventory that is LOADED and queued for dispatch' })
loadedExport() {
return this.inventoryService.loadedExport();
}
@Get('loadable-trains')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
@ApiOperation({ summary: 'EXPORT trains (pre-dispatch) with inventory waiting to be loaded' })
loadableTrains() {
return this.inventoryService.loadableTrains();
}
@Get('train/:scheduleId/loadable-items')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
@ApiOperation({ summary: 'Container/cargo inventory assigned to a train, with allocated wagons' })
trainLoadableItems(@Param('scheduleId', ParseUUIDPipe) scheduleId: string) {
return this.inventoryService.trainLoadableItems(scheduleId);
}
@Post('train/:scheduleId/load')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.load)
@ApiOperation({ summary: 'Load selected inventory items onto their allocated wagons for a train' })
loadItemsOntoTrain(
@Param('scheduleId', ParseUUIDPipe) scheduleId: string,
@@ -118,18 +149,21 @@ export class WarehouseInventoryController {
}
@Post('bulk-dispatch-export')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.dispatch)
@ApiOperation({ summary: 'Bulk-dispatch loaded EXPORT inventory (LOADED → DISPATCHED)' })
bulkDispatchExport(@Body() dto: { inventoryIds: string[]; performedBy?: string }) {
return this.inventoryService.bulkDispatchExport(dto.inventoryIds ?? [], dto.performedBy);
}
@Post('bulk-mark-inspected')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.inspect)
@ApiOperation({ summary: 'Bulk mark received inventory inspection PASSED (EXPORT → READY_FOR_LOADING)' })
bulkMarkInspected(@Body() dto: BulkInspectDto) {
return this.inventoryService.bulkMarkInspected(dto);
}
@Post('bookings/:bookingId/unload')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.unload)
@ApiOperation({ summary: 'Unload a single arrived booking into a location' })
unloadBooking(
@Param('bookingId', ParseUUIDPipe) bookingId: string,
@@ -139,24 +173,28 @@ export class WarehouseInventoryController {
}
@Post(':id/gate-clearance')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.gatePass)
@ApiOperation({ summary: 'Final terminal release / gate clearance (blocked while fees unpaid)' })
gateClearance(@Param('id', ParseUUIDPipe) id: string, @Body('performedBy') performedBy?: string) {
return this.inventoryService.gateClearance(id, performedBy);
}
@Get('import/arrive-queue')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
@ApiOperation({ summary: 'Arrived IMPORT train schedules (route-derived), read-only from scheduling' })
importArriveQueue() {
return this.scheduling.importArriveQueue();
}
@Get('import/trains/:scheduleId/items')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
@ApiOperation({ summary: 'Assigned bookings/items for an arrived import train (read-only)' })
importTrainDetail(@Param('scheduleId', ParseUUIDPipe) scheduleId: string) {
return this.scheduling.importTrainDetail(scheduleId);
}
@Post('import/auto-unload-arrived-bookings')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.unload)
@ApiOperation({ summary: 'Unload all eligible assigned bookings of an ARRIVED import train (→ UNLOADED)' })
autoUnloadArrivedBookings(@Body() dto: {
scheduleId: string;
@@ -173,12 +211,14 @@ export class WarehouseInventoryController {
}
@Get('import/unloaded-queue')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
@ApiOperation({ summary: 'IMPORT inventory in the Unloaded Queue (UNLOADED / destination inspection)' })
importUnloadedQueue() {
return this.inventoryService.importUnloadedQueue();
}
@Get('export/djibouti-arrival-queue')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
@ApiOperation({ summary: 'Arrived EXPORT train schedules at Djibouti-side ports, ready for unloading' })
exportDjiboutiArrivalQueue(
@Query('scheduleId') scheduleId?: string,
@@ -197,102 +237,119 @@ export class WarehouseInventoryController {
}
@Get('export/djibouti-trains/:scheduleId/items')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
@ApiOperation({ summary: 'Assigned export bookings/items for an arrived Djibouti-side train' })
exportDjiboutiTrainDetail(@Param('scheduleId', ParseUUIDPipe) scheduleId: string) {
return this.scheduling.exportDjiboutiTrainDetail(scheduleId);
}
@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' })
autoUnloadExportAtDjibouti(@Body() dto: { scheduleId: string; performedBy?: string }) {
return this.inventoryService.autoUnloadExportAtDjibouti(dto.scheduleId, dto.performedBy);
}
@Get('import/pickup-ready-queue')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
@ApiOperation({ summary: 'IMPORT inventory that is PICKUP_READY (READY_FOR_PICKUP) awaiting pickup/dispatch' })
importPickupReadyQueue() {
return this.inventoryService.importPickupReadyQueue();
}
@Get('loadable-wagons')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
@ApiOperation({ summary: 'List wagons usable for loading (read-only from scheduling)' })
loadableWagons() {
return this.scheduling.listLoadableWagons();
}
@Get('booking/:bookingId/schedule')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
@ApiOperation({ summary: 'Read-only schedule + wagon + departure status for a booking' })
bookingSchedule(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
return this.scheduling.getBookingSchedule(bookingId);
}
@Post('receive')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.receive)
@ApiOperation({ summary: 'Receive inventory at a warehouse location' })
receive(@Body() dto: ReceiveWarehouseInventoryDto) {
return this.inventoryService.receive(dto);
}
@Post('reserve')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.move)
@ApiOperation({ summary: 'Reserve stored inventory for a PAID booking' })
reserve(@Body() dto: ReserveInventoryDto) {
return this.inventoryService.reserve(dto);
}
@Get(':id/movements')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
@ApiOperation({ summary: 'Inventory movement history' })
movements(@Param('id', ParseUUIDPipe) id: string) {
return this.inventoryService.findMovements(id);
}
@Get(':id/activity')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
@ApiOperation({ summary: 'Inventory activity log' })
activity(@Param('id', ParseUUIDPipe) id: string) {
return this.inventoryService.findActivity(id);
}
@Get(':id/loadings')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
@ApiOperation({ summary: 'Loading records for an inventory item' })
loadings(@Param('id', ParseUUIDPipe) id: string) {
return this.inventoryService.findLoadingsByInventory(id);
}
@Post(':id/move')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.move)
@ApiOperation({ summary: 'Move inventory to another warehouse/yard/zone' })
move(@Param('id', ParseUUIDPipe) id: string, @Body() dto: MoveInventoryDto) {
return this.inventoryService.move(id, dto);
}
@Post(':id/store')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.move)
@ApiOperation({ summary: 'Mark received inventory as STORED (optional explicit warehouse/yard/zone)' })
store(@Param('id', ParseUUIDPipe) id: string, @Body() dto: StoreInventoryDto) {
return this.inventoryService.store(id, dto.performedBy, dto);
}
@Post(':id/ready-for-loading')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.move)
@ApiOperation({ summary: 'Mark reserved inventory READY_FOR_LOADING' })
readyForLoading(@Param('id', ParseUUIDPipe) id: string, @Body('performedBy') performedBy?: string) {
return this.inventoryService.readyForLoading(id, performedBy);
}
@Post(':id/load')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.load)
@ApiOperation({ summary: 'Load READY_FOR_LOADING inventory onto a wagon' })
load(@Param('id', ParseUUIDPipe) id: string, @Body() dto: LoadInventoryDto) {
return this.inventoryService.load(id, dto);
}
@Post(':id/ready-for-pickup')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.move)
@ApiOperation({ summary: 'Mark inspected IMPORT inventory READY_FOR_PICKUP' })
readyForPickup(@Param('id', ParseUUIDPipe) id: string, @Body('performedBy') performedBy?: string) {
return this.inventoryService.readyForPickup(id, performedBy);
}
@Post(':id/release')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.release)
@ApiOperation({ summary: 'Issue a DO / release order for ready-for-pickup inventory' })
release(@Param('id', ParseUUIDPipe) id: string, @Body() dto: ReleaseOrderDto) {
return this.inventoryService.release(id, dto);
}
@Get(':id/release-document')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
@ApiOperation({ summary: 'View warehouse release / exit paper PDF' })
async releaseDocument(@Param('id', ParseUUIDPipe) id: string, @Res() res: Response) {
const { filename, buffer } = await this.inventoryService.releaseDocument(id);
@@ -303,6 +360,7 @@ export class WarehouseInventoryController {
}
@Get('customer-truck-exit-paper/:assignmentId')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
@ApiOperation({ summary: 'Per-truck exit paper PDF (containers loaded on one customer truck)' })
async truckExitPaper(
@Param('assignmentId', ParseUUIDPipe) assignmentId: string,
@@ -316,6 +374,7 @@ export class WarehouseInventoryController {
}
@Get(':id/grn-document')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
@ApiOperation({ summary: 'View goods received note PDF' })
async grnDocument(@Param('id', ParseUUIDPipe) id: string, @Res() res: Response) {
const { filename, buffer } = await this.inventoryService.grnDocument(id);
@@ -336,12 +395,17 @@ export class WarehouseInventoryController {
}
@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(
@Param('bookingId', ParseUUIDPipe) bookingId: string,
@Body() dto: ApproveDeliveryDto,
@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')
@@ -356,6 +420,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) {
@@ -379,12 +463,14 @@ export class WarehouseInventoryController {
}
@Post(':id/deliver')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.deliver)
@ApiOperation({ summary: 'Deliver import goods to the customer + capture proof of delivery' })
deliver(@Param('id', ParseUUIDPipe) id: string, @Body() dto: DeliverInventoryDto) {
return this.inventoryService.deliver(id, dto);
}
@Patch(':id/dispatch')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.dispatch)
@ApiOperation({ summary: 'Mark loaded inventory DISPATCHED (left the terminal)' })
dispatch(@Param('id', ParseUUIDPipe) id: string, @Body('performedBy') performedBy?: string) {
return this.inventoryService.dispatch(id, performedBy);

View File

@@ -1,4 +1,5 @@
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { Between, DataSource, EntityManager, FindManyOptions, ILike, LessThanOrEqual, MoreThanOrEqual } from 'typeorm';
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
@@ -396,6 +397,177 @@ export class WarehouseInventoryService {
* 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.
*/
/**
* 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
* (excludes items that have left — DELIVERED/DISPATCHED). Powers the yard
* occupancy heatmap. Optionally scoped to one yard.
*/
async zoneOccupancy(yardId?: string): Promise<
Array<{
id: string;
name: string;
code: string;
type: string;
yardId: string;
capacityWeight: number | null;
capacityContainers: number | null;
usedWeight: number;
usedItems: number;
occupancyPct: number | null;
}>
> {
const rows: Array<{
id: string;
name: string;
code: string;
type: string;
yardId: string;
capacityWeight: string | null;
capacityContainers: number | null;
usedWeight: number;
usedItems: number;
}> = await this.dataSource.query(
`SELECT z.id,
z.name,
z.code,
z.type,
z.yard_id AS "yardId",
z.capacity_weight AS "capacityWeight",
z.capacity_containers AS "capacityContainers",
COALESCE(SUM(inv.weight) FILTER (
WHERE inv.deleted_at IS NULL
AND inv.status NOT IN ('DELIVERED', 'DISPATCHED')
), 0)::float8 AS "usedWeight",
COALESCE(COUNT(inv.id) FILTER (
WHERE inv.deleted_at IS NULL
AND inv.status NOT IN ('DELIVERED', 'DISPATCHED')
), 0)::int AS "usedItems"
FROM freight.warehouse_zones z
LEFT JOIN freight.warehouse_inventory inv ON inv.zone_id = z.id
WHERE z.is_active = true
AND z.deleted_at IS NULL
AND ($1::uuid IS NULL OR z.yard_id = $1)
GROUP BY z.id
ORDER BY z.name`,
[yardId ?? null],
);
return rows.map((r) => {
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 =
capWeight && capWeight > 0 ? (usedWeightTons / capWeight) * 100 : null;
const byItems =
r.capacityContainers && r.capacityContainers > 0
? (r.usedItems / r.capacityContainers) * 100
: null;
// Container zones use item-count occupancy; bulk zones (no container cap)
// fall back to the now unit-correct weight occupancy.
const pct = byItems ?? byWeight;
return {
id: r.id,
name: r.name,
code: r.code,
type: r.type,
yardId: r.yardId,
capacityWeight: capWeight,
capacityContainers: r.capacityContainers,
usedWeight: r.usedWeight,
usedItems: r.usedItems,
occupancyPct: pct == null ? null : Math.round(pct * 10) / 10,
};
});
}
/**
* Recurring nudge: keep reminding self-haul IMPORT customers to assign a
* collection truck while their goods are still in the warehouse
* (READY_FOR_PICKUP) and no truck has been assigned yet. Stops once a truck is
* assigned (customer_truck_assigned_at set) or the goods leave (DELIVERED).
*/
@Cron(CronExpression.EVERY_30_MINUTES, { name: 'import-truck-assignment-reminder' })
async remindImportTruckAssignment(): Promise<void> {
try {
const rows: Array<{
bookingId: string;
companyId: string | null;
reference: string | null;
}> = await this.dataSource.query(
`SELECT DISTINCT b.id AS "bookingId",
b.company_id AS "companyId",
b.reference
FROM freight.warehouse_inventory inv
JOIN freight.bookings b ON b.id = inv.booking_id AND b.deleted_at IS NULL
WHERE inv.deleted_at IS NULL
AND inv.status = 'READY_FOR_PICKUP'
AND b.trade_direction = 'IMPORT'
AND b.customer_truck_assigned_at IS NULL
AND COALESCE(NULLIF(TRIM(b.last_mile_delivery_address), ''), '') = ''`,
);
if (!rows.length) return;
this.logger.log(
`Import truck-assignment reminder: ${rows.length} booking(s) awaiting a collection truck`,
);
for (const row of rows) {
await this.notifyTruckAssignmentNeeded(
{
companyId: row.companyId,
reference: row.reference,
hasFirstMile: false,
hasLastMile: false,
customerTruckAssignedAt: null,
},
row.bookingId,
);
}
} catch (err) {
this.logger.warn(
`Import truck-assignment reminder tick failed: ${(err as Error).message}`,
);
}
}
private async notifyTruckAssignmentNeeded(booking: {
companyId?: string | null;
reference?: string | null;
@@ -1601,6 +1773,8 @@ export class WarehouseInventoryService {
status: 'UNLOADED',
unloadedAt: now,
arrivedAt: existing.arrivedAt ?? now,
// Import GRN is issued automatically at train unload.
...(existing.grnNumber ? {} : { grnNumber: this.generateGrnNumber('IMPORT', booking.id, now) }),
});
await this.activityLog.record({
activityType: 'INVENTORY_UNLOADED',
@@ -1634,6 +1808,7 @@ export class WarehouseInventoryService {
quantity: 1,
weight: Number(booking.weight) || 0,
status: 'UNLOADED',
grnNumber: this.generateGrnNumber('IMPORT', booking.id, now),
arrivedAt: now,
unloadedAt: now,
notes: allocated?.rule ? `Unloaded → ${allocated.path}` : 'Unloaded from arrived import train',
@@ -2761,11 +2936,12 @@ export class WarehouseInventoryService {
const rows: Array<{ containerNumber: string; weightTons: string }> =
await this.dataSource.query(
`SELECT bcu.container_number AS "containerNumber",
COALESCE(bcu.vgm_tons, 0) AS "weightTons"
MAX(COALESCE(bcu.vgm_tons, 0)) AS "weightTons"
FROM freight.booking_container_units bcu
JOIN freight.booking_container bc
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
WHERE bc.booking_id = $1 AND bcu.deleted_at IS NULL
GROUP BY bcu.container_number
ORDER BY bcu.container_number`,
[bookingId],
);
@@ -2998,16 +3174,20 @@ export class WarehouseInventoryService {
async approveDeliveryForBooking(
bookingId: string,
userId?: string,
signerName?: string,
): Promise<{ bookingId: string; inventoryId: string; approvedAt: string; signerDisplayName: string }> {
if (!userId) {
throw new BadRequestException('Authentication is required to approve delivery');
}
const signature = await this.signatures.getForUser(userId);
if (!signature?.signatureImageUrl) {
throw new BadRequestException('Please save your signature before approving delivery');
const name = signerName?.trim();
if (!name) {
throw new BadRequestException('Please enter your full name to approve 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<{
id: string;
warehouseId: string | null;
@@ -3042,8 +3222,8 @@ export class WarehouseInventoryService {
const approvedAt = new Date();
const approval = {
approvedAt: approvedAt.toISOString(),
signerDisplayName: signature.signerDisplayName,
signatureImageUrl: signature.signatureImageUrl,
signerDisplayName: name,
signatureImageUrl: signature?.signatureImageUrl ?? null,
userId,
};
const existingNotes = this.stripCustomerDeliveryApproval(item.notes);
@@ -3058,8 +3238,8 @@ export class WarehouseInventoryService {
activityType: 'INVENTORY_RELEASED',
inventoryId: item.id,
warehouseId: item.warehouseId,
description: `Customer approved delivery as ${signature.signerDisplayName}`,
performedBy: signature.signerDisplayName,
description: `Customer approved delivery as ${name}`,
performedBy: name,
},
manager,
);
@@ -3067,13 +3247,13 @@ export class WarehouseInventoryService {
// Sign the structured handover record(s) for this booking (self-haul: before
// the truck leaves). Kept alongside the legacy approval note.
await this.handover.signForBooking(bookingId, userId);
await this.handover.signForBooking(bookingId, userId, name);
return {
bookingId,
inventoryId: item.id,
approvedAt: approval.approvedAt,
signerDisplayName: signature.signerDisplayName,
signerDisplayName: name,
};
}
@@ -3092,6 +3272,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

@@ -2,6 +2,8 @@ import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query, Res }
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
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 { GenerateInvoiceDto, PayInvoiceBodyDto } from './dto/invoice.dto';
import { WarehouseInvoiceService } from './warehouse-invoice.service';
@@ -13,12 +15,14 @@ export class WarehouseInvoiceController {
constructor(private readonly invoiceService: WarehouseInvoiceService) {}
@Post('warehouse-inventory/:id/generate-fee-invoice')
@BookingStaff(FREIGHT_PERMS.warehouseFeeInvoices.generate)
@ApiOperation({ summary: 'Generate a warehouse fee invoice from Batch 5 fee calculation' })
generate(@Param('id', ParseUUIDPipe) id: string, @Body() dto: GenerateInvoiceDto) {
return this.invoiceService.generateForInventory(id, dto);
}
@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)' })
generateTruckDetention(
@Param('id', ParseUUIDPipe) id: string,
@@ -28,6 +32,7 @@ export class WarehouseInvoiceController {
}
@Get('warehouse-inventory/:id/fee-invoices')
@BookingStaff(FREIGHT_PERMS.warehouseFeeInvoices.view)
@ApiOperation({ summary: 'List fee invoices for an inventory item' })
listForInventory(@Param('id', ParseUUIDPipe) id: string) {
return this.invoiceService.listForInventory(id);
@@ -40,6 +45,7 @@ export class WarehouseInvoiceController {
}
@Get('warehouse-fee-invoices')
@BookingStaff(FREIGHT_PERMS.warehouseFeeInvoices.view)
@ApiOperation({ summary: 'List / filter warehouse fee invoices' })
findAll(
@Query('status') status?: string,
@@ -86,12 +92,14 @@ export class WarehouseInvoiceController {
}
@Patch('warehouse-fee-invoices/:id/cancel')
@BookingStaff(FREIGHT_PERMS.warehouseFeeInvoices.cancel)
@ApiOperation({ summary: 'Cancel a warehouse fee invoice' })
cancel(@Param('id', ParseUUIDPipe) id: string) {
return this.invoiceService.cancel(id);
}
@Post('warehouse-fee-invoices/:id/pay')
@BookingStaff(FREIGHT_PERMS.warehouseFeeInvoices.pay)
@ApiOperation({ summary: 'Record a payment against a warehouse fee invoice' })
pay(@Param('id', ParseUUIDPipe) id: string, @Body() dto: PayInvoiceBodyDto) {
return this.invoiceService.pay(id, dto);

View File

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

View File

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

View File

@@ -1,12 +1,15 @@
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch } from '@nestjs/common';
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 { WarehouseZonesService } from './warehouse-zones.service';
@ApiTags('warehouse-zones')
@ApiBearerAuth()
@Controller('warehouse-zones')
@BookingStaff(FREIGHT_PERMS.warehouseZones.view)
export class WarehouseZonesController {
constructor(private readonly zonesService: WarehouseZonesService) {}
@@ -23,6 +26,7 @@ export class WarehouseZonesController {
}
@Patch(':id')
@BookingStaff(FREIGHT_PERMS.warehouseZones.update)
@ApiOperation({ summary: 'Update warehouse zone' })
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateWarehouseZoneDto) {
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 { 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 { CreateWarehouseYardDto } from './dto/create-warehouse-yard.dto';
import { FilterWarehouseDto } from './dto/filter-warehouse.dto';
@@ -12,6 +14,7 @@ import { WarehousesService } from './warehouses.service';
@ApiTags('warehouses')
@ApiBearerAuth()
@Controller('warehouses')
@BookingStaff(FREIGHT_PERMS.warehouses.view)
export class WarehousesController {
constructor(
private readonly warehousesService: WarehousesService,
@@ -26,12 +29,14 @@ export class WarehousesController {
}
@Get('dashboard')
@BookingStaff(FREIGHT_PERMS.warehouseDashboard.view)
@ApiOperation({ summary: 'Warehouse dashboard metrics' })
dashboard() {
return this.dashboardService.getDashboard();
}
@Post()
@BookingStaff(FREIGHT_PERMS.warehouses.create)
@ApiOperation({ summary: 'Create warehouse' })
create(@Body() dto: CreateWarehouseDto) {
return this.warehousesService.create(dto);
@@ -44,18 +49,21 @@ export class WarehousesController {
}
@Patch(':id')
@BookingStaff(FREIGHT_PERMS.warehouses.update)
@ApiOperation({ summary: 'Update warehouse' })
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateWarehouseDto) {
return this.warehousesService.update(id, dto);
}
@Get(':warehouseId/yards')
@BookingStaff(FREIGHT_PERMS.warehouseYards.view)
@ApiOperation({ summary: 'List yards within a warehouse' })
listYards(@Param('warehouseId', ParseUUIDPipe) warehouseId: string) {
return this.yardsService.findByWarehouse(warehouseId);
}
@Post(':warehouseId/yards')
@BookingStaff(FREIGHT_PERMS.warehouseYards.create)
@ApiOperation({ summary: 'Create a yard within a warehouse' })
createYard(
@Param('warehouseId', ParseUUIDPipe) warehouseId: string,