Merge freight/develop into Warehouses

This commit is contained in:
Hagernesh
2026-06-11 19:26:21 +00:00
709 changed files with 68627 additions and 9486 deletions

View File

@@ -14,6 +14,11 @@ export function computeNextStep(
const { status } = booking;
switch (status) {
case 'PRICE_CHANGED_PENDING_CONFIRM':
return {
action: 'CONFIRM_SUBMIT',
description: 'Price has changed since preview; confirm to submit booking',
};
case 'SUBMITTED':
return {
action: 'ACCEPT_INTAKE',

View File

@@ -4,56 +4,44 @@ import { Booking } from './entities/booking.entity';
import { assertBookingStatus } from './booking-status.util';
import { InAppPaymentReceiptDto } from './dto/pay-booking.dto';
import { PaymentService } from '../payment/payment.service';
import { PaymentStatus } from '../payment/entities/payment.entity';
export interface InAppPaymentReceipt extends InAppPaymentReceiptDto { }
const NON_TERMINAL_STATUSES: PaymentStatus[] = [
"action-required",
"processing",
"success",
];
@Injectable()
export class BookingPaymentService {
constructor(private readonly bookingsRepository: BookingsRepository, private readonly paymentService: PaymentService) { }
constructor(
private readonly bookingsRepository: BookingsRepository,
private readonly paymentService: PaymentService,
) { }
async pay(
bookingId: string,
): Promise<{ redirectUrl: string }> {
async pay(bookingId: string): Promise<{ redirectUrl: string }> {
const booking = await this.requireBooking(bookingId);
assertBookingStatus(booking, ['FULLY_EXECUTED']);
assertBookingStatus(booking, ['FULLY_EXECUTED', '']);
// const receipt = this.buildMockReceipt(booking);
// const updated = await this.bookingsRepository.update(bookingId, {
// status: 'PAID',
// paymentStatus: 'PAID',
// } as never);
const resp = await this.paymentService.pay(booking.totalAmount, "ETB", "telebirr", "payment for booking", 'booking', (_) => {
return new Promise((resp, _) => {
resp({
id: booking.id,
type: "booking"
})
});
})
return {
redirectUrl: resp.clientAction.type == "REDIRECT" ? `http://localhost:3001/api/payments/telebirr/${booking.id}` : ""
const existing = await this.paymentService.findBookingById(bookingId);
if (existing && NON_TERMINAL_STATUSES.includes(existing.status)) {
if (existing.clientAction) {
const action = existing.clientAction as { type?: string; url?: string };
if (action.type === "REDIRECT" && action.url) {
return { redirectUrl: action.url };
}
}
}
const resp = await this.paymentService.initBookingTelebirr(bookingId, "web");
return {
redirectUrl:
resp.redirectUrl ?? "",
};
}
// private buildMockReceipt(booking: Booking): InAppPaymentReceipt {
// const timestamp = Date.now();
// const isEtb = booking.paymentCurrency === 'ETB';
// const prefix = isEtb ? 'TB' : 'CARD';
// const provider = isEtb ? 'TELEBIRR' : 'CARD';
// return {
// success: true,
// provider,
// providerRef: `${prefix}-${booking.reference}-${timestamp}`,
// amount: booking.totalAmount,
// currency: booking.paymentCurrency,
// paidAt: new Date().toISOString(),
// };
// }
private async requireBooking(id: string): Promise<Booking> {
const booking = await this.bookingsRepository.findById(id);
if (!booking) throw new NotFoundException(`Booking ${id} not found`);

View File

@@ -14,6 +14,24 @@ import { GeneratePriceResponseDto, PriceLineItemDto } from './dto/generate-price
import { Booking } from './entities/booking.entity';
import { assertBookingStatus } from './booking-status.util';
export interface ComputedPriceResult {
lineItems: PriceLineItemDto[];
totalAmount: number;
currency: string;
usedRates: Rate[];
appliedModifiers: AppliedCargoModifier[];
priorityScore: number;
warnings: string[];
hardBlocked: string[];
}
type StoredPricingBreakdown = {
lineItems?: PriceLineItemDto[];
totalAmount?: number;
currency?: string;
generatedAt?: string;
} | null;
@Injectable()
export class BookingPricingService {
constructor(
@@ -26,22 +44,56 @@ export class BookingPricingService {
async generatePrice(bookingId: string): Promise<GeneratePriceResponseDto> {
const booking = await this.requireBooking(bookingId);
assertBookingStatus(booking, ['DRAFT']);
assertBookingStatus(booking, ['DRAFT', 'CHANGES_REQUESTED']);
const computed = await this.computePriceForBooking(booking);
this.ruleEngineService.assertNoHardBlocks({
priorityScore: computed.priorityScore,
appliedModifiers: computed.appliedModifiers,
containerWeightResults: [],
warnings: computed.warnings,
hardBlocked: computed.hardBlocked,
requiresDirectorApproval: false,
});
await this.bookingsRepository.update(bookingId, {
totalAmount: computed.totalAmount,
priorityScore: computed.priorityScore,
pricingBreakdown: {
lineItems: computed.lineItems,
totalAmount: computed.totalAmount,
currency: computed.currency,
generatedAt: new Date().toISOString(),
},
} as never);
return {
bookingId,
totalAmount: computed.totalAmount,
currency: computed.currency,
lineItems: computed.lineItems,
warnings: computed.warnings,
};
}
async computePriceForBooking(booking: Booking): Promise<ComputedPriceResult> {
const evalInput = await this.buildEvalInputForBooking(booking);
console.log('evalInput----', evalInput);
const ruleResult = await this.ruleEngineService.evaluate(evalInput);
this.ruleEngineService.assertNoHardBlocks(ruleResult);
const lineItems: PriceLineItemDto[] = [];
let total = 0;
const baseLines = await this.computeBaseRailLines(booking, evalInput);
const { lineItems: baseLines, usedRates: baseRates } =
await this.computeBaseRailLinesWithRates(booking, evalInput);
for (const line of baseLines) {
lineItems.push(line);
total += line.amount;
}
const liveRates = await this.ratesService.findLiveRates();
const rateById = new Map(liveRates.map((r) => [r.id, r]));
const usedRatesMap = new Map(baseRates.map((r) => [r.id, r]));
for (const mod of ruleResult.appliedModifiers) {
const item: PriceLineItemDto = {
code: mod.surchargeTypeCode,
@@ -51,44 +103,76 @@ export class BookingPricingService {
};
lineItems.push(item);
total += mod.calculatedAmount;
const rate = rateById.get(mod.rateId);
if (rate) usedRatesMap.set(rate.id, rate);
}
await this.persistPriceRun(bookingId, ruleResult.appliedModifiers, total);
await this.bookingsRepository.update(bookingId, {
totalAmount: total,
priorityScore: ruleResult.priorityScore,
pricingBreakdown: {
lineItems,
totalAmount: total,
currency: booking.paymentCurrency,
generatedAt: new Date().toISOString(),
},
} as never);
return {
bookingId,
lineItems,
totalAmount: total,
currency: booking.paymentCurrency,
lineItems,
usedRates: [...usedRatesMap.values()],
appliedModifiers: ruleResult.appliedModifiers,
priorityScore: ruleResult.priorityScore,
warnings: ruleResult.warnings,
hardBlocked: ruleResult.hardBlocked,
};
}
pricesMatch(stored: StoredPricingBreakdown, computed: ComputedPriceResult): boolean {
if (!stored?.lineItems?.length) return false;
if (Number(stored.totalAmount) !== computed.totalAmount) return false;
return (
this.lineItemsSignature(stored.lineItems) ===
this.lineItemsSignature(computed.lineItems)
);
}
async createPricingSnapshots(
bookingId: string,
usedRates: Rate[],
appliedModifiers: AppliedCargoModifier[],
): Promise<void> {
await this.bookingsRepository.clearPricingArtifacts(bookingId);
const snapshots = await this.ruleEngineService.snapshotRates(bookingId, usedRates);
const snapshotByRateId = new Map(snapshots.map((s) => [s.rateId, s.id]));
const rows = appliedModifiers
.map((m) => {
const snapshotId = snapshotByRateId.get(m.rateId);
if (!snapshotId) return null;
return {
bookingId,
surchargeTypeId: m.surchargeTypeId,
triggerValue: m.triggerValue,
calculatedAmount: m.calculatedAmount,
rateSnapshotId: snapshotId,
};
})
.filter((r): r is NonNullable<typeof r> => r !== null);
if (rows.length > 0) {
await this.bookingsRepository.createCargoModifiers(rows);
}
}
async buildEvalInputForBooking(booking: Booking): Promise<BookingEvaluationInput> {
const containers = await Promise.all(
(booking.bookingContainers ?? []).map(async (bc) => {
const ct = await this.containerTypesService.findById(bc.containerTypeId);
const vgm = Number(bc.vgmPerUnitTons);
const qty = bc.quantity;
return {
containerTypeId: bc.containerTypeId,
quantity: qty,
vgmPerUnitTons: vgm,
totalVgmTons: qty * vgm,
isReefer: ct.isReefer,
};
}),
(booking.bookingContainers ?? [])
.filter((bc): bc is typeof bc & { containerTypeId: string } => bc.containerTypeId != null)
.map(async (bc) => {
const ct = await this.containerTypesService.findById(bc.containerTypeId);
const vgm = Number(bc.vgmPerUnitTons);
const qty = bc.quantity;
return {
containerTypeId: bc.containerTypeId,
quantity: qty,
vgmPerUnitTons: vgm,
totalVgmTons: qty * vgm,
isReefer: ct.isReefer,
};
}),
);
return {
freightType: booking.freightType as 'CONTAINER' | 'BULK',
@@ -97,6 +181,7 @@ export class BookingPricingService {
paymentCurrency: booking.paymentCurrency,
tradeDirection: booking.tradeDirection,
isHazardous: booking.isHazardous,
isGovernment: booking.isGovernment,
allowConsolidation: booking.allowConsolidation,
shippingLineId: booking.shippingLineId,
containers,
@@ -115,11 +200,7 @@ export class BookingPricingService {
totalAmount: number;
currency: string;
}> {
const stored = booking.pricingBreakdown as {
lineItems?: PriceLineItemDto[];
totalAmount?: number;
currency?: string;
} | null;
const stored = booking.pricingBreakdown as StoredPricingBreakdown;
if (stored?.lineItems?.length) {
return {
@@ -129,41 +210,28 @@ export class BookingPricingService {
};
}
const evalInput = await this.buildEvalInputForBooking(booking);
const ruleResult = await this.ruleEngineService.evaluate(evalInput);
const lineItems: PriceLineItemDto[] = [];
let total = 0;
const computed = await this.computePriceForBooking(booking);
const baseLines = await this.computeBaseRailLines(booking, evalInput);
for (const line of baseLines) {
lineItems.push(line);
total += line.amount;
}
for (const mod of ruleResult.appliedModifiers) {
lineItems.push({
code: mod.surchargeTypeCode,
description: `Surcharge: ${mod.surchargeTypeCode}`,
amount: mod.calculatedAmount,
currency: mod.currency,
});
total += mod.calculatedAmount;
}
if (lineItems.length === 0) {
total = Number(booking.totalAmount);
lineItems.push({
code: 'TOTAL',
description: 'Contract total',
amount: total,
if (computed.lineItems.length === 0) {
const total = Number(booking.totalAmount);
return {
lineItems: [
{
code: 'TOTAL',
description: 'Contract total',
amount: total,
currency: booking.paymentCurrency,
},
],
totalAmount: total,
currency: booking.paymentCurrency,
});
};
}
return {
lineItems,
totalAmount: total || Number(booking.totalAmount),
currency: booking.paymentCurrency,
lineItems: computed.lineItems,
totalAmount: computed.totalAmount || Number(booking.totalAmount),
currency: computed.currency,
};
}
@@ -190,14 +258,14 @@ export class BookingPricingService {
return score;
}
private async computeBaseRailLines(
private async computeBaseRailLinesWithRates(
booking: Booking,
evalInput: BookingEvaluationInput,
): Promise<PriceLineItemDto[]> {
): Promise<{ lineItems: PriceLineItemDto[]; usedRates: Rate[] }> {
const liveRates = await this.ratesService.findLiveRates();
const currency = booking.paymentCurrency;
const isBulk = booking.freightType === 'BULK';
console.log('liveRates----', liveRates);
const rateType =
booking.tradeDirection === 'IMPORT'
? isBulk
@@ -209,18 +277,15 @@ console.log('liveRates----', liveRates);
: 'CONTAINER_EXPORT'
: 'INTERCITY_CONTAINER';
console.log('rateType----', rateType);
const lines: PriceLineItemDto[] = [];
const usedRatesMap = new Map<string, Rate>();
const wagonCount = await this.bookingsRepository.calculateWagonCount(booking.id);
for (const container of evalInput.containers) {
console.log('container----', container);
const rate = this.pickRate(liveRates, rateType, container.containerTypeId, currency);
console.log('rate----', rate);
if (!rate) continue;
usedRatesMap.set(rate.id, rate);
const amount = this.amountForRate(rate, container.quantity, wagonCount);
lines.push({
code: rateType,
@@ -235,6 +300,7 @@ console.log('liveRates----', liveRates);
(r) => r.rateType === rateType && r.currency === currency && r.status === 'LIVE',
);
if (fallback) {
usedRatesMap.set(fallback.id, fallback);
const amount = this.amountForRate(fallback, 1, wagonCount);
lines.push({
code: rateType,
@@ -245,7 +311,7 @@ console.log('liveRates----', liveRates);
}
}
return lines;
return { lineItems: lines, usedRates: [...usedRatesMap.values()] };
}
private pickRate(
@@ -281,31 +347,15 @@ console.log('liveRates----', liveRates);
}
}
private async persistPriceRun(
bookingId: string,
modifiers: AppliedCargoModifier[],
_total: number,
): Promise<void> {
await this.bookingsRepository.clearPricingArtifacts(bookingId);
const snapshots = await this.ruleEngineService.snapshotLiveRates(bookingId);
const snapshotByRateId = new Map(snapshots.map((s) => [s.rateId, s.id]));
const rows = modifiers
.map((m) => {
const snapshotId = snapshotByRateId.get(m.rateId);
if (!snapshotId) return null;
return {
bookingId,
surchargeTypeId: m.surchargeTypeId,
triggerValue: m.triggerValue,
calculatedAmount: m.calculatedAmount,
rateSnapshotId: snapshotId,
};
})
.filter((r): r is NonNullable<typeof r> => r !== null);
if (rows.length > 0) {
await this.bookingsRepository.createCargoModifiers(rows);
}
private lineItemsSignature(items: PriceLineItemDto[]): string {
return JSON.stringify(
[...items]
.map((item) => ({
code: item.code,
amount: item.amount,
currency: item.currency,
}))
.sort((a, b) => a.code.localeCompare(b.code)),
);
}
}

View File

@@ -8,6 +8,8 @@ import { BookingPricingService } from './booking-pricing.service';
import { BookingsRepository } from './bookings.repository';
import { assertBookingStatus } from './booking-status.util';
import { computeNextStep, type BookingNextStep } from './booking-next-step.util';
import { SubmitBookingResponseDto } from './dto/submit-booking-response.dto';
import { PriceLineItemDto } from './dto/generate-price-response.dto';
import { Booking } from './entities/booking.entity';
import { BookingsService } from './bookings.service';
@@ -22,7 +24,7 @@ export class BookingTransitionService {
private readonly bookingsService: BookingsService,
) {}
async submit(bookingId: string): Promise<Booking> {
async submit(bookingId: string): Promise<SubmitBookingResponseDto> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ['DRAFT', 'CHANGES_REQUESTED']);
@@ -32,14 +34,119 @@ export class BookingTransitionService {
);
}
const priorityScore = await this.pricingService.computeSubmitPriorityScore(booking);
await this.ruleEngineService.snapshotLiveRates(bookingId);
const computed = await this.pricingService.computePriceForBooking(booking);
this.ruleEngineService.assertNoHardBlocks({
priorityScore: computed.priorityScore,
appliedModifiers: computed.appliedModifiers,
containerWeightResults: [],
warnings: computed.warnings,
hardBlocked: computed.hardBlocked,
requiresDirectorApproval: false,
});
const stored = booking.pricingBreakdown as {
lineItems?: PriceLineItemDto[];
totalAmount?: number;
} | null;
const unchanged = this.pricingService.pricesMatch(stored, computed);
const priorityScore = await this.pricingService.computeSubmitPriorityScore(booking);
if (unchanged) {
await this.pricingService.createPricingSnapshots(
bookingId,
computed.usedRates,
computed.appliedModifiers,
);
const updated = await this.bookingsRepository.update(bookingId, {
status: 'SUBMITTED',
priorityScore,
} as never);
const finalBooking = await this.bookingsService.findById(updated!.id);
return {
bookingId: finalBooking.id,
status: finalBooking.status,
priceChanged: false,
totalAmount: Number(finalBooking.totalAmount),
currency: finalBooking.paymentCurrency,
lineItems: computed.lineItems,
};
}
const previousTotalAmount = Number(booking.totalAmount);
await this.bookingsRepository.update(bookingId, {
totalAmount: computed.totalAmount,
priorityScore: computed.priorityScore,
pricingBreakdown: {
lineItems: computed.lineItems,
totalAmount: computed.totalAmount,
currency: computed.currency,
generatedAt: new Date().toISOString(),
},
status: 'PRICE_CHANGED_PENDING_CONFIRM',
} as never);
const updatedBooking = await this.bookingsService.findById(bookingId);
return {
bookingId: updatedBooking.id,
status: updatedBooking.status,
priceChanged: true,
previousTotalAmount,
totalAmount: computed.totalAmount,
currency: computed.currency,
lineItems: computed.lineItems,
message: 'Price has changed since preview. Confirm to submit with the updated price.',
};
}
async confirmSubmit(bookingId: string): Promise<SubmitBookingResponseDto> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ['PRICE_CHANGED_PENDING_CONFIRM']);
if (Number(booking.totalAmount) <= 0) {
throw new BadRequestException('No price to confirm');
}
const computed = await this.pricingService.computePriceForBooking(booking);
this.ruleEngineService.assertNoHardBlocks({
priorityScore: computed.priorityScore,
appliedModifiers: computed.appliedModifiers,
containerWeightResults: [],
warnings: computed.warnings,
hardBlocked: computed.hardBlocked,
requiresDirectorApproval: false,
});
await this.pricingService.createPricingSnapshots(
bookingId,
computed.usedRates,
computed.appliedModifiers,
);
const priorityScore = await this.pricingService.computeSubmitPriorityScore(booking);
const updated = await this.bookingsRepository.update(bookingId, {
status: 'SUBMITTED',
priorityScore,
totalAmount: computed.totalAmount,
pricingBreakdown: {
lineItems: computed.lineItems,
totalAmount: computed.totalAmount,
currency: computed.currency,
generatedAt: new Date().toISOString(),
},
} as never);
return this.bookingsService.findById(updated!.id);
const finalBooking = await this.bookingsService.findById(updated!.id);
return {
bookingId: finalBooking.id,
status: finalBooking.status,
priceChanged: false,
totalAmount: Number(finalBooking.totalAmount),
currency: finalBooking.paymentCurrency,
lineItems: computed.lineItems,
message: 'Booking submitted with confirmed price.',
};
}
async requestChanges(
@@ -279,6 +386,7 @@ export class BookingTransitionService {
assertBookingStatus(booking, [
'DRAFT',
'SUBMITTED',
'PRICE_CHANGED_PENDING_CONFIRM',
'CHANGES_REQUESTED',
'PENDING_APPROVAL',
'CONTRACT_READY',
@@ -321,4 +429,4 @@ export class BookingTransitionService {
nextStep,
};
}
}
}

View File

@@ -39,6 +39,7 @@ import { CreateBookingDto } from './dto/create-booking.dto';
import { BookingListSummaryDto } from './dto/booking-list-summary.dto';
import { FilterBookingDto } from './dto/filter-booking.dto';
import { GeneratePriceResponseDto } from './dto/generate-price-response.dto';
import { SubmitBookingResponseDto } from './dto/submit-booking-response.dto';
import {
ApproveStepDto,
CancelBookingDto,
@@ -53,6 +54,7 @@ import {
type AuthUserPayload,
resolveAuthUserId,
} from '../../common/resolve-auth-user-id';
import { assertFreightPermission } from '../../common/freight-permission.util';
@ApiTags('bookings')
@Controller('bookings')
@@ -74,10 +76,12 @@ export class BookingsController {
create(
@Body() dto: CreateBookingDto,
@UploadedFiles() files: Express.Multer.File[],
@Request() req: { user?: { id?: string; sub?: string } },
@CurrentUser() user: TCurrentUser,
) {
const userId = req.user?.id ?? req.user?.sub;
return this.bookingsService.create(dto, files ?? [], userId);
if (dto.isGovernment) {
assertFreightPermission(user, FREIGHT_PERMS.bookings.staffAccept);
}
return this.bookingsService.create(dto, files ?? [], user?.id);
}
@Patch(':id')
@@ -165,17 +169,36 @@ export class BookingsController {
}
@Post(':id/generate-price')
@ApiOperation({ summary: 'Generate price preview (DRAFT only)' })
@ApiOperation({
summary: 'Generate price preview (DRAFT or CHANGES_REQUESTED)',
description:
'Computes and stores a price preview on the booking. Does not create rate snapshots.',
})
@ApiOkResponse({ type: GeneratePriceResponseDto })
generatePrice(@Param('id', ParseUUIDPipe) id: string) {
return this.pricingService.generatePrice(id);
}
@Post(':id/submit')
@ApiOperation({ summary: 'Customer submit booking' })
async submit(@Param('id', ParseUUIDPipe) id: string) {
const booking = await this.transitionService.submit(id);
return this.transitionService.enrichBookingResponse(booking);
@ApiOperation({
summary: 'Customer submit booking',
description:
'Recomputes price against live rates. If unchanged, creates rate snapshots and submits. If changed, updates the booking price and returns priceChanged=true for confirmation.',
})
@ApiOkResponse({ type: SubmitBookingResponseDto })
submit(@Param('id', ParseUUIDPipe) id: string) {
return this.transitionService.submit(id);
}
@Post(':id/confirm-submit')
@ApiOperation({
summary: 'Confirm submit after price change',
description:
'Creates rate snapshots for the updated booking price and moves the booking to SUBMITTED.',
})
@ApiOkResponse({ type: SubmitBookingResponseDto })
confirmSubmit(@Param('id', ParseUUIDPipe) id: string) {
return this.transitionService.confirmSubmit(id);
}
@Post(':id/staff/request-changes')
@@ -224,6 +247,20 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/government-expedite')
@BookingStaff(FREIGHT_PERMS.bookings.staffAccept)
@ApiOperation({ summary: 'Expedite government booking to PAID / ELIGIBLE for scheduling' })
async governmentExpedite(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() user: AuthUserPayload,
) {
const booking = await this.bookingsService.governmentExpedite(
id,
resolveAuthUserId(user),
);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/approval-steps/:stepId/approve')
@BookingStaff([
FREIGHT_PERMS.bookings.approveLineStaff,

View File

@@ -0,0 +1,70 @@
import { DataSource, Repository } from 'typeorm';
import { Booking } from './entities/booking.entity';
import { BookingsRepository } from './bookings.repository';
function mockQueryBuilder() {
const qb = {
leftJoinAndSelect: jest.fn().mockReturnThis(),
leftJoin: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
addOrderBy: jest.fn().mockReturnThis(),
skip: jest.fn().mockReturnThis(),
take: jest.fn().mockReturnThis(),
getMany: jest.fn(),
getManyAndCount: jest.fn().mockResolvedValue([[], 0]),
};
return qb;
}
describe('BookingsRepository', () => {
let repository: jest.Mocked<Repository<Booking>>;
let dataSource: { getRepository: jest.Mock };
let bookingsRepository: BookingsRepository;
beforeEach(() => {
repository = {
createQueryBuilder: jest.fn(),
} as unknown as jest.Mocked<Repository<Booking>>;
dataSource = { getRepository: jest.fn() };
bookingsRepository = new BookingsRepository(repository, dataSource as unknown as DataSource);
});
it('findEligibleForScheduling does not filter by schedule date', async () => {
const qb = mockQueryBuilder();
const bookings = [
{ id: 'b1', scheduledDate: new Date('2026-06-20T08:00:00.000Z') },
{ id: 'b2', scheduledDate: new Date('2026-06-21T14:00:00.000Z') },
];
qb.getMany.mockResolvedValue(bookings);
repository.createQueryBuilder.mockReturnValue(qb as never);
const result = await bookingsRepository.findEligibleForScheduling({
originStationId: 'yard-origin',
destinationStationId: 'yard-destination',
freightType: 'CONTAINER',
});
expect(result).toHaveLength(2);
const dateFilters = qb.andWhere.mock.calls.filter(([clause]) =>
String(clause).includes('scheduled_date'),
);
expect(dateFilters).toHaveLength(0);
});
it('applyListFilters excludes assigned bookings when assignedToSchedule is false', async () => {
const qb = mockQueryBuilder();
repository.createQueryBuilder.mockReturnValue(qb as never);
dataSource.getRepository.mockReturnValue({ find: jest.fn().mockResolvedValue([]) });
await bookingsRepository.findAllPaginated({
page: 1,
pageSize: 10,
assignedToSchedule: 'false',
});
expect(qb.andWhere).toHaveBeenCalledWith(expect.stringContaining('NOT EXISTS'));
});
});

View File

@@ -1,7 +1,8 @@
import { BaseRepository } from '@edr/api-common';
import { SchedulingStatus } from '@edr/types';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { DataSource, FindOptionsWhere, Repository, SelectQueryBuilder } from 'typeorm';
import { DataSource, EntityManager, FindOptionsWhere, In, Repository, SelectQueryBuilder } from 'typeorm';
import { ContainerType } from '../rule-engine/entities/container-type.entity';
import { BookingApprovalStep } from './entities/booking-approval-step.entity';
@@ -9,6 +10,7 @@ import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity';
import { BookingContainer } from './entities/booking-container.entity';
import { BookingRateSnapshot } from './entities/booking-rate-snapshot.entity';
import { BookingReviewNote, ReviewNoteType } from './entities/booking-review-note.entity';
import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity';
import { Booking } from './entities/booking.entity';
import {
BookingContractSignature,
@@ -20,6 +22,8 @@ import { ContainerWeightResult } from '../rule-engine/rule-engine.service';
export interface BookingListFilterOptions {
statuses?: string[];
status?: string;
schedulingStatuses?: string[];
assignedToSchedule?: 'true' | 'false';
companyId?: string;
contractType?: string;
serviceTypeId?: string;
@@ -345,6 +349,26 @@ export class BookingsRepository extends BaseRepository<Booking> {
await this.dataSource.getRepository(BookingRateSnapshot).delete({ bookingId });
}
async hasPricingArtifacts(bookingId: string): Promise<boolean> {
const snapshotCount = await this.dataSource
.getRepository(BookingRateSnapshot)
.count({ where: { bookingId } });
const modifierCount = await this.dataSource
.getRepository(BookingCargoModifier)
.count({ where: { bookingId } });
return snapshotCount > 0 || modifierCount > 0;
}
async invalidatePricingPreview(bookingId: string): Promise<void> {
if (await this.hasPricingArtifacts(bookingId)) {
await this.clearPricingArtifacts(bookingId);
}
await this.update(bookingId, {
totalAmount: 0,
pricingBreakdown: null,
} as never);
}
/** Queue listing with optional bulk exclusion for LINE_STAFF. */
async findQueue(options: {
status: string | string[];
@@ -407,17 +431,37 @@ export class BookingsRepository extends BaseRepository<Booking> {
this.applyListFilters(qb, options);
const sortField =
options.sortBy === 'priorityScore'
? 'booking.priorityScore'
: 'booking.createdAt';
qb.orderBy(sortField, options.sortOrder ?? 'DESC');
if (options.sortBy === 'isGovernment') {
qb.orderBy('booking.isGovernment', 'DESC')
.addOrderBy('booking.priorityScore', 'DESC')
.addOrderBy('booking.scheduledDate', 'ASC');
} else {
const sortField =
options.sortBy === 'priorityScore'
? 'booking.priorityScore'
: options.sortBy === 'scheduledDate'
? 'booking.scheduledDate'
: 'booking.createdAt';
qb.orderBy(sortField, options.sortOrder ?? 'DESC');
}
const [items, total] = await qb
.skip((page - 1) * pageSize)
.take(pageSize)
.getManyAndCount();
if (items.length) {
const links = await this.dataSource.getRepository(TrainScheduleBooking).find({
where: { bookingId: In(items.map((item) => item.id)) },
select: { bookingId: true, trainScheduleId: true },
});
const scheduleByBooking = new Map(links.map((link) => [link.bookingId, link.trainScheduleId]));
for (const item of items) {
(item as Booking & { trainScheduleId?: string | null }).trainScheduleId =
scheduleByBooking.get(item.id) ?? null;
}
}
return { items, total };
}
@@ -536,6 +580,26 @@ export class BookingsRepository extends BaseRepository<Booking> {
} else if (options.consolidationPaired === 'false') {
qb.andWhere('booking.consolidation_partner_id IS NULL');
}
if (options.schedulingStatuses?.length) {
qb.andWhere('booking.scheduling_status IN (:...schedulingStatuses)', {
schedulingStatuses: options.schedulingStatuses,
});
}
if (options.assignedToSchedule === 'true') {
qb.andWhere(
`EXISTS (
SELECT 1 FROM freight.train_schedule_bookings tsb
WHERE tsb.booking_id = booking.id AND tsb.deleted_at IS NULL
)`,
);
} else if (options.assignedToSchedule === 'false') {
qb.andWhere(
`NOT EXISTS (
SELECT 1 FROM freight.train_schedule_bookings tsb
WHERE tsb.booking_id = booking.id AND tsb.deleted_at IS NULL
)`,
);
}
}
async findAndCountFiltered(where: FindOptionsWhere<Booking>, options: {
@@ -585,4 +649,99 @@ export class BookingsRepository extends BaseRepository<Booking> {
}
return repo.save(repo.create(data));
}
private bookingRepo(manager?: EntityManager) {
return manager ? manager.getRepository(Booking) : this.repository;
}
findEligibleForScheduling(options: {
freightType?: string;
originStationId?: string;
destinationStationId?: string;
schedulingStatus?: string;
}): Promise<Booking[]> {
const qb = this.repository
.createQueryBuilder('booking')
.leftJoinAndSelect('booking.company', 'company')
.leftJoinAndSelect('booking.originYard', 'originYard')
.leftJoinAndSelect('booking.destinationYard', 'destinationYard')
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
.leftJoinAndSelect('bookingContainer.containerType', 'containerType')
.leftJoinAndSelect('booking.cargoType', 'cargoType')
.leftJoin(
TrainScheduleBooking,
'scheduleBooking',
'scheduleBooking.booking_id = booking.id',
)
.where('booking.status = :paidStatus', { paidStatus: 'PAID' })
.andWhere('scheduleBooking.id IS NULL');
if (options.freightType) {
qb.andWhere('booking.freightType = :freightType', { freightType: options.freightType });
}
if (options.originStationId) {
qb.andWhere('booking.originYardId = :originStationId', {
originStationId: options.originStationId,
});
}
if (options.destinationStationId) {
qb.andWhere('booking.destinationYardId = :destinationStationId', {
destinationStationId: options.destinationStationId,
});
}
if (options.schedulingStatus) {
qb.andWhere('booking.scheduling_status = :schedulingStatus', {
schedulingStatus: options.schedulingStatus,
});
}
return qb
.orderBy('booking.priority_score', 'DESC')
.addOrderBy('booking.scheduled_date', 'ASC')
.addOrderBy('booking.created_at', 'ASC')
.getMany();
}
findByIdsForScheduling(bookingIds: string[], manager?: EntityManager): Promise<Booking[]> {
if (!bookingIds.length) return Promise.resolve([]);
return this.bookingRepo(manager).find({
where: { id: In(bookingIds) },
relations: {
company: true,
originYard: true,
destinationYard: true,
bookingContainers: { containerType: true },
cargoType: true,
},
order: { priorityScore: 'DESC', createdAt: 'ASC' },
});
}
async updateSchedulingFields(
bookingId: string,
fields: Partial<
Pick<
Booking,
'schedulingStatus' | 'wagonsRequired' | 'scheduledAt' | 'holdStartedAt' | 'holdExpiresAt'
>
>,
manager?: EntityManager,
): Promise<void> {
await this.bookingRepo(manager).update(bookingId, fields as never);
}
async setHoldWindowOnPaid(bookingId: string, manager?: EntityManager): Promise<void> {
const now = new Date();
const expires = new Date(now.getTime() + 3 * 60 * 60 * 1000);
await this.updateSchedulingFields(
bookingId,
{
schedulingStatus: SchedulingStatus.Holding,
holdStartedAt: now,
holdExpiresAt: expires,
},
manager,
);
}
}

View File

@@ -4,6 +4,7 @@ import {
Injectable,
NotFoundException,
} from '@nestjs/common';
import { SchedulingStatus } from '@edr/types';
// import { CustomersService } from '../customers/customers.service';
import { CompaniesService } from '../companies/companies.service';
import { FilesService } from '../files/files.service';
@@ -64,6 +65,7 @@ export class BookingsService {
paymentCurrency: string;
tradeDirection: string;
isHazardous?: boolean;
isGovernment?: boolean;
allowConsolidation?: boolean;
shippingLineId?: string | null;
containers: CreateBookingContainerDto[];
@@ -92,6 +94,7 @@ export class BookingsService {
paymentCurrency: dto.paymentCurrency,
tradeDirection: dto.tradeDirection,
isHazardous: dto.isHazardous ?? false,
isGovernment: dto.isGovernment ?? false,
allowConsolidation:
dto.freightType === 'CONTAINER' ? dto.allowConsolidation : false,
shippingLineId: dto.shippingLineId,
@@ -178,8 +181,15 @@ export class BookingsService {
// customerId = customer.id;
// }
let companyId = dto.companyId;
if (!companyId) {
const isGovernment = dto.isGovernment === true;
let companyId: string | null | undefined = dto.companyId;
if (isGovernment) {
if (!dto.governmentInstitution?.trim()) {
throw new BadRequestException('governmentInstitution is required for government bookings');
}
companyId = dto.companyId ?? null;
} else if (!companyId) {
if (!userId) {
throw new BadRequestException(
'companyId is required or must be resolvable from auth token',
@@ -209,6 +219,7 @@ export class BookingsService {
paymentCurrency: dto.paymentCurrency,
tradeDirection: dto.tradeDirection,
isHazardous: dto.isHazardous,
isGovernment,
allowConsolidation,
shippingLineId: dto.shippingLineId,
containers,
@@ -220,7 +231,9 @@ export class BookingsService {
const booking = await this.bookingsRepository.create({
reference,
companyId,
companyId: companyId ?? null,
isGovernment,
governmentInstitution: isGovernment ? dto.governmentInstitution!.trim() : null,
trainId: dto.trainId,
contractType: dto.contractType,
previousContractId: dto.previousContractId,
@@ -300,12 +313,13 @@ export class BookingsService {
const freightType = (dto.freightType ?? existing.freightType) as FreightType;
let containers =
dto.containers ??
existing.bookingContainers?.map((bc) => ({
containerTypeId: bc.containerTypeId,
quantity: bc.quantity,
vgmPerUnitTons: Number(bc.vgmPerUnitTons),
})) ??
[];
(existing.bookingContainers ?? [])
.filter((bc): bc is typeof bc & { containerTypeId: string } => bc.containerTypeId != null)
.map((bc) => ({
containerTypeId: bc.containerTypeId,
quantity: bc.quantity,
vgmPerUnitTons: Number(bc.vgmPerUnitTons),
}));
let cargoTypeId =
dto.cargoTypeId !== undefined ? dto.cargoTypeId : existing.cargoTypeId;
@@ -348,6 +362,15 @@ export class BookingsService {
this.ruleEngineService.assertNoHardBlocks(ruleResult);
warnings.push(...ruleResult.warnings);
const pricingFieldsChanged = this.pricingRelevantFieldsChanged(
existing,
dto,
freightType,
cargoTypeId,
allowConsolidation,
containers,
);
const updates: Record<string, unknown> = {
...dto,
freightType,
@@ -375,6 +398,10 @@ export class BookingsService {
);
}
if (pricingFieldsChanged) {
await this.bookingsRepository.invalidatePricingPreview(id);
}
if (files.length > 0) {
await this.filesService.uploadMany(id, 'bookings', files);
}
@@ -390,6 +417,19 @@ export class BookingsService {
return { booking, warnings };
}
/** Parse comma-separated scheduling status query values. */
private parseSchedulingStatusFilter(filter: FilterBookingDto): {
schedulingStatuses?: string[];
} {
const raw = filter.schedulingStatuses;
if (!raw) return {};
const schedulingStatuses = raw
.split(',')
.map((s) => s.trim())
.filter(Boolean);
return schedulingStatuses.length ? { schedulingStatuses } : {};
}
/** Parse comma-separated or repeated status query values. */
private parseStatusFilter(filter: FilterBookingDto): {
statuses?: string[];
@@ -420,11 +460,14 @@ export class BookingsService {
const page = filter.page ?? 1;
const pageSize = filter.pageSize ?? 20;
const statusFilter = this.parseStatusFilter(filter);
const schedulingStatusFilter = this.parseSchedulingStatusFilter(filter);
return this.bookingsRepository.findAllPaginated({
page,
pageSize,
...statusFilter,
...schedulingStatusFilter,
assignedToSchedule: filter.assignedToSchedule,
companyId: filter.companyId,
contractType: filter.contractType,
serviceTypeId: filter.serviceTypeId,
@@ -648,4 +691,86 @@ export class BookingsService {
),
};
}
private pricingRelevantFieldsChanged(
existing: Booking,
dto: UpdateBookingDto,
freightType: FreightType,
cargoTypeId: string | null | undefined,
allowConsolidation: boolean,
containers: CreateBookingContainerDto[],
): boolean {
if (dto.freightType !== undefined && dto.freightType !== existing.freightType) {
return true;
}
if (dto.tradeDirection !== undefined && dto.tradeDirection !== existing.tradeDirection) {
return true;
}
if (dto.paymentCurrency !== undefined && dto.paymentCurrency !== existing.paymentCurrency) {
return true;
}
if (dto.isHazardous !== undefined && dto.isHazardous !== existing.isHazardous) {
return true;
}
if (
dto.allowConsolidation !== undefined &&
dto.allowConsolidation !== existing.allowConsolidation
) {
return true;
}
if (dto.shippingLineId !== undefined && dto.shippingLineId !== existing.shippingLineId) {
return true;
}
if (dto.cargoTypeId !== undefined && dto.cargoTypeId !== existing.cargoTypeId) {
return true;
}
if (dto.containers !== undefined) {
const existingContainers = (existing.bookingContainers ?? [])
.filter((bc): bc is typeof bc & { containerTypeId: string } => bc.containerTypeId != null)
.map((bc) => ({
containerTypeId: bc.containerTypeId,
quantity: bc.quantity,
vgmPerUnitTons: Number(bc.vgmPerUnitTons),
}));
if (JSON.stringify(existingContainers) !== JSON.stringify(containers)) {
return true;
}
}
if (
freightType !== existing.freightType ||
(cargoTypeId ?? null) !== (existing.cargoTypeId ?? null) ||
allowConsolidation !== existing.allowConsolidation
) {
return true;
}
return false;
}
/** Staff expedite: mark a government booking PAID and ready for scheduling (no commercial hold). */
async governmentExpedite(id: string, staffUserId: string): Promise<Booking> {
const booking = await this.findById(id);
if (!booking.isGovernment) {
throw new BadRequestException('Only government bookings can be expedited');
}
const blocked = ['PAID', 'IN_TRANSIT', 'COMPLETED', 'CANCELLED', 'REJECTED'];
if (blocked.includes(booking.status)) {
throw new BadRequestException(`Cannot expedite booking in status ${booking.status}`);
}
await this.bookingsRepository.update(id, {
status: 'PAID',
paymentStatus: 'PAID',
schedulingStatus: SchedulingStatus.Eligible,
holdStartedAt: null,
holdExpiresAt: null,
});
await this.bookingsRepository.createReviewNote(
id,
`Government booking expedited to PAID by staff (${staffUserId})`,
'STAFF_NOTE',
staffUserId,
);
return this.findById(id);
}
}

View File

@@ -76,11 +76,12 @@ export class ConsolidationService {
}
async slotsFromBooking(booking: Booking): Promise<ConsolidationSlot[]> {
const lines =
booking.bookingContainers?.map((bc) => ({
const lines = (booking.bookingContainers ?? [])
.filter((bc): bc is typeof bc & { containerTypeId: string } => bc.containerTypeId != null)
.map((bc) => ({
containerTypeId: bc.containerTypeId,
quantity: bc.quantity,
})) ?? [];
}));
return this.slotsFromContainerLines(lines);
}

View File

@@ -12,6 +12,7 @@ import {
IsString,
IsUUID,
Min,
MinLength,
Validate,
ValidateIf,
ValidateNested,
@@ -66,7 +67,21 @@ export class CreateBookingDto {
// @IsUUID()
// customerId?: string;
@ApiPropertyOptional({ description: 'Staff only: government booking flag' })
@IsOptional()
@IsBoolean()
@Transform(({ value }) => value === 'true' || value === true)
isGovernment?: boolean;
@ApiPropertyOptional({ description: 'Required when isGovernment is true' })
@ValidateIf((o) => o.isGovernment === true)
@IsString()
@MinLength(2)
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
governmentInstitution?: string;
@ApiPropertyOptional({ format: 'uuid', description: 'Admin only: target company' })
@ValidateIf((o) => o.isGovernment !== true)
@IsOptional()
@IsUUID()
companyId?: string;

View File

@@ -84,8 +84,25 @@ export class FilterBookingDto {
@Transform(({ value }) => (value ? parseInt(value, 10) : 20))
pageSize?: number;
@ApiPropertyOptional({
description: 'Comma-separated scheduling statuses (NOT_SCHEDULED,HOLDING,ELIGIBLE,SCHEDULED)',
})
@IsOptional()
@Transform(({ value }) => {
if (value === undefined || value === null || value === '') return undefined;
if (Array.isArray(value)) return value.map(String).join(',');
return String(value);
})
schedulingStatuses?: string;
@ApiPropertyOptional({ enum: ['true', 'false'], description: 'Filter by train schedule assignment' })
@IsOptional()
@IsIn(['true', 'false'])
assignedToSchedule?: 'true' | 'false';
@ApiPropertyOptional({ default: 'createdAt' })
@IsOptional()
@IsIn(['createdAt', 'priorityScore', 'scheduledDate', 'isGovernment'])
sortBy?: string;
@ApiPropertyOptional({ enum: ['ASC', 'DESC'], default: 'DESC' })

View File

@@ -0,0 +1,29 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { PriceLineItemDto } from './generate-price-response.dto';
export class SubmitBookingResponseDto {
@ApiProperty()
bookingId!: string;
@ApiProperty()
status!: string;
@ApiProperty()
priceChanged!: boolean;
@ApiPropertyOptional()
previousTotalAmount?: number;
@ApiProperty()
totalAmount!: number;
@ApiProperty()
currency!: string;
@ApiPropertyOptional({ type: [PriceLineItemDto] })
lineItems?: PriceLineItemDto[];
@ApiPropertyOptional()
message?: string;
}

View File

@@ -15,12 +15,15 @@ export class BookingContainer extends BaseEntity {
@JoinColumn({ name: 'booking_id' })
booking?: Booking;
@Column({ name: 'container_type_id', type: 'uuid' })
containerTypeId!: string;
@Column({ name: 'container_type_id', type: 'uuid', nullable: true })
containerTypeId?: string | null;
@ManyToOne(() => ContainerType)
@ManyToOne(() => ContainerType, { nullable: true })
@JoinColumn({ name: 'container_type_id' })
containerType?: ContainerType;
containerType?: ContainerType | null;
@Column({ name: 'container_number', type: 'varchar', length: 64, nullable: true })
containerNumber?: string | null;
@Column({ name: 'quantity', type: 'smallint' })
quantity!: number;

View File

@@ -2,7 +2,7 @@ import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { Booking } from './booking.entity';
export const REVIEW_NOTE_TYPES = ['CHANGES_REQUESTED', 'REJECTION'] as const;
export const REVIEW_NOTE_TYPES = ['CHANGES_REQUESTED', 'REJECTION', 'STAFF_NOTE'] as const;
export type ReviewNoteType = (typeof REVIEW_NOTE_TYPES)[number];
@Entity({ schema: 'freight', name: 'booking_review_note' })

View File

@@ -1,4 +1,5 @@
import { BaseEntity } from '@edr/api-common';
import { SchedulingStatus } from '@edr/types';
import { Column, Entity, JoinColumn, ManyToOne, OneToMany } from 'typeorm';
// import { Customer } from '../../customers/entities/customer.entity';
import { Company } from '../../companies/entities/company.entity';
@@ -17,6 +18,7 @@ import { BookingReviewNote } from './booking-review-note.entity';
export const BOOKING_STATUSES = [
'DRAFT',
'SUBMITTED',
'PRICE_CHANGED_PENDING_CONFIRM',
'CHANGES_REQUESTED',
'PENDING_APPROVAL',
'APPROVED_PENDING_SIGNATURE',
@@ -53,6 +55,16 @@ export type PaymentStatus = (typeof PAYMENT_STATUSES)[number];
export const FREIGHT_TYPES = ['CONTAINER', 'BULK'] as const;
export type FreightType = (typeof FREIGHT_TYPES)[number];
export const SCHEDULING_STATUSES = [
SchedulingStatus.NotScheduled,
SchedulingStatus.Holding,
SchedulingStatus.Eligible,
SchedulingStatus.Scheduled,
SchedulingStatus.Dispatched,
] as const;
export type BookingSchedulingStatus = (typeof SCHEDULING_STATUSES)[number];
/** Statuses where the customer may edit booking fields. */
export const CUSTOMER_EDITABLE_STATUSES: BookingStatus[] = [
'DRAFT',
@@ -71,16 +83,24 @@ export class Booking extends BaseEntity {
// @JoinColumn({ name: 'customer_id' })
// customer?: Customer;
@Column({ name: 'company_id', type: 'uuid' })
companyId!: string;
@Column({ name: 'company_id', type: 'uuid', nullable: true })
companyId?: string | null;
@ManyToOne(() => Company)
@ManyToOne(() => Company, { nullable: true })
@JoinColumn({ name: 'company_id' })
company?: Company;
company?: Company | null;
@Column({ name: 'is_government', type: 'boolean', default: false })
isGovernment!: boolean;
@Column({ name: 'government_institution', type: 'varchar', length: 255, nullable: true })
governmentInstitution?: string | null;
/** @deprecated Fleet master data link — scheduling uses train_schedule_bookings instead. */
@Column({ name: 'train_id', type: 'uuid', nullable: true })
trainId?: string | null;
/** @deprecated Use train_schedule_bookings for operational scheduling. */
@ManyToOne(() => Train, { nullable: true })
@JoinColumn({ name: 'train_id' })
train?: Train | null;
@@ -242,6 +262,21 @@ export class Booking extends BaseEntity {
@JoinColumn({ name: 'consolidation_partner_id' })
consolidationPartner?: Booking | null;
@Column({ name: 'wagons_required', type: 'numeric', precision: 6, scale: 2, nullable: true })
wagonsRequired?: number | null;
@Column({ name: 'scheduling_status', type: 'varchar', length: 30, default: 'NOT_SCHEDULED' })
schedulingStatus!: string;
@Column({ name: 'hold_started_at', type: 'timestamptz', nullable: true })
holdStartedAt?: Date | null;
@Column({ name: 'hold_expires_at', type: 'timestamptz', nullable: true })
holdExpiresAt?: Date | null;
@Column({ name: 'scheduled_at', type: 'timestamptz', nullable: true })
scheduledAt?: Date | null;
@OneToMany(() => BookingContainer, (bc) => bc.booking)
bookingContainers?: BookingContainer[];

View File

@@ -159,9 +159,12 @@ export class CargoesService {
cargo.status = 'DELIVERED';
if (dto?.deliveryRemarks) cargo.description = dto.deliveryRemarks;
const remaining = await this.cargoRepo.count({
where: { containerId: cargo.containerId, status: 'LOADED' },
});
const remaining =
cargo.containerId != null
? await this.cargoRepo.count({
where: { containerId: cargo.containerId, status: 'LOADED' },
})
: 0;
if (remaining === 0 && cargo.container) {
cargo.container.status = 'AVAILABLE';
await this.containerRepo.save(cargo.container);

View File

@@ -1,7 +1,9 @@
// apps/edr-freight-api/src/modules/cargoes/entities/cargo.entity.ts
import { Entity, Column, ManyToOne, JoinColumn } from 'typeorm';
import { BaseEntity } from '@edr/api-common';
import { Booking } from '../../bookings/entities/booking.entity';
import { Container } from '../../container-management/entities/container.entity';
import { WagonBookingAllocation } from '../../train-schedules/entities/wagon-booking-allocation.entity';
@Entity({ name: 'cargoes', schema: 'freight' })
export class Cargo extends BaseEntity {
@@ -11,8 +13,8 @@ export class Cargo extends BaseEntity {
@Column({ name: 'shipment_id', type: 'uuid' })
shipmentId!: string;
@Column({ name: 'container_id', type: 'uuid' })
containerId!: string;
@Column({ name: 'container_id', type: 'uuid', nullable: true })
containerId!: string | null;
@Column({ name: 'cargo_type_id', type: 'uuid', nullable: true })
cargoTypeId!: string | null; // optional link to cargo_types table
@@ -38,8 +40,24 @@ export class Cargo extends BaseEntity {
@Column({ name: 'unloaded_at', type: 'timestamp', nullable: true })
unloadedAt!: Date | null;
// Relationship to Container
@ManyToOne(() => Container, (container) => container.cargoes, { onDelete: 'RESTRICT' })
@Column({ name: 'wagon_booking_allocation_id', type: 'uuid', nullable: true })
wagonBookingAllocationId!: string | null;
@ManyToOne(() => WagonBookingAllocation, { nullable: true, onDelete: 'SET NULL' })
@JoinColumn({ name: 'wagon_booking_allocation_id' })
wagonBookingAllocation?: WagonBookingAllocation | null;
@Column({ name: 'booking_id', type: 'uuid', nullable: true })
bookingId!: string | null;
@ManyToOne(() => Booking, { nullable: true, onDelete: 'SET NULL' })
@JoinColumn({ name: 'booking_id' })
booking?: Booking | null;
@Column({ name: 'load_type', type: 'varchar', length: 20, nullable: true })
loadType!: string | null;
@ManyToOne(() => Container, (container) => container.cargoes, { onDelete: 'RESTRICT', nullable: true })
@JoinColumn({ name: 'container_id' })
container!: Container;
container!: Container | null;
}

View File

@@ -1,6 +1,9 @@
// apps/edr-freight-api/src/modules/container-management/entities/container.entity.ts
import { Entity, Column, ManyToOne, OneToMany, JoinColumn } from 'typeorm';
import { BaseEntity } from '@edr/api-common';
import { Booking } from '../../bookings/entities/booking.entity';
import { BookingContainer } from '../../bookings/entities/booking-container.entity';
import { WagonBookingAllocation } from '../../train-schedules/entities/wagon-booking-allocation.entity';
import { Wagon } from '../../wagons/entities/wagon.entity';
import { Cargo } from '../../cargoes/entities/cargoes.entity';
@@ -34,7 +37,27 @@ sealNumber!: string | null;
@Column({ type: 'varchar', default: 'AVAILABLE' })
status!: string; // AVAILABLE, LOADED, IN_TRANSIT, MAINTENANCE, DAMAGED
// Relationship to Wagon
@Column({ name: 'booking_id', type: 'uuid', nullable: true })
bookingId!: string | null;
@ManyToOne(() => Booking, { nullable: true, onDelete: 'SET NULL' })
@JoinColumn({ name: 'booking_id' })
booking?: Booking | null;
@Column({ name: 'wagon_booking_allocation_id', type: 'uuid', nullable: true })
wagonBookingAllocationId!: string | null;
@ManyToOne(() => WagonBookingAllocation, { nullable: true, onDelete: 'SET NULL' })
@JoinColumn({ name: 'wagon_booking_allocation_id' })
wagonBookingAllocation?: WagonBookingAllocation | null;
@Column({ name: 'booking_container_id', type: 'uuid', nullable: true })
bookingContainerId!: string | null;
@ManyToOne(() => BookingContainer, { nullable: true, onDelete: 'SET NULL' })
@JoinColumn({ name: 'booking_container_id' })
bookingContainer?: BookingContainer | null;
@ManyToOne(() => Wagon, (wagon) => wagon.containers, { onDelete: 'SET NULL' })
@JoinColumn({ name: 'wagon_id' })
wagon!: Wagon | null;

View File

@@ -0,0 +1,17 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsIn, IsOptional } from 'class-validator';
const OVERVIEW_RANGES = ['7d', '30d', '90d'] as const;
export type OverviewRangeQuery = (typeof OVERVIEW_RANGES)[number];
export class OverviewQueryDto {
@ApiPropertyOptional({
enum: OVERVIEW_RANGES,
default: '30d',
description: 'Time range for trend charts',
})
@IsOptional()
@IsIn(OVERVIEW_RANGES)
range?: OverviewRangeQuery = '30d';
}

View File

@@ -0,0 +1,104 @@
import { ApiProperty } from '@nestjs/swagger';
export class OverviewBookingKpisDto {
@ApiProperty() totalActive!: number;
@ApiProperty() needsAction!: number;
@ApiProperty() urgent!: number;
@ApiProperty() inApproval!: number;
@ApiProperty() submittedToday!: number;
}
export class OverviewOperationsKpisDto {
@ApiProperty() trainsActive!: number;
@ApiProperty() wagonsAvailable!: number;
@ApiProperty() containersInTransit!: number;
@ApiProperty() cargoesLoaded!: number;
}
export class OverviewCustomerKpisDto {
@ApiProperty() totalCustomers!: number;
@ApiProperty() newCustomersThisMonth!: number;
}
export class OverviewBillingKpisDto {
@ApiProperty() revenueMtdEtb!: number;
@ApiProperty() revenueMtdUsd!: number;
@ApiProperty() pendingPayments!: number;
@ApiProperty() successfulPaymentsMtd!: number;
}
export class OverviewStaffKpisDto {
@ApiProperty() activeEmployees!: number;
@ApiProperty() activeUsers!: number;
}
export class OverviewKpisDto {
@ApiProperty({ type: OverviewBookingKpisDto })
bookings!: OverviewBookingKpisDto;
@ApiProperty({ type: OverviewOperationsKpisDto })
operations!: OverviewOperationsKpisDto;
@ApiProperty({ type: OverviewCustomerKpisDto })
customers!: OverviewCustomerKpisDto;
@ApiProperty({ type: OverviewBillingKpisDto })
billing!: OverviewBillingKpisDto;
@ApiProperty({ type: OverviewStaffKpisDto })
staff!: OverviewStaffKpisDto;
}
export class OverviewTrendPointDto {
@ApiProperty({ example: '2026-06-01' }) date!: string;
@ApiProperty() count!: number;
}
export class OverviewStatusCountDto {
@ApiProperty() status!: string;
@ApiProperty() count!: number;
}
export class OverviewPipelineCountDto {
@ApiProperty() stage!: string;
@ApiProperty() count!: number;
}
export class OverviewPaymentTrendPointDto {
@ApiProperty({ example: '2026-06-01' }) date!: string;
@ApiProperty() amountEtb!: number;
@ApiProperty() amountUsd!: number;
}
export class OverviewRecentBookingDto {
@ApiProperty() id!: string;
@ApiProperty() reference!: string;
@ApiProperty() customerLabel!: string;
@ApiProperty() status!: string;
@ApiProperty() priorityScore!: number;
@ApiProperty({ nullable: true }) totalAmount!: number | null;
@ApiProperty({ nullable: true }) paymentCurrency!: string | null;
@ApiProperty() createdAt!: string;
}
export class OverviewResponseDto {
@ApiProperty({ type: OverviewKpisDto })
kpis!: OverviewKpisDto;
@ApiProperty({ type: [OverviewTrendPointDto] })
bookingTrend!: OverviewTrendPointDto[];
@ApiProperty({ type: [OverviewStatusCountDto] })
bookingsByStatus!: OverviewStatusCountDto[];
@ApiProperty({ type: [OverviewPipelineCountDto] })
bookingsByPipeline!: OverviewPipelineCountDto[];
@ApiProperty({ type: [OverviewPaymentTrendPointDto] })
paymentTrend!: OverviewPaymentTrendPointDto[];
@ApiProperty({ type: [OverviewRecentBookingDto] })
recentBookings!: OverviewRecentBookingDto[];
@ApiProperty() generatedAt!: string;
}

View File

@@ -0,0 +1,131 @@
import { ApiProperty } from '@nestjs/swagger';
import {
OverviewBillingKpisDto,
OverviewBookingKpisDto,
OverviewCustomerKpisDto,
OverviewOperationsKpisDto,
OverviewPaymentTrendPointDto,
OverviewPipelineCountDto,
OverviewRecentBookingDto,
OverviewStaffKpisDto,
OverviewStatusCountDto,
OverviewTrendPointDto,
} from './overview-response.dto';
export class OverviewLabelCountDto {
@ApiProperty() label!: string;
@ApiProperty() count!: number;
}
export class OverviewPaymentMethodDto {
@ApiProperty() method!: string;
@ApiProperty() count!: number;
@ApiProperty() amountEtb!: number;
@ApiProperty() amountUsd!: number;
}
export class OverviewCurrencyAmountDto {
@ApiProperty() currency!: string;
@ApiProperty() amount!: number;
}
export class OverviewBookingsTabDto {
@ApiProperty({ type: OverviewBookingKpisDto })
kpis!: OverviewBookingKpisDto;
@ApiProperty({ type: [OverviewTrendPointDto] })
bookingTrend!: OverviewTrendPointDto[];
@ApiProperty({ type: [OverviewStatusCountDto] })
bookingsByStatus!: OverviewStatusCountDto[];
@ApiProperty({ type: [OverviewPipelineCountDto] })
bookingsByPipeline!: OverviewPipelineCountDto[];
@ApiProperty({ type: [OverviewLabelCountDto] })
bookingsByFreightType!: OverviewLabelCountDto[];
@ApiProperty({ type: [OverviewLabelCountDto] })
bookingsByCurrency!: OverviewLabelCountDto[];
@ApiProperty({ type: [OverviewRecentBookingDto] })
recentBookings!: OverviewRecentBookingDto[];
@ApiProperty()
generatedAt!: string;
}
export class OverviewBillingTabDto {
@ApiProperty({ type: OverviewBillingKpisDto })
kpis!: OverviewBillingKpisDto;
@ApiProperty({ type: [OverviewPaymentTrendPointDto] })
paymentTrend!: OverviewPaymentTrendPointDto[];
@ApiProperty({ type: [OverviewStatusCountDto] })
paymentsByStatus!: OverviewStatusCountDto[];
@ApiProperty({ type: [OverviewPaymentMethodDto] })
paymentsByMethod!: OverviewPaymentMethodDto[];
@ApiProperty({ type: [OverviewCurrencyAmountDto] })
revenueByCurrency!: OverviewCurrencyAmountDto[];
@ApiProperty()
generatedAt!: string;
}
export class OverviewOperationsTabDto {
@ApiProperty({ type: OverviewOperationsKpisDto })
kpis!: OverviewOperationsKpisDto;
@ApiProperty({ type: [OverviewStatusCountDto] })
trainStatusBreakdown!: OverviewStatusCountDto[];
@ApiProperty({ type: [OverviewStatusCountDto] })
wagonStatusBreakdown!: OverviewStatusCountDto[];
@ApiProperty({ type: [OverviewStatusCountDto] })
containerStatusBreakdown!: OverviewStatusCountDto[];
@ApiProperty({ type: [OverviewStatusCountDto] })
cargoStatusBreakdown!: OverviewStatusCountDto[];
@ApiProperty()
generatedAt!: string;
}
export class OverviewCustomersTabDto {
@ApiProperty({ type: OverviewCustomerKpisDto })
kpis!: OverviewCustomerKpisDto;
@ApiProperty({ type: [OverviewTrendPointDto] })
customerGrowthTrend!: OverviewTrendPointDto[];
@ApiProperty({ type: [OverviewLabelCountDto] })
customersByType!: OverviewLabelCountDto[];
@ApiProperty({ type: [OverviewLabelCountDto] })
topCustomersByBookings!: OverviewLabelCountDto[];
@ApiProperty()
generatedAt!: string;
}
export class OverviewStaffTabDto {
@ApiProperty({ type: OverviewStaffKpisDto })
kpis!: OverviewStaffKpisDto;
@ApiProperty({ type: [OverviewStatusCountDto] })
usersByStatus!: OverviewStatusCountDto[];
@ApiProperty({ type: [OverviewTrendPointDto] })
employeeGrowthTrend!: OverviewTrendPointDto[];
@ApiProperty({ type: [OverviewLabelCountDto] })
activeUsersBreakdown!: OverviewLabelCountDto[];
@ApiProperty()
generatedAt!: string;
}

View File

@@ -0,0 +1,26 @@
export const OVERVIEW_URGENT_PRIORITY_THRESHOLD = 1000;
export const OVERVIEW_NEEDS_ACTION_STATUSES = [
'SUBMITTED',
'PENDING_APPROVAL',
'APPROVED_PENDING_SIGNATURE',
] as const;
export const OVERVIEW_IN_APPROVAL_STATUSES = [
'PENDING_APPROVAL',
'APPROVED_PENDING_SIGNATURE',
] as const;
export const OVERVIEW_CLOSED_STATUSES = [
'REJECTED',
'CANCELLED',
'COMPLETED',
] as const;
export const OVERVIEW_RANGE_DAYS = {
'7d': 7,
'30d': 30,
'90d': 90,
} as const;
export type OverviewRange = keyof typeof OVERVIEW_RANGE_DAYS;

View File

@@ -0,0 +1,74 @@
import { Controller, Get, Query } from '@nestjs/common';
import {
ApiBearerAuth,
ApiOkResponse,
ApiOperation,
ApiTags,
} from '@nestjs/swagger';
import { BookingView } from '../../common/booking-guards';
import { OverviewQueryDto } from './dto/overview-query.dto';
import { OverviewResponseDto } from './dto/overview-response.dto';
import {
OverviewBillingTabDto,
OverviewBookingsTabDto,
OverviewCustomersTabDto,
OverviewOperationsTabDto,
OverviewStaffTabDto,
} from './dto/overview-tab-response.dto';
import { OverviewService } from './overview.service';
@ApiTags('Overview')
@ApiBearerAuth()
@Controller('overview')
export class OverviewController {
constructor(private readonly overviewService: OverviewService) {}
@Get()
@BookingView()
@ApiOperation({ summary: 'Aggregated dashboard summary for backoffice overview' })
@ApiOkResponse({ type: OverviewResponseDto })
getDashboard(@Query() query: OverviewQueryDto): Promise<OverviewResponseDto> {
return this.overviewService.getDashboard(query.range ?? '30d');
}
@Get('bookings')
@BookingView()
@ApiOperation({ summary: 'Bookings tab metrics and charts' })
@ApiOkResponse({ type: OverviewBookingsTabDto })
getBookingsTab(@Query() query: OverviewQueryDto): Promise<OverviewBookingsTabDto> {
return this.overviewService.getBookingsTab(query.range ?? '30d');
}
@Get('billing')
@BookingView()
@ApiOperation({ summary: 'Billing tab metrics and charts' })
@ApiOkResponse({ type: OverviewBillingTabDto })
getBillingTab(@Query() query: OverviewQueryDto): Promise<OverviewBillingTabDto> {
return this.overviewService.getBillingTab(query.range ?? '30d');
}
@Get('operations')
@BookingView()
@ApiOperation({ summary: 'Operations tab metrics and charts' })
@ApiOkResponse({ type: OverviewOperationsTabDto })
getOperationsTab(): Promise<OverviewOperationsTabDto> {
return this.overviewService.getOperationsTab();
}
@Get('customers')
@BookingView()
@ApiOperation({ summary: 'Customers tab metrics and charts' })
@ApiOkResponse({ type: OverviewCustomersTabDto })
getCustomersTab(@Query() query: OverviewQueryDto): Promise<OverviewCustomersTabDto> {
return this.overviewService.getCustomersTab(query.range ?? '30d');
}
@Get('staff')
@BookingView()
@ApiOperation({ summary: 'Staff tab metrics and charts' })
@ApiOkResponse({ type: OverviewStaffTabDto })
getStaffTab(@Query() query: OverviewQueryDto): Promise<OverviewStaffTabDto> {
return this.overviewService.getStaffTab(query.range ?? '30d');
}
}

View File

@@ -0,0 +1,34 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Employee } from '@tria-plc/iamapi-common';
import { User } from '@tria-plc/iamapi-common/entities/iam/user/user.entity';
import { Booking } from '../bookings/entities/booking.entity';
import { Cargo } from '../cargoes/entities/cargoes.entity';
import { Container } from '../container-management/entities/container.entity';
import { Customer } from '../customers/entities/customer.entity';
import { PaymentEntity } from '../payment/entities/payment.entity';
import { Train } from '../trains/entities/train.entity';
import { Wagon } from '../wagons/entities/wagon.entity';
import { OverviewController } from './overview.controller';
import { OverviewRepository } from './overview.repository';
import { OverviewService } from './overview.service';
@Module({
imports: [
TypeOrmModule.forFeature([
Booking,
PaymentEntity,
Customer,
Train,
Wagon,
Container,
Cargo,
Employee,
User,
]),
],
controllers: [OverviewController],
providers: [OverviewService, OverviewRepository],
})
export class OverviewModule {}

View File

@@ -0,0 +1,553 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { EUserStatus } from '@tria-plc/api-common/utils/enums/user.enum';
import { Employee } from '@tria-plc/iamapi-common';
import { User } from '@tria-plc/iamapi-common/entities/iam/user/user.entity';
import { Freight } from '@edr/types';
import { Repository, ObjectLiteral } from 'typeorm';
import { Booking } from '../bookings/entities/booking.entity';
import { Cargo } from '../cargoes/entities/cargoes.entity';
import { Container } from '../container-management/entities/container.entity';
import { Customer } from '../customers/entities/customer.entity';
import { PaymentEntity } from '../payment/entities/payment.entity';
import { Train } from '../trains/entities/train.entity';
import { Wagon } from '../wagons/entities/wagon.entity';
import {
OVERVIEW_CLOSED_STATUSES,
OVERVIEW_IN_APPROVAL_STATUSES,
OVERVIEW_NEEDS_ACTION_STATUSES,
OVERVIEW_URGENT_PRIORITY_THRESHOLD,
} from './overview.constants';
export type OverviewBookingKpisRow = {
totalActive: number;
needsAction: number;
urgent: number;
inApproval: number;
submittedToday: number;
};
export type OverviewRecentBookingRow = {
id: string;
reference: string;
customerLabel: string;
status: string;
priorityScore: number;
totalAmount: number | null;
paymentCurrency: string | null;
createdAt: Date;
};
@Injectable()
export class OverviewRepository {
constructor(
@InjectRepository(Booking)
private readonly bookingRepository: Repository<Booking>,
@InjectRepository(PaymentEntity)
private readonly paymentRepository: Repository<PaymentEntity>,
@InjectRepository(Customer)
private readonly customerRepository: Repository<Customer>,
@InjectRepository(Train)
private readonly trainRepository: Repository<Train>,
@InjectRepository(Wagon)
private readonly wagonRepository: Repository<Wagon>,
@InjectRepository(Container)
private readonly containerRepository: Repository<Container>,
@InjectRepository(Cargo)
private readonly cargoRepository: Repository<Cargo>,
@InjectRepository(Employee)
private readonly employeeRepository: Repository<Employee>,
@InjectRepository(User)
private readonly userRepository: Repository<User>,
) {}
async getBookingKpis(): Promise<OverviewBookingKpisRow> {
const row = await this.bookingRepository
.createQueryBuilder('booking')
.select(
`COUNT(*) FILTER (WHERE booking.status NOT IN (:...closedStatuses) AND booking.status != 'DRAFT')::int`,
'totalActive',
)
.addSelect(
`COUNT(*) FILTER (WHERE booking.status IN (:...needsActionStatuses))::int`,
'needsAction',
)
.addSelect(
`COUNT(*) FILTER (WHERE booking.priority_score >= :urgentThreshold)::int`,
'urgent',
)
.addSelect(
`COUNT(*) FILTER (WHERE booking.status IN (:...inApprovalStatuses))::int`,
'inApproval',
)
.addSelect(
`COUNT(*) FILTER (WHERE booking.created_at >= CURRENT_DATE AND booking.status != 'DRAFT')::int`,
'submittedToday',
)
.where('booking.deleted_at IS NULL')
.setParameters({
closedStatuses: [...OVERVIEW_CLOSED_STATUSES],
needsActionStatuses: [...OVERVIEW_NEEDS_ACTION_STATUSES],
inApprovalStatuses: [...OVERVIEW_IN_APPROVAL_STATUSES],
urgentThreshold: OVERVIEW_URGENT_PRIORITY_THRESHOLD,
})
.getRawOne<Record<string, string>>();
return {
totalActive: Number(row?.totalActive ?? 0),
needsAction: Number(row?.needsAction ?? 0),
urgent: Number(row?.urgent ?? 0),
inApproval: Number(row?.inApproval ?? 0),
submittedToday: Number(row?.submittedToday ?? 0),
};
}
async getOperationsKpis(): Promise<{
trainsActive: number;
wagonsAvailable: number;
containersInTransit: number;
cargoesLoaded: number;
}> {
const [trainsActive, wagonsAvailable, containersInTransit, cargoesLoaded] =
await Promise.all([
this.trainRepository
.createQueryBuilder('train')
.where('train.deleted_at IS NULL')
.andWhere('train.status IN (:...statuses)', {
statuses: [
Freight.TrainStatus.InService,
Freight.TrainStatus.Scheduled,
],
})
.getCount(),
this.wagonRepository
.createQueryBuilder('wagon')
.where('wagon.deleted_at IS NULL')
.andWhere('wagon.status = :status', { status: Freight.WagonStatus.Available })
.getCount(),
this.containerRepository
.createQueryBuilder('container')
.where('container.deleted_at IS NULL')
.andWhere('container.status = :status', { status: 'IN_TRANSIT' })
.getCount(),
this.cargoRepository
.createQueryBuilder('cargo')
.where('cargo.deleted_at IS NULL')
.andWhere('cargo.status IN (:...statuses)', {
statuses: ['LOADED', 'IN_TRANSIT'],
})
.getCount(),
]);
return { trainsActive, wagonsAvailable, containersInTransit, cargoesLoaded };
}
async getCustomerKpis(): Promise<{
totalCustomers: number;
newCustomersThisMonth: number;
}> {
const row = await this.customerRepository
.createQueryBuilder('customer')
.select('COUNT(*)::int', 'totalCustomers')
.addSelect(
`COUNT(*) FILTER (WHERE customer.created_at >= date_trunc('month', CURRENT_DATE))::int`,
'newCustomersThisMonth',
)
.where('customer.deleted_at IS NULL')
.getRawOne<Record<string, string>>();
return {
totalCustomers: Number(row?.totalCustomers ?? 0),
newCustomersThisMonth: Number(row?.newCustomersThisMonth ?? 0),
};
}
async getBillingKpis(): Promise<{
revenueMtdEtb: number;
revenueMtdUsd: number;
pendingPayments: number;
successfulPaymentsMtd: number;
}> {
const revenueRow = await this.paymentRepository
.createQueryBuilder('payment')
.select(
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'ETB'), 0)`,
'revenueMtdEtb',
)
.addSelect(
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`,
'revenueMtdUsd',
)
.addSelect(`COUNT(*)::int`, 'successfulPaymentsMtd')
.where('payment.status = :status', { status: 'success' })
.andWhere(
`COALESCE(payment.paid_at, payment.created_at) >= date_trunc('month', CURRENT_DATE)`,
)
.getRawOne<Record<string, string>>();
const pendingPayments = await this.paymentRepository
.createQueryBuilder('payment')
.where('payment.status IN (:...statuses)', {
statuses: ['action-required', 'processing'],
})
.getCount();
return {
revenueMtdEtb: Number(revenueRow?.revenueMtdEtb ?? 0),
revenueMtdUsd: Number(revenueRow?.revenueMtdUsd ?? 0),
pendingPayments,
successfulPaymentsMtd: Number(revenueRow?.successfulPaymentsMtd ?? 0),
};
}
async getStaffKpis(): Promise<{ activeEmployees: number; activeUsers: number }> {
const [activeEmployees, activeUsers] = await Promise.all([
this.employeeRepository.count({
where: { isCurrent: true },
}),
this.userRepository.count({
where: {
isActive: true,
status: EUserStatus.ACCEPTED,
},
}),
]);
return { activeEmployees, activeUsers };
}
async getBookingTrend(days: number): Promise<{ date: string; count: number }[]> {
const rows = await this.bookingRepository
.createQueryBuilder('booking')
.select(`to_char(booking.created_at::date, 'YYYY-MM-DD')`, 'date')
.addSelect('COUNT(*)::int', 'count')
.where('booking.deleted_at IS NULL')
.andWhere(`booking.created_at >= CURRENT_DATE - :days::int + 1`, { days })
.groupBy('booking.created_at::date')
.orderBy('booking.created_at::date', 'ASC')
.getRawMany<{ date: string; count: string }>();
return rows.map((row) => ({
date: row.date,
count: Number(row.count),
}));
}
async getStatusCounts(): Promise<Record<string, number>> {
const rows = await this.bookingRepository
.createQueryBuilder('booking')
.select('booking.status', 'status')
.addSelect('COUNT(*)::int', 'count')
.where('booking.deleted_at IS NULL')
.groupBy('booking.status')
.getRawMany<{ status: string; count: string }>();
return Object.fromEntries(
rows.map((row) => [row.status, Number(row.count)]),
);
}
async getPaymentTrend(
days: number,
): Promise<{ date: string; amountEtb: number; amountUsd: number }[]> {
const rows = await this.paymentRepository
.createQueryBuilder('payment')
.select(
`to_char(COALESCE(payment.paid_at, payment.created_at)::date, 'YYYY-MM-DD')`,
'date',
)
.addSelect(
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'ETB'), 0)`,
'amountEtb',
)
.addSelect(
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`,
'amountUsd',
)
.where('payment.status = :status', { status: 'success' })
.andWhere(
`COALESCE(payment.paid_at, payment.created_at) >= CURRENT_DATE - :days::int + 1`,
{ days },
)
.groupBy(`COALESCE(payment.paid_at, payment.created_at)::date`)
.orderBy(`COALESCE(payment.paid_at, payment.created_at)::date`, 'ASC')
.getRawMany<{ date: string; amountEtb: string; amountUsd: string }>();
return rows.map((row) => ({
date: row.date,
amountEtb: Number(row.amountEtb),
amountUsd: Number(row.amountUsd),
}));
}
async getRecentBookings(limit: number): Promise<OverviewRecentBookingRow[]> {
const rows = await this.bookingRepository
.createQueryBuilder('booking')
.leftJoin('booking.company', 'company')
.select('booking.id', 'id')
.addSelect('booking.reference', 'reference')
.addSelect('COALESCE(company.name, \'—\')', 'customerLabel')
.addSelect('booking.status', 'status')
.addSelect('booking.priority_score', 'priorityScore')
.addSelect('booking.total_amount', 'totalAmount')
.addSelect('booking.payment_currency', 'paymentCurrency')
.addSelect('booking.created_at', 'createdAt')
.where('booking.deleted_at IS NULL')
.orderBy('booking.created_at', 'DESC')
.limit(limit)
.getRawMany<{
id: string;
reference: string;
customerLabel: string;
status: string;
priorityScore: string;
totalAmount: string | null;
paymentCurrency: string | null;
createdAt: Date;
}>();
return rows.map((row) => ({
id: row.id,
reference: row.reference,
customerLabel: row.customerLabel,
status: row.status,
priorityScore: Number(row.priorityScore),
totalAmount: row.totalAmount != null ? Number(row.totalAmount) : null,
paymentCurrency: row.paymentCurrency,
createdAt: row.createdAt,
}));
}
async getBookingsByFreightType(): Promise<{ label: string; count: number }[]> {
const rows = await this.bookingRepository
.createQueryBuilder('booking')
.select('booking.freight_type', 'label')
.addSelect('COUNT(*)::int', 'count')
.where('booking.deleted_at IS NULL')
.andWhere("booking.status != 'DRAFT'")
.groupBy('booking.freight_type')
.orderBy('count', 'DESC')
.getRawMany<{ label: string; count: string }>();
return rows.map((row) => ({
label: row.label,
count: Number(row.count),
}));
}
async getBookingsByCurrency(): Promise<{ label: string; count: number }[]> {
const rows = await this.bookingRepository
.createQueryBuilder('booking')
.select('booking.payment_currency', 'label')
.addSelect('COUNT(*)::int', 'count')
.where('booking.deleted_at IS NULL')
.andWhere("booking.status != 'DRAFT'")
.groupBy('booking.payment_currency')
.orderBy('count', 'DESC')
.getRawMany<{ label: string; count: string }>();
return rows.map((row) => ({
label: row.label,
count: Number(row.count),
}));
}
async getPaymentsByStatus(): Promise<{ status: string; count: number }[]> {
const rows = await this.paymentRepository
.createQueryBuilder('payment')
.select('payment.status', 'status')
.addSelect('COUNT(*)::int', 'count')
.groupBy('payment.status')
.orderBy('count', 'DESC')
.getRawMany<{ status: string; count: string }>();
return rows.map((row) => ({
status: row.status,
count: Number(row.count),
}));
}
async getPaymentsByMethod(): Promise<
{ method: string; count: number; amountEtb: number; amountUsd: number }[]
> {
const rows = await this.paymentRepository
.createQueryBuilder('payment')
.select('payment.method', 'method')
.addSelect('COUNT(*)::int', 'count')
.addSelect(
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'ETB' AND payment.status = 'success'), 0)`,
'amountEtb',
)
.addSelect(
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD' AND payment.status = 'success'), 0)`,
'amountUsd',
)
.groupBy('payment.method')
.orderBy('count', 'DESC')
.getRawMany<{ method: string; count: string; amountEtb: string; amountUsd: string }>();
return rows.map((row) => ({
method: row.method,
count: Number(row.count),
amountEtb: Number(row.amountEtb),
amountUsd: Number(row.amountUsd),
}));
}
async getRevenueByCurrency(): Promise<{ currency: string; amount: number }[]> {
const rows = await this.paymentRepository
.createQueryBuilder('payment')
.select('payment.currency', 'currency')
.addSelect('COALESCE(SUM(payment.amount), 0)', 'amount')
.where('payment.status = :status', { status: 'success' })
.andWhere(
`COALESCE(payment.paid_at, payment.created_at) >= date_trunc('month', CURRENT_DATE)`,
)
.groupBy('payment.currency')
.getRawMany<{ currency: string; amount: string }>();
return rows.map((row) => ({
currency: row.currency,
amount: Number(row.amount),
}));
}
async getTrainStatusBreakdown(): Promise<{ status: string; count: number }[]> {
return this.statusBreakdown(this.trainRepository, 'train');
}
async getWagonStatusBreakdown(): Promise<{ status: string; count: number }[]> {
return this.statusBreakdown(this.wagonRepository, 'wagon');
}
async getContainerStatusBreakdown(): Promise<{ status: string; count: number }[]> {
return this.statusBreakdown(this.containerRepository, 'container');
}
async getCargoStatusBreakdown(): Promise<{ status: string; count: number }[]> {
return this.statusBreakdown(this.cargoRepository, 'cargo');
}
private async statusBreakdown(
repository: Repository<ObjectLiteral>,
alias: string,
): Promise<{ status: string; count: number }[]> {
const rows = await repository
.createQueryBuilder(alias)
.select(`${alias}.status`, 'status')
.addSelect('COUNT(*)::int', 'count')
.where(`${alias}.deleted_at IS NULL`)
.groupBy(`${alias}.status`)
.orderBy('count', 'DESC')
.getRawMany<{ status: string; count: string }>();
return rows.map((row) => ({
status: row.status,
count: Number(row.count),
}));
}
async getCustomerGrowthTrend(days: number): Promise<{ date: string; count: number }[]> {
const rows = await this.customerRepository
.createQueryBuilder('customer')
.select(`to_char(customer.created_at::date, 'YYYY-MM-DD')`, 'date')
.addSelect('COUNT(*)::int', 'count')
.where('customer.deleted_at IS NULL')
.andWhere(`customer.created_at >= CURRENT_DATE - :days::int + 1`, { days })
.groupBy('customer.created_at::date')
.orderBy('customer.created_at::date', 'ASC')
.getRawMany<{ date: string; count: string }>();
return rows.map((row) => ({
date: row.date,
count: Number(row.count),
}));
}
async getCustomersByType(): Promise<{ label: string; count: number }[]> {
const rows = await this.customerRepository
.createQueryBuilder('customer')
.select(`COALESCE(NULLIF(customer.customer_type, ''), 'Unknown')`, 'label')
.addSelect('COUNT(*)::int', 'count')
.where('customer.deleted_at IS NULL')
.groupBy('customer.customer_type')
.orderBy('count', 'DESC')
.getRawMany<{ label: string; count: string }>();
return rows.map((row) => ({
label: row.label,
count: Number(row.count),
}));
}
async getTopCustomersByBookings(limit: number): Promise<{ label: string; count: number }[]> {
const rows = await this.bookingRepository
.createQueryBuilder('booking')
.leftJoin('booking.company', 'company')
.select(`COALESCE(company.name, 'Unknown')`, 'label')
.addSelect('COUNT(*)::int', 'count')
.where('booking.deleted_at IS NULL')
.andWhere("booking.status != 'DRAFT'")
.groupBy('company.name')
.orderBy('count', 'DESC')
.limit(limit)
.getRawMany<{ label: string; count: string }>();
return rows.map((row) => ({
label: row.label,
count: Number(row.count),
}));
}
async getUsersByStatus(): Promise<{ status: string; count: number }[]> {
const rows = await this.userRepository
.createQueryBuilder('user')
.select('user.status', 'status')
.addSelect('COUNT(*)::int', 'count')
.groupBy('user.status')
.orderBy('count', 'DESC')
.getRawMany<{ status: string; count: string }>();
return rows.map((row) => ({
status: row.status,
count: Number(row.count),
}));
}
async getEmployeeGrowthTrend(days: number): Promise<{ date: string; count: number }[]> {
const rows = await this.employeeRepository
.createQueryBuilder('employee')
.select(`to_char(employee.created_at::date, 'YYYY-MM-DD')`, 'date')
.addSelect('COUNT(*)::int', 'count')
.where('employee.is_current = true')
.andWhere(`employee.created_at >= CURRENT_DATE - :days::int + 1`, { days })
.groupBy('employee.created_at::date')
.orderBy('employee.created_at::date', 'ASC')
.getRawMany<{ date: string; count: string }>();
return rows.map((row) => ({
date: row.date,
count: Number(row.count),
}));
}
async getActiveUsersBreakdown(): Promise<{ label: string; count: number }[]> {
const [active, inactive] = await Promise.all([
this.userRepository.count({
where: { isActive: true, status: EUserStatus.ACCEPTED },
}),
this.userRepository
.createQueryBuilder('user')
.where('user.is_active = false OR user.status != :status', {
status: EUserStatus.ACCEPTED,
})
.getCount(),
]);
return [
{ label: 'Active', count: active },
{ label: 'Inactive', count: inactive },
];
}
}

View File

@@ -0,0 +1,210 @@
import { Injectable } from '@nestjs/common';
import {
BOOKING_LIST_TABS,
mapStatusCountsToTabs,
} from '../bookings/booking-list-tabs.config';
import type { OverviewRangeQuery } from './dto/overview-query.dto';
import type { OverviewResponseDto } from './dto/overview-response.dto';
import type {
OverviewBillingTabDto,
OverviewBookingsTabDto,
OverviewCustomersTabDto,
OverviewOperationsTabDto,
OverviewStaffTabDto,
} from './dto/overview-tab-response.dto';
import { OVERVIEW_RANGE_DAYS } from './overview.constants';
import { OverviewRepository } from './overview.repository';
@Injectable()
export class OverviewService {
constructor(private readonly overviewRepository: OverviewRepository) {}
private mapStatusCounts(statusCounts: Record<string, number>) {
const pipelineTabs = mapStatusCountsToTabs(statusCounts);
const bookingsByPipeline = BOOKING_LIST_TABS.filter(
(tab) => tab.key !== 'all',
).map((tab) => ({
stage: tab.key,
count: pipelineTabs[tab.key],
}));
const bookingsByStatus = Object.entries(statusCounts)
.map(([status, count]) => ({ status, count }))
.sort((a, b) => b.count - a.count);
return { bookingsByPipeline, bookingsByStatus };
}
async getDashboard(range: OverviewRangeQuery = '30d'): Promise<OverviewResponseDto> {
const days = OVERVIEW_RANGE_DAYS[range];
const [
bookingKpis,
operationsKpis,
customerKpis,
billingKpis,
staffKpis,
bookingTrend,
statusCounts,
paymentTrend,
recentBookings,
] = await Promise.all([
this.overviewRepository.getBookingKpis(),
this.overviewRepository.getOperationsKpis(),
this.overviewRepository.getCustomerKpis(),
this.overviewRepository.getBillingKpis(),
this.overviewRepository.getStaffKpis(),
this.overviewRepository.getBookingTrend(days),
this.overviewRepository.getStatusCounts(),
this.overviewRepository.getPaymentTrend(days),
this.overviewRepository.getRecentBookings(8),
]);
const { bookingsByPipeline, bookingsByStatus } =
this.mapStatusCounts(statusCounts);
return {
kpis: {
bookings: bookingKpis,
operations: operationsKpis,
customers: customerKpis,
billing: billingKpis,
staff: staffKpis,
},
bookingTrend,
bookingsByStatus,
bookingsByPipeline,
paymentTrend,
recentBookings: recentBookings.map((row) => ({
...row,
createdAt: row.createdAt.toISOString(),
})),
generatedAt: new Date().toISOString(),
};
}
async getBookingsTab(range: OverviewRangeQuery = '30d'): Promise<OverviewBookingsTabDto> {
const days = OVERVIEW_RANGE_DAYS[range];
const [
kpis,
bookingTrend,
statusCounts,
bookingsByFreightType,
bookingsByCurrency,
recentBookings,
] = await Promise.all([
this.overviewRepository.getBookingKpis(),
this.overviewRepository.getBookingTrend(days),
this.overviewRepository.getStatusCounts(),
this.overviewRepository.getBookingsByFreightType(),
this.overviewRepository.getBookingsByCurrency(),
this.overviewRepository.getRecentBookings(8),
]);
const { bookingsByPipeline, bookingsByStatus } =
this.mapStatusCounts(statusCounts);
return {
kpis,
bookingTrend,
bookingsByStatus,
bookingsByPipeline,
bookingsByFreightType,
bookingsByCurrency,
recentBookings: recentBookings.map((row) => ({
...row,
createdAt: row.createdAt.toISOString(),
})),
generatedAt: new Date().toISOString(),
};
}
async getBillingTab(range: OverviewRangeQuery = '30d'): Promise<OverviewBillingTabDto> {
const days = OVERVIEW_RANGE_DAYS[range];
const [kpis, paymentTrend, paymentsByStatus, paymentsByMethod, revenueByCurrency] =
await Promise.all([
this.overviewRepository.getBillingKpis(),
this.overviewRepository.getPaymentTrend(days),
this.overviewRepository.getPaymentsByStatus(),
this.overviewRepository.getPaymentsByMethod(),
this.overviewRepository.getRevenueByCurrency(),
]);
return {
kpis,
paymentTrend,
paymentsByStatus,
paymentsByMethod,
revenueByCurrency,
generatedAt: new Date().toISOString(),
};
}
async getOperationsTab(): Promise<OverviewOperationsTabDto> {
const [
kpis,
trainStatusBreakdown,
wagonStatusBreakdown,
containerStatusBreakdown,
cargoStatusBreakdown,
] = await Promise.all([
this.overviewRepository.getOperationsKpis(),
this.overviewRepository.getTrainStatusBreakdown(),
this.overviewRepository.getWagonStatusBreakdown(),
this.overviewRepository.getContainerStatusBreakdown(),
this.overviewRepository.getCargoStatusBreakdown(),
]);
return {
kpis,
trainStatusBreakdown,
wagonStatusBreakdown,
containerStatusBreakdown,
cargoStatusBreakdown,
generatedAt: new Date().toISOString(),
};
}
async getCustomersTab(range: OverviewRangeQuery = '30d'): Promise<OverviewCustomersTabDto> {
const days = OVERVIEW_RANGE_DAYS[range];
const [kpis, customerGrowthTrend, customersByType, topCustomersByBookings] =
await Promise.all([
this.overviewRepository.getCustomerKpis(),
this.overviewRepository.getCustomerGrowthTrend(days),
this.overviewRepository.getCustomersByType(),
this.overviewRepository.getTopCustomersByBookings(8),
]);
return {
kpis,
customerGrowthTrend,
customersByType,
topCustomersByBookings,
generatedAt: new Date().toISOString(),
};
}
async getStaffTab(range: OverviewRangeQuery = '30d'): Promise<OverviewStaffTabDto> {
const days = OVERVIEW_RANGE_DAYS[range];
const [kpis, usersByStatus, employeeGrowthTrend, activeUsersBreakdown] =
await Promise.all([
this.overviewRepository.getStaffKpis(),
this.overviewRepository.getUsersByStatus(),
this.overviewRepository.getEmployeeGrowthTrend(days),
this.overviewRepository.getActiveUsersBreakdown(),
]);
return {
kpis,
usersByStatus,
employeeGrowthTrend,
activeUsersBreakdown,
generatedAt: new Date().toISOString(),
};
}
}

View File

@@ -1,6 +0,0 @@
import { IsString } from "class-validator";
export class InitiateBookingPayment {
@IsString()
bookingId!: string;
}

View File

@@ -4,7 +4,7 @@ import { BaseEntity, Column, CreateDateColumn, Entity, PrimaryGeneratedColumn }
type PaymentType = "booking"
type PaymentMethod = "telebirr" | "cbe-birr" | "ebirr"
type Currency = "ETB" | "USD"
type PaymentStatus = "action-required" | "processing" | "success" | "failed" | "canceled" | "refunded"
export type PaymentStatus = "action-required" | "processing" | "success" | "failed" | "canceled" | "refunded"
@Entity({ schema: 'freight', name: 'payments' })
export class PaymentEntity extends BaseEntity {

View File

@@ -1,53 +1,31 @@
import { Controller, Get, NotFoundException, Param, ParseUUIDPipe, Post, Res } from "@nestjs/common";
import { Controller, Get, NotFoundException, Param, Post, Res } from "@nestjs/common";
import { PaymentService } from "./payment.service";
import { Public } from "@edr/api-common";
// import { randomUUID } from "crypto";
import { Response } from "express"
@Public()
@Controller("payments")
export class PaymentController {
constructor(private readonly paymentService: PaymentService,) { }
constructor(private readonly paymentService: PaymentService,) { }
@Post("/initiate")
initiate() {
return this.paymentService.initBookingTelebirr("123", "web")
}
// @Get("/receipts/:orderId/html")
// async genReceipt(@Param("orderId") orderId: string, @Res() res: Response) {
// const filled = await this.paymentService.genReceiptHtml(orderId);
// return res.send(filled)
// }
@Post("/bookings/check-payment/:orderId")
checkPayment(@Param("orderId") orderId: string) {
return this.paymentService.checkStatusAndUpdate(orderId)
}
// @Post("/initiate/booking")
// async initiatePayment() {
// //Only for testing..
// const description = "Booking for contact"
// const price = 2000
// const data = await this.paymentService.pay(price, "ETB", "telebirr", description, "booking", (_) => {
// return new Promise((resp, _) => {
// resp({
// id: randomUUID(),
// type: "booking"
// })
// });
// })
// return data
// }
@Post("/bookings/check-payment/:orderId")
checkPayment(@Param("orderId") orderId: string) {
return this.paymentService.checkStatusAndUpdate(orderId)
@Get("/bookings/telebirr/redirect/:orderId")
async pay(@Param("orderId") orderId: string, @Res() res: Response) {
const payment = await this.paymentService.getActivePaymentByOrderIdAndMethod(orderId, "telebirr")
if (!payment) {
throw new NotFoundException('payment not found')
}
@Get("/telebirr/:refId")
async pay(@Param("refId", ParseUUIDPipe) refId: string, @Res() res: Response) {
const payment = await this.paymentService.getActivePaymentByRefIdAndMethod(refId, "telebirr")
if (!payment) {
throw new NotFoundException('payment not found')
}
return res.send(`
return res.send(`
<!DOCTYPE html>
<html>
<head>
@@ -62,6 +40,5 @@ export class PaymentController {
</body>
</html>
`);
}
}
}

View File

@@ -1,5 +1,4 @@
import { Module } from "@nestjs/common";
import { PaymentTelebirrStrategy } from "./strategies/payment.telebirr.strategy";
import { PaymentService } from "./payment.service";
import { HttpModule } from "@nestjs/axios";
import { PaymentController } from "./payment.controller";
@@ -7,10 +6,11 @@ import { ConfigModule } from "@nestjs/config";
import { PaymentRepository } from "./payment.repository";
import { WebhookController } from "./webhooks/webhook.controller";
import { TelebirrWebhookService } from "./webhooks/providers/telebirr.service";
import { TelebirrProvider } from "@edr/payment-providers";
@Module({
imports: [HttpModule, ConfigModule],
providers: [PaymentRepository, PaymentTelebirrStrategy, PaymentService, TelebirrWebhookService],
providers: [PaymentRepository, PaymentService, TelebirrWebhookService, TelebirrProvider],
controllers: [PaymentController, WebhookController],
exports: [PaymentService]
})

View File

@@ -14,6 +14,13 @@ export class PaymentRepository {
return qr.manager.save(payment)
}
async create(data: Pick<PaymentEntity, "amount" | "method" | "currency" | "type" | "refId" | "merchantOrderId" | "rawInitiation" | "clientAction" | "expiresAt" | "reason">): Promise<PaymentEntity> {
const payment = this.paymentRepo.create(data)
return this.paymentRepo.save(payment)
}
findOneBy(options: FindOptionsWhere<PaymentEntity> | FindOptionsWhere<PaymentEntity>[]): Promise<PaymentEntity | null> {
return this.paymentRepo.findOneBy(options);
}
@@ -36,4 +43,20 @@ export class PaymentRepository {
getActivePaymentByOrderIdAndMethod(orderId: string, method: PaymentEntity["method"]) {
return this.paymentRepo
.createQueryBuilder('payment')
.where('payment.method = :method', { method })
.andWhere('payment.merchantOrderId = :orderId', { orderId })
.andWhere('payment.status IN (:...statuses)', {
statuses: ['action-required'],
})
.andWhere('payment.expiresAt > :now', { now: new Date() })
.getOne();
}
}

View File

@@ -1,83 +1,68 @@
import {
BadRequestException,
Injectable,
InternalServerErrorException,
NotFoundException,
BadRequestException,
Injectable,
InternalServerErrorException,
NotFoundException,
} from "@nestjs/common";
import { DataSource, QueryRunner } from "typeorm";
import { DataSource } from "typeorm";
import { PaymentEntity } from "./entities/payment.entity";
import { PaymentStrategy } from "./strategies/payment.strategy";
import { PaymentTelebirrStrategy } from "./strategies/payment.telebirr.strategy";
import { PaymentRepository } from "./payment.repository";
import { ClientAction, PaymentPlatform } from "./strategies/payments.types";
import * as crypto from "crypto";
import * as fs from "fs";
import * as path from "path";
import * as Handlebars from "handlebars";
import { ConfigService } from "@nestjs/config";
import { SchedulingStatus } from "@edr/types";
import { Booking } from "../bookings/entities/booking.entity";
type PaymentMethod = PaymentEntity["method"];
type CurrencyType = PaymentEntity["currency"];
import {
ClientAction,
createMerchantOrderId,
ProviderPaymentStatus,
TelebirrProvider,
} from "@edr/payment-providers";
import { ProviderInitiationInput } from "@edr/types"
import { InitiateResponseDto, PaymentPlatformDto } from "./payments.dto";
const DEFAULT_CURRENCY = "ETB";
@Injectable()
export class PaymentService {
private strategies: Map<PaymentMethod, PaymentStrategy>;
constructor(
private readonly configService: ConfigService,
private readonly datasource: DataSource,
private readonly paymentRepo: PaymentRepository,
private readonly telebirrProvider: TelebirrProvider,
) { }
constructor(
private readonly configService: ConfigService,
private readonly datasource: DataSource,
private readonly paymentRepo: PaymentRepository,
private readonly telebirrPaymentStategy: PaymentTelebirrStrategy,
) {
this.strategies = new Map([
["telebirr", this.telebirrPaymentStategy as PaymentStrategy],
]);
}
async initBookingTelebirr(
bookingId: string,
platform: PaymentPlatformDto,
): Promise<{ redirectUrl: string }> {
// const booking = await this.datasource.getRepository(Booking).findOneBy({ id: bookingId });
// if (!booking) throw new NotFoundException("Booking not found");
async pay(
amount: number,
currency: CurrencyType,
method: PaymentMethod,
reason: string,
type: PaymentEntity["type"],
cb: (
qr: QueryRunner,
) => Promise<{ id: string; type: PaymentEntity["type"] }>,
payform: PaymentPlatform = "web",
): Promise<{
refId: string;
clientAction: ClientAction;
status: PaymentEntity["status"];
paidAt?: string;
failureCode?: string;
failureMessage?: string;
}> {
const strategy = this.strategies.get(method);
if (!strategy) {
throw new NotFoundException("strategy not found");
}
// const booking = new Booking()
// booking.totalAmount = 20
// booking.id = randomUUID
const amount = 20
const merchantOrderId = createMerchantOrderId();
const redirectBase = this.configService.get<string>("TELEBIRR_SUCCESS_BOOKING_REDIRECT_BASE_URL");
const redirectUrl = `${redirectBase}/${merchantOrderId}`;
const amountMinor = Math.round(Number(amount) * 100);
const orderId = `${Date.now()}${crypto.randomBytes(4).toString("hex")}`; //todo: make it dynamic
let redirectUrl: string;
switch (type) {
case "booking":
const url = this.configService.get<string>(
"TELEBIRR_SUCCESS_REDIRECT_BASE_URL",
);
redirectUrl = `${url}/${orderId}`;
break;
}
const input: ProviderInitiationInput = {
merchantOrderId,
orderRef: bookingId,
amountMinor,
currency: DEFAULT_CURRENCY,
platform: platform || "web",
redirectUrl,
};
const paymentResp = await strategy.pay({
redirectUrl,
amountMinor: amount,
currency: currency,
merchantOrderId: orderId,
platform: payform,
});
const result = await this.telebirrProvider.initiate(input);
<<<<<<< HEAD
const queryRunner = this.datasource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
@@ -168,22 +153,111 @@ export class PaymentService {
const ordersStatus = bizContent.order_status;
if (ordersStatus == "PAY_SUCCESS") {
await this.datasource.transaction(async (mg) => {
await mg.update(Booking, { id: resp.refId }, { status: "PAID" });
const now = new Date();
const holdExpires = new Date(now.getTime() + 3 * 60 * 60 * 1000);
await mg.update(Booking, { id: resp.refId }, {
status: "PAID",
schedulingStatus: SchedulingStatus.Holding,
holdStartedAt: now,
holdExpiresAt: holdExpires,
});
await mg.update(PaymentEntity, { id: resp.id }, { status: "success" });
=======
const payment = await this.paymentRepo.create({
amount: amount,
currency: DEFAULT_CURRENCY,
method: "telebirr",
refId: bookingId,
type: "booking",
merchantOrderId,
rawInitiation: result.rawInitiation,
clientAction: result.clientAction as Record<string, unknown>,
expiresAt: result.expiresAt,
reason: `Payment for booking`,
>>>>>>> eda21e22d872344b74c0c72308f87ce7435b299f
});
}
return {
status: result.status,
};
} catch {
// Telebirr API unavailable — fall back to current DB payment status
const dbStatus =
resp.status === "success"
? "success"
: resp.status === "failed"
? "failed"
: "processing";
return { status: dbStatus };
return {
redirectUrl: `${this.configService.get<string>("TELEBIRR_REDIRECT_BASE_URL")}/${payment.merchantOrderId}`
}
}
async getActivePaymentByOrderIdAndMethod(orderId: string, method: PaymentEntity["method"]): Promise<PaymentEntity | null> {
return this.paymentRepo.getActivePaymentByOrderIdAndMethod(orderId, method)
}
async genReceiptHtml(orderId: string) {
const payment = await this.paymentRepo.findOneBy({
merchantOrderId: orderId,
status: "success"
})
if (!payment) {
throw new BadRequestException()
}
const filePath = path.join(__dirname, "templates", "receipt.hbs");
if (!fs.existsSync(filePath)) {
throw new InternalServerErrorException()
}
const source = fs.readFileSync(filePath, "utf8");
const template = Handlebars.compile(source);
const html = template({
vendorName: "Ethio Djibouti Railway Ticket Booking",
vendorAddress: "Addis Ababa",
receiptDate: payment.paidAt,
paymentMethod: payment?.method,
subtotal: payment?.amount.toString(),
total: payment?.amount.toString(),
currency: payment?.currency,
reason: payment?.reason
});
return html;
}
async checkStatusAndUpdate(orderId: string) {
const resp = await this.paymentRepo.findOneBy({ merchantOrderId: orderId })
if (!resp) {
throw new NotFoundException("order id not found")
}
const result = await this.telebirrProvider.queryStatus(resp.merchantOrderId)
if (result.status === ProviderPaymentStatus.SUCCEEDED) {
await this.datasource.transaction(async (mg) => {
await mg.update(Booking, { id: resp.refId }, { status: "PAID" })
await mg.update(PaymentEntity, { id: resp.id }, { status: "success" })
})
}
return {
status: result.status
}
}
findBookingById(id: string) {
return this.paymentRepo.findOneBy({ refId: id, type: "booking" })
}
formatIntentResponse(intent: PaymentEntity): InitiateResponseDto {
const clientAction =
intent.clientAction && typeof intent.clientAction === "object"
? (intent.clientAction as unknown as ClientAction)
: undefined;
const statusMap: Record<string, ProviderPaymentStatus> = {
"action-required": ProviderPaymentStatus.REQUIRES_ACTION,
"processing": ProviderPaymentStatus.PROCESSING,
"success": ProviderPaymentStatus.SUCCEEDED,
"failed": ProviderPaymentStatus.FAILED,
"canceled": ProviderPaymentStatus.CANCELLED,
"refunded": ProviderPaymentStatus.CANCELLED,
};
return {
intentId: intent.id,
status: statusMap[intent.status] ?? ProviderPaymentStatus.PROCESSING,
clientAction,
merchantOrderId: intent.merchantOrderId ?? undefined,
};
}
}
}

View File

@@ -0,0 +1,62 @@
import { ProviderPaymentStatus } from "@edr/types";
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { IsIn, IsOptional, IsString } from "class-validator";
export type PaymentPlatformDto = "web" | "mobile";
export class InitiatePaymentDto {
@ApiProperty({ example: "booking-uuid" })
@IsString()
bookingId!: string;
@ApiProperty({ enum: ["TELEBIRR"], example: "TELEBIRR" })
@IsIn(["TELEBIRR"])
method!: "TELEBIRR";
@ApiPropertyOptional({ enum: ["web", "mobile"], default: "web" })
@IsOptional()
@IsIn(["web", "mobile"])
platform?: PaymentPlatformDto;
}
export class ClientActionDto {
@ApiProperty({ enum: ["REDIRECT", "LAUNCH_APP"] })
type!: "REDIRECT" | "LAUNCH_APP";
@ApiPropertyOptional({ description: "Set when type=REDIRECT (web flow)" })
url?: string;
@ApiPropertyOptional({ description: "Set when type=LAUNCH_APP (mobile flow)" })
appId?: string;
@ApiPropertyOptional({ description: "Set when type=LAUNCH_APP (mobile flow)" })
receiveCode?: string;
@ApiPropertyOptional({ description: "Set when type=LAUNCH_APP (mobile flow)" })
shortCode?: string;
}
export class InitiateResponseDto {
@ApiProperty()
intentId!: string;
@ApiProperty({ enum: ProviderPaymentStatus })
status!: ProviderPaymentStatus;
@ApiPropertyOptional({ type: ClientActionDto })
clientAction?: ClientActionDto;
@ApiPropertyOptional()
merchantOrderId?: string;
}
export class IntentStatusDto extends InitiateResponseDto {
@ApiPropertyOptional()
paidAt?: string;
@ApiPropertyOptional()
failureCode?: string;
@ApiPropertyOptional()
failureMessage?: string;
}

View File

@@ -1,8 +0,0 @@
import { Injectable } from "@nestjs/common";
import { ProviderInitiationInput, ProviderInitiationResult } from "./payments.types";
@Injectable()
export abstract class PaymentStrategy {
abstract pay(data: ProviderInitiationInput): Promise<ProviderInitiationResult>
}

View File

@@ -1,304 +0,0 @@
import { Injectable, Logger } from "@nestjs/common";
import { PaymentStrategy } from "./payment.strategy";
import { ConfigService } from '@nestjs/config';
import { HttpService } from '@nestjs/axios';
import { AxiosError, AxiosRequestConfig } from 'axios';
import { firstValueFrom } from 'rxjs';
import * as https from 'node:https';
import { PaymentEntity } from "../entities/payment.entity";
import { ProviderInitiationInput, ProviderInitiationResult, ProviderStatus } from "./payments.types";
import { CreateOrderRequest, CreateOrderResponse, FabricTokenResponse, QueryOrderResponse } from "./telebirr/telebirr.types";
import { createNonceStr, createTimestamp, signRequestObject, verifyRequestObject } from "./telebirr/telebirr.crypto";
// type PaymentCurrency = PaymentEntity["currency"]
type PaymentIntentStatus = PaymentEntity["status"]
const TELEBIRR_HTTP_TIMEOUT_MS = 10_000;
@Injectable()
export class PaymentTelebirrStrategy implements PaymentStrategy {
async pay(data: ProviderInitiationInput): Promise<any> {
// const refId = randomUUID()
// const orderId = createMerchantOrderId()
const resp = await this.initiate(data)
return resp;
}
// readonly method = PaymentMethodType.TELEBIRR;
private readonly logger = new Logger(PaymentTelebirrStrategy.name);
private readonly httpsAgent: https.Agent;
constructor(
private readonly config: ConfigService,
private readonly http: HttpService,
) {
const insecure = this.config.get<boolean>('telebirr.insecureTls');
if (insecure) {
this.logger.warn('TELEBIRR_INSECURE_TLS=true — TLS verification disabled for Telebirr calls. DEV ONLY.');
}
this.httpsAgent = new https.Agent({
rejectUnauthorized: !insecure,
secureProtocol: 'TLSv1_2_method',
});
}
async initiate(input: ProviderInitiationInput): Promise<ProviderInitiationResult> {
const fabricToken = await this.applyFabricToken();
const requestBody = this.buildCreateOrderRequest(input);
const response = await this.requestCreateOrder(fabricToken, requestBody);
const prepayId = response.biz_content?.prepay_id;
if (!prepayId) {
throw new Error(
`Telebirr createOrder returned no prepay_id: ${JSON.stringify(response)}`,
);
}
const expiresAt = this.computeExpiresAt(requestBody.biz_content.timeout_express);
const platform = input.platform ?? 'web';
const clientAction =
platform === 'mobile'
? {
type: 'LAUNCH_APP' as const,
prepayId,
receiveCode: response.biz_content?.receiveCode,
shortCode: this.merchantCode,
}
: { type: 'REDIRECT' as const, url: this.buildCheckoutUrl(prepayId) };
return {
providerOrderId: prepayId,
clientAction,
expiresAt,
rawInitiation: {
request: this.sanitize(requestBody),
response,
},
};
}
async queryStatus(merchantOrderId: string): Promise<ProviderStatus> {
const fabricToken = await this.applyFabricToken();
const requestBody = this.buildQueryOrderRequest(merchantOrderId);
const response = await this.postJson<QueryOrderResponse>(
`${this.baseUrl}/payment/v1/merchant/queryOrder`,
requestBody,
{
'Content-Type': 'application/json',
'X-APP-Key': this.fabricAppId,
Authorization: fabricToken,
},
);
const tradeStatus = response.biz_content?.trade_status;
const providerTxnId =
response.biz_content?.trans_id ?? response.biz_content?.payment_order_id;
const mapped = this.mapTradeStatus(tradeStatus);
return {
status: mapped,
providerTxnId,
failureCode:
mapped === "failed" && tradeStatus ? tradeStatus : undefined,
rawResponse: response as Record<string, unknown>,
};
}
mapTradeStatus(tradeStatus: string | undefined): PaymentIntentStatus {
switch (tradeStatus) {
case 'PAY_SUCCESS':
return "success";
case 'PAY_FAILED':
case 'ORDER_CLOSED':
return "failed";
case 'WAIT_PAY':
return "action-required";
case 'PAYING':
return "processing";
default:
return "processing";
}
}
mapWebhookTradeStatus(tradeStatus: string | undefined): PaymentIntentStatus {
switch (tradeStatus) {
case 'Completed':
return "success";
case 'Failure':
case 'Expired':
return "failed";
case 'Paying':
case 'Pending':
return "processing";
default:
return "processing";
}
}
verifyWebhookSignature(payload: Record<string, unknown>): boolean {
if (!this.publicKey) {
this.logger.error('TELEBIRR_PUBLIC_KEY not configured; rejecting all webhooks');
return false;
}
return verifyRequestObject(payload, this.publicKey);
}
private async applyFabricToken(): Promise<string> {
console.log(this.baseUrl, "base url")
const response = await this.postJson<FabricTokenResponse>(
`${this.baseUrl}/payment/v1/token`,
{ appSecret: this.appSecret },
{
'Content-Type': 'application/json',
'X-APP-Key': this.fabricAppId,
},
);
if (!response?.token) {
throw new Error(`Telebirr token request failed: ${JSON.stringify(response)}`);
}
return response.token;
}
private async requestCreateOrder(
fabricToken: string,
body: CreateOrderRequest,
): Promise<CreateOrderResponse> {
return this.postJson<CreateOrderResponse>(
`${this.baseUrl}/payment/v1/inapp/createOrder`,
body,
{
'Content-Type': 'application/json',
'X-APP-Key': this.fabricAppId,
Authorization: fabricToken,
},
);
}
private buildCreateOrderRequest(input: ProviderInitiationInput): CreateOrderRequest {
// const totalAmount = String(input.amountMinor / 100);
const totalAmount = String(input.amountMinor)
const req = {
timestamp: createTimestamp(),
nonce_str: createNonceStr(),
method: 'payment.preorder' as const,
version: '1.0' as const,
biz_content: {
notify_url: this.notifyUrl,
appid: this.merchantAppId,
redirect_url: input.redirectUrl,
merch_code: this.merchantCode,
merch_order_id: input.merchantOrderId,
trade_type: 'Checkout' as const,
title: `EDR Booking`,
total_amount: totalAmount,
trans_currency: input.currency,
timeout_express: this.timeoutExpress,
},
};
const sign = signRequestObject(req as unknown as Record<string, unknown>, this.privateKey);
return { ...req, sign, sign_type: 'SHA256WithRSA' };
}
private buildQueryOrderRequest(merchantOrderId: string): Record<string, unknown> {
const req = {
timestamp: createTimestamp(),
nonce_str: createNonceStr(),
method: 'payment.queryorder',
version: '1.0',
biz_content: {
appid: this.merchantAppId,
merch_code: this.merchantCode,
merch_order_id: merchantOrderId,
},
};
const sign = signRequestObject(req as Record<string, unknown>, this.privateKey);
return { ...req, sign, sign_type: 'SHA256WithRSA' };
}
private buildCheckoutUrl(prepayId: string): string {
const map: Record<string, string> = {
appid: this.merchantAppId,
merch_code: this.merchantCode,
nonce_str: createNonceStr(),
prepay_id: prepayId,
timestamp: createTimestamp(),
};
const sign = signRequestObject(map, this.privateKey);
const rawRequest = [
`appid=${map.appid}`,
`merch_code=${map.merch_code}`,
`nonce_str=${map.nonce_str}`,
`prepay_id=${map.prepay_id}`,
`timestamp=${map.timestamp}`,
'sign_type=SHA256WithRSA',
`sign=${sign}`,
'version=1.0',
'trade_type=Checkout',
].join('&');
return `${this.webBaseUrl}${rawRequest}`;
}
private computeExpiresAt(timeoutExpress: string): Date {
const match = /^(\d+)([smhd])$/.exec(timeoutExpress);
const minutes = match ? this.toMinutes(parseInt(match[1], 10), match[2]) : 15;
return new Date(Date.now() + minutes * 60_000);
}
private toMinutes(n: number, unit: string): number {
switch (unit) {
case 's': return Math.max(1, Math.round(n / 60));
case 'm': return n;
case 'h': return n * 60;
case 'd': return n * 60 * 24;
default: return 15;
}
}
private async postJson<T>(
url: string,
body: unknown,
headers: Record<string, string>,
): Promise<T> {
const config: AxiosRequestConfig = {
headers,
timeout: TELEBIRR_HTTP_TIMEOUT_MS,
httpsAgent: this.httpsAgent,
};
const started = Date.now();
try {
const res = await firstValueFrom(this.http.post<T>(url, body, config));
this.logger.debug(`Telebirr POST ${url} status=${res.status} latency=${Date.now() - started}ms`);
return res.data;
} catch (err) {
if (err instanceof AxiosError) {
this.logger.error(
`Telebirr POST ${url} failed: status=${err.response?.status} body=${JSON.stringify(err.response?.data)} code=${err.code} message=${err.message}`,
);
} else {
this.logger.error(`Telebirr POST ${url} threw: ${err instanceof Error ? err.message : err}`);
}
throw err;
}
}
private sanitize(body: CreateOrderRequest): Record<string, unknown> {
const { sign: _sign, ...rest } = body;
return rest;
}
private get baseUrl(): string { return this.config.get<string>('telebirr.baseUrl') ?? ''; }
private get webBaseUrl(): string { return this.config.get<string>('telebirr.webBaseUrl') ?? ''; }
private get fabricAppId(): string { return this.config.get<string>('telebirr.fabricAppId') ?? ''; }
private get appSecret(): string { return this.config.get<string>('telebirr.appSecret') ?? ''; }
private get merchantAppId(): string { return this.config.get<string>('telebirr.merchantAppId') ?? ''; }
private get merchantCode(): string { return this.config.get<string>('telebirr.merchantCode') ?? ''; }
private get notifyUrl(): string { return this.config.get<string>('telebirr.notifyUrl') ?? ''; }
private get timeoutExpress(): string { return this.config.get<string>('telebirr.timeoutExpress') ?? '15m'; }
private get privateKey(): string { return this.config.get<string>('telebirr.privateKey') ?? ''; }
private get publicKey(): string {
return this.config.get<string>('telebirr.publicKey') ?? '';
}
}

View File

@@ -1,40 +0,0 @@
import { PaymentEntity } from "../entities/payment.entity";
type PaymentIntentStatus = PaymentEntity["status"]
type PaymentMethodType = PaymentEntity["method"]
export type PaymentPlatform = 'web' | 'mobile';
export type ClientAction =
| { type: 'REDIRECT'; url: string }
| { type: 'LAUNCH_APP'; prepayId: string; receiveCode?: string; shortCode: string };
export interface ProviderInitiationInput {
redirectUrl: string;
merchantOrderId: string;
// bookingRef: string;
amountMinor: number;
currency: string;
platform?: PaymentPlatform;
}
export interface ProviderInitiationResult {
providerOrderId: string;
clientAction: ClientAction;
expiresAt: Date;
rawInitiation: Record<string, unknown>;
}
export interface ProviderStatus {
status: PaymentIntentStatus;
providerTxnId?: string;
failureCode?: string;
failureMessage?: string;
rawResponse: Record<string, unknown>;
}
export interface PaymentProvider {
readonly method: PaymentMethodType;
initiate(input: ProviderInitiationInput): Promise<ProviderInitiationResult>;
queryStatus(merchantOrderId: string): Promise<ProviderStatus>;
}

View File

@@ -1,98 +0,0 @@
import * as crypto from 'crypto';
const EXCLUDE_FIELDS = new Set([
'sign',
'sign_type',
'header',
'refund_info',
'openType',
'raw_request',
'biz_content',
]);
const NONCE_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
export function buildCanonicalString(requestObject: Record<string, unknown>): string {
const fieldMap: Record<string, unknown> = {};
for (const key of Object.keys(requestObject)) {
if (EXCLUDE_FIELDS.has(key)) continue;
fieldMap[key] = requestObject[key];
}
const biz = requestObject['biz_content'];
if (biz && typeof biz === 'object') {
for (const key of Object.keys(biz as Record<string, unknown>)) {
if (EXCLUDE_FIELDS.has(key)) continue;
fieldMap[key] = (biz as Record<string, unknown>)[key];
}
}
return Object.keys(fieldMap)
.sort()
.map((k) => `${k}=${fieldMap[k]}`)
.join('&');
}
export function signRequestObject(
requestObject: Record<string, unknown>,
privateKey: string,
): string {
return signString(buildCanonicalString(requestObject), privateKey);
}
export function verifyRequestObject(
requestObject: Record<string, unknown>,
publicKey: string,
): boolean {
const signature = requestObject['sign'];
if (typeof signature !== 'string' || signature.length === 0) return false;
return verifySignature(buildCanonicalString(requestObject), signature, publicKey);
}
export function signString(text: string, privateKey: string): string {
const signature = crypto.sign('sha256', Buffer.from(text), {
key: privateKey,
padding: crypto.constants.RSA_PKCS1_PSS_PADDING,
saltLength: crypto.constants.RSA_PSS_SALTLEN_DIGEST,
});
return signature.toString('base64');
}
export function verifySignature(
text: string,
signatureBase64: string,
publicKey: string,
): boolean {
try {
return crypto.verify(
'sha256',
Buffer.from(text),
{
key: publicKey,
padding: crypto.constants.RSA_PKCS1_PSS_PADDING,
saltLength: crypto.constants.RSA_PSS_SALTLEN_DIGEST,
},
Buffer.from(signatureBase64, 'base64'),
);
} catch {
return false;
}
}
export function createTimestamp(): string {
return Math.round(Date.now() / 1000).toString();
}
export function createNonceStr(length = 32): string {
const bytes = crypto.randomBytes(length);
let out = '';
for (let i = 0; i < length; i++) {
out += NONCE_CHARS[bytes[i] % NONCE_CHARS.length];
}
return out;
}
export function createMerchantOrderId(): string {
return `${Date.now()}${crypto.randomBytes(4).toString('hex')}`;
}

View File

@@ -1,69 +0,0 @@
export interface FabricTokenResponse {
token: string;
expires_in?: number | string;
}
export interface CreateOrderBizContent {
notify_url: string;
appid: string;
merch_code: string;
merch_order_id: string;
trade_type: 'Checkout' | 'InApp' | 'MiniApp';
title: string;
total_amount: string;
trans_currency: string;
timeout_express: string;
}
export interface CreateOrderRequest {
timestamp: string;
nonce_str: string;
method: 'payment.preorder';
version: '1.0';
biz_content: CreateOrderBizContent;
sign: string;
sign_type: 'SHA256WithRSA';
}
export interface CreateOrderResponse {
code?: string;
msg?: string;
biz_content?: {
prepay_id?: string;
receiveCode?: string;
[key: string]: unknown;
};
[key: string]: unknown;
}
export type TelebirrTradeStatus =
| 'PAY_SUCCESS'
| 'PAY_FAILED'
| 'WAIT_PAY'
| 'ORDER_CLOSED'
| 'PAYING'
| 'ACCEPTED'
| 'REFUNDING'
| 'REFUND_SUCCESS'
| 'REFUND_FAILED';
export interface QueryOrderResponse {
result?: 'SUCCESS' | 'FAIL';
code?: string;
msg?: string;
nonce_str?: string;
sign?: string;
sign_type?: string;
biz_content?: {
merch_order_id?: string;
order_status?: string;
trade_status?: TelebirrTradeStatus | string;
payment_order_id?: string;
trans_id?: string;
trans_time?: string;
trans_currency?: string;
total_amount?: string;
[key: string]: unknown;
};
[key: string]: unknown;
}

View File

@@ -1,82 +1,53 @@
import { Injectable, } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as crypto from "crypto"
import { Injectable, Logger } from '@nestjs/common';
import { TelebirrDto } from '../dto/telebirr.dto';
import { PaymentRepository } from '../../payment.repository';
import { DataSource } from 'typeorm';
import { Booking } from 'src/modules/bookings/entities/booking.entity';
import { Booking } from '../../../bookings/entities/booking.entity';
import { TelebirrProvider, ProviderPaymentStatus } from '@edr/payment-providers';
@Injectable()
export class TelebirrWebhookService {
// private readonly logger = new Logger(TelebirrWebhookService.name);
private readonly logger = new Logger(TelebirrWebhookService.name);
constructor(
private readonly datasource: DataSource,
private readonly config: ConfigService,
private readonly paymentRepo: PaymentRepository,
private readonly telebirrProvider: TelebirrProvider,
) { }
verifyTelebirrNotification(payload: TelebirrDto) {
// 1. Extract the signature provided by Telebirr
const { sign, ...bizContent } = payload;
if (!sign) {
throw new Error("Missing 'sign' field from Telebirr payload");
}
// 2. Sort the remaining keys alphabetically to rebuild the raw string
const sortedKeys = Object.keys(bizContent).sort();
const signString = sortedKeys
.map(key => `${key}=${typeof bizContent[key] === 'object' ? JSON.stringify(bizContent[key]) : bizContent[key]}`)
.join('&');
// 3. Convert Telebirr's public key into an object specifying RSA-PSS padding
const publicKey = {
key: this.config.get<string>("telebirr.publicKey") ?? "",
padding: crypto.constants.RSA_PKCS1_PSS_PADDING,
saltLength: 32 // Telebirr standard salt length
};
// 4. Verify the signature against the sorted string
const isVerified = crypto.verify(
"sha256",
Buffer.from(signString),
publicKey,
Buffer.from(sign, 'base64')
);
return isVerified;
return this.telebirrProvider.verifyWebhookSignature(payload as unknown as Record<string, unknown>);
}
async handle(payload: TelebirrDto): Promise<void> {
const payment = await this.paymentRepo.findOneBy({ merchantOrderId: payload.merch_order_id })
if (!payment) {
throw new Error("payment not found")
this.logger.warn(`Webhook received for unknown merchantOrderId: ${payload.merch_order_id}`);
return;
}
switch (payload.trade_status) {
case "SUCCEEDED":
await this.paymentRepo.update({ id: payment.id }, { status: "success", paidAt: new Date() })
switch (payment.type) {
case "booking":
await this.datasource.manager.update(Booking, { id: payment.refId }, { paymentStatus: "PAID", })
// await this.bookingRepo.update(payment.refId, { paymentStatus: "PAID", })
break;
const mapped = this.telebirrProvider.mapWebhookTradeStatus(payload.trade_status);
switch (mapped) {
case ProviderPaymentStatus.SUCCEEDED:
await this.paymentRepo.update(
{ id: payment.id },
{ status: "success", paidAt: new Date() },
);
if (payment.type === "booking") {
await this.datasource.manager.update(
Booking,
{ id: payment.refId },
{ paymentStatus: "PAID" },
);
}
break;
case "FAILED":
await this.paymentRepo.update({ id: payment.id }, { status: "failed" })
case ProviderPaymentStatus.FAILED:
await this.paymentRepo.update({ id: payment.id }, { status: "failed" });
break;
case "CANCELLED":
await this.paymentRepo.update({ id: payment.id }, { status: "canceled" })
case ProviderPaymentStatus.PROCESSING:
await this.paymentRepo.update({ id: payment.id }, { status: "processing" });
break;
case "PROCESSING":
await this.paymentRepo.update({ id: payment.id }, { status: "processing" })
break;
case "REFUNDED":
await this.paymentRepo.update({ id: payment.id }, { status: "refunded" })
break;
}
}
}

View File

@@ -22,14 +22,12 @@ export class WebhookController {
);
try {
// const verified = this.telebirr.verifyTelebirrNotification(payload)
// if (!verified) {
// throw new Error("not valid")
// }
// const merchantOrderId = payload.merch_order_id;
const verified = this.telebirr.verifyTelebirrNotification(payload)
if (!verified) {
throw new Error("Telebirr webhook signature verification failed")
}
await this.telebirr.handle(payload);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
this.logger.error(`Telebirr webhook handler threw: ${message}`);

View File

@@ -5,6 +5,8 @@ import {
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { CreateApprovalRuleDto } from '../dto/create-approval-rule.dto';
import { MoveOrderDto } from '../dto/move-order.dto';
import { ReorderItemsDto } from '../dto/reorder-items.dto';
import { UpdateApprovalRuleDto } from '../dto/update-approval-rule.dto';
import { ApprovalRulesService } from '../services/approval-rules.service';
@@ -35,6 +37,22 @@ export class ApprovalRulesController {
return this.service.findChain(flag === 'true');
}
@Post('reorder')
@RuleEngineManage('approval-rules')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Bulk reorder approval steps within a chain' })
reorder(@Body() dto: ReorderItemsDto) {
return this.service.reorder(dto);
}
@Post(':id/move-order')
@RuleEngineManage('approval-rules')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Move an approval step up or down within its chain' })
moveOrder(@Param('id', ParseUUIDPipe) id: string, @Body() dto: MoveOrderDto) {
return this.service.moveOrder(id, dto.direction);
}
@Get(':id')
@RuleEngineView('approval-rules')
@ApiOperation({ summary: 'Get an approval rule by ID' })

View File

@@ -5,6 +5,8 @@ import {
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
import { CreateCargoTypeDto } from '../dto/create-cargo-type.dto';
import { MoveOrderDto } from '../dto/move-order.dto';
import { ReorderItemsDto } from '../dto/reorder-items.dto';
import { UpdateCargoTypeDto } from '../dto/update-cargo-type.dto';
import { CargoTypesService } from '../services/cargo-types.service';
@@ -32,6 +34,22 @@ export class CargoTypesController {
});
}
@Post('reorder')
@RuleEngineManage('cargo-types')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Bulk reorder cargo types by ID list' })
reorder(@Body() dto: ReorderItemsDto) {
return this.service.reorder(dto);
}
@Post(':id/move-order')
@RuleEngineManage('cargo-types')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Move a cargo type up or down in display order' })
moveOrder(@Param('id', ParseUUIDPipe) id: string, @Body() dto: MoveOrderDto) {
return this.service.moveOrder(id, dto.direction);
}
@Get(':id')
@RuleEngineView('cargo-types')
@ApiOperation({ summary: 'Get a cargo type by ID' })

View File

@@ -5,6 +5,8 @@ import {
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
import { CreateContainerTypeDto } from '../dto/create-container-type.dto';
import { MoveOrderDto } from '../dto/move-order.dto';
import { ReorderItemsDto } from '../dto/reorder-items.dto';
import { UpdateContainerTypeDto } from '../dto/update-container-type.dto';
import { ContainerTypesService } from '../services/container-types.service';
@@ -25,6 +27,22 @@ export class ContainerTypesController {
});
}
@Post('reorder')
@RuleEngineManage('container-types')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Bulk reorder container types by ID list' })
reorder(@Body() dto: ReorderItemsDto) {
return this.service.reorder(dto);
}
@Post(':id/move-order')
@RuleEngineManage('container-types')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Move a container type up or down in display order' })
moveOrder(@Param('id', ParseUUIDPipe) id: string, @Body() dto: MoveOrderDto) {
return this.service.moveOrder(id, dto.direction);
}
@Get(':id')
@RuleEngineView('container-types')
@ApiOperation({ summary: 'Get a container type by ID' })

View File

@@ -5,6 +5,8 @@ import {
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { CreateServiceTypeDto } from '../dto/create-service-type.dto';
import { MoveOrderDto } from '../dto/move-order.dto';
import { ReorderItemsDto } from '../dto/reorder-items.dto';
import { UpdateServiceTypeDto } from '../dto/update-service-type.dto';
import { ServiceTypesService } from '../services/service-types.service';
@@ -29,6 +31,22 @@ export class ServiceTypesController {
});
}
@Post('reorder')
@RuleEngineManage('service-types')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Bulk reorder service types by ID list' })
reorder(@Body() dto: ReorderItemsDto) {
return this.service.reorder(dto);
}
@Post(':id/move-order')
@RuleEngineManage('service-types')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Move a service type up or down in display order' })
moveOrder(@Param('id', ParseUUIDPipe) id: string, @Body() dto: MoveOrderDto) {
return this.service.moveOrder(id, dto.direction);
}
@Get(':id')
@RuleEngineView('service-types')
@ApiOperation({ summary: 'Get a service type by ID' })

View File

@@ -5,6 +5,8 @@ import {
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { CreateYardDto } from '../dto/create-yard.dto';
import { MoveOrderDto } from '../dto/move-order.dto';
import { ReorderItemsDto } from '../dto/reorder-items.dto';
import { UpdateYardDto } from '../dto/update-yard.dto';
import { YardsService } from '../services/yards.service';
@@ -26,6 +28,22 @@ export class YardsController {
});
}
@Post('reorder')
@RuleEngineManage('yards')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Bulk reorder yards by ID list' })
reorder(@Body() dto: ReorderItemsDto) {
return this.service.reorder(dto);
}
@Post(':id/move-order')
@RuleEngineManage('yards')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Move a yard up or down in display order' })
moveOrder(@Param('id', ParseUUIDPipe) id: string, @Body() dto: MoveOrderDto) {
return this.service.moveOrder(id, dto.direction);
}
@Get(':id')
@RuleEngineView('yards')
@ApiOperation({ summary: 'Get a yard by ID' })

View File

@@ -1,5 +1,5 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsBoolean, IsInt, IsOptional, IsString, MaxLength, Min } from 'class-validator';
import { IsBoolean, IsInt, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator';
const ROLES = ['LINE_STAFF', 'DIRECTOR', 'CEO'] as const;
@@ -8,10 +8,16 @@ export class CreateApprovalRuleDto {
@IsBoolean()
requiresDirectorApproval!: boolean;
@ApiProperty({ description: 'Step sequence number (1 = first, 2 = second)', minimum: 1 })
@ApiPropertyOptional({ description: 'Step sequence number (auto-assigned if omitted)', minimum: 1 })
@IsOptional()
@IsInt()
@Min(1)
stepOrder!: number;
stepOrder?: number;
@ApiPropertyOptional({ description: 'Insert after this step ID within the same chain' })
@IsOptional()
@IsUUID('4')
insertAfterId?: string;
@ApiProperty({ enum: ROLES, description: 'Role required to action this step' })
@IsString()

View File

@@ -32,4 +32,9 @@ export class CreateCargoTypeDto {
@IsInt()
@Min(1)
displayOrder?: number;
@ApiPropertyOptional({ description: 'Insert after this record ID' })
@IsOptional()
@IsUUID('4')
insertAfterId?: string;
}

View File

@@ -1,6 +1,6 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsBoolean, IsInt, IsNumber, IsOptional, IsString, Max, MaxLength, Min } from 'class-validator';
import { IsBoolean, IsInt, IsNumber, IsOptional, IsString, IsUUID, Max, MaxLength, Min } from 'class-validator';
export class CreateContainerTypeDto {
@ApiProperty({ description: 'Customer-facing label, e.g. "20ft Dry Container"', maxLength: 100 })
@@ -40,4 +40,9 @@ export class CreateContainerTypeDto {
@IsInt()
@Min(1)
displayOrder?: number;
@ApiPropertyOptional({ description: 'Insert after this record ID' })
@IsOptional()
@IsUUID('4')
insertAfterId?: string;
}

View File

@@ -1,5 +1,5 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsBoolean, IsInt, IsOptional, IsString, MaxLength, Min } from 'class-validator';
import { IsBoolean, IsInt, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator';
export class CreateServiceTypeDto {
@ApiProperty({ description: 'Service type display name', maxLength: 255 })
@@ -48,4 +48,9 @@ export class CreateServiceTypeDto {
@IsInt()
@Min(1)
displayOrder?: number;
@ApiPropertyOptional({ description: 'Insert after this record ID' })
@IsOptional()
@IsUUID('4')
insertAfterId?: string;
}

View File

@@ -1,5 +1,5 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsBoolean, IsInt, IsOptional, IsString, MaxLength, Min } from 'class-validator';
import { IsBoolean, IsInt, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator';
export class CreateYardDto {
@ApiProperty({ description: 'Customer-facing yard label', maxLength: 100 })
@@ -22,4 +22,9 @@ export class CreateYardDto {
@IsInt()
@Min(1)
displayOrder?: number;
@ApiPropertyOptional({ description: 'Insert after this record ID' })
@IsOptional()
@IsUUID('4')
insertAfterId?: string;
}

View File

@@ -0,0 +1,8 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsIn } from 'class-validator';
export class MoveOrderDto {
@ApiProperty({ enum: ['up', 'down'] })
@IsIn(['up', 'down'])
direction!: 'up' | 'down';
}

View File

@@ -0,0 +1,17 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { ArrayMinSize, IsArray, IsBoolean, IsOptional, IsUUID } from 'class-validator';
export class ReorderItemsDto {
@ApiProperty({ description: 'Ordered list of record IDs (new display/step order)', type: [String] })
@IsArray()
@ArrayMinSize(1)
@IsUUID('4', { each: true })
ids!: string[];
@ApiPropertyOptional({
description: 'Approval-rules only: scope reorder to this chain',
})
@IsOptional()
@IsBoolean()
requiresDirectorApproval?: boolean;
}

View File

@@ -0,0 +1,2 @@
/** Ensures government bookings outrank commercial priority (max ~1,500 today). */
export const GOVERNMENT_PRIORITY_BONUS = 50_000;

View File

@@ -46,6 +46,7 @@ import { WeightLimitRulesRepository } from './repositories/weight-limit-rules.re
import { YardsRepository } from './repositories/yards.repository';
import { ApprovalRulesService } from './services/approval-rules.service';
import { DisplayOrderService } from './services/display-order.service';
import { CargoTypesService } from './services/cargo-types.service';
import { ContainerTypesService } from './services/container-types.service';
import { PriorityRulesService } from './services/priority-rules.service';
@@ -126,6 +127,7 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.
ShippingLinesService,
RatesService,
ApprovalRulesService,
DisplayOrderService,
RuleEngineService,
],
exports: [

View File

@@ -36,6 +36,7 @@ import {
SHIPPING_LINES_REPOSITORY,
} from './interfaces/shipping-lines.repository.interface';
import { DEFAULT_APPROVAL_RULE_ROWS } from './approval-rules.defaults';
import { GOVERNMENT_PRIORITY_BONUS } from './government-priority.constants';
export interface BookingContainerEvalInput {
containerTypeId: string;
@@ -54,6 +55,7 @@ export interface BookingEvaluationInput {
paymentCurrency: string;
tradeDirection: string;
isHazardous: boolean;
isGovernment?: boolean;
allowConsolidation?: boolean;
shippingLineId?: string | null;
containers: BookingContainerEvalInput[];
@@ -180,6 +182,10 @@ export class RuleEngineService {
}
}
if (input.isGovernment) {
priorityScore += GOVERNMENT_PRIORITY_BONUS;
}
let shippingLineMapped = false;
if (input.shippingLineId) {
const line = await this.shippingLinesRepo.findById(input.shippingLineId);
@@ -315,15 +321,27 @@ export class RuleEngineService {
}
/**
* Snapshot all LIVE rates into booking_rate_snapshot for a booking.
* Snapshot only the rates used in a booking's final price.
*/
async snapshotLiveRates(bookingId: string): Promise<BookingRateSnapshot[]> {
const liveRates = await this.ratesRepo.findLiveRates();
async snapshotRates(
bookingId: string,
rates: Array<{
id: string;
rateType: string;
rateValue: number;
rateUnit: string;
currency: string;
}>,
): Promise<BookingRateSnapshot[]> {
const snapshotRepo = this.dataSource.getRepository(BookingRateSnapshot);
const now = new Date();
const seen = new Set<string>();
const snapshots: BookingRateSnapshot[] = [];
for (const rate of liveRates) {
for (const rate of rates) {
if (seen.has(rate.id)) continue;
seen.add(rate.id);
const snapshot = snapshotRepo.create({
bookingId,
rateId: rate.id,

View File

@@ -1,17 +1,20 @@
import { Inject, Injectable, NotFoundException } from '@nestjs/common';
import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common';
import { CreateApprovalRuleDto } from '../dto/create-approval-rule.dto';
import { ReorderItemsDto } from '../dto/reorder-items.dto';
import { UpdateApprovalRuleDto } from '../dto/update-approval-rule.dto';
import { ApprovalRule } from '../entities/approval-rule.entity';
import {
APPROVAL_RULES_REPOSITORY,
IApprovalRulesRepository,
} from '../interfaces/approval-rules.repository.interface';
import { DisplayOrderService } from './display-order.service';
@Injectable()
export class ApprovalRulesService {
constructor(
@Inject(APPROVAL_RULES_REPOSITORY)
private readonly repository: IApprovalRulesRepository,
private readonly displayOrder: DisplayOrderService,
) {}
/** List approval rules. */
@@ -21,7 +24,7 @@ export class ApprovalRulesService {
pageSize?: number;
}): Promise<{ data: ApprovalRule[]; meta: { total: number; page: number; pageSize: number; totalPages: number } }> {
const page = filter.page ?? 1;
const pageSize = filter.pageSize ?? 20;
const pageSize = filter.pageSize ?? 10;
const where: Record<string, unknown> = {};
if (filter.requiresDirectorApproval !== undefined) {
where.requiresDirectorApproval = filter.requiresDirectorApproval;
@@ -50,9 +53,20 @@ export class ApprovalRulesService {
/** Create an approval rule step. */
async create(dto: CreateApprovalRuleDto): Promise<ApprovalRule> {
if (dto.stepOrder !== undefined && dto.insertAfterId) {
throw new BadRequestException('Cannot set both stepOrder and insertAfterId');
}
const scopeWhere = { requiresDirectorApproval: dto.requiresDirectorApproval };
const stepOrder = await this.displayOrder.resolveCreateOrder(ApprovalRule, 'stepOrder', {
explicitOrder: dto.stepOrder,
insertAfterId: dto.insertAfterId,
scopeWhere,
});
return this.repository.create({
requiresDirectorApproval: dto.requiresDirectorApproval,
stepOrder: dto.stepOrder,
stepOrder,
requiredRole: dto.requiredRole,
actionLabel: dto.actionLabel,
blocksRole: dto.blocksRole,
@@ -72,4 +86,20 @@ export class ApprovalRulesService {
await this.findById(id);
await this.repository.softDelete(id);
}
async reorder(dto: ReorderItemsDto): Promise<void> {
if (dto.requiresDirectorApproval === undefined) {
throw new BadRequestException('requiresDirectorApproval is required for approval rule reorder');
}
await this.displayOrder.reorderByIds(ApprovalRule, 'stepOrder', dto.ids, {
requiresDirectorApproval: dto.requiresDirectorApproval,
});
}
async moveOrder(id: string, direction: 'up' | 'down'): Promise<void> {
const rule = await this.findById(id);
await this.displayOrder.moveOne(ApprovalRule, 'stepOrder', id, direction, {
requiresDirectorApproval: rule.requiresDirectorApproval,
});
}
}

View File

@@ -2,18 +2,21 @@ import { ConflictException, Inject, Injectable, NotFoundException } from '@nestj
import { ILike } from 'typeorm';
import { generateCode } from '../../../common/utils/generate-code.util';
import { CreateCargoTypeDto } from '../dto/create-cargo-type.dto';
import { ReorderItemsDto } from '../dto/reorder-items.dto';
import { UpdateCargoTypeDto } from '../dto/update-cargo-type.dto';
import { CargoType } from '../entities/cargo-type.entity';
import {
CARGO_TYPES_REPOSITORY,
ICargoTypesRepository,
} from '../interfaces/cargo-types.repository.interface';
import { DisplayOrderService } from './display-order.service';
@Injectable()
export class CargoTypesService {
constructor(
@Inject(CARGO_TYPES_REPOSITORY)
private readonly repository: ICargoTypesRepository,
private readonly displayOrder: DisplayOrderService,
) {}
/** List cargo types with pagination and optional filtering. */
@@ -28,7 +31,7 @@ export class CargoTypesService {
sortOrder?: 'ASC' | 'DESC';
}): Promise<{ data: CargoType[]; meta: { total: number; page: number; pageSize: number; totalPages: number } }> {
const page = filter.page ?? 1;
const pageSize = filter.pageSize ?? 20;
const pageSize = filter.pageSize ?? 10;
const where: Record<string, unknown> = {};
if (filter.isActive !== undefined) where.isActive = filter.isActive;
if (filter.requiresDirectorApproval !== undefined) where.requiresDirectorApproval = filter.requiresDirectorApproval;
@@ -66,6 +69,12 @@ export class CargoTypesService {
const parent = await this.repository.findById(dto.parentGroupId);
if (!parent) throw new NotFoundException(`Parent cargo type ${dto.parentGroupId} not found`);
}
const displayOrder = await this.displayOrder.resolveCreateOrder(CargoType, 'displayOrder', {
explicitOrder: dto.displayOrder,
insertAfterId: dto.insertAfterId,
});
return this.repository.create({
code,
cargoTypeName: dto.cargoTypeName,
@@ -73,7 +82,7 @@ export class CargoTypesService {
showFreeTextBox: dto.showFreeTextBox ?? false,
requiresDirectorApproval: dto.requiresDirectorApproval ?? false,
isActive: dto.isActive ?? true,
displayOrder: dto.displayOrder ?? 1,
displayOrder,
});
}
@@ -95,4 +104,13 @@ export class CargoTypesService {
await this.findById(id);
await this.repository.softDelete(id);
}
async reorder(dto: ReorderItemsDto): Promise<void> {
await this.displayOrder.reorderByIds(CargoType, 'displayOrder', dto.ids);
}
async moveOrder(id: string, direction: 'up' | 'down'): Promise<void> {
await this.findById(id);
await this.displayOrder.moveOne(CargoType, 'displayOrder', id, direction);
}
}

View File

@@ -1,18 +1,21 @@
import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common';
import { generateCode } from '../../../common/utils/generate-code.util';
import { CreateContainerTypeDto } from '../dto/create-container-type.dto';
import { ReorderItemsDto } from '../dto/reorder-items.dto';
import { UpdateContainerTypeDto } from '../dto/update-container-type.dto';
import { ContainerType } from '../entities/container-type.entity';
import {
CONTAINER_TYPES_REPOSITORY,
IContainerTypesRepository,
} from '../interfaces/container-types.repository.interface';
import { DisplayOrderService } from './display-order.service';
@Injectable()
export class ContainerTypesService {
constructor(
@Inject(CONTAINER_TYPES_REPOSITORY)
private readonly repository: IContainerTypesRepository,
private readonly displayOrder: DisplayOrderService,
) {}
/** List container types with pagination. */
@@ -22,7 +25,7 @@ export class ContainerTypesService {
pageSize?: number;
}): Promise<{ data: ContainerType[]; meta: { total: number; page: number; pageSize: number; totalPages: number } }> {
const page = filter.page ?? 1;
const pageSize = filter.pageSize ?? 20;
const pageSize = filter.pageSize ?? 10;
const where: Record<string, unknown> = {};
if (filter.isActive !== undefined) where.isActive = filter.isActive;
@@ -47,6 +50,12 @@ export class ContainerTypesService {
const code = generateCode(dto.label);
const existing = await this.repository.findByCode(code);
if (existing) throw new ConflictException(`Container type with label "${dto.label}" conflicts with existing code "${code}"`);
const displayOrder = await this.displayOrder.resolveCreateOrder(ContainerType, 'displayOrder', {
explicitOrder: dto.displayOrder,
insertAfterId: dto.insertAfterId,
});
return this.repository.create({
code,
label: dto.label,
@@ -55,7 +64,7 @@ export class ContainerTypesService {
isReefer: dto.isReefer ?? false,
isOpenTop: dto.isOpenTop ?? false,
isActive: dto.isActive ?? true,
displayOrder: dto.displayOrder ?? 1,
displayOrder,
});
}
@@ -72,4 +81,13 @@ export class ContainerTypesService {
await this.findById(id);
await this.repository.softDelete(id);
}
async reorder(dto: ReorderItemsDto): Promise<void> {
await this.displayOrder.reorderByIds(ContainerType, 'displayOrder', dto.ids);
}
async moveOrder(id: string, direction: 'up' | 'down'): Promise<void> {
await this.findById(id);
await this.displayOrder.moveOne(ContainerType, 'displayOrder', id, direction);
}
}

View File

@@ -0,0 +1,175 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { DataSource, EntityTarget, FindOptionsWhere, ObjectLiteral } from 'typeorm';
export type OrderField = 'displayOrder' | 'stepOrder';
@Injectable()
export class DisplayOrderService {
constructor(private readonly dataSource: DataSource) {}
async getMaxOrder<T extends ObjectLiteral>(
entity: EntityTarget<T>,
field: OrderField,
where?: FindOptionsWhere<T>,
): Promise<number> {
const repo = this.dataSource.getRepository(entity);
const qb = repo.createQueryBuilder('e').select(`MAX(e.${field})`, 'max');
if (where) {
Object.entries(where).forEach(([key, value]) => {
if (value !== undefined) {
qb.andWhere(`e.${key} = :${key}`, { [key]: value });
}
});
}
const row = await qb.getRawOne<{ max: string | null }>();
return row?.max ? Number(row.max) : 0;
}
async resolveCreateOrder<T extends ObjectLiteral>(
entity: EntityTarget<T>,
field: OrderField,
options: {
explicitOrder?: number;
insertAfterId?: string;
scopeWhere?: FindOptionsWhere<T>;
},
): Promise<number> {
const { explicitOrder, insertAfterId, scopeWhere } = options;
if (insertAfterId) {
if (explicitOrder !== undefined) {
throw new BadRequestException('Cannot set both explicit order and insertAfterId');
}
const repo = this.dataSource.getRepository(entity);
const after = await repo.findOne({
where: { id: insertAfterId, ...scopeWhere } as unknown as FindOptionsWhere<T>,
});
if (!after) {
throw new NotFoundException(`Record ${insertAfterId} not found in scope`);
}
const afterOrder = Number((after as Record<string, unknown>)[field]);
await this.shiftOrdersFrom(entity, field, afterOrder + 1, 1, scopeWhere);
return afterOrder + 1;
}
if (explicitOrder !== undefined) {
return explicitOrder;
}
const max = await this.getMaxOrder(entity, field, scopeWhere);
return max + 1;
}
async reorderByIds<T extends ObjectLiteral>(
entity: EntityTarget<T>,
field: OrderField,
ids: string[],
scopeWhere?: FindOptionsWhere<T>,
): Promise<void> {
const repo = this.dataSource.getRepository(entity);
const existing = await repo.find({
where: scopeWhere,
order: { [field]: 'ASC' } as never,
});
const scopedIds = new Set(existing.map((row) => String(row.id)));
if (ids.length !== scopedIds.size) {
throw new BadRequestException('Reorder list must include every item in scope exactly once');
}
for (const id of ids) {
if (!scopedIds.has(id)) {
throw new BadRequestException(`ID ${id} is not in the reorder scope`);
}
}
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
for (let i = 0; i < ids.length; i++) {
await queryRunner.manager.update(entity, ids[i], { [field]: -(i + 1) } as never);
}
for (let i = 0; i < ids.length; i++) {
await queryRunner.manager.update(entity, ids[i], { [field]: i + 1 } as never);
}
await queryRunner.commitTransaction();
} catch (err) {
await queryRunner.rollbackTransaction();
throw err;
} finally {
await queryRunner.release();
}
}
async moveOne<T extends ObjectLiteral>(
entity: EntityTarget<T>,
field: OrderField,
id: string,
direction: 'up' | 'down',
scopeWhere?: FindOptionsWhere<T>,
): Promise<void> {
const repo = this.dataSource.getRepository(entity);
const items = await repo.find({
where: scopeWhere,
order: { [field]: 'ASC' } as never,
});
const index = items.findIndex((row) => String(row.id) === id);
if (index === -1) {
throw new NotFoundException(`Record ${id} not found in scope`);
}
const targetIndex = direction === 'up' ? index - 1 : index + 1;
if (targetIndex < 0 || targetIndex >= items.length) {
throw new BadRequestException(`Cannot move ${direction}`);
}
const current = items[index] as Record<string, unknown>;
const neighbor = items[targetIndex] as Record<string, unknown>;
const currentOrder = Number(current[field]);
const neighborOrder = Number(neighbor[field]);
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
await queryRunner.manager.update(entity, String(current.id), { [field]: -1 } as never);
await queryRunner.manager.update(entity, String(neighbor.id), { [field]: -2 } as never);
await queryRunner.manager.update(entity, String(current.id), { [field]: neighborOrder } as never);
await queryRunner.manager.update(entity, String(neighbor.id), { [field]: currentOrder } as never);
await queryRunner.commitTransaction();
} catch (err) {
await queryRunner.rollbackTransaction();
throw err;
} finally {
await queryRunner.release();
}
}
private async shiftOrdersFrom<T extends ObjectLiteral>(
entity: EntityTarget<T>,
field: OrderField,
fromOrder: number,
delta: number,
scopeWhere?: FindOptionsWhere<T>,
): Promise<void> {
const repo = this.dataSource.getRepository(entity);
const orderColumn = repo.metadata.findColumnWithPropertyName(field)?.databaseName ?? field;
const qb = repo
.createQueryBuilder()
.update()
.set({ [field]: () => `"${orderColumn}" + ${delta}` } as never)
.where(`"${orderColumn}" >= :fromOrder`, { fromOrder });
if (scopeWhere) {
Object.entries(scopeWhere).forEach(([key, value]) => {
if (value !== undefined) {
const col = repo.metadata.findColumnWithPropertyName(key)?.databaseName ?? key;
qb.andWhere(`"${col}" = :scope_${key}`, { [`scope_${key}`]: value });
}
});
}
await qb.execute();
}
}

View File

@@ -30,7 +30,7 @@ export class RatesService {
skip: (page - 1) * pageSize,
take: pageSize,
});
return { data, meta: { total, page, pageSize, totalPages: Math.ceil(total / pageSize) } };
return { data, meta: { total, page, pageSize, totalPages: Math.max(1, Math.ceil(total / pageSize)) } };
}
/** Return all currently LIVE rates. */

View File

@@ -2,18 +2,21 @@ import { ConflictException, Inject, Injectable, NotFoundException } from '@nestj
import { ILike } from 'typeorm';
import { generateCode } from '../../../common/utils/generate-code.util';
import { CreateServiceTypeDto } from '../dto/create-service-type.dto';
import { ReorderItemsDto } from '../dto/reorder-items.dto';
import { UpdateServiceTypeDto } from '../dto/update-service-type.dto';
import { ServiceType } from '../entities/service-type.entity';
import {
IServiceTypesRepository,
SERVICE_TYPES_REPOSITORY,
} from '../interfaces/service-types.repository.interface';
import { DisplayOrderService } from './display-order.service';
@Injectable()
export class ServiceTypesService {
constructor(
@Inject(SERVICE_TYPES_REPOSITORY)
private readonly repository: IServiceTypesRepository,
private readonly displayOrder: DisplayOrderService,
) {}
/** List service types with pagination and optional filtering. */
@@ -27,7 +30,7 @@ export class ServiceTypesService {
sortOrder?: 'ASC' | 'DESC';
}): Promise<{ data: ServiceType[]; meta: { total: number; page: number; pageSize: number; totalPages: number } }> {
const page = filter.page ?? 1;
const pageSize = filter.pageSize ?? 20;
const pageSize = filter.pageSize ?? 10;
const where: Record<string, unknown> = {};
if (filter.isActive !== undefined) where.isActive = filter.isActive;
if (filter.canBeBookedAlone !== undefined) where.canBeBookedAlone = filter.canBeBookedAlone;
@@ -59,6 +62,12 @@ export class ServiceTypesService {
const code = generateCode(dto.serviceName);
const existing = await this.repository.findByCode(code);
if (existing) throw new ConflictException(`Service type with name "${dto.serviceName}" conflicts with existing code "${code}"`);
const displayOrder = await this.displayOrder.resolveCreateOrder(ServiceType, 'displayOrder', {
explicitOrder: dto.displayOrder,
insertAfterId: dto.insertAfterId,
});
return this.repository.create({
code,
serviceName: dto.serviceName,
@@ -69,7 +78,7 @@ export class ServiceTypesService {
includesCustoms: dto.includesCustoms ?? false,
priorityBonusPoints: dto.priorityBonusPoints ?? 0,
isActive: dto.isActive ?? true,
displayOrder: dto.displayOrder ?? 1,
displayOrder,
});
}
@@ -87,4 +96,13 @@ export class ServiceTypesService {
await this.findById(id);
await this.repository.softDelete(id);
}
async reorder(dto: ReorderItemsDto): Promise<void> {
await this.displayOrder.reorderByIds(ServiceType, 'displayOrder', dto.ids);
}
async moveOrder(id: string, direction: 'up' | 'down'): Promise<void> {
await this.findById(id);
await this.displayOrder.moveOne(ServiceType, 'displayOrder', id, direction);
}
}

View File

@@ -1,15 +1,18 @@
import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common';
import { generateCode } from '../../../common/utils/generate-code.util';
import { CreateYardDto } from '../dto/create-yard.dto';
import { ReorderItemsDto } from '../dto/reorder-items.dto';
import { UpdateYardDto } from '../dto/update-yard.dto';
import { Yard } from '../entities/yard.entity';
import { IYardsRepository, YARDS_REPOSITORY } from '../interfaces/yards.repository.interface';
import { DisplayOrderService } from './display-order.service';
@Injectable()
export class YardsService {
constructor(
@Inject(YARDS_REPOSITORY)
private readonly repository: IYardsRepository,
private readonly displayOrder: DisplayOrderService,
) {}
/** List yards with pagination. */
@@ -20,7 +23,7 @@ export class YardsService {
pageSize?: number;
}): Promise<{ data: Yard[]; meta: { total: number; page: number; pageSize: number; totalPages: number } }> {
const page = filter.page ?? 1;
const pageSize = filter.pageSize ?? 20;
const pageSize = filter.pageSize ?? 10;
const where: Record<string, unknown> = {};
if (filter.isActive !== undefined) where.isActive = filter.isActive;
if (filter.country) where.country = filter.country;
@@ -46,12 +49,18 @@ export class YardsService {
const code = generateCode(dto.label);
const existing = await this.repository.findByCode(code);
if (existing) throw new ConflictException(`Yard with label "${dto.label}" conflicts with existing code "${code}"`);
const displayOrder = await this.displayOrder.resolveCreateOrder(Yard, 'displayOrder', {
explicitOrder: dto.displayOrder,
insertAfterId: dto.insertAfterId,
});
return this.repository.create({
code,
label: dto.label,
country: dto.country,
isActive: dto.isActive ?? true,
displayOrder: dto.displayOrder ?? 1,
displayOrder,
});
}
@@ -68,4 +77,13 @@ export class YardsService {
await this.findById(id);
await this.repository.softDelete(id);
}
async reorder(dto: ReorderItemsDto): Promise<void> {
await this.displayOrder.reorderByIds(Yard, 'displayOrder', dto.ids);
}
async moveOrder(id: string, direction: 'up' | 'down'): Promise<void> {
await this.findById(id);
await this.displayOrder.moveOne(Yard, 'displayOrder', id, direction);
}
}

View File

@@ -0,0 +1,46 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
ArrayMinSize,
IsArray,
IsDateString,
IsIn,
IsOptional,
IsString,
IsUUID,
} from 'class-validator';
import { RESCHEDULE_TRIGGERS } from '../entities/scheduling-event.entity';
export class PreviewRescheduleDto {
@ApiProperty({ type: [String] })
@IsArray()
@ArrayMinSize(1)
@IsUUID('4', { each: true })
incomingBookingIds!: string[];
@ApiProperty({ enum: RESCHEDULE_TRIGGERS })
@IsIn([...RESCHEDULE_TRIGGERS])
trigger!: (typeof RESCHEDULE_TRIGGERS)[number];
@ApiPropertyOptional()
@IsOptional()
@IsString()
reason?: string;
@ApiPropertyOptional({ example: '2026-06-22T08:00:00.000Z' })
@IsOptional()
@IsDateString()
newDepartureDate?: string;
}
export class ExecuteRescheduleDto extends PreviewRescheduleDto {
@ApiProperty({ type: [String], description: 'Booking IDs to assign after reschedule' })
@IsArray()
@IsUUID('4', { each: true })
finalBookingIds!: string[];
@ApiProperty({ type: [String], description: 'Booking IDs removed from the schedule' })
@IsArray()
@IsUUID('4', { each: true })
displacedBookingIds!: string[];
}

View File

@@ -0,0 +1,33 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index } from 'typeorm';
export const RESCHEDULE_TRIGGERS = [
'GOVERNMENT_PREEMPT',
'TRAIN_MAINTENANCE',
'MANUAL',
'CAPACITY_REBALANCE',
] as const;
export type RescheduleTrigger = (typeof RESCHEDULE_TRIGGERS)[number];
@Entity({ schema: 'freight', name: 'scheduling_events' })
@Index(['trainScheduleId'])
export class SchedulingEvent extends BaseEntity {
@Column({ name: 'train_schedule_id', type: 'uuid' })
trainScheduleId!: string;
@Column({ name: 'trigger', type: 'varchar', length: 40 })
trigger!: RescheduleTrigger;
@Column({ name: 'actor_user_id', type: 'uuid', nullable: true })
actorUserId?: string | null;
@Column({ name: 'reason', type: 'text', nullable: true })
reason?: string | null;
@Column({ name: 'plan_snapshot', type: 'jsonb' })
planSnapshot!: Record<string, unknown>;
@Column({ name: 'displaced_booking_ids', type: 'jsonb', default: '[]' })
displacedBookingIds!: string[];
}

View File

@@ -0,0 +1,65 @@
import { Body, Controller, Param, ParseUUIDPipe, Post } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { CurrentUser } from '@edr/api-common';
import { TrainSchedulingManage } from '../../common/booking-guards';
import {
type AuthUserPayload,
resolveAuthUserId,
} from '../../common/resolve-auth-user-id';
import { ExecuteRescheduleDto, PreviewRescheduleDto } from './dto/preview-reschedule.dto';
import { SchedulingRescheduleService } from './scheduling-reschedule.service';
@ApiTags('train-scheduling')
@ApiBearerAuth()
@Controller('train-scheduling/schedules/:id/reschedule')
export class SchedulingRescheduleController {
constructor(private readonly schedulingRescheduleService: SchedulingRescheduleService) {}
@Post('preview')
@TrainSchedulingManage()
@ApiOperation({ summary: 'Preview reschedule / government preempt plan' })
preview(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: PreviewRescheduleDto,
) {
return this.schedulingRescheduleService.previewReschedule(id, dto);
}
@Post('execute')
@TrainSchedulingManage()
@ApiOperation({ summary: 'Execute a confirmed reschedule plan' })
execute(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: ExecuteRescheduleDto,
@CurrentUser() user: AuthUserPayload,
) {
return this.schedulingRescheduleService.executeReschedule(
id,
dto,
resolveAuthUserId(user),
);
}
}
@ApiTags('train-scheduling')
@ApiBearerAuth()
@Controller('train-scheduling/schedules/:id')
export class SchedulingMaintenanceController {
constructor(private readonly schedulingRescheduleService: SchedulingRescheduleService) {}
@Post('maintenance')
@TrainSchedulingManage()
@ApiOperation({ summary: 'Reschedule train for maintenance (new departure + rebalance)' })
maintenance(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: PreviewRescheduleDto & { newDepartureDate: string },
@CurrentUser() user: AuthUserPayload,
) {
return this.schedulingRescheduleService.maintenanceReschedule(
id,
dto,
resolveAuthUserId(user),
);
}
}

View File

@@ -0,0 +1,26 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { BookingsModule } from '../bookings/bookings.module';
import { TrainSchedulesModule } from '../train-schedules/train-schedules.module';
import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module';
import { SchedulingEvent } from './entities/scheduling-event.entity';
import {
SchedulingMaintenanceController,
SchedulingRescheduleController,
} from './scheduling-reschedule.controller';
import { SchedulingRescheduleRepository } from './scheduling-reschedule.repository';
import { SchedulingRescheduleService } from './scheduling-reschedule.service';
@Module({
imports: [
TypeOrmModule.forFeature([SchedulingEvent]),
BookingsModule,
TrainSchedulesModule,
TrainSchedulingModule,
],
controllers: [SchedulingRescheduleController, SchedulingMaintenanceController],
providers: [SchedulingRescheduleRepository, SchedulingRescheduleService],
exports: [SchedulingRescheduleService],
})
export class SchedulingRescheduleModule {}

View File

@@ -0,0 +1,25 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { SchedulingEvent, type RescheduleTrigger } from './entities/scheduling-event.entity';
@Injectable()
export class SchedulingRescheduleRepository {
constructor(
@InjectRepository(SchedulingEvent)
private readonly repository: Repository<SchedulingEvent>,
) {}
/** Persist an audit record for a completed reschedule. */
async createEvent(data: {
trainScheduleId: string;
trigger: RescheduleTrigger;
actorUserId?: string;
reason?: string;
planSnapshot: Record<string, unknown>;
displacedBookingIds: string[];
}): Promise<SchedulingEvent> {
return this.repository.save(this.repository.create(data));
}
}

View File

@@ -0,0 +1,230 @@
import { BadRequestException } from '@nestjs/common';
import { compareSchedulingPriority } from '../scheduling/compare-scheduling-priority.util';
import { SchedulingRescheduleService } from './scheduling-reschedule.service';
const makeBooking = (
id: string,
reference: string,
extra: Record<string, unknown> = {},
) => ({
id,
reference,
freightType: 'CONTAINER',
cargoTotalWeightVgm: 100,
scheduledDate: new Date('2026-06-20T08:00:00.000Z'),
originYardId: 'yard-origin',
destinationYardId: 'yard-destination',
status: 'PAID',
isGovernment: false,
priorityScore: 50,
bookingContainers: [
{
id: `${id}-line`,
wagonsRequired: 5,
quantity: 1,
vgmPerUnitTons: 100,
},
],
...extra,
});
describe('compareSchedulingPriority', () => {
it('orders government before commercial', () => {
const sorted = [
{
isGovernment: false,
priorityScore: 50000,
scheduledDate: new Date('2026-06-20'),
},
{
isGovernment: true,
priorityScore: 100,
scheduledDate: new Date('2026-06-25'),
},
].sort(compareSchedulingPriority);
expect(sorted[0]?.isGovernment).toBe(true);
});
});
describe('SchedulingRescheduleService', () => {
let service: SchedulingRescheduleService;
let trainSchedulesRepository: Record<string, jest.Mock>;
let bookingsRepository: Record<string, jest.Mock>;
let trainSchedulingService: Record<string, jest.Mock>;
let schedulingRescheduleRepository: Record<string, jest.Mock>;
beforeEach(() => {
trainSchedulesRepository = {
findByIdWithFullGraph: jest.fn(),
updateStatus: jest.fn(),
};
bookingsRepository = {
findByIdsForScheduling: jest.fn(),
updateSchedulingFields: jest.fn(),
};
trainSchedulingService = {
previewTrainSchedule: jest.fn(),
unassignBooking: jest.fn(),
assignBookingsToSchedule: jest.fn(),
};
schedulingRescheduleRepository = {
createEvent: jest.fn().mockResolvedValue({ id: 'event-1' }),
};
service = new SchedulingRescheduleService(
trainSchedulesRepository as never,
bookingsRepository as never,
trainSchedulingService as never,
schedulingRescheduleRepository as never,
);
});
it('rejects reschedule on dispatched trains', async () => {
trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue({
id: 'sched-1',
status: 'DISPATCHED',
scheduleBookings: [],
});
await expect(
service.previewReschedule('sched-1', {
incomingBookingIds: ['gov-1'],
trigger: 'GOVERNMENT_PREEMPT',
}),
).rejects.toBeInstanceOf(BadRequestException);
});
it('displaces lower-priority commercial when government incoming exceeds capacity', async () => {
const commercial = makeBooking('c1', 'BKG-COMM', { priorityScore: 10, isGovernment: false });
const government = makeBooking('g1', 'BKG-GOV', {
isGovernment: true,
priorityScore: 60000,
governmentInstitution: 'Ministry',
});
trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue({
id: 'sched-1',
status: 'DRAFT',
scheduledDepartureDate: new Date('2026-06-20T08:00:00.000Z'),
originStationId: 'yard-origin',
destinationStationId: 'yard-destination',
scheduleBookings: [{ bookingId: 'c1', booking: commercial }],
});
bookingsRepository.findByIdsForScheduling.mockResolvedValue([government]);
trainSchedulingService.previewTrainSchedule.mockImplementation(
async ({ bookingIds }: { bookingIds: string[] }) => ({
valid: bookingIds.length <= 1,
violations: bookingIds.length > 1 ? ['Train capacity exceeded'] : [],
warnings: [],
}),
);
const plan = await service.previewReschedule('sched-1', {
incomingBookingIds: ['g1'],
trigger: 'GOVERNMENT_PREEMPT',
});
expect(plan.retained.map((b) => b.id)).toEqual(['g1']);
expect(plan.displaced.map((b) => b.id)).toEqual(['c1']);
expect(plan.finalBookingIds).toEqual(['g1']);
});
it('readmits high-priority commercial when spare capacity remains', async () => {
const low = makeBooking('c-low', 'BKG-LOW', { priorityScore: 5 });
const high = makeBooking('c-high', 'BKG-HIGH', { priorityScore: 500 });
const government = makeBooking('g1', 'BKG-GOV', { isGovernment: true, priorityScore: 60000 });
trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue({
id: 'sched-1',
status: 'DRAFT',
scheduledDepartureDate: new Date('2026-06-20T08:00:00.000Z'),
originStationId: 'yard-origin',
destinationStationId: 'yard-destination',
scheduleBookings: [
{ bookingId: 'c-low', booking: low },
{ bookingId: 'c-high', booking: high },
],
});
bookingsRepository.findByIdsForScheduling.mockResolvedValue([government]);
const fitAttempts = new Map<string, number>();
trainSchedulingService.previewTrainSchedule.mockImplementation(
async ({ bookingIds }: { bookingIds: string[] }) => {
const key = [...bookingIds].sort().join(',');
const attempt = (fitAttempts.get(key) ?? 0) + 1;
fitAttempts.set(key, attempt);
const fits =
bookingIds.length === 1 ||
(key === 'c-high,g1' && attempt > 1);
return {
valid: fits,
violations: fits ? [] : ['Train capacity exceeded'],
warnings: [],
};
},
);
const plan = await service.previewReschedule('sched-1', {
incomingBookingIds: ['g1'],
trigger: 'GOVERNMENT_PREEMPT',
});
expect(plan.retained.map((b) => b.id)).toEqual(['g1']);
expect(plan.readmitted.map((b) => b.id)).toEqual(['c-high']);
expect(plan.displaced.map((b) => b.id)).toEqual(['c-low']);
expect(plan.finalBookingIds).toEqual(['g1', 'c-high']);
});
it('maintenance reschedule updates departure and rebalances bookings', async () => {
const commercial = makeBooking('c1', 'BKG-COMM', { priorityScore: 10 });
const schedule = {
id: 'sched-1',
status: 'DRAFT',
scheduledDepartureDate: new Date('2026-06-20T08:00:00.000Z'),
originStationId: 'yard-origin',
destinationStationId: 'yard-destination',
scheduleBookings: [{ bookingId: 'c1', booking: commercial }],
};
trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue(schedule);
bookingsRepository.findByIdsForScheduling.mockResolvedValue([commercial]);
trainSchedulingService.previewTrainSchedule.mockResolvedValue({
valid: true,
violations: [],
warnings: [],
});
trainSchedulesRepository.updateStatus.mockResolvedValue(undefined);
trainSchedulingService.assignBookingsToSchedule.mockResolvedValue({ id: 'sched-1' });
const result = await service.maintenanceReschedule(
'sched-1',
{
incomingBookingIds: ['c1'],
trigger: 'TRAIN_MAINTENANCE',
reason: 'Locomotive service',
newDepartureDate: '2026-06-22T10:00:00.000Z',
},
'staff-1',
);
expect(trainSchedulesRepository.updateStatus).toHaveBeenCalledWith(
'sched-1',
'DRAFT',
{ scheduledDepartureDate: new Date('2026-06-22T10:00:00.000Z') },
);
expect(schedulingRescheduleRepository.createEvent).toHaveBeenCalledWith(
expect.objectContaining({
trigger: 'TRAIN_MAINTENANCE',
actorUserId: 'staff-1',
reason: 'Locomotive service',
}),
);
expect(result.plan.trigger).toBe('TRAIN_MAINTENANCE');
expect(result.plan.finalBookingIds).toEqual(['c1']);
});
});

View File

@@ -0,0 +1,230 @@
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { SchedulingStatus, TrainScheduleStatus } from '@edr/types';
import { Booking } from '../bookings/entities/booking.entity';
import { BookingsRepository } from '../bookings/bookings.repository';
import { compareSchedulingPriority } from '../scheduling/compare-scheduling-priority.util';
import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository';
import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service';
import { ExecuteRescheduleDto, PreviewRescheduleDto } from './dto/preview-reschedule.dto';
import { SchedulingRescheduleRepository } from './scheduling-reschedule.repository';
export interface RescheduleBookingSummary {
id: string;
reference: string;
isGovernment: boolean;
priorityScore: number;
governmentInstitution?: string | null;
}
export interface ReschedulePlan {
scheduleId: string;
trigger: PreviewRescheduleDto['trigger'];
retained: RescheduleBookingSummary[];
displaced: RescheduleBookingSummary[];
readmitted: RescheduleBookingSummary[];
finalBookingIds: string[];
warnings: string[];
}
@Injectable()
export class SchedulingRescheduleService {
constructor(
private readonly trainSchedulesRepository: TrainSchedulesRepository,
private readonly bookingsRepository: BookingsRepository,
private readonly trainSchedulingService: TrainSchedulingService,
private readonly schedulingRescheduleRepository: SchedulingRescheduleRepository,
) {}
/** Preview who is retained, displaced, and readmitted on a schedule. */
async previewReschedule(
scheduleId: string,
dto: PreviewRescheduleDto,
): Promise<ReschedulePlan> {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule) {
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
}
if (schedule.status === TrainScheduleStatus.Dispatched) {
throw new BadRequestException('Cannot reschedule a dispatched train');
}
const currentOnSchedule = (schedule.scheduleBookings ?? [])
.map((link) => link.booking)
.filter((b): b is Booking => Boolean(b));
const incoming = await this.bookingsRepository.findByIdsForScheduling(dto.incomingBookingIds);
if (incoming.length !== dto.incomingBookingIds.length) {
throw new BadRequestException('One or more incoming bookings were not found');
}
const mergedMap = new Map<string, Booking>();
for (const booking of [...currentOnSchedule, ...incoming]) {
mergedMap.set(booking.id, booking);
}
const sorted = [...mergedMap.values()].sort(compareSchedulingPriority);
const warnings: string[] = [];
const retained: Booking[] = [];
for (const booking of sorted) {
const candidate = [...retained, booking];
const fits = await this.bookingsFitOnSchedule(candidate, schedule, scheduleId);
if (fits) {
retained.push(booking);
} else if (currentOnSchedule.some((b) => b.id === booking.id)) {
warnings.push(`Booking ${booking.reference} will be displaced from the train`);
}
}
const retainedIds = new Set(retained.map((b) => b.id));
const displacedFromCurrent = currentOnSchedule.filter((b) => !retainedIds.has(b.id));
const readmitted: Booking[] = [];
const displacedCommercial = displacedFromCurrent
.filter((b) => !b.isGovernment)
.sort(compareSchedulingPriority);
for (const booking of displacedCommercial) {
const candidate = [...retained, ...readmitted, booking];
const fits = await this.bookingsFitOnSchedule(candidate, schedule, scheduleId);
if (fits) {
readmitted.push(booking);
warnings.push(`Booking ${booking.reference} readmitted after government placement`);
}
}
const finalIds = [...retained, ...readmitted].map((b) => b.id);
const displacedIds = new Set(displacedFromCurrent.map((b) => b.id));
for (const id of readmitted.map((b) => b.id)) {
displacedIds.delete(id);
}
const displaced = displacedFromCurrent.filter((b) => displacedIds.has(b.id));
return {
scheduleId,
trigger: dto.trigger,
retained: retained.map((b) => this.toSummary(b)),
displaced: displaced.map((b) => this.toSummary(b)),
readmitted: readmitted.map((b) => this.toSummary(b)),
finalBookingIds: finalIds,
warnings,
};
}
/** Execute a confirmed reschedule plan. */
async executeReschedule(
scheduleId: string,
dto: ExecuteRescheduleDto,
actorUserId?: string,
) {
const plan = await this.previewReschedule(scheduleId, dto);
const expectedDisplaced = new Set(plan.displaced.map((b) => b.id));
const providedDisplaced = new Set(dto.displacedBookingIds);
if (
expectedDisplaced.size !== providedDisplaced.size ||
[...expectedDisplaced].some((id) => !providedDisplaced.has(id))
) {
throw new BadRequestException('Displaced booking list does not match current preview');
}
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule) {
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
}
if (dto.newDepartureDate && schedule) {
await this.trainSchedulesRepository.updateStatus(
scheduleId,
schedule.status as TrainScheduleStatus,
{ scheduledDepartureDate: new Date(dto.newDepartureDate) },
);
}
for (const bookingId of dto.displacedBookingIds) {
try {
await this.trainSchedulingService.unassignBooking(scheduleId, bookingId);
} catch {
await this.bookingsRepository.updateSchedulingFields(bookingId, {
schedulingStatus: SchedulingStatus.Eligible,
wagonsRequired: null,
});
}
}
const assignResult = await this.trainSchedulingService.assignBookingsToSchedule(scheduleId, {
bookingIds: dto.finalBookingIds,
forceAssign: dto.trigger === 'GOVERNMENT_PREEMPT',
});
await this.schedulingRescheduleRepository.createEvent({
trainScheduleId: scheduleId,
trigger: dto.trigger,
actorUserId,
reason: dto.reason,
planSnapshot: plan as unknown as Record<string, unknown>,
displacedBookingIds: dto.displacedBookingIds,
});
return { plan, schedule: assignResult };
}
/** Maintenance shortcut: new departure + rebalance. */
async maintenanceReschedule(
scheduleId: string,
dto: PreviewRescheduleDto & { newDepartureDate: string },
actorUserId?: string,
) {
const currentIds = (
await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId)
)?.scheduleBookings?.map((l) => l.bookingId) ?? [];
const preview = await this.previewReschedule(scheduleId, {
...dto,
trigger: 'TRAIN_MAINTENANCE',
incomingBookingIds: currentIds.length ? currentIds : dto.incomingBookingIds,
});
return this.executeReschedule(
scheduleId,
{
...dto,
trigger: 'TRAIN_MAINTENANCE',
incomingBookingIds: dto.incomingBookingIds,
finalBookingIds: preview.finalBookingIds,
displacedBookingIds: preview.displaced.map((b) => b.id),
},
actorUserId,
);
}
private async bookingsFitOnSchedule(
bookings: Booking[],
schedule: { scheduledDepartureDate: Date; originStationId: string; destinationStationId: string },
scheduleId: string,
): Promise<boolean> {
if (!bookings.length) return true;
const preview = await this.trainSchedulingService.previewTrainSchedule({
bookingIds: bookings.map((b) => b.id),
scheduleDate: schedule.scheduledDepartureDate.toISOString(),
originStationId: schedule.originStationId,
destinationStationId: schedule.destinationStationId,
targetScheduleId: scheduleId,
});
return preview.valid;
}
private toSummary(booking: Booking): RescheduleBookingSummary {
return {
id: booking.id,
reference: booking.reference,
isGovernment: booking.isGovernment,
priorityScore: booking.priorityScore,
governmentInstitution: booking.governmentInstitution,
};
}
}

View File

@@ -0,0 +1,19 @@
export interface SchedulingPriorityBooking {
isGovernment?: boolean;
priorityScore?: number | null;
scheduledDate: Date | string;
}
/** Government first, then priority score, then earliest scheduled date. */
export function compareSchedulingPriority(
a: SchedulingPriorityBooking,
b: SchedulingPriorityBooking,
): number {
const govDiff = Number(Boolean(b.isGovernment)) - Number(Boolean(a.isGovernment));
if (govDiff !== 0) return govDiff;
const priorityDiff = (b.priorityScore ?? 0) - (a.priorityScore ?? 0);
if (priorityDiff !== 0) return priorityDiff;
return new Date(a.scheduledDate).getTime() - new Date(b.scheduledDate).getTime();
}

View File

@@ -1,4 +1,5 @@
import { BaseEntity } from '@edr/api-common';
import { TrainScheduleStatus as TrainScheduleStatusEnum } from '@edr/types';
import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany, OneToOne } from 'typeorm';
import { Yard } from '../../rule-engine/entities/yard.entity';
@@ -7,6 +8,7 @@ import { TrainSet } from '../../train-sets/entities/train-set.entity';
import { TrainScheduleBooking } from './train-schedule-booking.entity';
export const TRAIN_SCHEDULE_STATUSES = [
<<<<<<< HEAD
'DRAFT',
'READY',
'PUBLISHED',
@@ -15,6 +17,13 @@ export const TRAIN_SCHEDULE_STATUSES = [
'ARRIVED',
'COMPLETED',
'CANCELLED',
=======
TrainScheduleStatusEnum.Draft,
TrainScheduleStatusEnum.Scheduled,
TrainScheduleStatusEnum.Dispatched,
TrainScheduleStatusEnum.Arrived,
TrainScheduleStatusEnum.Cancelled,
>>>>>>> 10b011de8feafce5c0bae21594df5b36596587db
] as const;
export type TrainScheduleStatus = (typeof TRAIN_SCHEDULE_STATUSES)[number];
@@ -60,6 +69,27 @@ export class TrainSchedule extends BaseEntity {
@Column({ name: 'status', type: 'varchar', length: 20, default: 'DRAFT' })
status!: TrainScheduleStatus;
@Column({ name: 'train_number', type: 'varchar', length: 20, nullable: true })
trainNumber?: string | null;
@Column({ name: 'direction', type: 'varchar', length: 10, nullable: true })
direction?: string | null;
@Column({ name: 'actual_departure_at', type: 'timestamptz', nullable: true })
actualDepartureAt?: Date | null;
@Column({ name: 'actual_arrival_at', type: 'timestamptz', nullable: true })
actualArrivalAt?: Date | null;
@Column({ name: 'prepared_by_user_id', type: 'uuid', nullable: true })
preparedByUserId?: string | null;
@Column({ name: 'checked_by_user_id', type: 'uuid', nullable: true })
checkedByUserId?: string | null;
@Column({ name: 'max_wagons', type: 'int', default: 53 })
maxWagons!: number;
@OneToMany(() => TrainScheduleBooking, (scheduleBooking) => scheduleBooking.trainSchedule)
scheduleBookings?: TrainScheduleBooking[];
}

View File

@@ -0,0 +1,53 @@
import { BaseEntity } from '@edr/api-common';
import { BulkPricingUnit } from '@edr/types';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { Booking } from '../../bookings/entities/booking.entity';
import { CargoType } from '../../rule-engine/entities/cargo-type.entity';
import { WagonBookingAllocation } from './wagon-booking-allocation.entity';
export const BULK_PRICING_UNITS = [
BulkPricingUnit.PerWagon,
BulkPricingUnit.PerTon,
BulkPricingUnit.PerItem,
] as const;
@Entity({ schema: 'freight', name: 'wagon_allocation_bulk_loads' })
@Index(['bookingId'])
export class WagonAllocationBulkLoad extends BaseEntity {
@Column({ name: 'wagon_booking_allocation_id', type: 'uuid', unique: true })
wagonBookingAllocationId!: string;
@ManyToOne(() => WagonBookingAllocation, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'wagon_booking_allocation_id' })
allocation?: WagonBookingAllocation;
@Column({ name: 'booking_id', type: 'uuid' })
bookingId!: string;
@ManyToOne(() => Booking)
@JoinColumn({ name: 'booking_id' })
booking?: Booking;
@Column({ name: 'cargo_type_id', type: 'uuid', nullable: true })
cargoTypeId?: string | null;
@ManyToOne(() => CargoType, { nullable: true, onDelete: 'SET NULL' })
@JoinColumn({ name: 'cargo_type_id' })
cargoType?: CargoType | null;
@Column({ name: 'cargo_description', type: 'text', nullable: true })
cargoDescription?: string | null;
@Column({ name: 'pricing_unit', type: 'varchar', length: 20, default: BulkPricingUnit.PerTon })
pricingUnit!: string;
@Column({ name: 'quantity', type: 'numeric', precision: 12, scale: 3, default: 0 })
quantity!: number;
@Column({ name: 'weight_tons', type: 'numeric', precision: 10, scale: 3, default: 0 })
weightTons!: number;
@Column({ name: 'truck_plate_number', type: 'varchar', length: 32, nullable: true })
truckPlateNumber?: string | null;
}

View File

@@ -0,0 +1,54 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { BookingContainer } from '../../bookings/entities/booking-container.entity';
import { Container } from '../../container-management/entities/container.entity';
import { ContainerType } from '../../rule-engine/entities/container-type.entity';
import { WagonBookingAllocation } from './wagon-booking-allocation.entity';
@Entity({ schema: 'freight', name: 'wagon_allocation_container_items' })
@Index(['wagonBookingAllocationId'])
export class WagonAllocationContainerItem extends BaseEntity {
@Column({ name: 'wagon_booking_allocation_id', type: 'uuid' })
wagonBookingAllocationId!: string;
@ManyToOne(() => WagonBookingAllocation, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'wagon_booking_allocation_id' })
allocation?: WagonBookingAllocation;
@Column({ name: 'booking_container_id', type: 'uuid', nullable: true })
bookingContainerId?: string | null;
@ManyToOne(() => BookingContainer, { nullable: true, onDelete: 'SET NULL' })
@JoinColumn({ name: 'booking_container_id' })
bookingContainer?: BookingContainer | null;
@Column({ name: 'container_id', type: 'uuid', nullable: true })
containerId?: string | null;
@ManyToOne(() => Container, { nullable: true, onDelete: 'SET NULL' })
@JoinColumn({ name: 'container_id' })
container?: Container | null;
@Column({ name: 'container_number', type: 'varchar', length: 64, nullable: true })
containerNumber?: string | null;
@Column({ name: 'container_type_id', type: 'uuid', nullable: true })
containerTypeId?: string | null;
@ManyToOne(() => ContainerType, { nullable: true })
@JoinColumn({ name: 'container_type_id' })
containerType?: ContainerType | null;
@Column({ name: 'position_on_wagon', type: 'smallint', nullable: true })
positionOnWagon?: number | null;
@Column({ name: 'seal_number', type: 'varchar', length: 64, nullable: true })
sealNumber?: string | null;
@Column({ name: 'chassis_number', type: 'varchar', length: 64, nullable: true })
chassisNumber?: string | null;
@Column({ name: 'gross_weight_tons', type: 'numeric', precision: 10, scale: 3, nullable: true })
grossWeightTons?: number | null;
}

View File

@@ -1,8 +1,22 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { AllocationLoadType, AllocationStatus } from '@edr/types';
import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm';
import { Booking } from '../../bookings/entities/booking.entity';
import { TrainSetWagon } from '../../train-sets/entities/train-set-wagon.entity';
import { WagonAllocationContainerItem } from './wagon-allocation-container-item.entity';
export const ALLOCATION_LOAD_TYPES = [
AllocationLoadType.Container,
AllocationLoadType.Bulk,
] as const;
export const ALLOCATION_STATUSES = [
AllocationStatus.Planned,
AllocationStatus.Reserved,
AllocationStatus.Loaded,
AllocationStatus.Departed,
] as const;
@Entity({ schema: 'freight', name: 'wagon_booking_allocations' })
@Index(['trainSetWagonId', 'bookingId'])
@@ -23,4 +37,19 @@ export class WagonBookingAllocation extends BaseEntity {
@Column({ name: 'allocated_weight_tons', type: 'numeric', precision: 10, scale: 3 })
allocatedWeightTons!: number;
@Column({ name: 'load_type', type: 'varchar', length: 20, nullable: true })
loadType?: string | null;
@Column({ name: 'status', type: 'varchar', length: 20, default: 'PLANNED' })
status!: string;
@Column({ name: 'confirmed_at', type: 'timestamptz', nullable: true })
confirmedAt?: Date | null;
@Column({ name: 'confirmed_by_user_id', type: 'uuid', nullable: true })
confirmedByUserId?: string | null;
@OneToMany(() => WagonAllocationContainerItem, (item) => item.allocation)
containerItems?: WagonAllocationContainerItem[];
}

View File

@@ -1,7 +1,7 @@
import { BaseRepository } from '@edr/api-common';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { DeepPartial, EntityManager, In, Repository } from 'typeorm';
import { TrainScheduleBooking } from './entities/train-schedule-booking.entity';
@@ -13,4 +13,38 @@ export class TrainScheduleBookingsRepository extends BaseRepository<TrainSchedul
) {
super(repository);
}
private repo(manager?: EntityManager) {
return manager ? manager.getRepository(TrainScheduleBooking) : this.repository;
}
async createMany(
records: DeepPartial<TrainScheduleBooking>[],
manager?: EntityManager,
): Promise<TrainScheduleBooking[]> {
if (!records.length) return [];
const repo = this.repo(manager);
return repo.save(repo.create(records));
}
async deleteByScheduleAndBooking(
trainScheduleId: string,
bookingId: string,
manager?: EntityManager,
): Promise<void> {
await this.repo(manager).delete({ trainScheduleId, bookingId });
}
async existsForBooking(bookingId: string, manager?: EntityManager): Promise<boolean> {
const count = await this.repo(manager).count({ where: { bookingId } });
return count > 0;
}
findByBookingIds(bookingIds: string[], manager?: EntityManager): Promise<TrainScheduleBooking[]> {
if (!bookingIds.length) return Promise.resolve([]);
return this.repo(manager).find({
where: { bookingId: In(bookingIds) },
select: { id: true, bookingId: true, trainScheduleId: true },
});
}
}

View File

@@ -3,22 +3,38 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { TrainScheduleBooking } from './entities/train-schedule-booking.entity';
import { TrainSchedule } from './entities/train-schedule.entity';
import { WagonAllocationBulkLoad } from './entities/wagon-allocation-bulk-load.entity';
import { WagonAllocationContainerItem } from './entities/wagon-allocation-container-item.entity';
import { WagonBookingAllocation } from './entities/wagon-booking-allocation.entity';
import { TrainScheduleBookingsRepository } from './train-schedule-bookings.repository';
import { TrainSchedulesRepository } from './train-schedules.repository';
import { WagonAllocationBulkLoadsRepository } from './wagon-allocation-bulk-loads.repository';
import { WagonAllocationContainerItemsRepository } from './wagon-allocation-container-items.repository';
import { WagonBookingAllocationsRepository } from './wagon-booking-allocations.repository';
@Module({
imports: [TypeOrmModule.forFeature([TrainSchedule, TrainScheduleBooking, WagonBookingAllocation])],
imports: [
TypeOrmModule.forFeature([
TrainSchedule,
TrainScheduleBooking,
WagonBookingAllocation,
WagonAllocationContainerItem,
WagonAllocationBulkLoad,
]),
],
providers: [
TrainSchedulesRepository,
TrainScheduleBookingsRepository,
WagonBookingAllocationsRepository,
WagonAllocationContainerItemsRepository,
WagonAllocationBulkLoadsRepository,
],
exports: [
TrainSchedulesRepository,
TrainScheduleBookingsRepository,
WagonBookingAllocationsRepository,
WagonAllocationContainerItemsRepository,
WagonAllocationBulkLoadsRepository,
],
})
export class TrainSchedulesModule {}

View File

@@ -1,9 +1,9 @@
import { BaseRepository } from '@edr/api-common';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { EntityManager, Repository } from 'typeorm';
import { TrainSchedule } from './entities/train-schedule.entity';
import { TrainSchedule, TrainScheduleStatus } from './entities/train-schedule.entity';
@Injectable()
export class TrainSchedulesRepository extends BaseRepository<TrainSchedule> {
@@ -13,4 +13,48 @@ export class TrainSchedulesRepository extends BaseRepository<TrainSchedule> {
) {
super(repository);
}
private repo(manager?: EntityManager) {
return manager ? manager.getRepository(TrainSchedule) : this.repository;
}
findByIdWithFullGraph(id: string, manager?: EntityManager): Promise<TrainSchedule | null> {
return this.repo(manager).findOne({
where: { id },
relations: {
route: true,
trainSet: {
locomotive: true,
wagons: {
wagonType: true,
physicalWagon: true,
allocations: {
booking: { company: true, bookingContainers: { containerType: true } },
containerItems: true,
},
},
},
originStation: true,
destinationStation: true,
scheduleBookings: {
booking: {
company: true,
originYard: true,
destinationYard: true,
bookingContainers: { containerType: true },
cargoType: true,
},
},
},
});
}
async updateStatus(
id: string,
status: TrainScheduleStatus,
extra?: Partial<TrainSchedule>,
manager?: EntityManager,
): Promise<void> {
await this.repo(manager).update(id, { status, ...extra } as never);
}
}

View File

@@ -0,0 +1,36 @@
import { BaseRepository } from '@edr/api-common';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { DeepPartial, EntityManager, In, Repository } from 'typeorm';
import { WagonAllocationBulkLoad } from './entities/wagon-allocation-bulk-load.entity';
@Injectable()
export class WagonAllocationBulkLoadsRepository extends BaseRepository<WagonAllocationBulkLoad> {
constructor(
@InjectRepository(WagonAllocationBulkLoad)
repository: Repository<WagonAllocationBulkLoad>,
) {
super(repository);
}
private repo(manager?: EntityManager) {
return manager
? manager.getRepository(WagonAllocationBulkLoad)
: this.repository;
}
async createMany(
items: DeepPartial<WagonAllocationBulkLoad>[],
manager?: EntityManager,
): Promise<WagonAllocationBulkLoad[]> {
if (!items.length) return [];
const repo = this.repo(manager);
return repo.save(repo.create(items));
}
async deleteByAllocationIds(allocationIds: string[], manager?: EntityManager): Promise<void> {
if (!allocationIds.length) return;
await this.repo(manager).delete({ wagonBookingAllocationId: In(allocationIds) });
}
}

View File

@@ -0,0 +1,36 @@
import { BaseRepository } from '@edr/api-common';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { DeepPartial, EntityManager, In, Repository } from 'typeorm';
import { WagonAllocationContainerItem } from './entities/wagon-allocation-container-item.entity';
@Injectable()
export class WagonAllocationContainerItemsRepository extends BaseRepository<WagonAllocationContainerItem> {
constructor(
@InjectRepository(WagonAllocationContainerItem)
repository: Repository<WagonAllocationContainerItem>,
) {
super(repository);
}
private repo(manager?: EntityManager) {
return manager
? manager.getRepository(WagonAllocationContainerItem)
: this.repository;
}
async createMany(
items: DeepPartial<WagonAllocationContainerItem>[],
manager?: EntityManager,
): Promise<WagonAllocationContainerItem[]> {
if (!items.length) return [];
const repo = this.repo(manager);
return repo.save(repo.create(items));
}
async deleteByAllocationIds(allocationIds: string[], manager?: EntityManager): Promise<void> {
if (!allocationIds.length) return;
await this.repo(manager).delete({ wagonBookingAllocationId: In(allocationIds) });
}
}

View File

@@ -1,7 +1,7 @@
import { BaseRepository } from '@edr/api-common';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { DeepPartial, EntityManager, Repository } from 'typeorm';
import { WagonBookingAllocation } from './entities/wagon-booking-allocation.entity';
@@ -13,4 +13,43 @@ export class WagonBookingAllocationsRepository extends BaseRepository<WagonBooki
) {
super(repository);
}
private repo(manager?: EntityManager) {
return manager ? manager.getRepository(WagonBookingAllocation) : this.repository;
}
async createMany(
records: DeepPartial<WagonBookingAllocation>[],
manager?: EntityManager,
): Promise<WagonBookingAllocation[]> {
if (!records.length) return [];
const repo = this.repo(manager);
return repo.save(repo.create(records));
}
findByScheduleId(trainScheduleId: string, manager?: EntityManager): Promise<WagonBookingAllocation[]> {
return this.repo(manager)
.createQueryBuilder('allocation')
.innerJoin('allocation.trainSetWagon', 'wagon')
.innerJoin('wagon.trainSet', 'trainSet')
.innerJoin('trainSet.trainSchedule', 'schedule')
.where('schedule.id = :trainScheduleId', { trainScheduleId })
.leftJoinAndSelect('allocation.booking', 'booking')
.getMany();
}
async deleteByTrainSetId(trainSetId: string, manager?: EntityManager): Promise<string[]> {
const allocations = await this.repo(manager)
.createQueryBuilder('allocation')
.innerJoin('allocation.trainSetWagon', 'wagon')
.where('wagon.train_set_id = :trainSetId', { trainSetId })
.select(['allocation.id'])
.getMany();
const ids = allocations.map((a) => a.id);
if (ids.length) {
await this.repo(manager).delete(ids);
}
return ids;
}
}

View File

@@ -0,0 +1,21 @@
import { deriveScheduleDirection } from './derive-schedule-direction.util';
describe('deriveScheduleDirection', () => {
it('returns IMPORT when origin is Djibouti', () => {
expect(
deriveScheduleDirection({ country: 'Djibouti' }, { country: 'Ethiopia' }),
).toBe('IMPORT');
});
it('returns EXPORT when destination is Djibouti and origin is not', () => {
expect(
deriveScheduleDirection({ country: 'Ethiopia' }, { country: 'Djibouti' }),
).toBe('EXPORT');
});
it('returns DOMESTIC for intra-Ethiopia routes', () => {
expect(
deriveScheduleDirection({ country: 'Ethiopia' }, { country: 'Ethiopia' }),
).toBe('DOMESTIC');
});
});

View File

@@ -0,0 +1,19 @@
import type { ScheduleTradeDirection } from '@edr/types';
type YardLike = { country?: string | null };
export function deriveScheduleDirection(
originYard: YardLike,
destinationYard: YardLike,
): ScheduleTradeDirection {
const originCountry = originYard.country?.trim();
const destinationCountry = destinationYard.country?.trim();
if (originCountry === 'Djibouti') {
return 'IMPORT';
}
if (destinationCountry === 'Djibouti' && originCountry !== 'Djibouti') {
return 'EXPORT';
}
return 'DOMESTIC';
}

View File

@@ -0,0 +1,86 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
ArrayMinSize,
IsArray,
IsBoolean,
IsInt,
IsNumber,
IsOptional,
IsString,
IsUUID,
Min,
ValidateNested,
} from 'class-validator';
export class ContainerPlacementDto {
@ApiProperty({ format: 'uuid' })
@IsUUID()
bookingContainerId!: string;
@ApiProperty({ minimum: 0 })
@IsInt()
@Min(0)
unitIndex!: number;
@ApiProperty({ minimum: 1 })
@IsInt()
@Min(1)
sequenceNo!: number;
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID()
containerId?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
containerNumber?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
sealNumber?: string;
}
export class AssignBookingsDto {
@ApiProperty({ type: [String] })
@IsArray()
@ArrayMinSize(1)
@IsUUID('4', { each: true })
bookingIds!: string[];
@ApiPropertyOptional({ description: 'Bypass soft hold and overweight warnings' })
@IsOptional()
@IsBoolean()
forceAssign?: boolean;
@ApiPropertyOptional({ type: [ContainerPlacementDto] })
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => ContainerPlacementDto)
containerPlacements?: ContainerPlacementDto[];
@ApiPropertyOptional({ description: 'Maximum total booking weight allowed on this train' })
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(1)
maxTrainWeightTons?: number;
@ApiPropertyOptional({ description: 'Maximum total wagon length allowed on this train' })
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(1)
maxTrainLengthMeters?: number;
@ApiPropertyOptional({ description: 'Maximum wagons allowed on this train' })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
maxWagonsPerTrain?: number;
}

View File

@@ -1,5 +1,11 @@
<<<<<<< HEAD
import { ApiProperty } from '@nestjs/swagger';
import { ArrayMinSize, IsArray, IsDateString, IsIn, IsOptional, IsUUID } from 'class-validator';
=======
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsDateString, IsInt, IsNumber, IsOptional, IsUUID, Min } from 'class-validator';
>>>>>>> 10b011de8feafce5c0bae21594df5b36596587db
export class CreateContainerTrainScheduleDto {
@ApiProperty({ format: 'uuid' })
@@ -19,6 +25,7 @@ export class CreateContainerTrainScheduleDto {
@IsUUID()
locomotiveId!: string;
<<<<<<< HEAD
@ApiProperty({ enum: ['CONTAINER', 'BULK'], default: 'CONTAINER' })
@IsOptional()
@IsIn(['CONTAINER', 'BULK'])
@@ -37,4 +44,26 @@ export class CreateContainerTrainScheduleDto {
@ArrayMinSize(1)
@IsUUID('4', { each: true })
wagonIds?: string[];
=======
@ApiPropertyOptional({ description: 'Maximum total booking weight allowed on this train' })
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(1)
maxTrainWeightTons?: number;
@ApiPropertyOptional({ description: 'Maximum total wagon length allowed on this train' })
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(1)
maxTrainLengthMeters?: number;
@ApiPropertyOptional({ description: 'Maximum wagons allowed on this train' })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
maxWagonsPerTrain?: number;
>>>>>>> 10b011de8feafce5c0bae21594df5b36596587db
}

View File

@@ -0,0 +1,23 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsIn, IsOptional, IsUUID } from 'class-validator';
export class GetEligibleBookingsDto {
@ApiPropertyOptional({ enum: ['CONTAINER', 'BULK'] })
@IsOptional()
@IsIn(['CONTAINER', 'BULK'])
freightType?: 'CONTAINER' | 'BULK';
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID()
originStationId?: string;
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID()
destinationStationId?: string;
@ApiPropertyOptional()
@IsOptional()
schedulingStatus?: string;
}

View File

@@ -0,0 +1,18 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsOptional, IsUUID } from 'class-validator';
export class GetEligibleBulkBookingsDto {
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID()
originStationId?: string;
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID()
destinationStationId?: string;
@ApiPropertyOptional({ example: 'HOLDING' })
@IsOptional()
schedulingStatus?: string;
}

View File

@@ -1,5 +1,9 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
<<<<<<< HEAD
import { IsDateString, IsIn, IsOptional, IsUUID } from 'class-validator';
=======
import { IsOptional, IsUUID } from 'class-validator';
>>>>>>> 10b011de8feafce5c0bae21594df5b36596587db
export class GetEligibleContainerBookingsDto {
@ApiPropertyOptional({ format: 'uuid' })
@@ -12,8 +16,9 @@ export class GetEligibleContainerBookingsDto {
@IsUUID()
destinationStationId?: string;
@ApiPropertyOptional({ example: '2026-06-20T08:00:00.000Z' })
@ApiPropertyOptional({ example: 'HOLDING' })
@IsOptional()
<<<<<<< HEAD
@IsDateString()
scheduleDate?: string;
@@ -26,4 +31,7 @@ export class GetEligibleContainerBookingsDto {
@IsOptional()
@IsIn(['IMPORT', 'EXPORT', 'DOMESTIC'])
tradeDirection?: 'IMPORT' | 'EXPORT' | 'DOMESTIC';
=======
schedulingStatus?: string;
>>>>>>> 10b011de8feafce5c0bae21594df5b36596587db
}

View File

@@ -0,0 +1,22 @@
import { ApiProperty } from '@nestjs/swagger';
import { ArrayMinSize, IsArray, IsUUID, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';
export class PinWagonAssignmentDto {
@ApiProperty({ format: 'uuid' })
@IsUUID()
trainSetWagonId!: string;
@ApiProperty({ format: 'uuid' })
@IsUUID()
physicalWagonId!: string;
}
export class PinWagonsDto {
@ApiProperty({ type: [PinWagonAssignmentDto] })
@IsArray()
@ArrayMinSize(1)
@ValidateNested({ each: true })
@Type(() => PinWagonAssignmentDto)
assignments!: PinWagonAssignmentDto[];
}

View File

@@ -0,0 +1,3 @@
import { PreviewTrainScheduleDto } from './preview-train-schedule.dto';
export class PreviewBulkTrainScheduleDto extends PreviewTrainScheduleDto {}

View File

@@ -1,3 +1,4 @@
<<<<<<< HEAD
import { ApiProperty } from '@nestjs/swagger';
import { ArrayMinSize, IsArray, IsDateString, IsIn, IsOptional, IsUUID } from 'class-validator';
@@ -25,3 +26,8 @@ export class PreviewContainerTrainScheduleDto {
@IsIn(['CONTAINER', 'BULK'])
assignmentType?: 'CONTAINER' | 'BULK';
}
=======
import { PreviewTrainScheduleDto } from './preview-train-schedule.dto';
export class PreviewContainerTrainScheduleDto extends PreviewTrainScheduleDto {}
>>>>>>> 10b011de8feafce5c0bae21594df5b36596587db

View File

@@ -0,0 +1,61 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
ArrayMinSize,
IsArray,
IsDateString,
IsInt,
IsNumber,
IsOptional,
IsUUID,
Min,
} from 'class-validator';
export class PreviewTrainScheduleDto {
@ApiProperty({ type: [String] })
@IsArray()
@ArrayMinSize(1)
@IsUUID('4', { each: true })
bookingIds!: string[];
@ApiProperty({ example: '2026-06-20T08:00:00.000Z' })
@IsDateString()
scheduleDate!: string;
@ApiProperty({ format: 'uuid' })
@IsUUID()
originStationId!: string;
@ApiProperty({ format: 'uuid' })
@IsUUID()
destinationStationId!: string;
@ApiPropertyOptional({
format: 'uuid',
description: 'Allow bookings already assigned to this schedule (re-assign / reschedule)',
})
@IsOptional()
@IsUUID()
targetScheduleId?: string;
@ApiPropertyOptional({ description: 'Maximum total booking weight allowed on this train' })
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(1)
maxTrainWeightTons?: number;
@ApiPropertyOptional({ description: 'Maximum total wagon length allowed on this train' })
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(1)
maxTrainLengthMeters?: number;
@ApiPropertyOptional({ description: 'Maximum wagons allowed on this train' })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
maxWagonsPerTrain?: number;
}

View File

@@ -0,0 +1,40 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsInt, IsNumber, IsOptional, Min } from 'class-validator';
export class UpdateTrainSchedulingGlobalRulesDto {
@ApiPropertyOptional({ example: 760 })
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(1)
maxTrainLengthMeters?: number;
@ApiPropertyOptional({ example: 3500 })
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(1)
maxTrainWeightTons?: number;
@ApiPropertyOptional({ example: 53 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
maxWagonsPerTrain?: number;
@ApiPropertyOptional({ example: 30 })
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(0.001)
max20ftContainerWeightTons?: number;
@ApiPropertyOptional({ example: 10 })
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(0)
max20ftPairWeightDiffTons?: number;
}

View File

@@ -0,0 +1,44 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity } from 'typeorm';
@Entity({ schema: 'freight', name: 'train_scheduling_global_rules' })
export class TrainSchedulingGlobalRules extends BaseEntity {
@Column({
name: 'max_train_length_meters',
type: 'numeric',
precision: 10,
scale: 2,
default: 760,
})
maxTrainLengthMeters!: number;
@Column({
name: 'max_train_weight_tons',
type: 'numeric',
precision: 10,
scale: 3,
default: 3500,
})
maxTrainWeightTons!: number;
@Column({ name: 'max_wagons_per_train', type: 'int', default: 53 })
maxWagonsPerTrain!: number;
@Column({
name: 'max_20ft_container_weight_tons',
type: 'numeric',
precision: 8,
scale: 3,
default: 30,
})
max20ftContainerWeightTons!: number;
@Column({
name: 'max_20ft_pair_weight_diff_tons',
type: 'numeric',
precision: 8,
scale: 3,
default: 10,
})
max20ftPairWeightDiffTons!: number;
}

View File

@@ -0,0 +1,127 @@
import { Booking } from '../bookings/entities/booking.entity';
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
import {
computeFleetAvailability,
selectBookingsWithinFleetCap,
sortBookingsForScheduling,
summarizeFleetWarnings,
wagonsRequiredForBooking,
} from './fleet-plan.util';
import { buildContainerWagonPlan, type WagonPlanSlot } from './wagon-plan.util';
const nw5: WagonType = {
id: 'wt-nw5',
code: 'NW5',
name: 'Flat Wagon',
capacityTons: 70,
lengthMeters: 14,
maxWagonsPerTrain: 53,
supportedLoadTypes: ['CONTAINER'],
isActive: true,
supportsContainer: true,
} as WagonType;
const makeBooking = (
id: string,
extra: Partial<Booking> = {},
): Booking =>
({
id,
reference: id,
freightType: 'CONTAINER',
isGovernment: false,
priorityScore: 0,
scheduledDate: new Date('2026-06-20T08:00:00.000Z'),
cargoTotalWeightVgm: 50,
bookingContainers: [{ id: `${id}-line`, quantity: 2, wagonsRequired: 1, vgmPerUnitTons: 25 }],
...extra,
}) as Booking;
describe('fleet-plan.util', () => {
it('sorts bookings government first, then priority, then date', () => {
const bookings = [
makeBooking('late', { scheduledDate: new Date('2026-06-22T08:00:00.000Z') }),
makeBooking('gov', { isGovernment: true, priorityScore: 0 }),
makeBooking('prio', { priorityScore: 10 }),
];
const sorted = sortBookingsForScheduling(bookings);
expect(sorted.map((b) => b.id)).toEqual(['gov', 'prio', 'late']);
});
it('computes fleet availability with shortfall', () => {
const plan: WagonPlanSlot[] = buildContainerWagonPlan(
[
makeBooking('b1', {
bookingContainers: [
{ id: 'b1-line', quantity: 4, wagonsRequired: 2, vgmPerUnitTons: 25 } as never,
],
}),
],
nw5,
);
const fleetByTypeId = new Map([[nw5.id, 1]]);
const rows = computeFleetAvailability(plan, fleetByTypeId, new Map([[nw5.id, 'NW5']]));
const nw5Row = rows.find((r) => r.wagonTypeCode === 'NW5');
expect(nw5Row?.needed).toBe(2);
expect(nw5Row?.available).toBe(1);
expect(nw5Row?.shortfall).toBe(1);
});
it('defers lower-priority bookings when fleet is insufficient', () => {
const high = makeBooking('high', {
priorityScore: 100,
bookingContainers: [
{ id: 'high-line', quantity: 2, wagonsRequired: 2, vgmPerUnitTons: 25 } as never,
],
});
const low = makeBooking('low', {
priorityScore: 1,
bookingContainers: [
{ id: 'low-line', quantity: 2, wagonsRequired: 2, vgmPerUnitTons: 25 } as never,
],
});
const fleet = new Map([[nw5.id, 2]]);
const { fitting, deferred } = selectBookingsWithinFleetCap(
[low, high],
fleet,
() => nw5.id,
);
expect(fitting.map((b) => b.id)).toEqual(['high']);
expect(deferred).toHaveLength(1);
expect(deferred[0]?.id).toBe('low');
expect(deferred[0]?.reason).toContain('2');
});
it('summarizes fleet shortage warnings', () => {
const warnings = summarizeFleetWarnings(
[
{
wagonTypeId: nw5.id,
wagonTypeCode: 'NW5',
needed: 5,
available: 2,
shortfall: 3,
},
],
[{ id: 'b1', reference: 'BKG-1', reason: 'No wagons' }],
);
expect(warnings.some((w) => w.includes('Fleet shortage'))).toBe(true);
expect(warnings.some((w) => w.includes('deferred'))).toBe(true);
});
it('counts wagons required per booking from container lines', () => {
const booking = makeBooking('b1', {
bookingContainers: [
{ id: 'b1-line-0', quantity: 2, wagonsRequired: 1, vgmPerUnitTons: 25 } as never,
{ id: 'b1-line-1', quantity: 1, wagonsRequired: 1, vgmPerUnitTons: 25 } as never,
],
});
expect(wagonsRequiredForBooking(booking)).toBe(2);
});
});

View File

@@ -0,0 +1,168 @@
import type { Booking } from '../bookings/entities/booking.entity';
import type { WagonType } from '../wagon-types/entities/wagon-type.entity';
import {
buildBulkWagonPlan,
buildContainerWagonPlan,
buildMixedWagonPlan,
roundTons,
type WagonPlanSlot,
} from './wagon-plan.util';
export type FleetAvailabilityRow = {
wagonTypeId: string;
wagonTypeCode: string;
needed: number;
available: number;
shortfall: number;
};
export type DeferredBookingRow = {
id: string;
reference: string;
reason: string;
};
export function sortBookingsForScheduling(bookings: Booking[]): Booking[] {
return [...bookings].sort((a, b) => {
const govDiff = Number(Boolean(b.isGovernment)) - Number(Boolean(a.isGovernment));
if (govDiff !== 0) return govDiff;
const priorityDiff = (b.priorityScore ?? 0) - (a.priorityScore ?? 0);
if (priorityDiff !== 0) return priorityDiff;
return new Date(a.scheduledDate).getTime() - new Date(b.scheduledDate).getTime();
});
}
export function wagonsRequiredForBooking(booking: Booking, bulkWagonCapacity?: number): number {
if (booking.freightType === 'BULK') {
const weight = Number(booking.cargoTotalWeightVgm ?? 0);
const capacity = bulkWagonCapacity && bulkWagonCapacity > 0 ? bulkWagonCapacity : 1;
return Math.max(1, Math.ceil(weight / capacity));
}
const lineSlots = (booking.bookingContainers ?? []).reduce(
(sum, line) => sum + Number(line.wagonsRequired ?? 0),
0,
);
return Math.max(1, lineSlots);
}
export function countSlotsByType(wagonPlan: WagonPlanSlot[]): Map<string, { code: string; count: number }> {
const map = new Map<string, { code: string; count: number }>();
for (const slot of wagonPlan) {
const existing = map.get(slot.wagonTypeId) ?? { code: slot.wagonTypeCode, count: 0 };
existing.count += 1;
map.set(slot.wagonTypeId, existing);
}
return map;
}
export function computeFleetAvailability(
demandPlan: WagonPlanSlot[],
fleetByTypeId: Map<string, number>,
fleetTypeCodes: Map<string, string>,
): FleetAvailabilityRow[] {
const neededByType = countSlotsByType(demandPlan);
const typeIds = new Set([...neededByType.keys(), ...fleetByTypeId.keys()]);
return [...typeIds].map((wagonTypeId) => {
const needed = neededByType.get(wagonTypeId)?.count ?? 0;
const available = fleetByTypeId.get(wagonTypeId) ?? 0;
return {
wagonTypeId,
wagonTypeCode:
neededByType.get(wagonTypeId)?.code ??
fleetTypeCodes.get(wagonTypeId) ??
wagonTypeId,
needed,
available,
shortfall: Math.max(0, needed - available),
};
}).filter((row) => row.needed > 0 || row.available > 0);
}
export function selectBookingsWithinFleetCap(
bookings: Booking[],
fleetByTypeId: Map<string, number>,
resolveWagonTypeId: (booking: Booking) => string,
bulkWagonCapacity?: number,
): { fitting: Booking[]; deferred: DeferredBookingRow[] } {
const remaining = new Map(fleetByTypeId);
const fitting: Booking[] = [];
const deferred: DeferredBookingRow[] = [];
for (const booking of sortBookingsForScheduling(bookings)) {
const typeId = resolveWagonTypeId(booking);
const needed = wagonsRequiredForBooking(booking, bulkWagonCapacity);
const available = remaining.get(typeId) ?? 0;
if (available >= needed) {
remaining.set(typeId, available - needed);
fitting.push(booking);
continue;
}
deferred.push({
id: booking.id,
reference: booking.reference,
reason:
available > 0
? `Needs ${needed} wagons but only ${available} available for this type`
: `No available wagons for required type (${needed} needed)`,
});
}
return { fitting, deferred };
}
export function buildCappedWagonPlan(params: {
bookings: Booking[];
resolvedMode: 'CONTAINER' | 'BULK' | 'MIXED';
containerWagonType: WagonType;
bulkWagonType: WagonType;
}): WagonPlanSlot[] {
const { bookings, resolvedMode, containerWagonType, bulkWagonType } = params;
if (resolvedMode === 'MIXED') {
const containerBookings = bookings.filter((b) => b.freightType === 'CONTAINER');
const bulkBookings = bookings.filter((b) => b.freightType === 'BULK');
return buildMixedWagonPlan(
containerBookings,
bulkBookings,
containerWagonType,
bulkWagonType,
);
}
if (resolvedMode === 'BULK') {
return buildBulkWagonPlan(bookings, bulkWagonType);
}
return buildContainerWagonPlan(bookings, containerWagonType);
}
export function summarizeFleetWarnings(
fleetAvailability: FleetAvailabilityRow[],
deferred: DeferredBookingRow[],
): string[] {
const warnings: string[] = [];
for (const row of fleetAvailability.filter((r) => r.shortfall > 0)) {
warnings.push(
`Fleet shortage: need ${row.needed} ${row.wagonTypeCode}, only ${row.available} available (short ${row.shortfall})`,
);
}
if (deferred.length) {
warnings.push(
`${deferred.length} booking(s) deferred to next train due to insufficient fleet wagons`,
);
}
return warnings;
}
export function totalAssignedWeight(bookings: Booking[]): number {
return roundTons(bookings.reduce((sum, b) => sum + Number(b.cargoTotalWeightVgm ?? 0), 0));
}

View File

@@ -1,17 +1,27 @@
import {
Body,
Controller,
Delete,
Get,
Param,
ParseUUIDPipe,
Patch,
Post,
Query,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { TrainSchedulingManage, TrainSchedulingView } from '../../common/booking-guards';
import { AssignBookingsDto } from './dto/assign-bookings.dto';
import { CreateContainerTrainScheduleDto } from './dto/create-container-train-schedule.dto';
import { GetEligibleBookingsDto } from './dto/get-eligible-bookings.dto';
import { GetEligibleBulkBookingsDto } from './dto/get-eligible-bulk-bookings.dto';
import { GetEligibleContainerBookingsDto } from './dto/get-eligible-container-bookings.dto';
import { PinWagonsDto } from './dto/pin-wagons.dto';
import { PreviewBulkTrainScheduleDto } from './dto/preview-bulk-train-schedule.dto';
import { PreviewContainerTrainScheduleDto } from './dto/preview-container-train-schedule.dto';
import { PreviewTrainScheduleDto } from './dto/preview-train-schedule.dto';
import { UpdateTrainSchedulingGlobalRulesDto } from './dto/update-train-scheduling-global-rules.dto';
import { TrainSchedulingService } from './train-scheduling.service';
@ApiTags('train-scheduling')
@@ -20,45 +30,183 @@ import { TrainSchedulingService } from './train-scheduling.service';
export class TrainSchedulingController {
constructor(private readonly trainSchedulingService: TrainSchedulingService) {}
@Get('global-rules')
@TrainSchedulingView()
@ApiOperation({ summary: 'Get global train scheduling rules (singleton)' })
getGlobalRules() {
return this.trainSchedulingService.getTrainSchedulingGlobalRules();
}
@Patch('global-rules')
@TrainSchedulingManage()
@ApiOperation({ summary: 'Update global train scheduling rules (singleton)' })
updateGlobalRules(@Body() dto: UpdateTrainSchedulingGlobalRulesDto) {
return this.trainSchedulingService.updateTrainSchedulingGlobalRules(dto);
}
@Get('eligible-bookings')
@TrainSchedulingView()
@ApiOperation({ summary: 'List eligible bookings (container and/or bulk)' })
getEligibleBookings(@Query() query: GetEligibleBookingsDto) {
return this.trainSchedulingService.getEligibleBookings(query);
}
@Get('container/eligible-bookings')
@TrainSchedulingView()
@ApiOperation({ summary: 'List eligible container bookings' })
getEligibleContainerBookings(@Query() query: GetEligibleContainerBookingsDto) {
return this.trainSchedulingService.getEligibleContainerBookings(query);
}
@Get('bulk/eligible-bookings')
@TrainSchedulingView()
@ApiOperation({ summary: 'List eligible bulk bookings' })
getEligibleBulkBookings(@Query() query: GetEligibleBulkBookingsDto) {
return this.trainSchedulingService.getEligibleBulkBookings(query);
}
@Post('preview')
@TrainSchedulingView()
@ApiOperation({ summary: 'Preview a mixed-capable train schedule' })
previewTrainSchedule(@Body() dto: PreviewTrainScheduleDto) {
return this.trainSchedulingService.previewTrainSchedule(dto);
}
@Post('container/preview')
@TrainSchedulingView()
@ApiOperation({ summary: 'Preview a container train schedule' })
previewContainerTrainSchedule(@Body() dto: PreviewContainerTrainScheduleDto) {
return this.trainSchedulingService.previewContainerTrainSchedule(dto);
}
@Post('bulk/preview')
@TrainSchedulingView()
@ApiOperation({ summary: 'Preview a bulk train schedule' })
previewBulkTrainSchedule(@Body() dto: PreviewBulkTrainScheduleDto) {
return this.trainSchedulingService.previewBulkTrainSchedule(dto);
}
@Post('container/schedules')
@TrainSchedulingManage()
@ApiOperation({ summary: 'Create a container train schedule' })
createContainerTrainSchedule(@Body() dto: CreateContainerTrainScheduleDto) {
return this.trainSchedulingService.createContainerTrainSchedule(dto);
}
@Post('bulk/schedules')
@TrainSchedulingManage()
@ApiOperation({ summary: 'Create a bulk train schedule' })
createBulkTrainSchedule(@Body() dto: CreateContainerTrainScheduleDto) {
return this.trainSchedulingService.createContainerTrainSchedule(dto);
}
@Post('schedules/:id/assign-bookings')
@TrainSchedulingManage()
@ApiOperation({ summary: 'Assign bookings to a train schedule (mixed-capable)' })
assignBookings(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: AssignBookingsDto,
) {
return this.trainSchedulingService.assignBookingsToSchedule(id, dto);
}
@Post('container/schedules/:id/assign-bookings')
@TrainSchedulingManage()
@ApiOperation({ summary: 'Assign container bookings to a train schedule' })
assignContainerBookings(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: AssignBookingsDto,
) {
return this.trainSchedulingService.assignBookingsToSchedule(id, dto, 'CONTAINER');
}
@Post('bulk/schedules/:id/assign-bookings')
@TrainSchedulingManage()
@ApiOperation({ summary: 'Assign bulk bookings to a train schedule' })
assignBulkBookings(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: AssignBookingsDto,
) {
return this.trainSchedulingService.assignBookingsToSchedule(id, dto, 'BULK');
}
@Delete('schedules/:id/bookings/:bookingId')
@TrainSchedulingManage()
@ApiOperation({ summary: 'Unassign a booking from a train schedule' })
unassignBooking(
@Param('id', ParseUUIDPipe) id: string,
@Param('bookingId', ParseUUIDPipe) bookingId: string,
) {
return this.trainSchedulingService.unassignBooking(id, bookingId);
}
@Post('schedules/:id/pin-wagons')
@TrainSchedulingManage()
@ApiOperation({ summary: 'Pin physical wagons to train set slots' })
pinWagons(@Param('id', ParseUUIDPipe) id: string, @Body() dto: PinWagonsDto) {
return this.trainSchedulingService.pinWagons(id, dto);
}
@Post('schedules/:id/finalize')
@TrainSchedulingManage()
@ApiOperation({ summary: 'Finalize a draft train schedule' })
finalizeSchedule(@Param('id', ParseUUIDPipe) id: string) {
return this.trainSchedulingService.finalizeSchedule(id);
}
@Post('schedules/:id/dispatch')
@TrainSchedulingManage()
@ApiOperation({ summary: 'Dispatch a scheduled train' })
dispatchSchedule(@Param('id', ParseUUIDPipe) id: string) {
return this.trainSchedulingService.dispatchSchedule(id);
}
@Get('container/schedules')
@TrainSchedulingView()
@ApiOperation({ summary: 'List container train schedules' })
getContainerTrainSchedules() {
return this.trainSchedulingService.getContainerTrainSchedules();
}
@Get('bulk/schedules')
@TrainSchedulingView()
@ApiOperation({ summary: 'List bulk train schedules' })
getBulkTrainSchedules() {
return this.trainSchedulingService.getContainerTrainSchedules();
}
@Get('container/schedules/:id')
@TrainSchedulingView()
@ApiOperation({ summary: 'Get container train schedule detail' })
getContainerTrainScheduleById(@Param('id', ParseUUIDPipe) id: string) {
return this.trainSchedulingService.getContainerTrainScheduleById(id);
}
@Get('bulk/schedules/:id')
@TrainSchedulingView()
@ApiOperation({ summary: 'Get bulk train schedule detail' })
getBulkTrainScheduleById(@Param('id', ParseUUIDPipe) id: string) {
return this.trainSchedulingService.getContainerTrainScheduleById(id);
}
@Post('container/schedules/:id/cancel')
@TrainSchedulingManage()
@ApiOperation({ summary: 'Cancel container train schedule' })
cancelTrainSchedule(@Param('id', ParseUUIDPipe) id: string) {
return this.trainSchedulingService.cancelTrainSchedule(id);
}
<<<<<<< HEAD
@Post('container/schedules/:id/publish')
@ApiOperation({ summary: 'Publish container train schedule' })
publishTrainSchedule(@Param('id', ParseUUIDPipe) id: string) {
return this.trainSchedulingService.publishTrainSchedule(id);
=======
@Post('bulk/schedules/:id/cancel')
@TrainSchedulingManage()
@ApiOperation({ summary: 'Cancel bulk train schedule' })
cancelBulkTrainSchedule(@Param('id', ParseUUIDPipe) id: string) {
return this.trainSchedulingService.cancelTrainSchedule(id);
>>>>>>> 10b011de8feafce5c0bae21594df5b36596587db
}
}

View File

@@ -2,13 +2,19 @@ import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { BookingsModule } from '../bookings/bookings.module';
import { Booking } from '../bookings/entities/booking.entity';
import { BookingContainer } from '../bookings/entities/booking-container.entity';
import { Container } from '../container-management/entities/container.entity';
import { LocomotivesModule } from '../locomotives/locomotives.module';
import { RuleEngineModule } from '../rule-engine/rule-engine.module';
import { Locomotive } from '../locomotives/entities/locomotive.entity';
import { Route } from '../routes/entities/route.entity';
import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity';
import { TrainSet } from '../train-sets/entities/train-set.entity';
import { TrainSetsModule } from '../train-sets/train-sets.module';
import { TrainSchedulesModule } from '../train-schedules/train-schedules.module';
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
import { WagonTypesModule } from '../wagon-types/wagon-types.module';
import { Wagon } from '../wagons/entities/wagon.entity';
<<<<<<< HEAD
import { TrainSet } from '../train-sets/entities/train-set.entity';
import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity';
import { TrainSetsModule } from '../train-sets/train-sets.module';
@@ -17,29 +23,31 @@ import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity
import { WagonBookingAllocation } from '../train-schedules/entities/wagon-booking-allocation.entity';
import { TrainSchedulesModule } from '../train-schedules/train-schedules.module';
import { Yard } from '../rule-engine/entities/yard.entity';
=======
import { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-rules.entity';
>>>>>>> 10b011de8feafce5c0bae21594df5b36596587db
import { TrainSchedulingController } from './train-scheduling.controller';
import { TrainSchedulingService } from './train-scheduling.service';
@Module({
imports: [
TypeOrmModule.forFeature([
Booking,
BookingContainer,
Locomotive,
WagonType,
Wagon,
TrainSet,
TrainSetWagon,
TrainSchedule,
TrainScheduleBooking,
WagonBookingAllocation,
Yard,
Route,
Wagon,
Container,
TrainSchedulingGlobalRules,
]),
BookingsModule,
LocomotivesModule,
WagonTypesModule,
TrainSetsModule,
TrainSchedulesModule,
RuleEngineModule,
],
controllers: [TrainSchedulingController],
providers: [TrainSchedulingService],

View File

@@ -1,5 +1,10 @@
import { ConflictException } from '@nestjs/common';
import { WagonReadiness, WagonStatus } from '@edr/types';
import { Wagon } from '../wagons/entities/wagon.entity';
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity';
import { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-rules.entity';
import { TrainSchedulingService } from './train-scheduling.service';
const nw5 = {
@@ -11,6 +16,7 @@ const nw5 = {
maxWagonsPerTrain: 53,
supportedLoadTypes: ['CONTAINER'],
isActive: true,
supportsContainer: true,
};
const locomotive = {
@@ -21,15 +27,29 @@ const locomotive = {
status: 'AVAILABLE',
};
const cw3 = {
id: 'wagon-type-bulk',
code: 'CW3',
name: 'Covered Wagon',
capacityTons: 60,
lengthMeters: 14,
maxWagonsPerTrain: 53,
supportedLoadTypes: ['BULK'],
isActive: true,
supportsContainer: false,
};
const makeBooking = (
id: string,
reference: string,
weight: number,
quantity: number,
containerCode: string,
wagonsRequired: number,
scheduledDate = '2026-06-20T08:00:00.000Z',
originYardId = 'yard-origin',
destinationYardId = 'yard-destination',
extra: Record<string, unknown> = {},
) => ({
id,
reference,
@@ -38,76 +58,183 @@ const makeBooking = (
scheduledDate: new Date(scheduledDate),
originYardId,
destinationYardId,
<<<<<<< HEAD
status: 'APPROVED',
customer: { companyName: 'Demo Customer' },
=======
status: 'PAID',
schedulingStatus: 'HOLDING',
holdExpiresAt: new Date(Date.now() + 60 * 60 * 1000),
company: { companyName: 'Demo Customer' },
>>>>>>> 10b011de8feafce5c0bae21594df5b36596587db
originYard: { label: 'Djibouti', code: 'DJIBOUTI' },
destinationYard: { label: 'Addis Ababa', code: 'ADDIS_ABABA' },
bookingContainers: [
{
id: `${id}-line`,
containerTypeId: 'ct-1',
quantity,
wagonsRequired,
vgmPerUnitTons: weight / quantity,
isOverweight: false,
containerType: { code: containerCode, label: containerCode },
},
],
...extra,
});
describe('TrainSchedulingService', () => {
let service: TrainSchedulingService;
let dataSource: {
getRepository: jest.Mock;
transaction: jest.Mock;
};
let locomotivesRepository: {
findById: jest.Mock;
};
let wagonTypesRepository: {
findAll: jest.Mock;
};
let dataSource: { getRepository: jest.Mock; transaction: jest.Mock };
let bookingsRepository: Record<string, jest.Mock>;
let locomotivesRepository: { findById: jest.Mock; findAll: jest.Mock };
let wagonTypesRepository: { findAll: jest.Mock };
let trainSchedulesRepository: Record<string, jest.Mock>;
let trainScheduleBookingsRepository: Record<string, jest.Mock>;
let wagonBookingAllocationsRepository: Record<string, jest.Mock>;
let wagonAllocationContainerItemsRepository: Record<string, jest.Mock>;
let wagonAllocationBulkLoadsRepository: Record<string, jest.Mock>;
beforeEach(() => {
dataSource = {
getRepository: jest.fn(),
transaction: jest.fn(),
dataSource = { getRepository: jest.fn(), transaction: jest.fn() };
bookingsRepository = {
findEligibleForScheduling: jest.fn(),
findByIdsForScheduling: jest.fn(),
updateSchedulingFields: jest.fn(),
};
locomotivesRepository = {
locomotivesRepository = { findById: jest.fn(), findAll: jest.fn() };
wagonTypesRepository = { findAll: jest.fn() };
trainSchedulesRepository = {
findById: jest.fn(),
};
wagonTypesRepository = {
findByIdWithFullGraph: jest.fn(),
findAll: jest.fn(),
updateStatus: jest.fn(),
};
trainScheduleBookingsRepository = {
findByBookingIds: jest.fn(),
createMany: jest.fn(),
deleteByScheduleAndBooking: jest.fn(),
};
wagonBookingAllocationsRepository = {
deleteByTrainSetId: jest.fn().mockResolvedValue([]),
createMany: jest.fn(),
};
wagonAllocationContainerItemsRepository = {
createMany: jest.fn(),
deleteByAllocationIds: jest.fn(),
findAll: jest.fn().mockResolvedValue([]),
};
wagonAllocationBulkLoadsRepository = {
createMany: jest.fn(),
deleteByAllocationIds: jest.fn(),
findAll: jest.fn().mockResolvedValue([]),
};
service = new TrainSchedulingService(
dataSource as never,
bookingsRepository as never,
locomotivesRepository as never,
wagonTypesRepository as never,
trainSchedulesRepository as never,
trainScheduleBookingsRepository as never,
wagonBookingAllocationsRepository as never,
wagonAllocationContainerItemsRepository as never,
wagonAllocationBulkLoadsRepository as never,
);
const defaultFleetWagons = [
...Array.from({ length: 100 }, (_, index) => ({
id: `wagon-nw5-${index}`,
wagonTypeId: nw5.id,
status: WagonStatus.Available,
readiness: WagonReadiness.ImportReady,
currentTrainScheduleId: null,
})),
...Array.from({ length: 50 }, (_, index) => ({
id: `wagon-cw3-${index}`,
wagonTypeId: cw3.id,
status: WagonStatus.Available,
readiness: WagonReadiness.ImportReady,
currentTrainScheduleId: null,
})),
];
dataSource.getRepository.mockImplementation((entity: unknown) => {
if (entity === TrainSchedulingGlobalRules) {
return { find: jest.fn().mockResolvedValue([]) };
}
if (entity === Wagon) {
return { find: jest.fn().mockResolvedValue(defaultFleetWagons) };
}
if (entity === WagonType) {
return { find: jest.fn().mockResolvedValue([nw5, cw3]) };
}
return { find: jest.fn().mockResolvedValue([]), findOne: jest.fn().mockResolvedValue(null) };
});
});
it('computes the expected valid preview for Group A', async () => {
it('returns fleet availability and defers bookings when fleet is insufficient', async () => {
const bookings = [
makeBooking('b1', 'BKG-CONT-001', 500, 20, '40FT'),
makeBooking('b2', 'BKG-CONT-002', 300, 10, '20FT'),
makeBooking('b3', 'BKG-CONT-003', 450, 15, '40FT'),
makeBooking('b1', 'BKG-CONT-001', 500, 20, '40FT', 20),
makeBooking('b2', 'BKG-CONT-002', 300, 10, '20FT', 10),
];
wagonTypesRepository.findAll.mockResolvedValue([nw5]);
dataSource.getRepository.mockImplementation((entity: { name?: string }) => {
if (entity?.name === 'Booking') {
return { find: jest.fn().mockResolvedValue(bookings) };
}
if (entity?.name === 'TrainScheduleBooking') {
bookingsRepository.findByIdsForScheduling.mockResolvedValue(bookings);
trainScheduleBookingsRepository.findByBookingIds.mockResolvedValue([]);
locomotivesRepository.findAll.mockResolvedValue([locomotive]);
const availableWagons = Array.from({ length: 15 }, (_, index) => ({
id: `wagon-${index}`,
wagonTypeId: nw5.id,
status: WagonStatus.Available,
readiness: WagonReadiness.ImportReady,
currentTrainScheduleId: null,
}));
dataSource.getRepository.mockImplementation((entity: unknown) => {
if (entity === TrainSchedulingGlobalRules) {
return { find: jest.fn().mockResolvedValue([]) };
}
if (entity?.name === 'Locomotive') {
return {
count: jest.fn().mockResolvedValue(2),
find: jest.fn().mockResolvedValue([locomotive]),
};
if (entity === Wagon) {
return { find: jest.fn().mockResolvedValue(availableWagons) };
}
throw new Error(`Unexpected repository ${entity?.name}`);
if (entity === WagonType) {
return { find: jest.fn().mockResolvedValue([nw5]) };
}
return { find: jest.fn().mockResolvedValue([]), findOne: jest.fn().mockResolvedValue(null) };
});
const result = await service.previewContainerTrainSchedule({
bookingIds: bookings.map((booking) => booking.id),
bookingIds: bookings.map((b) => b.id),
scheduleDate: '2026-06-20T08:00:00.000Z',
originStationId: 'yard-origin',
destinationStationId: 'yard-destination',
});
expect(result.fleetAvailability?.length).toBeGreaterThan(0);
expect(result.fleetAvailability?.[0]?.shortfall).toBeGreaterThan(0);
expect(result.deferredBookings?.length).toBeGreaterThan(0);
expect(result.summary.wagonsNeeded).toBeLessThan(30);
expect(result.warnings.some((w) => w.includes('Fleet shortage') || w.includes('deferred'))).toBe(
true,
);
});
it('computes slot-based preview for Group A', async () => {
const bookings = [
makeBooking('b1', 'BKG-CONT-001', 500, 20, '40FT', 20),
makeBooking('b2', 'BKG-CONT-002', 300, 10, '20FT', 10),
makeBooking('b3', 'BKG-CONT-003', 450, 15, '40FT', 15),
];
wagonTypesRepository.findAll.mockResolvedValue([nw5]);
bookingsRepository.findByIdsForScheduling.mockResolvedValue(bookings);
trainScheduleBookingsRepository.findByBookingIds.mockResolvedValue([]);
locomotivesRepository.findAll.mockResolvedValue([locomotive]);
const result = await service.previewContainerTrainSchedule({
bookingIds: bookings.map((b) => b.id),
scheduleDate: '2026-06-20T08:00:00.000Z',
originStationId: 'yard-origin',
destinationStationId: 'yard-destination',
@@ -115,40 +242,50 @@ describe('TrainSchedulingService', () => {
expect(result.valid).toBe(true);
expect(result.violations).toEqual([]);
expect(result.summary).toEqual({
totalBookings: 3,
totalWeightTons: 1250,
wagonType: 'NW5',
wagonsNeeded: 18,
totalLengthMeters: 252,
});
expect(result.wagonPlan).toHaveLength(18);
expect(result.wagonPlan[0]?.allocations[0]).toEqual({
bookingId: 'b1',
bookingReference: 'BKG-CONT-001',
allocatedWeightTons: 70,
expect(result.summary.wagonsNeeded).toBe(45);
expect(result.wagonPlan).toHaveLength(45);
});
it('returns soft hold warnings without forceAssign', async () => {
const bookings = [makeBooking('b7', 'BKG-CONT-007', 120, 2, '40FT', 2)];
wagonTypesRepository.findAll.mockResolvedValue([nw5]);
bookingsRepository.findByIdsForScheduling.mockResolvedValue(bookings);
trainScheduleBookingsRepository.findByBookingIds.mockResolvedValue([]);
locomotivesRepository.findAll.mockResolvedValue([locomotive]);
const result = await service.previewContainerTrainSchedule({
bookingIds: ['b7'],
scheduleDate: '2026-06-20T08:00:00.000Z',
originStationId: 'yard-origin',
destinationStationId: 'yard-destination',
});
expect(result.warnings.length).toBeGreaterThan(0);
expect(result.warnings[0]).toContain('soft hold window');
});
it('flags the overweight booking as invalid', async () => {
const bookings = [makeBooking('b6', 'BKG-CONT-006', 3600, 80, '40FT')];
const bookings = [
makeBooking('b6', 'BKG-CONT-006', 3600, 80, '40FT', 80, undefined, undefined, undefined, {
bookingContainers: [
{
id: 'b6-line',
containerTypeId: 'ct-1',
quantity: 80,
wagonsRequired: 80,
vgmPerUnitTons: 45,
isOverweight: true,
containerType: { code: '40FT', label: '40FT' },
},
],
}),
];
wagonTypesRepository.findAll.mockResolvedValue([nw5]);
dataSource.getRepository.mockImplementation((entity: { name?: string }) => {
if (entity?.name === 'Booking') {
return { find: jest.fn().mockResolvedValue(bookings) };
}
if (entity?.name === 'TrainScheduleBooking') {
return { find: jest.fn().mockResolvedValue([]) };
}
if (entity?.name === 'Locomotive') {
return {
count: jest.fn().mockResolvedValue(1),
find: jest.fn().mockResolvedValue([locomotive]),
};
}
throw new Error(`Unexpected repository ${entity?.name}`);
});
bookingsRepository.findByIdsForScheduling.mockResolvedValue(bookings);
trainScheduleBookingsRepository.findByBookingIds.mockResolvedValue([]);
locomotivesRepository.findAll.mockResolvedValue([locomotive]);
const result = await service.previewContainerTrainSchedule({
bookingIds: ['b6'],
@@ -158,36 +295,77 @@ describe('TrainSchedulingService', () => {
});
expect(result.valid).toBe(false);
expect(result.summary.totalWeightTons).toBe(3600);
expect(result.violations).toContain(
'Total booking weight 3600T exceeds max train weight 3500T',
expect(result.violations.some((v) => v.includes('overweight'))).toBe(true);
});
it('allows preview when bookings are already on the target schedule', async () => {
const bookings = [makeBooking('b1', 'BKG-CONT-001', 500, 20, '40FT', 20)];
wagonTypesRepository.findAll.mockResolvedValue([nw5]);
bookingsRepository.findByIdsForScheduling.mockResolvedValue(bookings);
trainScheduleBookingsRepository.findByBookingIds.mockResolvedValue([
{ bookingId: 'b1', trainScheduleId: 'sched-target' },
]);
trainSchedulesRepository.findById.mockResolvedValue({
id: 'sched-target',
direction: 'IMPORT',
});
locomotivesRepository.findAll.mockResolvedValue([locomotive]);
const result = await service.previewContainerTrainSchedule({
bookingIds: ['b1'],
scheduleDate: '2026-06-20T08:00:00.000Z',
originStationId: 'yard-origin',
destinationStationId: 'yard-destination',
targetScheduleId: 'sched-target',
});
expect(result.violations).not.toContain(
'One or more selected bookings are already assigned to a train schedule',
);
expect(result.valid).toBe(true);
});
it('allows preview when selected bookings are on different schedule dates', async () => {
const bookings = [
makeBooking('b1', 'BKG-CONT-001', 500, 20, '40FT', 20, '2026-06-20T08:00:00.000Z'),
makeBooking('b2', 'BKG-CONT-002', 300, 10, '20FT', 10, '2026-06-21T14:00:00.000Z'),
];
wagonTypesRepository.findAll.mockResolvedValue([nw5]);
bookingsRepository.findByIdsForScheduling.mockResolvedValue(bookings);
trainScheduleBookingsRepository.findByBookingIds.mockResolvedValue([]);
locomotivesRepository.findAll.mockResolvedValue([locomotive]);
const result = await service.previewContainerTrainSchedule({
bookingIds: bookings.map((b) => b.id),
scheduleDate: '2026-06-20T08:00:00.000Z',
originStationId: 'yard-origin',
destinationStationId: 'yard-destination',
});
expect(result.violations).not.toContain(
'Selected bookings must share the same schedule date',
);
expect(result.valid).toBe(true);
});
it('rejects bookings that are not in assignable status', async () => {
const bookings = [
<<<<<<< HEAD
{
...makeBooking('b7', 'BKG-CONT-007', 120, 2, '40FT'),
status: 'PAID',
},
=======
{ ...makeBooking('b7', 'BKG-CONT-007', 120, 2, '40FT', 2), status: 'APPROVED' },
>>>>>>> 10b011de8feafce5c0bae21594df5b36596587db
];
wagonTypesRepository.findAll.mockResolvedValue([nw5]);
dataSource.getRepository.mockImplementation((entity: { name?: string }) => {
if (entity?.name === 'Booking') {
return { find: jest.fn().mockResolvedValue(bookings) };
}
if (entity?.name === 'TrainScheduleBooking') {
return { find: jest.fn().mockResolvedValue([]) };
}
if (entity?.name === 'Locomotive') {
return {
count: jest.fn().mockResolvedValue(1),
find: jest.fn().mockResolvedValue([locomotive]),
};
}
throw new Error(`Unexpected repository ${entity?.name}`);
});
bookingsRepository.findByIdsForScheduling.mockResolvedValue(bookings);
trainScheduleBookingsRepository.findByBookingIds.mockResolvedValue([]);
locomotivesRepository.findAll.mockResolvedValue([locomotive]);
const result = await service.previewContainerTrainSchedule({
bookingIds: ['b7'],
@@ -239,13 +417,16 @@ describe('TrainSchedulingService', () => {
};
jest.spyOn(service, 'selectOrValidateLocomotive').mockResolvedValue(locomotive as never);
dataSource.getRepository.mockImplementation((entity: { name?: string }) => {
if (entity?.name === 'Route') {
dataSource.getRepository.mockImplementation((entity: unknown) => {
if ((entity as { name?: string })?.name === 'Route') {
return { findOne: jest.fn().mockResolvedValue(route) };
}
throw new Error(`Unexpected repository ${entity?.name}`);
if (entity === TrainSchedulingGlobalRules) {
return { find: jest.fn().mockResolvedValue([]) };
}
throw new Error(`Unexpected repository ${(entity as { name?: string })?.name}`);
});
jest.spyOn(service, 'getContainerTrainScheduleById').mockResolvedValue({ id: 'schedule-1' } as never);
trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue({ id: 'schedule-1' });
dataSource.transaction.mockImplementation(async (callback: (tx: typeof manager) => Promise<string>) =>
callback(manager),
);
@@ -259,7 +440,62 @@ describe('TrainSchedulingService', () => {
expect(trainSetRepo.save).toHaveBeenCalled();
expect(trainScheduleRepo.save).toHaveBeenCalled();
expect(lockedLocomotiveRepo.update).toHaveBeenCalledWith('loc-1', { status: 'ASSIGNED' });
expect(result).toEqual({ id: 'schedule-1' });
expect(result.id).toBe('schedule-1');
});
it('previews mixed container and bulk bookings', async () => {
const containerBooking = makeBooking('c1', 'BKG-CONT', 100, 2, '40FT', 2);
const bulkBooking = {
id: 'b1',
reference: 'BKG-BULK',
freightType: 'BULK',
cargoTotalWeightVgm: 120,
scheduledDate: new Date('2026-06-20T08:00:00.000Z'),
originYardId: 'yard-origin',
destinationYardId: 'yard-destination',
status: 'PAID',
bookingContainers: [],
cargoType: { code: 'COFFEE' },
};
wagonTypesRepository.findAll.mockImplementation(async ({ where }: { where?: { code?: string } }) => {
if (where?.code === 'NW5') return [nw5];
return [nw5, cw3];
});
bookingsRepository.findByIdsForScheduling.mockResolvedValue([containerBooking, bulkBooking]);
trainScheduleBookingsRepository.findByBookingIds.mockResolvedValue([]);
locomotivesRepository.findAll.mockResolvedValue([locomotive]);
const result = await service.previewTrainSchedule({
bookingIds: ['c1', 'b1'],
scheduleDate: '2026-06-20T08:00:00.000Z',
originStationId: 'yard-origin',
destinationStationId: 'yard-destination',
});
expect(result.valid).toBe(true);
expect(result.summary.wagonType).toBe('MIXED');
expect(result.wagonPlan.length).toBeGreaterThan(2);
expect(result.containerUnits).toHaveLength(2);
});
it('previews container bookings without requiring placements', async () => {
const bookings = [makeBooking('c2', 'BKG-CONT-2', 50, 1, '40FT', 1)];
wagonTypesRepository.findAll.mockResolvedValue([nw5]);
bookingsRepository.findByIdsForScheduling.mockResolvedValue(bookings);
trainScheduleBookingsRepository.findByBookingIds.mockResolvedValue([]);
locomotivesRepository.findAll.mockResolvedValue([locomotive]);
const result = await service.previewTrainSchedule({
bookingIds: ['c2'],
scheduleDate: '2026-06-20T08:00:00.000Z',
originStationId: 'yard-origin',
destinationStationId: 'yard-destination',
});
expect(result.valid).toBe(true);
expect(result.containerUnits).toHaveLength(1);
});
it('rejects create when the locked locomotive is no longer available', async () => {
@@ -296,4 +532,48 @@ describe('TrainSchedulingService', () => {
}),
).rejects.toBeInstanceOf(ConflictException);
});
it('rejects pin when wagon readiness does not match schedule direction', async () => {
const scheduleId = 'sched-1';
const slotId = 'slot-1';
trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue({
id: scheduleId,
status: 'DRAFT',
direction: 'IMPORT',
trainSet: {
wagons: [{ id: slotId, physicalWagonId: null }],
},
});
const manager = {
getRepository: jest.fn((entity: { name?: string }) => {
if (entity === Wagon) {
return {
findOne: jest.fn().mockResolvedValue({
id: 'wagon-1',
wagonNumber: 'WGN-001',
status: WagonStatus.Available,
readiness: WagonReadiness.ExportReady,
currentTrainScheduleId: null,
}),
update: jest.fn(),
};
}
if (entity === TrainSetWagon) {
return { update: jest.fn() };
}
throw new Error(`Unexpected repository ${entity?.name}`);
}),
};
dataSource.transaction.mockImplementation(async (callback: (tx: typeof manager) => Promise<void>) =>
callback(manager),
);
await expect(
service.pinWagons(scheduleId, {
assignments: [{ trainSetWagonId: slotId, physicalWagonId: 'wagon-1' }],
}),
).rejects.toBeInstanceOf(ConflictException);
});
});

View File

@@ -0,0 +1,202 @@
import { AllocationLoadType } from '@edr/types';
import { Booking } from '../bookings/entities/booking.entity';
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
import {
buildBulkWagonPlan,
buildContainerWagonPlan,
buildMixedWagonPlan,
expandBookingContainerUnits,
expandContainerItems,
roundTons,
sumWagonsRequired,
validate20ftContainerRules,
validateContainerPlacements,
} from './wagon-plan.util';
const nw5: WagonType = {
id: 'wt-nw5',
code: 'NW5',
name: 'Flat Wagon',
capacityTons: 70,
lengthMeters: 14,
maxWagonsPerTrain: 53,
supportedLoadTypes: ['CONTAINER'],
isActive: true,
supportsContainer: true,
} as WagonType;
const cw3: WagonType = {
id: 'wt-cw3',
code: 'CW3',
name: 'Covered Wagon',
capacityTons: 60,
lengthMeters: 14,
maxWagonsPerTrain: 53,
supportedLoadTypes: ['BULK'],
isActive: true,
supportsContainer: false,
} as WagonType;
const makeContainerBooking = (
id: string,
lines: Array<{ quantity: number; wagonsRequired: number; vgmPerUnitTons?: number }>,
): Booking =>
({
id,
reference: id,
freightType: 'CONTAINER',
cargoTotalWeightVgm: lines.reduce(
(sum, line) => sum + line.quantity * (line.vgmPerUnitTons ?? 25),
0,
),
bookingContainers: lines.map((line, index) => ({
id: `${id}-line-${index}`,
containerTypeId: `ct-${index}`,
quantity: line.quantity,
wagonsRequired: line.wagonsRequired,
vgmPerUnitTons: line.vgmPerUnitTons ?? 25,
})),
}) as Booking;
describe('wagon-plan.util', () => {
it('uses slot-based planning: 2×20ft = 1 wagon slot', () => {
const booking = makeContainerBooking('b1', [{ quantity: 2, wagonsRequired: 1 }]);
const plan = buildContainerWagonPlan([booking], nw5);
expect(plan).toHaveLength(1);
expect(plan[0]?.allocations[0]?.loadType).toBe(AllocationLoadType.Container);
});
it('uses slot-based planning: 1×40ft = 1 wagon slot', () => {
const booking = makeContainerBooking('b2', [{ quantity: 1, wagonsRequired: 1 }]);
const plan = buildContainerWagonPlan([booking], nw5);
expect(plan).toHaveLength(1);
});
it('sums wagons across multiple container lines', () => {
const booking = makeContainerBooking('b3', [
{ quantity: 2, wagonsRequired: 1 },
{ quantity: 1, wagonsRequired: 1 },
]);
expect(sumWagonsRequired(booking)).toBe(2);
const plan = buildContainerWagonPlan([booking], nw5);
expect(plan).toHaveLength(2);
});
it('6×20ft containers = 3 wagon slots (2 per wagon)', () => {
// 20ft containers have wagonsPerUnit = 0.5, so 6 * 0.5 = 3 wagons
const booking = makeContainerBooking('b6x20', [{ quantity: 6, wagonsRequired: 3 }]);
expect(sumWagonsRequired(booking)).toBe(3);
const plan = buildContainerWagonPlan([booking], nw5);
expect(plan).toHaveLength(3);
// Verify sequence numbers are 1, 2, 3
expect(plan.map((s) => s.sequenceNo)).toEqual([1, 2, 3]);
});
it('expands container items per quantity', () => {
const booking = makeContainerBooking('b4', [{ quantity: 3, wagonsRequired: 3 }]);
const items = expandContainerItems(booking, 'alloc-1');
expect(items).toHaveLength(3);
expect(items[0]?.wagonBookingAllocationId).toBe('alloc-1');
});
it('rounds tons to three decimal places', () => {
expect(roundTons(1.23456)).toBe(1.235);
expect(roundTons('bad')).toBe(0);
});
it('builds mixed plan with container block before bulk', () => {
const containerBooking = makeContainerBooking('c1', [{ quantity: 2, wagonsRequired: 2 }]);
const bulkBooking = {
id: 'b1',
reference: 'BKG-BULK',
freightType: 'BULK',
cargoTotalWeightVgm: 120,
bookingContainers: [],
} as unknown as Booking;
const plan = buildMixedWagonPlan([containerBooking], [bulkBooking], nw5, cw3);
expect(plan).toHaveLength(4);
expect(plan[0]?.slotLoadType).toBe('CONTAINER');
expect(plan[2]?.slotLoadType).toBe('BULK');
expect(plan.map((s) => s.sequenceNo)).toEqual([1, 2, 3, 4]);
});
it('expands booking container units for UI rows', () => {
const booking = makeContainerBooking('c2', [{ quantity: 3, wagonsRequired: 3 }]);
const units = expandBookingContainerUnits([booking]);
expect(units).toHaveLength(3);
expect(units[1]?.unitIndex).toBe(1);
expect(units[1]?.bookingContainerId).toBe('c2-line-0');
});
it('validates required placements per container unit', () => {
const booking = makeContainerBooking('c3', [{ quantity: 2, wagonsRequired: 2 }]);
const plan = buildContainerWagonPlan([booking], nw5);
const violations = validateContainerPlacements([booking], plan, []);
expect(violations.some((v) => v.includes('required'))).toBe(true);
const units = expandBookingContainerUnits([booking]);
const placements = units.map((unit, index) => ({
bookingContainerId: unit.bookingContainerId,
unitIndex: unit.unitIndex,
sequenceNo: plan[index]?.sequenceNo ?? 1,
containerNumber: `CNTR-${index + 1}`,
}));
expect(validateContainerPlacements([booking], plan, placements)).toEqual([]);
});
it('rejects 20ft container over max individual weight', () => {
const booking = makeContainerBooking('c20', [{ quantity: 2, wagonsRequired: 1, vgmPerUnitTons: 35 }]);
const units = expandBookingContainerUnits([booking]);
const placements = units.map((unit, index) => ({
bookingContainerId: unit.bookingContainerId,
unitIndex: unit.unitIndex,
sequenceNo: 1,
containerNumber: `CNTR-${index + 1}`,
}));
const violations = validate20ftContainerRules(units, placements, {
max20ftContainerWeightTons: 30,
max20ftPairWeightDiffTons: 10,
});
expect(violations.some((v) => v.includes('exceeds max 30T'))).toBe(true);
});
it('rejects 20ft pair when weight difference exceeds limit', () => {
const booking = makeContainerBooking('c21', [
{ quantity: 2, wagonsRequired: 1, vgmPerUnitTons: 25 },
]);
booking.bookingContainers![0]!.vgmPerUnitTons = 25;
const units = expandBookingContainerUnits([booking]);
units[1]!.grossWeightTons = 10;
const placements = units.map((unit) => ({
bookingContainerId: unit.bookingContainerId,
unitIndex: unit.unitIndex,
sequenceNo: 1,
containerNumber: `CNTR-${unit.unitIndex}`,
}));
const violations = validate20ftContainerRules(units, placements, {
max20ftContainerWeightTons: 30,
max20ftPairWeightDiffTons: 10,
});
expect(violations.some((v) => v.includes('weight difference'))).toBe(true);
});
it('builds bulk-only plan as degenerate mixed case', () => {
const bulkBooking = {
id: 'b2',
reference: 'BKG-BULK-2',
freightType: 'BULK',
cargoTotalWeightVgm: 60,
bookingContainers: [],
} as unknown as Booking;
const plan = buildMixedWagonPlan([], [bulkBooking], nw5, cw3);
expect(plan).toHaveLength(1);
expect(plan[0]?.slotLoadType).toBe('BULK');
expect(buildBulkWagonPlan([bulkBooking], cw3)).toHaveLength(1);
});
});

Some files were not shown because too many files have changed in this diff Show More