train gate pass, Telebirr and Wafi

This commit is contained in:
hagiye
2026-07-01 16:23:52 +03:00
parent 38db9a4177
commit 14a00af341
41 changed files with 2807 additions and 151 deletions

View File

@@ -657,6 +657,11 @@ export class BillingService {
throw new NotFoundException(`No open invoice to charge for ${source}:${sourceId}`);
}
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: sourceId,
source: invoice.source,
@@ -667,7 +672,7 @@ export class BillingService {
// service branches on a domain-specific reference type.
referenceType: PaymentReferenceType.SHIPMENT,
orderRef: invoice.invoiceNumber,
amountMinor: Math.round(Number(invoice.totalAmount)),
amountMinor: Math.round(amountDue),
currency: invoice.currency,
reason: `Payment for invoice ${invoice.invoiceNumber}`,
method: opts.method ?? "TELEBIRR",

View File

@@ -54,6 +54,7 @@ import {
StaffRejectDto,
} 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 {
@@ -254,6 +255,39 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking);
}
@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',

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 {
@@ -81,8 +83,54 @@ 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);
if (booking.lastMileDeliveryAddress?.trim()) {
throw new BadRequestException(
'Customer truck assignment is only allowed when 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,
@@ -120,6 +168,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

@@ -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

@@ -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

@@ -221,6 +221,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 +307,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;
@@ -704,7 +714,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 +811,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
@@ -1044,6 +1064,11 @@ export class WarehouseInventoryService {
CASE WHEN b.last_mile_delivery_address IS NOT NULL
THEN 'DOOR_DELIVERY' ELSE 'TERMINAL_PICKUP' END AS "pickupOption",
(b.last_mile_delivery_address IS NOT NULL) 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",
@@ -1336,6 +1361,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;
@@ -1955,6 +1990,9 @@ 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;
@@ -1967,6 +2005,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 +2083,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 +2230,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 +2256,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 +2363,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 +3365,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 +3393,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 +3413,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,
};
}
@@ -3622,6 +3701,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