Merge branch 'dev' of github.com:Tria-plc/edr-platform into contrat-backup2

This commit is contained in:
Marshal
2026-07-02 13:29:10 +00:00
71 changed files with 5165 additions and 672 deletions

View File

@@ -867,6 +867,11 @@ export class BillingService {
);
}
const amountDue = Number(invoice.balanceAmount ?? invoice.totalAmount);
if (!(amountDue > 0)) {
throw new BadRequestException("Invoice has no outstanding balance.");
}
const result = await this.payment.initiate({
referenceId: invoice.sourceId,
source: invoice.source,

View File

@@ -58,10 +58,11 @@ import {
RequestOperationDto,
OperationReviewDto,
StaffRejectDto,
} from "./dto/request-changes.dto";
import { ContractViewDto } from "./dto/contract-view.dto";
import { SignContractDto } from "./dto/sign-contract.dto";
import { UpdateBookingDto } from "./dto/update-booking.dto";
} from './dto/request-changes.dto';
import { ContractViewDto } from './dto/contract-view.dto';
import { CustomerTruckAssignmentDto } from './dto/customer-truck-assignment.dto';
import { SignContractDto } from './dto/sign-contract.dto';
import { UpdateBookingDto } from './dto/update-booking.dto';
import {
type AuthUserPayload,
resolveAuthUserId,
@@ -275,7 +276,40 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking);
}
@Get(":id/tracking")
@Post(':id/customer-truck-assignment')
@ApiOperation({ summary: 'Customer assigns external truck and driver for terminal pickup' })
async assignCustomerTruck(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: CustomerTruckAssignmentDto,
@CurrentUser() user: TCurrentUser,
) {
const booking = await this.bookingsService.findById(id);
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking);
}
const assigned = await this.bookingsService.assignCustomerTruck(id, dto);
return this.transitionService.enrichBookingResponse(assigned);
}
@Get(':id/customer-truck-assignment/freight-order')
@ApiOperation({ summary: 'Download duplicate freight order copies for customer truck assignment' })
async customerTruckFreightOrder(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() user: TCurrentUser,
@Res() res: Response,
) {
const booking = await this.bookingsService.findById(id);
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking);
}
const { filename, buffer } =
await this.bookingsService.customerTruckFreightOrderCopies(id);
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
res.send(buffer);
}
@Get(':id/tracking')
@ApiOperation({
summary: "Shipment tracking timeline for a booking",
description:

View File

@@ -45,6 +45,8 @@ import {
import { Booking } from './entities/booking.entity';
import { BookingContainerAllocation } from './entities/booking-container-allocation.entity';
import { FileRecord } from '../files/entities/file.entity';
import { CustomerTruckAssignmentDto } from './dto/customer-truck-assignment.dto';
import { ContractPdfService } from '../../contracts/contract-pdf.service';
/** Paginated booking list: flat `total` (backoffice) + `meta` block (portal). */
export interface PaginatedBookings {
@@ -92,8 +94,62 @@ export class BookingsService {
private readonly ruleEngineService: RuleEngineService,
private readonly containerTypesService: ContainerTypesService,
private readonly consolidationService: ConsolidationService,
private readonly contractPdfService: ContractPdfService,
) {}
async assignCustomerTruck(
bookingId: string,
dto: CustomerTruckAssignmentDto,
): Promise<Booking> {
const booking = await this.findById(bookingId);
const hasFirstMile = Boolean(booking.firstMilePickupAddress?.trim());
const hasLastMile = Boolean(booking.lastMileDeliveryAddress?.trim());
const usesMileService =
booking.tradeDirection === 'IMPORT'
? hasLastMile
: booking.tradeDirection === 'EXPORT'
? hasFirstMile
: hasFirstMile || hasLastMile;
if (usesMileService) {
throw new BadRequestException(
'Customer truck assignment is only allowed when first/last mile delivery is not selected',
);
}
if (booking.customerTruckAssignedAt) {
throw new ConflictException('Customer truck assignment is already submitted and locked');
}
if (booking.paymentStatus !== 'PAID') {
throw new BadRequestException('Booking must be paid before assigning an external customer truck');
}
await this.bookingsRepository.update(bookingId, {
status: 'TRUCK_ASSIGNED',
customerTruckPlateNumber: dto.truckPlateNumber.trim().toUpperCase(),
customerTruckDriverName: dto.driverName.trim(),
customerTruckType: dto.truckType.trim(),
customerTruckContainerNumber: dto.containerNumberToLoad.trim().toUpperCase(),
customerTruckAssignedAt: new Date(),
});
return this.findById(bookingId);
}
async customerTruckFreightOrderCopies(
bookingId: string,
): Promise<{ filename: string; buffer: Buffer }> {
const booking = await this.findById(bookingId);
if (!booking.customerTruckAssignedAt) {
throw new BadRequestException('Customer truck must be assigned before freight order copies can be generated');
}
const html = this.buildCustomerTruckFreightOrderHtml(booking);
const buffer = await this.contractPdfService.htmlToPdfBuffer(html);
return {
filename: `freight-order-${booking.reference.replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`,
buffer,
};
}
/** Resolve trade direction from yard countries; reject client mismatch. */
private async resolveTradeDirectionForBooking(
originYardId: string,
@@ -131,6 +187,79 @@ export class BookingsService {
return `BK-${year}-${String(count + 1).padStart(6, '0')}`;
}
private buildCustomerTruckFreightOrderHtml(booking: Booking): string {
const assignedAt = booking.customerTruckAssignedAt
? new Date(booking.customerTruckAssignedAt).toLocaleString('en-GB')
: '-';
const rows: Array<[string, string | null | undefined]> = [
['Booking Reference', booking.reference],
['Client Name', booking.company?.name],
['Client ID', booking.companyId],
['Trade Direction', booking.tradeDirection],
['Freight Type', booking.freightType],
['Truck Plate Number', booking.customerTruckPlateNumber],
['Driver Name', booking.customerTruckDriverName],
['Truck Type', booking.customerTruckType],
['Container Number to Load', booking.customerTruckContainerNumber],
['Assigned At', assignedAt],
['Booking Status', booking.status],
];
const rowHtml = rows
.map(([label, value]) => `<tr><th>${this.escapeHtml(label)}</th><td>${this.escapeHtml(value || '-')}</td></tr>`)
.join('');
const copy = (watermark: string) => `
<section class="copy">
<div class="watermark">${this.escapeHtml(watermark)}</div>
<header>
<div>
<h1>Freight Order</h1>
<p>Customer external truck assignment</p>
</div>
<strong>${this.escapeHtml(booking.reference)}</strong>
</header>
<table>${rowHtml}</table>
<div class="signatures">
<div>Customer / Carrier Signature</div>
<div>Port Operations Verification</div>
<div>Gate Security Verification</div>
</div>
</section>`;
return `<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<style>
body { font-family: Arial, sans-serif; color: #10202f; margin: 0; }
.copy { position: relative; min-height: 46vh; padding: 28px 32px; page-break-inside: avoid; border-bottom: 1px dashed #94a3b8; }
.watermark { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; font-size: 34px; font-weight: 800; color: rgba(16, 32, 47, 0.08); transform: rotate(-18deg); pointer-events: none; }
header { display: flex; justify-content: space-between; align-items: flex-start; border-bottom: 3px solid #0a9f6a; padding-bottom: 14px; margin-bottom: 18px; }
h1 { margin: 0; font-size: 28px; letter-spacing: 0; }
p { margin: 4px 0 0; color: #64748b; }
strong { font-size: 16px; color: #0a9f6a; }
table { width: 100%; border-collapse: collapse; position: relative; z-index: 1; }
th, td { border: 1px solid #cbd5e1; padding: 9px 10px; text-align: left; font-size: 12px; }
th { width: 34%; background: #f1f5f9; }
.signatures { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; margin-top: 34px; font-size: 11px; color: #475569; position: relative; z-index: 1; }
.signatures div { border-top: 1px solid #334155; padding-top: 8px; min-height: 28px; }
</style>
</head>
<body>
${copy('Copy 1: Port Operations Copy')}
${copy('Copy 2: Gate Security & Carrier Copy')}
</body>
</html>`;
}
private escapeHtml(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
/** Build evaluation input from booking freight shape. */
/**
* Whether a service type bundles customs clearance. This is the single source

View File

@@ -0,0 +1,34 @@
import { IsIn, IsNotEmpty, IsString, Matches, MaxLength } from 'class-validator';
export const CUSTOMER_TRUCK_TYPES = [
'Flatbed',
'Container Chassis',
'Lowboy',
'Box Truck',
'Tipper',
] as const;
export class CustomerTruckAssignmentDto {
@IsString()
@IsNotEmpty()
@MaxLength(32)
truckPlateNumber!: string;
@IsString()
@IsNotEmpty()
@MaxLength(120)
driverName!: string;
@IsString()
@IsNotEmpty()
@IsIn(CUSTOMER_TRUCK_TYPES)
truckType!: string;
@IsString()
@IsNotEmpty()
@MaxLength(16)
@Matches(/^[A-Z]{4}\d{7}$/, {
message: 'containerNumberToLoad must match ISO container format, e.g. ABCD1234567',
})
containerNumberToLoad!: string;
}

View File

@@ -51,6 +51,7 @@ export const BOOKING_STATUSES = [
// Road (truck) drawdown orders skip the train batch pool and wait here for
// truck dispatch after Marketing accepts; billed by KM, not wagons.
'ROAD_DISPATCH_PENDING',
'TRUCK_ASSIGNED',
'OPERATION_REQUESTED',
// Operations review gate: customer picks a schedule day and submits the
// operation request; the operations team reviews capacity/docs/route before
@@ -260,6 +261,24 @@ export class Booking extends BaseEntity {
@Column({ name: 'last_mile_delivery_lng', type: 'numeric', precision: 10, scale: 7, nullable: true })
lastMileDeliveryLng?: number | null;
@Column({ name: 'customer_truck_plate_number', type: 'varchar', length: 32, nullable: true })
customerTruckPlateNumber?: string | null;
@Column({ name: 'customer_truck_driver_name', type: 'varchar', length: 120, nullable: true })
customerTruckDriverName?: string | null;
@Column({ name: 'customer_truck_type', type: 'varchar', length: 60, nullable: true })
customerTruckType?: string | null;
@Column({ name: 'customer_truck_container_number', type: 'varchar', length: 16, nullable: true })
customerTruckContainerNumber?: string | null;
@Column({ name: 'customer_truck_assigned_at', type: 'timestamptz', nullable: true })
customerTruckAssignedAt?: Date | null;
@Column({ name: 'customer_truck_arrived_at', type: 'timestamptz', nullable: true })
customerTruckArrivedAt?: Date | null;
@Column({ name: 'customs_clearing_enabled', type: 'boolean', default: false })
customsClearingEnabled!: boolean;

View File

@@ -131,6 +131,12 @@ export class LastMileService {
}
async create(dto: CreateLastMileDto): Promise<LastMile> {
const [existing] = await this.lastMileRepository.findAll({
where: { bookingId: dto.bookingId },
take: 1,
});
if (existing) return existing;
return this.lastMileRepository.create({
bookingId: dto.bookingId,
status: dto.status ?? 'READY_TO_TRANSIT',

View File

@@ -1,7 +1,8 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsIn, IsOptional, IsString } from 'class-validator';
import { IsDateString, IsIn, IsOptional, IsString } from 'class-validator';
export const IMPORT_DJIBOUTI_DOCUMENT_TYPES = [
'GATE_PASS',
'DELIVERY_ORDER',
'PORT_INVOICE',
'DJIBOUTI_T1',
@@ -43,6 +44,26 @@ export class UploadImportDjiboutiDocumentDto {
}
export class ImportDjiboutiActionDto {
@ApiPropertyOptional({ description: 'Gate pass secured date/time. Defaults to now.' })
@IsOptional()
@IsDateString()
securedAt?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
fileId?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
fileUrl?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
reference?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()

View File

@@ -4,6 +4,7 @@ import { Column, Entity, Index, JoinColumn, OneToOne } from 'typeorm';
import { TrainSchedule } from '../../train-schedules/entities/train-schedule.entity';
export type ImportDjiboutiDocumentType =
| 'GATE_PASS'
| 'DELIVERY_ORDER'
| 'PORT_INVOICE'
| 'DJIBOUTI_T1'

View File

@@ -54,7 +54,6 @@ import {
type ImportDjiboutiDocumentType,
} from './entities/import-djibouti-operation.entity';
import {
IMPORT_DJIBOUTI_DOCUMENT_TYPES,
ImportDjiboutiActionDto,
UploadImportDjiboutiDocumentDto,
} from './dto/import-djibouti-operation.dto';
@@ -832,7 +831,7 @@ export class TrainSchedulingService {
}
async getImportDjiboutiOperation(scheduleId: string) {
const schedule = await this.getImportDjiboutiSchedule(scheduleId);
const schedule = await this.getDjiboutiGatepassSchedule(scheduleId);
const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId);
return this.mapImportDjiboutiOperation(schedule, operation);
}
@@ -841,7 +840,7 @@ export class TrainSchedulingService {
scheduleId: string,
dto: UploadImportDjiboutiDocumentDto,
) {
const schedule = await this.getImportDjiboutiSchedule(scheduleId);
const schedule = await this.getDjiboutiGatepassSchedule(scheduleId);
const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId);
const documents = {
...(operation.documents ?? {}),
@@ -865,21 +864,30 @@ export class TrainSchedulingService {
}
async grantImportDjiboutiGatepass(scheduleId: string, dto: ImportDjiboutiActionDto = {}) {
const schedule = await this.getImportDjiboutiSchedule(scheduleId);
const schedule = await this.getDjiboutiGatepassSchedule(scheduleId);
const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId);
const missing = this.missingImportDjiboutiDocuments(operation);
if (missing.length) {
throw new BadRequestException(`Gatepass cannot be granted until documents are uploaded: ${missing.join(', ')}`);
const securedAt = dto.securedAt ? new Date(dto.securedAt) : new Date();
const documents = { ...(operation.documents ?? {}) };
if (dto.fileId || dto.fileUrl || dto.reference || dto.notes) {
documents.GATE_PASS = {
fileId: dto.fileId ?? null,
fileUrl: dto.fileUrl ?? null,
reference: dto.reference ?? null,
uploadedAt: new Date().toISOString(),
uploadedBy: dto.performedBy ?? null,
notes: dto.notes ?? null,
};
}
await this.dataSource.getRepository(ImportDjiboutiOperation).update(operation.id, {
gatepassGrantedAt: operation.gatepassGrantedAt ?? new Date(),
documents,
gatepassGrantedAt: securedAt,
performedBy: dto.performedBy ?? operation.performedBy ?? null,
notes: dto.notes ?? operation.notes ?? null,
});
console.log(
`[NOTIFY] Import gatepass granted for train ${schedule.trainNumber ?? schedule.id}; loading may proceed.`,
`[NOTIFY] Gate pass secured for train ${schedule.trainNumber ?? schedule.id}; Djibouti Port entry is allowed.`,
);
return this.getImportDjiboutiOperation(schedule.id);
}
@@ -1299,16 +1307,28 @@ export class TrainSchedulingService {
}
private async getImportDjiboutiSchedule(scheduleId: string): Promise<TrainSchedule> {
const schedule = await this.getDjiboutiGatepassSchedule(scheduleId);
if (!this.isImportDjiboutiSchedule(schedule)) {
throw new BadRequestException('This action applies only to IMPORT schedules originating from Djibouti');
}
return schedule;
}
private async getDjiboutiGatepassSchedule(scheduleId: string): Promise<TrainSchedule> {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule) {
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
}
if (!this.isImportDjiboutiSchedule(schedule)) {
throw new BadRequestException('Batch 7 actions apply only to IMPORT schedules originating from Djibouti');
if (!this.isDjiboutiGatepassSchedule(schedule)) {
throw new BadRequestException('Gate pass applies only to trains entering Djibouti Port on import or export routes');
}
return schedule;
}
private isDjiboutiGatepassSchedule(schedule: TrainSchedule): boolean {
return this.isImportDjiboutiSchedule(schedule) || this.isExportDjiboutiSchedule(schedule);
}
private isImportDjiboutiSchedule(schedule: TrainSchedule): boolean {
const direction =
(schedule.direction as 'IMPORT' | 'EXPORT' | 'DOMESTIC' | null) ??
@@ -1323,6 +1343,20 @@ export class TrainSchedulingService {
);
}
private isExportDjiboutiSchedule(schedule: TrainSchedule): boolean {
const direction =
(schedule.direction as 'IMPORT' | 'EXPORT' | 'DOMESTIC' | null) ??
(schedule.originStation && schedule.destinationStation
? deriveScheduleDirection(schedule.originStation, schedule.destinationStation)
: null);
return (
direction === 'EXPORT' &&
this.isDjiboutiPortDestination(
`${schedule.destinationStation?.code ?? ''} ${schedule.destinationStation?.label ?? ''}`,
)
);
}
private async getOrCreateImportDjiboutiOperation(scheduleId: string): Promise<ImportDjiboutiOperation> {
const repo = this.dataSource.getRepository(ImportDjiboutiOperation);
const existing = await repo.findOne({ where: { trainScheduleId: scheduleId } });
@@ -1331,8 +1365,8 @@ export class TrainSchedulingService {
}
private missingImportDjiboutiDocuments(operation?: ImportDjiboutiOperation | null): ImportDjiboutiDocumentType[] {
const documents = operation?.documents ?? {};
return IMPORT_DJIBOUTI_DOCUMENT_TYPES.filter((type) => !documents[type]);
void operation;
return [];
}
private assertImportDjiboutiGatepassGranted(operation?: ImportDjiboutiOperation | null): void {
@@ -1343,6 +1377,7 @@ export class TrainSchedulingService {
private mapImportDjiboutiOperation(schedule: TrainSchedule, operation: ImportDjiboutiOperation) {
const missingDocuments = this.missingImportDjiboutiDocuments(operation);
const gatepassStatus = operation.gatepassGrantedAt ? 'SECURED' : 'NOT_SECURED';
return {
trainScheduleId: schedule.id,
trainNumber: schedule.trainNumber ?? null,
@@ -1350,6 +1385,7 @@ export class TrainSchedulingService {
status: {
documentsComplete: missingDocuments.length === 0,
missingDocuments,
gatepassStatus,
gatepassGranted: Boolean(operation.gatepassGrantedAt),
readyForLoading: Boolean(operation.readyForLoadingAt),
loadedOnTrain: Boolean(operation.loadedOnTrainAt),
@@ -1358,6 +1394,8 @@ export class TrainSchedulingService {
},
documents: operation.documents ?? {},
gatepassGrantedAt: operation.gatepassGrantedAt ?? null,
gatepassSecuredAt: operation.gatepassGrantedAt ?? null,
gatepassStatus,
readyForLoadingAt: operation.readyForLoadingAt ?? null,
loadedOnTrainAt: operation.loadedOnTrainAt ?? null,
departedFromDjiboutiAt: operation.departedFromDjiboutiAt ?? null,

View File

@@ -1,6 +1,6 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { ArrayNotEmpty, IsArray, IsIn, IsNumber, IsOptional, IsString, IsUUID, Min } from 'class-validator';
import { ArrayNotEmpty, IsArray, IsBoolean, IsIn, IsNumber, IsOptional, IsString, IsUUID, Min } from 'class-validator';
import { ValidateNested } from 'class-validator';
export class TruckEntranceDto {
@@ -90,6 +90,11 @@ export class TruckEntranceDto {
@Min(0)
grossWeightKg?: number;
@ApiPropertyOptional({ description: 'Whether the customer truck was weighed at receipt.' })
@IsOptional()
@IsBoolean()
weighingRequired?: boolean;
@ApiPropertyOptional()
@IsOptional()
@IsNumber()
@@ -135,10 +140,11 @@ export class TruckEntranceDto {
@IsString()
truckType?: string;
@ApiProperty()
@ApiPropertyOptional()
@IsOptional()
@IsNumber()
@Min(0)
entranceTareWeightKg!: number;
entranceTareWeightKg?: number;
@ApiPropertyOptional()
@IsOptional()

View File

@@ -1,8 +1,27 @@
import { ApiProperty, ApiPropertyOptional, PartialType } from '@nestjs/swagger';
import { IsEnum, IsInt, IsNumber, IsOptional, IsString, IsUUID, Min } from 'class-validator';
import { Type } from 'class-transformer';
import { IsArray, IsEnum, IsInt, IsNumber, IsOptional, IsString, IsUUID, Min, ValidateNested } from 'class-validator';
import { FEE_RULE_TYPES, FeeRuleType } from '../entities/warehouse-fee-rule.entity';
export class FeeRuleTierDto {
@ApiProperty({ example: 4 })
@IsInt()
@Min(1)
fromDay!: number;
@ApiPropertyOptional({ example: 4, description: 'Inclusive. Leave empty for an open-ended tier.' })
@IsOptional()
@IsInt()
@Min(1)
toDay?: number | null;
@ApiProperty({ example: 2500 })
@IsNumber()
@Min(0)
ratePerDay!: number;
}
export class CreateFeeRuleDto {
@ApiProperty()
@IsString()
@@ -67,6 +86,13 @@ export class CreateFeeRuleDto {
@Min(0)
ratePerDay!: number;
@ApiPropertyOptional({ type: [FeeRuleTierDto] })
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => FeeRuleTierDto)
tiers?: FeeRuleTierDto[];
@ApiPropertyOptional({ default: 'USD' })
@IsOptional()
@IsString()

View File

@@ -4,6 +4,12 @@ import { Column, Entity, Index } from 'typeorm';
export const FEE_RULE_TYPES = ['STORAGE_FEE', 'DEMURRAGE_FEE'] as const;
export type FeeRuleType = (typeof FEE_RULE_TYPES)[number];
export interface WarehouseFeeTier {
fromDay: number;
toDay: number | null;
ratePerDay: number;
}
/**
* Batch 5 — configurable storage / demurrage fee rules (no invoice/payment here — that is Batch 6).
* The most specific active rule (highest `specificity` then lowest `priority`) applies to an item.
@@ -54,6 +60,9 @@ export class WarehouseFeeRule extends BaseEntity {
@Column({ name: 'rate_per_day', type: 'numeric', precision: 14, scale: 2, default: 0 })
ratePerDay!: number;
@Column({ name: 'tiers', type: 'jsonb', default: () => "'[]'" })
tiers!: WarehouseFeeTier[];
@Column({ name: 'currency', type: 'varchar', length: 8, default: 'USD' })
currency!: string;

View File

@@ -35,6 +35,7 @@ export interface ImportTrainItemRow {
wagonNumber: string | null;
sequenceNo: number | null;
allocatedWeightTons: number | null;
freightType: string | null;
containerNumber: string | null;
cargoType: string | null;
weight: number | null;
@@ -274,6 +275,7 @@ export class SchedulingReadFacade {
w.wagon_number AS "wagonNumber",
tsw.sequence_no AS "sequenceNo",
wba.allocated_weight_tons AS "allocatedWeightTons",
b.freight_type AS "freightType",
(SELECT c.container_number FROM freight.containers c
WHERE c.booking_id = b.id AND c.deleted_at IS NULL
ORDER BY c.container_number LIMIT 1) AS "containerNumber",

View File

@@ -1,9 +1,9 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { ExchangeService } from '@edr/api-common';
import { DataSource } from 'typeorm';
import { CreateFeeRuleDto, UpdateFeeRuleDto } from './dto/fee-rule.dto';
import { FeeRuleType, WarehouseFeeRule } from './entities/warehouse-fee-rule.entity';
import { FeeRuleType, WarehouseFeeRule, WarehouseFeeTier } from './entities/warehouse-fee-rule.entity';
import { WarehouseFeeRuleRepository } from './warehouse-fee-rule.repository';
interface ItemAttributes {
@@ -39,6 +39,15 @@ export interface FeePreview {
containerCount: number;
billableUnits: number;
amount: number;
tiers: Array<{
fromDay: number;
toDay: number | null;
appliedFromDay: number;
appliedToDay: number;
days: number;
ratePerDay: number;
amount: number;
}>;
}
const MS_PER_DAY = 24 * 60 * 60 * 1000;
@@ -57,11 +66,16 @@ export class WarehouseFeeService {
}
createRule(dto: CreateFeeRuleDto): Promise<WarehouseFeeRule> {
return this.feeRuleRepository.create({ isActive: true, priority: 100, currency: 'USD', ...dto });
return this.feeRuleRepository.create({
isActive: true,
priority: 100,
currency: 'USD',
...this.normalizeRuleInput(dto),
});
}
async updateRule(id: string, dto: UpdateFeeRuleDto): Promise<WarehouseFeeRule> {
const updated = await this.feeRuleRepository.update(id, dto);
const updated = await this.feeRuleRepository.update(id, this.normalizeRuleInput(dto));
if (!updated) throw new NotFoundException(`Fee rule ${id} not found`);
return updated;
}
@@ -70,6 +84,40 @@ export class WarehouseFeeService {
return this.feeRuleRepository.softDelete(id);
}
private normalizeRuleInput<T extends CreateFeeRuleDto | UpdateFeeRuleDto>(dto: T): T {
if (dto.tiers === undefined) return dto;
const tiers = (dto.tiers ?? [])
.map((tier) => ({
fromDay: Number(tier.fromDay),
toDay: tier.toDay == null ? null : Number(tier.toDay),
ratePerDay: Number(tier.ratePerDay),
}))
.filter((tier) => tier.fromDay > 0 || tier.toDay != null || tier.ratePerDay > 0);
for (const tier of tiers) {
if (!Number.isInteger(tier.fromDay) || tier.fromDay < 1) {
throw new BadRequestException('Fee tier from day must be a positive whole number.');
}
if (tier.toDay != null && (!Number.isInteger(tier.toDay) || tier.toDay < tier.fromDay)) {
throw new BadRequestException('Fee tier to day must be empty or greater than/equal to from day.');
}
if (!Number.isFinite(tier.ratePerDay) || tier.ratePerDay < 0) {
throw new BadRequestException('Fee tier rate per day must be zero or greater.');
}
}
const sorted = [...tiers].sort((a, b) => a.fromDay - b.fromDay || (a.toDay ?? Infinity) - (b.toDay ?? Infinity));
for (let i = 1; i < sorted.length; i += 1) {
const prev = sorted[i - 1];
const current = sorted[i];
if (prev.toDay == null || current.fromDay <= prev.toDay) {
throw new BadRequestException('Fee tiers cannot overlap. Use separate from/to day ranges.');
}
}
return { ...dto, tiers: sorted } as T;
}
private async loadItem(inventoryId: string): Promise<ItemAttributes> {
const [row] = await this.dataSource.query(
`SELECT inv.arrived_at AS "arrivedAt",
@@ -82,16 +130,27 @@ export class WarehouseFeeService {
w.facility_id AS "facilityId",
b.freight_type AS "freightType",
b.trade_direction AS "tradeDirection",
cgt.code AS "cargoTypeCode",
ctt.code AS "containerTypeCode",
COALESCE(cgt.code, booking_cgt.code) AS "cargoTypeCode",
COALESCE(ctt.code, booking_ctt.code) AS "containerTypeCode",
COALESCE(container_lines.container_count, 0) AS "bookingContainerCount"
FROM freight.warehouse_inventory inv
LEFT JOIN freight.warehouses w ON w.id = inv.warehouse_id
LEFT JOIN freight.bookings b ON b.id = inv.booking_id
LEFT JOIN freight.cargoes cg ON cg.id = inv.cargo_id
LEFT JOIN freight.cargo_types cgt ON cgt.id = cg.cargo_type_id
LEFT JOIN freight.cargo_types booking_cgt ON booking_cgt.id = b.cargo_type_id
LEFT JOIN freight.containers ct ON ct.id = inv.container_id
LEFT JOIN freight.container_types ctt ON ctt.id = ct.container_type_id
LEFT JOIN LATERAL (
SELECT bc.container_type_id
FROM freight.booking_container bc
WHERE bc.booking_id = inv.booking_id
AND bc.deleted_at IS NULL
AND bc.container_type_id IS NOT NULL
ORDER BY bc.created_at ASC
LIMIT 1
) booking_container_type ON true
LEFT JOIN freight.container_types booking_ctt ON booking_ctt.id = booking_container_type.container_type_id
LEFT JOIN LATERAL (
SELECT COALESCE(SUM(bc.quantity), 0)::int AS container_count
FROM freight.booking_container bc
@@ -108,16 +167,26 @@ export class WarehouseFeeService {
private matchScore(rule: WarehouseFeeRule, item: ItemAttributes): number | null {
// Returns specificity score (#matched non-null scope fields), or null if any constraint fails.
let score = 0;
const check = (ruleVal: string | null | undefined, itemVal: string | null) => {
if (ruleVal == null) return true;
if (itemVal != null && ruleVal.toUpperCase() === itemVal.toUpperCase()) {
const normalized = (value: string | null | undefined) => value?.trim().toUpperCase() || null;
const check = (
ruleVal: string | null | undefined,
itemVal: string | null,
opts: { allowBoth?: boolean } = {},
) => {
const ruleCode = normalized(ruleVal);
if (ruleCode == null || ruleCode === 'ANY' || ruleCode === 'ALL') return true;
if (opts.allowBoth && ruleCode === 'BOTH') {
score += 1;
return true;
}
if (ruleCode === normalized(itemVal)) {
score += 1;
return true;
}
return false;
};
if (!check(rule.freightType, item.freightType)) return null;
if (!check(rule.tradeDirection, item.tradeDirection)) return null;
if (!check(rule.tradeDirection, item.tradeDirection, { allowBoth: true })) return null;
if (!check(rule.cargoTypeCode, item.cargoTypeCode)) return null;
if (!check(rule.containerType, item.containerTypeCode)) return null;
if (!check(rule.facilityId, item.facilityId)) return null;
@@ -153,6 +222,60 @@ export class WarehouseFeeService {
return Math.round(amount * rate * 100) / 100;
}
private calculateTieredAmount(
tiers: WarehouseFeeTier[] | null | undefined,
elapsedDays: number,
containerCount: number,
): {
sourceAmount: number;
billableUnits: number;
chargeableDays: number;
weightedRatePerDay: number;
tiers: FeePreview['tiers'];
} {
const sourceTiers = (tiers ?? [])
.map((tier) => ({
fromDay: Number(tier.fromDay),
toDay: tier.toDay == null ? null : Number(tier.toDay),
ratePerDay: Number(tier.ratePerDay),
}))
.filter((tier) => Number.isFinite(tier.fromDay) && tier.fromDay > 0 && Number.isFinite(tier.ratePerDay))
.sort((a, b) => a.fromDay - b.fromDay);
let sourceAmount = 0;
let tierDays = 0;
const appliedTiers: FeePreview['tiers'] = [];
for (const tier of sourceTiers) {
if (elapsedDays < tier.fromDay) continue;
const appliedFromDay = tier.fromDay;
const appliedToDay = Math.min(elapsedDays, tier.toDay ?? elapsedDays);
const days = Math.max(0, appliedToDay - appliedFromDay + 1);
if (days <= 0) continue;
const amount = Math.round(days * containerCount * tier.ratePerDay * 100) / 100;
sourceAmount += amount;
tierDays += days;
appliedTiers.push({
fromDay: tier.fromDay,
toDay: tier.toDay,
appliedFromDay,
appliedToDay,
days,
ratePerDay: tier.ratePerDay,
amount,
});
}
return {
sourceAmount: Math.round(sourceAmount * 100) / 100,
billableUnits: tierDays * containerCount,
chargeableDays: tierDays,
weightedRatePerDay: tierDays > 0 ? Math.round((sourceAmount / tierDays / containerCount) * 100) / 100 : 0,
tiers: appliedTiers,
};
}
private async compute(
ruleType: FeeRuleType,
rule: WarehouseFeeRule | null,
@@ -176,13 +299,25 @@ export class WarehouseFeeService {
const elapsedDays = start
? Math.max(0, Math.ceil((new Date(endDate).getTime() - start.getTime()) / MS_PER_DAY))
: 0;
const chargeableDays = Math.max(0, elapsedDays - freeDays);
const billableUnits = chargeableDays * containerCount;
const sourceAmount = Math.round(billableUnits * ratePerDay * 100) / 100;
const tiered = this.calculateTieredAmount(rule?.tiers, elapsedDays, containerCount);
const hasTiers = Boolean(rule?.tiers?.length);
const chargeableDays = hasTiers ? tiered.chargeableDays : Math.max(0, elapsedDays - freeDays);
const billableUnits = hasTiers ? tiered.billableUnits : chargeableDays * containerCount;
const sourceAmount = hasTiers ? tiered.sourceAmount : Math.round(billableUnits * ratePerDay * 100) / 100;
const amount = ruleCurrency ? await this.convertAmount(sourceAmount, ruleCurrency, targetCurrency) : 0;
const sourceRatePerDay = hasTiers ? tiered.weightedRatePerDay : ratePerDay;
const convertedRatePerDay = ruleCurrency
? await this.convertAmount(ratePerDay, ruleCurrency, targetCurrency)
? await this.convertAmount(sourceRatePerDay, ruleCurrency, targetCurrency)
: 0;
const convertedTiers = ruleCurrency
? await Promise.all(
tiered.tiers.map(async (tier) => ({
...tier,
ratePerDay: await this.convertAmount(tier.ratePerDay, ruleCurrency, targetCurrency),
amount: await this.convertAmount(tier.amount, ruleCurrency, targetCurrency),
})),
)
: [];
return {
ruleType,
@@ -201,6 +336,7 @@ export class WarehouseFeeService {
containerCount,
billableUnits,
amount,
tiers: hasTiers ? convertedTiers : [],
};
}

View File

@@ -84,9 +84,11 @@ export class WarehouseInspectionService {
`SELECT inv.booking_id AS "bookingId",
b.reference AS "bookingReference",
b.trade_direction AS "tradeDirection",
b.last_mile_delivery_address AS "lastMileDeliveryAddress"
b.last_mile_delivery_address AS "lastMileDeliveryAddress",
COALESCE(st.includes_last_mile, false) AS "serviceIncludesLastMile"
FROM freight.warehouse_inventory inv
LEFT JOIN freight.bookings b ON b.id = inv.booking_id
LEFT JOIN freight.service_types st ON st.id = b.service_type_id
WHERE inv.id = $1 AND inv.deleted_at IS NULL
LIMIT 1`,
[inventoryId],
@@ -98,7 +100,10 @@ export class WarehouseInspectionService {
readyForPickupAt: new Date(),
});
if (row.bookingReference && row.lastMileDeliveryAddress) {
const hasLastMile =
Boolean(row.lastMileDeliveryAddress?.trim?.()) || Boolean(row.serviceIncludesLastMile);
if (row.bookingReference && hasLastMile) {
await this.lastMileService.acceptBooking(row.bookingReference);
}
}

View File

@@ -139,8 +139,18 @@ export class WarehouseInventoryController {
@Post('import/auto-unload-arrived-bookings')
@ApiOperation({ summary: 'Unload all eligible assigned bookings of an ARRIVED import train (→ UNLOADED)' })
autoUnloadArrivedBookings(@Body() dto: { scheduleId: string; performedBy?: string }) {
return this.inventoryService.autoUnloadArrivedBookings(dto.scheduleId, dto.performedBy);
autoUnloadArrivedBookings(@Body() dto: {
scheduleId: string;
warehouseId?: string;
performedBy?: string;
assignments?: { bookingId: string; warehouseId: string; yardId: string; zoneId: string }[];
}) {
return this.inventoryService.autoUnloadArrivedBookings(
dto.scheduleId,
dto.performedBy,
dto.warehouseId,
dto.assignments,
);
}
@Get('import/unloaded-queue')

View File

@@ -180,6 +180,10 @@ interface LocationRef {
zoneId: string;
}
interface BookingUnloadLocation extends LocationRef {
bookingId: string;
}
interface LocationNode {
capacityWeight?: number | null;
capacityContainers?: number | null;
@@ -221,6 +225,11 @@ export interface EligibleBookingRow {
firstMileDriverPhone: string | null;
firstMileDriverLicenseNumber: string | null;
firstMileTruckType: string | null;
customerTruckPlateNumber: string | null;
customerTruckDriverName: string | null;
customerTruckType: string | null;
customerTruckContainerNumber: string | null;
customerTruckAssignedAt: string | null;
}
export interface BulkReceiveResult {
@@ -302,6 +311,11 @@ export interface ImportUnloadedRow {
inspectionStatus: string | null;
pickupOption: string;
lastMileRequested: boolean;
customerTruckPlateNumber: string | null;
customerTruckDriverName: string | null;
customerTruckType: string | null;
customerTruckContainerNumber: string | null;
customerTruckAssignedAt: string | null;
currentStatus: string;
releaseDate: string | null;
releaseOrderReference: string | null;
@@ -504,8 +518,9 @@ export class WarehouseInventoryService {
}));
}
/** First warehouse that has at least one yard + zone (fallback location for auto-unload). */
private async pickDefaultLocation(): Promise<DefaultLocation | null> {
/** First matching warehouse that has at least one yard + zone (fallback location for auto-unload). */
private async pickDefaultLocation(warehouseId?: string): Promise<DefaultLocation | null> {
const params = warehouseId ? [warehouseId] : [];
const [row]: DefaultLocation[] = await this.dataSource.query(
`SELECT wh.id AS "warehouseId", wh.facility_id AS "facilityId",
yard.id AS "yardId", zone.id AS "zoneId"
@@ -513,8 +528,10 @@ export class WarehouseInventoryService {
JOIN freight.warehouse_yards yard ON yard.warehouse_id = wh.id AND yard.deleted_at IS NULL
JOIN freight.warehouse_zones zone ON zone.yard_id = yard.id AND zone.deleted_at IS NULL
WHERE wh.deleted_at IS NULL
${warehouseId ? 'AND wh.id = $1' : ''}
ORDER BY wh.created_at ASC
LIMIT 1`,
LIMIT 1`,
params,
);
return row ?? null;
}
@@ -592,6 +609,7 @@ export class WarehouseInventoryService {
dto.warehouseId && dto.yardId && dto.zoneId
? { warehouseId: dto.warehouseId, yardId: dto.yardId, zoneId: dto.zoneId, facilityId: dto.facilityId ?? null }
: null;
if (!location && dto.warehouseId) location = await this.pickDefaultLocation(dto.warehouseId);
if (!location) location = await this.pickDefaultLocation();
if (!location) {
throw new BadRequestException('No warehouse/yard/zone provided or configured for unloading');
@@ -704,7 +722,12 @@ export class WarehouseInventoryService {
) AS "firstMileDriverName",
driver.phone_number AS "firstMileDriverPhone",
driver.license_number AS "firstMileDriverLicenseNumber",
v.vehicle_type AS "firstMileTruckType"
v.vehicle_type AS "firstMileTruckType",
b.customer_truck_plate_number AS "customerTruckPlateNumber",
b.customer_truck_driver_name AS "customerTruckDriverName",
b.customer_truck_type AS "customerTruckType",
b.customer_truck_container_number AS "customerTruckContainerNumber",
b.customer_truck_assigned_at AS "customerTruckAssignedAt"
FROM freight.bookings b
LEFT JOIN freight.companies company ON company.id = b.company_id
LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id
@@ -796,7 +819,12 @@ export class WarehouseInventoryService {
) AS "firstMileDriverName",
driver.phone_number AS "firstMileDriverPhone",
driver.license_number AS "firstMileDriverLicenseNumber",
v.vehicle_type AS "firstMileTruckType"
v.vehicle_type AS "firstMileTruckType",
b.customer_truck_plate_number AS "customerTruckPlateNumber",
b.customer_truck_driver_name AS "customerTruckDriverName",
b.customer_truck_type AS "customerTruckType",
b.customer_truck_container_number AS "customerTruckContainerNumber",
b.customer_truck_assigned_at AS "customerTruckAssignedAt"
FROM freight.bookings b
LEFT JOIN freight.companies company ON company.id = b.company_id
LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id
@@ -1041,9 +1069,16 @@ export class WarehouseInventoryService {
COALESCE(inv.grn_number, substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)')) AS "grnNumber",
ts.train_number AS "trainSchedule",
inv.inspection_status AS "inspectionStatus",
CASE WHEN b.last_mile_delivery_address IS NOT NULL
CASE WHEN NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NOT NULL
OR COALESCE(st.includes_last_mile, false)
THEN 'DOOR_DELIVERY' ELSE 'TERMINAL_PICKUP' END AS "pickupOption",
(b.last_mile_delivery_address IS NOT NULL) AS "lastMileRequested",
(NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NOT NULL
OR COALESCE(st.includes_last_mile, false)) AS "lastMileRequested",
b.customer_truck_plate_number AS "customerTruckPlateNumber",
b.customer_truck_driver_name AS "customerTruckDriverName",
b.customer_truck_type AS "customerTruckType",
b.customer_truck_container_number AS "customerTruckContainerNumber",
b.customer_truck_assigned_at AS "customerTruckAssignedAt",
inv.status AS "currentStatus",
inv.release_date AS "releaseDate",
inv.release_order_reference AS "releaseOrderReference",
@@ -1056,6 +1091,7 @@ export class WarehouseInventoryService {
LEFT JOIN freight.bookings b ON b.id = inv.booking_id AND b.deleted_at IS NULL
LEFT JOIN freight.companies company ON company.id = b.company_id
LEFT JOIN freight.cargo_types cgt ON cgt.id = b.cargo_type_id
LEFT JOIN freight.service_types st ON st.id = b.service_type_id
LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id
LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id
LEFT JOIN freight.train_schedule_bookings tsb ON tsb.booking_id = b.id AND tsb.deleted_at IS NULL
@@ -1157,6 +1193,8 @@ export class WarehouseInventoryService {
async autoUnloadArrivedBookings(
scheduleId: string,
performedBy?: string,
warehouseId?: string,
assignments: BookingUnloadLocation[] = [],
): Promise<AutoUnloadArrivedResult> {
const result: AutoUnloadArrivedResult = { unloadedCount: 0, skippedCount: 0, failedCount: 0, results: [] };
@@ -1203,7 +1241,21 @@ export class WarehouseInventoryService {
[scheduleId],
);
const fallback = await this.pickDefaultLocation();
const requestedLocation = warehouseId ? await this.pickDefaultLocation(warehouseId) : null;
if (warehouseId && !requestedLocation) {
throw new BadRequestException('Selected warehouse has no yard/zone configured for unloading');
}
const fallback = requestedLocation ?? (await this.pickDefaultLocation());
const assignmentByBooking = new Map(
assignments.map((assignment) => [
assignment.bookingId,
{
warehouseId: assignment.warehouseId,
yardId: assignment.yardId,
zoneId: assignment.zoneId,
} satisfies LocationRef,
]),
);
const now = new Date();
for (const booking of bookings) {
@@ -1223,6 +1275,8 @@ export class WarehouseInventoryService {
try {
const existing = (await this.inventoryRepository.findAll({ where: { bookingId: booking.id } }))[0];
const assignedLocation = assignmentByBooking.get(booking.id) ?? null;
const unloadLocation = assignedLocation ?? requestedLocation;
// Already unloaded or further along — leave it (do not regress the lifecycle).
if (existing && existing.status !== 'RECEIVED') {
@@ -1232,6 +1286,13 @@ export class WarehouseInventoryService {
if (existing) {
await this.inventoryRepository.update(existing.id, {
...(unloadLocation
? {
warehouseId: unloadLocation.warehouseId,
yardId: unloadLocation.yardId,
zoneId: unloadLocation.zoneId,
}
: {}),
status: 'UNLOADED',
unloadedAt: now,
arrivedAt: existing.arrivedAt ?? now,
@@ -1239,7 +1300,7 @@ export class WarehouseInventoryService {
await this.activityLog.record({
activityType: 'INVENTORY_UNLOADED',
inventoryId: existing.id,
warehouseId: existing.warehouseId,
warehouseId: unloadLocation?.warehouseId ?? existing.warehouseId,
description: 'Unloaded from arrived import train',
performedBy,
});
@@ -1254,7 +1315,7 @@ export class WarehouseInventoryService {
tradeDirection: booking.tradeDirection,
cargoTypeCode: booking.cargoTypeCode,
});
const location = allocated ?? fallback;
const location = assignedLocation ?? requestedLocation ?? allocated ?? fallback;
if (!location) {
fail('No warehouse/yard/zone configured');
continue;
@@ -1336,6 +1397,16 @@ export class WarehouseInventoryService {
if (!['ARRIVED', 'ARRIVED_AT_DJIBOUTI'].includes(schedule.status)) {
throw new BadRequestException(`Train schedule is ${schedule.status}, not ARRIVED`);
}
const [gatepass] = await this.dataSource.query(
`SELECT gatepass_granted_at AS "gatepassSecuredAt"
FROM freight.import_djibouti_operations
WHERE train_schedule_id = $1 AND deleted_at IS NULL
LIMIT 1`,
[scheduleId],
);
if (!gatepass?.gatepassSecuredAt) {
throw new BadRequestException('Djibouti Port entry blocked: gate pass status is NOT_SECURED');
}
const items: Array<{
bookingId: string;
@@ -1631,13 +1702,17 @@ export class WarehouseInventoryService {
if (!bookingId) return;
const [booking] = await this.dataSource.query(
`SELECT reference,
last_mile_delivery_address AS "lastMileDeliveryAddress"
FROM freight.bookings
WHERE id = $1 AND deleted_at IS NULL
last_mile_delivery_address AS "lastMileDeliveryAddress",
COALESCE(st.includes_last_mile, false) AS "serviceIncludesLastMile"
FROM freight.bookings b
LEFT JOIN freight.service_types st ON st.id = b.service_type_id
WHERE b.id = $1 AND b.deleted_at IS NULL
LIMIT 1`,
[bookingId],
);
if (!booking?.reference || !booking.lastMileDeliveryAddress) return;
const hasLastMile =
Boolean(booking?.lastMileDeliveryAddress?.trim?.()) || Boolean(booking?.serviceIncludesLastMile);
if (!booking?.reference || !hasLastMile) return;
await this.lastMileService.acceptBooking(booking.reference);
}
@@ -1955,11 +2030,19 @@ export class WarehouseInventoryService {
}
const isTruckLeaving = dto.grossWeight !== undefined && Boolean(dto.gateOutTime);
if (isTruckLeaving) {
await this.invoices.assertClearanceAllowed(id);
}
const releaseDate = isTruckLeaving
? dto.releaseDate ? new Date(dto.releaseDate) : new Date()
: item.releaseDate ?? null;
const reference = dto.reference?.trim() || (await this.generateReleaseReference(item));
const exitInspectionNote = this.buildExitInspectionNote(dto);
const reference = isTruckLeaving
? item.releaseOrderReference || dto.reference?.trim() || (await this.generateReleaseReference(item))
: dto.reference?.trim() || (await this.generateReleaseReference(item));
const exitInspectionDto = isTruckLeaving
? this.preserveTruckArrivalForExit(dto, item.notes)
: dto;
const exitInspectionNote = this.buildExitInspectionNote(exitInspectionDto);
await this.dataSource.transaction(async (manager) => {
await manager.getRepository(WarehouseInventory).update(id, {
@@ -1967,6 +2050,17 @@ export class WarehouseInventoryService {
releaseOrderReference: reference,
notes: this.replaceExitInspectionNote(item.notes, exitInspectionNote),
});
if (!isTruckLeaving && item.bookingId) {
await manager.query(
`UPDATE freight.bookings
SET customer_truck_arrived_at = COALESCE(customer_truck_arrived_at, NOW()),
updated_at = NOW()
WHERE id = $1
AND customer_truck_assigned_at IS NOT NULL
AND deleted_at IS NULL`,
[item.bookingId],
);
}
await this.activityLog.record(
{
activityType: 'INVENTORY_RELEASED',
@@ -2034,6 +2128,7 @@ export class WarehouseInventoryService {
if (!row.releaseDate) {
throw new BadRequestException('A release order must be issued before downloading the exit paper');
}
await this.invoices.assertClearanceAllowed(id);
const bookingReference = row?.bookingReference || 'N/A';
const reference =
@@ -2180,11 +2275,19 @@ export class WarehouseInventoryService {
throw new BadRequestException('Please save your signature before approving delivery');
}
const [item]: Array<{ id: string; warehouseId: string | null; notes: string | null }> =
const [item]: Array<{
id: string;
warehouseId: string | null;
notes: string | null;
customerTruckAssignedAt: string | null;
customerTruckArrivedAt: string | null;
}> =
await this.dataSource.query(
`SELECT inv.id,
inv.warehouse_id AS "warehouseId",
inv.notes
inv.notes,
b.customer_truck_assigned_at AS "customerTruckAssignedAt",
b.customer_truck_arrived_at AS "customerTruckArrivedAt"
FROM freight.warehouse_inventory inv
JOIN freight.bookings b ON b.id = inv.booking_id AND b.deleted_at IS NULL
WHERE inv.booking_id = $1
@@ -2198,6 +2301,10 @@ export class WarehouseInventoryService {
if (!item) {
throw new BadRequestException('Delivery can be approved after warehouse inspection has passed');
}
if (item.customerTruckAssignedAt && !item.customerTruckArrivedAt) {
throw new BadRequestException('Customer truck arrival must be recorded before delivery approval');
}
await this.invoices.assertClearanceAllowed(item.id);
const approvedAt = new Date();
const approval = {
@@ -2301,6 +2408,7 @@ export class WarehouseInventoryService {
if (!row) {
throw new NotFoundException(`Inventory item ${id} not found`);
}
await this.invoices.assertClearanceAllowed(id);
if (row.inspectionStatus !== 'PASSED') {
throw new BadRequestException('Handover document is available after inspection has passed');
}
@@ -3302,8 +3410,13 @@ export class WarehouseInventoryService {
if (!truckEntrance.driverPhone?.trim()) {
throw new BadRequestException('Driver phone is required for entrance registration');
}
if (truckEntrance.entranceTareWeightKg === undefined || Number(truckEntrance.entranceTareWeightKg) < 0) {
throw new BadRequestException('Entrance tare weight is required for entrance registration');
if (truckEntrance.weighingRequired) {
if (truckEntrance.grossWeightKg === undefined || Number(truckEntrance.grossWeightKg) < 0) {
throw new BadRequestException('Gross weight is required when customer truck weighing is Yes');
}
if (truckEntrance.exitTareWeightKg === undefined || Number(truckEntrance.exitTareWeightKg) < 0) {
throw new BadRequestException('Exit tare weight is required when customer truck weighing is Yes');
}
}
}
@@ -3325,6 +3438,11 @@ export class WarehouseInventoryService {
firstMileDriverPhone?: string | null;
firstMileDriverLicenseNumber?: string | null;
firstMileTruckType?: string | null;
customerTruckPlateNumber?: string | null;
customerTruckDriverName?: string | null;
customerTruckType?: string | null;
customerTruckContainerNumber?: string | null;
customerTruckAssignedAt?: string | null;
},
): TruckEntranceDto {
return {
@@ -3340,16 +3458,22 @@ export class WarehouseInventoryService {
booking.containerQuantity !== undefined && booking.containerQuantity !== null
? Number(booking.containerQuantity)
: submitted.unitCount,
grossWeightKg:
booking.weight !== undefined && booking.weight !== null
? Number(booking.weight)
: submitted.grossWeightKg,
truckPlateNumber: booking.firstMileTruckPlateNumber?.trim() || submitted.truckPlateNumber,
grossWeightKg: submitted.grossWeightKg,
truckPlateNumber:
booking.firstMileTruckPlateNumber?.trim() ||
booking.customerTruckPlateNumber?.trim() ||
submitted.truckPlateNumber,
trailerPlateNumber: booking.firstMileTrailerPlateNumber?.trim() || submitted.trailerPlateNumber,
driverName: booking.firstMileDriverName?.trim() || submitted.driverName,
driverName:
booking.firstMileDriverName?.trim() ||
booking.customerTruckDriverName?.trim() ||
submitted.driverName,
driverPhone: booking.firstMileDriverPhone?.trim() || submitted.driverPhone,
driverLicenseNumber: booking.firstMileDriverLicenseNumber?.trim() || submitted.driverLicenseNumber,
truckType: booking.firstMileTruckType?.trim() || submitted.truckType,
truckType:
booking.firstMileTruckType?.trim() ||
booking.customerTruckType?.trim() ||
submitted.truckType,
};
}
@@ -3543,6 +3667,24 @@ export class WarehouseInventoryService {
return rows.filter(Boolean).join('\n');
}
private preserveTruckArrivalForExit(dto: ReleaseOrderDto, notes: string | null | undefined): ReleaseOrderDto {
const inspection = this.extractExitInspectionNote(notes);
if (!inspection) return dto;
return {
...dto,
truckPlateNumber: this.extractExitInspectionLine(inspection, 'Truck Plate') || dto.truckPlateNumber,
trailerPlateNumber: this.extractExitInspectionLine(inspection, 'Trailer Plate') || dto.trailerPlateNumber,
driverName: this.extractExitInspectionLine(inspection, 'Driver') || dto.driverName,
driverLicense: this.extractExitInspectionLine(inspection, 'Driver License') || dto.driverLicense,
driverPhone: this.extractExitInspectionLine(inspection, 'Driver Phone') || dto.driverPhone,
truckType: this.extractExitInspectionLine(inspection, 'Truck Type') || dto.truckType,
containerNumber: this.extractExitInspectionLine(inspection, 'Container Number') || dto.containerNumber,
gateInTime: this.extractExitInspectionLine(inspection, 'Gate In Time') || dto.gateInTime,
tareWeight: this.extractExitInspectionNumber(inspection, 'Tare Weight') ?? dto.tareWeight,
};
}
private replaceExitInspectionNote(notes: string | null | undefined, exitInspectionNote: string | null): string | null {
const trimmed = notes?.trim();
if (!exitInspectionNote) return trimmed || null;
@@ -3564,6 +3706,18 @@ export class WarehouseInventoryService {
return notes.slice(index + marker.length).trim() || null;
}
private extractExitInspectionLine(note: string | null | undefined, label: string): string | null {
const match = note?.match(new RegExp(`^${label}:\\s*(.+)$`, 'im'));
return match?.[1]?.trim() || null;
}
private extractExitInspectionNumber(note: string | null | undefined, label: string): number | undefined {
const value = this.extractExitInspectionLine(note, label)?.replace(/\s*kg$/i, '');
if (!value) return undefined;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : undefined;
}
private extractReceiveSummary(notes?: string | null): string | null {
if (!notes?.trim()) return null;
const withoutExit = notes.split('\n\n[Exit Inspection]')[0] ?? notes;
@@ -3622,6 +3776,7 @@ export class WarehouseInventoryService {
truck?.driverPhone ? `Driver Phone: ${truck.driverPhone}` : null,
truck?.driverLicenseNumber ? `Driver License: ${truck.driverLicenseNumber}` : null,
truck?.entranceTareWeightKg !== undefined ? `Entrance Tare Weight: ${Number(truck.entranceTareWeightKg)} kg` : null,
truck?.weighingRequired !== undefined ? `Weighing Required: ${truck.weighingRequired ? 'Yes' : 'No'}` : null,
truck?.exitTareWeightKg !== undefined ? `Exit Tare Weight: ${Number(truck.exitTareWeightKg)} kg` : null,
truck?.declarationNumber ? `Declaration / Bill of Entry: ${truck.declarationNumber}` : null,
truck?.incoterms ? `Incoterms: ${truck.incoterms}` : null,

View File

@@ -2,6 +2,7 @@ import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query, Res }
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import type { Response } from 'express';
import { PayInvoiceDto as GatewayPayInvoiceDto } from '../billing/dto/pay-invoice.dto';
import { GenerateInvoiceDto, PayInvoiceBodyDto } from './dto/invoice.dto';
import { WarehouseInvoiceService } from './warehouse-invoice.service';
@@ -86,4 +87,10 @@ export class WarehouseInvoiceController {
pay(@Param('id', ParseUUIDPipe) id: string, @Body() dto: PayInvoiceBodyDto) {
return this.invoiceService.pay(id, dto);
}
@Post('warehouse-fee-invoices/:id/pay-online')
@ApiOperation({ summary: 'Initiate Telebirr/Waafi payment for a warehouse fee invoice' })
payOnline(@Param('id', ParseUUIDPipe) id: string, @Body() dto: GatewayPayInvoiceDto) {
return this.invoiceService.initiatePayment(id, dto);
}
}

View File

@@ -3,6 +3,7 @@ import { OnEvent } from '@nestjs/event-emitter';
import { Freight } from '@edr/types';
import { DataSource } from 'typeorm';
import { PayInvoiceDto as GatewayPayInvoiceDto } from '../billing/dto/pay-invoice.dto';
import { BillingService, InvoiceEventPayload, InvoiceLineInput } from '../billing/billing.service';
import { Invoice } from '../billing/entities/invoice.entity';
import { InvoiceLine } from '../billing/entities/invoice-line.entity';
@@ -171,8 +172,12 @@ export class WarehouseInvoiceService {
feeType,
description:
p.ruleType === 'STORAGE_FEE'
? `Storage fee - ${p.chargeableDays} chargeable day(s) x ${p.containerCount} container(s) after ${p.freeDays} free`
: `${isContainer ? 'Container' : 'Bulk'} demurrage - ${p.chargeableDays} chargeable day(s) x ${p.containerCount} container(s) after ${p.freeDays} free`,
? `Storage fee - ${p.chargeableDays} chargeable day(s) x ${p.containerCount} container(s)${
p.tiers.length ? ' using tiered tariff' : ` after ${p.freeDays} free`
}`
: `${isContainer ? 'Container' : 'Bulk'} demurrage - ${p.chargeableDays} chargeable day(s) x ${p.containerCount} container(s)${
p.tiers.length ? ' using tiered tariff' : ` after ${p.freeDays} free`
}`,
quantity: p.billableUnits,
unitRate: p.ratePerDay,
amount: p.amount,
@@ -305,6 +310,22 @@ export class WarehouseInvoiceService {
return detail;
}
/** Initiate a wallet/gateway payment for the invoice. */
async initiatePayment(id: string, dto: GatewayPayInvoiceDto = {}) {
const invoice = await this.loadWarehouseInvoice(id);
if (invoice.status === Freight.InvoiceStatus.Paid) {
throw new BadRequestException('Invoice is already fully paid.');
}
return this.billing.payInvoice(invoice.source as Freight.InvoiceSource, invoice.sourceId, {
method: dto.method ?? (invoice.currency === 'USD' ? 'WAAFI' : 'TELEBIRR'),
platform: dto.platform ?? 'web',
payerAccount: dto.payerAccount,
returnUrl: dto.returnUrl,
failureUrl: dto.failureUrl,
});
}
/**
* Notify on online (gateway) settlement — the domain side-effect of a warehouse
* fee being paid through billing's payment flow. The counter {@link pay} path