Cleaned warehouse related UI and made all 15 pages consisitent

This commit is contained in:
Hagernesh
2026-07-11 06:50:33 +00:00
parent 290f013525
commit eec3d1863a
14 changed files with 985 additions and 361 deletions

View File

@@ -93,6 +93,7 @@ import { FirstMileModule } from "./modules/first-mile/first-mile.module";
import { LastMileModule } from "./modules/last-mile/last-mile.module";
import { InterchangeDocumentsModule } from "./modules/interchange-documents/interchange-documents.module";
import { ImportOperationsModule } from "./modules/import-operations/import-operations.module";
import { AiModule } from "./modules/ai/ai.module";
import { LoggerMiddleware } from "./logger.middleware";
@Module({
@@ -188,6 +189,7 @@ import { LoggerMiddleware } from "./logger.middleware";
ImportOperationsModule,
VerifaydaModule,
FleetHistoryModule,
AiModule,
],
providers: [
EdrOrgSeeder,

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;
}