mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 04:20:55 +00:00
gMerge branch 'dev' of github.com:Tria-plc/edr-platform into dev
This commit is contained in:
31
apps/edr-freight-api/src/modules/ai/ai.controller.ts
Normal file
31
apps/edr-freight-api/src/modules/ai/ai.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
11
apps/edr-freight-api/src/modules/ai/ai.module.ts
Normal file
11
apps/edr-freight-api/src/modules/ai/ai.module.ts
Normal 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 {}
|
||||
@@ -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;
|
||||
}
|
||||
277
apps/edr-freight-api/src/modules/ai/mock-ai.service.ts
Normal file
277
apps/edr-freight-api/src/modules/ai/mock-ai.service.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
62
apps/edr-freight-api/src/modules/auth/account.controller.ts
Normal file
62
apps/edr-freight-api/src/modules/auth/account.controller.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { Body, Controller, Patch, Post, UseGuards } from "@nestjs/common";
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
import { CurrentUser } from "@tria-plc/api-common/modules/auth/decorators/current-user.decorator";
|
||||
import { JwtGuard } from "@tria-plc/api-common/modules/auth/services/jwt.guard";
|
||||
import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type";
|
||||
|
||||
import { AccountService } from "./account.service";
|
||||
import {
|
||||
SendContactOtpDto,
|
||||
UpdateAccountNameDto,
|
||||
UpdateContactDto,
|
||||
} from "./dto/account.dto";
|
||||
|
||||
/**
|
||||
* The caller's own account record. Everything here is scoped to the JWT's user
|
||||
* id — there is no `:id` parameter to tamper with, so these routes need no
|
||||
* permission key beyond being authenticated.
|
||||
*/
|
||||
@ApiTags("auth")
|
||||
@Controller("me")
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtGuard)
|
||||
export class AccountController {
|
||||
constructor(private readonly accountService: AccountService) {}
|
||||
|
||||
@Post("contact/otp")
|
||||
@ApiOperation({
|
||||
summary: "Send a verification code to a new email/phone before changing it",
|
||||
description:
|
||||
"The code goes to the NEW value supplied here, proving the caller controls " +
|
||||
"it. Returns the target masked — an unverified caller never gets it back in full.",
|
||||
})
|
||||
sendContactOtp(
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
@Body() dto: SendContactOtpDto,
|
||||
): Promise<{ sentTo: string }> {
|
||||
return this.accountService.sendContactOtp(user.id, dto);
|
||||
}
|
||||
|
||||
@Patch("contact")
|
||||
@ApiOperation({
|
||||
summary: "Change the account's email or phone, gated by a verification code",
|
||||
description:
|
||||
"Verifies the code and writes the new value in one call, so the API never " +
|
||||
"has to take a client's word that verification happened.",
|
||||
})
|
||||
updateContact(
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
@Body() dto: UpdateContactDto,
|
||||
): Promise<{ success: true; value: string }> {
|
||||
return this.accountService.updateContact(user.id, dto);
|
||||
}
|
||||
|
||||
@Patch("name")
|
||||
@ApiOperation({ summary: "Change the account's display name" })
|
||||
updateName(
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
@Body() dto: UpdateAccountNameDto,
|
||||
): Promise<{ success: true }> {
|
||||
return this.accountService.updateName(user.id, dto);
|
||||
}
|
||||
}
|
||||
226
apps/edr-freight-api/src/modules/auth/account.service.ts
Normal file
226
apps/edr-freight-api/src/modules/auth/account.service.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
Logger,
|
||||
} from "@nestjs/common";
|
||||
import { InjectDataSource, InjectRepository } from "@nestjs/typeorm";
|
||||
import { DataSource, EntityManager, Repository } from "typeorm";
|
||||
import { isValidPhoneNumber } from "libphonenumber-js";
|
||||
|
||||
import { EUserVerifiedBy } from "@tria-plc/api-common/utils/enums/user.enum";
|
||||
import type { TCurrentTokenUser } from "@tria-plc/iamapi-common/types/current-user.type";
|
||||
import { Employee } from "@tria-plc/iamapi-common/entities/iam/organization-structure/employee.entity";
|
||||
import { Session } from "@tria-plc/iamapi-common/entities/iam/user/session.entity";
|
||||
import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity";
|
||||
|
||||
import { normalizeE164 } from "../../common/validators/is-phone-number.validator";
|
||||
import { OtpService, OtpTarget } from "../otp/otp.service";
|
||||
import {
|
||||
ContactChannel,
|
||||
SendContactOtpDto,
|
||||
UpdateAccountNameDto,
|
||||
UpdateContactDto,
|
||||
} from "./dto/account.dto";
|
||||
import { maskOtpTarget } from "./mask-target.util";
|
||||
|
||||
/** How long a contact-change code stays valid before it must be re-requested. */
|
||||
const CONTACT_OTP_TTL_MS = 10 * 60 * 1000;
|
||||
|
||||
/** Postgres unique-violation SQLSTATE. */
|
||||
const PG_UNIQUE_VIOLATION = "23505";
|
||||
|
||||
/**
|
||||
* Self-serve management of the caller's own IAM user record.
|
||||
*
|
||||
* IAM ships `PATCH /api/auth/update-profile`, but it takes email + username +
|
||||
* phone + name all at once (every field `@IsNotEmpty`) and performs no
|
||||
* verification — it will move an account's phone to any number the caller
|
||||
* types. These routes exist so a contact change is *proven*: the code goes to
|
||||
* the NEW address and the write only lands once it comes back.
|
||||
*/
|
||||
@Injectable()
|
||||
export class AccountService {
|
||||
private readonly logger = new Logger(AccountService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(User)
|
||||
private readonly userRepository: Repository<User>,
|
||||
@InjectDataSource()
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly otpService: OtpService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Send a code to the address the caller wants to move TO. Sending to the new
|
||||
* value (rather than the one on file) is the whole point — it proves control
|
||||
* of the destination before anything is written.
|
||||
*/
|
||||
async sendContactOtp(
|
||||
userId: string,
|
||||
dto: SendContactOtpDto,
|
||||
): Promise<{ sentTo: string }> {
|
||||
const value = this.normalize(dto.channel, dto.value);
|
||||
await this.assertNotTaken(dto.channel, value, userId);
|
||||
|
||||
const target = this.targetFor(dto.channel, value);
|
||||
await this.otpService.sendOtp(target);
|
||||
|
||||
return { sentTo: maskOtpTarget(target) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify the code, then write the new contact value. The verify and the write
|
||||
* are one call: the API never has to trust that a client "already verified"
|
||||
* — unlike the signup flow, where the OTP is client-orchestrated and
|
||||
* `POST /api/otp/verify` is a separate public route the client may simply skip.
|
||||
*/
|
||||
async updateContact(
|
||||
userId: string,
|
||||
dto: UpdateContactDto,
|
||||
): Promise<{ success: true; value: string }> {
|
||||
const value = this.normalize(dto.channel, dto.value);
|
||||
await this.assertNotTaken(dto.channel, value, userId);
|
||||
|
||||
await this.otpService.verifyOtpForAction(
|
||||
this.targetFor(dto.channel, value),
|
||||
dto.otp,
|
||||
CONTACT_OTP_TTL_MS,
|
||||
);
|
||||
|
||||
const isEmail = dto.channel === ContactChannel.Email;
|
||||
const userPatch = isEmail
|
||||
? { email: value }
|
||||
: {
|
||||
phoneNumber: value,
|
||||
// The number just passed an OTP, which is exactly what IAM's own
|
||||
// phone-verification flag means. Set it here so the freight app stops
|
||||
// needing its own parallel "verified phone" bookkeeping.
|
||||
isPhoneNumberVerified: true,
|
||||
verifiedBy: EUserVerifiedBy.PHONE_NUMBER,
|
||||
};
|
||||
const sessionPatch: Partial<TCurrentTokenUser> = isEmail
|
||||
? { email: value }
|
||||
: { phoneNumber: value, isPhoneNumberVerified: true };
|
||||
|
||||
try {
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
await manager.getRepository(User).update({ id: userId }, userPatch);
|
||||
await this.refreshSessions(manager, userId, sessionPatch);
|
||||
});
|
||||
} catch (error) {
|
||||
throw this.asConflict(error, dto.channel);
|
||||
}
|
||||
|
||||
this.logger.log(`Account ${dto.channel} updated for user ${userId}`);
|
||||
return { success: true, value };
|
||||
}
|
||||
|
||||
/** Rename the account. No OTP — a name change proves nothing and grants nothing. */
|
||||
async updateName(
|
||||
userId: string,
|
||||
dto: UpdateAccountNameDto,
|
||||
): Promise<{ success: true }> {
|
||||
const en = dto.name.en?.trim();
|
||||
const name = { am: dto.name.am.trim(), ...(en ? { en } : {}) };
|
||||
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
await manager.getRepository(User).update({ id: userId }, { name });
|
||||
// IAM mirrors the name onto the employee row. Portal customers are
|
||||
// `individual` users with no employee row at all, so this is a no-op for
|
||||
// them — hence an unconditional update() rather than a lookup-then-write.
|
||||
await manager.getRepository(Employee).update({ userId }, { name });
|
||||
await this.refreshSessions(manager, userId, { name });
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* `GET /api/auth/me` serves `session.userInfo` — a snapshot IAM writes only
|
||||
* when a session is created at login. Without patching it here, a saved change
|
||||
* stays invisible to /me (and to anything reading the token's claims) until the
|
||||
* user logs out and back in, which reads as "my edit didn't save".
|
||||
*/
|
||||
private async refreshSessions(
|
||||
manager: EntityManager,
|
||||
userId: string,
|
||||
patch: Partial<TCurrentTokenUser>,
|
||||
): Promise<void> {
|
||||
const repo = manager.getRepository(Session);
|
||||
const sessions = await repo.find({ where: { userId } });
|
||||
|
||||
await Promise.all(
|
||||
sessions.map((session) =>
|
||||
repo.update(
|
||||
{ id: session.id },
|
||||
{ userInfo: { ...session.userInfo, ...patch } },
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** Canonicalise for the channel and reject anything malformed up front. */
|
||||
private normalize(channel: ContactChannel, value: string): string {
|
||||
const raw = value.trim();
|
||||
|
||||
if (channel === ContactChannel.Email) {
|
||||
const email = raw.toLowerCase();
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||
throw new BadRequestException("A valid email address is required");
|
||||
}
|
||||
return email;
|
||||
}
|
||||
|
||||
if (!isValidPhoneNumber(raw)) {
|
||||
throw new BadRequestException(
|
||||
"A valid international phone number is required (E.164, e.g. +251911223344)",
|
||||
);
|
||||
}
|
||||
// Store the same canonical form the OTP is keyed by, so the code sent here
|
||||
// is findable on verify regardless of how the number was typed.
|
||||
return normalizeE164(raw) as string;
|
||||
}
|
||||
|
||||
private targetFor(channel: ContactChannel, value: string): OtpTarget {
|
||||
return channel === ContactChannel.Email ? { email: value } : { phone: value };
|
||||
}
|
||||
|
||||
/**
|
||||
* `iam.users.email` and `.phone_number` are each independently UNIQUE, so a
|
||||
* collision would otherwise surface as a raw 500 at write time. This is a
|
||||
* courtesy check, not the guard — it races, so {@link asConflict} still has to
|
||||
* catch the violation.
|
||||
*/
|
||||
private async assertNotTaken(
|
||||
channel: ContactChannel,
|
||||
value: string,
|
||||
userId: string,
|
||||
): Promise<void> {
|
||||
const existing = await this.userRepository.findOne({
|
||||
where:
|
||||
channel === ContactChannel.Email
|
||||
? { email: value }
|
||||
: { phoneNumber: value },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (existing && existing.id !== userId) {
|
||||
throw this.takenError(channel);
|
||||
}
|
||||
}
|
||||
|
||||
private asConflict(error: unknown, channel: ContactChannel): Error {
|
||||
const code = (error as { code?: string } | null)?.code;
|
||||
if (code === PG_UNIQUE_VIOLATION) return this.takenError(channel);
|
||||
return error as Error;
|
||||
}
|
||||
|
||||
private takenError(channel: ContactChannel): ConflictException {
|
||||
return new ConflictException(
|
||||
channel === ContactChannel.Email
|
||||
? "That email address is already registered to another account"
|
||||
: "That phone number is already registered to another account",
|
||||
);
|
||||
}
|
||||
}
|
||||
60
apps/edr-freight-api/src/modules/auth/dto/account.dto.ts
Normal file
60
apps/edr-freight-api/src/modules/auth/dto/account.dto.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import {
|
||||
IsEnum,
|
||||
IsNotEmpty,
|
||||
IsObject,
|
||||
IsOptional,
|
||||
IsString,
|
||||
ValidateNested,
|
||||
} from "class-validator";
|
||||
|
||||
/** The contact channel being changed on the caller's own account. */
|
||||
export enum ContactChannel {
|
||||
Email = "email",
|
||||
Phone = "phone",
|
||||
}
|
||||
|
||||
export class SendContactOtpDto {
|
||||
@ApiProperty({ enum: ContactChannel })
|
||||
@IsEnum(ContactChannel)
|
||||
channel!: ContactChannel;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
"The NEW email or phone to verify. The code is sent here, not to the " +
|
||||
"address currently on the account — that is what proves the caller " +
|
||||
"controls the number/inbox they are moving to.",
|
||||
example: "+251911223344",
|
||||
})
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
value!: string;
|
||||
}
|
||||
|
||||
export class UpdateContactDto extends SendContactOtpDto {
|
||||
@ApiProperty({ description: "The 6-digit code sent to the new value" })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
otp!: string;
|
||||
}
|
||||
|
||||
export class AccountNameDto {
|
||||
@ApiProperty({ description: "Amharic name", example: "አበበ በቀለ" })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
am!: string;
|
||||
|
||||
@ApiPropertyOptional({ description: "English name", example: "Abebe Bekele" })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
en?: string;
|
||||
}
|
||||
|
||||
export class UpdateAccountNameDto {
|
||||
@ApiProperty({ type: AccountNameDto })
|
||||
@IsObject()
|
||||
@ValidateNested()
|
||||
@Type(() => AccountNameDto)
|
||||
name!: AccountNameDto;
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { UserVerification } from "@tria-plc/iamapi-common/entities/iam/user/user
|
||||
|
||||
import { OtpService, OtpTarget } from "../otp/otp.service";
|
||||
import { ResetChannel } from "./dto/forgot-password.dto";
|
||||
import { maskOtpTarget } from "./mask-target.util";
|
||||
|
||||
/**
|
||||
* How long the reset ticket minted for `PATCH /api/auth/set-password` stays
|
||||
@@ -158,12 +159,6 @@ export class ForgotPasswordService {
|
||||
|
||||
/** `+251911234567` -> `+251•••••4567`; `ab@x.com` -> `a•@x.com`. */
|
||||
maskTarget(target: OtpTarget): string {
|
||||
if (target.email) {
|
||||
const [local, domain] = target.email.split("@");
|
||||
const head = local.slice(0, 1);
|
||||
return `${head}${"•".repeat(Math.max(local.length - 1, 1))}@${domain}`;
|
||||
}
|
||||
const phone = target.phone ?? "";
|
||||
return `${phone.slice(0, 4)}${"•".repeat(Math.max(phone.length - 8, 1))}${phone.slice(-4)}`;
|
||||
return maskOtpTarget(target);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { Employee } from '@tria-plc/iamapi-common/entities/iam/organization-structure/employee.entity';
|
||||
import { Session } from '@tria-plc/iamapi-common/entities/iam/user/session.entity';
|
||||
import { User } from '@tria-plc/iamapi-common/entities/iam/user/user.entity';
|
||||
import { UserVerification } from '@tria-plc/iamapi-common/entities/iam/user/user-verification.entity';
|
||||
|
||||
import { ExternalProfile } from '../companies/entities/external-profile.entity';
|
||||
import { OtpModule } from '../otp/otp.module';
|
||||
import { AccountController } from './account.controller';
|
||||
import { AccountService } from './account.service';
|
||||
import { CheckAvailabilityController } from './check-availability.controller';
|
||||
import { CheckAvailabilityService } from './check-availability.service';
|
||||
import { CustomerResetController } from './customer-reset.controller';
|
||||
@@ -17,17 +21,25 @@ import { FreightMeService } from './freight-me.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([User, UserVerification, ExternalProfile]),
|
||||
TypeOrmModule.forFeature([
|
||||
User,
|
||||
UserVerification,
|
||||
ExternalProfile,
|
||||
Session,
|
||||
Employee,
|
||||
]),
|
||||
OtpModule,
|
||||
],
|
||||
controllers: [
|
||||
FreightMeController,
|
||||
AccountController,
|
||||
CheckAvailabilityController,
|
||||
ForgotPasswordController,
|
||||
CustomerResetController,
|
||||
],
|
||||
providers: [
|
||||
FreightMeService,
|
||||
AccountService,
|
||||
CheckAvailabilityService,
|
||||
ForgotPasswordService,
|
||||
CustomerResetService,
|
||||
|
||||
16
apps/edr-freight-api/src/modules/auth/mask-target.util.ts
Normal file
16
apps/edr-freight-api/src/modules/auth/mask-target.util.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { OtpTarget } from "../otp/otp.service";
|
||||
|
||||
/**
|
||||
* Mask an OTP target for echoing back to the caller: `+251911234567` ->
|
||||
* `+251•••••4567`; `ab@x.com` -> `a•@x.com`. Never return an unmasked target to
|
||||
* a caller who has not yet proven possession of the channel.
|
||||
*/
|
||||
export function maskOtpTarget(target: OtpTarget): string {
|
||||
if (target.email) {
|
||||
const [local, domain] = target.email.split("@");
|
||||
const head = local.slice(0, 1);
|
||||
return `${head}${"•".repeat(Math.max(local.length - 1, 1))}@${domain}`;
|
||||
}
|
||||
const phone = target.phone ?? "";
|
||||
return `${phone.slice(0, 4)}${"•".repeat(Math.max(phone.length - 8, 1))}${phone.slice(-4)}`;
|
||||
}
|
||||
@@ -376,4 +376,97 @@ describe("BillingService.expirePayable — locked write runs in a transaction",
|
||||
expect(result).toBeNull();
|
||||
expect(transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("also retires a DRAFT invoice — a superseded/cancelled source must not leave one behind", async () => {
|
||||
const { service, defaultManager } = build({
|
||||
...openInvoice,
|
||||
status: Freight.InvoiceStatus.Draft,
|
||||
});
|
||||
|
||||
await service.expirePayable(
|
||||
Freight.InvoiceSource.Booking,
|
||||
"booking-1",
|
||||
"prepaid",
|
||||
);
|
||||
|
||||
const { where } = defaultManager.findOne.mock.calls[0][1];
|
||||
expect(where.status.value).toContain(Freight.InvoiceStatus.Draft);
|
||||
});
|
||||
});
|
||||
|
||||
describe("BillingService.issuePayable", () => {
|
||||
const dueAt = new Date("2026-01-02T00:00:00.000Z");
|
||||
|
||||
const build = (found: Record<string, unknown> | null) => {
|
||||
const manager = {
|
||||
findOne: jest.fn().mockResolvedValue(found),
|
||||
update: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const service = new BillingService(
|
||||
{ manager, transaction: jest.fn() } as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
makeEvents() as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
);
|
||||
return { service, manager };
|
||||
};
|
||||
|
||||
const issue = (service: BillingService) =>
|
||||
service.issuePayable(
|
||||
Freight.InvoiceSource.Booking,
|
||||
"booking-1",
|
||||
dueAt,
|
||||
"PREPAID",
|
||||
);
|
||||
|
||||
it("issues a DRAFT invoice to PENDING, stamping issuedAt and the pay-window dueAt", async () => {
|
||||
const { service, manager } = build({
|
||||
id: "inv-1",
|
||||
invoiceNumber: "INV-20260101-00001",
|
||||
status: Freight.InvoiceStatus.Draft,
|
||||
issuedAt: null,
|
||||
});
|
||||
|
||||
const result = await issue(service);
|
||||
|
||||
const patch = manager.update.mock.calls[0][2];
|
||||
expect(patch.status).toBe(Freight.InvoiceStatus.Pending);
|
||||
expect(patch.dueAt).toBe(dueAt);
|
||||
expect(patch.issuedAt).toBeInstanceOf(Date);
|
||||
expect(result?.status).toBe(Freight.InvoiceStatus.Pending);
|
||||
});
|
||||
|
||||
it("looks up DRAFT invoices — a booking's invoice is minted DRAFT and this is what makes it payable", async () => {
|
||||
const { service, manager } = build(null);
|
||||
|
||||
await issue(service);
|
||||
|
||||
const { where } = manager.findOne.mock.calls[0][1];
|
||||
expect(where.status.value).toContain(Freight.InvoiceStatus.Draft);
|
||||
});
|
||||
|
||||
it("only refreshes dueAt on an already-issued invoice, so a re-reserve never re-issues", async () => {
|
||||
const issuedAt = new Date("2026-01-01T00:00:00.000Z");
|
||||
const { service, manager } = build({
|
||||
id: "inv-1",
|
||||
invoiceNumber: "INV-20260101-00001",
|
||||
status: Freight.InvoiceStatus.Pending,
|
||||
issuedAt,
|
||||
});
|
||||
|
||||
const result = await issue(service);
|
||||
|
||||
expect(manager.update.mock.calls[0][2]).toEqual({ dueAt });
|
||||
expect(result?.issuedAt).toBe(issuedAt);
|
||||
});
|
||||
|
||||
it("is a no-op (returns null, writes nothing) when the source has no draft-or-open invoice", async () => {
|
||||
const { service, manager } = build(null);
|
||||
|
||||
await expect(issue(service)).resolves.toBeNull();
|
||||
expect(manager.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -125,7 +125,7 @@ export class BillingService {
|
||||
private readonly payment: PaymentService,
|
||||
private readonly companies: CompaniesService,
|
||||
private readonly invoiceDocuments: InvoiceDocumentService,
|
||||
) {}
|
||||
) { }
|
||||
|
||||
// ── Reads ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -432,7 +432,7 @@ export class BillingService {
|
||||
input.dueAt ??
|
||||
new Date(
|
||||
Date.now() +
|
||||
(input.dueInDays ?? DEFAULT_DUE_DAYS) * 24 * 60 * 60 * 1000,
|
||||
(input.dueInDays ?? DEFAULT_DUE_DAYS) * 24 * 60 * 60 * 1000,
|
||||
);
|
||||
|
||||
const invoiceNumber = await this.nextInvoiceNumber(mg);
|
||||
@@ -602,6 +602,19 @@ export class BillingService {
|
||||
if (invoice.status === Freight.InvoiceStatus.Paid) {
|
||||
throw new BadRequestException("Invoice is already fully paid.");
|
||||
}
|
||||
// M27: a Draft invoice is not yet issued and an Expired invoice's pay
|
||||
// window has closed — neither is payable. Without these guards a payment
|
||||
// could settle an unissued draft or a lapsed invoice.
|
||||
if (invoice.status === Freight.InvoiceStatus.Draft) {
|
||||
throw new BadRequestException(
|
||||
"Cannot pay a draft invoice — it must be issued first.",
|
||||
);
|
||||
}
|
||||
if (invoice.status === Freight.InvoiceStatus.Expired) {
|
||||
throw new BadRequestException(
|
||||
"Cannot pay an expired invoice — its payment window has closed.",
|
||||
);
|
||||
}
|
||||
if (round2(input.amount) > Number(invoice.balanceAmount)) {
|
||||
throw new BadRequestException(
|
||||
`Payment of ${round2(input.amount)} exceeds the outstanding balance of ${Number(invoice.balanceAmount)}.`,
|
||||
@@ -813,8 +826,15 @@ export class BillingService {
|
||||
* Expire a source's currently-open invoice (its pay window closed before
|
||||
* settlement), then emit `${source}.invoice.expired`. Resolves the open invoice
|
||||
* and transitions it to EXPIRED — a terminal, non-payable status (kept out of
|
||||
* `OPEN_STATUSES`). No-op (returns null) when the source has no open invoice
|
||||
* (already paid/cancelled/expired).
|
||||
* `OPEN_STATUSES`). No-op (returns null) when the source has no invoice left to
|
||||
* retire (already paid/cancelled/expired).
|
||||
*
|
||||
* DRAFT invoices are matched too, even though they were never issued: this is
|
||||
* also the "retire the invoice this source no longer needs" path (a cancelled
|
||||
* booking, or a full-amount invoice superseded by a partial-offer one). Skipping
|
||||
* drafts would leave the stale one behind for `findPayable` to hand back — the
|
||||
* superseding invoice would then never be minted, and a cancelled booking would
|
||||
* keep a draft that a later `issuePayable` could still make payable.
|
||||
*
|
||||
* Pass the caller's transaction `manager` (e.g. the booking pay-window expiry in
|
||||
* the batch engine) to enlist in its DB transaction.
|
||||
@@ -837,7 +857,7 @@ export class BillingService {
|
||||
where: {
|
||||
source,
|
||||
sourceId,
|
||||
status: In(OPEN_STATUSES),
|
||||
status: In([Freight.InvoiceStatus.Draft, ...OPEN_STATUSES]),
|
||||
...(type ? { type } : {}),
|
||||
},
|
||||
order: { issuedAt: "DESC" },
|
||||
@@ -854,30 +874,58 @@ export class BillingService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync a source's open invoice `dueAt` to its real pay-window deadline. The
|
||||
* booking invoice is generated before the pay window opens (at booking
|
||||
* creation/approval), so its printed due date is refreshed when the batch engine
|
||||
* sets `paymentDeadline`. No-op when the source has no open invoice.
|
||||
* Issue a source's invoice and stamp its real pay-window deadline — the single
|
||||
* transition that makes a source payable.
|
||||
*
|
||||
* A source's invoice is minted DRAFT, before any pay window exists (e.g. a
|
||||
* booking invoice is generated at creation / operation-accept, long before the
|
||||
* batch engine reserves a slot). DRAFT is deliberately outside `OPEN_STATUSES`,
|
||||
* so such an invoice is not settleable and the portal renders no pay button.
|
||||
* The domain calls this at the moment the pay window actually opens (booking →
|
||||
* `reserve`, which sets SELECTED_FOR_BATCH + `paymentDeadline`), which issues
|
||||
* the draft (→ PENDING, stamping `issuedAt`) and prints the real `dueAt`.
|
||||
*
|
||||
* Idempotent: an already-issued open invoice only has its `dueAt` refreshed, so
|
||||
* a re-reserve never re-issues. No-op (returns null) when the source has no
|
||||
* draft-or-open invoice (already paid/cancelled/expired).
|
||||
*/
|
||||
async syncPayableDueDate(
|
||||
async issuePayable(
|
||||
source: Freight.InvoiceSource,
|
||||
sourceId: string,
|
||||
dueAt: Date,
|
||||
type?: string,
|
||||
manager?: EntityManager,
|
||||
): Promise<void> {
|
||||
): Promise<Invoice | null> {
|
||||
const mg = manager ?? this.dataSource.manager;
|
||||
const invoice = await mg.findOne(Invoice, {
|
||||
where: {
|
||||
source,
|
||||
sourceId,
|
||||
status: In(OPEN_STATUSES),
|
||||
status: In([Freight.InvoiceStatus.Draft, ...OPEN_STATUSES]),
|
||||
...(type ? { type } : {}),
|
||||
},
|
||||
order: { issuedAt: "DESC" },
|
||||
});
|
||||
if (!invoice) return;
|
||||
await mg.update(Invoice, { id: invoice.id }, { dueAt });
|
||||
if (!invoice) return null;
|
||||
|
||||
const issuing = invoice.status === Freight.InvoiceStatus.Draft;
|
||||
const patch = {
|
||||
dueAt,
|
||||
...(issuing
|
||||
? {
|
||||
status: Freight.InvoiceStatus.Pending,
|
||||
issuedAt: invoice.issuedAt ?? new Date(),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
await mg.update(Invoice, { id: invoice.id }, patch);
|
||||
|
||||
if (issuing) {
|
||||
this.logger.log(
|
||||
`Issued invoice ${invoice.invoiceNumber} (${invoice.id}) for ${source}:${sourceId} — payable until ${dueAt.toISOString()}`,
|
||||
);
|
||||
}
|
||||
return { ...invoice, ...patch } as Invoice;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -891,6 +939,20 @@ export class BillingService {
|
||||
status: Freight.InvoiceStatus,
|
||||
manager?: EntityManager,
|
||||
): Promise<void> {
|
||||
// M27: this is the blunt "issue a draft" override — it stamps `issuedAt` but
|
||||
// does NOT touch paidAmount/balanceAmount. Its only legitimate use is the
|
||||
// Draft → Pending/Issued issue transition. It must NEVER mark an invoice
|
||||
// Paid/Refunded/Cancelled/Expired (or PartiallyPaid/Overdue): those carry
|
||||
// balance implications and must go through the dedicated settlement methods
|
||||
// (recordPayment / markInvoiceAsRefunded / cancelInvoice / expirePayable).
|
||||
if (
|
||||
status !== Freight.InvoiceStatus.Pending &&
|
||||
status !== Freight.InvoiceStatus.Issued
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
`updateStatus only issues an invoice (→ PENDING/ISSUED); use the dedicated settlement methods to set ${status}.`,
|
||||
);
|
||||
}
|
||||
const mg = manager ?? this.dataSource.manager;
|
||||
const invoice = await mg.findOne(Invoice, {
|
||||
where: {
|
||||
@@ -954,7 +1016,7 @@ export class BillingService {
|
||||
// in the domain via `${source}.invoice.paid`. Neither billing nor the payment
|
||||
// service branches on a domain-specific reference type.
|
||||
referenceType: PaymentReferenceType.SHIPMENT,
|
||||
orderRef: invoice.invoiceNumber.replace("-", "_"),
|
||||
orderRef: invoice.invoiceNumber.replace(/-/g, "_"),
|
||||
amountMinor: Math.round(Number(invoice.balanceAmount)),
|
||||
currency: invoice.currency,
|
||||
reason: `Payment for invoice ${invoice.invoiceNumber}`,
|
||||
@@ -964,16 +1026,14 @@ export class BillingService {
|
||||
returnUrl: opts.returnUrl,
|
||||
failureUrl: opts.failureUrl,
|
||||
});
|
||||
//
|
||||
//
|
||||
// Link the intent to the invoice BEFORE any settlement can correlate against it.
|
||||
await this.dataSource
|
||||
.getRepository(Invoice)
|
||||
.update({ id: invoice.id }, { paymentId: result.intentId });
|
||||
|
||||
// DEMO: manually fire the gateway `payment.succeeded` callback here, without
|
||||
// waiting for real gateway settlement. Runs AFTER the paymentId link above so
|
||||
// `handlePaymentEvent → settleByPaymentId` can correlate the invoice. TODO:
|
||||
// remove — real settlement flips this via the `${source}.invoice.paid` handler.
|
||||
// Settlement is driven by the payment API (webhook/outbox → payment.succeeded);
|
||||
// billing must not simulate it. Kept commented for local demos only.
|
||||
if (!result.immediateSuccess) {
|
||||
await this.payment.handlePaymentEvent({
|
||||
eventType: "payment.succeeded",
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -103,8 +103,35 @@ export class PaymentController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* HTML-escape a value interpolated into the public checkout pages. These
|
||||
* pages are served unauthenticated and the interpolated values (provider
|
||||
* error messages, status strings, intent ids, redirect URLs) can carry
|
||||
* attacker-influenced input — unescaped they are a reflected-XSS sink.
|
||||
*/
|
||||
private escapeHtml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
private buildRedirectHtml(url: string): string {
|
||||
const escaped = url.replace(/\"/g, """);
|
||||
// Only http(s) URLs may be used as a redirect target — a javascript:
|
||||
// URL would execute in the victim's browser from the <a>/location.href.
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(url);
|
||||
} catch {
|
||||
return this.buildErrorHtml("Invalid payment redirect URL");
|
||||
}
|
||||
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
|
||||
return this.buildErrorHtml("Invalid payment redirect URL");
|
||||
}
|
||||
const escaped = this.escapeHtml(url);
|
||||
const jsEscaped = JSON.stringify(url);
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
@@ -126,12 +153,14 @@ export class PaymentController {
|
||||
<p>Redirecting to payment provider…</p>
|
||||
<p><a href="${escaped}">Click here if you are not redirected</a></p>
|
||||
</div>
|
||||
<script>window.location.href = "${escaped}";</script>
|
||||
<script>window.location.href = ${jsEscaped};</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
private buildStatusHtml(status: string, intentId: string): string {
|
||||
private buildStatusHtml(rawStatus: string, rawIntentId: string): string {
|
||||
const status = this.escapeHtml(rawStatus);
|
||||
const intentId = this.escapeHtml(rawIntentId);
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
@@ -153,7 +182,8 @@ export class PaymentController {
|
||||
</html>`;
|
||||
}
|
||||
|
||||
private buildErrorHtml(message: string): string {
|
||||
private buildErrorHtml(rawMessage: string): string {
|
||||
const message = this.escapeHtml(rawMessage);
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
InvoiceLineInput,
|
||||
} from "../billing/billing.service";
|
||||
import { Invoice } from "../billing/entities/invoice.entity";
|
||||
import { CLEARANCE_BOOKING_INVOICE_TYPE } from "../contracts/clearance-fee.service";
|
||||
import { FirstMileService } from "../first-mile/first-mile.service";
|
||||
import { BookingBatchService } from "../train-scheduling/booking-batch.service";
|
||||
import { PriceLineItemDto } from "./dto/generate-price-response.dto";
|
||||
@@ -119,6 +120,36 @@ export class BookingInvoiceService {
|
||||
return this.billing.updateStatus(invoiceId, status, manager);
|
||||
}
|
||||
|
||||
/**
|
||||
* Expire the booking's currently-open invoices (freight PREPAID and the
|
||||
* per-shipment clearance fee) when the booking is
|
||||
* cancelled or rejected — the counterpart to the pay-window-expiry path
|
||||
* (which also calls {@link BillingService.expirePayable}). Stops a terminated
|
||||
* booking from leaving a payable invoice open. No-op when the booking has no
|
||||
* open invoice (never invoiced, already paid/cancelled/expired). Pass a
|
||||
* caller `manager` to enlist in its transaction.
|
||||
*/
|
||||
async expireOpenInvoices(
|
||||
bookingId: string,
|
||||
manager?: EntityManager,
|
||||
): Promise<Invoice | null> {
|
||||
// The per-shipment clearance fee (GENERAL contracts) bills this same booking
|
||||
// id under its own source/type — retire it alongside the freight invoice, or
|
||||
// a cancelled shipment keeps a payable clearance invoice open.
|
||||
await this.billing.expirePayable(
|
||||
Freight.InvoiceSource.Clearance,
|
||||
bookingId,
|
||||
CLEARANCE_BOOKING_INVOICE_TYPE,
|
||||
manager,
|
||||
);
|
||||
return this.billing.expirePayable(
|
||||
Freight.InvoiceSource.Booking,
|
||||
bookingId,
|
||||
"PREPAID",
|
||||
manager,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Advance a booking once its prepaid invoice settles — the domain side-effect
|
||||
* of payment, relocated out of the payment service: the booking becomes PAID
|
||||
@@ -138,7 +169,32 @@ export class BookingInvoiceService {
|
||||
);
|
||||
return;
|
||||
}
|
||||
// if (booking.paymentStatus === "PAID") return;
|
||||
|
||||
// Idempotency + state-machine guard (restored). The prepaid-invoice paid
|
||||
// event can be delivered more than once (retries / re-emit), and a booking
|
||||
// may have moved on or been terminated between invoicing and settlement.
|
||||
// Only advance one that is still awaiting payment: no-op when already PAID,
|
||||
// and refuse to advance a booking in a terminal/advanced status
|
||||
// (CANCELLED/REJECTED/EXPIRED or already past the payment gate) so we never
|
||||
// rewrite its status or re-run allocation.
|
||||
if (booking.paymentStatus === "PAID" || booking.status === "PAID") {
|
||||
return;
|
||||
}
|
||||
const TERMINAL_OR_ADVANCED_STATUSES: string[] = [
|
||||
"CANCELLED",
|
||||
"REJECTED",
|
||||
"EXPIRED",
|
||||
"IN_TRANSIT",
|
||||
"ARRIVED",
|
||||
"COMPLETED",
|
||||
"CONTRACT_CLOSED",
|
||||
];
|
||||
if (TERMINAL_OR_ADVANCED_STATUSES.includes(booking.status)) {
|
||||
this.logger.warn(
|
||||
`Skipping advance of booking ${bookingId} on payment: status ${booking.status} is terminal/advanced.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.dataSource.transaction(async (mg) => {
|
||||
await mg.update(
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import { DataSource } from 'typeorm';
|
||||
import {
|
||||
NotificationAudience,
|
||||
NotificationType,
|
||||
@@ -8,6 +10,7 @@ import {
|
||||
import { Booking } from './entities/booking.entity';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
|
||||
import { resolveCompanyNotifyPhone } from '../notifications/resolve-company-phone.util';
|
||||
|
||||
/**
|
||||
* Customer + staff notifications for the booking lifecycle: review, clearance
|
||||
@@ -27,6 +30,8 @@ export class BookingLifecycleNotifierService {
|
||||
constructor(
|
||||
private readonly notifications: NotificationsService,
|
||||
private readonly inbox: NotificationInboxService,
|
||||
@InjectDataSource()
|
||||
private readonly dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
private ref(b: Booking): string {
|
||||
@@ -40,7 +45,9 @@ export class BookingLifecycleNotifierService {
|
||||
logLabel: string,
|
||||
): Promise<void> {
|
||||
this.logger.log(`${logLabel} — ${this.ref(b)}`);
|
||||
const phone = b.company?.contactPersonPhone ?? b.company?.phone ?? null;
|
||||
const phone = b.companyId
|
||||
? await resolveCompanyNotifyPhone(this.dataSource, b.companyId)
|
||||
: null;
|
||||
const email = b.company?.email ?? b.company?.generalManagerEmail ?? null;
|
||||
|
||||
if (phone) {
|
||||
|
||||
@@ -4,6 +4,12 @@ import type { Rate } from '../rule-engine/entities/rate.entity';
|
||||
|
||||
const MOCK_CBE_RATE = 130;
|
||||
|
||||
// Base freight is configured per leg, so every rate and every booking names the
|
||||
// route it runs. MOJO → DIRE is the corridor these rates are priced for.
|
||||
const MOJO = 'yard-mojo';
|
||||
const DIRE = 'yard-dire-dawa';
|
||||
const LEBU = 'yard-lebu';
|
||||
|
||||
describe('BookingPricingService — domestic corridor', () => {
|
||||
const intercityBulkUsd: Rate = {
|
||||
id: 'rate-intercity-bulk-usd',
|
||||
@@ -13,6 +19,8 @@ describe('BookingPricingService — domestic corridor', () => {
|
||||
rateUnit: 'PER_TON',
|
||||
status: 'LIVE',
|
||||
containerTypeId: null,
|
||||
originYardId: MOJO,
|
||||
destinationYardId: DIRE,
|
||||
} as Rate;
|
||||
|
||||
const intercityContainerUsd: Rate = {
|
||||
@@ -23,6 +31,8 @@ describe('BookingPricingService — domestic corridor', () => {
|
||||
rateUnit: 'PER_CONTAINER',
|
||||
status: 'LIVE',
|
||||
containerTypeId: null,
|
||||
originYardId: MOJO,
|
||||
destinationYardId: DIRE,
|
||||
} as Rate;
|
||||
|
||||
let service: BookingPricingService;
|
||||
@@ -56,6 +66,8 @@ describe('BookingPricingService — domestic corridor', () => {
|
||||
tradeDirection: 'DOMESTIC',
|
||||
paymentCurrency: 'ETB',
|
||||
cargoTotalWeightVgm: 120,
|
||||
originYardId: MOJO,
|
||||
destinationYardId: DIRE,
|
||||
bookingContainers: [],
|
||||
} as unknown as Booking;
|
||||
|
||||
@@ -81,6 +93,8 @@ describe('BookingPricingService — domestic corridor', () => {
|
||||
tradeDirection: 'DOMESTIC',
|
||||
paymentCurrency: 'USD',
|
||||
cargoTotalWeightVgm: 120,
|
||||
originYardId: MOJO,
|
||||
destinationYardId: DIRE,
|
||||
bookingContainers: [],
|
||||
} as unknown as Booking;
|
||||
|
||||
@@ -106,6 +120,8 @@ describe('BookingPricingService — domestic corridor', () => {
|
||||
tradeDirection: 'DOMESTIC',
|
||||
paymentCurrency: 'ETB',
|
||||
cargoTotalWeightVgm: 50,
|
||||
originYardId: MOJO,
|
||||
destinationYardId: DIRE,
|
||||
bookingContainers: [],
|
||||
} as unknown as Booking;
|
||||
|
||||
@@ -126,4 +142,59 @@ describe('BookingPricingService — domestic corridor', () => {
|
||||
const line = result.lineItems.find((l) => l.code === 'INTERCITY_CONTAINER')!;
|
||||
expect(line.currency).toBe('ETB');
|
||||
});
|
||||
|
||||
// Rates are quoted per leg, so one configured for MOJO → DIRE must not price a
|
||||
// shipment that runs LEBU → DIRE. Charging the wrong corridor's price because
|
||||
// nobody configured this one yet is worse than billing no base freight.
|
||||
it('does not price bulk off a rate configured for a different leg', async () => {
|
||||
const booking = {
|
||||
id: 'b-3',
|
||||
freightType: 'BULK',
|
||||
tradeDirection: 'DOMESTIC',
|
||||
paymentCurrency: 'USD',
|
||||
cargoTotalWeightVgm: 120,
|
||||
originYardId: LEBU,
|
||||
destinationYardId: DIRE,
|
||||
bookingContainers: [],
|
||||
} as unknown as Booking;
|
||||
|
||||
const result = await (
|
||||
service as unknown as {
|
||||
computeBaseRailLinesWithRates: (
|
||||
b: Booking,
|
||||
input: { containers: [] },
|
||||
) => Promise<{ lineItems: Array<{ amount: number }> }>;
|
||||
}
|
||||
).computeBaseRailLinesWithRates(booking, { containers: [] });
|
||||
|
||||
expect(result.lineItems).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('does not price containers off a rate configured for a different leg', async () => {
|
||||
const booking = {
|
||||
id: 'b-4',
|
||||
freightType: 'CONTAINER',
|
||||
tradeDirection: 'DOMESTIC',
|
||||
paymentCurrency: 'USD',
|
||||
cargoTotalWeightVgm: 50,
|
||||
originYardId: LEBU,
|
||||
destinationYardId: DIRE,
|
||||
bookingContainers: [],
|
||||
} as unknown as Booking;
|
||||
|
||||
const result = await (
|
||||
service as unknown as {
|
||||
computeBaseRailLinesWithRates: (
|
||||
b: Booking,
|
||||
input: {
|
||||
containers: Array<{ containerTypeId: string; quantity: number }>;
|
||||
},
|
||||
) => Promise<{ lineItems: Array<{ amount: number }> }>;
|
||||
}
|
||||
).computeBaseRailLinesWithRates(booking, {
|
||||
containers: [{ containerTypeId: 'ct-20', quantity: 3 }],
|
||||
});
|
||||
|
||||
expect(result.lineItems).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,17 +3,16 @@ import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
|
||||
import { RatesService } from '../rule-engine/services/rates.service';
|
||||
import { Rate } from '../rule-engine/entities/rate.entity';
|
||||
import { ContractRateSnapshot } from '../contracts/entities/contract-rate-snapshot.entity';
|
||||
import { ExchangeService } from '@edr/api-common';
|
||||
import {
|
||||
AppliedCargoModifier,
|
||||
BookingEvaluationInput,
|
||||
RuleEngineService,
|
||||
} from '../rule-engine/rule-engine.service';
|
||||
import { containersPerWagonForSize } from '../rule-engine/container-type.util';
|
||||
import { BookingsRepository } from './bookings.repository';
|
||||
import {
|
||||
containersPerWagon,
|
||||
wagonRemainder,
|
||||
} from './consolidation.service';
|
||||
import { wagonRemainder } from './consolidation.service';
|
||||
import { GeneratePriceResponseDto, PriceLineItemDto } from './dto/generate-price-response.dto';
|
||||
import { Booking } from './entities/booking.entity';
|
||||
import { assertBookingStatus } from './booking-status.util';
|
||||
@@ -128,11 +127,18 @@ export class BookingPricingService {
|
||||
const isEtbBooking = paymentCurrency === 'ETB';
|
||||
const usdToEtb = isEtbBooking ? await this.exchangeService.getRate('USD', 'ETB') : 1;
|
||||
|
||||
// H15: a booking created under a contract prices from that contract's FROZEN
|
||||
// rate snapshots (the agreed rates), not the live rate of the day. Loaded
|
||||
// once and threaded through the line builders; each rate code that has a
|
||||
// snapshot uses it, and any code without one falls back to the live rate.
|
||||
// Non-contract bookings resolve to null and keep the live-rate path.
|
||||
const frozenRates = await this.loadFrozenContractRates(booking);
|
||||
|
||||
const lineItems: PriceLineItemDto[] = [];
|
||||
let total = 0;
|
||||
|
||||
const { lineItems: baseLines, usedRates: baseRates } =
|
||||
await this.computeBaseRailLinesWithRates(booking, evalInput);
|
||||
await this.computeBaseRailLinesWithRates(booking, evalInput, frozenRates);
|
||||
for (const line of baseLines) {
|
||||
lineItems.push(line);
|
||||
total += line.amount;
|
||||
@@ -141,7 +147,7 @@ export class BookingPricingService {
|
||||
// First / last mile trucking — billed per the rate's unit (km / container /
|
||||
// ton / flat), only for legs the booking actually carries.
|
||||
const { lineItems: mileLines, usedRates: mileRates } =
|
||||
await this.computeFirstLastMileLines(booking, evalInput);
|
||||
await this.computeFirstLastMileLines(booking, evalInput, frozenRates);
|
||||
for (const line of mileLines) {
|
||||
lineItems.push(line);
|
||||
total += line.amount;
|
||||
@@ -153,15 +159,14 @@ export class BookingPricingService {
|
||||
|
||||
for (const mod of ruleResult.appliedModifiers) {
|
||||
const usdAmount = mod.calculatedAmount;
|
||||
const convertedAmount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount;
|
||||
|
||||
const rate = rateById.get(mod.rateId);
|
||||
const unit = rate?.rateUnit ?? 'FLAT';
|
||||
const unitUsd = rate ? Number(rate.rateValue) : usdAmount;
|
||||
const unitAmount = isEtbBooking ? Math.round(unitUsd * usdToEtb) : unitUsd;
|
||||
// Per-unit count: FLAT and PER_INVOICE are billed once (qty 1); an
|
||||
// explicit trigger (e.g. overweight tons) wins when present; otherwise
|
||||
// derive from total ÷ unit price.
|
||||
// derive from total ÷ unit price (the live unit price — a count, not a
|
||||
// currency amount, so it is snapshot-independent).
|
||||
const quantity =
|
||||
unit === 'FLAT' || unit === 'PER_INVOICE'
|
||||
? 1
|
||||
@@ -171,6 +176,26 @@ export class BookingPricingService {
|
||||
? Math.max(1, Math.round(usdAmount / unitUsd))
|
||||
: 1;
|
||||
|
||||
// H15: bill the frozen contract surcharge rate (already in the booking
|
||||
// currency) when this code has a snapshot; else keep the live amount.
|
||||
const frozen = this.frozenRateByCode(
|
||||
frozenRates,
|
||||
mod.surchargeCode,
|
||||
paymentCurrency,
|
||||
);
|
||||
const unitAmount = frozen
|
||||
? Number(frozen.unitPrice)
|
||||
: isEtbBooking
|
||||
? Math.round(unitUsd * usdToEtb)
|
||||
: unitUsd;
|
||||
const convertedAmount = frozen
|
||||
? isEtbBooking
|
||||
? Math.round(unitAmount * quantity)
|
||||
: unitAmount * quantity
|
||||
: isEtbBooking
|
||||
? Math.round(usdAmount * usdToEtb)
|
||||
: usdAmount;
|
||||
|
||||
const item: PriceLineItemDto = {
|
||||
code: mod.surchargeCode,
|
||||
description: surchargeLabel(mod.surchargeCode),
|
||||
@@ -281,7 +306,7 @@ export class BookingPricingService {
|
||||
totalVgmTons: qty * vgm,
|
||||
isReefer: ct.isReefer,
|
||||
},
|
||||
perWagon: containersPerWagon(Number(ct.wagonsPerUnit)),
|
||||
perWagon: containersPerWagonForSize(ct.sizeFt),
|
||||
quantity: qty,
|
||||
};
|
||||
}),
|
||||
@@ -328,6 +353,11 @@ export class BookingPricingService {
|
||||
// reefer quantity) applies the REEFER surcharge even for non-reefer
|
||||
// container types. ORed with per-container reefer in the engine.
|
||||
isReefer: booking.isReefer === true || (booking.isReefer as unknown) === 'true',
|
||||
// Empty-container return service (container freight only) — bills the
|
||||
// WITH_RETURN surcharge per container, like hazard/reefer.
|
||||
withReturn:
|
||||
booking.freightType === 'CONTAINER' &&
|
||||
booking.equipmentReturn === 'WITH_RETURN',
|
||||
isGovernment: booking.isGovernment,
|
||||
allowConsolidation,
|
||||
shippingLineId: booking.shippingLineId,
|
||||
@@ -419,6 +449,7 @@ export class BookingPricingService {
|
||||
private async computeBaseRailLinesWithRates(
|
||||
booking: Booking,
|
||||
evalInput: BookingEvaluationInput,
|
||||
frozenRates: Map<string, ContractRateSnapshot> | null = null,
|
||||
): Promise<{ lineItems: PriceLineItemDto[]; usedRates: Rate[] }> {
|
||||
const liveRates = await this.ratesService.findLiveRates();
|
||||
const paymentCurrency = booking.paymentCurrency;
|
||||
@@ -444,19 +475,46 @@ export class BookingPricingService {
|
||||
const wagonCount = await this.resolveWagonCount(booking);
|
||||
|
||||
for (const container of evalInput.containers) {
|
||||
const rate = this.pickRate(liveRates, rateType, container.containerTypeId, 'USD');
|
||||
const rate = this.pickRate(
|
||||
liveRates,
|
||||
rateType,
|
||||
container.containerTypeId,
|
||||
'USD',
|
||||
booking.originYardId,
|
||||
booking.destinationYardId,
|
||||
);
|
||||
if (!rate) continue;
|
||||
|
||||
usedRatesMap.set(rate.id, rate);
|
||||
const usdAmount = this.amountForRate(rate, container.quantity, wagonCount);
|
||||
const amount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount;
|
||||
const unitUsd = Number(rate.rateValue);
|
||||
// H15: frozen contract rate for this container size, when present — its
|
||||
// unitPrice is already in the booking currency (no USD→currency convert).
|
||||
const frozen = await this.frozenRateForContainer(
|
||||
frozenRates,
|
||||
container.containerTypeId,
|
||||
paymentCurrency,
|
||||
);
|
||||
let amount: number;
|
||||
let unitAmount: number;
|
||||
if (frozen) {
|
||||
unitAmount = Number(frozen.unitPrice);
|
||||
amount = this.amountForUnit(
|
||||
rate.rateUnit,
|
||||
unitAmount,
|
||||
container.quantity,
|
||||
wagonCount,
|
||||
);
|
||||
} else {
|
||||
const usdAmount = this.amountForRate(rate, container.quantity, wagonCount);
|
||||
amount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount;
|
||||
unitAmount = isEtbBooking ? Math.round(unitUsd * usdToEtb) : unitUsd;
|
||||
}
|
||||
const label = await this.containerTypeLabel(container.containerTypeId);
|
||||
lines.push({
|
||||
code: rateType,
|
||||
description: `${label} rail freight`,
|
||||
amount,
|
||||
unitAmount: isEtbBooking ? Math.round(unitUsd * usdToEtb) : unitUsd,
|
||||
unitAmount,
|
||||
unit: rate.rateUnit,
|
||||
quantity: this.effectiveUnitQuantity(rate.rateUnit, container.quantity, wagonCount),
|
||||
currency: paymentCurrency,
|
||||
@@ -464,22 +522,46 @@ export class BookingPricingService {
|
||||
}
|
||||
|
||||
if (lines.length === 0) {
|
||||
// Bulk (and any booking with no container lines) still has to price off a
|
||||
// rate configured for this leg — never one belonging to another route.
|
||||
const fallback = liveRates.find(
|
||||
(r) => r.rateType === rateType && r.currency === 'USD' && r.status === 'LIVE',
|
||||
(r) =>
|
||||
r.rateType === rateType &&
|
||||
r.currency === 'USD' &&
|
||||
r.status === 'LIVE' &&
|
||||
r.originYardId === booking.originYardId &&
|
||||
r.destinationYardId === booking.destinationYardId,
|
||||
);
|
||||
if (fallback) {
|
||||
usedRatesMap.set(fallback.id, fallback);
|
||||
const bulkTons = Number(booking.cargoTotalWeightVgm ?? 0);
|
||||
const quantity =
|
||||
isBulk && fallback.rateUnit === 'PER_TON' ? Math.max(bulkTons, 0) : 1;
|
||||
const usdAmount = this.amountForRate(fallback, quantity, wagonCount);
|
||||
const amount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount;
|
||||
const unitUsd = Number(fallback.rateValue);
|
||||
// H15: bulk freight uses the frozen BULK_FREIGHT snapshot when present.
|
||||
const frozen = isBulk
|
||||
? this.frozenRateByCode(frozenRates, 'BULK_FREIGHT', paymentCurrency)
|
||||
: null;
|
||||
let amount: number;
|
||||
let unitAmount: number;
|
||||
if (frozen) {
|
||||
unitAmount = Number(frozen.unitPrice);
|
||||
amount = this.amountForUnit(
|
||||
fallback.rateUnit,
|
||||
unitAmount,
|
||||
quantity,
|
||||
wagonCount,
|
||||
);
|
||||
} else {
|
||||
const usdAmount = this.amountForRate(fallback, quantity, wagonCount);
|
||||
amount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount;
|
||||
unitAmount = isEtbBooking ? Math.round(unitUsd * usdToEtb) : unitUsd;
|
||||
}
|
||||
lines.push({
|
||||
code: rateType,
|
||||
description: isBulk ? 'Bulk rail freight' : 'Container rail freight',
|
||||
amount,
|
||||
unitAmount: isEtbBooking ? Math.round(unitUsd * usdToEtb) : unitUsd,
|
||||
unitAmount,
|
||||
unit: fallback.rateUnit,
|
||||
quantity: this.effectiveUnitQuantity(fallback.rateUnit, quantity, wagonCount),
|
||||
currency: paymentCurrency,
|
||||
@@ -503,6 +585,7 @@ export class BookingPricingService {
|
||||
private async computeFirstLastMileLines(
|
||||
booking: Booking,
|
||||
evalInput: BookingEvaluationInput,
|
||||
frozenRates: Map<string, ContractRateSnapshot> | null = null,
|
||||
): Promise<{ lineItems: PriceLineItemDto[]; usedRates: Rate[] }> {
|
||||
const legs: Array<{ rateType: 'FIRST_MILE' | 'LAST_MILE'; label: string; active: boolean }> = [
|
||||
{
|
||||
@@ -560,18 +643,34 @@ export class BookingPricingService {
|
||||
break;
|
||||
}
|
||||
|
||||
const usdAmount = value * quantity;
|
||||
// H15: frozen mile rate (already in booking currency) when the contract
|
||||
// has one; else the live USD rate converted as before.
|
||||
const frozen = this.frozenRateByCode(
|
||||
frozenRates,
|
||||
leg.rateType,
|
||||
paymentCurrency,
|
||||
);
|
||||
let amount: number;
|
||||
let unitAmount: number;
|
||||
if (frozen) {
|
||||
unitAmount = Number(frozen.unitPrice);
|
||||
amount = isEtbBooking
|
||||
? Math.round(unitAmount * quantity)
|
||||
: unitAmount * quantity;
|
||||
} else {
|
||||
const usdAmount = value * quantity;
|
||||
amount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount;
|
||||
unitAmount = isEtbBooking ? Math.round(value * usdToEtb) : value;
|
||||
}
|
||||
// Skip legs that resolve to nothing (zero rate, or zero km / count / tons).
|
||||
if (!(usdAmount > 0)) continue;
|
||||
if (!(amount > 0)) continue;
|
||||
|
||||
const amount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount;
|
||||
const unitUsd = value;
|
||||
usedRatesMap.set(rate.id, rate);
|
||||
lines.push({
|
||||
code: leg.rateType,
|
||||
description: leg.label,
|
||||
amount,
|
||||
unitAmount: isEtbBooking ? Math.round(unitUsd * usdToEtb) : unitUsd,
|
||||
unitAmount,
|
||||
unit: rate.rateUnit,
|
||||
quantity,
|
||||
currency: paymentCurrency,
|
||||
@@ -626,39 +725,123 @@ export class BookingPricingService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Base freight is quoted per leg, so a rate only applies to a booking running
|
||||
* the exact origin → destination it was configured for. There is deliberately
|
||||
* no route-agnostic fallback: charging a Dire Dawa price for a Mojo shipment
|
||||
* because nobody configured Mojo yet is worse than surfacing no line at all.
|
||||
* Within the leg, a rate scoped to the container type wins over one that
|
||||
* covers every type.
|
||||
*/
|
||||
private pickRate(
|
||||
rates: Rate[],
|
||||
rateType: string,
|
||||
containerTypeId: string,
|
||||
currency: string,
|
||||
originYardId: string,
|
||||
destinationYardId: string,
|
||||
): Rate | undefined {
|
||||
const onLeg = rates.filter(
|
||||
(r) =>
|
||||
r.rateType === rateType &&
|
||||
r.currency === currency &&
|
||||
r.originYardId === originYardId &&
|
||||
r.destinationYardId === destinationYardId,
|
||||
);
|
||||
return (
|
||||
rates.find(
|
||||
(r) =>
|
||||
r.rateType === rateType &&
|
||||
r.currency === currency &&
|
||||
r.containerTypeId === containerTypeId,
|
||||
) ??
|
||||
rates.find((r) => r.rateType === rateType && r.currency === currency && !r.containerTypeId)
|
||||
onLeg.find((r) => r.containerTypeId === containerTypeId) ??
|
||||
onLeg.find((r) => !r.containerTypeId)
|
||||
);
|
||||
}
|
||||
|
||||
private amountForRate(rate: Rate, quantity: number, wagonCount: number): number {
|
||||
const value = Number(rate.rateValue);
|
||||
switch (rate.rateUnit) {
|
||||
return this.amountForUnit(
|
||||
rate.rateUnit,
|
||||
Number(rate.rateValue),
|
||||
quantity,
|
||||
wagonCount,
|
||||
);
|
||||
}
|
||||
|
||||
/** Apply a unit value by rate unit — shared by live and frozen-snapshot lines. */
|
||||
private amountForUnit(
|
||||
rateUnit: string,
|
||||
unitValue: number,
|
||||
quantity: number,
|
||||
wagonCount: number,
|
||||
): number {
|
||||
switch (rateUnit) {
|
||||
case 'PER_CONTAINER':
|
||||
return value * quantity;
|
||||
return unitValue * quantity;
|
||||
case 'PER_WAGON':
|
||||
return value * wagonCount;
|
||||
return unitValue * wagonCount;
|
||||
case 'PER_TON':
|
||||
return value * quantity;
|
||||
return unitValue * quantity;
|
||||
case 'FLAT':
|
||||
return value;
|
||||
return unitValue;
|
||||
default:
|
||||
return value * quantity;
|
||||
return unitValue * quantity;
|
||||
}
|
||||
}
|
||||
|
||||
// ── H15: frozen contract rate snapshots ────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Load a contract's frozen rate snapshots into a by-rate-code lookup, or null
|
||||
* for a non-contract booking (or a contract with no snapshots). The pricing
|
||||
* line builders prefer a matching snapshot's unit price over the live rate.
|
||||
*/
|
||||
private async loadFrozenContractRates(
|
||||
booking: Booking,
|
||||
): Promise<Map<string, ContractRateSnapshot> | null> {
|
||||
if (!booking.contractId) return null;
|
||||
const snapshots = await this.bookingsRepository.findContractRateSnapshots(
|
||||
booking.contractId,
|
||||
);
|
||||
if (!snapshots.length) return null;
|
||||
const byCode = new Map<string, ContractRateSnapshot>();
|
||||
for (const snap of snapshots) byCode.set(snap.rateCode, snap);
|
||||
return byCode;
|
||||
}
|
||||
|
||||
/**
|
||||
* The frozen snapshot for a rate code, or null when there is none, its price
|
||||
* is negative, or it is in a different currency than the booking (in which
|
||||
* case the live-rate path is safer than a mis-converted frozen price).
|
||||
*/
|
||||
private frozenRateByCode(
|
||||
frozenRates: Map<string, ContractRateSnapshot> | null,
|
||||
code: string,
|
||||
bookingCurrency: string,
|
||||
): ContractRateSnapshot | null {
|
||||
const snap = frozenRates?.get(code);
|
||||
if (!snap) return null;
|
||||
if (snap.currency !== bookingCurrency) return null;
|
||||
if (!(Number(snap.unitPrice) >= 0)) return null;
|
||||
return snap;
|
||||
}
|
||||
|
||||
/**
|
||||
* The frozen base-rail snapshot for a container line, matched by the
|
||||
* container's size (CONTAINER_20FT / CONTAINER_40FT — the codes
|
||||
* ContractPricingService freezes). Null when there is no snapshot.
|
||||
*/
|
||||
private async frozenRateForContainer(
|
||||
frozenRates: Map<string, ContractRateSnapshot> | null,
|
||||
containerTypeId: string,
|
||||
bookingCurrency: string,
|
||||
): Promise<ContractRateSnapshot | null> {
|
||||
if (!frozenRates) return null;
|
||||
let sizeFt: number | null = null;
|
||||
try {
|
||||
sizeFt = Number((await this.containerTypesService.findById(containerTypeId)).sizeFt) || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!sizeFt) return null;
|
||||
return this.frozenRateByCode(frozenRates, `CONTAINER_${sizeFt}FT`, bookingCurrency);
|
||||
}
|
||||
|
||||
private lineItemsSignature(items: PriceLineItemDto[]): string {
|
||||
return JSON.stringify(
|
||||
[...items]
|
||||
|
||||
@@ -110,7 +110,6 @@ export function groupContainersBySize(
|
||||
name: ct.label?.trim() ? ct.label : ct.code,
|
||||
code: ct.code,
|
||||
is_reefer: ct.isReefer ?? false,
|
||||
wagons_per_unit: Number(ct.wagonsPerUnit ?? 1),
|
||||
}),
|
||||
),
|
||||
}));
|
||||
|
||||
@@ -14,9 +14,10 @@ describe('BookingTransitionService — finalizeClearance gate', () => {
|
||||
serviceType: { includesCustoms: false }, // no output set → only the input gate
|
||||
};
|
||||
|
||||
// Input set has two required docs.
|
||||
// Input set has two required docs. Non-customs bookings resolve to the
|
||||
// ONE_TIME self-clearance document set.
|
||||
const inputSetting = {
|
||||
code: 'clearance_import_container_without_customs',
|
||||
code: 'contract_clearance_selfclear_import_container',
|
||||
fields: [
|
||||
{ fileKey: 'commercial_invoice', isRequired: true },
|
||||
{ fileKey: 'packing_list', isRequired: true },
|
||||
@@ -198,7 +199,7 @@ describe('BookingTransitionService — finalizeClearance customs output gate', (
|
||||
*/
|
||||
describe('BookingTransitionService — submitClearanceDocuments required-fields gate', () => {
|
||||
const inputSetting = {
|
||||
code: 'clearance_import_container_without_customs',
|
||||
code: 'contract_clearance_selfclear_import_container',
|
||||
fields: [
|
||||
{ fileKey: 'commercial_invoice', fileLabel: 'Commercial invoice', isRequired: true },
|
||||
{ fileKey: 'packing_list', fileLabel: 'Packing list', isRequired: true },
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { BadRequestException, ConflictException } from '@nestjs/common';
|
||||
import { BookingTransitionService } from './booking-transition.service';
|
||||
|
||||
/**
|
||||
@@ -116,3 +116,98 @@ describe('BookingTransitionService — operation review', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Export over-book gate at the customer's requestOperation step: export never
|
||||
* splits, so the free-space check runs the moment the customer commits to a
|
||||
* shipment day. When no single export train that day can carry the whole
|
||||
* booking, `pickExportSchedule` throws and the request is refused BEFORE the
|
||||
* booking moves to OPERATION_REQUEST_PENDING. Import bookings are never gated
|
||||
* here (they are batched + splittable later).
|
||||
*/
|
||||
describe('BookingTransitionService — requestOperation export space gate', () => {
|
||||
function makeService(tradeDirection: 'EXPORT' | 'IMPORT', overbook: boolean) {
|
||||
const booking = {
|
||||
id: 'b-1',
|
||||
reference: 'BKG-1',
|
||||
status: 'CLEARANCE_READY',
|
||||
tradeDirection,
|
||||
originYardId: 'o-1',
|
||||
destinationYardId: 'd-1',
|
||||
totalAmount: 1000,
|
||||
contractId: null,
|
||||
serviceType: { code: 'RAIL_CONTAINER' },
|
||||
};
|
||||
const bookingsRepository = {
|
||||
update: jest.fn().mockResolvedValue({ id: 'b-1' }),
|
||||
};
|
||||
const bookingsService = {
|
||||
findById: jest.fn().mockResolvedValue(booking),
|
||||
checkDayCompatibilityForBooking: jest
|
||||
.fn()
|
||||
.mockResolvedValue({ hasDeparture: true, hasCompatible: true }),
|
||||
};
|
||||
const bookingBatchService = {
|
||||
// Over-book → the export gate rejects; otherwise it returns a schedule id.
|
||||
pickExportSchedule: overbook
|
||||
? jest.fn().mockRejectedValue(new ConflictException('Not enough train space'))
|
||||
: jest.fn().mockResolvedValue('sched-1'),
|
||||
};
|
||||
const notifier = { operationRequestedToStaff: jest.fn() };
|
||||
|
||||
const service = new BookingTransitionService(
|
||||
bookingsRepository as never,
|
||||
{} as never, // ruleEngineService
|
||||
{} as never, // pricingService
|
||||
{} as never, // contractService
|
||||
{} as never, // filesService
|
||||
{} as never, // fileUploadSettingsService
|
||||
bookingBatchService as never,
|
||||
bookingsService as never,
|
||||
{ isPhasedGeneralCustomsBooking: () => false } as never,
|
||||
{} as never, // workflowService
|
||||
{} as never, // invoiceService
|
||||
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
|
||||
notifier as never,
|
||||
);
|
||||
return { service, bookingsRepository, bookingBatchService };
|
||||
}
|
||||
|
||||
it('rejects an over-booked export request and does NOT advance the booking', async () => {
|
||||
const { service, bookingsRepository, bookingBatchService } = makeService(
|
||||
'EXPORT',
|
||||
true,
|
||||
);
|
||||
await expect(
|
||||
service.requestOperation('b-1', '2026-07-20T00:00:00.000Z'),
|
||||
).rejects.toBeInstanceOf(ConflictException);
|
||||
expect(bookingBatchService.pickExportSchedule).toHaveBeenCalledTimes(1);
|
||||
expect(bookingsRepository.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('lets an export request through when a train fits the whole booking', async () => {
|
||||
const { service, bookingsRepository, bookingBatchService } = makeService(
|
||||
'EXPORT',
|
||||
false,
|
||||
);
|
||||
await service.requestOperation('b-1', '2026-07-20T00:00:00.000Z');
|
||||
expect(bookingBatchService.pickExportSchedule).toHaveBeenCalledTimes(1);
|
||||
expect(bookingsRepository.update).toHaveBeenCalledWith(
|
||||
'b-1',
|
||||
expect.objectContaining({ status: 'OPERATION_REQUEST_PENDING' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('never runs the export gate for an import request', async () => {
|
||||
const { service, bookingsRepository, bookingBatchService } = makeService(
|
||||
'IMPORT',
|
||||
true, // would reject IF called — proves it is not called
|
||||
);
|
||||
await service.requestOperation('b-1', '2026-07-20T00:00:00.000Z');
|
||||
expect(bookingBatchService.pickExportSchedule).not.toHaveBeenCalled();
|
||||
expect(bookingsRepository.update).toHaveBeenCalledWith(
|
||||
'b-1',
|
||||
expect.objectContaining({ status: 'OPERATION_REQUEST_PENDING' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
forwardRef,
|
||||
Inject,
|
||||
Injectable,
|
||||
@@ -32,8 +33,6 @@ import { ClearanceMilestoneService } from '../contracts/clearance-milestone.serv
|
||||
import { ClearanceWorkflowService } from '../contracts/clearance-workflow.service';
|
||||
import { ContractDocPhase } from '@edr/types';
|
||||
|
||||
|
||||
import { Freight } from "@edr/types";
|
||||
import { BookingInvoiceService } from "./booking-invoice.service";
|
||||
|
||||
@Injectable()
|
||||
@@ -533,6 +532,10 @@ export class BookingTransitionService {
|
||||
"REJECTION",
|
||||
);
|
||||
|
||||
// Stop the open-invoice leak: a cancelled booking must not leave a payable
|
||||
// invoice open. Mirror the pay-window-expiry path (billing.expirePayable).
|
||||
await this.invoiceService.expireOpenInvoices(bookingId);
|
||||
|
||||
const updated = await this.bookingsRepository.update(bookingId, {
|
||||
status: "CANCELLED",
|
||||
} as never);
|
||||
@@ -561,6 +564,10 @@ export class BookingTransitionService {
|
||||
"REJECTION",
|
||||
);
|
||||
|
||||
// Stop the open-invoice leak: a rejected booking must not leave a payable
|
||||
// invoice open. Mirror the pay-window-expiry path (billing.expirePayable).
|
||||
await this.invoiceService.expireOpenInvoices(bookingId);
|
||||
|
||||
const updated = await this.bookingsRepository.update(bookingId, {
|
||||
status: "REJECTED",
|
||||
} as never);
|
||||
@@ -714,6 +721,11 @@ export class BookingTransitionService {
|
||||
files: Express.Multer.File[],
|
||||
): Promise<Booking> {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
if (booking.status === "AWAITING_CLEARANCE_PAYMENT") {
|
||||
throw new ConflictException(
|
||||
"The customs clearance service fee for this shipment has not been paid yet — pay it from the portal to unlock document upload.",
|
||||
);
|
||||
}
|
||||
assertBookingStatus(booking, [
|
||||
"AWAITING_DOCUMENTS",
|
||||
"DOCUMENTS_UNDER_REVIEW",
|
||||
@@ -988,24 +1000,57 @@ export class BookingTransitionService {
|
||||
"OPERATION_CHANGES_REQUESTED",
|
||||
]);
|
||||
|
||||
// A bare initiated instance (clearance-first flow) carries no cargo or
|
||||
// price — it must go through the contract completion endpoint, which
|
||||
// persists cargo, prices, invoices and only then lands here itself.
|
||||
if (booking.contractId && !(Number(booking.totalAmount) > 0)) {
|
||||
throw new BadRequestException(
|
||||
"This booking must be completed (cargo and shipment day) before requesting operation.",
|
||||
);
|
||||
}
|
||||
|
||||
const date = new Date(scheduledDate);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
throw new BadRequestException("A valid schedule date is required");
|
||||
}
|
||||
|
||||
// The binding shipment day must have at least one OPEN departure on the
|
||||
// route — only schedule-backed days are selectable. The batch engine
|
||||
// assigns the specific train within that (route, day) pool later.
|
||||
const hasDeparture = await this.bookingsService.hasOpenDepartureOnDay(
|
||||
booking.originYardId,
|
||||
booking.destinationYardId,
|
||||
eatDay(date),
|
||||
);
|
||||
// route — only schedule-backed days are selectable — AND some departure
|
||||
// that day must be able to physically carry this cargo type (wagon-TYPE
|
||||
// gate; quantity never blocks — oversized bookings get a partial split
|
||||
// offer). The batch engine assigns the specific train within that
|
||||
// (route, day) pool later.
|
||||
const { hasDeparture, hasCompatible } =
|
||||
await this.bookingsService.checkDayCompatibilityForBooking(
|
||||
booking,
|
||||
eatDay(date),
|
||||
);
|
||||
if (!hasDeparture) {
|
||||
throw new BadRequestException(
|
||||
"No departures available on the selected day for this route",
|
||||
);
|
||||
}
|
||||
if (!hasCompatible) {
|
||||
throw new BadRequestException(
|
||||
"No wagon on the selected day can carry this cargo type — please choose another day",
|
||||
);
|
||||
}
|
||||
|
||||
// Export is FCFS and never splits — a booking must ride one train whole. So
|
||||
// the free-space check belongs HERE, the moment the customer commits to a
|
||||
// shipment day, not later at staff operation-accept. Blocking now stops the
|
||||
// customer booking more wagons than any single export train that day can
|
||||
// still carry; `exportSpaceReport` throws a 409 whose message carries the
|
||||
// largest bookable leftover ("reduce to N wagons or pick another day").
|
||||
// Import/domestic bookings are batched + splittable, so they are NOT gated
|
||||
// here — they get an advisory count below and the batch engine sizes them.
|
||||
const scheduledBooking = { ...booking, scheduledDate: date } as Booking;
|
||||
const isExportTrain =
|
||||
booking.tradeDirection === "EXPORT" &&
|
||||
!isRoadService(booking.serviceType);
|
||||
if (isExportTrain) {
|
||||
await this.bookingBatchService.pickExportSchedule(scheduledBooking);
|
||||
}
|
||||
|
||||
await this.bookingsRepository.update(bookingId, {
|
||||
status: "OPERATION_REQUEST_PENDING",
|
||||
@@ -1016,6 +1061,47 @@ export class BookingTransitionService {
|
||||
return fresh;
|
||||
}
|
||||
|
||||
/**
|
||||
* Advisory availability for a shipment day the customer is considering — a
|
||||
* planning hint for the day picker, computed but never enforced. For EXPORT it
|
||||
* mirrors the real request-time gate: `fits` is whether a single open train
|
||||
* that day can carry the WHOLE booking (export never splits), and `freeWagons`
|
||||
* is the largest single-train leftover. For IMPORT/DOMESTIC `freeWagons` is the
|
||||
* TOTAL room across the day's trains for the booking's wagon type (the batch
|
||||
* engine may still split or defer a remainder), and `fits` is whether that
|
||||
* total covers the booking. `trainsForDay` is false when no departure carries
|
||||
* the leg — the day is unbookable regardless of space.
|
||||
*/
|
||||
async dayAvailabilityForBooking(
|
||||
bookingId: string,
|
||||
scheduledDate: string,
|
||||
): Promise<{ fits: boolean; freeWagons: number; trainsForDay: boolean }> {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
const date = new Date(scheduledDate);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
throw new BadRequestException("A valid schedule date is required");
|
||||
}
|
||||
const day = eatDay(date);
|
||||
const isExportTrain =
|
||||
booking.tradeDirection === "EXPORT" &&
|
||||
!isRoadService(booking.serviceType);
|
||||
|
||||
if (isExportTrain) {
|
||||
const scheduledBooking = { ...booking, scheduledDate: date } as Booking;
|
||||
const report =
|
||||
await this.bookingBatchService.exportSpaceReport(scheduledBooking);
|
||||
return {
|
||||
fits: report.scheduleId != null,
|
||||
freeWagons: report.bestAvailable?.wagons ?? 0,
|
||||
trainsForDay: report.trainsForDay && report.corridorMatched,
|
||||
};
|
||||
}
|
||||
|
||||
const { freeWagons, need, trainsForDay } =
|
||||
await this.bookingBatchService.dayImportAvailability(booking, day);
|
||||
return { fits: freeWagons >= need, freeWagons, trainsForDay };
|
||||
}
|
||||
|
||||
/**
|
||||
* Operations team reviews a pending operation request (capacity, documents,
|
||||
* route). Two outcomes:
|
||||
@@ -1081,14 +1167,25 @@ export class BookingTransitionService {
|
||||
await this.bookingBatchService.pickExportSchedule(booking);
|
||||
}
|
||||
|
||||
// Mint the booking's invoice (DRAFT) so the priced order carries its billing
|
||||
// record from accept onward. It is deliberately NOT issued here: accepting an
|
||||
// operation only puts the booking in the batch holding pool — no slot has been
|
||||
// offered and no pay window exists yet. Issuing at this point made the invoice
|
||||
// payable straight away (portal invoice list/detail gate on invoice status
|
||||
// alone), letting a customer pay before being selected for a batch, while the
|
||||
// booking page correctly still showed it as not payable. The batch engine
|
||||
// issues it in `reserve` (SELECTED_FOR_BATCH), which is where the pay window
|
||||
// and the real deadline are created — matching the portal's `canPay` gate.
|
||||
const invoice = await this.invoiceService.ensureInvoiceForBooking(booking);
|
||||
this.logger.log(
|
||||
`Generated invoice ${invoice.invoiceNumber} (${invoice.id}) for ${booking.reference}:${booking.id}`,
|
||||
);
|
||||
await this.invoiceService.updateStatus(
|
||||
invoice.id,
|
||||
Freight.InvoiceStatus.Pending,
|
||||
`Generated draft invoice ${invoice.invoiceNumber} (${invoice.id}) for ${booking.reference}:${booking.id} — issued on batch selection`,
|
||||
);
|
||||
// TODO: road (truck) orders are an incomplete feature — they stop at the
|
||||
// dead-end ROAD_DISPATCH_PENDING status below (no dispatch transition, no
|
||||
// per-km pricing wired via roadKmPrice, no pay surface in the portal). They
|
||||
// skip the train batch, so they never reach `reserve` and their invoice stays
|
||||
// DRAFT / unpayable. When the road flow is built, issue its invoice
|
||||
// (billing.issuePayable) at whatever transition opens the road pay window.
|
||||
if (isRoadService(booking.serviceType)) {
|
||||
await this.bookingsRepository.update(booking.id, {
|
||||
status: "ROAD_DISPATCH_PENDING",
|
||||
|
||||
@@ -348,6 +348,53 @@ export class BookingsController {
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Get(':id/available-days')
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Days bookable for THIS booking (cargo-aware wagon-TYPE gate; days only, no capacity counts)',
|
||||
})
|
||||
async availableDays(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
const booking = await this.bookingsService.findById(id);
|
||||
if (
|
||||
!hasFreightPermission(user, FREIGHT_PERMS.bookings.view) &&
|
||||
!hasFreightPermission(user, FREIGHT_PERMS.bookings.clearanceView)
|
||||
) {
|
||||
await this.bookingsService.assertCustomerCanAccessBooking(
|
||||
user?.id,
|
||||
booking,
|
||||
);
|
||||
}
|
||||
return this.bookingsService.availableDaysForBooking(id);
|
||||
}
|
||||
|
||||
@Get(':id/day-availability')
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Advisory free-wagon count for a shipment day (planning hint, not enforced). ' +
|
||||
'Export: whole-booking fit + largest single-train leftover. ' +
|
||||
'Import/domestic: total room across the day for the booking\'s wagon type.',
|
||||
})
|
||||
async dayAvailability(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Query('date') date: string,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
const booking = await this.bookingsService.findById(id);
|
||||
if (
|
||||
!hasFreightPermission(user, FREIGHT_PERMS.bookings.view) &&
|
||||
!hasFreightPermission(user, FREIGHT_PERMS.bookings.clearanceView)
|
||||
) {
|
||||
await this.bookingsService.assertCustomerCanAccessBooking(
|
||||
user?.id,
|
||||
booking,
|
||||
);
|
||||
}
|
||||
return this.transitionService.dayAvailabilityForBooking(id, date);
|
||||
}
|
||||
|
||||
@Get(':id/mile-summary')
|
||||
@ApiOperation({
|
||||
summary: 'First/last-mile operational summary for a booking (customer-safe)',
|
||||
|
||||
@@ -47,6 +47,7 @@ import { ContractPdfService } from '../../contracts/contract-pdf.service';
|
||||
import { ContractsModule } from '../contracts/contracts.module';
|
||||
import { BookingContainerAllocation } from "./entities/booking-container-allocation.entity";
|
||||
import { ContractPricingScheduleBuilder } from "../../contracts/contract-pricing-schedule.builder";
|
||||
import { ContractRateScheduleBuilder } from "../../contracts/contract-rate-schedule.builder";
|
||||
import { ContractRendererService } from "../../contracts/contract-renderer.service";
|
||||
import { ContractTemplateResolver } from "../../contracts/contract-template.resolver";
|
||||
import { ContractViewModelBuilder } from "../../contracts/contract-view-model.builder";
|
||||
@@ -106,6 +107,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
|
||||
ContractTemplateResolver,
|
||||
ContractViewModelBuilder,
|
||||
ContractPricingScheduleBuilder,
|
||||
ContractRateScheduleBuilder,
|
||||
ContractRendererService,
|
||||
ContractPdfService,
|
||||
CustomerTruckAssignmentsRepository,
|
||||
@@ -118,6 +120,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
|
||||
BookingPricingService,
|
||||
BookingInvoiceService,
|
||||
BookingLifecycleNotifierService,
|
||||
BookingTransitionService,
|
||||
ConsolidationService,
|
||||
CustomerTruckService,
|
||||
ContainerReceiptService,
|
||||
|
||||
@@ -7,6 +7,7 @@ function mockQueryBuilder() {
|
||||
const qb = {
|
||||
leftJoinAndSelect: jest.fn().mockReturnThis(),
|
||||
leftJoin: jest.fn().mockReturnThis(),
|
||||
addSelect: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
orderBy: jest.fn().mockReturnThis(),
|
||||
@@ -15,6 +16,8 @@ function mockQueryBuilder() {
|
||||
take: jest.fn().mockReturnThis(),
|
||||
getMany: jest.fn(),
|
||||
getManyAndCount: jest.fn().mockResolvedValue([[], 0]),
|
||||
getCount: jest.fn().mockResolvedValue(0),
|
||||
getRawAndEntities: jest.fn().mockResolvedValue({ entities: [], raw: [] }),
|
||||
};
|
||||
return qb;
|
||||
}
|
||||
|
||||
@@ -4,8 +4,10 @@ import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { DataSource, EntityManager, FindOptionsWhere, In, Repository, SelectQueryBuilder } from 'typeorm';
|
||||
|
||||
import { wagonsPerUnitForSize } from '../rule-engine/container-type.util';
|
||||
import { ContainerType } from '../rule-engine/entities/container-type.entity';
|
||||
import { Contract } from '../contracts/entities/contract.entity';
|
||||
import { ContractRateSnapshot } from '../contracts/entities/contract-rate-snapshot.entity';
|
||||
import { ContractRoute } from '../contracts/entities/contract-route.entity';
|
||||
import { BookingApprovalStep } from './entities/booking-approval-step.entity';
|
||||
import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity';
|
||||
@@ -148,7 +150,7 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
|
||||
for (const item of containers) {
|
||||
const ct = await typeRepo.findOne({ where: { id: item.containerTypeId } });
|
||||
const wagonsPerUnit = ct ? Number(ct.wagonsPerUnit) : 1;
|
||||
const wagonsPerUnit = wagonsPerUnitForSize(ct?.sizeFt);
|
||||
const totalVgm = item.quantity * item.vgmPerUnitTons;
|
||||
const wagonsRequired = Math.ceil(item.quantity * wagonsPerUnit);
|
||||
// A per-line breakdown can never exceed the line's own quantity.
|
||||
@@ -178,7 +180,10 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
async calculateWagonCount(bookingId: string): Promise<number> {
|
||||
const result = await this.dataSource
|
||||
.createQueryBuilder()
|
||||
.select('CEILING(SUM(bc.quantity * ct.wagons_per_unit))', 'total')
|
||||
.select(
|
||||
'CEILING(SUM(bc.quantity * CASE WHEN ct.size_ft >= 40 THEN 1 WHEN ct.size_ft > 0 THEN 0.5 ELSE 1 END))',
|
||||
'total',
|
||||
)
|
||||
.from(BookingContainer, 'bc')
|
||||
.innerJoin(ContainerType, 'ct', 'ct.id = bc.container_type_id')
|
||||
.where('bc.booking_id = :bookingId', { bookingId })
|
||||
@@ -200,6 +205,19 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
return Number(route?.km ?? 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Frozen contract unit-rate snapshots for a contract (H15). A booking created
|
||||
* under a contract prices from these agreed, frozen rates rather than the live
|
||||
* rate of the day; the pricing service matches them by rate code.
|
||||
*/
|
||||
findContractRateSnapshots(
|
||||
contractId: string,
|
||||
): Promise<ContractRateSnapshot[]> {
|
||||
return this.dataSource
|
||||
.getRepository(ContractRateSnapshot)
|
||||
.find({ where: { contractId } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Find another booking whose container quantity complements this one to fill whole wagon(s)
|
||||
* (same route, same container type, partial wagon on both sides). Only 20ft lines ever
|
||||
@@ -217,10 +235,12 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
quantity: number;
|
||||
containersPerWagon: number;
|
||||
},
|
||||
manager?: EntityManager,
|
||||
): Promise<Booking | null> {
|
||||
const { containerTypeId, quantity, containersPerWagon: perWagon } = slot;
|
||||
|
||||
const qb = this.repository
|
||||
const repo = manager ? manager.getRepository(Booking) : this.repository;
|
||||
const qb = repo
|
||||
.createQueryBuilder('b')
|
||||
.innerJoinAndSelect('b.bookingContainers', 'bc')
|
||||
.innerJoin('bc.containerType', 'ct')
|
||||
@@ -257,7 +277,18 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
);
|
||||
}
|
||||
|
||||
return qb.orderBy('b.createdAt', 'ASC').getOne();
|
||||
qb.orderBy('b.createdAt', 'ASC');
|
||||
|
||||
// H9: under the caller's transaction, take a write lock on the matched
|
||||
// partner booking row (FOR UPDATE OF b — booking rows only, not the joined
|
||||
// reference tables) so a concurrent consolidation cannot claim the same
|
||||
// partner between this find and the pair write. Only when a transaction
|
||||
// manager is supplied — a pessimistic lock requires an open transaction.
|
||||
if (manager) {
|
||||
qb.setLock('pessimistic_write', undefined, ['b']);
|
||||
}
|
||||
|
||||
return qb.getOne();
|
||||
}
|
||||
|
||||
/** Try each partial-wagon line until a complementary partner booking is found. */
|
||||
@@ -268,9 +299,14 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
quantity: number;
|
||||
containersPerWagon: number;
|
||||
}>,
|
||||
manager?: EntityManager,
|
||||
): Promise<Booking | null> {
|
||||
for (const slot of slots) {
|
||||
const partner = await this.findComplementaryConsolidationPartner(booking, slot);
|
||||
const partner = await this.findComplementaryConsolidationPartner(
|
||||
booking,
|
||||
slot,
|
||||
manager,
|
||||
);
|
||||
if (partner) return partner;
|
||||
}
|
||||
return null;
|
||||
@@ -308,6 +344,63 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
} as never);
|
||||
}
|
||||
|
||||
/**
|
||||
* Race-safe pairing (H9): the transactional counterpart of
|
||||
* {@link pairConsolidation}. Must run inside the caller's transaction
|
||||
* (`manager`), which should already hold the partner-row write lock taken by
|
||||
* {@link findComplementaryConsolidationPartner}. Re-reads both rows and
|
||||
* re-asserts `consolidationPartnerId IS NULL` on each before writing; returns
|
||||
* `false` (no write) when either booking was already paired by a concurrent
|
||||
* flow, so the caller can fall back to parking.
|
||||
*/
|
||||
async pairConsolidationIfUnpaired(
|
||||
bookingId: string,
|
||||
partnerId: string,
|
||||
manager: EntityManager,
|
||||
): Promise<boolean> {
|
||||
const repo = manager.getRepository(Booking);
|
||||
// Sequential (one connection per transaction) — never Promise.all here.
|
||||
const booking = await repo.findOne({
|
||||
where: { id: bookingId },
|
||||
select: {
|
||||
id: true,
|
||||
consolidationPartnerId: true,
|
||||
consolidationResumeStatus: true,
|
||||
},
|
||||
});
|
||||
const partner = await repo.findOne({
|
||||
where: { id: partnerId },
|
||||
select: {
|
||||
id: true,
|
||||
consolidationPartnerId: true,
|
||||
consolidationResumeStatus: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Re-assert both are still unpaired before writing (the partner row is held
|
||||
// under the finder's write lock, so its state is stable here).
|
||||
if (
|
||||
!booking ||
|
||||
!partner ||
|
||||
booking.consolidationPartnerId != null ||
|
||||
partner.consolidationPartnerId != null
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await repo.update(bookingId, {
|
||||
consolidationPartnerId: partnerId,
|
||||
status: booking.consolidationResumeStatus ?? 'SUBMITTED',
|
||||
consolidationResumeStatus: null,
|
||||
} as never);
|
||||
await repo.update(partnerId, {
|
||||
consolidationPartnerId: bookingId,
|
||||
status: partner.consolidationResumeStatus ?? 'SUBMITTED',
|
||||
consolidationResumeStatus: null,
|
||||
} as never);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Park a booking that needs consolidation but has no partner yet. The optional
|
||||
* resumeStatus is where the booking returns once it pairs — pass it for a
|
||||
@@ -605,6 +698,7 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
async findAllPaginated(options: BookingListFilterOptions & {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
search?: string;
|
||||
sortBy?: string;
|
||||
sortOrder?: 'ASC' | 'DESC';
|
||||
}): Promise<{
|
||||
@@ -640,6 +734,16 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
|
||||
this.applyListFilters(qb, options);
|
||||
|
||||
// Free-text search spans joined columns (company, contract) that only this
|
||||
// list query joins — so it lives here, not in applyListFilters (shared
|
||||
// with getListSummaryMetrics, whose query builder has no joins).
|
||||
if (options.search) {
|
||||
qb.andWhere(
|
||||
'(booking.reference ILIKE :search OR company.name ILIKE :search OR contract.reference ILIKE :search)',
|
||||
{ search: `%${options.search}%` },
|
||||
);
|
||||
}
|
||||
|
||||
if (options.sortBy === 'isGovernment') {
|
||||
qb.orderBy('booking.isGovernment', 'DESC')
|
||||
.addOrderBy('booking.priorityScore', 'DESC')
|
||||
@@ -804,9 +908,16 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
});
|
||||
}
|
||||
if (options.bookingType) {
|
||||
qb.andWhere('booking.bookingType = :bookingType', {
|
||||
bookingType: options.bookingType,
|
||||
});
|
||||
// The stored booking_type column is 'ONE_TIME' for every row (contract
|
||||
// drawdowns included — see contract-booking.service create), so the
|
||||
// one-time vs general split keys on the denormalized contract_kind:
|
||||
// GENERAL_CONTRACT tab = bookings under a GENERAL contract, ONE_TIME tab
|
||||
// = everything else (ONE_TIME contracts and legacy contract-less rows).
|
||||
if (options.bookingType === 'GENERAL_CONTRACT') {
|
||||
qb.andWhere("booking.contract_kind = 'GENERAL'");
|
||||
} else {
|
||||
qb.andWhere("booking.contract_kind IS DISTINCT FROM 'GENERAL'");
|
||||
}
|
||||
}
|
||||
if (options.createdFrom) {
|
||||
qb.andWhere('booking.created_at >= :createdFrom', {
|
||||
@@ -1237,10 +1348,11 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
destinationYard: true,
|
||||
// units carry the real per-container numbers entered at booking time —
|
||||
// the wagon plan shows those instead of generated placeholders.
|
||||
// containerType.wagonType + cargoType.wagonType drive wagon-type
|
||||
// resolution during scheduling (FK, not the old load-type string map).
|
||||
bookingContainers: { containerType: { wagonType: true }, units: true },
|
||||
cargoType: { wagonType: true },
|
||||
// containerType.wagonTypes + cargoType.wagonTypes drive wagon-type
|
||||
// resolution during scheduling (many-to-many lists — the plan mixes
|
||||
// wagon types within one consist).
|
||||
bookingContainers: { containerType: { wagonTypes: true }, units: true },
|
||||
cargoType: { wagonTypes: true },
|
||||
},
|
||||
order: { priorityScore: 'DESC', createdAt: 'ASC' },
|
||||
});
|
||||
@@ -1251,7 +1363,12 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
fields: Partial<
|
||||
Pick<
|
||||
Booking,
|
||||
'schedulingStatus' | 'wagonsRequired' | 'scheduledAt' | 'holdStartedAt' | 'holdExpiresAt'
|
||||
| 'schedulingStatus'
|
||||
| 'wagonsRequired'
|
||||
| 'scheduledAt'
|
||||
| 'holdStartedAt'
|
||||
| 'holdExpiresAt'
|
||||
| 'trainScheduleId'
|
||||
>
|
||||
>,
|
||||
manager?: EntityManager,
|
||||
|
||||
@@ -18,6 +18,7 @@ import { TrainSchedulingService } from '../train-scheduling/train-scheduling.ser
|
||||
import { eatDay } from '../train-scheduling/batch-window.util';
|
||||
import { FilesService } from '../files/files.service';
|
||||
import { MinioService } from '../minio/minio.service';
|
||||
import { wagonsPerUnitForSize } from '../rule-engine/container-type.util';
|
||||
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
|
||||
import {
|
||||
BookingEvaluationInput,
|
||||
@@ -31,6 +32,7 @@ import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
|
||||
import { Yard } from '../rule-engine/entities/yard.entity';
|
||||
import { ServiceType } from '../rule-engine/entities/service-type.entity';
|
||||
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
||||
import { Contract } from '../contracts/entities/contract.entity';
|
||||
import { BookingsRepository } from './bookings.repository';
|
||||
import { ConsolidationService } from './consolidation.service';
|
||||
import { VehiclesService } from '../vehicles/vehicles.service';
|
||||
@@ -437,7 +439,7 @@ export class BookingsService {
|
||||
vgmPerUnitTons: c.vgmPerUnitTons,
|
||||
totalVgmTons,
|
||||
isReefer: ct.isReefer,
|
||||
wagonsRequired: c.quantity * (Number(ct.wagonsPerUnit) || 1),
|
||||
wagonsRequired: c.quantity * wagonsPerUnitForSize(ct.sizeFt),
|
||||
};
|
||||
}),
|
||||
);
|
||||
@@ -507,13 +509,28 @@ export class BookingsService {
|
||||
return { booking, messages };
|
||||
}
|
||||
|
||||
const partner = await this.bookingsRepository.findConsolidationPartner(
|
||||
booking,
|
||||
slots,
|
||||
);
|
||||
// H9: find + pair must be atomic. Run both inside one transaction where the
|
||||
// finder holds a write lock on the candidate partner row and pairing
|
||||
// re-asserts both rows are still unpaired before writing — otherwise two
|
||||
// concurrent bookings can claim the same partner (or pair an
|
||||
// already-paired booking). `didPair` is false when a concurrent flow won
|
||||
// the partner, in which case we fall through to parking below.
|
||||
const partner = await this.dataSource.transaction(async (manager) => {
|
||||
const candidate = await this.bookingsRepository.findConsolidationPartner(
|
||||
booking,
|
||||
slots,
|
||||
manager,
|
||||
);
|
||||
if (!candidate) return null;
|
||||
const didPair = await this.bookingsRepository.pairConsolidationIfUnpaired(
|
||||
booking.id,
|
||||
candidate.id,
|
||||
manager,
|
||||
);
|
||||
return didPair ? candidate : null;
|
||||
});
|
||||
|
||||
if (partner) {
|
||||
await this.bookingsRepository.pairConsolidation(booking.id, partner.id);
|
||||
const paired = await this.findById(booking.id);
|
||||
messages.push(
|
||||
this.consolidationService.describePaired(partner.reference, slots),
|
||||
@@ -652,22 +669,37 @@ export class BookingsService {
|
||||
} else if (dto.scheduledDate) {
|
||||
// A real (binding) scheduledDate was supplied (e.g. staff pinning a day
|
||||
// directly). Require that the route has at least one OPEN departure on
|
||||
// that EAT day. The booking wizard does NOT send scheduledDate at creation
|
||||
// — it captures a non-binding estimatedShipmentDate instead, and the
|
||||
// binding day is chosen later at the operation-request step. General
|
||||
// contracts also skip this (each drawdown order validates its own day).
|
||||
// that EAT day AND that some departure that day can physically carry the
|
||||
// cargo (wagon-TYPE gate — quantity never blocks; oversized bookings get
|
||||
// a partial split offer later). The booking wizard does NOT send
|
||||
// scheduledDate at creation — it captures a non-binding
|
||||
// estimatedShipmentDate instead, and the binding day is chosen later at
|
||||
// the operation-request step. General contracts also skip this (each
|
||||
// drawdown order validates its own day).
|
||||
const day = eatDay(new Date(dto.scheduledDate));
|
||||
const hasDeparture =
|
||||
await this.trainSchedulingService.existsOpenScheduleOnRouteDay(
|
||||
const { hasDeparture, hasCompatible } =
|
||||
await this.trainSchedulingService.checkDayCargoCompatibility(
|
||||
dto.originYardId,
|
||||
dto.destinationYardId,
|
||||
day,
|
||||
{
|
||||
freightType: dto.freightType as 'CONTAINER' | 'BULK',
|
||||
cargoTypeId: dto.cargoTypeId,
|
||||
containerTypeIds: (dto.containers ?? [])
|
||||
.map((c) => c.containerTypeId)
|
||||
.filter((id): id is string => Boolean(id)),
|
||||
},
|
||||
);
|
||||
if (!hasDeparture) {
|
||||
throw new BadRequestException(
|
||||
'No departures available on the selected day for this route',
|
||||
);
|
||||
}
|
||||
if (!hasCompatible) {
|
||||
throw new BadRequestException(
|
||||
'No wagon on the selected day can carry this cargo type — please choose another day',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const containers = dto.containers ?? [];
|
||||
@@ -1148,6 +1180,52 @@ export class BookingsService {
|
||||
);
|
||||
}
|
||||
|
||||
/** Cargo identity of a booking for the wagon-TYPE compatibility gate. */
|
||||
private cargoIdentityOf(booking: Booking): {
|
||||
freightType: 'CONTAINER' | 'BULK';
|
||||
cargoTypeId?: string | null;
|
||||
containerTypeIds?: string[];
|
||||
} {
|
||||
return {
|
||||
freightType: booking.freightType as 'CONTAINER' | 'BULK',
|
||||
cargoTypeId: booking.cargoTypeId ?? null,
|
||||
containerTypeIds: (booking.bookingContainers ?? [])
|
||||
.map((line) => line.containerTypeId)
|
||||
.filter((id): id is string => Boolean(id)),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Day gate for a specific booking: OPEN departure exists AND some departure
|
||||
* that day can physically carry the booking's cargo/container type.
|
||||
* Quantity never blocks — oversized bookings get a partial split offer.
|
||||
*/
|
||||
async checkDayCompatibilityForBooking(
|
||||
booking: Booking,
|
||||
day: string,
|
||||
): Promise<{ hasDeparture: boolean; hasCompatible: boolean }> {
|
||||
return this.trainSchedulingService.checkDayCargoCompatibility(
|
||||
booking.originYardId,
|
||||
booking.destinationYardId,
|
||||
day,
|
||||
this.cargoIdentityOf(booking),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Days the customer may pick for THIS booking (operation-request step):
|
||||
* cargo-aware — only days whose departures can carry the booking's cargo
|
||||
* type. Returns days only, no capacity counts.
|
||||
*/
|
||||
async availableDaysForBooking(bookingId: string): Promise<{ days: string[] }> {
|
||||
const booking = await this.findById(bookingId);
|
||||
return this.trainSchedulingService.getAvailableDaysForCargo({
|
||||
originYardId: booking.originYardId,
|
||||
destinationYardId: booking.destinationYardId,
|
||||
...this.cargoIdentityOf(booking),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Batched version of the findById flag: marks each page item whose booking
|
||||
* has a generated-but-unsigned SELF_HAUL handover, so list rows (portal
|
||||
@@ -1211,6 +1289,13 @@ export class BookingsService {
|
||||
destinationYardId: filter.destinationYardId,
|
||||
isGovernment: filter.isGovernment,
|
||||
consolidationPaired: filter.consolidationPaired,
|
||||
// DTO carries 'true'/'false' strings (query params); the repo option is a
|
||||
// real boolean — convert, preserving "not filtered" when absent.
|
||||
customsClearingEnabled:
|
||||
filter.customsClearingEnabled === undefined
|
||||
? undefined
|
||||
: filter.customsClearingEnabled === 'true',
|
||||
search: filter.search,
|
||||
sortBy: filter.sortBy,
|
||||
sortOrder: filter.sortOrder,
|
||||
});
|
||||
@@ -1262,6 +1347,7 @@ export class BookingsService {
|
||||
// Global Logistics only clears customs bookings; non-customs clearance is
|
||||
// reviewed by Marketing from the booking detail, not this queue.
|
||||
customsClearingEnabled: true,
|
||||
search: filter.search,
|
||||
sortBy: filter.sortBy,
|
||||
sortOrder: filter.sortOrder,
|
||||
});
|
||||
@@ -1286,6 +1372,7 @@ export class BookingsService {
|
||||
// Company-wide: payables span all of the customer's services.
|
||||
companyId: company.id,
|
||||
companyProfileId: filter.companyProfileId,
|
||||
search: filter.search,
|
||||
sortBy: filter.sortBy,
|
||||
sortOrder: filter.sortOrder,
|
||||
});
|
||||
@@ -1473,6 +1560,18 @@ export class BookingsService {
|
||||
);
|
||||
}
|
||||
|
||||
// Surface the parent contract's reference for drawdown bookings — the
|
||||
// portal detail header shows it (the entity has no contract relation, so
|
||||
// the list attaches it via a raw join and the detail attaches it here).
|
||||
if (booking.contractId) {
|
||||
const contract = await this.dataSource.getRepository(Contract).findOne({
|
||||
where: { id: booking.contractId },
|
||||
select: { reference: true },
|
||||
});
|
||||
(booking as Booking & { contractReference?: string | null }).contractReference =
|
||||
contract?.reference ?? null;
|
||||
}
|
||||
|
||||
// Surface the assigned train's operational status so the portal stepper
|
||||
// can show the Arrival stage: the booking status stays IN_TRANSIT from
|
||||
// dispatch until delivery, so arrival is only knowable from the schedule.
|
||||
|
||||
@@ -8,8 +8,10 @@ describe('clearance.util — clearanceSettingCode', () => {
|
||||
expect(clearanceSettingCode('IMPORT', 'CONTAINER', true)).toBe(
|
||||
'clearance_import_container_with_customs',
|
||||
);
|
||||
// Non-customs bookings self-clear with the same document set a ONE_TIME
|
||||
// self-clear contract uses.
|
||||
expect(clearanceSettingCode('IMPORT', 'CONTAINER', false)).toBe(
|
||||
'clearance_import_container_without_customs',
|
||||
'contract_clearance_selfclear_import_container',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -18,7 +20,7 @@ describe('clearance.util — clearanceSettingCode', () => {
|
||||
'clearance_export_bulk_with_customs',
|
||||
);
|
||||
expect(clearanceSettingCode('EXPORT', 'BULK', false)).toBe(
|
||||
'clearance_export_bulk_without_customs',
|
||||
'contract_clearance_selfclear_export_bulk',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -29,8 +29,14 @@ export function clearanceSettingCode(
|
||||
const op = operationFor(tradeDirection);
|
||||
if (!op) return null;
|
||||
const freight = freightFor(freightType);
|
||||
const customs = includesCustoms ? 'with_customs' : 'without_customs';
|
||||
return `clearance_${op}_${freight}_${customs}`;
|
||||
// Non-customs (Path A) bookings self-clear: the customer proves his own
|
||||
// clearance with the SAME smaller document set a ONE_TIME self-clear
|
||||
// contract uses (customs declaration, release permit, …) — not the
|
||||
// GL-oriented booking sets.
|
||||
if (!includesCustoms) {
|
||||
return `contract_clearance_selfclear_${op}_${freight}`;
|
||||
}
|
||||
return `clearance_${op}_${freight}_with_customs`;
|
||||
}
|
||||
|
||||
/** The GL-output (customs output) setting code, keyed on op + freight. */
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { containersPerWagonForSize } from '../rule-engine/container-type.util';
|
||||
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
|
||||
import { Booking } from './entities/booking.entity';
|
||||
|
||||
@@ -19,13 +20,6 @@ export interface ConsolidationAttemptResult {
|
||||
messages: string[];
|
||||
}
|
||||
|
||||
/** Containers that fit on one wagon for a given container type (inverse of wagons_per_unit). */
|
||||
export function containersPerWagon(wagonsPerUnit: number): number {
|
||||
const wpu = Number(wagonsPerUnit);
|
||||
if (!wpu || wpu <= 0) return 1;
|
||||
return Math.max(1, Math.round(1 / wpu));
|
||||
}
|
||||
|
||||
export function wagonRemainder(quantity: number, perWagon: number): number {
|
||||
const r = quantity % perWagon;
|
||||
return r;
|
||||
@@ -73,7 +67,7 @@ export class ConsolidationService {
|
||||
const slots: ConsolidationSlot[] = [];
|
||||
for (const [containerTypeId, quantity] of quantityByType) {
|
||||
const ct = await this.containerTypesService.findById(containerTypeId);
|
||||
const perWagon = containersPerWagon(Number(ct.wagonsPerUnit));
|
||||
const perWagon = containersPerWagonForSize(ct.sizeFt);
|
||||
const remainder = wagonRemainder(quantity, perWagon);
|
||||
if (remainder === 0) continue;
|
||||
slots.push({
|
||||
|
||||
@@ -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
|
||||
// 1–2 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) {
|
||||
@@ -312,18 +328,21 @@ export class CustomerTruckService {
|
||||
if (assignment.departedAt) {
|
||||
throw new ConflictException('This truck has already left — its load is locked');
|
||||
}
|
||||
// Containers can only be loaded after the truck has physically arrived at the
|
||||
// warehouse (arrival weighing recorded). Assignment alone is just planning.
|
||||
if (!assignment.arrivedAt) {
|
||||
throw new BadRequestException(
|
||||
'Record the truck arrival before loading — containers can only be loaded onto an arrived truck',
|
||||
);
|
||||
}
|
||||
// Loading a truck at the warehouse implies it is physically present, so a
|
||||
// truck that is still only assigned (not yet marked arrived) is auto-arrived
|
||||
// here rather than blocking the operator — the real gross is weighed on
|
||||
// departure anyway.
|
||||
const needsArrival = !assignment.arrivedAt;
|
||||
|
||||
const requested = (dto.containerNumbers ?? []).map((n) => n.trim().toUpperCase());
|
||||
if (!requested.length) {
|
||||
throw new BadRequestException('Select at least one container to load onto the truck');
|
||||
}
|
||||
// Capacity is size-based: a truck carries at most 2 containers, and a 40ft
|
||||
// container fills the truck (max 1) — mirror the addTruck/updateTruck rule.
|
||||
if (requested.length > 2) {
|
||||
throw new BadRequestException('A truck carries at most 2 containers');
|
||||
}
|
||||
const bookingNumbers = await this.bookingContainerNumbers(bookingId);
|
||||
for (const n of requested) {
|
||||
if (!bookingNumbers.includes(n)) {
|
||||
@@ -336,6 +355,12 @@ export class CustomerTruckService {
|
||||
throw new ConflictException(`Container ${n} is already loaded onto another truck`);
|
||||
}
|
||||
}
|
||||
const sizes = await this.containerSizes(bookingId, requested);
|
||||
if (sizes.some((s) => s.includes('40')) && requested.length > 1) {
|
||||
throw new BadRequestException(
|
||||
'A 40ft container fills the truck — load only 1 container onto this truck',
|
||||
);
|
||||
}
|
||||
|
||||
const grossTons = await this.vgmTonsForContainers(bookingId, requested);
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
@@ -355,9 +380,20 @@ export class CustomerTruckService {
|
||||
);
|
||||
// Provisional gross (tonnes) from the loaded containers' VGM — overridden
|
||||
// by the weighed gross on departure. (Column is *_kg but holds tonnes.)
|
||||
// Auto-stamp arrival if the truck was still only assigned.
|
||||
await manager.getRepository(CustomerTruckAssignment).update(assignmentId, {
|
||||
grossWeightKg: grossTons,
|
||||
...(needsArrival ? { arrivedAt: new Date() } : {}),
|
||||
});
|
||||
if (needsArrival) {
|
||||
await manager.query(
|
||||
`UPDATE freight.bookings
|
||||
SET customer_truck_arrived_at = COALESCE(customer_truck_arrived_at, NOW()),
|
||||
updated_at = NOW()
|
||||
WHERE id = $1`,
|
||||
[bookingId],
|
||||
);
|
||||
}
|
||||
});
|
||||
return this.listTrucks(bookingId);
|
||||
}
|
||||
@@ -395,20 +431,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 +520,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 +554,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"
|
||||
|
||||
@@ -27,9 +27,6 @@ export class BookingReferenceContainerTypeDto {
|
||||
|
||||
@ApiProperty()
|
||||
is_reefer!: boolean;
|
||||
|
||||
@ApiProperty({ example: 0.5, description: 'Wagon fraction per container' })
|
||||
wagons_per_unit!: number;
|
||||
}
|
||||
|
||||
export class BookingReferenceContainerSizeGroupDto {
|
||||
|
||||
@@ -106,6 +106,14 @@ export class FilterBookingDto {
|
||||
@IsIn(['true', 'false'])
|
||||
isGovernment?: 'true' | 'false';
|
||||
|
||||
@ApiPropertyOptional({
|
||||
enum: ['true', 'false'],
|
||||
description: 'Filter customs vs self-clearance (non-customs) bookings',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsIn(['true', 'false'])
|
||||
customsClearingEnabled?: 'true' | 'false';
|
||||
|
||||
@ApiPropertyOptional({ enum: TRADE_DIRECTIONS })
|
||||
@IsOptional()
|
||||
@IsIn([...TRADE_DIRECTIONS])
|
||||
@@ -125,6 +133,16 @@ export class FilterBookingDto {
|
||||
@IsOptional()
|
||||
consolidationPaired?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'Free-text search across booking reference, company name, and contract reference.',
|
||||
})
|
||||
@IsOptional()
|
||||
@Transform(({ value }) =>
|
||||
typeof value === 'string' && value.trim() ? value.trim() : undefined,
|
||||
)
|
||||
search?: string;
|
||||
|
||||
@ApiPropertyOptional({ default: 1 })
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => (value ? parseInt(value, 10) : 1))
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { ArrayMinSize, ArrayUnique, IsArray, Matches } from 'class-validator';
|
||||
import { ArrayMaxSize, ArrayMinSize, ArrayUnique, IsArray, Matches } from 'class-validator';
|
||||
|
||||
/** Containers loaded onto a truck at Truck_dispatch (after arrival, before it leaves). */
|
||||
export class LoadCustomerTruckDto {
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
// A truck carries at most 2 containers (two 20ft, or one 40ft).
|
||||
@ArrayMaxSize(2)
|
||||
@ArrayUnique()
|
||||
@Matches(/^[A-Z]{4}\d{7}$/, {
|
||||
each: true,
|
||||
|
||||
@@ -41,6 +41,10 @@ export class BookingContainer extends BaseEntity {
|
||||
@Column({ name: 'reefer_quantity', type: 'smallint', default: 0 })
|
||||
reeferQuantity!: number;
|
||||
|
||||
/** How many units of this line ship with empty-container return (≤ quantity). */
|
||||
@Column({ name: 'return_quantity', type: 'smallint', default: 0 })
|
||||
returnQuantity!: number;
|
||||
|
||||
@Column({ name: 'vgm_per_unit_tons', type: 'numeric', precision: 10, scale: 3 })
|
||||
vgmPerUnitTons!: number;
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ export const BOOKING_STATUSES = [
|
||||
'CONTRACT_ACTIVE',
|
||||
'CONTRACT_CLOSED',
|
||||
// Post counter-sign document-clearance gate (GL workflow).
|
||||
'AWAITING_CLEARANCE_PAYMENT', // clearance fee invoiced, unpaid — docs locked
|
||||
'AWAITING_DOCUMENTS',
|
||||
'DOCUMENTS_UNDER_REVIEW',
|
||||
'CLEARANCE_READY',
|
||||
@@ -86,6 +87,7 @@ export const SCHEDULING_STATUSES = [
|
||||
SchedulingStatus.Eligible,
|
||||
SchedulingStatus.Scheduled,
|
||||
SchedulingStatus.Dispatched,
|
||||
SchedulingStatus.WaitingForWagon,
|
||||
] as const;
|
||||
|
||||
export type BookingSchedulingStatus = (typeof SCHEDULING_STATUSES)[number];
|
||||
@@ -166,6 +168,24 @@ export class Booking extends BaseEntity {
|
||||
@Column({ name: 'contract_kind', type: 'varchar', length: 20, nullable: true })
|
||||
contractKind?: string | null;
|
||||
|
||||
/**
|
||||
* The customer paid a partial batch offer and this booking was reduced to the
|
||||
* offered part (see BookingSplitService.applySplit). On a ONE_TIME contract a
|
||||
* split booking releases the single-active-booking slot for the remainder —
|
||||
* the contract kind itself is never changed.
|
||||
*/
|
||||
@Column({ name: 'is_split', type: 'boolean', default: false })
|
||||
isSplit!: boolean;
|
||||
|
||||
/**
|
||||
* Quantities this booking carried BEFORE it was reduced by a split — the
|
||||
* split chain's source of truth for the outstanding remainder (ONE_TIME
|
||||
* contracts have no quantity cap to derive it from). Bulk: total tons;
|
||||
* container: units per size. Null until the booking is split.
|
||||
*/
|
||||
@Column({ name: 'pre_split_quantities', type: 'jsonb', nullable: true })
|
||||
preSplitQuantities?: { bulkTons?: number; bySize?: Record<string, number> } | null;
|
||||
|
||||
/** Who created this booking: CUSTOMER (Path A), GL_ET (Path B), or STAFF. */
|
||||
@Column({ name: 'created_by_role', type: 'varchar', length: 20, default: 'CUSTOMER', nullable: true })
|
||||
createdByRole?: string | null;
|
||||
@@ -502,6 +522,10 @@ export class Booking extends BaseEntity {
|
||||
@Column({ name: 'clearance_current_phase', type: 'varchar', length: 40, nullable: true })
|
||||
clearanceCurrentPhase?: string | null;
|
||||
|
||||
/** When the prepaid customs clearance service fee settled (GENERAL + customs). */
|
||||
@Column({ name: 'clearance_fee_paid_at', type: 'timestamptz', nullable: true })
|
||||
clearanceFeePaidAt?: Date | null;
|
||||
|
||||
@Column({ name: 'duty_required', type: 'boolean', nullable: true })
|
||||
dutyRequired?: boolean | null;
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
|
||||
import { Injectable, NotFoundException, ConflictException, BadRequestException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { FindOptionsOrder, FindOptionsWhere, ILike, Repository } from 'typeorm';
|
||||
import { FindOptionsOrder, FindOptionsWhere, ILike, Not, Repository } from 'typeorm';
|
||||
import { CreateCargoDto } from './dto/create-cargo.dto';
|
||||
import { UpdateCargoDto } from './dto/update-cargo.dto';
|
||||
import { LoadCargoDto } from './dto/load-cargo.dto';
|
||||
@@ -32,6 +32,7 @@ export class CargoesService {
|
||||
if (!container) {
|
||||
throw new NotFoundException(`Container ${dto.containerId} not found`);
|
||||
}
|
||||
await this.assertContainerCapacity(container, dto.weight);
|
||||
|
||||
if (dto.cargoTypeId) {
|
||||
const cargoType = await this.cargoTypeRepo.findOne({
|
||||
@@ -121,6 +122,10 @@ export class CargoesService {
|
||||
throw new ConflictException('Cargo already loaded or delivered');
|
||||
}
|
||||
|
||||
if (cargo.container) {
|
||||
await this.assertContainerCapacity(cargo.container, dto.weight, cargo.id);
|
||||
}
|
||||
|
||||
cargo.status = 'LOADED';
|
||||
cargo.loadedAt = new Date();
|
||||
cargo.quantity = dto.quantity;
|
||||
@@ -137,13 +142,31 @@ export class CargoesService {
|
||||
}
|
||||
|
||||
async unloadCargo(id: string): Promise<Cargo> {
|
||||
const cargo = await this.findById(id);
|
||||
const cargo = await this.cargoRepo.findOne({
|
||||
where: { id },
|
||||
relations: { container: true },
|
||||
});
|
||||
if (!cargo) throw new NotFoundException('Cargo not found');
|
||||
if (cargo.status !== 'LOADED') {
|
||||
throw new ConflictException('Cargo is not loaded');
|
||||
}
|
||||
cargo.status = 'UNLOADED';
|
||||
cargo.unloadedAt = new Date();
|
||||
return this.cargoRepo.save(cargo);
|
||||
const saved = await this.cargoRepo.save(cargo);
|
||||
|
||||
// loadCargo flips the container to LOADED; on unload, free it back to
|
||||
// AVAILABLE once no other LOADED cargo still references the container.
|
||||
if (cargo.containerId != null && cargo.container) {
|
||||
const remaining = await this.cargoRepo.count({
|
||||
where: { containerId: cargo.containerId, status: 'LOADED', id: Not(cargo.id) },
|
||||
});
|
||||
if (remaining === 0) {
|
||||
cargo.container.status = 'AVAILABLE';
|
||||
await this.containerRepo.save(cargo.container);
|
||||
}
|
||||
}
|
||||
|
||||
return saved;
|
||||
}
|
||||
|
||||
async deliverCargo(id: string, dto?: DeliverCargoDto): Promise<Cargo> {
|
||||
@@ -161,10 +184,13 @@ export class CargoesService {
|
||||
if (dto?.receiverName) cargo.receiverName = dto.receiverName;
|
||||
if (dto?.deliveryRemarks) cargo.deliveryRemarks = dto.deliveryRemarks;
|
||||
|
||||
// Exclude the cargo being delivered — it is still LOADED in the DB until the
|
||||
// save below, so counting it would keep `remaining` > 0 and never free the
|
||||
// container.
|
||||
const remaining =
|
||||
cargo.containerId != null
|
||||
? await this.cargoRepo.count({
|
||||
where: { containerId: cargo.containerId, status: 'LOADED' },
|
||||
where: { containerId: cargo.containerId, status: 'LOADED', id: Not(cargo.id) },
|
||||
})
|
||||
: 0;
|
||||
if (remaining === 0 && cargo.container) {
|
||||
@@ -174,4 +200,34 @@ export class CargoesService {
|
||||
|
||||
return this.cargoRepo.save(cargo);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject when placing `newWeightKg` on the container would exceed its max gross
|
||||
* weight. All values are kilograms: cargoes.weight is kg (entity), and the
|
||||
* container's tare_weight / max_gross_weight are kg (entity). Capacity check is
|
||||
* tare + already-LOADED cargo + new cargo <= max gross weight.
|
||||
*/
|
||||
private async assertContainerCapacity(
|
||||
container: Container,
|
||||
newWeightKg: number,
|
||||
excludeCargoId?: string,
|
||||
): Promise<void> {
|
||||
const qb = this.cargoRepo
|
||||
.createQueryBuilder('c')
|
||||
.select('COALESCE(SUM(c.weight), 0)', 'sum')
|
||||
.where('c.containerId = :containerId', { containerId: container.id })
|
||||
.andWhere('c.status = :status', { status: 'LOADED' });
|
||||
if (excludeCargoId) qb.andWhere('c.id != :excludeCargoId', { excludeCargoId });
|
||||
const raw = await qb.getRawOne<{ sum: string }>();
|
||||
|
||||
const loadedKg = Number(raw?.sum ?? 0);
|
||||
const tareKg = Number(container.tareWeight);
|
||||
const maxGrossKg = Number(container.maxGrossWeight);
|
||||
if (tareKg + loadedKg + newWeightKg > maxGrossKg) {
|
||||
throw new BadRequestException(
|
||||
`Cargo weight exceeds container capacity: tare ${tareKg}kg + loaded ${loadedKg}kg + ` +
|
||||
`new ${newWeightKg}kg > max gross ${maxGrossKg}kg`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,7 +34,10 @@ import {
|
||||
ResponseCompanyDto,
|
||||
ResponseCompanyProfileDto,
|
||||
} from "./dto/response-company.dto";
|
||||
import { ProfileLicenseFileView } from "./entities/company-profile.entity";
|
||||
import {
|
||||
CompanyDocumentFileView,
|
||||
ProfileLicenseFileView,
|
||||
} from "./entities/company-profile.entity";
|
||||
import { ResponseExternalProfileDto } from "./dto/response-external-profile.dto";
|
||||
import { CompanyInfoResponseDto } from "./dto/company-info-response.dto";
|
||||
import { UpdateProfileDto } from "./dto/update-profile.dto";
|
||||
@@ -218,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,
|
||||
@@ -306,6 +309,49 @@ export class CompaniesController {
|
||||
return this.companiesService.listProfileLicenseFiles(user.id, profileId);
|
||||
}
|
||||
|
||||
@Get("poa-delegation")
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"List the Power of Attorney delegation letter (with review state) for the current user's company",
|
||||
})
|
||||
async listPoaDelegation(
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
): Promise<CompanyDocumentFileView[]> {
|
||||
return this.companiesService.listPoaDelegationFiles(user.id);
|
||||
}
|
||||
|
||||
@Post("poa-delegation")
|
||||
@UseInterceptors(AnyFilesInterceptor())
|
||||
@ApiConsumes("multipart/form-data")
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Upload the Power of Attorney delegation letter, replacing any existing one. " +
|
||||
"For an approved company the upload is staged for backoffice review; during " +
|
||||
"onboarding it goes live.",
|
||||
})
|
||||
async uploadPoaDelegation(
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
@UploadedFiles() files: Array<Express.Multer.File>,
|
||||
): Promise<CompanyDocumentFileView[]> {
|
||||
const file = files?.[0];
|
||||
if (!file) {
|
||||
throw new BadRequestException("A delegation letter file is required");
|
||||
}
|
||||
return this.companiesService.uploadPoaDelegationLetter(user.id, file);
|
||||
}
|
||||
|
||||
@Delete("poa-delegation/:fileId")
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Remove the Power of Attorney delegation letter (staged for review on an approved company).",
|
||||
})
|
||||
async removePoaDelegation(
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
@Param("fileId", ParseUUIDPipe) fileId: string,
|
||||
): Promise<CompanyDocumentFileView[]> {
|
||||
return this.companiesService.removePoaDelegationLetter(user.id, fileId);
|
||||
}
|
||||
|
||||
@Patch("active-mode")
|
||||
@ApiOperation({
|
||||
summary: "Switch the current user's active operational mode (importer/exporter)",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -8,6 +8,27 @@ import { CompanyStatsResponseDto } from './dto/company-stats-response.dto';
|
||||
|
||||
@Injectable()
|
||||
export class CompaniesRepository extends BaseRepository<Company> {
|
||||
/**
|
||||
* A company still being filled in by its owner in the portal wizard: it was
|
||||
* self-registered (so it has an external profile) and nobody has submitted
|
||||
* onboarding yet. The row exists from the wizard's first click, carrying a
|
||||
* placeholder name + TIN, so it must not be offered up for review.
|
||||
* Staff-created companies have no external profiles and are never drafts.
|
||||
*/
|
||||
private static readonly DRAFT_SQL = `(
|
||||
EXISTS (
|
||||
SELECT 1 FROM freight.external_profiles ep
|
||||
WHERE ep.company_id = company.id
|
||||
AND ep.deleted_at IS NULL
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM freight.external_profiles ep
|
||||
WHERE ep.company_id = company.id
|
||||
AND ep.deleted_at IS NULL
|
||||
AND ep.onboarding_completed = true
|
||||
)
|
||||
)`;
|
||||
|
||||
constructor(
|
||||
@InjectRepository(Company)
|
||||
repo: Repository<Company>,
|
||||
@@ -38,11 +59,22 @@ export class CompaniesRepository extends BaseRepository<Company> {
|
||||
async findPaginated(
|
||||
query: ListCompaniesQueryDto,
|
||||
): Promise<{ items: Company[]; total: number }> {
|
||||
const { page = 1, pageSize = 20, search, type, kind, status } = query;
|
||||
const {
|
||||
page = 1,
|
||||
pageSize = 20,
|
||||
search,
|
||||
type,
|
||||
kind,
|
||||
status,
|
||||
onboardingCompleted,
|
||||
} = query;
|
||||
|
||||
const qb = this.repository
|
||||
.createQueryBuilder('company')
|
||||
.leftJoinAndSelect('company.companyProfiles', 'companyProfiles')
|
||||
// External profiles carry onboardingCompleted, which the backoffice list
|
||||
// uses to flag customers still mid-onboarding (not yet reviewable).
|
||||
.leftJoinAndSelect('company.profiles', 'profiles')
|
||||
.where('company.deleted_at IS NULL');
|
||||
|
||||
if (type) {
|
||||
@@ -57,6 +89,14 @@ export class CompaniesRepository extends BaseRepository<Company> {
|
||||
qb.andWhere('company.status = :status', { status });
|
||||
}
|
||||
|
||||
if (onboardingCompleted !== undefined) {
|
||||
qb.andWhere(
|
||||
onboardingCompleted
|
||||
? `NOT ${CompaniesRepository.DRAFT_SQL}`
|
||||
: CompaniesRepository.DRAFT_SQL,
|
||||
);
|
||||
}
|
||||
|
||||
if (search) {
|
||||
const term = `%${search.trim()}%`;
|
||||
qb.andWhere(
|
||||
@@ -83,21 +123,35 @@ export class CompaniesRepository extends BaseRepository<Company> {
|
||||
}
|
||||
|
||||
async getStats(): Promise<CompanyStatsResponseDto> {
|
||||
const rows: { status: string; count: string }[] = await this.repository
|
||||
.createQueryBuilder('company')
|
||||
.select('company.status', 'status')
|
||||
.addSelect('COUNT(*)', 'count')
|
||||
.where('company.deleted_at IS NULL')
|
||||
.groupBy('company.status')
|
||||
.getRawMany();
|
||||
// Drafts are counted separately rather than under `pending`: they carry
|
||||
// status=pending from creation, which would otherwise inflate the review
|
||||
// queue's KPI with customers who haven't submitted anything yet.
|
||||
const rows: { status: string; is_draft: boolean; count: string }[] =
|
||||
await this.repository
|
||||
.createQueryBuilder('company')
|
||||
.select('company.status', 'status')
|
||||
.addSelect(CompaniesRepository.DRAFT_SQL, 'is_draft')
|
||||
.addSelect('COUNT(*)', 'count')
|
||||
.where('company.deleted_at IS NULL')
|
||||
.groupBy('company.status')
|
||||
.addGroupBy(CompaniesRepository.DRAFT_SQL)
|
||||
.getRawMany();
|
||||
|
||||
const map = new Map(rows.map((r) => [r.status, parseInt(r.count, 10)]));
|
||||
const total = rows.reduce((sum, r) => sum + parseInt(r.count, 10), 0);
|
||||
const map = new Map<string, number>();
|
||||
let onboarding = 0;
|
||||
let total = 0;
|
||||
for (const row of rows) {
|
||||
const count = parseInt(row.count, 10);
|
||||
total += count;
|
||||
if (row.is_draft) onboarding += count;
|
||||
else map.set(row.status, (map.get(row.status) ?? 0) + count);
|
||||
}
|
||||
|
||||
return {
|
||||
total,
|
||||
active: map.get('active') ?? 0,
|
||||
pending: map.get('pending') ?? 0,
|
||||
onboarding,
|
||||
suspended: map.get('suspended') ?? 0,
|
||||
blacklisted: map.get('blacklisted') ?? 0,
|
||||
};
|
||||
|
||||
@@ -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";
|
||||
@@ -37,6 +38,7 @@ import {
|
||||
import { ExternalProfile } from "./entities/external-profile.entity";
|
||||
import {
|
||||
BusinessLicenseFile,
|
||||
CompanyDocumentFileView,
|
||||
CompanyProfile,
|
||||
ProfileLicenseFileView,
|
||||
ProfileType,
|
||||
@@ -45,6 +47,7 @@ import {
|
||||
import {
|
||||
ChangeRequestStatus,
|
||||
CompanyChangeRequest,
|
||||
DocumentChangeIntent,
|
||||
LicenseChangeIntent,
|
||||
} from "./entities/company-change-request.entity";
|
||||
|
||||
@@ -54,6 +57,27 @@ const LICENSE_CODE = "business_license";
|
||||
/** Code for a license file staged in an open change request (not yet live). */
|
||||
const LICENSE_PENDING_CODE = "business_license_pending";
|
||||
|
||||
/** Mirrors the field seeded in seed/file-upload-settings.seeder.ts. */
|
||||
const POA_DELEGATION_FILE_KEY = "poa_delegation_letter";
|
||||
/** Code for a PoA letter staged in an open change request (not yet live). */
|
||||
const POA_DELEGATION_PENDING_CODE = "poa_delegation_letter_pending";
|
||||
/** FileRecord resource that company-level documents are stored under. */
|
||||
const COMPANY_RESOURCE = "companies";
|
||||
/** company.attributes keys that together mean "a PoA was entered". */
|
||||
const POA_ATTRIBUTES = [
|
||||
"poaName",
|
||||
"poaPhone",
|
||||
"poaEmail",
|
||||
"poaLocation",
|
||||
"poaAddress",
|
||||
] as const;
|
||||
/** Mandatory once the company operates as a freight forwarder. */
|
||||
const REQUIRED_POA_FIELDS: { key: string; label: string }[] = [
|
||||
{ key: "poaName", label: "PoA name" },
|
||||
{ key: "poaEmail", label: "PoA email" },
|
||||
{ key: "poaPhone", label: "PoA phone" },
|
||||
];
|
||||
|
||||
export interface UserIdentity {
|
||||
userId: string;
|
||||
firstName: string;
|
||||
@@ -73,6 +97,7 @@ export class CompaniesService {
|
||||
private readonly filesService: FilesService,
|
||||
private readonly fileUploadSettingsService: FileUploadSettingsService,
|
||||
private readonly etradeService: ETradeService,
|
||||
private readonly companyNotifier: CompanyNotifierService,
|
||||
) { }
|
||||
|
||||
/**
|
||||
@@ -347,6 +372,9 @@ export class CompaniesService {
|
||||
const company = await this.companiesRepo.findById(id);
|
||||
if (!company) throw new NotFoundException(`Company ${id} not found`);
|
||||
company.companyProfiles = await this.companyProfilesRepo.findByCompanyId(id);
|
||||
// External profiles carry the onboarding flag the backoffice gates
|
||||
// approval decisions on (see ResponseCompanyDto.onboardingCompleted).
|
||||
company.profiles = await this.profilesRepo.findByCompanyId(id);
|
||||
return company;
|
||||
}
|
||||
|
||||
@@ -562,9 +590,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;
|
||||
}
|
||||
|
||||
@@ -765,6 +797,7 @@ export class CompaniesService {
|
||||
const companyUpdates = this.mapProfileDtoToCompanyUpdates(company, snapshot);
|
||||
await this.companiesRepo.update(company.id, companyUpdates);
|
||||
await this.applyLicenseChanges(request);
|
||||
await this.applyDocumentChanges(request);
|
||||
|
||||
return (
|
||||
(await this.changeRequestRepo.update(id, {
|
||||
@@ -817,7 +850,12 @@ export class CompaniesService {
|
||||
if (existing) {
|
||||
const prev = existing.documents?.documentFileIds ?? [];
|
||||
await this.changeRequestRepo.update(existing.id, {
|
||||
documents: { documentFileIds: [...prev, ...fileIds] },
|
||||
// Spread the existing documents blob: a bare object would drop any
|
||||
// licenseChanges/documentChanges already staged on this request.
|
||||
documents: {
|
||||
...existing.documents,
|
||||
documentFileIds: [...prev, ...fileIds],
|
||||
},
|
||||
submittedBy: submittedBy ?? existing.submittedBy ?? null,
|
||||
submittedAt: now,
|
||||
note: null,
|
||||
@@ -849,12 +887,17 @@ export class CompaniesService {
|
||||
);
|
||||
}
|
||||
await this.discardLicenseChanges(request);
|
||||
await this.discardDocumentChanges(request);
|
||||
return (
|
||||
(await this.changeRequestRepo.update(id, {
|
||||
status: ChangeRequestStatus.Rejected,
|
||||
// Staged license uploads were just discarded; drop their intents so an
|
||||
// amended resubmit never re-references deleted files.
|
||||
documents: { ...request.documents, licenseChanges: [] },
|
||||
// Staged license/document uploads were just discarded; drop their intents
|
||||
// so an amended resubmit never re-references deleted files.
|
||||
documents: {
|
||||
...request.documents,
|
||||
licenseChanges: [],
|
||||
documentChanges: [],
|
||||
},
|
||||
note,
|
||||
reviewedBy: reviewerId ?? null,
|
||||
reviewedAt: new Date(),
|
||||
@@ -922,6 +965,28 @@ export class CompaniesService {
|
||||
if (!existing)
|
||||
throw new NotFoundException(`Company profile ${profileId} not found`);
|
||||
|
||||
// A self-registered company is only reviewable once its owner submits the
|
||||
// onboarding wizard (markOnboardingComplete) — until then its profiles are
|
||||
// half-filled drafts and approving one would mint a reference against an
|
||||
// application that doesn't exist yet. Staff-created companies have no
|
||||
// external profiles and are exempt.
|
||||
//
|
||||
// Only the review decision itself is gated (a profile still awaiting one:
|
||||
// Pending, or Rejected and awaiting re-approval). Profiles already in
|
||||
// service stay managable so staff can suspend/blacklist them — including to
|
||||
// undo an approval granted before this guard existed.
|
||||
const awaitingReview =
|
||||
existing.status === ProfileStatus.Pending ||
|
||||
existing.status === ProfileStatus.Rejected;
|
||||
if (awaitingReview) {
|
||||
const owners = await this.profilesRepo.findByCompanyId(existing.companyId);
|
||||
if (owners.length > 0 && !owners.some((o) => o.onboardingCompleted)) {
|
||||
throw new BadRequestException(
|
||||
"This customer hasn't finished onboarding yet. Their roles can be reviewed once they submit their application.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// A reference number is only minted the first time a profile is approved
|
||||
// (status → Active). Pending/unapproved profiles carry no reference.
|
||||
const patch: Partial<CompanyProfile> = { status };
|
||||
@@ -1017,13 +1082,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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1097,9 +1161,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,
|
||||
@@ -1122,8 +1188,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,
|
||||
@@ -1132,8 +1197,6 @@ export class CompaniesService {
|
||||
});
|
||||
}
|
||||
|
||||
await this.profilesRepo.update(profile.id, { activeProfileType: type });
|
||||
|
||||
return created;
|
||||
}
|
||||
|
||||
@@ -1240,6 +1303,29 @@ export class CompaniesService {
|
||||
);
|
||||
const missingLicenses = licenseProfiles.filter((p) => !p.uploaded);
|
||||
|
||||
// 4. Power of Attorney. Optional in general, but a freight forwarder acts on
|
||||
// other companies' behalf so its PoA is mandatory. Either way, a PoA that
|
||||
// has been entered must be evidenced by the delegation letter.
|
||||
const poaRequired = (company.companyProfiles ?? []).some(
|
||||
(p) => p.type === ProfileType.freightForwarder,
|
||||
);
|
||||
const poaProvided = POA_ATTRIBUTES.some((k) =>
|
||||
(company.attributes?.[k] as string | undefined)?.trim(),
|
||||
);
|
||||
const missingPoaFields = poaRequired
|
||||
? REQUIRED_POA_FIELDS.filter(
|
||||
(f) => !(company.attributes?.[f.key] as string | undefined)?.trim(),
|
||||
)
|
||||
: [];
|
||||
// Only gate on the letter once the document set actually carries the field.
|
||||
const delegationField = (setting?.fields ?? []).find(
|
||||
(f) => f.fileKey === POA_DELEGATION_FILE_KEY,
|
||||
);
|
||||
const missingDelegation =
|
||||
Boolean(delegationField) &&
|
||||
(poaRequired || poaProvided) &&
|
||||
!uploadedCodes.has(POA_DELEGATION_FILE_KEY);
|
||||
|
||||
const outstanding = [
|
||||
...missingInfo.map((f) => `Add your ${f.label.toLowerCase()}`),
|
||||
...missingDocs.map((d) => `Upload your ${d.fileLabel}`),
|
||||
@@ -1247,18 +1333,31 @@ export class CompaniesService {
|
||||
(p) =>
|
||||
`Upload a business license for your ${p.type.replace(/_/g, " ")} profile`,
|
||||
),
|
||||
...missingPoaFields.map((f) => `Add your ${f.label.toLowerCase()}`),
|
||||
...(missingDelegation
|
||||
? ["Upload the delegation letter for your Power of Attorney"]
|
||||
: []),
|
||||
];
|
||||
|
||||
// Progress spans every required item the user has to satisfy: company-info
|
||||
// fields, required documents and one license per operational profile.
|
||||
// fields, required documents, one license per operational profile, and the
|
||||
// PoA details/letter whenever those are mandatory.
|
||||
const requiredDocCount = documents.filter((d) => d.isRequired).length;
|
||||
const poaItemCount =
|
||||
(poaRequired ? REQUIRED_POA_FIELDS.length : 0) +
|
||||
(delegationField && (poaRequired || poaProvided) ? 1 : 0);
|
||||
const total =
|
||||
this.REQUIRED_COMPANY_INFO.length +
|
||||
requiredDocCount +
|
||||
licenseProfiles.length;
|
||||
licenseProfiles.length +
|
||||
poaItemCount;
|
||||
const completed =
|
||||
total -
|
||||
(missingInfo.length + missingDocs.length + missingLicenses.length);
|
||||
(missingInfo.length +
|
||||
missingDocs.length +
|
||||
missingLicenses.length +
|
||||
missingPoaFields.length +
|
||||
(missingDelegation ? 1 : 0));
|
||||
|
||||
return new OnboardingRequirementsResponseDto({
|
||||
documentSettingCode,
|
||||
@@ -1266,6 +1365,13 @@ export class CompaniesService {
|
||||
companyInfo: { complete: missingInfo.length === 0, missingFields: missingInfo },
|
||||
documents,
|
||||
licenseProfiles,
|
||||
poa: {
|
||||
required: poaRequired,
|
||||
provided: poaProvided,
|
||||
delegationLetterUploaded: uploadedCodes.has(POA_DELEGATION_FILE_KEY),
|
||||
missingFields: missingPoaFields,
|
||||
complete: missingPoaFields.length === 0 && !missingDelegation,
|
||||
},
|
||||
progress: { completed, total },
|
||||
isComplete: outstanding.length === 0,
|
||||
onboardingCompleted: profile.onboardingCompleted,
|
||||
@@ -1365,10 +1471,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,
|
||||
@@ -1377,7 +1485,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(
|
||||
@@ -1409,9 +1517,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,
|
||||
@@ -1427,7 +1535,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.
|
||||
@@ -1449,7 +1557,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,
|
||||
@@ -1463,7 +1571,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,
|
||||
@@ -1671,6 +1779,254 @@ export class CompaniesService {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Power of Attorney delegation letter
|
||||
//
|
||||
// A company-level document that follows the same staged-review model as the
|
||||
// business license: on an approved (Active) company an upload lands under the
|
||||
// pending code and the live letter is flagged for removal, so the reviewer
|
||||
// sees both and approval swaps them atomically. During onboarding it goes live.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** The company's PoA letter(s), with each file's review status resolved. */
|
||||
async listPoaDelegationFiles(
|
||||
userId: string,
|
||||
): Promise<CompanyDocumentFileView[]> {
|
||||
const { company } = await this.getCompanyInfoByUserId(userId);
|
||||
return this.getPoaDelegationView(company.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload the PoA delegation letter, replacing whatever is already on file.
|
||||
* On an Active company this stages an `add` for the new file plus a `remove`
|
||||
* for each live one; a letter still awaiting approval is withdrawn outright
|
||||
* rather than stacking a second pending upload.
|
||||
*/
|
||||
async uploadPoaDelegationLetter(
|
||||
userId: string,
|
||||
file: Express.Multer.File,
|
||||
): Promise<CompanyDocumentFileView[]> {
|
||||
const { company } = await this.getCompanyInfoByUserId(userId);
|
||||
const gated = company.status === CompanyStatus.Active;
|
||||
|
||||
const records = await this.filesService.findByResource(
|
||||
company.id,
|
||||
COMPANY_RESOURCE,
|
||||
);
|
||||
const live = records.filter((r) => r.code === POA_DELEGATION_FILE_KEY);
|
||||
const staged = records.filter(
|
||||
(r) => r.code === POA_DELEGATION_PENDING_CODE,
|
||||
);
|
||||
|
||||
// Supersede an unreviewed upload instead of queueing another one.
|
||||
for (const r of staged) {
|
||||
await this.filesService.remove(r.id);
|
||||
await this.withdrawDocumentIntent(company.id, r.id);
|
||||
}
|
||||
|
||||
const created = await this.filesService.upload({
|
||||
resourceId: company.id,
|
||||
resource: COMPANY_RESOURCE,
|
||||
code: gated ? POA_DELEGATION_PENDING_CODE : POA_DELEGATION_FILE_KEY,
|
||||
file,
|
||||
});
|
||||
|
||||
if (gated) {
|
||||
await this.stageDocumentIntent(
|
||||
company.id,
|
||||
[
|
||||
...live.map((r) => ({
|
||||
op: "remove" as const,
|
||||
fileId: r.id,
|
||||
code: POA_DELEGATION_FILE_KEY,
|
||||
fileName: r.name,
|
||||
})),
|
||||
{
|
||||
op: "add" as const,
|
||||
fileId: created.id,
|
||||
code: POA_DELEGATION_FILE_KEY,
|
||||
fileName: created.name,
|
||||
},
|
||||
],
|
||||
userId,
|
||||
);
|
||||
} else {
|
||||
// Onboarding: no review, so the old letter is simply replaced.
|
||||
for (const r of live) await this.filesService.remove(r.id);
|
||||
}
|
||||
|
||||
return this.getPoaDelegationView(company.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the PoA letter. A staged upload is withdrawn outright; a live file on
|
||||
* an Active company is kept and flagged for deletion on approval; during
|
||||
* onboarding it is deleted immediately.
|
||||
*/
|
||||
async removePoaDelegationLetter(
|
||||
userId: string,
|
||||
fileId: string,
|
||||
): Promise<CompanyDocumentFileView[]> {
|
||||
const { company } = await this.getCompanyInfoByUserId(userId);
|
||||
const record = await this.filesService.findById(fileId);
|
||||
if (
|
||||
record.resource !== COMPANY_RESOURCE ||
|
||||
record.resourceId !== company.id ||
|
||||
(record.code !== POA_DELEGATION_FILE_KEY &&
|
||||
record.code !== POA_DELEGATION_PENDING_CODE)
|
||||
) {
|
||||
throw new NotFoundException(`Delegation letter ${fileId} not found`);
|
||||
}
|
||||
|
||||
if (record.code === POA_DELEGATION_PENDING_CODE) {
|
||||
await this.filesService.remove(fileId);
|
||||
await this.withdrawDocumentIntent(company.id, fileId);
|
||||
} else if (company.status === CompanyStatus.Active) {
|
||||
await this.stageDocumentIntent(
|
||||
company.id,
|
||||
[
|
||||
{
|
||||
op: "remove",
|
||||
fileId,
|
||||
code: POA_DELEGATION_FILE_KEY,
|
||||
fileName: record.name,
|
||||
},
|
||||
],
|
||||
userId,
|
||||
);
|
||||
} else {
|
||||
await this.filesService.remove(fileId);
|
||||
}
|
||||
|
||||
return this.getPoaDelegationView(company.id);
|
||||
}
|
||||
|
||||
private async getPoaDelegationView(
|
||||
companyId: string,
|
||||
): Promise<CompanyDocumentFileView[]> {
|
||||
const pending =
|
||||
await this.changeRequestRepo.findPendingByCompanyId(companyId);
|
||||
const removeIds = new Set(
|
||||
(pending?.documents?.documentChanges ?? [])
|
||||
.filter((c) => c.op === "remove")
|
||||
.map((c) => c.fileId),
|
||||
);
|
||||
const records = await this.filesService.findByResource(
|
||||
companyId,
|
||||
COMPANY_RESOURCE,
|
||||
);
|
||||
return records
|
||||
.filter(
|
||||
(r) =>
|
||||
r.code === POA_DELEGATION_FILE_KEY ||
|
||||
r.code === POA_DELEGATION_PENDING_CODE,
|
||||
)
|
||||
.map((r) => ({
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
size: r.size,
|
||||
mimeType: r.mimeType,
|
||||
status:
|
||||
r.code === POA_DELEGATION_PENDING_CODE
|
||||
? ("pending_add" as const)
|
||||
: removeIds.has(r.id)
|
||||
? ("pending_remove" as const)
|
||||
: ("live" as const),
|
||||
}));
|
||||
}
|
||||
|
||||
/** Open or append a pending change request recording document add/remove intents. */
|
||||
private async stageDocumentIntent(
|
||||
companyId: string,
|
||||
changes: DocumentChangeIntent[],
|
||||
submittedBy?: string,
|
||||
): Promise<void> {
|
||||
if (changes.length === 0) return;
|
||||
const now = new Date();
|
||||
const existing =
|
||||
await this.changeRequestRepo.findPendingByCompanyId(companyId);
|
||||
if (existing) {
|
||||
const prev = existing.documents?.documentChanges ?? [];
|
||||
// Re-uploading twice before review would otherwise stage a second `remove`
|
||||
// for the same live file, and the duplicate would fail on approval.
|
||||
const seen = new Set(prev.map((c) => `${c.op}:${c.fileId}`));
|
||||
const fresh = changes.filter((c) => !seen.has(`${c.op}:${c.fileId}`));
|
||||
if (fresh.length === 0) return;
|
||||
await this.changeRequestRepo.update(existing.id, {
|
||||
documents: {
|
||||
...existing.documents,
|
||||
documentChanges: [...prev, ...fresh],
|
||||
},
|
||||
submittedBy: submittedBy ?? existing.submittedBy ?? null,
|
||||
submittedAt: now,
|
||||
note: null,
|
||||
});
|
||||
} else {
|
||||
await this.changeRequestRepo.create({
|
||||
companyId,
|
||||
snapshot: {},
|
||||
documents: { documentChanges: changes },
|
||||
status: ChangeRequestStatus.Pending,
|
||||
submittedBy: submittedBy ?? null,
|
||||
submittedAt: now,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop a staged document intent referencing `fileId`. If that empties the
|
||||
* request entirely, delete it so the customer's settings page unlocks.
|
||||
*/
|
||||
private async withdrawDocumentIntent(
|
||||
companyId: string,
|
||||
fileId: string,
|
||||
): Promise<void> {
|
||||
const existing =
|
||||
await this.changeRequestRepo.findPendingByCompanyId(companyId);
|
||||
if (!existing) return;
|
||||
const remaining = (existing.documents?.documentChanges ?? []).filter(
|
||||
(c) => c.fileId !== fileId,
|
||||
);
|
||||
const docs = existing.documents ?? {};
|
||||
const stillHasWork =
|
||||
remaining.length > 0 ||
|
||||
(docs.licenseChanges?.length ?? 0) > 0 ||
|
||||
(docs.documentFileIds?.length ?? 0) > 0 ||
|
||||
Object.keys(existing.snapshot ?? {}).length > 0;
|
||||
|
||||
if (stillHasWork) {
|
||||
await this.changeRequestRepo.update(existing.id, {
|
||||
documents: { ...docs, documentChanges: remaining },
|
||||
});
|
||||
} else {
|
||||
await this.changeRequestRepo.softDelete(existing.id);
|
||||
}
|
||||
}
|
||||
|
||||
/** Apply a request's staged document changes: promote adds, delete removes. */
|
||||
private async applyDocumentChanges(
|
||||
request: CompanyChangeRequest,
|
||||
): Promise<void> {
|
||||
for (const change of request.documents?.documentChanges ?? []) {
|
||||
if (change.op === "add") {
|
||||
await this.filesService.setCode(change.fileId, change.code);
|
||||
} else {
|
||||
await this.filesService.remove(change.fileId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Discard a rejected request's staged document uploads (adds only). */
|
||||
private async discardDocumentChanges(
|
||||
request: CompanyChangeRequest,
|
||||
): Promise<void> {
|
||||
for (const change of request.documents?.documentChanges ?? []) {
|
||||
if (change.op === "add") {
|
||||
await this.filesService.remove(change.fileId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve which company_profile a new booking belongs to, from the company
|
||||
* and the booking's trade direction. IMPORT → importer profile, EXPORT →
|
||||
@@ -1719,13 +2075,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 };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import { InjectDataSource } from "@nestjs/typeorm";
|
||||
import { DataSource } from "typeorm";
|
||||
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";
|
||||
import { resolveCompanyNotifyPhone } from "../notifications/resolve-company-phone.util";
|
||||
|
||||
/** 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,
|
||||
@InjectDataSource()
|
||||
private readonly dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
/** Send SMS + email to the company contact; log-only on failure. */
|
||||
private async notifyContact(company: Company, message: string): Promise<void> {
|
||||
const phone = await resolveCompanyNotifyPhone(this.dataSource, company.id);
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
ChangeRequestStatus,
|
||||
CompanyChangeRequest,
|
||||
DocumentChangeIntent,
|
||||
LicenseChangeIntent,
|
||||
} from "../entities/company-change-request.entity";
|
||||
|
||||
@@ -18,6 +19,8 @@ export class ChangeRequestResponseDto {
|
||||
documentFileIds: string[];
|
||||
/** Staged business-license add/remove intents attached to this request. */
|
||||
licenseChanges: LicenseChangeIntent[];
|
||||
/** Staged company-document add/remove intents (e.g. the PoA letter). */
|
||||
documentChanges: DocumentChangeIntent[];
|
||||
note: string | null;
|
||||
submittedBy: string | null;
|
||||
submittedAt: Date | null;
|
||||
@@ -33,6 +36,7 @@ export class ChangeRequestResponseDto {
|
||||
this.snapshot = req.snapshot ?? {};
|
||||
this.documentFileIds = req.documents?.documentFileIds ?? [];
|
||||
this.licenseChanges = req.documents?.licenseChanges ?? [];
|
||||
this.documentChanges = req.documents?.documentChanges ?? [];
|
||||
this.note = req.note ?? null;
|
||||
this.submittedBy = req.submittedBy ?? null;
|
||||
this.submittedAt = req.submittedAt ?? null;
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
export class CompanyStatsResponseDto {
|
||||
total!: number;
|
||||
active!: number;
|
||||
/** Submitted applications awaiting review. Excludes drafts. */
|
||||
pending!: number;
|
||||
/** Self-registered companies still working through the onboarding wizard. */
|
||||
onboarding!: number;
|
||||
suspended!: number;
|
||||
blacklisted!: number;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { IsIn, IsInt, IsOptional, IsString, Min } from "class-validator";
|
||||
import { IsBoolean, IsIn, IsInt, IsOptional, IsString, Min } from "class-validator";
|
||||
import { Transform } from "class-transformer";
|
||||
import { CompanyKind, CompanyStatus, CompanyType } from "../entities/company.entity";
|
||||
|
||||
@@ -37,4 +37,14 @@ export class ListCompaniesQueryDto {
|
||||
@IsOptional()
|
||||
@IsIn(Object.values(CompanyStatus))
|
||||
status?: CompanyStatus;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
"Filter by onboarding submission. `true` = reviewable applications; " +
|
||||
"`false` = drafts still in the portal wizard. Omit for both.",
|
||||
})
|
||||
@IsOptional()
|
||||
@Transform(({ value }: { value: unknown }) => value === "true" || value === true)
|
||||
@IsBoolean()
|
||||
onboardingCompleted?: boolean;
|
||||
}
|
||||
|
||||
@@ -35,6 +35,19 @@ export interface OnboardingLicenseProfile {
|
||||
uploaded: boolean;
|
||||
}
|
||||
|
||||
export interface OnboardingPoaState {
|
||||
/** True when the company operates as a freight forwarder — PoA is mandatory. */
|
||||
required: boolean;
|
||||
/** True once any PoA detail has been entered. */
|
||||
provided: boolean;
|
||||
/** True when the delegation letter is stored for the company. */
|
||||
delegationLetterUploaded: boolean;
|
||||
/** PoA details still missing (only populated when `required`). */
|
||||
missingFields: OnboardingInfoField[];
|
||||
/** False while the PoA step still owes details or a delegation letter. */
|
||||
complete: boolean;
|
||||
}
|
||||
|
||||
export class OnboardingRequirementsResponseDto {
|
||||
/** Resolved document setting code (by nationality) the docs were drawn from. */
|
||||
documentSettingCode: string;
|
||||
@@ -52,6 +65,9 @@ export class OnboardingRequirementsResponseDto {
|
||||
/** Per-operational-profile business-license requirements. */
|
||||
licenseProfiles: OnboardingLicenseProfile[];
|
||||
|
||||
/** Power of Attorney state, so the wizard needn't re-derive the rule. */
|
||||
poa: OnboardingPoaState;
|
||||
|
||||
/** Overall setup progress across fields + documents + licenses. */
|
||||
progress: { completed: number; total: number };
|
||||
|
||||
@@ -70,6 +86,7 @@ export class OnboardingRequirementsResponseDto {
|
||||
this.companyInfo = init.companyInfo;
|
||||
this.documents = init.documents;
|
||||
this.licenseProfiles = init.licenseProfiles;
|
||||
this.poa = init.poa;
|
||||
this.progress = init.progress;
|
||||
this.isComplete = init.isComplete;
|
||||
this.onboardingCompleted = init.onboardingCompleted;
|
||||
|
||||
@@ -62,6 +62,13 @@ export class ResponseCompanyDto {
|
||||
attributes?: Record<string, any> | null;
|
||||
profiles?: ResponseExternalProfileDto[];
|
||||
companyProfiles?: ResponseCompanyProfileDto[];
|
||||
/**
|
||||
* Whether the owning portal user has submitted the onboarding wizard.
|
||||
* Approval decisions are blocked while this is false. Staff-created
|
||||
* companies (no external profiles) count as completed. Undefined when the
|
||||
* external profiles weren't loaded.
|
||||
*/
|
||||
onboardingCompleted?: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
|
||||
@@ -84,6 +91,10 @@ export class ResponseCompanyDto {
|
||||
this.companyProfiles = company.companyProfiles?.map(
|
||||
(p) => new ResponseCompanyProfileDto(p),
|
||||
);
|
||||
this.onboardingCompleted = company.profiles
|
||||
? company.profiles.length === 0 ||
|
||||
company.profiles.some((p) => p.onboardingCompleted)
|
||||
: undefined;
|
||||
this.createdAt = company.createdAt;
|
||||
this.updatedAt = company.updatedAt;
|
||||
}
|
||||
|
||||
@@ -30,12 +30,34 @@ export interface LicenseChangeIntent {
|
||||
fileName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A staged change to a company-level document, awaiting review. Same semantics
|
||||
* as {@link LicenseChangeIntent} but keyed by the document's FileRecord `code`
|
||||
* (e.g. `poa_delegation_letter`) rather than a profile: `add` → uploaded under
|
||||
* the pending code, promoted to `code` on approval; `remove` → a live file that
|
||||
* is deleted on approval. A replace is a `remove` plus an `add`.
|
||||
*/
|
||||
export interface DocumentChangeIntent {
|
||||
op: "add" | "remove";
|
||||
fileId: string;
|
||||
/** The live FileRecord code this op targets (the upload setting's fileKey). */
|
||||
code: string;
|
||||
/** File name, snapshotted for the backoffice review screen. */
|
||||
fileName?: string;
|
||||
}
|
||||
|
||||
/** File references staged alongside a change request (documents/licenses). */
|
||||
export interface ChangeRequestDocuments {
|
||||
/** FileRecord ids uploaded against the company while this request was open. */
|
||||
/**
|
||||
* FileRecord ids uploaded against the company while this request was open.
|
||||
* These go live immediately — only their ids are recorded, for the reviewer.
|
||||
* Contrast `documentChanges`, which stages the file behind the pending code.
|
||||
*/
|
||||
documentFileIds?: string[];
|
||||
/** Staged per-profile business-license add/remove intents. */
|
||||
licenseChanges?: LicenseChangeIntent[];
|
||||
/** Staged company-level document add/remove intents (e.g. the PoA letter). */
|
||||
documentChanges?: DocumentChangeIntent[];
|
||||
}
|
||||
|
||||
@Entity({ schema: "freight", name: "company_change_request" })
|
||||
|
||||
@@ -31,17 +31,28 @@ export interface BusinessLicenseFile {
|
||||
mimeType?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* `live` — approved & in effect; `pending_add` — uploaded, awaiting approval;
|
||||
* `pending_remove` — live but flagged for deletion on approval.
|
||||
*/
|
||||
export type StagedFileStatus = "live" | "pending_add" | "pending_remove";
|
||||
|
||||
/** A business-license file plus its change-review state, surfaced to clients. */
|
||||
export interface ProfileLicenseFileView {
|
||||
id: string;
|
||||
name: string;
|
||||
size: number;
|
||||
mimeType: string;
|
||||
/**
|
||||
* `live` — approved & in effect; `pending_add` — uploaded, awaiting approval;
|
||||
* `pending_remove` — live but flagged for deletion on approval.
|
||||
*/
|
||||
status: "live" | "pending_add" | "pending_remove";
|
||||
status: StagedFileStatus;
|
||||
}
|
||||
|
||||
/** A company-level document (e.g. the PoA letter) with its change-review state. */
|
||||
export interface CompanyDocumentFileView {
|
||||
id: string;
|
||||
name: string;
|
||||
size: number;
|
||||
mimeType: string;
|
||||
status: StagedFileStatus;
|
||||
}
|
||||
|
||||
@Entity({ schema: "freight", name: "company_profiles" })
|
||||
@@ -73,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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// apps/edr-freight-api/src/modules/container-management/containers.service.ts
|
||||
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { FindOptionsOrder, FindOptionsWhere, ILike, Repository } from 'typeorm';
|
||||
import { DataSource, FindOptionsOrder, FindOptionsWhere, ILike, Repository } from 'typeorm';
|
||||
import { CreateContainerDto } from './dto/create-container.dto';
|
||||
import { UpdateContainerDto } from './dto/update-container.dto';
|
||||
import { AssignContainerToWagonDto } from './dto/assign-container-to-wagon.dto';
|
||||
@@ -18,6 +18,7 @@ export class ContainersService {
|
||||
private readonly wagonRepo: Repository<Wagon>, // ✅ use raw repository
|
||||
@InjectRepository(ContainerType)
|
||||
private readonly containerTypeRepo: Repository<ContainerType>,
|
||||
private readonly dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
async create(dto: CreateContainerDto): Promise<Container> {
|
||||
@@ -115,24 +116,42 @@ export class ContainersService {
|
||||
if (container.status === 'LOADED') {
|
||||
throw new ConflictException('Cannot reassign a loaded container');
|
||||
}
|
||||
// Reject a container that is already placed on a wagon — it must be
|
||||
// unassigned first, otherwise it would silently jump to another wagon.
|
||||
if (container.wagonId) {
|
||||
throw new ConflictException(
|
||||
`Container ${containerId} is already assigned to wagon ${container.wagonId}`,
|
||||
);
|
||||
}
|
||||
|
||||
const wagon = await this.wagonRepo.findOne({ where: { id: dto.wagonId } });
|
||||
if (!wagon) throw new NotFoundException(`Wagon ${dto.wagonId} not found`);
|
||||
|
||||
let position: number | null = dto.position ?? null;
|
||||
if (position === null) {
|
||||
const maxPos = await this.containerRepo
|
||||
.createQueryBuilder('c')
|
||||
.select('MAX(c.position)', 'max')
|
||||
.where('c.wagonId = :wagonId', { wagonId: wagon.id })
|
||||
.getRawOne();
|
||||
position = (maxPos?.max ?? 0) + 1;
|
||||
}
|
||||
// The MAX(position)+1 allocation is check-then-act: two concurrent assigns can
|
||||
// read the same MAX and collide on the same position. Do the read + save inside
|
||||
// one transaction to narrow the race window.
|
||||
// TODO: add a unique (wagon_id, position) DB index so the database itself
|
||||
// rejects a colliding position even under concurrency.
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
const containerRepo = manager.getRepository(Container);
|
||||
|
||||
container.wagonId = wagon.id;
|
||||
container.position = position;
|
||||
container.status = 'AVAILABLE';
|
||||
return this.containerRepo.save(container);
|
||||
let position: number | null = dto.position ?? null;
|
||||
if (position === null) {
|
||||
const maxPos = await containerRepo
|
||||
.createQueryBuilder('c')
|
||||
.select('MAX(c.position)', 'max')
|
||||
.where('c.wagonId = :wagonId', { wagonId: wagon.id })
|
||||
.getRawOne<{ max: number | null }>();
|
||||
position = (maxPos?.max ?? 0) + 1;
|
||||
}
|
||||
|
||||
container.wagonId = wagon.id;
|
||||
container.position = position;
|
||||
// Placing a container on a wagon does not make it AVAILABLE. The status enum
|
||||
// (AVAILABLE, LOADED, IN_TRANSIT, MAINTENANCE, DAMAGED) has no ASSIGNED/ON_WAGON
|
||||
// state, so leave the existing status unchanged rather than forcing AVAILABLE.
|
||||
return containerRepo.save(container);
|
||||
});
|
||||
}
|
||||
|
||||
async unassignFromWagon(containerId: string): Promise<Container> {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { BadRequestException, Injectable, NotFoundException } from "@nestjs/comm
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
import { ContractRendererService } from "../../contracts/contract-renderer.service";
|
||||
import { RateSchedule } from "../../contracts/contract-rate-schedule.builder";
|
||||
import { getTemplateMeta } from "../../contracts/contract-template.registry";
|
||||
import {
|
||||
ContractDynamicTemplateView,
|
||||
@@ -177,17 +178,9 @@ export class ContractTemplatesService {
|
||||
const isBulk = code.endsWith("_BULK");
|
||||
const now = new Date();
|
||||
|
||||
const unitRates = isBulk
|
||||
? [
|
||||
{ label: "Rail transport — per metric ton", unitPrice: 59.4, unit: "ton", currency: "USD" },
|
||||
{ label: "Origin handling and documentation", unitPrice: 18, unit: "ton", currency: "USD" },
|
||||
{ label: "Lashing material (when provided by EDR)", unitPrice: 150, unit: "unit", currency: "USD" },
|
||||
]
|
||||
: [
|
||||
{ label: "Rail transport — 40ft container", unitPrice: 1916, unit: "container", currency: "USD" },
|
||||
{ label: "Rail transport — 2 × 20ft containers", unitPrice: 1944, unit: "container", currency: "USD" },
|
||||
{ label: "Excess tonnage surcharge", unitPrice: 10, unit: "ton", currency: "USD" },
|
||||
];
|
||||
// Representative rate schedule so the admin preview shows the live-rate
|
||||
// table shape. Real contracts populate this from freight.rates (LIVE).
|
||||
const rateSchedule = this.mockRateSchedule(code, isBulk);
|
||||
|
||||
return {
|
||||
bookingId: "00000000-0000-0000-0000-000000000000",
|
||||
@@ -239,13 +232,16 @@ export class ContractTemplatesService {
|
||||
lastMileDeliveryAddress: "—",
|
||||
},
|
||||
pricing: {
|
||||
displayMode: "UNIT_RATES",
|
||||
unitRates,
|
||||
lineItems: [],
|
||||
surcharges: [],
|
||||
totalAmount: 0,
|
||||
currency: "USD",
|
||||
equipmentReturn: isBulk ? "—" : "With empty return",
|
||||
originLabel: isBulk ? "Nagad Railway Station" : "SGTD Freight Station",
|
||||
destinationLabel: "Galaan Multipurpose Port (GMP)",
|
||||
containerLines: [],
|
||||
} as unknown as ContractViewModel["pricing"],
|
||||
rateSchedule,
|
||||
signatures: [],
|
||||
canSignCustomer: false,
|
||||
canSignStaff: false,
|
||||
@@ -256,6 +252,43 @@ export class ContractTemplatesService {
|
||||
};
|
||||
}
|
||||
|
||||
/** Static, representative rate schedule for the admin preview only. */
|
||||
private mockRateSchedule(code: ContractTemplateCode, isBulk: boolean): RateSchedule {
|
||||
const dir = code.startsWith("IMPORT")
|
||||
? "import"
|
||||
: code.startsWith("EXPORT")
|
||||
? "export"
|
||||
: "domestic";
|
||||
const lane =
|
||||
dir === "export"
|
||||
? "Galaan Multipurpose Port → SGTD"
|
||||
: dir === "domestic"
|
||||
? "Mojo Dry Port → Dire Dawa"
|
||||
: "Negad → Mojo Dry Port";
|
||||
|
||||
const freightLanes = isBulk
|
||||
? [
|
||||
{ route: lane, cargo: "Wheat", currency: "USD", amount: "100", unit: "per wagon" },
|
||||
]
|
||||
: [
|
||||
{ route: lane, cargo: "40ft GP", currency: "USD", amount: "200", unit: "per container" },
|
||||
{ route: lane, cargo: "20ft GP", currency: "USD", amount: "180", unit: "per container" },
|
||||
];
|
||||
|
||||
return {
|
||||
freightLanes,
|
||||
additionalServices: [
|
||||
{ route: "First-mile pickup by truck", cargo: "—", currency: "USD", amount: "50", unit: "per container" },
|
||||
{ route: "Last-mile delivery by truck", cargo: "—", currency: "USD", amount: "50", unit: "per container" },
|
||||
],
|
||||
surcharges: [
|
||||
{ route: "Customs clearance service", cargo: "—", currency: "USD", amount: "120", unit: "flat" },
|
||||
],
|
||||
isEmpty: false,
|
||||
currencyLabel: "USD",
|
||||
};
|
||||
}
|
||||
|
||||
private assertCode(code: string): ContractTemplateCode {
|
||||
const upper = code?.toUpperCase() as ContractTemplateCode;
|
||||
if (!CONTRACT_TEMPLATE_CODES.includes(upper)) {
|
||||
|
||||
@@ -22,12 +22,15 @@ export class BookingRequestRepository extends BaseRepository<BookingRequest> {
|
||||
});
|
||||
}
|
||||
|
||||
/** GL queue: pending requests across all contracts, oldest first. */
|
||||
async findPending(): Promise<BookingRequest[]> {
|
||||
/**
|
||||
* GL queue: every request across all contracts, newest first. The queue page
|
||||
* filters by status client-side (pending work vs accepted/rejected history),
|
||||
* and surfaces the customer — so the contract's company rides along.
|
||||
*/
|
||||
async findQueue(): Promise<BookingRequest[]> {
|
||||
return this.repository.find({
|
||||
where: { status: 'PENDING' },
|
||||
order: { createdAt: 'ASC' },
|
||||
relations: { contract: true },
|
||||
order: { createdAt: 'DESC' },
|
||||
relations: { contract: { company: true } },
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -113,6 +113,17 @@ export class BookingRequestService {
|
||||
},
|
||||
};
|
||||
|
||||
// Clearance-first flow: the request immediately initiates a BARE booking
|
||||
// instance (no cargo, no date, no price) that enters per-booking phased
|
||||
// customs clearance. GL no longer screens the request up front — it
|
||||
// reviews the documents in the clearance queue and completes the booking
|
||||
// (container numbers, VGM, shipment day) once clearance is ready. The
|
||||
// instance is created first so a failure leaves no half-linked request.
|
||||
const booking = await this.contractBookingService.initiateForShipmentRequest(
|
||||
contract,
|
||||
{ contractRouteId: dto.contractRouteId, userId },
|
||||
);
|
||||
|
||||
const reference = await this.generateReference();
|
||||
const request = await this.repo.create({
|
||||
reference,
|
||||
@@ -120,7 +131,8 @@ export class BookingRequestService {
|
||||
requestedByUserId: userId ?? null,
|
||||
contractRouteId: dto.contractRouteId ?? null,
|
||||
scheduledDate: dto.scheduledDate ? new Date(dto.scheduledDate) : null,
|
||||
status: 'PENDING',
|
||||
status: 'ACCEPTED',
|
||||
createdBookingId: booking.id,
|
||||
requestedLines,
|
||||
notes: dto.notes ?? null,
|
||||
} as never);
|
||||
@@ -139,7 +151,7 @@ export class BookingRequestService {
|
||||
}
|
||||
|
||||
queue(): Promise<BookingRequest[]> {
|
||||
return this.repo.findPending();
|
||||
return this.repo.findQueue();
|
||||
}
|
||||
|
||||
private async findPending(requestId: string): Promise<BookingRequest> {
|
||||
@@ -187,6 +199,8 @@ export class BookingRequestService {
|
||||
reviewedByStaffId: staffId ?? null,
|
||||
reviewedAt: new Date(),
|
||||
} as never);
|
||||
const contract = await this.contractsService.findById(request.contractId);
|
||||
this.notifier.shipmentRequestRejected(contract, request.reference, note);
|
||||
return (await this.repo.findById(requestId)) ?? request;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
import { Injectable, Logger, UnprocessableEntityException } from '@nestjs/common';
|
||||
import { OnEvent } from '@nestjs/event-emitter';
|
||||
import { Freight } from '@edr/types';
|
||||
|
||||
import { BillingService, InvoiceEventPayload } from '../billing/billing.service';
|
||||
import { Invoice } from '../billing/entities/invoice.entity';
|
||||
import { BookingsRepository } from '../bookings/bookings.repository';
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
import { ContractPricingBreakdown } from './contract-pricing.service';
|
||||
import { ContractNotifierService } from './contract-notifier.service';
|
||||
import { ContractsRepository } from './contracts.repository';
|
||||
import { Contract } from './entities/contract.entity';
|
||||
|
||||
/** Invoice `type` for the contract-level fee (Path B ONE_TIME, after counter-sign). */
|
||||
export const CLEARANCE_CONTRACT_INVOICE_TYPE = 'CLEARANCE_CONTRACT';
|
||||
/** Invoice `type` for the per-shipment fee (Path B GENERAL, at shipment request). */
|
||||
export const CLEARANCE_BOOKING_INVOICE_TYPE = 'CLEARANCE_BOOKING';
|
||||
|
||||
/**
|
||||
* The prepaid customs clearance service fee (Path B) — the GL service charge,
|
||||
* separate from both freight (booking invoice) and duty/tax (paid offline).
|
||||
* Issued as its own `clearance`-source invoice and paid BEFORE the clearance
|
||||
* document step opens and before GL touches the file:
|
||||
* - ONE_TIME: once per contract, at staff counter-sign
|
||||
* (AWAITING_CLEARANCE_PAYMENT → paid → AWAITING_CLEARANCE_DOCUMENTS);
|
||||
* - GENERAL: once per shipment request, on the initiated booking instance
|
||||
* (booking AWAITING_CLEARANCE_PAYMENT → paid → AWAITING_DOCUMENTS).
|
||||
* The fee amount is the frozen CUSTOMS_CLEARANCE contract rate snapshot, so
|
||||
* customers pay what their contract shows, not the live rate of the day.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ClearanceFeeService {
|
||||
private readonly logger = new Logger(ClearanceFeeService.name);
|
||||
|
||||
constructor(
|
||||
private readonly billing: BillingService,
|
||||
private readonly contractsRepository: ContractsRepository,
|
||||
private readonly bookingsRepository: BookingsRepository,
|
||||
private readonly notifier: ContractNotifierService,
|
||||
) {}
|
||||
|
||||
/** The frozen flat fee for a contract; falls back to the pricing breakdown. */
|
||||
private async feeAmountOrNull(
|
||||
contract: Contract,
|
||||
): Promise<{ amount: number; currency: string } | null> {
|
||||
const snapshots = await this.contractsRepository.findRateSnapshots(contract.id);
|
||||
const snapshot = snapshots.find(
|
||||
(s) => s.isClearance || s.rateCode === 'CUSTOMS_CLEARANCE',
|
||||
);
|
||||
if (snapshot && Number(snapshot.unitPrice) > 0) {
|
||||
return { amount: Number(snapshot.unitPrice), currency: snapshot.currency };
|
||||
}
|
||||
const breakdown = contract.pricingBreakdown as ContractPricingBreakdown | null;
|
||||
const line = breakdown?.lineItems?.find((l) => l.code === 'CUSTOMS_CLEARANCE');
|
||||
if (line && Number(line.unitPrice) > 0) {
|
||||
return { amount: Number(line.unitPrice), currency: breakdown!.currency };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private async feeAmount(
|
||||
contract: Contract,
|
||||
): Promise<{ amount: number; currency: string }> {
|
||||
const fee = await this.feeAmountOrNull(contract);
|
||||
if (!fee) {
|
||||
throw new UnprocessableEntityException(
|
||||
`Contract ${contract.reference} has no frozen customs clearance fee — regenerate its price with a live CUSTOMS_CLEARANCE rate.`,
|
||||
);
|
||||
}
|
||||
return fee;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the payment gate applies. Skipped for government/unlinked
|
||||
* contracts (no company to bill — invoices require one, same rule the
|
||||
* booking invoice applies) and for legacy customs contracts frozen before
|
||||
* the fee existed (no CUSTOMS_CLEARANCE snapshot to bill from) — both keep
|
||||
* the pre-fee flow instead of dead-ending.
|
||||
*/
|
||||
async gateApplies(contract: Contract): Promise<boolean> {
|
||||
// Customs disabled → the prepay gate genuinely does not apply.
|
||||
if (!contract.customsClearingEnabled) return false;
|
||||
// No company to bill (government / unlinked) → the gate cannot raise an
|
||||
// invoice, so it stays out of the flow (same rule the booking invoice uses).
|
||||
if (!contract.companyId) return false;
|
||||
// M26: customs IS enabled and billable. A missing frozen fee line must NOT
|
||||
// silently waive the gate — that ships clearance for free. Hard-fail exactly
|
||||
// as price generation does when no CUSTOMS_CLEARANCE rate is configured, so a
|
||||
// missing fee blocks counter-sign / shipment instead of bypassing payment.
|
||||
if ((await this.feeAmountOrNull(contract)) === null) {
|
||||
throw new UnprocessableEntityException(
|
||||
'No customs clearance service fee is configured. Ask the rates team to set a live CUSTOMS_CLEARANCE rate before submitting customs contracts.',
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Issue (idempotently) the ONE_TIME contract-level fee invoice. */
|
||||
async issueForContract(contract: Contract): Promise<Invoice> {
|
||||
const existing = await this.billing.findPayable(
|
||||
Freight.InvoiceSource.Clearance,
|
||||
contract.id,
|
||||
CLEARANCE_CONTRACT_INVOICE_TYPE,
|
||||
);
|
||||
if (existing) return existing;
|
||||
|
||||
const { amount, currency } = await this.feeAmount(contract);
|
||||
const invoice = await this.billing.generateInvoice({
|
||||
source: Freight.InvoiceSource.Clearance,
|
||||
sourceId: contract.id,
|
||||
type: CLEARANCE_CONTRACT_INVOICE_TYPE,
|
||||
companyId: contract.companyId!,
|
||||
companyProfileId: contract.companyProfileId!,
|
||||
currency,
|
||||
lines: [
|
||||
{
|
||||
chargeType: 'CUSTOMS_CLEARANCE',
|
||||
description: `Customs clearance service fee — contract ${contract.reference}`,
|
||||
quantity: 1,
|
||||
unitRate: amount,
|
||||
amount,
|
||||
currency,
|
||||
},
|
||||
],
|
||||
status: Freight.InvoiceStatus.Pending,
|
||||
});
|
||||
this.notifier.clearanceFeeDue(contract, amount, currency);
|
||||
return invoice;
|
||||
}
|
||||
|
||||
/** Issue (idempotently) the GENERAL per-shipment fee invoice on the booking. */
|
||||
async issueForBooking(booking: Booking, contract: Contract): Promise<Invoice> {
|
||||
const existing = await this.billing.findPayable(
|
||||
Freight.InvoiceSource.Clearance,
|
||||
booking.id,
|
||||
CLEARANCE_BOOKING_INVOICE_TYPE,
|
||||
);
|
||||
if (existing) return existing;
|
||||
|
||||
const { amount, currency } = await this.feeAmount(contract);
|
||||
const invoice = await this.billing.generateInvoice({
|
||||
source: Freight.InvoiceSource.Clearance,
|
||||
sourceId: booking.id,
|
||||
type: CLEARANCE_BOOKING_INVOICE_TYPE,
|
||||
companyId: booking.companyId ?? contract.companyId!,
|
||||
companyProfileId: booking.companyProfileId ?? contract.companyProfileId!,
|
||||
currency,
|
||||
lines: [
|
||||
{
|
||||
chargeType: 'CUSTOMS_CLEARANCE',
|
||||
description: `Customs clearance service fee — shipment ${booking.reference}`,
|
||||
quantity: 1,
|
||||
unitRate: amount,
|
||||
amount,
|
||||
currency,
|
||||
},
|
||||
],
|
||||
status: Freight.InvoiceStatus.Pending,
|
||||
});
|
||||
this.notifier.clearanceFeeDue(contract, amount, currency, booking.reference);
|
||||
return invoice;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retire (idempotently) the unpaid contract-level fee invoice when the
|
||||
* contract reaches a terminal state — a dead contract must not leave a
|
||||
* payable clearance invoice open for the customer to settle. No-op when the
|
||||
* fee was already paid or never invoiced (mirrors the booking cancel path,
|
||||
* {@link BillingService.expirePayable}).
|
||||
*/
|
||||
async expireForContract(contractId: string): Promise<Invoice | null> {
|
||||
return this.billing.expirePayable(
|
||||
Freight.InvoiceSource.Clearance,
|
||||
contractId,
|
||||
CLEARANCE_CONTRACT_INVOICE_TYPE,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Settlement branch point for `clearance`-source invoices: unlock the
|
||||
* document-upload step the fee was gating. Idempotent — a replayed event on
|
||||
* an already-advanced contract/booking is a no-op.
|
||||
*/
|
||||
@OnEvent('clearance.invoice.paid')
|
||||
async onClearanceInvoicePaid(payload: InvoiceEventPayload): Promise<void> {
|
||||
this.logger.log(
|
||||
`clearance.invoice.paid (${payload.type}) for ${payload.sourceId} from ${payload.invoiceId}`,
|
||||
);
|
||||
switch (payload.type) {
|
||||
case CLEARANCE_CONTRACT_INVOICE_TYPE:
|
||||
await this.advanceContract(payload.sourceId);
|
||||
break;
|
||||
case CLEARANCE_BOOKING_INVOICE_TYPE:
|
||||
await this.advanceBooking(payload.sourceId);
|
||||
break;
|
||||
default:
|
||||
this.logger.warn(
|
||||
`Unhandled clearance invoice type "${payload.type}" paid (${payload.invoiceId})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async advanceContract(contractId: string): Promise<void> {
|
||||
const contract = await this.contractsRepository.findById(contractId);
|
||||
if (!contract) {
|
||||
this.logger.warn(`Cannot advance unknown contract ${contractId} on clearance fee payment.`);
|
||||
return;
|
||||
}
|
||||
if (contract.status !== 'AWAITING_CLEARANCE_PAYMENT') return;
|
||||
|
||||
await this.contractsRepository.update(contractId, {
|
||||
status: 'AWAITING_CLEARANCE_DOCUMENTS',
|
||||
clearanceStatus: 'AWAITING_DOCUMENTS',
|
||||
clearanceFeePaidAt: new Date(),
|
||||
} as never);
|
||||
const updated = await this.contractsRepository.findByIdWithRelations(contractId);
|
||||
if (updated) this.notifier.clearanceFeePaid(updated);
|
||||
}
|
||||
|
||||
private async advanceBooking(bookingId: string): Promise<void> {
|
||||
const booking = await this.bookingsRepository.findById(bookingId);
|
||||
if (!booking) {
|
||||
this.logger.warn(`Cannot advance unknown booking ${bookingId} on clearance fee payment.`);
|
||||
return;
|
||||
}
|
||||
if (booking.status !== 'AWAITING_CLEARANCE_PAYMENT') return;
|
||||
|
||||
await this.bookingsRepository.update(bookingId, {
|
||||
status: 'AWAITING_DOCUMENTS',
|
||||
clearanceFeePaidAt: new Date(),
|
||||
} as never);
|
||||
if (booking.contractId) {
|
||||
const contract = await this.contractsRepository.findByIdWithRelations(
|
||||
booking.contractId,
|
||||
);
|
||||
if (contract) this.notifier.clearanceFeePaid(contract, booking.reference);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -58,6 +58,31 @@ export class ClearanceMilestoneService {
|
||||
await this.seed(postBooking, { bookingId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed whichever pre/post-booking milestones the booking is still missing,
|
||||
* keyed by milestoneCode. Plain seeding is a blind insert, so paths that can
|
||||
* run more than once (completing an initiated instance whose pre-booking
|
||||
* milestones were seeded at initiation, or a consolidation pairing replay)
|
||||
* must go through this instead — a duplicate timeline breaks the phase
|
||||
* derivation.
|
||||
*/
|
||||
async ensureBookingMilestones(
|
||||
bookingId: string,
|
||||
tradeDirection: string,
|
||||
): Promise<void> {
|
||||
const existing = await this.repo.find({ where: { bookingId } });
|
||||
const have = new Set(existing.map((m) => m.milestoneCode));
|
||||
const { preBooking, postBooking } = splitMilestones(tradeDirection);
|
||||
await this.seed(
|
||||
preBooking.filter((d) => !have.has(d.code)),
|
||||
{ bookingId },
|
||||
);
|
||||
await this.seed(
|
||||
postBooking.filter((d) => !have.has(d.code)),
|
||||
{ bookingId },
|
||||
);
|
||||
}
|
||||
|
||||
private async seed(
|
||||
defs: MilestoneDef[],
|
||||
scope: { contractId?: string; clearanceCycleId?: string; bookingId?: string },
|
||||
|
||||
@@ -26,8 +26,11 @@ describe('ContractBookingService — quantity-cap completion', () => {
|
||||
{} as never, // milestoneService
|
||||
{} as never, // workflowService
|
||||
{} as never, // invoiceService
|
||||
{} as never, // clearanceFeeService
|
||||
{} as never, // dataSource
|
||||
{} as never, // trainSchedulingService
|
||||
{} as never, // bookingBatchService
|
||||
{} as never, // bookingTransitionService
|
||||
);
|
||||
return { service, contractsRepository };
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ describe('ContractBookingService — drawdown consolidation gate', () => {
|
||||
const milestoneService = {
|
||||
seedPostBookingMilestones: jest.fn().mockResolvedValue(undefined),
|
||||
seedPreBookingMilestonesOnBooking: jest.fn().mockResolvedValue(undefined),
|
||||
ensureBookingMilestones: jest.fn().mockResolvedValue(undefined),
|
||||
...overrides.milestoneService,
|
||||
};
|
||||
const contractsRepository = {
|
||||
@@ -56,8 +57,11 @@ describe('ContractBookingService — drawdown consolidation gate', () => {
|
||||
milestoneService as never,
|
||||
{} as never, // workflowService
|
||||
invoiceService as never,
|
||||
{} as never, // clearanceFeeService
|
||||
{} as never, // dataSource
|
||||
{} as never, // trainSchedulingService
|
||||
{} as never, // bookingBatchService
|
||||
{} as never, // bookingTransitionService
|
||||
);
|
||||
return {
|
||||
service,
|
||||
@@ -143,9 +147,13 @@ describe('ContractBookingService — drawdown consolidation gate', () => {
|
||||
await service.onConsolidationPaired({ bookingIds: ['b-1'] });
|
||||
|
||||
expect(invoiceService.ensureInvoiceForBooking).toHaveBeenCalledTimes(1);
|
||||
// GENERAL customs → per-booking pre + post milestones.
|
||||
expect(milestoneService.seedPreBookingMilestonesOnBooking).toHaveBeenCalled();
|
||||
expect(milestoneService.seedPostBookingMilestones).toHaveBeenCalled();
|
||||
// GENERAL customs → per-booking milestones, via the idempotent ensure so a
|
||||
// pairing replay (or an initiated instance's pre-seeded timeline) never
|
||||
// duplicates rows.
|
||||
expect(milestoneService.ensureBookingMilestones).toHaveBeenCalledWith(
|
||||
'b-1',
|
||||
'EXPORT',
|
||||
);
|
||||
});
|
||||
|
||||
it('onConsolidationPaired ignores a booking still PENDING_CONSOLIDATION', async () => {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -489,6 +489,11 @@ export class ContractClearanceService {
|
||||
files: Express.Multer.File[],
|
||||
): Promise<Contract> {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
if (contract.status === 'AWAITING_CLEARANCE_PAYMENT') {
|
||||
throw new ConflictException(
|
||||
'The customs clearance service fee has not been paid yet — pay it from the portal to unlock document upload.',
|
||||
);
|
||||
}
|
||||
if (
|
||||
contract.status !== 'AWAITING_CLEARANCE_DOCUMENTS' &&
|
||||
contract.status !== 'CLEARANCE_UNDER_REVIEW'
|
||||
@@ -848,8 +853,10 @@ export class ContractClearanceService {
|
||||
}
|
||||
|
||||
/**
|
||||
* GL ET clearance hub: every customs (Path B) contract in phased clearance,
|
||||
* including after booking is created.
|
||||
* GL ET clearance hub, Contracts tab: ONE_TIME customs (Path B) contracts in
|
||||
* phased clearance that already carry at least one uploaded clearance
|
||||
* document — a contract still waiting for its first document has nothing to
|
||||
* review, and GENERAL contracts clear per booking, not at contract level.
|
||||
*/
|
||||
async queue(filter: FilterContractDto): Promise<PaginatedContracts> {
|
||||
return this.contractsRepository.findAllPaginated({
|
||||
@@ -857,6 +864,8 @@ export class ContractClearanceService {
|
||||
pageSize: filter.pageSize ?? 100,
|
||||
statuses: [...PHASED_CUSTOMS_CONTRACT_QUEUE_STATUSES],
|
||||
customsClearingEnabled: true,
|
||||
contractKind: 'ONE_TIME',
|
||||
hasClearanceDocuments: true,
|
||||
sortBy: filter.sortBy,
|
||||
sortOrder: filter.sortOrder,
|
||||
});
|
||||
@@ -866,12 +875,38 @@ export class ContractClearanceService {
|
||||
* Operations queue: self-clearance (Path A) contracts awaiting Operations
|
||||
* review of the customer's own clearance documents.
|
||||
*/
|
||||
/**
|
||||
* Statuses a non-customs contract passes through around Operations
|
||||
* clearance review — the set a caller may narrow {@link opsQueue} to.
|
||||
*/
|
||||
private static readonly OPS_CLEARANCE_STATUSES = [
|
||||
'AWAITING_CLEARANCE_DOCUMENTS',
|
||||
'CLEARANCE_UNDER_REVIEW',
|
||||
'CLEARANCE_READY_FOR_BOOKING',
|
||||
'FULLY_EXECUTED',
|
||||
'CONTRACT_ACTIVE',
|
||||
'ACTIVE_SHIPMENT_IN_PROGRESS',
|
||||
'CONTRACT_CLOSED',
|
||||
'CANCELLED',
|
||||
];
|
||||
|
||||
async opsQueue(filter: FilterContractDto): Promise<PaginatedContracts> {
|
||||
// Callers may narrow to any subset of the ops-clearance lifecycle (the
|
||||
// hub's status filter sends an explicit list); anything outside the
|
||||
// whitelist is dropped so this endpoint can't become a general contract
|
||||
// browser. No statuses given → the original under-review queue.
|
||||
const requested = (filter.statuses ?? filter.status ?? '')
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter((s) =>
|
||||
ContractClearanceService.OPS_CLEARANCE_STATUSES.includes(s),
|
||||
);
|
||||
return this.contractsRepository.findAllPaginated({
|
||||
page: filter.page ?? 1,
|
||||
pageSize: filter.pageSize ?? 100,
|
||||
statuses: ['CLEARANCE_UNDER_REVIEW'],
|
||||
statuses: requested.length ? requested : ['CLEARANCE_UNDER_REVIEW'],
|
||||
customsClearingEnabled: false,
|
||||
search: filter.search,
|
||||
sortBy: filter.sortBy,
|
||||
sortOrder: filter.sortOrder,
|
||||
});
|
||||
@@ -896,6 +931,7 @@ export class ContractClearanceService {
|
||||
pageSize: filter.pageSize ?? 50,
|
||||
statuses: ['CLEARANCE_READY_FOR_BOOKING', 'ACTIVE', 'CLOSED', 'CANCELLED'],
|
||||
customsClearingEnabled: false,
|
||||
search: filter.search,
|
||||
sortBy: filter.sortBy ?? 'createdAt',
|
||||
sortOrder: filter.sortOrder ?? 'DESC',
|
||||
});
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import { DataSource } from 'typeorm';
|
||||
import {
|
||||
NotificationAudience,
|
||||
NotificationType,
|
||||
@@ -8,6 +10,7 @@ import {
|
||||
import { Contract } from './entities/contract.entity';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
|
||||
import { resolveCompanyNotifyPhone } from '../notifications/resolve-company-phone.util';
|
||||
|
||||
/**
|
||||
* Customer + staff notifications for the contract lifecycle. Every customer
|
||||
@@ -24,6 +27,8 @@ export class ContractNotifierService {
|
||||
constructor(
|
||||
private readonly notifications: NotificationsService,
|
||||
private readonly inbox: NotificationInboxService,
|
||||
@InjectDataSource()
|
||||
private readonly dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
private ref(c: Contract): string {
|
||||
@@ -37,7 +42,9 @@ export class ContractNotifierService {
|
||||
logLabel: string,
|
||||
): Promise<void> {
|
||||
this.logger.log(`${logLabel} — ${this.ref(c)}`);
|
||||
const phone = c.company?.contactPersonPhone ?? c.company?.phone ?? null;
|
||||
const phone = c.companyId
|
||||
? await resolveCompanyNotifyPhone(this.dataSource, c.companyId)
|
||||
: null;
|
||||
const email = c.company?.email ?? c.company?.generalManagerEmail ?? null;
|
||||
|
||||
if (phone) {
|
||||
@@ -145,6 +152,39 @@ export class ContractNotifierService {
|
||||
this.inApp(c, 'Contract changes requested', msg);
|
||||
}
|
||||
|
||||
/** GL rejected a shipment request filed under the contract. */
|
||||
shipmentRequestRejected(c: Contract, requestRef: string, note?: string): void {
|
||||
const msg =
|
||||
`Your shipment request ${requestRef} under contract ${c.reference} was rejected.` +
|
||||
(note ? ` Reason: ${note}.` : '') +
|
||||
` Please contact us for details.`;
|
||||
void this.notifyContact(c, msg, 'SHIPMENT REQUEST REJECTED');
|
||||
this.inApp(c, 'Shipment request rejected', msg, {
|
||||
type: NotificationType.BOOKING_STATUS,
|
||||
data: { contractId: c.id, reference: requestRef },
|
||||
});
|
||||
}
|
||||
|
||||
/** Clearance service fee invoiced — customer must pay before document upload. */
|
||||
clearanceFeeDue(c: Contract, amount: number, currency: string, shipmentRef?: string): void {
|
||||
const scope = shipmentRef ? `shipment ${shipmentRef} under contract ${c.reference}` : `contract ${c.reference}`;
|
||||
const msg =
|
||||
`A customs clearance service fee of ${amount} ${currency} is due for ${scope}. ` +
|
||||
`Please pay from the portal to unlock the clearance document upload.`;
|
||||
void this.notifyContact(c, msg, 'CLEARANCE FEE DUE');
|
||||
this.inApp(c, 'Clearance fee due', msg);
|
||||
}
|
||||
|
||||
/** Clearance service fee settled — document upload is now open. */
|
||||
clearanceFeePaid(c: Contract, shipmentRef?: string): void {
|
||||
const scope = shipmentRef ? `shipment ${shipmentRef} under contract ${c.reference}` : `contract ${c.reference}`;
|
||||
const msg =
|
||||
`Your customs clearance service fee for ${scope} has been received. ` +
|
||||
`You can now upload the clearance documents from the portal.`;
|
||||
void this.notifyContact(c, msg, 'CLEARANCE FEE PAID');
|
||||
this.inApp(c, 'Clearance fee paid', msg);
|
||||
}
|
||||
|
||||
// ── Clearance milestones needing customer action ──────────────────────────
|
||||
|
||||
/** GL advised duty & tax on the contract cycle — customer pays + uploads slip. */
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Injectable, UnprocessableEntityException } from '@nestjs/common';
|
||||
|
||||
import { RatesService } from '../rule-engine/services/rates.service';
|
||||
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
|
||||
@@ -15,6 +15,11 @@ export interface ContractUnitRateLineItem {
|
||||
containerSize?: string | null;
|
||||
conditionalOn?: string | null;
|
||||
cargoTypeCode?: string | null;
|
||||
/**
|
||||
* Customs clearance service fee — billed separately in advance (before the
|
||||
* clearance document step), never part of shipment booking totals.
|
||||
*/
|
||||
isClearance?: boolean;
|
||||
}
|
||||
|
||||
/** The contract `pricing_breakdown` shape (doc §9.1). */
|
||||
@@ -80,9 +85,9 @@ export class ContractPricingService {
|
||||
const sizes = (contract.cargoScope ?? [])
|
||||
.map((c) => c.containerSize)
|
||||
.filter((s): s is string => !!s);
|
||||
const { data: containerTypes } = await this.containerTypesService.findAll({
|
||||
const { items: containerTypes } = await this.containerTypesService.findAll({
|
||||
isActive: true,
|
||||
pageSize: 500,
|
||||
pageSize: 100,
|
||||
});
|
||||
for (const size of sizes) {
|
||||
const sizeFt = size === '40ft' ? 40 : 20;
|
||||
@@ -183,6 +188,50 @@ export class ContractPricingService {
|
||||
});
|
||||
}
|
||||
}
|
||||
// Empty-container return service — container contracts only, toggled on the
|
||||
// contract like hazard/reefer. Billed at booking per WITH_RETURN container.
|
||||
if (
|
||||
contract.freightType === 'CONTAINER' &&
|
||||
contract.equipmentReturn === 'WITH_RETURN'
|
||||
) {
|
||||
const withReturn = liveRates.find(
|
||||
(r) => r.rateType === 'RETURN_SURCHARGE' && r.currency === 'USD',
|
||||
);
|
||||
if (withReturn && Number(withReturn.rateValue) > 0) {
|
||||
lineItems.push({
|
||||
code: 'RETURN_SURCHARGE',
|
||||
label: 'Empty container return',
|
||||
unit: toContractUnit(withReturn.rateUnit),
|
||||
unitPrice: convert(Number(withReturn.rateValue)),
|
||||
conditionalOn: 'with_return',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Customs clearance service fee (Path B) — a FLAT prepaid fee, shown on the
|
||||
// contract and billed via its own clearance invoice: after counter-sign for
|
||||
// ONE_TIME, per shipment request for GENERAL. Excluded from booking totals.
|
||||
// A customs contract may not proceed without a configured live rate.
|
||||
if (contract.customsClearingEnabled) {
|
||||
const clearance = liveRates.find(
|
||||
(r) => r.rateType === 'CUSTOMS_CLEARANCE' && r.currency === 'USD',
|
||||
);
|
||||
if (!clearance || Number(clearance.rateValue) <= 0) {
|
||||
throw new UnprocessableEntityException(
|
||||
'No customs clearance service fee is configured. Ask the rates team to set a live CUSTOMS_CLEARANCE rate before submitting customs contracts.',
|
||||
);
|
||||
}
|
||||
lineItems.push({
|
||||
code: 'CUSTOMS_CLEARANCE',
|
||||
label:
|
||||
contract.contractKind === 'GENERAL'
|
||||
? 'Customs clearance service fee (per shipment request, prepaid)'
|
||||
: 'Customs clearance service fee (prepaid)',
|
||||
unit: toContractUnit(clearance.rateUnit),
|
||||
unitPrice: convert(Number(clearance.rateValue)),
|
||||
isClearance: true,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
displayMode: 'UNIT_RATES',
|
||||
@@ -229,6 +278,7 @@ export class ContractPricingService {
|
||||
containerSize: line.containerSize ?? null,
|
||||
isSurcharge: !!line.conditionalOn,
|
||||
conditionalOn: line.conditionalOn ?? null,
|
||||
isClearance: !!line.isClearance,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,9 @@ import {
|
||||
Injectable,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { Readable } from 'stream';
|
||||
import { insertWithGeneratedReference } from '@edr/api-common';
|
||||
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
||||
@@ -21,16 +24,36 @@ import { DropdownSettingsService } from '../dropdown-settings/dropdown-settings.
|
||||
import { FilesService } from '../files/files.service';
|
||||
import { SignaturesService } from '../signatures/signatures.service';
|
||||
import { OtpService } from '../otp/otp.service';
|
||||
import { ContractTemplatesService } from '../contract-templates/contract-templates.service';
|
||||
import { ContractPricingService } from './contract-pricing.service';
|
||||
import { ClearanceFeeService } from './clearance-fee.service';
|
||||
import { ContractNotifierService } from './contract-notifier.service';
|
||||
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
||||
import { ContractsRepository } from './contracts.repository';
|
||||
import { ContractsService } from './contracts.service';
|
||||
import { contractClearanceSettingCode } from './contract-clearance.util';
|
||||
import { Contract } from './entities/contract.entity';
|
||||
import {
|
||||
Contract,
|
||||
ContractDocumentArticle,
|
||||
ContractDocumentSnapshot,
|
||||
ContractDocumentSnapshotInput,
|
||||
} from './entities/contract.entity';
|
||||
import { ContractSignerRole } from './entities/contract-signature.entity';
|
||||
import { SignContractDto } from './dto/sign-contract.dto';
|
||||
|
||||
/** The editable contract-document draft returned for the accept/edit dialog. */
|
||||
export interface ContractDocumentDraft {
|
||||
documentTitle: string | null;
|
||||
whereasClauses: string[];
|
||||
articles: ContractDocumentArticle[];
|
||||
code: string | null;
|
||||
name: string | null;
|
||||
/** True once the document may no longer be edited/regenerated. */
|
||||
locked: boolean;
|
||||
generatedAt: Date | null;
|
||||
status: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dropdown-settings code holding the admin-configured contract validity options
|
||||
* (each option's `value` is a day count). The staff accept dialog reads the same
|
||||
@@ -39,6 +62,17 @@ import { SignContractDto } from './dto/sign-contract.dto';
|
||||
*/
|
||||
const CONTRACT_VALIDITY_PERIODS_CODE = 'contract_validity_periods';
|
||||
|
||||
/**
|
||||
* Mask a phone for display — keep the last 4 digits, star the rest
|
||||
* (`+251986680099` → `•••••••0099`). Used to tell the customer WHERE the signing
|
||||
* code went without echoing the company's full registered number back to the UI.
|
||||
*/
|
||||
function maskPhone(phone: string): string {
|
||||
const trimmed = phone.trim();
|
||||
if (trimmed.length <= 4) return trimmed;
|
||||
return `${'•'.repeat(trimmed.length - 4)}${trimmed.slice(-4)}`;
|
||||
}
|
||||
|
||||
/** Status-machine guard mirroring booking-status.util. */
|
||||
function assertContractStatus(contract: Contract, allowed: string[]): void {
|
||||
if (!allowed.includes(contract.status)) {
|
||||
@@ -68,8 +102,43 @@ export class ContractTransitionService {
|
||||
private readonly minioService: MinioService,
|
||||
private readonly otpService: OtpService,
|
||||
private readonly notifier: ContractNotifierService,
|
||||
private readonly contractTemplates: ContractTemplatesService,
|
||||
private readonly clearanceFeeService: ClearanceFeeService,
|
||||
@InjectDataSource()
|
||||
private readonly dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* The phone the signing OTP is sent to and verified against: the signer's own
|
||||
* IAM account number.
|
||||
*
|
||||
* H12(b): resolved server-side from the authenticated user id, never from the
|
||||
* request body — a caller-supplied number would let an attacker point the code
|
||||
* at their own phone. Ownership is already gated separately by
|
||||
* {@link ContractsService.assertCustomerCanAccessContract}, so this binds the
|
||||
* signature to the *person* signing rather than to a company landline that may
|
||||
* be shared, stale, or imported from eTrade.
|
||||
*/
|
||||
private async resolveSignerPhone(signerUserId?: string): Promise<string> {
|
||||
if (!signerUserId) {
|
||||
// Unreachable in practice (the ownership gate rejects a missing user
|
||||
// first), but never fall back to another number if it ever changes.
|
||||
throw new BadRequestException('Authentication required to sign');
|
||||
}
|
||||
const rows: Array<{ phone_number: string | null }> =
|
||||
await this.dataSource.query(
|
||||
`SELECT phone_number FROM iam.users WHERE id = $1 AND is_active = true`,
|
||||
[signerUserId],
|
||||
);
|
||||
const phone = rows[0]?.phone_number?.trim();
|
||||
if (!phone) {
|
||||
throw new BadRequestException(
|
||||
'Your account has no registered phone number. Add one in Settings → Account before signing.',
|
||||
);
|
||||
}
|
||||
return phone;
|
||||
}
|
||||
|
||||
/** Customer submits the contract for approval → SUBMITTED; freeze unit rates. */
|
||||
async submit(contractId: string): Promise<Contract> {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
@@ -110,6 +179,7 @@ export class ContractTransitionService {
|
||||
contractId: string,
|
||||
actorId: string,
|
||||
validityDays: number,
|
||||
documentSnapshot?: ContractDocumentSnapshotInput | null,
|
||||
): Promise<Contract> {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
assertContractStatus(contract, ['SUBMITTED']);
|
||||
@@ -128,6 +198,12 @@ export class ContractTransitionService {
|
||||
|
||||
await this.instantiateApprovalSteps(contract);
|
||||
|
||||
// Freeze the contract document for THIS contract only. Staff may have edited
|
||||
// the articles in the accept dialog; otherwise the live template is captured
|
||||
// as-is so later template edits never change an in-flight contract. The
|
||||
// shared six templates are never written here.
|
||||
const snapshot = await this.resolveDocumentSnapshot(contract, documentSnapshot);
|
||||
|
||||
await this.contractsRepository.update(contractId, {
|
||||
status: 'PENDING_APPROVAL',
|
||||
approvedByStaffId: actorId,
|
||||
@@ -135,12 +211,148 @@ export class ContractTransitionService {
|
||||
contractValidityDays: validityDays,
|
||||
contractValidFrom: validFrom,
|
||||
contractValidUntil: validUntil,
|
||||
documentSnapshot: snapshot,
|
||||
} as never);
|
||||
const updated = await this.contractsService.findById(contractId);
|
||||
this.notifier.accepted(updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
// ── Per-contract document snapshot (US: edit articles for one contract) ─────
|
||||
|
||||
/**
|
||||
* The editable document draft for the accept/edit dialog: the frozen snapshot
|
||||
* if one exists, else the live active template resolved for this contract's
|
||||
* direction/freight pair. `locked` flips true once the document may no longer
|
||||
* be edited (an approver has acted, or the contract has left the pre-approval
|
||||
* window).
|
||||
*/
|
||||
async getContractDocumentDraft(
|
||||
contractId: string,
|
||||
): Promise<ContractDocumentDraft> {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
const snapshot =
|
||||
(contract.documentSnapshot as ContractDocumentSnapshot | null) ??
|
||||
(await this.resolveDocumentSnapshot(contract));
|
||||
return {
|
||||
documentTitle: snapshot?.documentTitle ?? null,
|
||||
whereasClauses: snapshot?.whereasClauses ?? [],
|
||||
articles: snapshot?.articles ?? [],
|
||||
code: snapshot?.code ?? null,
|
||||
name: snapshot?.name ?? null,
|
||||
locked: !this.documentIsEditable(contract),
|
||||
generatedAt: contract.contractGeneratedAt ?? null,
|
||||
status: contract.status,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace this contract's document articles from the editor. Per-contract
|
||||
* only — it writes the contract's own snapshot and never the shared templates.
|
||||
* Allowed while the document is still editable (PENDING_APPROVAL, no approver
|
||||
* has acted).
|
||||
*/
|
||||
async updateContractDocument(
|
||||
contractId: string,
|
||||
input: ContractDocumentSnapshotInput,
|
||||
): Promise<Contract> {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
assertContractStatus(contract, ['PENDING_APPROVAL']);
|
||||
this.assertDocumentEditable(contract);
|
||||
|
||||
const current =
|
||||
(contract.documentSnapshot as ContractDocumentSnapshot | null) ??
|
||||
(await this.resolveDocumentSnapshot(contract));
|
||||
const merged: ContractDocumentSnapshotInput = {
|
||||
code: current?.code ?? null,
|
||||
name: input.name ?? current?.name ?? null,
|
||||
documentTitle: input.documentTitle ?? current?.documentTitle ?? null,
|
||||
whereasClauses: input.whereasClauses ?? current?.whereasClauses ?? [],
|
||||
articles: input.articles ?? current?.articles ?? [],
|
||||
};
|
||||
await this.contractsRepository.update(contractId, {
|
||||
documentSnapshot: this.normalizeSnapshot(merged),
|
||||
} as never);
|
||||
return this.contractsService.findById(contractId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the per-contract document snapshot. Prefer the staff's edited articles
|
||||
* from the dialog; otherwise freeze the active template matching the
|
||||
* contract's direction/freight. Returns null when no active template exists
|
||||
* (the renderer then falls back to the built-in generic layout at render time).
|
||||
*/
|
||||
private async resolveDocumentSnapshot(
|
||||
contract: Contract,
|
||||
provided?: ContractDocumentSnapshotInput | null,
|
||||
): Promise<ContractDocumentSnapshot | null> {
|
||||
if (provided && (provided.articles?.length ?? 0) > 0) {
|
||||
return this.normalizeSnapshot(provided);
|
||||
}
|
||||
const active = await this.contractTemplates.findActiveForContract(
|
||||
contract.tradeDirection,
|
||||
contract.freightType,
|
||||
);
|
||||
if (!active) return null;
|
||||
return {
|
||||
code: active.code,
|
||||
name: active.name,
|
||||
documentTitle: active.documentTitle,
|
||||
whereasClauses: active.whereasClauses ?? [],
|
||||
articles: this.normalizeArticles(active.articles ?? []),
|
||||
};
|
||||
}
|
||||
|
||||
private normalizeSnapshot(
|
||||
input: ContractDocumentSnapshotInput,
|
||||
): ContractDocumentSnapshot {
|
||||
return {
|
||||
code: input.code ?? null,
|
||||
name: input.name ?? null,
|
||||
documentTitle: input.documentTitle ?? null,
|
||||
whereasClauses: Array.isArray(input.whereasClauses)
|
||||
? input.whereasClauses
|
||||
.map((c) => String(c))
|
||||
.filter((c) => c.trim().length > 0)
|
||||
: [],
|
||||
articles: this.normalizeArticles(input.articles ?? []),
|
||||
};
|
||||
}
|
||||
|
||||
/** Re-key ids and renumber order sequentially, dropping empty-title rows. */
|
||||
private normalizeArticles(
|
||||
articles: Array<{ id?: string; title?: string; body?: string; order?: number }>,
|
||||
): ContractDocumentArticle[] {
|
||||
return articles
|
||||
.filter((a) => (a.title ?? '').trim().length > 0 || (a.body ?? '').trim().length > 0)
|
||||
.map((a, index) => ({
|
||||
id: a.id ?? randomUUID(),
|
||||
title: (a.title ?? '').trim(),
|
||||
body: a.body ?? '',
|
||||
order: index + 1,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* The per-contract document may be edited/regenerated while the contract is at
|
||||
* the accept stage (SUBMITTED) or in approval with NO approver having acted
|
||||
* yet. The first approval action freezes it.
|
||||
*/
|
||||
private documentIsEditable(contract: Contract): boolean {
|
||||
if (contract.status === 'SUBMITTED') return true;
|
||||
if (contract.status !== 'PENDING_APPROVAL') return false;
|
||||
return !(contract.approvalSteps ?? []).some((s) => s.status !== 'PENDING');
|
||||
}
|
||||
|
||||
private assertDocumentEditable(contract: Contract): void {
|
||||
if (!this.documentIsEditable(contract)) {
|
||||
throw new ConflictException(
|
||||
'The contract document is locked — an approver has already acted or the ' +
|
||||
'contract has advanced. It can no longer be edited or regenerated.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the chosen validity (days) is one of the admin-configured options in
|
||||
* the `contract_validity_periods` dropdown setting. If the setting is missing
|
||||
@@ -242,6 +454,10 @@ export class ContractTransitionService {
|
||||
actorId,
|
||||
'STAFF',
|
||||
);
|
||||
// Stop the open-invoice leak: a rejected contract must not leave a payable
|
||||
// clearance fee invoice open. Mirror the booking cancel path (billing.expirePayable).
|
||||
await this.clearanceFeeService.expireForContract(contractId);
|
||||
|
||||
await this.contractsRepository.update(contractId, {
|
||||
status: 'REJECTED',
|
||||
} as never);
|
||||
@@ -280,6 +496,10 @@ export class ContractTransitionService {
|
||||
'STAFF',
|
||||
);
|
||||
|
||||
// Stop the open-invoice leak: a rejected contract must not leave a payable
|
||||
// clearance fee invoice open. Mirror the booking cancel path (billing.expirePayable).
|
||||
await this.clearanceFeeService.expireForContract(contractId);
|
||||
|
||||
await this.contractsRepository.update(contractId, {
|
||||
status: 'REJECTED',
|
||||
} as never);
|
||||
@@ -303,6 +523,15 @@ export class ContractTransitionService {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
assertContractStatus(contract, ['PENDING_APPROVAL', 'APPROVED_PENDING_SIGNATURE']);
|
||||
|
||||
// Approvers review the generated contract document, so it must exist before
|
||||
// the first approval can be recorded. Staff generate it (from the frozen,
|
||||
// optionally-edited snapshot) at the accept stage.
|
||||
if (contract.status === 'PENDING_APPROVAL' && !contract.contractGeneratedAt) {
|
||||
throw new BadRequestException(
|
||||
'Generate the contract document before it can be approved.',
|
||||
);
|
||||
}
|
||||
|
||||
const step = await this.contractsRepository.findApprovalStepById(contractId, stepId);
|
||||
if (!step || step.status !== 'PENDING') {
|
||||
throw new BadRequestException('Approval step not found or already actioned');
|
||||
@@ -350,15 +579,14 @@ export class ContractTransitionService {
|
||||
const updated = await this.contractsService.findById(contractId);
|
||||
if (allDone) {
|
||||
this.notifier.approved(updated);
|
||||
// Final approval step also generates the contract document from the
|
||||
// template matching the contract's direction/freight pair. Best-effort:
|
||||
// a rendering hiccup must not roll back the approval — the document can
|
||||
// still be generated manually or lazily on view/download.
|
||||
// Every step approved → CONTRACT_READY. The document was already generated
|
||||
// (and reviewed) at the accept stage, so we reuse it rather than
|
||||
// re-rendering. Best-effort: a hiccup must not roll back the approval.
|
||||
try {
|
||||
return await this.generateContract(contractId);
|
||||
return await this.finalizeApprovedContract(contractId);
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Auto contract generation after final approval failed for ${updated.reference}: ${err}`,
|
||||
`Finalizing contract after final approval failed for ${updated.reference}: ${err}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -366,30 +594,66 @@ export class ContractTransitionService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the contract PDF from the Contract aggregate, store it via FilesService,
|
||||
* stamp the template key, and move to CONTRACT_READY. PDF rendering (Puppeteer/
|
||||
* Chromium) is best-effort and must NOT block the contract from becoming ready —
|
||||
* the document is (re)rendered lazily on view/download once Chromium is available.
|
||||
* Staff (re)generate the contract PDF. Two stages:
|
||||
* - PENDING_APPROVAL: render from the frozen (optionally staff-edited)
|
||||
* snapshot so approvers review the real document. Status is UNCHANGED, and
|
||||
* it is blocked once an approver has acted (the document is then locked).
|
||||
* - APPROVED / APPROVED_PENDING_SIGNATURE (fallback): render and advance to
|
||||
* CONTRACT_READY.
|
||||
* PDF rendering (Puppeteer/Chromium) is best-effort and never blocks the
|
||||
* transition — the document re-renders lazily on view/download.
|
||||
*/
|
||||
async generateContract(contractId: string): Promise<Contract> {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
|
||||
if (contract.status === 'PENDING_APPROVAL') {
|
||||
this.assertDocumentEditable(contract);
|
||||
await this.renderContractDocument(contract);
|
||||
return this.contractsService.findById(contractId);
|
||||
}
|
||||
|
||||
assertContractStatus(contract, ['APPROVED', 'APPROVED_PENDING_SIGNATURE']);
|
||||
await this.renderContractDocument(contract);
|
||||
await this.contractsRepository.update(contractId, {
|
||||
status: 'CONTRACT_READY',
|
||||
} as never);
|
||||
return this.contractsService.findById(contractId);
|
||||
}
|
||||
|
||||
const { view } = await this.documentViewModelBuilder.build(contractId);
|
||||
|
||||
/**
|
||||
* Render the contract PDF from the Contract aggregate (snapshot-driven), store
|
||||
* it via FilesService, and stamp the template key + generated timestamp. Never
|
||||
* changes status. Rendering is best-effort — a Chromium hiccup defers the file
|
||||
* (it re-renders on view/download) but the timestamp is still stamped.
|
||||
*/
|
||||
private async renderContractDocument(contract: Contract): Promise<void> {
|
||||
const { view } = await this.documentViewModelBuilder.build(contract.id);
|
||||
try {
|
||||
await this.upsertContractPdf(contractId, contract.reference, view);
|
||||
await this.upsertContractPdf(contract.id, contract.reference, view);
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Contract PDF deferred for ${contract.reference}: ${err}. It will render on view/download once Chromium is available.`,
|
||||
);
|
||||
}
|
||||
|
||||
await this.contractsRepository.update(contractId, {
|
||||
status: 'CONTRACT_READY',
|
||||
await this.contractsRepository.update(contract.id, {
|
||||
contractTemplateKey: view.templateKey,
|
||||
contractGeneratedAt: new Date(),
|
||||
} as never);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every approval step landed → CONTRACT_READY. The document was already
|
||||
* generated (and reviewed) at the accept stage, so reuse it; render now only
|
||||
* if it was somehow never generated. Never re-renders over an existing file.
|
||||
*/
|
||||
private async finalizeApprovedContract(contractId: string): Promise<Contract> {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
if (!contract.contractGeneratedAt) {
|
||||
await this.renderContractDocument(contract);
|
||||
}
|
||||
await this.contractsRepository.update(contractId, {
|
||||
status: 'CONTRACT_READY',
|
||||
} as never);
|
||||
return this.contractsService.findById(contractId);
|
||||
}
|
||||
|
||||
@@ -574,6 +838,31 @@ export class ContractTransitionService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send the sudo-mode signing OTP to the SIGNER's own registered phone — the
|
||||
* same number {@link sign} verifies against. The client never picks the number
|
||||
* (that is the H12(b) trust property): it only asks us to send, and we resolve
|
||||
* the phone from the authenticated user id. Returns a masked hint so the UI can
|
||||
* say where the code went without exposing the full number.
|
||||
*/
|
||||
async sendSigningOtp(
|
||||
contractId: string,
|
||||
options: { signerUserId?: string },
|
||||
): Promise<{ sentTo: string }> {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
// Same ownership gate as signing — only the owning company's customer may
|
||||
// trigger a code for this contract.
|
||||
await this.contractsService.assertCustomerCanAccessContract(
|
||||
options.signerUserId,
|
||||
contract,
|
||||
);
|
||||
assertContractStatus(contract, ['CONTRACT_READY']);
|
||||
|
||||
const signerPhone = await this.resolveSignerPhone(options.signerUserId);
|
||||
await this.otpService.sendOtp({ phone: signerPhone });
|
||||
return { sentTo: maskPhone(signerPhone) };
|
||||
}
|
||||
|
||||
/** Customer signs the ready contract → SIGNED_CUSTOMER. */
|
||||
async sign(
|
||||
contractId: string,
|
||||
@@ -583,17 +872,32 @@ export class ContractTransitionService {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
|
||||
if (dto.role === 'CUSTOMER') {
|
||||
// H12(a): only the owning company's customer may sign — assert ownership
|
||||
// before anything else (hidden as NotFound otherwise). A signing customer
|
||||
// has no permission key, so this is the gate that binds the sign to the
|
||||
// contract's company.
|
||||
await this.contractsService.assertCustomerCanAccessContract(
|
||||
options.signerUserId,
|
||||
contract,
|
||||
);
|
||||
assertContractStatus(contract, ['CONTRACT_READY']);
|
||||
const existing = await this.contractsRepository.findSignature(contractId, 'CUSTOMER');
|
||||
if (existing) {
|
||||
throw new BadRequestException('Customer has already signed this contract');
|
||||
}
|
||||
// Sudo-mode gate: a fresh, single-use OTP (SMS'd to the customer's phone)
|
||||
// must be verified before the signature is applied.
|
||||
if (!dto.otpPhone || !dto.otp) {
|
||||
// Sudo-mode gate: a fresh, single-use OTP must be verified before the
|
||||
// signature is applied. H12(b): verify against the SIGNER's own registered
|
||||
// phone, resolved server-side from the authenticated user id — never a
|
||||
// caller-supplied number, which an attacker could point at their own
|
||||
// phone. Ownership is already asserted above, so this proves the specific
|
||||
// person holding the account is present, not merely that someone reached a
|
||||
// shared company line. Must resolve identically to sendSigningOtp, or send
|
||||
// and verify would target different numbers.
|
||||
const signerPhone = await this.resolveSignerPhone(options.signerUserId);
|
||||
if (!dto.otp) {
|
||||
throw new BadRequestException('OTP verification is required to sign the contract');
|
||||
}
|
||||
await this.otpService.verifyOtpForAction({ phone: dto.otpPhone }, dto.otp);
|
||||
await this.otpService.verifyOtpForAction({ phone: signerPhone }, dto.otp);
|
||||
await this.applySignature(contract, dto, options);
|
||||
await this.contractsRepository.update(contractId, {
|
||||
status: 'SIGNED_CUSTOMER',
|
||||
@@ -658,8 +962,17 @@ export class ContractTransitionService {
|
||||
const cycleNumber = (contract.clearanceCycleNumber ?? 0) + 1;
|
||||
const cycle = await this.contractsRepository.openCycle(contractId, cycleNumber);
|
||||
await this.milestoneService.seedPreBookingMilestones(contract, cycle.id);
|
||||
updates.status = 'AWAITING_CLEARANCE_DOCUMENTS';
|
||||
updates.clearanceStatus = 'AWAITING_DOCUMENTS';
|
||||
// Path B prepay gate: the customs clearance service fee is invoiced here
|
||||
// and must settle before the document step opens (the paid event advances
|
||||
// to AWAITING_CLEARANCE_DOCUMENTS). Path A (self-clearance) has no GL fee.
|
||||
if (await this.clearanceFeeService.gateApplies(contract)) {
|
||||
await this.clearanceFeeService.issueForContract(contract);
|
||||
updates.status = 'AWAITING_CLEARANCE_PAYMENT';
|
||||
updates.clearanceStatus = 'AWAITING_PAYMENT';
|
||||
} else {
|
||||
updates.status = 'AWAITING_CLEARANCE_DOCUMENTS';
|
||||
updates.clearanceStatus = 'AWAITING_DOCUMENTS';
|
||||
}
|
||||
updates.clearanceCycleNumber = cycleNumber;
|
||||
} else {
|
||||
// No contract-level clearance gate — DOMESTIC, or any GENERAL contract
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
Res,
|
||||
UnauthorizedException,
|
||||
@@ -57,6 +58,7 @@ import { UpdateContractDto } from './dto/update-contract.dto';
|
||||
import { FilterContractDto } from './dto/filter-contract.dto';
|
||||
import { ContractListSummaryDto } from './dto/contract-list-summary.dto';
|
||||
import { AcceptContractDto } from './dto/accept-contract.dto';
|
||||
import { UpdateContractDocumentDto } from './dto/contract-document.dto';
|
||||
import {
|
||||
ApproveStepDto,
|
||||
RejectContractDto,
|
||||
@@ -107,7 +109,7 @@ export class ContractsController {
|
||||
|
||||
@Get('booking-requests/queue')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.createBooking)
|
||||
@ApiOperation({ summary: 'GL queue: pending shipment requests across contracts' })
|
||||
@ApiOperation({ summary: 'GL queue: shipment requests across contracts (all statuses, newest first)' })
|
||||
bookingRequestQueue() {
|
||||
return this.bookingRequestService.queue();
|
||||
}
|
||||
@@ -340,9 +342,33 @@ export class ContractsController {
|
||||
id,
|
||||
resolveAuthUserId(user),
|
||||
dto.validityDays,
|
||||
dto.documentSnapshot,
|
||||
);
|
||||
}
|
||||
|
||||
@Get(':id/document/draft')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.staffAccept)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Editable contract-document draft (this contract\'s snapshot, or the live template) for the accept/edit dialog',
|
||||
})
|
||||
getContractDocumentDraft(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.transitionService.getContractDocumentDraft(id);
|
||||
}
|
||||
|
||||
@Put(':id/document/articles')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.staffAccept)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Edit this contract\'s document articles only (per-contract; never touches the six shared templates)',
|
||||
})
|
||||
updateContractDocument(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: UpdateContractDocumentDto,
|
||||
) {
|
||||
return this.transitionService.updateContractDocument(id, dto);
|
||||
}
|
||||
|
||||
@Post(':id/staff/request-changes')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.requestChanges)
|
||||
@ApiOperation({ summary: 'Staff return contract for customer updates' })
|
||||
@@ -473,6 +499,19 @@ export class ContractsController {
|
||||
stream.pipe(res);
|
||||
}
|
||||
|
||||
@Post(':id/contract/send-signing-otp')
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Send the sudo-mode signing OTP to the contract company's registered phone (server picks the number)",
|
||||
})
|
||||
sendSigningOtp(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
return this.transitionService.sendSigningOtp(id, { signerUserId: user?.id });
|
||||
}
|
||||
|
||||
@Post(':id/contract/sign')
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiOperation({ summary: 'Apply digital signature (customer or staff/director/ceo)' })
|
||||
@@ -498,12 +537,18 @@ export class ContractsController {
|
||||
|
||||
@Post(':id/renew')
|
||||
@ApiOperation({ summary: 'Create a renewal draft linked via renewalOfId' })
|
||||
renew(
|
||||
async renew(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() _dto: RenewContractDto,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
return this.transitionService.renew(id, user?.id ?? user?.sub);
|
||||
// H12(c): a customer may only renew a contract their company owns. Staff
|
||||
// with bookings.view bypass, mirroring getContractView/downloadContractDocument.
|
||||
const contract = await this.contractsService.findById(id);
|
||||
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
|
||||
await this.contractsService.assertCustomerCanAccessContract(user?.id, contract);
|
||||
}
|
||||
return this.transitionService.renew(id, resolveAuthUserId(user));
|
||||
}
|
||||
|
||||
// ── Pre-booking clearance (Path B, doc §15.2.1) ────────────────────────────
|
||||
@@ -518,10 +563,17 @@ export class ContractsController {
|
||||
@UseInterceptors(AnyFilesInterceptor())
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiOperation({ summary: 'Customer uploads clearance documents (fieldname = document key)' })
|
||||
uploadClearanceDocuments(
|
||||
async uploadClearanceDocuments(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
@UploadedFiles() files: Express.Multer.File[],
|
||||
) {
|
||||
// H12(c): only the owning company's customer may upload clearance docs.
|
||||
// Staff with bookings.view bypass, mirroring the other contract handlers.
|
||||
const contract = await this.contractsService.findById(id);
|
||||
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
|
||||
await this.contractsService.assertCustomerCanAccessContract(user?.id, contract);
|
||||
}
|
||||
return this.clearanceService.uploadDocuments(id, files ?? []);
|
||||
}
|
||||
|
||||
@@ -799,6 +851,45 @@ export class ContractsController {
|
||||
);
|
||||
}
|
||||
|
||||
@Post(':id/bookings/initiate')
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Initiate a bare booking instance under a GENERAL non-customs contract — no cargo, no date; enters per-booking clearance (AWAITING_DOCUMENTS).',
|
||||
})
|
||||
initiateBooking(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: CreateBookingUnderContractDto,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
return this.contractBookingService.initiateUnderContract(
|
||||
id,
|
||||
{ contractRouteId: dto?.contractRouteId },
|
||||
{ id: user?.id ?? user?.sub },
|
||||
user,
|
||||
);
|
||||
}
|
||||
|
||||
@Post(':id/bookings/:bookingId/complete')
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Complete an initiated booking after Operations finalized its clearance — cargo + binding day, window and departure checks, pricing and invoicing.',
|
||||
})
|
||||
completeBooking(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param('bookingId', ParseUUIDPipe) bookingId: string,
|
||||
@Body() dto: CreateBookingUnderContractDto,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
// Customs (Path B) instances may only be completed by GL Ethiopia — the
|
||||
// service checks the actor's contracts:create_booking permission.
|
||||
return this.contractBookingService.completeUnderContract(
|
||||
id,
|
||||
bookingId,
|
||||
dto,
|
||||
user,
|
||||
);
|
||||
}
|
||||
|
||||
@Post(':id/validate-shipment')
|
||||
@ApiOperation({
|
||||
summary:
|
||||
@@ -813,11 +904,12 @@ export class ContractsController {
|
||||
|
||||
@Get(':id/capacity')
|
||||
@ApiOperation({
|
||||
summary: 'Remaining bookable quantity per cargo line (GENERAL draw-down cap)',
|
||||
summary:
|
||||
'Remaining bookable quantity per cargo line (GENERAL draw-down cap, or the outstanding remainder of a split ONE_TIME contract)',
|
||||
})
|
||||
async capacity(@Param('id', ParseUUIDPipe) id: string) {
|
||||
const contract = await this.contractsService.findById(id);
|
||||
return this.contractBookingService.computeCapacity(contract);
|
||||
return this.contractBookingService.capacityView(contract);
|
||||
}
|
||||
|
||||
// ── Clearance milestones (doc §11.3, §12.2) ────────────────────────────────
|
||||
|
||||
@@ -22,6 +22,7 @@ import { ContractsController } from './contracts.controller';
|
||||
import { ContractsService } from './contracts.service';
|
||||
import { ContractsRepository } from './contracts.repository';
|
||||
import { ContractPricingService } from './contract-pricing.service';
|
||||
import { ClearanceFeeService } from './clearance-fee.service';
|
||||
import { ContractNotifierService } from './contract-notifier.service';
|
||||
import { ContractTransitionService } from './contract-transition.service';
|
||||
import { ContractClearanceService } from './contract-clearance.service';
|
||||
@@ -103,6 +104,7 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum
|
||||
ContractsService,
|
||||
ContractsRepository,
|
||||
ContractPricingService,
|
||||
ClearanceFeeService,
|
||||
ContractNotifierService,
|
||||
ContractTransitionService,
|
||||
ContractClearanceService,
|
||||
|
||||
@@ -26,6 +26,8 @@ export interface ContractListFilterOptions {
|
||||
tradeDirection?: string;
|
||||
paymentCurrency?: string;
|
||||
customsClearingEnabled?: boolean;
|
||||
/** true → only contracts with at least one uploaded clearance document. */
|
||||
hasClearanceDocuments?: boolean;
|
||||
createdFrom?: string;
|
||||
createdTo?: string;
|
||||
}
|
||||
@@ -98,6 +100,7 @@ export class ContractsRepository extends BaseRepository<Contract> {
|
||||
options: ContractListFilterOptions & {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
search?: string;
|
||||
sortBy?: string;
|
||||
sortOrder?: 'ASC' | 'DESC';
|
||||
},
|
||||
@@ -128,6 +131,16 @@ export class ContractsRepository extends BaseRepository<Contract> {
|
||||
|
||||
this.applyListFilters(qb, options);
|
||||
|
||||
// Free-text search across contract reference and customer (company) name.
|
||||
// Applied here (not in applyListFilters) because only this query joins the
|
||||
// `company` alias — the summary-metrics query builder does not.
|
||||
if (options.search) {
|
||||
qb.andWhere(
|
||||
'(contract.reference ILIKE :search OR company.name ILIKE :search)',
|
||||
{ search: `%${options.search}%` },
|
||||
);
|
||||
}
|
||||
|
||||
const sortField =
|
||||
options.sortBy === 'contractValidUntil'
|
||||
? 'contract.contractValidUntil'
|
||||
@@ -191,17 +204,27 @@ export class ContractsRepository extends BaseRepository<Contract> {
|
||||
private async attachClearancePhases(contracts: Contract[]): Promise<void> {
|
||||
if (contracts.length === 0) return;
|
||||
const ids = contracts.map((c) => c.id);
|
||||
const rows: Array<{ contract_id: string; current_phase: string | null }> =
|
||||
await this.dataSource.query(
|
||||
`SELECT DISTINCT ON (contract_id) contract_id, current_phase
|
||||
FROM freight.contract_clearance_cycles
|
||||
WHERE contract_id = ANY($1)
|
||||
ORDER BY contract_id, cycle_number DESC`,
|
||||
[ids],
|
||||
);
|
||||
const byContract = new Map(rows.map((r) => [r.contract_id, r.current_phase]));
|
||||
const rows: Array<{
|
||||
contract_id: string;
|
||||
current_phase: string | null;
|
||||
booking_id: string | null;
|
||||
booking_status: string | null;
|
||||
}> = await this.dataSource.query(
|
||||
`SELECT DISTINCT ON (ccc.contract_id)
|
||||
ccc.contract_id, ccc.current_phase,
|
||||
b.id AS booking_id, b.status AS booking_status
|
||||
FROM freight.contract_clearance_cycles ccc
|
||||
LEFT JOIN freight.bookings b ON b.id = ccc.booking_id
|
||||
WHERE ccc.contract_id = ANY($1)
|
||||
ORDER BY ccc.contract_id, ccc.cycle_number DESC`,
|
||||
[ids],
|
||||
);
|
||||
const byContract = new Map(rows.map((r) => [r.contract_id, r]));
|
||||
for (const contract of contracts) {
|
||||
contract.clearancePhase = byContract.get(contract.id) ?? null;
|
||||
const row = byContract.get(contract.id);
|
||||
contract.clearancePhase = row?.current_phase ?? null;
|
||||
contract.latestCycleBookingId = row?.booking_id ?? null;
|
||||
contract.latestCycleBookingStatus = row?.booking_status ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -273,6 +296,12 @@ export class ContractsRepository extends BaseRepository<Contract> {
|
||||
customsClearingEnabled: options.customsClearingEnabled,
|
||||
});
|
||||
}
|
||||
if (options.hasClearanceDocuments) {
|
||||
qb.andWhere(
|
||||
'EXISTS (SELECT 1 FROM freight.contract_document_review cdr ' +
|
||||
'WHERE cdr.contract_id = contract.id AND cdr.deleted_at IS NULL)',
|
||||
);
|
||||
}
|
||||
if (options.serviceTypeId) {
|
||||
qb.andWhere('contract.service_type_id = :serviceTypeId', {
|
||||
serviceTypeId: options.serviceTypeId,
|
||||
|
||||
@@ -579,6 +579,7 @@ export class ContractsService {
|
||||
paymentCurrency: filter.paymentCurrency,
|
||||
createdFrom: filter.createdFrom,
|
||||
createdTo: filter.createdTo,
|
||||
search: filter.search,
|
||||
sortBy: filter.sortBy,
|
||||
sortOrder: filter.sortOrder,
|
||||
});
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsInt, Max, Min } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsInt, IsOptional, Max, Min, ValidateNested } from 'class-validator';
|
||||
|
||||
import { UpdateContractDocumentDto } from './contract-document.dto';
|
||||
|
||||
export class AcceptContractDto {
|
||||
@ApiProperty({
|
||||
@@ -14,4 +17,16 @@ export class AcceptContractDto {
|
||||
@Min(1)
|
||||
@Max(3650)
|
||||
validityDays!: number;
|
||||
|
||||
/**
|
||||
* Optional per-contract document override edited by staff in the accept
|
||||
* dialog. When present its articles are frozen onto THIS contract; when
|
||||
* omitted the live template is snapshotted as-is. Never edits the shared
|
||||
* six templates.
|
||||
*/
|
||||
@ApiPropertyOptional({ type: UpdateContractDocumentDto })
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => UpdateContractDocumentDto)
|
||||
documentSnapshot?: UpdateContractDocumentDto;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsArray,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
|
||||
/** One article of a per-contract document override sent from the editor. */
|
||||
export class ContractDocumentArticleDto {
|
||||
@ApiPropertyOptional({ description: 'Stable id; omitted for a new article.' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
id?: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
title!: string;
|
||||
|
||||
@ApiProperty({ description: 'Plain multiline body; each line becomes a clause.' })
|
||||
@IsString()
|
||||
body!: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
order?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The per-contract document override sent from the accept/edit editor. It edits
|
||||
* ONLY this contract's frozen snapshot — it is never written back to the shared
|
||||
* six {@link ContractTemplate} rows.
|
||||
*/
|
||||
export class UpdateContractDocumentDto {
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
code?: string | null;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
name?: string | null;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
documentTitle?: string | null;
|
||||
|
||||
@ApiPropertyOptional({ type: [String] })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
whereasClauses?: string[];
|
||||
|
||||
@ApiProperty({ type: [ContractDocumentArticleDto] })
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => ContractDocumentArticleDto)
|
||||
articles!: ContractDocumentArticleDto[];
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsDateString,
|
||||
IsIn,
|
||||
IsInt,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
@@ -14,6 +15,9 @@ import {
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
|
||||
/** Per-shipment equipment return — "NA" stays contract-level only. */
|
||||
const SHIPMENT_EQUIPMENT_RETURNS = ['WITH_RETURN', 'WITHOUT_RETURN'] as const;
|
||||
|
||||
/** One physical container under a booking line — entered at booking time. */
|
||||
export class CreateContainerUnitDto {
|
||||
@ApiProperty({ description: 'ISO 6346 container number, e.g. ABCD1234567' })
|
||||
@@ -73,6 +77,18 @@ export class CreateBookingContainerLineDto {
|
||||
@Transform(({ value }) => Number(value))
|
||||
reeferQuantity?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
minimum: 0,
|
||||
description:
|
||||
'How many units of this line ship with empty-container return (≤ quantity). ' +
|
||||
'Only allowed when the contract was created WITH_RETURN (container freight).',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Transform(({ value }) => Number(value))
|
||||
returnQuantity?: number;
|
||||
|
||||
@ApiProperty({ type: [CreateContainerUnitDto] })
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@@ -134,6 +150,15 @@ export class CreateBookingUnderContractDto {
|
||||
@IsDateString()
|
||||
scheduledDate?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
enum: SHIPMENT_EQUIPMENT_RETURNS,
|
||||
description:
|
||||
'Per-shipment equipment return override; omitted → the contract default applies.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsIn([...SHIPMENT_EQUIPMENT_RETURNS])
|
||||
equipmentReturn?: string;
|
||||
|
||||
@ApiPropertyOptional({ type: [CreateBookingContainerLineDto] })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
|
||||
@@ -22,7 +22,10 @@ import { CONTRACT_KINDS } from '../entities/contract.entity';
|
||||
const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'DOMESTIC'] as const;
|
||||
const FREIGHT_TYPES = ['CONTAINER', 'BULK'] as const;
|
||||
const PAYMENT_CURRENCIES = ['ETB', 'USD'] as const;
|
||||
const EQUIPMENT_RETURNS = ['with_return', 'without_return'] as const;
|
||||
// Canonical UPPERCASE — everything downstream (booking gating, pricing
|
||||
// surcharge, GL/portal booking forms) compares contract.equipmentReturn
|
||||
// against 'WITH_RETURN'/'WITHOUT_RETURN'. Lowercase input is normalized.
|
||||
const EQUIPMENT_RETURNS = ['WITH_RETURN', 'WITHOUT_RETURN'] as const;
|
||||
|
||||
export {
|
||||
CONTRACT_KINDS,
|
||||
@@ -161,6 +164,9 @@ export class CreateContractDto {
|
||||
|
||||
@ApiPropertyOptional({ enum: EQUIPMENT_RETURNS })
|
||||
@IsOptional()
|
||||
@Transform(({ value }) =>
|
||||
typeof value === 'string' ? value.toUpperCase() : value,
|
||||
)
|
||||
@IsIn([...EQUIPMENT_RETURNS])
|
||||
equipmentReturn?: string;
|
||||
|
||||
|
||||
@@ -71,6 +71,15 @@ export class FilterContractDto {
|
||||
@IsDateString()
|
||||
createdTo?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Free-text search across contract reference and company name.',
|
||||
})
|
||||
@IsOptional()
|
||||
@Transform(({ value }) =>
|
||||
typeof value === 'string' && value.trim() ? value.trim() : undefined,
|
||||
)
|
||||
search?: string;
|
||||
|
||||
@ApiPropertyOptional({ default: 1 })
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => (value ? parseInt(value, 10) : 1))
|
||||
|
||||
@@ -28,17 +28,13 @@ export class SignContractDto {
|
||||
consentText?: string;
|
||||
|
||||
// Sudo-mode OTP challenge. Required when role=CUSTOMER: a fresh 6-digit code
|
||||
// SMS'd to the signer's phone, verified server-side before the signature is
|
||||
// applied. `otpPhone` is the number the code was sent to (the signed-in
|
||||
// customer's registered phone).
|
||||
// SMS'd to the signer's registered phone, verified server-side before the
|
||||
// signature is applied. The number itself is deliberately NOT part of this
|
||||
// DTO — the server resolves it from the authenticated user id, so a caller
|
||||
// cannot redirect the challenge to a phone they control.
|
||||
@ApiPropertyOptional({ description: '6-digit OTP; required when role=CUSTOMER' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Matches(/^\d{6}$/, { message: 'otp must be 6 digits' })
|
||||
otp?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Phone the OTP was sent to; required when role=CUSTOMER' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
otpPhone?: string;
|
||||
}
|
||||
|
||||
@@ -44,4 +44,11 @@ export class ContractRateSnapshot extends BaseEntity {
|
||||
/** is_hazardous | is_reefer when this is a conditional surcharge. */
|
||||
@Column({ name: 'conditional_on', type: 'varchar', length: 32, nullable: true })
|
||||
conditionalOn?: string | null;
|
||||
|
||||
/**
|
||||
* Customs clearance service fee line — billed up front via a clearance
|
||||
* invoice, excluded from shipment booking totals.
|
||||
*/
|
||||
@Column({ name: 'is_clearance', type: 'boolean', default: false })
|
||||
isClearance!: boolean;
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ export const CONTRACT_STATUSES = [
|
||||
'SIGNED_CUSTOMER',
|
||||
'FULLY_EXECUTED',
|
||||
'CONTRACT_ACTIVE',
|
||||
'AWAITING_CLEARANCE_PAYMENT', // Path B — clearance fee invoiced, unpaid
|
||||
'AWAITING_CLEARANCE_DOCUMENTS',
|
||||
'CLEARANCE_UNDER_REVIEW',
|
||||
'CLEARANCE_READY_FOR_BOOKING',
|
||||
@@ -42,11 +43,49 @@ export const CONTRACT_STATUSES = [
|
||||
|
||||
export type ContractStatus = (typeof CONTRACT_STATUSES)[number];
|
||||
|
||||
/** One article on a per-contract document snapshot (mirrors the template shape). */
|
||||
export interface ContractDocumentArticle {
|
||||
id: string;
|
||||
title: string;
|
||||
body: string;
|
||||
order: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A per-contract copy of the resolved contract-document template, frozen when
|
||||
* staff accept the contract for approval. Staff may edit these articles for a
|
||||
* single contract in the accept/edit dialog — editing NEVER writes back to the
|
||||
* shared six {@link ContractTemplate} rows. The PDF is rendered from this
|
||||
* snapshot when present; a null snapshot renders from the live template.
|
||||
*/
|
||||
export interface ContractDocumentSnapshot {
|
||||
code?: string | null;
|
||||
name?: string | null;
|
||||
documentTitle?: string | null;
|
||||
whereasClauses: string[];
|
||||
articles: ContractDocumentArticle[];
|
||||
}
|
||||
|
||||
/** Loose inbound shape (article ids/order optional) — normalized before store. */
|
||||
export interface ContractDocumentSnapshotInput {
|
||||
code?: string | null;
|
||||
name?: string | null;
|
||||
documentTitle?: string | null;
|
||||
whereasClauses?: string[];
|
||||
articles?: Array<{
|
||||
id?: string;
|
||||
title?: string;
|
||||
body?: string;
|
||||
order?: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export const CONTRACT_KINDS = ['ONE_TIME', 'GENERAL'] as const;
|
||||
export type ContractKindValue = (typeof CONTRACT_KINDS)[number];
|
||||
|
||||
export const CONTRACT_CLEARANCE_STATUSES = [
|
||||
'NOT_APPLICABLE',
|
||||
'AWAITING_PAYMENT', // Path B — clearance service fee must be paid first
|
||||
'AWAITING_DOCUMENTS',
|
||||
'DOCUMENTS_UNDER_REVIEW',
|
||||
'CLEARANCE_READY_FOR_BOOKING', // Path B — GL may create the booking
|
||||
@@ -178,6 +217,10 @@ export class Contract extends BaseEntity {
|
||||
@Column({ name: 'clearance_cycle_number', type: 'int', default: 0 })
|
||||
clearanceCycleNumber!: number;
|
||||
|
||||
/** When the prepaid customs clearance service fee settled (Path B ONE_TIME). */
|
||||
@Column({ name: 'clearance_fee_paid_at', type: 'timestamptz', nullable: true })
|
||||
clearanceFeePaidAt?: Date | null;
|
||||
|
||||
@Column({ name: 'pricing_breakdown', type: 'jsonb', nullable: true })
|
||||
pricingBreakdown?: Record<string, unknown> | null;
|
||||
|
||||
@@ -193,6 +236,14 @@ export class Contract extends BaseEntity {
|
||||
@Column({ name: 'contract_generated_at', type: 'timestamptz', nullable: true })
|
||||
contractGeneratedAt?: Date | null;
|
||||
|
||||
/**
|
||||
* Per-contract frozen copy of the document template (articles + WHEREAS),
|
||||
* captured at staff accept. Editing it affects only this contract, never the
|
||||
* shared six templates. Null → the PDF renders from the live template.
|
||||
*/
|
||||
@Column({ name: 'document_snapshot', type: 'jsonb', nullable: true })
|
||||
documentSnapshot?: ContractDocumentSnapshot | null;
|
||||
|
||||
@Column({ name: 'contract_summary', type: 'text', nullable: true })
|
||||
contractSummary?: string | null;
|
||||
|
||||
@@ -261,6 +312,14 @@ export class Contract extends BaseEntity {
|
||||
*/
|
||||
clearancePhase?: string | null;
|
||||
|
||||
/**
|
||||
* Latest clearance cycle's linked booking (id + status), attached alongside
|
||||
* clearancePhase. Lets the GL queue tell an expired (unpaid) booking apart
|
||||
* from a live one so it can offer a rebook. Not columns.
|
||||
*/
|
||||
latestCycleBookingId?: string | null;
|
||||
latestCycleBookingStatus?: string | null;
|
||||
|
||||
/**
|
||||
* Body of the most recent CHANGES_REQUESTED review note, attached by
|
||||
* ContractsService.findById so the portal can show the customer what staff
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
@@ -72,7 +73,30 @@ export class DriversController {
|
||||
@Post(':id/documents')
|
||||
@BookingStaff(FREIGHT_PERMS.drivers.update)
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@UseInterceptors(AnyFilesInterceptor())
|
||||
// Bound the upload: 10MB/file, max 20 files, images + PDF only. Without limits
|
||||
// AnyFilesInterceptor buffers arbitrarily large / arbitrary-type payloads.
|
||||
@UseInterceptors(
|
||||
AnyFilesInterceptor({
|
||||
limits: { fileSize: 10 * 1024 * 1024, files: 20 },
|
||||
fileFilter: (_req, file, cb) => {
|
||||
const allowed = [
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/webp',
|
||||
'image/gif',
|
||||
'application/pdf',
|
||||
];
|
||||
if (allowed.includes(file.mimetype)) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(
|
||||
new BadRequestException(`Unsupported file type: ${file.mimetype}`),
|
||||
false,
|
||||
);
|
||||
}
|
||||
},
|
||||
}),
|
||||
)
|
||||
@ApiOperation({ summary: 'Upload driver documents (code driver_docs)' })
|
||||
uploadDocuments(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
|
||||
@@ -10,12 +10,14 @@ import {
|
||||
Patch,
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
} from "@nestjs/common";
|
||||
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
|
||||
import { FreightAdmin } from "../../common/booking-guards";
|
||||
import { CreateDropdownOptionDto } from "./dto/create-dropdown-option.dto";
|
||||
import { CreateDropdownSettingDto } from "./dto/create-dropdown-setting.dto";
|
||||
import { ListDropdownSettingsQueryDto } from "./dto/list-dropdown-settings-query.dto";
|
||||
import { UpdateDropdownOptionDto } from "./dto/update-dropdown-option.dto";
|
||||
import { UpdateDropdownSettingDto } from "./dto/update-dropdown-setting.dto";
|
||||
import { DropdownSettingsService } from "./dropdown-settings.service";
|
||||
@@ -34,6 +36,15 @@ export class DropdownSettingsController {
|
||||
return this.service.list();
|
||||
}
|
||||
|
||||
// Must be declared before @Get(":id") so "paged" isn't captured as an id.
|
||||
@Get("paged")
|
||||
@ApiOperation({
|
||||
summary: "Paged admin listing of dropdown settings (server-side search)",
|
||||
})
|
||||
listPaged(@Query() query: ListDropdownSettingsQueryDto) {
|
||||
return this.service.listPaged(query);
|
||||
}
|
||||
|
||||
@Get(":id")
|
||||
@ApiOperation({ summary: "Get a dropdown setting by ID" })
|
||||
getById(@Param("id", ParseUUIDPipe) id: string) {
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { BaseRepository } from "@edr/api-common";
|
||||
import { PaginatedResponse } from "@edr/types";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { InjectRepository } from "@nestjs/typeorm";
|
||||
import { Repository } from "typeorm";
|
||||
|
||||
import { paginateQuery } from "../../common/utils/pagination.util";
|
||||
import { ListDropdownSettingsQueryDto } from "./dto/list-dropdown-settings-query.dto";
|
||||
import { DropdownOption } from "./entities/dropdown-option.entity";
|
||||
import { DropdownSetting } from "./entities/dropdown-setting.entity";
|
||||
import type { IDropdownSettingsRepository } from "./interfaces/dropdown-settings.repository.interface";
|
||||
@@ -44,6 +47,27 @@ export class DropdownSettingsRepository
|
||||
});
|
||||
}
|
||||
|
||||
findPaged(
|
||||
query: ListDropdownSettingsQueryDto,
|
||||
): Promise<PaginatedResponse<DropdownSetting>> {
|
||||
// Soft-deleted rows are excluded automatically by the query builder
|
||||
// (BaseEntity's deletedAt column). Ordering mirrors findAll (label ASC).
|
||||
const qb = this.repository
|
||||
.createQueryBuilder("setting")
|
||||
.leftJoinAndSelect("setting.children", "option")
|
||||
.orderBy("setting.label", query.sortOrder ?? "ASC")
|
||||
.addOrderBy("option.order", "ASC");
|
||||
|
||||
if (query.search) {
|
||||
qb.andWhere(
|
||||
"(setting.code ILIKE :search OR setting.label ILIKE :search OR setting.description ILIKE :search)",
|
||||
{ search: `%${query.search}%` },
|
||||
);
|
||||
}
|
||||
|
||||
return paginateQuery(qb, query);
|
||||
}
|
||||
|
||||
async replaceOptions(
|
||||
settingId: string,
|
||||
options: Array<Partial<DropdownOption>>,
|
||||
|
||||
@@ -5,8 +5,11 @@ import {
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
|
||||
import { PaginatedResponse } from "@edr/types";
|
||||
|
||||
import { CreateDropdownOptionDto } from "./dto/create-dropdown-option.dto";
|
||||
import { CreateDropdownSettingDto } from "./dto/create-dropdown-setting.dto";
|
||||
import { ListDropdownSettingsQueryDto } from "./dto/list-dropdown-settings-query.dto";
|
||||
import { UpdateDropdownOptionDto } from "./dto/update-dropdown-option.dto";
|
||||
import { UpdateDropdownSettingDto } from "./dto/update-dropdown-setting.dto";
|
||||
import { DropdownOption } from "./entities/dropdown-option.entity";
|
||||
@@ -16,71 +19,6 @@ import {
|
||||
IDropdownSettingsRepository,
|
||||
} from "./interfaces/dropdown-settings.repository.interface";
|
||||
|
||||
const STATIONS_TER_CODE = "stations_ter";
|
||||
|
||||
const DEFAULT_STATION_OPTIONS: CreateDropdownOptionDto[] = [
|
||||
{
|
||||
value: "inside_addis_ababa",
|
||||
label: "Addis Ababa",
|
||||
note: "Inside country",
|
||||
order: 1,
|
||||
},
|
||||
{
|
||||
value: "inside_adama",
|
||||
label: "Adama",
|
||||
note: "Inside country",
|
||||
order: 2,
|
||||
},
|
||||
{
|
||||
value: "inside_mojo",
|
||||
label: "Mojo",
|
||||
note: "Inside country",
|
||||
order: 3,
|
||||
},
|
||||
{
|
||||
value: "inside_awash",
|
||||
label: "Awash",
|
||||
note: "Inside country",
|
||||
order: 4,
|
||||
},
|
||||
{
|
||||
value: "inside_mieso",
|
||||
label: "Mieso",
|
||||
note: "Inside country",
|
||||
order: 5,
|
||||
},
|
||||
{
|
||||
value: "inside_dire_dawa",
|
||||
label: "Dire Dawa",
|
||||
note: "Inside country",
|
||||
order: 6,
|
||||
},
|
||||
{
|
||||
value: "outside_ali_sabieh",
|
||||
label: "Ali Sabieh",
|
||||
note: "Outside country",
|
||||
order: 7,
|
||||
},
|
||||
{
|
||||
value: "outside_holhol",
|
||||
label: "Holhol",
|
||||
note: "Outside country",
|
||||
order: 8,
|
||||
},
|
||||
{
|
||||
value: "outside_djibouti_city",
|
||||
label: "Djibouti City",
|
||||
note: "Outside country",
|
||||
order: 9,
|
||||
},
|
||||
{
|
||||
value: "outside_doraleh_terminal",
|
||||
label: "Doraleh Terminal",
|
||||
note: "Outside country",
|
||||
order: 10,
|
||||
},
|
||||
];
|
||||
|
||||
@Injectable()
|
||||
export class DropdownSettingsService {
|
||||
constructor(
|
||||
@@ -92,6 +30,12 @@ export class DropdownSettingsService {
|
||||
return this.repository.findAll();
|
||||
}
|
||||
|
||||
listPaged(
|
||||
query: ListDropdownSettingsQueryDto,
|
||||
): Promise<PaginatedResponse<DropdownSetting>> {
|
||||
return this.repository.findPaged(query);
|
||||
}
|
||||
|
||||
async getById(id: string): Promise<DropdownSetting> {
|
||||
const setting = await this.repository.findById(id);
|
||||
if (!setting) throw new NotFoundException(`Setting ${id} not found`);
|
||||
@@ -127,34 +71,6 @@ export class DropdownSettingsService {
|
||||
return this.getById(setting.id);
|
||||
}
|
||||
|
||||
async seedDefaultStations(): Promise<void> {
|
||||
const existing = await this.repository.findByCode(STATIONS_TER_CODE);
|
||||
|
||||
if (!existing) {
|
||||
await this.create({
|
||||
code: STATIONS_TER_CODE,
|
||||
label: "Stations TER",
|
||||
description:
|
||||
"Temporary freight station list used by booking origin and destination yards.",
|
||||
multiple: false,
|
||||
meta: {
|
||||
searchable: true,
|
||||
clearable: true,
|
||||
version: "temporary",
|
||||
},
|
||||
children: DEFAULT_STATION_OPTIONS,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if ((existing.children?.length ?? 0) === 0) {
|
||||
await this.repository.replaceOptions(
|
||||
existing.id,
|
||||
DEFAULT_STATION_OPTIONS,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
dto: UpdateDropdownSettingDto,
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { PaginationQueryDto } from "../../../common/dto/pagination-query.dto";
|
||||
|
||||
/**
|
||||
* Query params for the paged admin listing (`GET /dropdown-settings/paged`).
|
||||
* `search` matches code, label and description server-side. The entity has no
|
||||
* status/isActive flag, so the base pagination fields are all that's needed.
|
||||
*/
|
||||
export class ListDropdownSettingsQueryDto extends PaginationQueryDto {}
|
||||
@@ -1,3 +1,6 @@
|
||||
import { PaginatedResponse } from "@edr/types";
|
||||
|
||||
import { ListDropdownSettingsQueryDto } from "../dto/list-dropdown-settings-query.dto";
|
||||
import { DropdownOption } from "../entities/dropdown-option.entity";
|
||||
import { DropdownSetting } from "../entities/dropdown-setting.entity";
|
||||
|
||||
@@ -11,6 +14,9 @@ export const DROPDOWN_SETTINGS_REPOSITORY = Symbol(
|
||||
|
||||
export interface IDropdownSettingsRepository {
|
||||
findAll(): Promise<DropdownSetting[]>;
|
||||
findPaged(
|
||||
query: ListDropdownSettingsQueryDto,
|
||||
): Promise<PaginatedResponse<DropdownSetting>>;
|
||||
findById(id: string): Promise<DropdownSetting | null>;
|
||||
findByCode(code: string): Promise<DropdownSetting | null>;
|
||||
|
||||
|
||||
@@ -6,22 +6,24 @@ import {
|
||||
Query,
|
||||
Res,
|
||||
} from "@nestjs/common";
|
||||
import { ApiOperation, ApiQuery, ApiTags } from "@nestjs/swagger";
|
||||
import { Public } from "@edr/api-common";
|
||||
import { ApiBearerAuth, ApiOperation, ApiQuery, ApiTags } from "@nestjs/swagger";
|
||||
import { Response } from "express";
|
||||
|
||||
import { FilesService } from "./files.service";
|
||||
|
||||
@ApiTags("files")
|
||||
@ApiBearerAuth()
|
||||
@Controller("files")
|
||||
export class FilesController {
|
||||
constructor(private readonly filesService: FilesService) {}
|
||||
|
||||
@Get(":fileId")
|
||||
// Public so the browser can load the bytes directly via <img>/<iframe>/<a> —
|
||||
// those requests can't carry the Bearer token the axios client injects, so a
|
||||
// guarded route 401s. File UUIDs are unguessable; same tradeoff as webhooks.
|
||||
@Public()
|
||||
// Authenticated: no @Public, so the global JwtGuard applies. Unguessable file
|
||||
// UUIDs are obscurity, not authorization — raw byte streams must require auth.
|
||||
// Browser inline previews (<img>/<iframe>/<a>) that can't carry the Bearer
|
||||
// token should use a short-lived signed URL instead (FilesService.signUrl).
|
||||
// TODO: enforce ownership-by-resource here next (scope the file to the
|
||||
// caller's booking/company before streaming).
|
||||
@ApiOperation({
|
||||
summary: "Stream a file by ID",
|
||||
description:
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { Injectable, NotFoundException } from "@nestjs/common";
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import { Readable } from "stream";
|
||||
|
||||
import { MinioService } from "../minio/minio.service";
|
||||
@@ -28,6 +32,31 @@ function sanitizeObjectName(name: string): string {
|
||||
|
||||
@Injectable()
|
||||
export class FilesService {
|
||||
// Defense-in-depth for ANY caller of upload() (not just the driver-docs
|
||||
// route). This is deliberately BROADER than the driver controller's strict
|
||||
// images+pdf Multer filter, because the same method also stores generated
|
||||
// PDFs, PNG signatures, and customer/customs booking documents (scans, office
|
||||
// docs). It rejects the actual attack surface (executables/scripts/HTML) while
|
||||
// permitting every business-document type these flows legitimately upload.
|
||||
// No file-upload-settings row governs raw byte size, so the cap is a sane,
|
||||
// generous default that won't reject large scanned documents.
|
||||
private static readonly MAX_UPLOAD_BYTES = 25 * 1024 * 1024;
|
||||
private static readonly ALLOWED_UPLOAD_MIME = new Set([
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/webp",
|
||||
"image/gif",
|
||||
"image/heic",
|
||||
"image/tiff",
|
||||
"application/pdf",
|
||||
"application/msword",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
"application/vnd.ms-excel",
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
"text/csv",
|
||||
"text/plain",
|
||||
]);
|
||||
|
||||
constructor(
|
||||
private readonly filesRepository: FilesRepository,
|
||||
private readonly minioService: MinioService,
|
||||
@@ -35,6 +64,15 @@ export class FilesService {
|
||||
|
||||
async upload(input: CreateFileInput): Promise<FileRecord> {
|
||||
const { resourceId, resource, code, file } = input;
|
||||
|
||||
if (!FilesService.ALLOWED_UPLOAD_MIME.has(file.mimetype)) {
|
||||
throw new BadRequestException(`Unsupported file type: ${file.mimetype}`);
|
||||
}
|
||||
if (file.size > FilesService.MAX_UPLOAD_BYTES) {
|
||||
throw new BadRequestException(
|
||||
`File exceeds the ${FilesService.MAX_UPLOAD_BYTES / (1024 * 1024)}MB upload limit`,
|
||||
);
|
||||
}
|
||||
// Keep the object key URL-safe so it survives the round-trip through the
|
||||
// stored URL (spaces/unicode in the original name would otherwise be
|
||||
// percent-encoded in the URL and no longer match the MinIO key). The
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { IsUUID, IsNumber, IsDateString, IsString, IsOptional, IsEnum } from 'class-validator';
|
||||
import { IsUUID, IsNumber, IsPositive, IsDateString, IsString, IsOptional, IsEnum } from 'class-validator';
|
||||
import { PaymentMethod } from '../entities/fuel-purchase.entity';
|
||||
|
||||
export class CreateFuelPurchaseDto {
|
||||
@@ -9,9 +9,11 @@ export class CreateFuelPurchaseDto {
|
||||
purchaseDate!: string;
|
||||
|
||||
@IsNumber()
|
||||
@IsPositive()
|
||||
liters!: number;
|
||||
|
||||
@IsNumber()
|
||||
@IsPositive()
|
||||
costPerLiter!: number;
|
||||
|
||||
@IsOptional()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ConflictException, Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { FuelRepository } from './fuel.repository';
|
||||
@@ -15,6 +15,18 @@ export class FuelService {
|
||||
) {}
|
||||
|
||||
async recordFuelPurchase(dto: CreateFuelPurchaseDto): Promise<FuelPurchase> {
|
||||
// Reject a re-submitted receipt for the same vehicle (double-entry guard).
|
||||
if (dto.receiptNumber) {
|
||||
const duplicate = await this.purchaseRepository.findOne({
|
||||
where: { vehicleId: dto.vehicleId, receiptNumber: dto.receiptNumber },
|
||||
});
|
||||
if (duplicate) {
|
||||
throw new ConflictException(
|
||||
`A fuel purchase with receipt number ${dto.receiptNumber} already exists for this vehicle`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const totalCost = dto.liters * dto.costPerLiter;
|
||||
|
||||
const purchase = this.purchaseRepository.create({
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Body, Controller, Get, Param, ParseUUIDPipe, 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 {
|
||||
AssignCustomsRiskDto,
|
||||
CreateDjiboutiIncidentDto,
|
||||
@@ -15,6 +17,9 @@ import { ImportOperationsService } from './import-operations.service';
|
||||
@ApiTags('import-operations')
|
||||
@ApiBearerAuth()
|
||||
@Controller('import-operations')
|
||||
// Post-booking customs / import-operations actions are GL/Ops work, mirroring the
|
||||
// contracts controller's GL operational endpoints (risk, duty, milestones).
|
||||
@BookingStaff(FREIGHT_PERMS.bookings.operations)
|
||||
export class ImportOperationsController {
|
||||
constructor(private readonly service: ImportOperationsService) {}
|
||||
|
||||
|
||||
@@ -8,18 +8,26 @@ import {
|
||||
Param,
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation } from '@nestjs/swagger';
|
||||
import { ApiBearerAuth, ApiTags, ApiOperation } from '@nestjs/swagger';
|
||||
import { BookingStaff } from '../../common/booking-guards';
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
import { IncidentsService } from './incidents.service';
|
||||
import { CreateIncidentDto } from './dto/create-incident.dto';
|
||||
import { UpdateIncidentDto } from './dto/update-incident.dto';
|
||||
import { IncidentStatus, IncidentType } from './entities/incident.entity';
|
||||
|
||||
@ApiTags('Accident & Incident Management')
|
||||
@ApiBearerAuth()
|
||||
@Controller('incidents')
|
||||
// No incidents-specific permission exists in the registry, so this reuses the
|
||||
// (real) drivers.* fleet-road keys — incident records are driver-safety data
|
||||
// (driver stats / incident history). TODO: add a dedicated incidents:* key.
|
||||
@BookingStaff(FREIGHT_PERMS.drivers.view)
|
||||
export class IncidentsController {
|
||||
constructor(private readonly incidentsService: IncidentsService) {}
|
||||
|
||||
@Post()
|
||||
@BookingStaff(FREIGHT_PERMS.drivers.create)
|
||||
@ApiOperation({ summary: 'Report an incident' })
|
||||
async create(@Body() dto: CreateIncidentDto) {
|
||||
return this.incidentsService.create(dto);
|
||||
@@ -55,12 +63,14 @@ export class IncidentsController {
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@BookingStaff(FREIGHT_PERMS.drivers.update)
|
||||
@ApiOperation({ summary: 'Update an incident' })
|
||||
async update(@Param('id') id: string, @Body() dto: UpdateIncidentDto) {
|
||||
return this.incidentsService.update(id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@BookingStaff(FREIGHT_PERMS.drivers.delete)
|
||||
@ApiOperation({ summary: 'Delete an incident' })
|
||||
async remove(@Param('id') id: string) {
|
||||
await this.incidentsService.remove(id);
|
||||
|
||||
@@ -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 { GenerateFromScheduleDto } from './dto/generate-from-schedule.dto';
|
||||
import { InterchangeDocumentQueryDto } from './dto/interchange-document-query.dto';
|
||||
import {
|
||||
@@ -12,6 +14,8 @@ import { InterchangeDocumentsService } from './interchange-documents.service';
|
||||
@ApiTags('interchange-documents')
|
||||
@ApiBearerAuth()
|
||||
@Controller('interchange-documents')
|
||||
// Class-level view guard; each write route adds its own manage permission below.
|
||||
@BookingStaff(FREIGHT_PERMS.interchangeDocuments.view)
|
||||
export class InterchangeDocumentsController {
|
||||
constructor(private readonly service: InterchangeDocumentsService) {}
|
||||
|
||||
@@ -28,12 +32,14 @@ export class InterchangeDocumentsController {
|
||||
}
|
||||
|
||||
@Post('generate-from-schedule')
|
||||
@BookingStaff(FREIGHT_PERMS.interchangeDocuments.generate)
|
||||
@ApiOperation({ summary: 'Generate interchange document from a train schedule handover' })
|
||||
generateFromSchedule(@Body() dto: GenerateFromScheduleDto) {
|
||||
return this.service.generateFromSchedule(dto);
|
||||
}
|
||||
|
||||
@Patch(':id/acknowledge')
|
||||
@BookingStaff(FREIGHT_PERMS.interchangeDocuments.acknowledge)
|
||||
@ApiOperation({ summary: 'Acknowledge an interchange document' })
|
||||
acknowledge(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@@ -43,12 +49,14 @@ export class InterchangeDocumentsController {
|
||||
}
|
||||
|
||||
@Patch(':id/dispute')
|
||||
@BookingStaff(FREIGHT_PERMS.interchangeDocuments.dispute)
|
||||
@ApiOperation({ summary: 'Dispute an interchange document' })
|
||||
dispute(@Param('id', ParseUUIDPipe) id: string, @Body() dto: DisputeInterchangeDocumentDto) {
|
||||
return this.service.dispute(id, dto);
|
||||
}
|
||||
|
||||
@Patch(':id/cancel')
|
||||
@BookingStaff(FREIGHT_PERMS.interchangeDocuments.cancel)
|
||||
@ApiOperation({ summary: 'Cancel a draft/generated interchange document' })
|
||||
cancel(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.service.cancel(id);
|
||||
|
||||
@@ -184,8 +184,13 @@ export class InterchangeDocumentsService {
|
||||
dto: AcknowledgeInterchangeDocumentDto,
|
||||
): Promise<InterchangeDocument> {
|
||||
const document = await this.findOne(id);
|
||||
if (document.status === 'CANCELLED') {
|
||||
throw new BadRequestException('Cancelled interchange document cannot be acknowledged');
|
||||
// Only a freshly GENERATED document can be acknowledged. Rejecting DISPUTED
|
||||
// (as well as CANCELLED / already-ACKNOWLEDGED) stops an acknowledge from
|
||||
// silently overriding a raised dispute.
|
||||
if (document.status !== 'GENERATED') {
|
||||
throw new BadRequestException(
|
||||
`Interchange document in ${document.status} status cannot be acknowledged (must be GENERATED)`,
|
||||
);
|
||||
}
|
||||
await this.dataSource.getRepository(InterchangeDocument).update(id, {
|
||||
status: 'ACKNOWLEDGED',
|
||||
@@ -197,7 +202,14 @@ export class InterchangeDocumentsService {
|
||||
}
|
||||
|
||||
async dispute(id: string, dto: DisputeInterchangeDocumentDto): Promise<InterchangeDocument> {
|
||||
await this.findOne(id);
|
||||
const document = await this.findOne(id);
|
||||
// A dispute can only be raised on a live handover — a GENERATED or already
|
||||
// ACKNOWLEDGED document. CANCELLED and already-DISPUTED are terminal here.
|
||||
if (!['GENERATED', 'ACKNOWLEDGED'].includes(document.status)) {
|
||||
throw new BadRequestException(
|
||||
`Interchange document in ${document.status} status cannot be disputed (must be GENERATED or ACKNOWLEDGED)`,
|
||||
);
|
||||
}
|
||||
await this.dataSource.getRepository(InterchangeDocument).update(id, {
|
||||
status: 'DISPUTED',
|
||||
remarks: dto.remarks,
|
||||
@@ -273,7 +285,10 @@ export class InterchangeDocumentsService {
|
||||
NULL::uuid AS "cargoId",
|
||||
a.booking_cargo_type AS "cargoType",
|
||||
a.cargo_free_text AS "cargoDescription",
|
||||
COALESCE(bc.total_vgm_tons, c.max_gross_weight, a.cargo_total_weight_vgm) AS "weight",
|
||||
-- Item weight is normalized to TONS. total_vgm_tons and
|
||||
-- cargo_total_weight_vgm are already tons; containers.max_gross_weight
|
||||
-- is kilograms, so convert it (kg -> tons).
|
||||
COALESCE(bc.total_vgm_tons, c.max_gross_weight / 1000.0 /* kg->tons */, a.cargo_total_weight_vgm) AS "weight",
|
||||
COALESCE(bc.quantity, 1) AS "quantity",
|
||||
COALESCE(bc.quantity, 1) AS "packageCount",
|
||||
a.wagon_number AS "wagonNumber",
|
||||
@@ -322,7 +337,9 @@ export class InterchangeDocumentsService {
|
||||
cg.id AS "cargoId",
|
||||
COALESCE(cgt.cargo_type_name, a.booking_cargo_type) AS "cargoType",
|
||||
COALESCE(cg.description, a.cargo_free_text) AS "cargoDescription",
|
||||
COALESCE(cg.weight, a.cargo_total_weight_vgm) AS "weight",
|
||||
-- Normalized to TONS: cargoes.weight is kilograms (convert), while
|
||||
-- cargo_total_weight_vgm is already tons.
|
||||
COALESCE(cg.weight / 1000.0 /* kg->tons */, a.cargo_total_weight_vgm) AS "weight",
|
||||
cg.quantity AS "quantity",
|
||||
cg.quantity AS "packageCount",
|
||||
a.wagon_number AS "wagonNumber",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,16 +1,36 @@
|
||||
import { IsArray, IsOptional, IsString, IsUUID, ValidateNested } from 'class-validator';
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
ArrayUnique,
|
||||
IsArray,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
export class LastMileVehicleInput {
|
||||
@IsUUID()
|
||||
vehicleId!: string;
|
||||
|
||||
/**
|
||||
* Containers this truck carries: one 40ft, or up to two 20ft. Omit for bulk
|
||||
* (the truck hauls loose tonnage and is weighed out on exit).
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayMaxSize(2)
|
||||
@ArrayUnique()
|
||||
@IsString({ each: true })
|
||||
containerNumbers?: string[];
|
||||
|
||||
/** @deprecated Single-container form — use `containerNumbers`. Still accepted. */
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
containerNumber?: string;
|
||||
}
|
||||
|
||||
/** Replace the full set of vehicles (with their container numbers) on a delivery. */
|
||||
/** Replace the full set of vehicles (with their containers) on a delivery. */
|
||||
export class SetVehiclesDto {
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne, Unique } from 'typeorm';
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany, Unique } from 'typeorm';
|
||||
|
||||
import { Vehicle } from '../../vehicles/entities/vehicle.entity';
|
||||
import { LastMile } from './last-mile.entity';
|
||||
import { LastMileVehicleContainer } from './last-mile-vehicle-container.entity';
|
||||
|
||||
/**
|
||||
* One row per vehicle assigned to a last-mile delivery. A delivery can be
|
||||
@@ -28,12 +29,34 @@ export class LastMileVehicleAssignment extends BaseEntity {
|
||||
@JoinColumn({ name: 'vehicle_id' })
|
||||
vehicle?: Vehicle;
|
||||
|
||||
/** Container this truck carries — auto-filled from the booking's container
|
||||
* number when known, else entered manually at assignment time. */
|
||||
/** Legacy single container this truck carries. Kept in sync with the FIRST
|
||||
* entry of `containers` for backward compatibility — a truck can hold 1x40ft
|
||||
* or 2x20ft, so `containers` is the authoritative list. */
|
||||
@Column({ name: 'container_number', type: 'varchar', nullable: true })
|
||||
containerNumber?: string | null;
|
||||
|
||||
/** Containers riding this truck (1x40ft, or up to 2x20ft). */
|
||||
@OneToMany(() => LastMileVehicleContainer, (c) => c.assignment, { cascade: true })
|
||||
containers?: LastMileVehicleContainer[];
|
||||
|
||||
/** Actual distance driven by this truck (km), entered per vehicle. */
|
||||
@Column({ name: 'distance_km', type: 'numeric', precision: 10, scale: 2, nullable: true })
|
||||
distanceKm?: number | null;
|
||||
|
||||
/** This truck reached the warehouse (stamped by the arrival weighing step). */
|
||||
@Column({ name: 'arrived_at', type: 'timestamptz', nullable: true })
|
||||
arrivedAt?: Date | null;
|
||||
|
||||
/** This truck left the warehouse (stamped by the exit weighing step). */
|
||||
@Column({ name: 'departed_at', type: 'timestamptz', nullable: true })
|
||||
departedAt?: Date | null;
|
||||
|
||||
/** Weighed gross on exit, in TONNES (not kg — see the migration note). */
|
||||
@Column({ name: 'gross_weight_tons', type: 'numeric', precision: 14, scale: 3, nullable: true })
|
||||
grossWeightTons?: number | null;
|
||||
|
||||
/** Cargo actually taken by this truck (gross − tare), in TONNES. Drives the
|
||||
* bulk drawdown: remaining = booking VGM − SUM(net) over departed trucks. */
|
||||
@Column({ name: 'net_weight_tons', type: 'numeric', precision: 14, scale: 3, nullable: true })
|
||||
netWeightTons?: number | null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
||||
|
||||
import { LastMileVehicleAssignment } from './last-mile-vehicle-assignment.entity';
|
||||
|
||||
/**
|
||||
* A container riding a specific EDR last-mile truck. A truck carries 1x40ft OR
|
||||
* 2x20ft, so the assignment needs more than the single legacy `container_number`
|
||||
* scalar. Mirrors the self-haul `customer_truck_containers` child table.
|
||||
*/
|
||||
@Entity({ schema: 'freight', name: 'last_mile_vehicle_containers' })
|
||||
@Index(['assignmentId'])
|
||||
export class LastMileVehicleContainer extends BaseEntity {
|
||||
@Column({ name: 'assignment_id', type: 'uuid' })
|
||||
assignmentId!: string;
|
||||
|
||||
@ManyToOne(() => LastMileVehicleAssignment, (a) => a.containers, {
|
||||
nullable: false,
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
@JoinColumn({ name: 'assignment_id' })
|
||||
assignment?: LastMileVehicleAssignment;
|
||||
|
||||
/** Denormalised for the "one container, one truck per delivery" unique index. */
|
||||
@Column({ name: 'last_mile_id', type: 'uuid' })
|
||||
lastMileId!: string;
|
||||
|
||||
@Column({ name: 'container_number', type: 'varchar', length: 32 })
|
||||
containerNumber!: string;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
@@ -63,6 +67,18 @@ export class LastMileController {
|
||||
return this.lastMileService.findById(id);
|
||||
}
|
||||
|
||||
@Get('booking/:bookingId/arrival-trucks')
|
||||
@ApiOperation({ summary: "Assigned EDR last-mile trucks for a booking (arrival/exit weighing prefill)" })
|
||||
arrivalTrucks(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
|
||||
return this.lastMileService.arrivalTrucksForBooking(bookingId);
|
||||
}
|
||||
|
||||
@Get('booking/:bookingId/remaining-tons')
|
||||
@ApiOperation({ summary: 'Bulk drawdown: tonnage still to be hauled (total − departed trucks)' })
|
||||
remainingTons(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
|
||||
return this.lastMileService.remainingTonsForBooking(bookingId);
|
||||
}
|
||||
|
||||
@Post('accept/:reference')
|
||||
@BookingStaff(FREIGHT_PERMS.lastMile.accept)
|
||||
@ApiOperation({ summary: 'Accept a paid booking and create a last-mile leg' })
|
||||
@@ -115,6 +131,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' })
|
||||
|
||||
@@ -3,12 +3,14 @@ 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';
|
||||
import { LastMile } from './entities/last-mile.entity';
|
||||
import { LastMileContainerAllocation } from './entities/last-mile-container-allocation.entity';
|
||||
import { LastMileVehicleAssignment } from './entities/last-mile-vehicle-assignment.entity';
|
||||
import { LastMileVehicleContainer } from './entities/last-mile-vehicle-container.entity';
|
||||
import { LastMileController } from './last-mile.controller';
|
||||
import { LastMileInvoiceService } from './last-mile-invoice.service';
|
||||
import { LastMileRepository } from './last-mile.repository';
|
||||
@@ -16,12 +18,18 @@ import { LastMileService } from './last-mile.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([LastMile, LastMileContainerAllocation, LastMileVehicleAssignment]),
|
||||
TypeOrmModule.forFeature([
|
||||
LastMile,
|
||||
LastMileContainerAllocation,
|
||||
LastMileVehicleAssignment,
|
||||
LastMileVehicleContainer,
|
||||
]),
|
||||
BillingModule,
|
||||
forwardRef(() => BookingsModule),
|
||||
VehiclesModule,
|
||||
DriversModule,
|
||||
NotificationsModule,
|
||||
FilesModule,
|
||||
],
|
||||
controllers: [LastMileController],
|
||||
providers: [LastMileRepository, LastMileService, LastMileInvoiceService],
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user