mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 07:51:02 +00:00
Warehouse Enhancemendt
This commit is contained in:
@@ -10,12 +10,14 @@ import {
|
||||
} from "@nestjs/common";
|
||||
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
|
||||
import { FreightAdmin } from "../../common/booking-guards";
|
||||
import { BackofficeService } from "./backoffice.service";
|
||||
import { CreateOrganizationUserDto } from "./dto/create-organization-user.dto";
|
||||
import { UpdateEmployeeUserRolesDto } from "./dto/update-employee-user-roles.dto";
|
||||
|
||||
@ApiTags("backoffice")
|
||||
@Controller("backoffice")
|
||||
@FreightAdmin()
|
||||
export class BackofficeController {
|
||||
constructor(private readonly backofficeService: BackofficeService) {}
|
||||
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { Controller, Get, Param, ParseUUIDPipe } from "@nestjs/common";
|
||||
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
|
||||
import { FreightAdmin } from "../../common/booking-guards";
|
||||
import { BillingService } from "./billing.service";
|
||||
|
||||
@ApiTags("billing")
|
||||
@Controller("billing")
|
||||
@FreightAdmin()
|
||||
export class BillingController {
|
||||
constructor(private readonly billingService: BillingService) {}
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
forwardRef,
|
||||
Inject,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { Readable } from 'stream';
|
||||
@@ -19,9 +22,13 @@ import { assertBookingStatus } from './booking-status.util';
|
||||
import { ContractViewDto } from './dto/contract-view.dto';
|
||||
import { SignContractDto } from './dto/sign-contract.dto';
|
||||
import { ContractSignerRole } from './entities/booking-contract-signature.entity';
|
||||
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
|
||||
import { SignaturesService } from '../signatures/signatures.service';
|
||||
|
||||
@Injectable()
|
||||
export class BookingContractService {
|
||||
private readonly logger = new Logger(BookingContractService.name);
|
||||
|
||||
constructor(
|
||||
private readonly bookingsRepository: BookingsRepository,
|
||||
private readonly filesService: FilesService,
|
||||
@@ -30,6 +37,9 @@ export class BookingContractService {
|
||||
private readonly viewModelBuilder: ContractViewModelBuilder,
|
||||
private readonly renderer: ContractRendererService,
|
||||
private readonly pdfService: ContractPdfService,
|
||||
@Inject(forwardRef(() => BookingBatchService))
|
||||
private readonly bookingBatchService: BookingBatchService,
|
||||
private readonly signaturesService: SignaturesService,
|
||||
) {}
|
||||
|
||||
buildContractSummary(booking: Booking): string {
|
||||
@@ -67,10 +77,16 @@ export class BookingContractService {
|
||||
return { summary };
|
||||
}
|
||||
|
||||
async getContractView(bookingId: string): Promise<ContractViewDto> {
|
||||
async getContractView(
|
||||
bookingId: string,
|
||||
viewerUserId?: string,
|
||||
): Promise<ContractViewDto> {
|
||||
const { view } = await this.viewModelBuilder.build(bookingId);
|
||||
await this.inlineSignatureImages(view.signatures);
|
||||
const html = this.renderer.render(view);
|
||||
const savedSignature = viewerUserId
|
||||
? ((await this.signaturesService.getForUser(viewerUserId)) ?? undefined)
|
||||
: undefined;
|
||||
return {
|
||||
bookingId: view.bookingId,
|
||||
reference: view.reference,
|
||||
@@ -82,6 +98,7 @@ export class BookingContractService {
|
||||
canSignStaff: view.canSignStaff,
|
||||
hasContractDocument: view.hasContractDocument,
|
||||
signatures: view.signatures,
|
||||
savedSignature,
|
||||
pricingSchedule: view.pricing as unknown as Record<string, unknown>,
|
||||
};
|
||||
}
|
||||
@@ -92,7 +109,16 @@ export class BookingContractService {
|
||||
|
||||
const templateKey = this.templateResolver.resolve(booking);
|
||||
const summary = this.buildContractSummary(booking);
|
||||
await this.upsertContractPdf(bookingId, booking.reference, templateKey);
|
||||
|
||||
// PDF rendering (Puppeteer/Chromium) is best-effort and must NOT block the contract
|
||||
// from becoming ready — the document is (re)rendered lazily on view/download.
|
||||
try {
|
||||
await this.upsertContractPdf(bookingId, booking.reference, templateKey);
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Contract PDF deferred for ${booking.reference}: ${err}. It will render on view/download once Chromium is available.`,
|
||||
);
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const updated = await this.bookingsRepository.update(bookingId, {
|
||||
@@ -177,6 +203,23 @@ export class BookingContractService {
|
||||
ipAddress: options.ipAddress ?? null,
|
||||
});
|
||||
|
||||
// Persist the just-used signature to the signer's reusable profile so they
|
||||
// don't have to redraw it on the next contract. Best-effort: a failure here
|
||||
// must never block contract execution.
|
||||
if (options.signerUserId) {
|
||||
try {
|
||||
await this.signaturesService.upsertForUser({
|
||||
userId: options.signerUserId,
|
||||
signerDisplayName: dto.signerDisplayName,
|
||||
signatureImageBase64: dto.signatureImageBase64,
|
||||
});
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Could not save reusable signature for user ${options.signerUserId}: ${err}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const updates: Record<string, unknown> = {};
|
||||
|
||||
if (role === 'CUSTOMER') {
|
||||
@@ -191,11 +234,20 @@ export class BookingContractService {
|
||||
}
|
||||
|
||||
const updated = await this.bookingsRepository.update(bookingId, updates as never);
|
||||
await this.upsertContractPdf(
|
||||
bookingId,
|
||||
booking.reference,
|
||||
booking.contractTemplateKey ?? this.templateResolver.resolve(booking),
|
||||
);
|
||||
if (role === 'STAFF' && updated?.trainScheduleId) {
|
||||
this.bookingBatchService.enqueueScheduleProcessing(updated.trainScheduleId);
|
||||
}
|
||||
try {
|
||||
await this.upsertContractPdf(
|
||||
bookingId,
|
||||
booking.reference,
|
||||
booking.contractTemplateKey ?? this.templateResolver.resolve(booking),
|
||||
);
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Signed-contract PDF deferred for ${booking.reference}: ${err}. It will render on view/download.`,
|
||||
);
|
||||
}
|
||||
return updated!;
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ export const BOOKING_LIST_TABS: ReadonlyArray<{
|
||||
statuses: readonly string[] | null;
|
||||
}> = [
|
||||
{ key: 'all', statuses: null },
|
||||
{ key: 'intake', statuses: ['SUBMITTED'] },
|
||||
{ key: 'intake', statuses: ['SUBMITTED', 'PENDING_CONSOLIDATION'] },
|
||||
{
|
||||
key: 'in_approval',
|
||||
statuses: ['PENDING_APPROVAL', 'APPROVED_PENDING_SIGNATURE'],
|
||||
@@ -28,7 +28,7 @@ export const BOOKING_LIST_TABS: ReadonlyArray<{
|
||||
{ key: 'payment', statuses: ['FULLY_EXECUTED'] },
|
||||
{
|
||||
key: 'operations',
|
||||
statuses: ['IN_TRANSIT', 'PENDING_CONSOLIDATION', 'CONSOLIDATED','PAID'],
|
||||
statuses: ['IN_TRANSIT', 'PAID'],
|
||||
},
|
||||
{ key: 'completed', statuses: ['COMPLETED'] },
|
||||
{ key: 'closed', statuses: ['REJECTED', 'CANCELLED'] },
|
||||
|
||||
@@ -5,6 +5,7 @@ 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';
|
||||
import { PaymentMethodTypeEnum } from '../payment/payments.dto';
|
||||
export interface InAppPaymentReceipt extends InAppPaymentReceiptDto { }
|
||||
|
||||
const NON_TERMINAL_STATUSES: PaymentStatus[] = [
|
||||
@@ -22,7 +23,7 @@ export class BookingPaymentService {
|
||||
|
||||
async pay(bookingId: string): Promise<{ redirectUrl: string }> {
|
||||
const booking = await this.requireBooking(bookingId);
|
||||
assertBookingStatus(booking, ['FULLY_EXECUTED', '']);
|
||||
assertBookingStatus(booking, ['FULLY_EXECUTED', 'SELECTED_FOR_BATCH', 'AWAITING_PAYMENT', '']);
|
||||
|
||||
const existing = await this.paymentService.findBookingById(bookingId);
|
||||
if (existing && NON_TERMINAL_STATUSES.includes(existing.status)) {
|
||||
@@ -34,11 +35,15 @@ export class BookingPaymentService {
|
||||
}
|
||||
}
|
||||
|
||||
const resp = await this.paymentService.initBookingTelebirr(bookingId, "web");
|
||||
const resp = await this.paymentService.initiatePayment({
|
||||
bookingId,
|
||||
method: PaymentMethodTypeEnum.TELEBIRR,
|
||||
platform: "web",
|
||||
});
|
||||
|
||||
const action = resp.clientAction as { type?: string; url?: string } | undefined;
|
||||
return {
|
||||
redirectUrl:
|
||||
resp.redirectUrl ?? "",
|
||||
redirectUrl: action?.type === "REDIRECT" ? (action.url ?? "") : "",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import { BookingPricingService } from './booking-pricing.service';
|
||||
import type { Booking } from './entities/booking.entity';
|
||||
import type { Rate } from '../rule-engine/entities/rate.entity';
|
||||
|
||||
const MOCK_CBE_RATE = 130;
|
||||
|
||||
describe('BookingPricingService — domestic corridor', () => {
|
||||
const intercityBulkUsd: Rate = {
|
||||
id: 'rate-intercity-bulk-usd',
|
||||
rateType: 'INTERCITY_BULK',
|
||||
currency: 'USD',
|
||||
rateValue: 35,
|
||||
rateUnit: 'PER_TON',
|
||||
status: 'LIVE',
|
||||
containerTypeId: null,
|
||||
} as Rate;
|
||||
|
||||
const intercityContainerUsd: Rate = {
|
||||
id: 'rate-intercity-container-usd',
|
||||
rateType: 'INTERCITY_CONTAINER',
|
||||
currency: 'USD',
|
||||
rateValue: 400,
|
||||
rateUnit: 'PER_CONTAINER',
|
||||
status: 'LIVE',
|
||||
containerTypeId: null,
|
||||
} as Rate;
|
||||
|
||||
let service: BookingPricingService;
|
||||
let bookingsRepository: { calculateWagonCount: jest.Mock };
|
||||
let ratesService: { findLiveRates: jest.Mock };
|
||||
let cbeExchangeService: { getUsdToEtbRate: jest.Mock };
|
||||
|
||||
beforeEach(() => {
|
||||
bookingsRepository = { calculateWagonCount: jest.fn().mockResolvedValue(2) };
|
||||
ratesService = {
|
||||
findLiveRates: jest.fn().mockResolvedValue([intercityBulkUsd, intercityContainerUsd]),
|
||||
};
|
||||
cbeExchangeService = {
|
||||
getUsdToEtbRate: jest.fn().mockResolvedValue(MOCK_CBE_RATE),
|
||||
};
|
||||
|
||||
service = new BookingPricingService(
|
||||
bookingsRepository as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
ratesService as never,
|
||||
{} as never,
|
||||
cbeExchangeService as never,
|
||||
);
|
||||
});
|
||||
|
||||
it('prices domestic bulk in ETB using INTERCITY_BULK USD rate × CBE exchange rate', async () => {
|
||||
const booking = {
|
||||
id: 'b-1',
|
||||
freightType: 'BULK',
|
||||
tradeDirection: 'DOMESTIC',
|
||||
paymentCurrency: 'ETB',
|
||||
cargoTotalWeightVgm: 120,
|
||||
bookingContainers: [],
|
||||
} as unknown as Booking;
|
||||
|
||||
const result = await (
|
||||
service as unknown as {
|
||||
computeBaseRailLinesWithRates: (
|
||||
b: Booking,
|
||||
input: { containers: [] },
|
||||
) => Promise<{ lineItems: Array<{ amount: number; code: string; currency: string }> }>;
|
||||
}
|
||||
).computeBaseRailLinesWithRates(booking, { containers: [] });
|
||||
|
||||
expect(result.lineItems).toHaveLength(1);
|
||||
expect(result.lineItems[0].code).toBe('INTERCITY_BULK');
|
||||
expect(result.lineItems[0].currency).toBe('ETB');
|
||||
expect(result.lineItems[0].amount).toBe(Math.round(35 * 120 * MOCK_CBE_RATE));
|
||||
});
|
||||
|
||||
it('prices domestic bulk in USD using INTERCITY_BULK USD rate directly', async () => {
|
||||
const booking = {
|
||||
id: 'b-1-usd',
|
||||
freightType: 'BULK',
|
||||
tradeDirection: 'DOMESTIC',
|
||||
paymentCurrency: 'USD',
|
||||
cargoTotalWeightVgm: 120,
|
||||
bookingContainers: [],
|
||||
} as unknown as Booking;
|
||||
|
||||
const result = await (
|
||||
service as unknown as {
|
||||
computeBaseRailLinesWithRates: (
|
||||
b: Booking,
|
||||
input: { containers: [] },
|
||||
) => Promise<{ lineItems: Array<{ amount: number; code: string; currency: string }> }>;
|
||||
}
|
||||
).computeBaseRailLinesWithRates(booking, { containers: [] });
|
||||
|
||||
expect(result.lineItems).toHaveLength(1);
|
||||
expect(result.lineItems[0].code).toBe('INTERCITY_BULK');
|
||||
expect(result.lineItems[0].currency).toBe('USD');
|
||||
expect(result.lineItems[0].amount).toBe(35 * 120);
|
||||
});
|
||||
|
||||
it('prices domestic container in ETB using INTERCITY_CONTAINER USD fallback × CBE rate', async () => {
|
||||
const booking = {
|
||||
id: 'b-2',
|
||||
freightType: 'CONTAINER',
|
||||
tradeDirection: 'DOMESTIC',
|
||||
paymentCurrency: 'ETB',
|
||||
cargoTotalWeightVgm: 50,
|
||||
bookingContainers: [],
|
||||
} as unknown as Booking;
|
||||
|
||||
const result = await (
|
||||
service as unknown as {
|
||||
computeBaseRailLinesWithRates: (
|
||||
b: Booking,
|
||||
input: {
|
||||
containers: Array<{ containerTypeId: string; quantity: number }>;
|
||||
},
|
||||
) => Promise<{ lineItems: Array<{ amount: number; code: string; currency: string }> }>;
|
||||
}
|
||||
).computeBaseRailLinesWithRates(booking, {
|
||||
containers: [{ containerTypeId: 'ct-20', quantity: 3 }],
|
||||
});
|
||||
|
||||
expect(result.lineItems.some((l) => l.code === 'INTERCITY_CONTAINER')).toBe(true);
|
||||
const line = result.lineItems.find((l) => l.code === 'INTERCITY_CONTAINER')!;
|
||||
expect(line.currency).toBe('ETB');
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import { ContainerTypesService } from '../rule-engine/services/container-types.s
|
||||
import { RatesService } from '../rule-engine/services/rates.service';
|
||||
import { ServiceTypesService } from '../rule-engine/services/service-types.service';
|
||||
import { Rate } from '../rule-engine/entities/rate.entity';
|
||||
import { CbeExchangeService } from '../cbe-exchange/cbe-exchange.service';
|
||||
import {
|
||||
AppliedCargoModifier,
|
||||
BookingEvaluationInput,
|
||||
@@ -40,6 +41,7 @@ export class BookingPricingService {
|
||||
private readonly containerTypesService: ContainerTypesService,
|
||||
private readonly ratesService: RatesService,
|
||||
private readonly serviceTypesService: ServiceTypesService,
|
||||
private readonly cbeExchangeService: CbeExchangeService,
|
||||
) {}
|
||||
|
||||
async generatePrice(bookingId: string): Promise<GeneratePriceResponseDto> {
|
||||
@@ -80,6 +82,10 @@ export class BookingPricingService {
|
||||
const evalInput = await this.buildEvalInputForBooking(booking);
|
||||
const ruleResult = await this.ruleEngineService.evaluate(evalInput);
|
||||
|
||||
const paymentCurrency = booking.paymentCurrency;
|
||||
const isEtbBooking = paymentCurrency === 'ETB';
|
||||
const usdToEtb = isEtbBooking ? await this.cbeExchangeService.getUsdToEtbRate() : 1;
|
||||
|
||||
const lineItems: PriceLineItemDto[] = [];
|
||||
let total = 0;
|
||||
|
||||
@@ -95,14 +101,16 @@ export class BookingPricingService {
|
||||
const usedRatesMap = new Map(baseRates.map((r) => [r.id, r]));
|
||||
|
||||
for (const mod of ruleResult.appliedModifiers) {
|
||||
const usdAmount = mod.calculatedAmount;
|
||||
const convertedAmount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount;
|
||||
const item: PriceLineItemDto = {
|
||||
code: mod.surchargeTypeCode,
|
||||
description: `Surcharge: ${mod.surchargeTypeCode}`,
|
||||
amount: mod.calculatedAmount,
|
||||
currency: mod.currency,
|
||||
amount: convertedAmount,
|
||||
currency: paymentCurrency,
|
||||
};
|
||||
lineItems.push(item);
|
||||
total += mod.calculatedAmount;
|
||||
total += convertedAmount;
|
||||
|
||||
const rate = rateById.get(mod.rateId);
|
||||
if (rate) usedRatesMap.set(rate.id, rate);
|
||||
@@ -174,6 +182,17 @@ export class BookingPricingService {
|
||||
};
|
||||
}),
|
||||
);
|
||||
// Wagon count is persisted per container line at booking creation; sum it.
|
||||
const totalWagons =
|
||||
booking.freightType === 'CONTAINER'
|
||||
? Math.ceil(
|
||||
(booking.bookingContainers ?? []).reduce(
|
||||
(sum, bc) => sum + Number(bc.wagonsRequired ?? 0),
|
||||
0,
|
||||
),
|
||||
)
|
||||
: 0;
|
||||
|
||||
return {
|
||||
freightType: booking.freightType as 'CONTAINER' | 'BULK',
|
||||
cargoTypeId: booking.cargoTypeId ?? null,
|
||||
@@ -184,6 +203,7 @@ export class BookingPricingService {
|
||||
isGovernment: booking.isGovernment,
|
||||
allowConsolidation: booking.allowConsolidation,
|
||||
shippingLineId: booking.shippingLineId,
|
||||
totalWagons,
|
||||
containers,
|
||||
};
|
||||
}
|
||||
@@ -263,7 +283,9 @@ export class BookingPricingService {
|
||||
evalInput: BookingEvaluationInput,
|
||||
): Promise<{ lineItems: PriceLineItemDto[]; usedRates: Rate[] }> {
|
||||
const liveRates = await this.ratesService.findLiveRates();
|
||||
const currency = booking.paymentCurrency;
|
||||
const paymentCurrency = booking.paymentCurrency;
|
||||
const isEtbBooking = paymentCurrency === 'ETB';
|
||||
const usdToEtb = isEtbBooking ? await this.cbeExchangeService.getUsdToEtbRate() : 1;
|
||||
const isBulk = booking.freightType === 'BULK';
|
||||
|
||||
const rateType =
|
||||
@@ -275,38 +297,45 @@ export class BookingPricingService {
|
||||
? isBulk
|
||||
? 'BULK_EXPORT'
|
||||
: 'CONTAINER_EXPORT'
|
||||
: 'INTERCITY_CONTAINER';
|
||||
: isBulk
|
||||
? 'INTERCITY_BULK'
|
||||
: 'INTERCITY_CONTAINER';
|
||||
|
||||
const lines: PriceLineItemDto[] = [];
|
||||
const usedRatesMap = new Map<string, Rate>();
|
||||
const wagonCount = await this.bookingsRepository.calculateWagonCount(booking.id);
|
||||
|
||||
for (const container of evalInput.containers) {
|
||||
const rate = this.pickRate(liveRates, rateType, container.containerTypeId, currency);
|
||||
const rate = this.pickRate(liveRates, rateType, container.containerTypeId, 'USD');
|
||||
if (!rate) continue;
|
||||
|
||||
usedRatesMap.set(rate.id, rate);
|
||||
const amount = this.amountForRate(rate, container.quantity, wagonCount);
|
||||
const usdAmount = this.amountForRate(rate, container.quantity, wagonCount);
|
||||
const amount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount;
|
||||
lines.push({
|
||||
code: rateType,
|
||||
description: `Base rail (${rateType})`,
|
||||
amount,
|
||||
currency: rate.currency,
|
||||
currency: paymentCurrency,
|
||||
});
|
||||
}
|
||||
|
||||
if (lines.length === 0) {
|
||||
const fallback = liveRates.find(
|
||||
(r) => r.rateType === rateType && r.currency === currency && r.status === 'LIVE',
|
||||
(r) => r.rateType === rateType && r.currency === 'USD' && r.status === 'LIVE',
|
||||
);
|
||||
if (fallback) {
|
||||
usedRatesMap.set(fallback.id, fallback);
|
||||
const amount = this.amountForRate(fallback, 1, wagonCount);
|
||||
const bulkTons = Number(booking.cargoTotalWeightVgm ?? 0);
|
||||
const quantity =
|
||||
isBulk && fallback.rateUnit === 'PER_TON' ? Math.max(bulkTons, 0) : 1;
|
||||
const usdAmount = this.amountForRate(fallback, quantity, wagonCount);
|
||||
const amount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount;
|
||||
lines.push({
|
||||
code: rateType,
|
||||
description: `Base rail (${rateType})`,
|
||||
amount,
|
||||
currency: fallback.currency,
|
||||
currency: paymentCurrency,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,28 +1,28 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { In, Not } from 'typeorm';
|
||||
import { Inject, Injectable } from "@nestjs/common";
|
||||
import { In, Not } from "typeorm";
|
||||
|
||||
import { CargoType } from '../rule-engine/entities/cargo-type.entity';
|
||||
import { ContainerType } from '../rule-engine/entities/container-type.entity';
|
||||
import { CargoType } from "../rule-engine/entities/cargo-type.entity";
|
||||
import { ContainerType } from "../rule-engine/entities/container-type.entity";
|
||||
import {
|
||||
CARGO_TYPES_REPOSITORY,
|
||||
ICargoTypesRepository,
|
||||
} from '../rule-engine/interfaces/cargo-types.repository.interface';
|
||||
} from "../rule-engine/interfaces/cargo-types.repository.interface";
|
||||
import {
|
||||
CONTAINER_TYPES_REPOSITORY,
|
||||
IContainerTypesRepository,
|
||||
} from '../rule-engine/interfaces/container-types.repository.interface';
|
||||
} from "../rule-engine/interfaces/container-types.repository.interface";
|
||||
import {
|
||||
IServiceTypesRepository,
|
||||
SERVICE_TYPES_REPOSITORY,
|
||||
} from '../rule-engine/interfaces/service-types.repository.interface';
|
||||
} from "../rule-engine/interfaces/service-types.repository.interface";
|
||||
import {
|
||||
IShippingLinesRepository,
|
||||
SHIPPING_LINES_REPOSITORY,
|
||||
} from '../rule-engine/interfaces/shipping-lines.repository.interface';
|
||||
} from "../rule-engine/interfaces/shipping-lines.repository.interface";
|
||||
import {
|
||||
IYardsRepository,
|
||||
YARDS_REPOSITORY,
|
||||
} from '../rule-engine/interfaces/yards.repository.interface';
|
||||
} from "../rule-engine/interfaces/yards.repository.interface";
|
||||
import {
|
||||
BookingReferenceCargoTypeChildDto,
|
||||
BookingReferenceCargoTypeGroupDto,
|
||||
@@ -32,9 +32,9 @@ import {
|
||||
BookingReferenceServiceDto,
|
||||
BookingReferenceShippingLineDto,
|
||||
BookingReferenceYardDto,
|
||||
} from './dto/booking-reference-data.dto';
|
||||
} from "./dto/booking-reference-data.dto";
|
||||
|
||||
const LEGACY_YARD_CODES = ['LEGACY_ORIGIN', 'LEGACY_DEST'] as const;
|
||||
const LEGACY_YARD_CODES = ["LEGACY_ORIGIN", "LEGACY_DEST"] as const;
|
||||
|
||||
export function buildCargoTypeTree(
|
||||
rows: CargoType[],
|
||||
@@ -42,13 +42,16 @@ export function buildCargoTypeTree(
|
||||
const active = rows.filter((r) => r.isActive);
|
||||
const parents = active
|
||||
.filter((r) => !r.parentGroupId)
|
||||
.sort((a, b) => a.displayOrder - b.displayOrder || a.code.localeCompare(b.code));
|
||||
.sort(
|
||||
(a, b) => a.displayOrder - b.displayOrder || a.code.localeCompare(b.code),
|
||||
);
|
||||
|
||||
return parents.map((parent) => {
|
||||
const children = active
|
||||
.filter((r) => r.parentGroupId === parent.id)
|
||||
.sort(
|
||||
(a, b) => a.displayOrder - b.displayOrder || a.code.localeCompare(b.code),
|
||||
(a, b) =>
|
||||
a.displayOrder - b.displayOrder || a.code.localeCompare(b.code),
|
||||
)
|
||||
.map(
|
||||
(child): BookingReferenceCargoTypeChildDto => ({
|
||||
@@ -79,14 +82,14 @@ export function groupContainersBySize(
|
||||
|
||||
for (const ct of active) {
|
||||
const sizeKey =
|
||||
ct.sizeFt != null && ct.sizeFt > 0 ? `${ct.sizeFt}ft` : 'other';
|
||||
ct.sizeFt != null && ct.sizeFt > 0 ? `${ct.sizeFt}ft` : "other";
|
||||
const list = bySize.get(sizeKey) ?? [];
|
||||
list.push(ct);
|
||||
bySize.set(sizeKey, list);
|
||||
}
|
||||
|
||||
const sortSizeKey = (key: string): number => {
|
||||
if (key === 'other') return Number.MAX_SAFE_INTEGER;
|
||||
if (key === "other") return Number.MAX_SAFE_INTEGER;
|
||||
const n = parseInt(key, 10);
|
||||
return Number.isNaN(n) ? Number.MAX_SAFE_INTEGER - 1 : n;
|
||||
};
|
||||
@@ -126,7 +129,7 @@ export class BookingReferenceDataService {
|
||||
private readonly shippingLinesRepository: IShippingLinesRepository,
|
||||
@Inject(CARGO_TYPES_REPOSITORY)
|
||||
private readonly cargoTypesRepository: ICargoTypesRepository,
|
||||
) {}
|
||||
) { }
|
||||
|
||||
async getReferenceData(): Promise<BookingReferenceDataDto> {
|
||||
const [yards, containerTypes, serviceTypes, shippingLines, cargoTypes] =
|
||||
@@ -136,23 +139,23 @@ export class BookingReferenceDataService {
|
||||
isActive: true,
|
||||
code: Not(In([...LEGACY_YARD_CODES])),
|
||||
},
|
||||
order: { displayOrder: 'ASC', code: 'ASC' },
|
||||
order: { displayOrder: "ASC", code: "ASC" },
|
||||
}),
|
||||
this.containerTypesRepository.findAll({
|
||||
where: { isActive: true },
|
||||
order: { displayOrder: 'ASC', code: 'ASC' },
|
||||
order: { displayOrder: "ASC", code: "ASC" },
|
||||
}),
|
||||
this.serviceTypesRepository.findAll({
|
||||
where: { isActive: true },
|
||||
order: { displayOrder: 'ASC', code: 'ASC' },
|
||||
order: { displayOrder: "ASC", code: "ASC" },
|
||||
}),
|
||||
this.shippingLinesRepository.findAll({
|
||||
where: { isActive: true },
|
||||
order: { label: 'ASC', code: 'ASC' },
|
||||
order: { label: "ASC", code: "ASC" },
|
||||
}),
|
||||
this.cargoTypesRepository.findAll({
|
||||
where: { isActive: true },
|
||||
order: { displayOrder: 'ASC', code: 'ASC' },
|
||||
order: { displayOrder: "ASC", code: "ASC" },
|
||||
}),
|
||||
]);
|
||||
|
||||
@@ -168,9 +171,8 @@ export class BookingReferenceDataService {
|
||||
containers: groupContainersBySize(containerTypes),
|
||||
service: serviceTypes.map(
|
||||
(s): BookingReferenceServiceDto => ({
|
||||
id: s.id,
|
||||
name: s.serviceName,
|
||||
code: s.code,
|
||||
...s,
|
||||
}),
|
||||
),
|
||||
shipping_line: shippingLines.map(
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { BadRequestException, forwardRef, Inject, Injectable } from '@nestjs/common';
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
forwardRef,
|
||||
Inject,
|
||||
Injectable,
|
||||
} from '@nestjs/common';
|
||||
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
||||
|
||||
import { assertCanApproveBookingStep } from '../../common/freight-permission.util';
|
||||
@@ -184,6 +190,16 @@ export class BookingTransitionService {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
assertBookingStatus(booking, ['SUBMITTED']);
|
||||
|
||||
// Consolidation gate: a booking whose containers don't fill whole wagons
|
||||
// cannot be accepted until it is paired with a complementary booking.
|
||||
const gate = await this.bookingsService.resolveConsolidationGate(bookingId);
|
||||
if (gate.blocked) {
|
||||
throw new ConflictException(
|
||||
gate.message ??
|
||||
'Booking requires consolidation and cannot be accepted until a partner is found.',
|
||||
);
|
||||
}
|
||||
|
||||
await this.ruleEngineService.instantiateApprovalSteps(bookingId, {
|
||||
freightType: booking.freightType as 'CONTAINER' | 'BULK',
|
||||
cargoTypeId: booking.cargoTypeId,
|
||||
|
||||
@@ -54,7 +54,7 @@ import {
|
||||
type AuthUserPayload,
|
||||
resolveAuthUserId,
|
||||
} from '../../common/resolve-auth-user-id';
|
||||
import { assertFreightPermission } from '../../common/freight-permission.util';
|
||||
import { assertFreightPermission, hasFreightPermission } from '../../common/freight-permission.util';
|
||||
|
||||
@ApiTags('bookings')
|
||||
@Controller('bookings')
|
||||
@@ -73,7 +73,7 @@ export class BookingsController {
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiOperation({ summary: 'Create a new freight booking (DRAFT)' })
|
||||
@ApiBody({ type: CreateBookingDto })
|
||||
create(
|
||||
async create(
|
||||
@Body() dto: CreateBookingDto,
|
||||
@UploadedFiles() files: Express.Multer.File[],
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
@@ -81,7 +81,22 @@ export class BookingsController {
|
||||
if (dto.isGovernment) {
|
||||
assertFreightPermission(user, FREIGHT_PERMS.bookings.staffAccept);
|
||||
}
|
||||
return this.bookingsService.create(dto, files ?? [], user?.id);
|
||||
const result = await this.bookingsService.create(dto, files ?? [], user?.id);
|
||||
|
||||
// Staff-created commercial bookings skip the draft stage: auto generate-price + submit.
|
||||
const isStaff = hasFreightPermission(user, FREIGHT_PERMS.bookings.staffAccept);
|
||||
if (isStaff && !dto.isGovernment) {
|
||||
try {
|
||||
await this.pricingService.generatePrice(result.booking.id);
|
||||
await this.transitionService.submit(result.booking.id);
|
||||
const submitted = await this.bookingsService.findById(result.booking.id);
|
||||
return { booking: submitted, warnings: result.warnings };
|
||||
} catch {
|
||||
// If auto-pricing/submit fails, fall back to the DRAFT so staff can finish manually.
|
||||
return result;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@@ -113,6 +128,20 @@ export class BookingsController {
|
||||
return this.bookingsService.getListSummary(filter);
|
||||
}
|
||||
|
||||
@Get('my')
|
||||
@ApiOperation({
|
||||
summary: "List the current customer's bookings ready for payment",
|
||||
description:
|
||||
'Bookings owned by the authenticated user\'s company that are payable ' +
|
||||
'(FULLY_EXECUTED, SELECTED_FOR_BATCH, AWAITING_PAYMENT) and not yet PAID.',
|
||||
})
|
||||
findMyPayable(
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
@Query() filter: FilterBookingDto,
|
||||
) {
|
||||
return this.bookingsService.findMyPayable(resolveAuthUserId(user), filter);
|
||||
}
|
||||
|
||||
@Get('queues/:queue')
|
||||
@ApiOperation({
|
||||
summary: 'List bookings for a dashboard queue',
|
||||
@@ -313,8 +342,12 @@ export class BookingsController {
|
||||
@Get(':id/contract/view')
|
||||
@ApiOkResponse({ type: ContractViewDto })
|
||||
@ApiOperation({ summary: 'Contract HTML view for portal and backoffice' })
|
||||
getContractView(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.contractService.getContractView(id);
|
||||
getContractView(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Request() req: { user?: { id?: string; sub?: string } },
|
||||
) {
|
||||
const userId = req.user?.id ?? req.user?.sub;
|
||||
return this.contractService.getContractView(id, userId);
|
||||
}
|
||||
|
||||
@Get(':id/contract/document')
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { Module, forwardRef } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
// import { CustomersModule } from '../customers/customers.module';
|
||||
@@ -6,6 +6,7 @@ import { CompaniesModule } from '../companies/companies.module';
|
||||
import { FilesModule } from '../files/files.module';
|
||||
import { MinioModule } from '../minio/minio.module';
|
||||
import { RuleEngineModule } from '../rule-engine/rule-engine.module';
|
||||
import { SignaturesModule } from '../signatures/signatures.module';
|
||||
import { BookingContractService } from './booking-contract.service';
|
||||
import { BookingPaymentService } from './booking-payment.service';
|
||||
import { BookingPricingService } from './booking-pricing.service';
|
||||
@@ -29,6 +30,8 @@ import { ContractRendererService } from '../../contracts/contract-renderer.servi
|
||||
import { ContractTemplateResolver } from '../../contracts/contract-template.resolver';
|
||||
import { ContractViewModelBuilder } from '../../contracts/contract-view-model.builder';
|
||||
import { PaymentModule } from '../payment/payment.module';
|
||||
import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module';
|
||||
import { CbeExchangeService } from '../cbe-exchange/cbe-exchange.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -42,11 +45,13 @@ import { PaymentModule } from '../payment/payment.module';
|
||||
BookingContractSignature,
|
||||
]),
|
||||
PaymentModule,
|
||||
forwardRef(() => TrainSchedulingModule),
|
||||
FilesModule,
|
||||
MinioModule,
|
||||
CompaniesModule,
|
||||
// CustomersModule,
|
||||
RuleEngineModule,
|
||||
SignaturesModule,
|
||||
],
|
||||
controllers: [BookingsController, PayController],
|
||||
providers: [
|
||||
@@ -63,6 +68,7 @@ import { PaymentModule } from '../payment/payment.module';
|
||||
ContractPricingScheduleBuilder,
|
||||
ContractRendererService,
|
||||
ContractPdfService,
|
||||
CbeExchangeService,
|
||||
],
|
||||
exports: [BookingsService, BookingsRepository],
|
||||
})
|
||||
|
||||
@@ -31,6 +31,8 @@ export interface BookingListFilterOptions {
|
||||
freightType?: string;
|
||||
tradeDirection?: string;
|
||||
paymentCurrency?: string;
|
||||
paymentStatus?: string;
|
||||
excludePaymentStatus?: string;
|
||||
allowConsolidation?: boolean;
|
||||
consolidationPaired?: string;
|
||||
}
|
||||
@@ -91,6 +93,7 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
.leftJoinAndSelect('booking.rateSnapshots', 'snapshots')
|
||||
.leftJoinAndSelect('booking.cargoModifiers', 'modifiers')
|
||||
.leftJoinAndSelect('booking.reviewNotes', 'reviewNotes')
|
||||
.leftJoinAndSelect('booking.consolidationPartner', 'consolidationPartner')
|
||||
.where('booking.id = :id', { id })
|
||||
.leftJoinAndMapMany(
|
||||
'booking.files',
|
||||
@@ -175,7 +178,7 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
.andWhere('b.allowConsolidation = true')
|
||||
.andWhere('b.consolidationPartnerId IS NULL')
|
||||
.andWhere('b.status IN (:...statuses)', {
|
||||
statuses: ['DRAFT', 'PENDING_CONSOLIDATION'],
|
||||
statuses: ['DRAFT', 'SUBMITTED', 'PENDING_CONSOLIDATION'],
|
||||
})
|
||||
.andWhere('b.originYardId = :originYardId', {
|
||||
originYardId: booking.originYardId,
|
||||
@@ -212,15 +215,27 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Pair two bookings for consolidation. */
|
||||
/**
|
||||
* Pair two bookings for consolidation. Both return to SUBMITTED so staff can
|
||||
* accept them into the approval chain; the link itself (consolidationPartnerId)
|
||||
* marks them as consolidated in the UI.
|
||||
*/
|
||||
async pairConsolidation(bookingId: string, partnerId: string): Promise<void> {
|
||||
await this.repository.update(bookingId, {
|
||||
consolidationPartnerId: partnerId,
|
||||
status: 'CONSOLIDATED',
|
||||
status: 'SUBMITTED',
|
||||
} as never);
|
||||
await this.repository.update(partnerId, {
|
||||
consolidationPartnerId: bookingId,
|
||||
status: 'CONSOLIDATED',
|
||||
status: 'SUBMITTED',
|
||||
} as never);
|
||||
}
|
||||
|
||||
/** Park a booking that needs consolidation but has no partner yet. */
|
||||
async parkForConsolidation(bookingId: string): Promise<void> {
|
||||
await this.repository.update(bookingId, {
|
||||
consolidationPartnerId: null,
|
||||
status: 'PENDING_CONSOLIDATION',
|
||||
} as never);
|
||||
}
|
||||
|
||||
@@ -427,6 +442,7 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
.leftJoinAndSelect('booking.destinationYard', 'destinationYard')
|
||||
.leftJoinAndSelect('booking.serviceType', 'serviceType')
|
||||
.leftJoinAndSelect('booking.approvalSteps', 'approvalSteps')
|
||||
.leftJoinAndSelect('booking.consolidationPartner', 'consolidationPartner')
|
||||
.where('booking.deleted_at IS NULL');
|
||||
|
||||
this.applyListFilters(qb, options);
|
||||
@@ -570,6 +586,16 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
paymentCurrency: options.paymentCurrency,
|
||||
});
|
||||
}
|
||||
if (options.paymentStatus) {
|
||||
qb.andWhere('booking.payment_status = :paymentStatus', {
|
||||
paymentStatus: options.paymentStatus,
|
||||
});
|
||||
}
|
||||
if (options.excludePaymentStatus) {
|
||||
qb.andWhere('booking.payment_status != :excludePaymentStatus', {
|
||||
excludePaymentStatus: options.excludePaymentStatus,
|
||||
});
|
||||
}
|
||||
if (options.allowConsolidation !== undefined) {
|
||||
qb.andWhere('booking.allow_consolidation = :allowConsolidation', {
|
||||
allowConsolidation: options.allowConsolidation,
|
||||
@@ -659,6 +685,7 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
originStationId?: string;
|
||||
destinationStationId?: string;
|
||||
schedulingStatus?: string;
|
||||
trainScheduleId?: string;
|
||||
}): Promise<Booking[]> {
|
||||
const qb = this.repository
|
||||
.createQueryBuilder('booking')
|
||||
@@ -676,6 +703,14 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
.where('booking.status = :paidStatus', { paidStatus: 'PAID' })
|
||||
.andWhere('scheduleBooking.id IS NULL');
|
||||
|
||||
// Mirror the automatic batch pool: a schedule only ever considers bookings that
|
||||
// targeted THAT schedule (same as findBatchPool's train_schedule_id filter).
|
||||
if (options.trainScheduleId) {
|
||||
qb.andWhere('booking.train_schedule_id = :trainScheduleId', {
|
||||
trainScheduleId: options.trainScheduleId,
|
||||
});
|
||||
}
|
||||
|
||||
if (options.freightType) {
|
||||
qb.andWhere('booking.freightType = :freightType', { freightType: options.freightType });
|
||||
}
|
||||
@@ -703,6 +738,90 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
.getMany();
|
||||
}
|
||||
|
||||
/**
|
||||
* Ready, not-yet-allocated bookings targeting a schedule (the batch pool).
|
||||
* Commercial = FULLY_EXECUTED; government = APPROVED or PAID (skips contract).
|
||||
* Ordered government → priority → contract-sign time.
|
||||
*/
|
||||
findBatchPool(scheduleId: string): Promise<Booking[]> {
|
||||
return this.repository
|
||||
.createQueryBuilder('booking')
|
||||
.leftJoinAndSelect('booking.company', 'company')
|
||||
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
|
||||
.leftJoin(TrainScheduleBooking, 'sb', 'sb.booking_id = booking.id')
|
||||
.where('booking.train_schedule_id = :scheduleId', { scheduleId })
|
||||
.andWhere('sb.id IS NULL')
|
||||
.andWhere(
|
||||
`((booking.is_government = false AND booking.status = 'FULLY_EXECUTED')
|
||||
OR (booking.is_government = true AND booking.status IN ('APPROVED','PAID')))`,
|
||||
)
|
||||
.orderBy('booking.is_government', 'DESC')
|
||||
.addOrderBy('booking.priority_score', 'DESC')
|
||||
.addOrderBy('booking.fully_executed_at', 'ASC')
|
||||
.addOrderBy('booking.created_at', 'ASC')
|
||||
.getMany();
|
||||
}
|
||||
|
||||
/** Every booking that targeted a schedule (any status) — for the batch monitoring board. */
|
||||
findAllBySchedule(scheduleId: string): Promise<Booking[]> {
|
||||
return this.repository
|
||||
.createQueryBuilder('booking')
|
||||
.leftJoinAndSelect('booking.company', 'company')
|
||||
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
|
||||
.where('booking.train_schedule_id = :scheduleId', { scheduleId })
|
||||
.orderBy('booking.is_government', 'DESC')
|
||||
.addOrderBy('booking.priority_score', 'DESC')
|
||||
.addOrderBy('booking.created_at', 'ASC')
|
||||
.getMany();
|
||||
}
|
||||
|
||||
/** Bookings currently reserved (SELECTED_FOR_BATCH) against a schedule. */
|
||||
findReservedForSchedule(scheduleId: string): Promise<Booking[]> {
|
||||
return this.repository
|
||||
.createQueryBuilder('booking')
|
||||
.leftJoinAndSelect('booking.company', 'company')
|
||||
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
|
||||
.where('booking.train_schedule_id = :scheduleId', { scheduleId })
|
||||
.andWhere(`booking.status IN ('SELECTED_FOR_BATCH', 'AWAITING_PAYMENT')`)
|
||||
.getMany();
|
||||
}
|
||||
|
||||
/** PAID bookings targeting a schedule that have no train_schedule_bookings link yet. */
|
||||
findPaidUnlinkedForSchedule(scheduleId: string): Promise<Booking[]> {
|
||||
return this.repository
|
||||
.createQueryBuilder('booking')
|
||||
.leftJoinAndSelect('booking.company', 'company')
|
||||
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
|
||||
.leftJoin(
|
||||
TrainScheduleBooking,
|
||||
'scheduleBooking',
|
||||
'scheduleBooking.booking_id = booking.id',
|
||||
)
|
||||
.where('booking.train_schedule_id = :scheduleId', { scheduleId })
|
||||
.andWhere(`booking.status = 'PAID'`)
|
||||
.andWhere('scheduleBooking.id IS NULL')
|
||||
.orderBy('booking.priority_score', 'DESC')
|
||||
.addOrderBy('booking.created_at', 'ASC')
|
||||
.getMany();
|
||||
}
|
||||
|
||||
/** Commercial bookings already allocated to a schedule, lowest-priority first (for government preempt). */
|
||||
findAllocatedCommercialForSchedule(scheduleId: string): Promise<Booking[]> {
|
||||
return this.repository
|
||||
.createQueryBuilder('booking')
|
||||
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
|
||||
.innerJoin(
|
||||
TrainScheduleBooking,
|
||||
'sb',
|
||||
'sb.booking_id = booking.id AND sb.train_schedule_id = :scheduleId',
|
||||
{ scheduleId },
|
||||
)
|
||||
.where('booking.is_government = false')
|
||||
.orderBy('booking.priority_score', 'ASC')
|
||||
.addOrderBy('booking.created_at', 'DESC')
|
||||
.getMany();
|
||||
}
|
||||
|
||||
findByIdsForScheduling(bookingIds: string[], manager?: EntityManager): Promise<Booking[]> {
|
||||
if (!bookingIds.length) return Promise.resolve([]);
|
||||
return this.bookingRepo(manager).find({
|
||||
|
||||
@@ -14,6 +14,12 @@ import {
|
||||
BookingEvaluationInput,
|
||||
RuleEngineService,
|
||||
} from '../rule-engine/rule-engine.service';
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import { DataSource, In } from 'typeorm';
|
||||
|
||||
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
|
||||
import { Yard } from '../rule-engine/entities/yard.entity';
|
||||
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
||||
import { BookingsRepository } from './bookings.repository';
|
||||
import { ConsolidationService } from './consolidation.service';
|
||||
import { assertFreightShape } from './booking-freight.util';
|
||||
@@ -40,6 +46,7 @@ const NEEDS_ACTION_STATUSES = [
|
||||
@Injectable()
|
||||
export class BookingsService {
|
||||
constructor(
|
||||
@InjectDataSource() private readonly dataSource: DataSource,
|
||||
private readonly bookingsRepository: BookingsRepository,
|
||||
private readonly filesService: FilesService,
|
||||
private readonly minioService: MinioService,
|
||||
@@ -50,6 +57,36 @@ export class BookingsService {
|
||||
private readonly consolidationService: ConsolidationService,
|
||||
) {}
|
||||
|
||||
/** Resolve trade direction from yard countries; reject client mismatch. */
|
||||
private async resolveTradeDirectionForBooking(
|
||||
originYardId: string,
|
||||
destinationYardId: string,
|
||||
provided?: string,
|
||||
): Promise<string> {
|
||||
const yards = await this.dataSource.getRepository(Yard).find({
|
||||
where: { id: In([originYardId, destinationYardId]) },
|
||||
});
|
||||
const origin = yards.find((y) => y.id === originYardId);
|
||||
const destination = yards.find((y) => y.id === destinationYardId);
|
||||
if (!origin) {
|
||||
throw new BadRequestException(`Origin yard ${originYardId} not found`);
|
||||
}
|
||||
if (!destination) {
|
||||
throw new BadRequestException(`Destination yard ${destinationYardId} not found`);
|
||||
}
|
||||
if (originYardId === destinationYardId) {
|
||||
throw new BadRequestException('Origin and destination yards must differ');
|
||||
}
|
||||
|
||||
const expected = deriveTradeDirection(origin, destination);
|
||||
if (provided && provided !== expected) {
|
||||
throw new BadRequestException(
|
||||
`tradeDirection must be ${expected} for the selected yard pair (got ${provided})`,
|
||||
);
|
||||
}
|
||||
return expected;
|
||||
}
|
||||
|
||||
/** Generate a unique booking reference number. */
|
||||
private async generateReference(): Promise<string> {
|
||||
const year = new Date().getFullYear();
|
||||
@@ -83,9 +120,13 @@ export class BookingsService {
|
||||
vgmPerUnitTons: c.vgmPerUnitTons,
|
||||
totalVgmTons,
|
||||
isReefer: ct.isReefer,
|
||||
wagonsRequired: c.quantity * (Number(ct.wagonsPerUnit) || 1),
|
||||
};
|
||||
}),
|
||||
);
|
||||
const totalWagons = Math.ceil(
|
||||
containers.reduce((sum, c) => sum + c.wagonsRequired, 0),
|
||||
);
|
||||
|
||||
return {
|
||||
freightType: dto.freightType,
|
||||
@@ -98,6 +139,7 @@ export class BookingsService {
|
||||
allowConsolidation:
|
||||
dto.freightType === 'CONTAINER' ? dto.allowConsolidation : false,
|
||||
shippingLineId: dto.shippingLineId,
|
||||
totalWagons,
|
||||
containers,
|
||||
};
|
||||
}
|
||||
@@ -162,6 +204,48 @@ export class BookingsService {
|
||||
return { booking: pending, messages };
|
||||
}
|
||||
|
||||
/**
|
||||
* Consolidation gate used at staff-accept time. Returns the (possibly newly
|
||||
* paired) booking plus whether it still needs a consolidation partner.
|
||||
* When a booking needs consolidation and none is found, it is parked in
|
||||
* PENDING_CONSOLIDATION and `blocked` is true so the caller refuses the accept.
|
||||
*/
|
||||
async resolveConsolidationGate(bookingId: string): Promise<{
|
||||
booking: Booking;
|
||||
blocked: boolean;
|
||||
message?: string;
|
||||
}> {
|
||||
let booking = await this.findById(bookingId);
|
||||
|
||||
// Already paired — passes the gate.
|
||||
if (booking.consolidationPartnerId) {
|
||||
return { booking, blocked: false };
|
||||
}
|
||||
|
||||
const needs =
|
||||
await this.consolidationService.needsConsolidationFromBooking(booking);
|
||||
if (!needs) {
|
||||
return { booking, blocked: false };
|
||||
}
|
||||
|
||||
// A partner may have appeared since submission — try to pair now.
|
||||
const result = await this.tryAutoConsolidate(booking);
|
||||
booking = result.booking;
|
||||
if (booking.consolidationPartnerId) {
|
||||
return { booking, blocked: false, message: result.messages.join(' ') };
|
||||
}
|
||||
|
||||
// Still no partner — park it and block the accept.
|
||||
await this.bookingsRepository.parkForConsolidation(booking.id);
|
||||
booking = await this.findById(booking.id);
|
||||
const slots = await this.consolidationService.slotsFromBooking(booking);
|
||||
return {
|
||||
booking,
|
||||
blocked: true,
|
||||
message: this.consolidationService.describePending(booking, slots),
|
||||
};
|
||||
}
|
||||
|
||||
/** Create a new freight booking. */
|
||||
async create(
|
||||
dto: CreateBookingDto,
|
||||
@@ -199,6 +283,25 @@ export class BookingsService {
|
||||
companyId = company.id;
|
||||
}
|
||||
|
||||
// Schedule targeting: when provided, the schedule must be OPEN and on the same route.
|
||||
if (dto.trainScheduleId) {
|
||||
const schedule = await this.dataSource
|
||||
.getRepository(TrainSchedule)
|
||||
.findOne({ where: { id: dto.trainScheduleId } });
|
||||
if (!schedule) {
|
||||
throw new BadRequestException(`Train schedule ${dto.trainScheduleId} not found`);
|
||||
}
|
||||
if (schedule.bookingWindowStatus !== 'OPEN') {
|
||||
throw new BadRequestException('Selected schedule is no longer accepting bookings');
|
||||
}
|
||||
if (
|
||||
schedule.originStationId !== dto.originYardId ||
|
||||
schedule.destinationStationId !== dto.destinationYardId
|
||||
) {
|
||||
throw new BadRequestException('Selected schedule is not on the booking route');
|
||||
}
|
||||
}
|
||||
|
||||
const reference = dto.reference || (await this.generateReference());
|
||||
const containers = dto.containers ?? [];
|
||||
assertFreightShape({
|
||||
@@ -207,6 +310,12 @@ export class BookingsService {
|
||||
containers,
|
||||
});
|
||||
|
||||
const tradeDirection = await this.resolveTradeDirectionForBooking(
|
||||
dto.originYardId,
|
||||
dto.destinationYardId,
|
||||
dto.tradeDirection,
|
||||
);
|
||||
|
||||
const allowConsolidation =
|
||||
dto.freightType === 'CONTAINER'
|
||||
? await this.resolveConsolidation(containers, dto.allowConsolidation)
|
||||
@@ -217,7 +326,7 @@ export class BookingsService {
|
||||
cargoTypeId: dto.freightType === 'BULK' ? dto.cargoTypeId : null,
|
||||
serviceTypeId: dto.serviceTypeId,
|
||||
paymentCurrency: dto.paymentCurrency,
|
||||
tradeDirection: dto.tradeDirection,
|
||||
tradeDirection,
|
||||
isHazardous: dto.isHazardous,
|
||||
isGovernment,
|
||||
allowConsolidation,
|
||||
@@ -235,6 +344,7 @@ export class BookingsService {
|
||||
isGovernment,
|
||||
governmentInstitution: isGovernment ? dto.governmentInstitution!.trim() : null,
|
||||
trainId: dto.trainId,
|
||||
trainScheduleId: dto.trainScheduleId ?? null,
|
||||
contractType: dto.contractType,
|
||||
previousContractId: dto.previousContractId,
|
||||
serviceTypeId: dto.serviceTypeId,
|
||||
@@ -243,7 +353,7 @@ export class BookingsService {
|
||||
equipmentReturn: dto.equipmentReturn,
|
||||
originYardId: dto.originYardId,
|
||||
destinationYardId: dto.destinationYardId,
|
||||
tradeDirection: dto.tradeDirection,
|
||||
tradeDirection,
|
||||
freightType: dto.freightType,
|
||||
cargoTypeId: dto.freightType === 'BULK' ? dto.cargoTypeId! : null,
|
||||
cargoFreeText: dto.cargoFreeText,
|
||||
@@ -338,6 +448,14 @@ export class BookingsService {
|
||||
|
||||
assertFreightShape({ freightType, cargoTypeId, containers });
|
||||
|
||||
const originYardId = dto.originYardId ?? existing.originYardId;
|
||||
const destinationYardId = dto.destinationYardId ?? existing.destinationYardId;
|
||||
const tradeDirection = await this.resolveTradeDirectionForBooking(
|
||||
originYardId,
|
||||
destinationYardId,
|
||||
dto.tradeDirection,
|
||||
);
|
||||
|
||||
const allowConsolidation =
|
||||
freightType === 'CONTAINER'
|
||||
? await this.resolveConsolidation(
|
||||
@@ -351,7 +469,7 @@ export class BookingsService {
|
||||
cargoTypeId,
|
||||
serviceTypeId: dto.serviceTypeId ?? existing.serviceTypeId,
|
||||
paymentCurrency: dto.paymentCurrency ?? existing.paymentCurrency,
|
||||
tradeDirection: dto.tradeDirection ?? existing.tradeDirection,
|
||||
tradeDirection,
|
||||
isHazardous: dto.isHazardous ?? existing.isHazardous,
|
||||
allowConsolidation,
|
||||
shippingLineId: dto.shippingLineId ?? existing.shippingLineId ?? undefined,
|
||||
@@ -377,6 +495,7 @@ export class BookingsService {
|
||||
cargoTypeId: freightType === 'BULK' ? cargoTypeId : null,
|
||||
allowConsolidation,
|
||||
priorityScore: ruleResult.priorityScore,
|
||||
tradeDirection,
|
||||
};
|
||||
if (dto.scheduledDate) updates.scheduledDate = new Date(dto.scheduledDate);
|
||||
if (dto.startDate) updates.startDate = new Date(dto.startDate);
|
||||
@@ -475,6 +594,7 @@ export class BookingsService {
|
||||
freightType: filter.freightType,
|
||||
tradeDirection: filter.tradeDirection,
|
||||
paymentCurrency: filter.paymentCurrency,
|
||||
paymentStatus: filter.paymentStatus,
|
||||
allowConsolidation: filter.allowConsolidation,
|
||||
consolidationPaired: filter.consolidationPaired,
|
||||
sortBy: filter.sortBy,
|
||||
@@ -482,6 +602,35 @@ export class BookingsService {
|
||||
});
|
||||
}
|
||||
|
||||
/** Booking statuses at which a customer can pay (mirrors booking-payment.service). */
|
||||
private static readonly PAYABLE_STATUSES = [
|
||||
'FULLY_EXECUTED',
|
||||
'SELECTED_FOR_BATCH',
|
||||
'AWAITING_PAYMENT',
|
||||
];
|
||||
|
||||
/**
|
||||
* List the current customer's bookings that are ready for payment:
|
||||
* payable status AND not yet PAID. Company scope is derived from the
|
||||
* authenticated user and cannot be widened by the caller.
|
||||
*/
|
||||
async findMyPayable(
|
||||
userId: string,
|
||||
filter: FilterBookingDto,
|
||||
): Promise<{ items: Booking[]; total: number }> {
|
||||
const { company } = await this.companiesService.getCompanyInfoByUserId(userId);
|
||||
|
||||
return this.bookingsRepository.findAllPaginated({
|
||||
page: filter.page ?? 1,
|
||||
pageSize: filter.pageSize ?? 20,
|
||||
statuses: BookingsService.PAYABLE_STATUSES,
|
||||
excludePaymentStatus: 'PAID',
|
||||
companyId: company.id,
|
||||
sortBy: filter.sortBy,
|
||||
sortOrder: filter.sortOrder,
|
||||
});
|
||||
}
|
||||
|
||||
/** Aggregate metrics and tab counts for the backoffice booking list. */
|
||||
async getListSummary(filter: FilterBookingDto): Promise<BookingListSummaryDto> {
|
||||
const page = filter.page ?? 1;
|
||||
@@ -496,6 +645,7 @@ export class BookingsService {
|
||||
freightType: filter.freightType,
|
||||
tradeDirection: filter.tradeDirection,
|
||||
paymentCurrency: filter.paymentCurrency,
|
||||
paymentStatus: filter.paymentStatus,
|
||||
allowConsolidation: filter.allowConsolidation,
|
||||
consolidationPaired: filter.consolidationPaired,
|
||||
};
|
||||
|
||||
@@ -14,6 +14,14 @@ export class ContractSignatureDto {
|
||||
signatureImageUrl?: string | null;
|
||||
}
|
||||
|
||||
export class SavedSignatureViewDto {
|
||||
@ApiProperty()
|
||||
signerDisplayName!: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
signatureImageUrl?: string | null;
|
||||
}
|
||||
|
||||
export class ContractViewDto {
|
||||
@ApiProperty()
|
||||
bookingId!: string;
|
||||
@@ -45,6 +53,9 @@ export class ContractViewDto {
|
||||
@ApiProperty({ type: [ContractSignatureDto] })
|
||||
signatures!: ContractSignatureDto[];
|
||||
|
||||
@ApiPropertyOptional({ type: SavedSignatureViewDto })
|
||||
savedSignature?: SavedSignatureViewDto;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
pricingSchedule?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
@@ -91,6 +91,12 @@ export class CreateBookingDto {
|
||||
@IsUUID()
|
||||
trainId?: string;
|
||||
|
||||
/** Target schedule this booking is created against (required by the backoffice create form). */
|
||||
@ApiPropertyOptional({ format: 'uuid', description: 'Target train schedule (pool membership)' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
trainScheduleId?: string;
|
||||
|
||||
@ApiProperty({ example: '2026-06-15T00:00:00.000Z' })
|
||||
@IsDateString()
|
||||
scheduledDate!: string;
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
PAYMENT_CURRENCIES,
|
||||
TRADE_DIRECTIONS,
|
||||
} from './create-booking.dto';
|
||||
import { PAYMENT_STATUSES } from '../entities/booking.entity';
|
||||
|
||||
export class FilterBookingDto {
|
||||
@ApiPropertyOptional({ enum: BOOKING_STATUSES })
|
||||
@@ -65,6 +66,11 @@ export class FilterBookingDto {
|
||||
@IsIn([...PAYMENT_CURRENCIES])
|
||||
paymentCurrency?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: PAYMENT_STATUSES })
|
||||
@IsOptional()
|
||||
@IsIn([...PAYMENT_STATUSES])
|
||||
paymentStatus?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => value === 'true' || value === true)
|
||||
|
||||
@@ -29,6 +29,8 @@ export const BOOKING_STATUSES = [
|
||||
'CONTRACT_READY',
|
||||
'SIGNED_CUSTOMER',
|
||||
'FULLY_EXECUTED',
|
||||
'SELECTED_FOR_BATCH',
|
||||
'EXPIRED',
|
||||
'PNR_GENERATED',
|
||||
'PAYMENT_VERIFICATION_IN_PROGRESS',
|
||||
'PAID',
|
||||
@@ -273,10 +275,23 @@ export class Booking extends BaseEntity {
|
||||
|
||||
@Column({ name: 'hold_expires_at', type: 'timestamptz', nullable: true })
|
||||
holdExpiresAt?: Date | null;
|
||||
|
||||
|
||||
@Column({ name: 'scheduled_at', type: 'timestamptz', nullable: true })
|
||||
scheduledAt?: Date | null;
|
||||
|
||||
/** The schedule this booking targets (pool membership), set at creation. FK to train_schedules. */
|
||||
@Column({ name: 'train_schedule_id', type: 'uuid', nullable: true })
|
||||
trainScheduleId?: string | null;
|
||||
|
||||
/** End of the pay window once the booking is SELECTED_FOR_BATCH. */
|
||||
@Column({ name: 'payment_deadline', type: 'timestamptz', nullable: true })
|
||||
paymentDeadline?: Date | null;
|
||||
|
||||
/** When the batch engine picked this booking and opened the pay window. */
|
||||
@Column({ name: 'selected_for_batch_at', type: 'timestamptz', nullable: true })
|
||||
selectedForBatchAt?: Date | null;
|
||||
|
||||
@OneToMany(() => BookingContainer, (bc) => bc.booking)
|
||||
bookingContainers?: BookingContainer[];
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { FleetManage, FleetView } from '../../common/booking-guards';
|
||||
import { CreateCargoDto } from './dto/create-cargo.dto';
|
||||
import { UpdateCargoDto } from './dto/update-cargo.dto';
|
||||
import { LoadCargoDto } from './dto/load-cargo.dto';
|
||||
@@ -18,10 +19,12 @@ import { CargoesService } from './cargoes.service';
|
||||
|
||||
@ApiTags('cargoes')
|
||||
@Controller('cargoes')
|
||||
@FleetView()
|
||||
export class CargoesController {
|
||||
constructor(private readonly cargoesService: CargoesService) {}
|
||||
|
||||
@Post()
|
||||
@FleetManage()
|
||||
@ApiOperation({ summary: 'Create a new cargo' })
|
||||
create(@Body() dto: CreateCargoDto) {
|
||||
return this.cargoesService.create(dto);
|
||||
@@ -40,30 +43,35 @@ export class CargoesController {
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@FleetManage()
|
||||
@ApiOperation({ summary: 'Update a cargo' })
|
||||
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateCargoDto) {
|
||||
return this.cargoesService.update(id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@FleetManage()
|
||||
@ApiOperation({ summary: 'Delete a cargo' })
|
||||
remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.cargoesService.remove(id);
|
||||
}
|
||||
|
||||
@Post(':id/load')
|
||||
@FleetManage()
|
||||
@ApiOperation({ summary: 'Load cargo into a container' })
|
||||
load(@Param('id', ParseUUIDPipe) id: string, @Body() dto: LoadCargoDto) {
|
||||
return this.cargoesService.loadCargo(id, dto);
|
||||
}
|
||||
|
||||
@Post(':id/unload')
|
||||
@FleetManage()
|
||||
@ApiOperation({ summary: 'Unload cargo from container' })
|
||||
unload(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.cargoesService.unloadCargo(id);
|
||||
}
|
||||
|
||||
@Post(':id/deliver')
|
||||
@FleetManage()
|
||||
@ApiOperation({ summary: 'Mark cargo as delivered' })
|
||||
deliver(@Param('id', ParseUUIDPipe) id: string, @Body() dto?: DeliverCargoDto) {
|
||||
return this.cargoesService.deliverCargo(id, dto);
|
||||
|
||||
@@ -157,7 +157,9 @@ export class CargoesService {
|
||||
}
|
||||
|
||||
cargo.status = 'DELIVERED';
|
||||
if (dto?.deliveryRemarks) cargo.description = dto.deliveryRemarks;
|
||||
cargo.deliveredAt = dto?.pickupDate ? new Date(dto.pickupDate) : new Date();
|
||||
if (dto?.receiverName) cargo.receiverName = dto.receiverName;
|
||||
if (dto?.deliveryRemarks) cargo.deliveryRemarks = dto.deliveryRemarks;
|
||||
|
||||
const remaining =
|
||||
cargo.containerId != null
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
import { IsOptional, IsString } from 'class-validator';
|
||||
import { IsDateString, IsOptional, IsString } from 'class-validator';
|
||||
|
||||
export class DeliverCargoDto {
|
||||
/** Name of the person who received / picked up the cargo (Proof of Delivery). */
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
receiverName?: string;
|
||||
|
||||
/** When the cargo was picked up / delivered. Defaults to now. */
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
pickupDate?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
deliveryRemarks?: string;
|
||||
|
||||
@@ -40,6 +40,16 @@ export class Cargo extends BaseEntity {
|
||||
@Column({ name: 'unloaded_at', type: 'timestamp', nullable: true })
|
||||
unloadedAt!: Date | null;
|
||||
|
||||
// Proof of Delivery (customer pickup) capture.
|
||||
@Column({ name: 'receiver_name', type: 'varchar', nullable: true })
|
||||
receiverName!: string | null;
|
||||
|
||||
@Column({ name: 'delivered_at', type: 'timestamp', nullable: true })
|
||||
deliveredAt!: Date | null;
|
||||
|
||||
@Column({ name: 'delivery_remarks', type: 'text', nullable: true })
|
||||
deliveryRemarks!: string | null;
|
||||
|
||||
@Column({ name: 'wagon_booking_allocation_id', type: 'uuid', nullable: true })
|
||||
wagonBookingAllocationId!: string | null;
|
||||
|
||||
@@ -57,6 +67,7 @@ export class Cargo extends BaseEntity {
|
||||
@Column({ name: 'load_type', type: 'varchar', length: 20, nullable: true })
|
||||
loadType!: string | null;
|
||||
|
||||
// Relationship to Container
|
||||
@ManyToOne(() => Container, (container) => container.cargoes, { onDelete: 'RESTRICT', nullable: true })
|
||||
@JoinColumn({ name: 'container_id' })
|
||||
container!: Container | null;
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
const DEFAULT_SCRAPE_URL = 'https://ethio.forex/bank/CBET';
|
||||
|
||||
/** Matches USD buying/selling embedded in ethio.forex CBET page HTML (after entity unescape). */
|
||||
const USD_RATE_REGEX =
|
||||
/currency_code":\[0,"USD"\],"currency_name":\[0,"US DOLLAR"\],"buying":\[0,([\d.]+)\],"selling":\[0,([\d.]+)\]/;
|
||||
|
||||
@Injectable()
|
||||
export class CbeExchangeService {
|
||||
private readonly logger = new Logger(CbeExchangeService.name);
|
||||
private cachedRate: number | null = null;
|
||||
private cacheExpiresAt = 0;
|
||||
|
||||
constructor(private readonly configService: ConfigService) {}
|
||||
|
||||
/**
|
||||
* Returns the current CBE USD→ETB **selling** rate scraped from ethio.forex.
|
||||
* Cached for CBE_EXCHANGE_CACHE_TTL_MS; falls back to CBE_EXCHANGE_FALLBACK_RATE on failure.
|
||||
*/
|
||||
async getUsdToEtbRate(): Promise<number> {
|
||||
const now = Date.now();
|
||||
|
||||
if (this.cachedRate !== null && now < this.cacheExpiresAt) {
|
||||
return this.cachedRate;
|
||||
}
|
||||
|
||||
const scrapeUrl = this.getScrapeUrl();
|
||||
const fallbackRate =
|
||||
this.configService.get<number>('app.cbeExchange.fallbackRate') ?? 130;
|
||||
const cacheTtlMs =
|
||||
this.configService.get<number>('app.cbeExchange.cacheTtlMs') ?? 3_600_000;
|
||||
|
||||
try {
|
||||
const response = await fetch(scrapeUrl, {
|
||||
signal: AbortSignal.timeout(8_000),
|
||||
headers: { 'User-Agent': 'Mozilla/5.0' },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`CBE scrape responded with status ${response.status}`);
|
||||
}
|
||||
|
||||
const html = await response.text();
|
||||
const rates = this.parseScrapedRates(html);
|
||||
|
||||
if (!rates) {
|
||||
throw new Error('USD rate not found in ethio.forex page HTML');
|
||||
}
|
||||
|
||||
const rate = rates.selling;
|
||||
if (!Number.isFinite(rate) || rate <= 0) {
|
||||
throw new Error(`Invalid selling rate parsed: ${rate}`);
|
||||
}
|
||||
|
||||
this.cachedRate = rate;
|
||||
this.cacheExpiresAt = now + cacheTtlMs;
|
||||
this.logger.log(
|
||||
`CBE USD→ETB rate refreshed from ethio.forex — buying=${rates.buying} selling=${rate}`,
|
||||
);
|
||||
return rate;
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Failed to scrape CBE exchange rate — using fallback ${fallbackRate} ETB/USD. Error: ${(err as Error).message}`,
|
||||
);
|
||||
|
||||
if (this.cachedRate !== null) {
|
||||
this.logger.warn(`Using previously cached CBE rate: ${this.cachedRate}`);
|
||||
return this.cachedRate;
|
||||
}
|
||||
|
||||
return fallbackRate;
|
||||
}
|
||||
}
|
||||
|
||||
private getScrapeUrl(): string {
|
||||
const configured =
|
||||
this.configService.get<string>('app.cbeExchange.scrapeUrl') ??
|
||||
this.configService.get<string>('app.cbeExchange.apiUrl');
|
||||
return configured?.trim() || DEFAULT_SCRAPE_URL;
|
||||
}
|
||||
|
||||
private parseScrapedRates(
|
||||
html: string,
|
||||
): { buying: number; selling: number } | null {
|
||||
const decoded = this.unescapeHtml(html);
|
||||
const match = USD_RATE_REGEX.exec(decoded);
|
||||
if (!match) return null;
|
||||
|
||||
const buying = Number(match[1]);
|
||||
const selling = Number(match[2]);
|
||||
if (!Number.isFinite(buying) || !Number.isFinite(selling)) return null;
|
||||
|
||||
return { buying, selling };
|
||||
}
|
||||
|
||||
private unescapeHtml(html: string): string {
|
||||
return html
|
||||
.replace(/"/g, '"')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { Controller, Get, Post, Patch, Delete, Body, Param, Query, ParseUUIDPipe
|
||||
import { AnyFilesInterceptor } from '@nestjs/platform-express';
|
||||
import { ApiOperation, ApiTags, ApiConsumes } from '@nestjs/swagger';
|
||||
import { CurrentUser } from '@edr/api-common';
|
||||
import { FreightAdmin } from '../../common/booking-guards';
|
||||
import { FilesService } from '../files/files.service';
|
||||
import { CompaniesService } from './companies.service';
|
||||
import { CreateCompanyDto } from './dto/create-company.dto';
|
||||
@@ -15,6 +16,7 @@ import { ResponseFFClientDto } from './dto/response-ff-client.dto';
|
||||
import { CompanyInfoResponseDto } from './dto/company-info-response.dto';
|
||||
import { UpdateProfileDto } from './dto/update-profile.dto';
|
||||
import { ProfileResponseDto } from './dto/profile-response.dto';
|
||||
import { DashboardSummaryResponseDto } from './dto/dashboard-summary-response.dto';
|
||||
|
||||
interface CurrentIamUser {
|
||||
id: string;
|
||||
@@ -45,6 +47,12 @@ export class CompaniesController {
|
||||
return new ProfileResponseDto(profile, company);
|
||||
}
|
||||
|
||||
@Get('dashboard')
|
||||
@ApiOperation({ summary: 'Get portal dashboard KPIs (delivered, spend, freight volume) for the current user' })
|
||||
async getDashboard(@CurrentUser() user: CurrentIamUser): Promise<DashboardSummaryResponseDto> {
|
||||
return this.companiesService.getDashboardSummary(user.id);
|
||||
}
|
||||
|
||||
@Patch('profile')
|
||||
@ApiOperation({ summary: 'Update profile (flattened settings page)' })
|
||||
async updateProfile(
|
||||
@@ -75,6 +83,7 @@ export class CompaniesController {
|
||||
}
|
||||
|
||||
@Post()
|
||||
@FreightAdmin()
|
||||
@ApiOperation({ summary: 'Create a new company (customer, forwarder, transporter, broker)' })
|
||||
async create(@Body() dto: CreateCompanyDto): Promise<ResponseCompanyDto> {
|
||||
const company = await this.companiesService.createCompany(dto);
|
||||
@@ -112,6 +121,7 @@ export class CompaniesController {
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@FreightAdmin()
|
||||
@ApiOperation({ summary: 'Update a company' })
|
||||
async update(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@@ -122,6 +132,7 @@ export class CompaniesController {
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@FreightAdmin()
|
||||
@ApiOperation({ summary: 'Soft-delete a company' })
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
async remove(@Param('id', ParseUUIDPipe) id: string): Promise<void> {
|
||||
@@ -140,6 +151,7 @@ export class CompaniesController {
|
||||
}
|
||||
|
||||
@Post(':companyId/profiles')
|
||||
@FreightAdmin()
|
||||
@ApiOperation({ summary: 'Add a profile (employee) to a company' })
|
||||
async createProfile(
|
||||
@Param('companyId', ParseUUIDPipe) companyId: string,
|
||||
@@ -168,6 +180,7 @@ export class CompaniesController {
|
||||
}
|
||||
|
||||
@Post('ff-clients')
|
||||
@FreightAdmin()
|
||||
@ApiOperation({ summary: 'Link a forwarder to a client company' })
|
||||
async createFFClient(@Body() dto: CreateFFClientDto): Promise<ResponseFFClientDto> {
|
||||
const client = await this.companiesService.createFFClient(dto);
|
||||
@@ -184,6 +197,7 @@ export class CompaniesController {
|
||||
}
|
||||
|
||||
@Delete('ff-clients/:id')
|
||||
@FreightAdmin()
|
||||
@ApiOperation({ summary: 'Remove a forwarder-client relationship' })
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
async removeFFClient(@Param('id', ParseUUIDPipe) id: string): Promise<void> {
|
||||
|
||||
@@ -6,14 +6,16 @@ import { CompaniesService } from './companies.service';
|
||||
import { CompaniesRepository } from './companies.repository';
|
||||
import { ExternalProfileRepository } from './external-profile.repository';
|
||||
import { FFClientRepository } from './ff-client.repository';
|
||||
import { CompanyDashboardRepository } from './company-dashboard.repository';
|
||||
import { Company } from './entities/company.entity';
|
||||
import { ExternalProfile } from './entities/external-profile.entity';
|
||||
import { FFClient } from './entities/ff-client.entity';
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Company, ExternalProfile, FFClient]), FilesModule],
|
||||
imports: [TypeOrmModule.forFeature([Company, ExternalProfile, FFClient, Booking]), FilesModule],
|
||||
controllers: [CompaniesController],
|
||||
providers: [CompaniesService, CompaniesRepository, ExternalProfileRepository, FFClientRepository],
|
||||
providers: [CompaniesService, CompaniesRepository, ExternalProfileRepository, FFClientRepository, CompanyDashboardRepository],
|
||||
exports: [CompaniesService],
|
||||
})
|
||||
export class CompaniesModule {}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Injectable, NotFoundException, ConflictException } from '@nestjs/common
|
||||
import { CompaniesRepository } from './companies.repository';
|
||||
import { ExternalProfileRepository } from './external-profile.repository';
|
||||
import { FFClientRepository } from './ff-client.repository';
|
||||
import { CompanyDashboardRepository } from './company-dashboard.repository';
|
||||
import { CreateCompanyDto } from './dto/create-company.dto';
|
||||
import { UpdateCompanyDto } from './dto/update-company.dto';
|
||||
import { CreateExternalProfileDto } from './dto/create-external-profile.dto';
|
||||
@@ -9,6 +10,7 @@ import { CreateFFClientDto } from './dto/create-ff-client.dto';
|
||||
import { CreateCompanyWithProfileDto } from './dto/create-company-with-profile.dto';
|
||||
import { UpdateProfileDto } from './dto/update-profile.dto';
|
||||
import { ProfileResponseDto } from './dto/profile-response.dto';
|
||||
import { DashboardSummaryResponseDto } from './dto/dashboard-summary-response.dto';
|
||||
import { Company } from './entities/company.entity';
|
||||
import { ExternalProfile } from './entities/external-profile.entity';
|
||||
import { FFClient } from './entities/ff-client.entity';
|
||||
@@ -27,6 +29,7 @@ export class CompaniesService {
|
||||
private readonly companiesRepo: CompaniesRepository,
|
||||
private readonly profilesRepo: ExternalProfileRepository,
|
||||
private readonly ffClientsRepo: FFClientRepository,
|
||||
private readonly dashboardRepo: CompanyDashboardRepository,
|
||||
) {}
|
||||
|
||||
async createCompany(dto: CreateCompanyDto): Promise<Company> {
|
||||
@@ -98,6 +101,122 @@ export class CompaniesService {
|
||||
return { profile, company };
|
||||
}
|
||||
|
||||
/**
|
||||
* Dashboard KPIs for the portal home (MyPortalPage), aggregated from the
|
||||
* current user's company bookings. All figures are scoped to that company.
|
||||
*
|
||||
* Note: delivered/spend/volume all derive from the bookings table — there is
|
||||
* no separate data source for them. On-time delivery rate is replaced by
|
||||
* completion rate (delivered ÷ committed): the schema has no ETA /
|
||||
* promised-delivery date, so on-time cannot be computed.
|
||||
*
|
||||
* Period attribution uses booking.created_at: there is no delivery-date
|
||||
* column, so "delivered YTD" counts bookings created this year that reached a
|
||||
* delivered/completed status.
|
||||
*/
|
||||
async getDashboardSummary(userId: string): Promise<DashboardSummaryResponseDto> {
|
||||
// A user without a company profile has no bookings — return an empty summary
|
||||
// rather than 404, so the portal home still renders.
|
||||
const profile = await this.profilesRepo.findByUserId(userId);
|
||||
const companyId = profile?.company?.id ?? profile?.companyId ?? null;
|
||||
if (!companyId) return this.emptyDashboardSummary();
|
||||
|
||||
const now = new Date();
|
||||
const yearStart = new Date(now.getFullYear(), 0, 1);
|
||||
const prevYearStart = new Date(now.getFullYear() - 1, 0, 1);
|
||||
// Same point in the previous year, so YoY compares like-for-like windows.
|
||||
const prevYearToDate = new Date(prevYearStart.getTime() + (now.getTime() - yearStart.getTime()));
|
||||
|
||||
const [
|
||||
deliveredThis,
|
||||
committedThis,
|
||||
spendThisByCcy,
|
||||
spendPrevByCcy,
|
||||
tonnageThis,
|
||||
tonnagePrev,
|
||||
monthlyRows,
|
||||
] = await Promise.all([
|
||||
this.dashboardRepo.countDelivered(companyId, yearStart, now),
|
||||
this.dashboardRepo.countCommitted(companyId, yearStart, now),
|
||||
this.dashboardRepo.sumPaidSpendByCurrency(companyId, yearStart, now),
|
||||
this.dashboardRepo.sumPaidSpendByCurrency(companyId, prevYearStart, prevYearToDate),
|
||||
this.dashboardRepo.sumCommittedTonnage(companyId, yearStart, now),
|
||||
this.dashboardRepo.sumCommittedTonnage(companyId, prevYearStart, prevYearToDate),
|
||||
this.dashboardRepo.monthlyCommittedTonnage(companyId, this.monthsAgo(now, 5), now),
|
||||
]);
|
||||
|
||||
// Spend can span currencies; report the dominant one (prefer ETB on ties).
|
||||
const spend = this.pickCurrencyTotal(spendThisByCcy);
|
||||
const spendPrev = spendPrevByCcy.find((c) => c.currency === spend.currency)?.total ?? 0;
|
||||
|
||||
return {
|
||||
deliveredCount: deliveredThis,
|
||||
// Share of committed bookings that reached delivered/completed.
|
||||
completionRate: committedThis > 0 ? Math.round((deliveredThis / committedThis) * 100) : 0,
|
||||
spendYtd: spend.total,
|
||||
spendCurrency: spend.currency,
|
||||
spendYtdChangePct: this.changePct(spend.total, spendPrev),
|
||||
freightVolume: {
|
||||
totalTonnes: Math.round(tonnageThis),
|
||||
totalValue: spend.total,
|
||||
currency: spend.currency,
|
||||
ytdChangePct: this.changePct(tonnageThis, tonnagePrev),
|
||||
monthly: this.buildMonthlySeries(now, monthlyRows),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private emptyDashboardSummary(): DashboardSummaryResponseDto {
|
||||
const now = new Date();
|
||||
return {
|
||||
deliveredCount: 0,
|
||||
completionRate: 0,
|
||||
spendYtd: 0,
|
||||
spendCurrency: 'ETB',
|
||||
spendYtdChangePct: 0,
|
||||
freightVolume: {
|
||||
totalTonnes: 0,
|
||||
totalValue: 0,
|
||||
currency: 'ETB',
|
||||
ytdChangePct: 0,
|
||||
monthly: this.buildMonthlySeries(now, []),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** First day of the month `n` months before `from`. */
|
||||
private monthsAgo(from: Date, n: number): Date {
|
||||
return new Date(from.getFullYear(), from.getMonth() - n, 1);
|
||||
}
|
||||
|
||||
/** Pick the currency with the largest total, preferring ETB on ties / when empty. */
|
||||
private pickCurrencyTotal(totals: { currency: string; total: number }[]): { currency: string; total: number } {
|
||||
if (totals.length === 0) return { currency: 'ETB', total: 0 };
|
||||
return totals.reduce((best, cur) => (cur.total > best.total ? cur : best));
|
||||
}
|
||||
|
||||
/** Percentage change vs a prior value, rounded; 0 when there is no prior base. */
|
||||
private changePct(current: number, previous: number): number {
|
||||
if (previous <= 0) return 0;
|
||||
return Math.round(((current - previous) / previous) * 100);
|
||||
}
|
||||
|
||||
/** Build a fixed 6-month tonnage series ending on `now`, zero-filling gaps. */
|
||||
private buildMonthlySeries(
|
||||
now: Date,
|
||||
rows: { year: number; month: number; tonnes: number }[],
|
||||
): { month: string; tonnes: number }[] {
|
||||
const labels = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||
const byKey = new Map(rows.map((r) => [`${r.year}-${r.month}`, r.tonnes]));
|
||||
const series: { month: string; tonnes: number }[] = [];
|
||||
for (let i = 5; i >= 0; i--) {
|
||||
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
|
||||
const key = `${d.getFullYear()}-${d.getMonth() + 1}`;
|
||||
series.push({ month: labels[d.getMonth()], tonnes: Math.round(byKey.get(key) ?? 0) });
|
||||
}
|
||||
return series;
|
||||
}
|
||||
|
||||
async updateCompany(id: string, dto: UpdateCompanyDto): Promise<Company> {
|
||||
await this.findCompanyById(id);
|
||||
const updated = await this.companiesRepo.update(id, dto);
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
|
||||
/** Booking statuses that represent a delivered/finished shipment. */
|
||||
const DELIVERED_STATUSES = ['DELIVERED', 'COMPLETED'] as const;
|
||||
|
||||
/**
|
||||
* Statuses that represent real, committed freight (excludes drafts and dead
|
||||
* bookings) — used for tonnage so cancelled/expired drafts don't inflate volume.
|
||||
*/
|
||||
const COMMITTED_STATUSES = [
|
||||
'APPROVED',
|
||||
'CONTRACT_READY',
|
||||
'SIGNED_CUSTOMER',
|
||||
'FULLY_EXECUTED',
|
||||
'SELECTED_FOR_BATCH',
|
||||
'PNR_GENERATED',
|
||||
'PAYMENT_VERIFICATION_IN_PROGRESS',
|
||||
'PAID',
|
||||
'IN_TRANSIT',
|
||||
'COMPLETED',
|
||||
'DELIVERED',
|
||||
'CONSOLIDATED',
|
||||
] as const;
|
||||
|
||||
export interface CurrencyTotal {
|
||||
currency: string;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface MonthlyTonnage {
|
||||
year: number;
|
||||
month: number; // 1-12
|
||||
tonnes: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read-only aggregation queries against the bookings table, scoped to a
|
||||
* company, that back the portal dashboard. Lives in the companies module so it
|
||||
* can be exposed via `companies.controller` without a circular dependency on
|
||||
* BookingsModule (which already imports CompaniesModule).
|
||||
*/
|
||||
@Injectable()
|
||||
export class CompanyDashboardRepository {
|
||||
constructor(
|
||||
@InjectRepository(Booking)
|
||||
private readonly bookings: Repository<Booking>,
|
||||
) {}
|
||||
|
||||
/** Count of delivered/completed bookings for a company within [from, to). */
|
||||
async countDelivered(companyId: string, from: Date, to: Date): Promise<number> {
|
||||
return this.bookings
|
||||
.createQueryBuilder('b')
|
||||
.where('b.company_id = :companyId', { companyId })
|
||||
.andWhere('b.deleted_at IS NULL')
|
||||
.andWhere('b.status IN (:...statuses)', { statuses: [...DELIVERED_STATUSES] })
|
||||
.andWhere('b.created_at >= :from AND b.created_at < :to', { from, to })
|
||||
.getCount();
|
||||
}
|
||||
|
||||
/** Count of committed (non-draft, non-dead) bookings for a company within [from, to). */
|
||||
async countCommitted(companyId: string, from: Date, to: Date): Promise<number> {
|
||||
return this.bookings
|
||||
.createQueryBuilder('b')
|
||||
.where('b.company_id = :companyId', { companyId })
|
||||
.andWhere('b.deleted_at IS NULL')
|
||||
.andWhere('b.status IN (:...statuses)', { statuses: [...COMMITTED_STATUSES] })
|
||||
.andWhere('b.created_at >= :from AND b.created_at < :to', { from, to })
|
||||
.getCount();
|
||||
}
|
||||
|
||||
/** Sum of paid booking totals, grouped by currency, within [from, to). */
|
||||
async sumPaidSpendByCurrency(companyId: string, from: Date, to: Date): Promise<CurrencyTotal[]> {
|
||||
const rows = await this.bookings
|
||||
.createQueryBuilder('b')
|
||||
.select('b.payment_currency', 'currency')
|
||||
.addSelect('COALESCE(SUM(b.total_amount), 0)', 'total')
|
||||
.where('b.company_id = :companyId', { companyId })
|
||||
.andWhere('b.deleted_at IS NULL')
|
||||
.andWhere("b.payment_status = 'PAID'")
|
||||
.andWhere('b.created_at >= :from AND b.created_at < :to', { from, to })
|
||||
.groupBy('b.payment_currency')
|
||||
.getRawMany<{ currency: string; total: string }>();
|
||||
|
||||
return rows.map((r) => ({ currency: r.currency ?? 'ETB', total: Number(r.total) }));
|
||||
}
|
||||
|
||||
/** Total committed tonnage (cargo VGM) for a company within [from, to). */
|
||||
async sumCommittedTonnage(companyId: string, from: Date, to: Date): Promise<number> {
|
||||
const row = await this.bookings
|
||||
.createQueryBuilder('b')
|
||||
.select('COALESCE(SUM(b.cargo_total_weight_vgm), 0)', 'total')
|
||||
.where('b.company_id = :companyId', { companyId })
|
||||
.andWhere('b.deleted_at IS NULL')
|
||||
.andWhere('b.status IN (:...statuses)', { statuses: [...COMMITTED_STATUSES] })
|
||||
.andWhere('b.created_at >= :from AND b.created_at < :to', { from, to })
|
||||
.getRawOne<{ total: string }>();
|
||||
|
||||
return Number(row?.total ?? 0);
|
||||
}
|
||||
|
||||
/** Committed tonnage grouped by calendar month within [from, to). */
|
||||
async monthlyCommittedTonnage(companyId: string, from: Date, to: Date): Promise<MonthlyTonnage[]> {
|
||||
const rows = await this.bookings
|
||||
.createQueryBuilder('b')
|
||||
.select('EXTRACT(YEAR FROM b.created_at)', 'year')
|
||||
.addSelect('EXTRACT(MONTH FROM b.created_at)', 'month')
|
||||
.addSelect('COALESCE(SUM(b.cargo_total_weight_vgm), 0)', 'total')
|
||||
.where('b.company_id = :companyId', { companyId })
|
||||
.andWhere('b.deleted_at IS NULL')
|
||||
.andWhere('b.status IN (:...statuses)', { statuses: [...COMMITTED_STATUSES] })
|
||||
.andWhere('b.created_at >= :from AND b.created_at < :to', { from, to })
|
||||
.groupBy('year')
|
||||
.addGroupBy('month')
|
||||
.getRawMany<{ year: string; month: string; total: string }>();
|
||||
|
||||
return rows.map((r) => ({
|
||||
year: Number(r.year),
|
||||
month: Number(r.month),
|
||||
tonnes: Number(r.total),
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class FreightVolumePointDto {
|
||||
@ApiProperty({ example: 'May', description: 'Short month label' })
|
||||
month!: string;
|
||||
|
||||
@ApiProperty({ example: 940, description: 'Tonnage shipped in the month' })
|
||||
tonnes!: number;
|
||||
}
|
||||
|
||||
export class FreightVolumeDto {
|
||||
@ApiProperty({ example: 4180, description: 'Total tonnage shipped year-to-date' })
|
||||
totalTonnes!: number;
|
||||
|
||||
@ApiProperty({ example: 1240000, description: 'Total committed freight value year-to-date' })
|
||||
totalValue!: number;
|
||||
|
||||
@ApiProperty({ example: 'ETB' })
|
||||
currency!: string;
|
||||
|
||||
@ApiProperty({ example: 16, description: 'Tonnage change vs same period last year, in percent' })
|
||||
ytdChangePct!: number;
|
||||
|
||||
@ApiProperty({ type: [FreightVolumePointDto], description: 'Monthly tonnage series (oldest first, last 6 months)' })
|
||||
monthly!: FreightVolumePointDto[];
|
||||
}
|
||||
|
||||
/**
|
||||
* KPIs for the portal dashboard (MyPortalPage), aggregated from the current
|
||||
* user's company bookings. All figures are scoped to that company.
|
||||
*
|
||||
* Note: every metric here derives from the bookings table — there is no
|
||||
* separate "non-booking" data source for delivered/spend/volume. On-time
|
||||
* delivery rate is replaced by completion rate: no ETA / promised-delivery
|
||||
* column exists in the schema, so on-time cannot be computed, whereas
|
||||
* completion rate (delivered ÷ committed) can.
|
||||
*/
|
||||
export class DashboardSummaryResponseDto {
|
||||
@ApiProperty({ example: 12, description: 'Bookings delivered/completed year-to-date' })
|
||||
deliveredCount!: number;
|
||||
|
||||
@ApiProperty({
|
||||
example: 92,
|
||||
description: 'Share of committed bookings that have been delivered/completed (YTD), in percent',
|
||||
})
|
||||
completionRate!: number;
|
||||
|
||||
@ApiProperty({ example: 1240000, description: 'Total paid spend year-to-date' })
|
||||
spendYtd!: number;
|
||||
|
||||
@ApiProperty({ example: 'ETB' })
|
||||
spendCurrency!: string;
|
||||
|
||||
@ApiProperty({ example: 16, description: 'Spend change vs same period last year, in percent' })
|
||||
spendYtdChangePct!: number;
|
||||
|
||||
@ApiProperty({ type: FreightVolumeDto })
|
||||
freightVolume!: FreightVolumeDto;
|
||||
}
|
||||
@@ -9,16 +9,19 @@ import {
|
||||
} from "@nestjs/common";
|
||||
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
|
||||
import { FleetManage, FleetView } from "../../common/booking-guards";
|
||||
import { ConsignmentsService } from "./consignments.service";
|
||||
import { CreateConsignmentDto } from "./dto/create-consignment.dto";
|
||||
import { FilterConsignmentDto } from "./dto/filter-consignment.dto";
|
||||
|
||||
@ApiTags("consignments")
|
||||
@Controller("consignments")
|
||||
@FleetView()
|
||||
export class ConsignmentsController {
|
||||
constructor(private readonly consignmentsService: ConsignmentsService) {}
|
||||
|
||||
@Post()
|
||||
@FleetManage()
|
||||
@ApiOperation({ summary: "Create a new consignment" })
|
||||
create(@Body() dto: CreateConsignmentDto) {
|
||||
return this.consignmentsService.create(dto);
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { FleetManage, FleetView } from '../../common/booking-guards';
|
||||
import { CreateContainerDto } from './dto/create-container.dto';
|
||||
import { UpdateContainerDto } from './dto/update-container.dto';
|
||||
import { AssignContainerToWagonDto } from './dto/assign-container-to-wagon.dto';
|
||||
@@ -17,10 +18,12 @@ import { ContainersService } from './containers.service';
|
||||
|
||||
@ApiTags('containers')
|
||||
@Controller('containers')
|
||||
@FleetView()
|
||||
export class ContainersController {
|
||||
constructor(private readonly containersService: ContainersService) {}
|
||||
|
||||
@Post()
|
||||
@FleetManage()
|
||||
@ApiOperation({ summary: 'Create a new container' })
|
||||
create(@Body() dto: CreateContainerDto) {
|
||||
return this.containersService.create(dto);
|
||||
@@ -39,24 +42,28 @@ export class ContainersController {
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@FleetManage()
|
||||
@ApiOperation({ summary: 'Update a container' })
|
||||
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateContainerDto) {
|
||||
return this.containersService.update(id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@FleetManage()
|
||||
@ApiOperation({ summary: 'Delete a container' })
|
||||
remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.containersService.remove(id);
|
||||
}
|
||||
|
||||
@Post(':id/assign-wagon')
|
||||
@FleetManage()
|
||||
@ApiOperation({ summary: 'Assign container to a wagon' })
|
||||
assignToWagon(@Param('id', ParseUUIDPipe) id: string, @Body() dto: AssignContainerToWagonDto) {
|
||||
return this.containersService.assignToWagon(id, dto);
|
||||
}
|
||||
|
||||
@Post(':id/unassign-wagon')
|
||||
@FleetManage()
|
||||
@ApiOperation({ summary: 'Unassign container from wagon' })
|
||||
unassignFromWagon(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.containersService.unassignFromWagon(id);
|
||||
|
||||
@@ -16,12 +16,14 @@ import {
|
||||
|
||||
import { ApiOperation } from "@nestjs/swagger";
|
||||
|
||||
import { FreightAdmin } from "../../common/booking-guards";
|
||||
import { CustomersService } from "./customers.service";
|
||||
import { CreateCustomerDto } from "./dto/create-customer.dto";
|
||||
import { UpdateCustomerDto } from "./dto/update-customer.dto";
|
||||
import { Customer } from "./entities/customer.entity";
|
||||
|
||||
@Controller("customers")
|
||||
@FreightAdmin()
|
||||
export class CustomersController {
|
||||
constructor(private readonly customersService: CustomersService) {}
|
||||
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Patch,
|
||||
Delete,
|
||||
Param,
|
||||
Body,
|
||||
Query,
|
||||
ParseUUIDPipe,
|
||||
} from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { FleetManage, FleetView } from '../../common/booking-guards';
|
||||
import { DriversService } from './drivers.service';
|
||||
import { CreateDriverDto } from './dto/create-driver.dto';
|
||||
import { UpdateDriverDto } from './dto/update-driver.dto';
|
||||
|
||||
@ApiTags('drivers')
|
||||
@ApiBearerAuth()
|
||||
@Controller('drivers')
|
||||
@FleetView()
|
||||
export class DriversController {
|
||||
constructor(private readonly driversService: DriversService) {}
|
||||
|
||||
@Post()
|
||||
@FleetManage()
|
||||
@ApiOperation({ summary: 'Create a new driver' })
|
||||
create(@Body() createDriverDto: CreateDriverDto) {
|
||||
return this.driversService.create(createDriverDto);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'Get all drivers with filters' })
|
||||
findAll(
|
||||
@Query('search') search?: string,
|
||||
@Query('status') status?: string,
|
||||
@Query('page') page?: string,
|
||||
@Query('limit') limit?: string,
|
||||
@Query('sortBy') sortBy?: string,
|
||||
@Query('sortOrder') sortOrder?: 'ASC' | 'DESC',
|
||||
) {
|
||||
return this.driversService.findAll({
|
||||
search,
|
||||
status: status as any,
|
||||
page: page ? parseInt(page) : undefined,
|
||||
limit: limit ? parseInt(limit) : undefined,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
});
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get driver by id' })
|
||||
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.driversService.findById(id);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@FleetManage()
|
||||
@ApiOperation({ summary: 'Update a driver' })
|
||||
update(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() updateDriverDto: UpdateDriverDto,
|
||||
) {
|
||||
return this.driversService.update(id, updateDriverDto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@FleetManage()
|
||||
@ApiOperation({ summary: 'Delete a driver' })
|
||||
remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.driversService.remove(id);
|
||||
}
|
||||
}
|
||||
13
apps/edr-freight-api/src/modules/drivers/drivers.module.ts
Normal file
13
apps/edr-freight-api/src/modules/drivers/drivers.module.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { Driver } from './entities/driver.entity';
|
||||
import { DriversService } from './drivers.service';
|
||||
import { DriversController } from './drivers.controller';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Driver])],
|
||||
providers: [DriversService],
|
||||
controllers: [DriversController],
|
||||
exports: [DriversService],
|
||||
})
|
||||
export class DriversModule {}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { BaseRepository } from '@edr/api-common';
|
||||
import { Driver } from './entities/driver.entity';
|
||||
|
||||
@Injectable()
|
||||
export class DriversRepository extends BaseRepository<Driver> {
|
||||
constructor(
|
||||
@InjectRepository(Driver)
|
||||
repository: Repository<Driver>,
|
||||
) {
|
||||
super(repository);
|
||||
}
|
||||
|
||||
async findByLicenseNumber(licenseNumber: string): Promise<Driver | null> {
|
||||
return this.repository.findOne({ where: { licenseNumber } });
|
||||
}
|
||||
|
||||
async findByEmail(email: string): Promise<Driver | null> {
|
||||
return this.repository.findOne({ where: { email } });
|
||||
}
|
||||
|
||||
async findByPhoneNumber(phoneNumber: string): Promise<Driver | null> {
|
||||
return this.repository.findOne({ where: { phoneNumber } });
|
||||
}
|
||||
|
||||
async findDriverById(id: string): Promise<Driver | null> {
|
||||
return this.repository.findOne({ where: { id } });
|
||||
}
|
||||
|
||||
async findAllWithFilters(query: {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
search?: string;
|
||||
status?: string;
|
||||
sortBy?: string;
|
||||
sortOrder?: 'ASC' | 'DESC';
|
||||
}) {
|
||||
const page = query.page || 1;
|
||||
const pageSize = query.pageSize || 10;
|
||||
const skip = (page - 1) * pageSize;
|
||||
|
||||
let queryBuilder = this.repository.createQueryBuilder('driver');
|
||||
|
||||
if (query.search) {
|
||||
queryBuilder = queryBuilder.where(
|
||||
'(driver.firstName ILIKE :search OR driver.lastName ILIKE :search OR driver.email ILIKE :search OR driver.phoneNumber ILIKE :search OR driver.licenseNumber ILIKE :search)',
|
||||
{ search: `%${query.search}%` },
|
||||
);
|
||||
}
|
||||
|
||||
if (query.status) {
|
||||
queryBuilder = queryBuilder.andWhere('driver.status = :status', {
|
||||
status: query.status,
|
||||
});
|
||||
}
|
||||
|
||||
const sortBy = query.sortBy || 'createdAt';
|
||||
const sortOrder = query.sortOrder || 'DESC';
|
||||
|
||||
queryBuilder = queryBuilder
|
||||
.orderBy(`driver.${sortBy}`, sortOrder)
|
||||
.skip(skip)
|
||||
.take(pageSize);
|
||||
|
||||
const [data, total] = await queryBuilder.getManyAndCount();
|
||||
|
||||
return {
|
||||
data,
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
totalPages: Math.ceil(total / pageSize),
|
||||
};
|
||||
}
|
||||
|
||||
async createDriver(driverData: any): Promise<Driver> {
|
||||
const driver = this.repository.create(driverData);
|
||||
const result = await this.repository.save(driver);
|
||||
return result?.[0] as Driver;
|
||||
}
|
||||
|
||||
async updateDriver(driver: Driver): Promise<Driver> {
|
||||
const result = await this.repository.save(driver);
|
||||
return result as Driver;
|
||||
}
|
||||
}
|
||||
119
apps/edr-freight-api/src/modules/drivers/drivers.service.ts
Normal file
119
apps/edr-freight-api/src/modules/drivers/drivers.service.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { CreateDriverDto } from './dto/create-driver.dto';
|
||||
import { UpdateDriverDto } from './dto/update-driver.dto';
|
||||
import { Driver, DriverStatus } from './entities/driver.entity';
|
||||
|
||||
@Injectable()
|
||||
export class DriversService {
|
||||
constructor(
|
||||
@InjectRepository(Driver)
|
||||
private readonly driverRepo: Repository<Driver>,
|
||||
) {}
|
||||
|
||||
async create(dto: CreateDriverDto): Promise<Driver> {
|
||||
const existing = await this.driverRepo.findOne({
|
||||
where: [
|
||||
{ licenseNumber: dto.licenseNumber },
|
||||
{ email: dto.email },
|
||||
{ phoneNumber: dto.phoneNumber },
|
||||
],
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
if (existing.licenseNumber === dto.licenseNumber) {
|
||||
throw new ConflictException(`Driver with license number ${dto.licenseNumber} already exists`);
|
||||
}
|
||||
if (existing.email === dto.email) {
|
||||
throw new ConflictException(`Driver with email ${dto.email} already exists`);
|
||||
}
|
||||
if (existing.phoneNumber === dto.phoneNumber) {
|
||||
throw new ConflictException(`Driver with phone number ${dto.phoneNumber} already exists`);
|
||||
}
|
||||
}
|
||||
|
||||
const driver = this.driverRepo.create(dto);
|
||||
return this.driverRepo.save(driver);
|
||||
}
|
||||
|
||||
async findAll(query: {
|
||||
search?: string;
|
||||
status?: DriverStatus | string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
sortBy?: string;
|
||||
sortOrder?: 'ASC' | 'DESC';
|
||||
} = {}): Promise<Driver[]> {
|
||||
const qb = this.driverRepo.createQueryBuilder('d');
|
||||
|
||||
if (query.search) {
|
||||
const searchTerm = `%${query.search}%`;
|
||||
qb.where('d.firstName ILIKE :search', { search: searchTerm })
|
||||
.orWhere('d.lastName ILIKE :search', { search: searchTerm })
|
||||
.orWhere('d.email ILIKE :search', { search: searchTerm })
|
||||
.orWhere('d.licenseNumber ILIKE :search', { search: searchTerm })
|
||||
.orWhere('d.phoneNumber ILIKE :search', { search: searchTerm });
|
||||
}
|
||||
|
||||
if (query.status) {
|
||||
qb.andWhere('d.status = :status', { status: query.status });
|
||||
}
|
||||
|
||||
const sortBy = query.sortBy && ['firstName', 'lastName', 'status', 'createdAt'].includes(query.sortBy)
|
||||
? query.sortBy
|
||||
: 'createdAt';
|
||||
const sortOrder = (query.sortOrder ?? 'DESC').toUpperCase();
|
||||
|
||||
return qb
|
||||
.orderBy(`d.${sortBy}`, sortOrder as 'ASC' | 'DESC')
|
||||
.getMany();
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<Driver> {
|
||||
const driver = await this.driverRepo.findOne({ where: { id } });
|
||||
if (!driver) {
|
||||
throw new NotFoundException(`Driver ${id} not found`);
|
||||
}
|
||||
return driver;
|
||||
}
|
||||
|
||||
async update(id: string, dto: UpdateDriverDto): Promise<Driver> {
|
||||
const driver = await this.findById(id);
|
||||
|
||||
if (dto.licenseNumber && dto.licenseNumber !== driver.licenseNumber) {
|
||||
const existing = await this.driverRepo.findOne({
|
||||
where: { licenseNumber: dto.licenseNumber },
|
||||
});
|
||||
if (existing) {
|
||||
throw new ConflictException(`Driver with license number ${dto.licenseNumber} already exists`);
|
||||
}
|
||||
}
|
||||
|
||||
if (dto.email && dto.email !== driver.email) {
|
||||
const existing = await this.driverRepo.findOne({
|
||||
where: { email: dto.email },
|
||||
});
|
||||
if (existing) {
|
||||
throw new ConflictException(`Driver with email ${dto.email} already exists`);
|
||||
}
|
||||
}
|
||||
|
||||
if (dto.phoneNumber && dto.phoneNumber !== driver.phoneNumber) {
|
||||
const existing = await this.driverRepo.findOne({
|
||||
where: { phoneNumber: dto.phoneNumber },
|
||||
});
|
||||
if (existing) {
|
||||
throw new ConflictException(`Driver with phone number ${dto.phoneNumber} already exists`);
|
||||
}
|
||||
}
|
||||
|
||||
Object.assign(driver, dto);
|
||||
return this.driverRepo.save(driver);
|
||||
}
|
||||
|
||||
async remove(id: string): Promise<void> {
|
||||
await this.findById(id);
|
||||
await this.driverRepo.softDelete(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { IsString, IsEmail, IsDateString, IsEnum, IsOptional, IsArray } from 'class-validator';
|
||||
import { DriverStatus } from '../entities/driver.entity';
|
||||
|
||||
export class CreateDriverDto {
|
||||
@IsString()
|
||||
licenseNumber!: string;
|
||||
|
||||
@IsString()
|
||||
firstName!: string;
|
||||
|
||||
@IsString()
|
||||
lastName!: string;
|
||||
|
||||
@IsEmail()
|
||||
email!: string;
|
||||
|
||||
@IsString()
|
||||
phoneNumber!: string;
|
||||
|
||||
@IsDateString()
|
||||
dateOfBirth!: string;
|
||||
|
||||
@IsDateString()
|
||||
licenseExpiryDate!: string;
|
||||
|
||||
@IsEnum(DriverStatus)
|
||||
status!: DriverStatus;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
vehicleTypesAuthorized?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
address?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
emergencyContact?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
notes?: string;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { PartialType } from '@nestjs/mapped-types';
|
||||
import { CreateDriverDto } from './create-driver.dto';
|
||||
|
||||
export class UpdateDriverDto extends PartialType(CreateDriverDto) {}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Entity, Column } from 'typeorm';
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
|
||||
export enum DriverStatus {
|
||||
ACTIVE = 'ACTIVE',
|
||||
INACTIVE = 'INACTIVE',
|
||||
SUSPENDED = 'SUSPENDED',
|
||||
ON_LEAVE = 'ON_LEAVE',
|
||||
}
|
||||
|
||||
@Entity({ name: 'drivers', schema: 'freight' })
|
||||
export class Driver extends BaseEntity {
|
||||
@Column({ name: 'license_number', unique: true, nullable: true })
|
||||
licenseNumber?: string;
|
||||
|
||||
@Column({ name: 'first_name', nullable: true })
|
||||
firstName?: string;
|
||||
|
||||
@Column({ name: 'last_name', nullable: true })
|
||||
lastName?: string;
|
||||
|
||||
@Column({ unique: true, nullable: true })
|
||||
email?: string;
|
||||
|
||||
@Column({ name: 'phone_number', unique: true, nullable: true })
|
||||
phoneNumber?: string;
|
||||
|
||||
@Column({ name: 'date_of_birth', type: 'date', nullable: true })
|
||||
dateOfBirth?: Date;
|
||||
|
||||
@Column({ name: 'license_expiry_date', type: 'date', nullable: true })
|
||||
licenseExpiryDate?: Date;
|
||||
|
||||
@Column({ type: 'varchar', default: DriverStatus.ACTIVE, nullable: true })
|
||||
status?: DriverStatus;
|
||||
|
||||
@Column({ name: 'vehicle_types_authorized', type: 'varchar', array: true, nullable: true })
|
||||
vehicleTypesAuthorized?: string[];
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
address?: string | null;
|
||||
|
||||
@Column({ name: 'emergency_contact', type: 'varchar', nullable: true })
|
||||
emergencyContact?: string | null;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
notes?: string | null;
|
||||
|
||||
@Column({ name: 'total_trips', type: 'int', default: 0, nullable: true })
|
||||
totalTrips?: number;
|
||||
|
||||
@Column({ type: 'numeric', precision: 3, scale: 2, nullable: true })
|
||||
rating?: number | null;
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from "@nestjs/common";
|
||||
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
|
||||
import { FreightAdmin } from "../../common/booking-guards";
|
||||
import { CreateDropdownOptionDto } from "./dto/create-dropdown-option.dto";
|
||||
import { CreateDropdownSettingDto } from "./dto/create-dropdown-setting.dto";
|
||||
import { UpdateDropdownOptionDto } from "./dto/update-dropdown-option.dto";
|
||||
@@ -24,6 +25,9 @@ import { DropdownSettingsService } from "./dropdown-settings.service";
|
||||
export class DropdownSettingsController {
|
||||
constructor(private readonly service: DropdownSettingsService) {}
|
||||
|
||||
// Reads stay open: the customer portal fetches these to render dynamic
|
||||
// dropdowns (by-code). Only writes are admin-guarded.
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: "List all dropdown settings" })
|
||||
list() {
|
||||
@@ -43,12 +47,14 @@ export class DropdownSettingsController {
|
||||
}
|
||||
|
||||
@Post()
|
||||
@FreightAdmin()
|
||||
@ApiOperation({ summary: "Create a new dropdown setting" })
|
||||
create(@Body() dto: CreateDropdownSettingDto) {
|
||||
return this.service.create(dto);
|
||||
}
|
||||
|
||||
@Patch(":id")
|
||||
@FreightAdmin()
|
||||
@ApiOperation({ summary: "Update a dropdown setting's metadata" })
|
||||
update(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@@ -58,6 +64,7 @@ export class DropdownSettingsController {
|
||||
}
|
||||
|
||||
@Delete(":id")
|
||||
@FreightAdmin()
|
||||
@ApiOperation({ summary: "Soft-delete a dropdown setting" })
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
remove(@Param("id", ParseUUIDPipe) id: string) {
|
||||
@@ -67,6 +74,7 @@ export class DropdownSettingsController {
|
||||
/* ------------------------- option routes ------------------------- */
|
||||
|
||||
@Put(":id/options")
|
||||
@FreightAdmin()
|
||||
@ApiOperation({ summary: "Replace the full option list for a setting" })
|
||||
replaceOptions(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@@ -76,6 +84,7 @@ export class DropdownSettingsController {
|
||||
}
|
||||
|
||||
@Post(":id/options")
|
||||
@FreightAdmin()
|
||||
@ApiOperation({ summary: "Append a single option to a setting" })
|
||||
addOption(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@@ -85,6 +94,7 @@ export class DropdownSettingsController {
|
||||
}
|
||||
|
||||
@Patch("options/:optionId")
|
||||
@FreightAdmin()
|
||||
@ApiOperation({ summary: "Update a single option" })
|
||||
updateOption(
|
||||
@Param("optionId", ParseUUIDPipe) optionId: string,
|
||||
@@ -94,6 +104,7 @@ export class DropdownSettingsController {
|
||||
}
|
||||
|
||||
@Delete("options/:optionId")
|
||||
@FreightAdmin()
|
||||
@ApiOperation({ summary: "Soft-delete a single option" })
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
removeOption(@Param("optionId", ParseUUIDPipe) optionId: string) {
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { FacilityStatus, FacilityType } from '../entities/facility.entity';
|
||||
|
||||
export class CreateFacilityDto {
|
||||
code!: string;
|
||||
name!: string;
|
||||
description?: string;
|
||||
facilityType!: FacilityType;
|
||||
facilityStatus?: FacilityStatus;
|
||||
locationName?: string;
|
||||
country?: string;
|
||||
city?: string;
|
||||
address?: string;
|
||||
latitude?: number;
|
||||
longitude?: number;
|
||||
capacity?: number;
|
||||
isActive?: boolean;
|
||||
notes?: string;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { FacilityStatus, FacilityType } from '../entities/facility.entity';
|
||||
|
||||
export class UpdateFacilityDto {
|
||||
code?: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
facilityType?: FacilityType;
|
||||
facilityStatus?: FacilityStatus;
|
||||
locationName?: string;
|
||||
country?: string;
|
||||
city?: string;
|
||||
address?: string;
|
||||
latitude?: number;
|
||||
longitude?: number;
|
||||
capacity?: number;
|
||||
isActive?: boolean;
|
||||
notes?: string;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index, OneToMany } from 'typeorm';
|
||||
|
||||
import { Warehouse } from '../../warehouses/entities/warehouse.entity';
|
||||
|
||||
export const FACILITY_TYPES = ['PORT', 'DRY_PORT', 'TERMINAL', 'RAIL_YARD', 'WAREHOUSE_COMPLEX'] as const;
|
||||
export type FacilityType = (typeof FACILITY_TYPES)[number];
|
||||
|
||||
export const FACILITY_STATUSES = ['ACTIVE', 'INACTIVE', 'UNDER_MAINTENANCE'] as const;
|
||||
export type FacilityStatus = (typeof FACILITY_STATUSES)[number];
|
||||
|
||||
@Entity({ schema: 'freight', name: 'facilities' })
|
||||
@Index(['code'], { unique: true })
|
||||
@Index(['facilityStatus'])
|
||||
export class Facility extends BaseEntity {
|
||||
@Column({ name: 'code', type: 'varchar', length: 40, unique: true })
|
||||
code!: string;
|
||||
|
||||
@Column({ name: 'name', type: 'varchar', length: 160 })
|
||||
name!: string;
|
||||
|
||||
@Column({ name: 'description', type: 'text', nullable: true })
|
||||
description?: string | null;
|
||||
|
||||
@Column({ name: 'facility_type', type: 'varchar', length: 32 })
|
||||
facilityType!: FacilityType;
|
||||
|
||||
@Column({ name: 'facility_status', type: 'varchar', length: 32, default: 'ACTIVE' })
|
||||
facilityStatus!: FacilityStatus;
|
||||
|
||||
@Column({ name: 'location_name', type: 'varchar', length: 200, nullable: true })
|
||||
locationName?: string | null;
|
||||
|
||||
@Column({ name: 'country', type: 'varchar', length: 100, nullable: true })
|
||||
country?: string | null;
|
||||
|
||||
@Column({ name: 'city', type: 'varchar', length: 100, nullable: true })
|
||||
city?: string | null;
|
||||
|
||||
@Column({ name: 'address', type: 'text', nullable: true })
|
||||
address?: string | null;
|
||||
|
||||
@Column({ name: 'latitude', type: 'numeric', precision: 10, scale: 8, nullable: true })
|
||||
latitude?: number | null;
|
||||
|
||||
@Column({ name: 'longitude', type: 'numeric', precision: 11, scale: 8, nullable: true })
|
||||
longitude?: number | null;
|
||||
|
||||
@Column({ name: 'capacity', type: 'numeric', precision: 14, scale: 3, nullable: true })
|
||||
capacity?: number | null;
|
||||
|
||||
@Column({ name: 'is_active', type: 'boolean', default: true })
|
||||
isActive!: boolean;
|
||||
|
||||
@Column({ name: 'notes', type: 'text', nullable: true })
|
||||
notes?: string | null;
|
||||
|
||||
@OneToMany(() => Warehouse, (warehouse) => warehouse.facility)
|
||||
warehouses?: Warehouse[];
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Body, Controller, Delete, Get, HttpCode, Param, Patch, Post } from '@nestjs/common';
|
||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { CreateFacilityDto } from './dto/create-facility.dto';
|
||||
import { UpdateFacilityDto } from './dto/update-facility.dto';
|
||||
import { Facility } from './entities/facility.entity';
|
||||
import { FacilitiesService } from './facilities.service';
|
||||
|
||||
@ApiTags('Facilities')
|
||||
@Controller('facilities')
|
||||
export class FacilitiesController {
|
||||
constructor(private readonly facilitiesService: FacilitiesService) {}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: 'Create a new facility' })
|
||||
async create(@Body() createFacilityDto: CreateFacilityDto): Promise<Facility> {
|
||||
return this.facilitiesService.create(createFacilityDto);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List all facilities' })
|
||||
async findAll(): Promise<Facility[]> {
|
||||
return this.facilitiesService.findAll();
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get a facility by ID' })
|
||||
async findOne(@Param('id') id: string): Promise<Facility | null> {
|
||||
return this.facilitiesService.findOne(id);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@ApiOperation({ summary: 'Update a facility' })
|
||||
async update(@Param('id') id: string, @Body() updateFacilityDto: UpdateFacilityDto): Promise<Facility | null> {
|
||||
return this.facilitiesService.update(id, updateFacilityDto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@HttpCode(204)
|
||||
@ApiOperation({ summary: 'Delete a facility (soft delete)' })
|
||||
async remove(@Param('id') id: string): Promise<void> {
|
||||
return this.facilitiesService.remove(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { Facility } from './entities/facility.entity';
|
||||
import { FacilitiesController } from './facilities.controller';
|
||||
import { FacilitiesRepository } from './facilities.repository';
|
||||
import { FacilitiesService } from './facilities.service';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Facility])],
|
||||
controllers: [FacilitiesController],
|
||||
providers: [FacilitiesService, FacilitiesRepository],
|
||||
exports: [FacilitiesService, FacilitiesRepository],
|
||||
})
|
||||
export class FacilitiesModule {}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { BaseRepository } from '@edr/api-common';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { Facility } from './entities/facility.entity';
|
||||
|
||||
@Injectable()
|
||||
export class FacilitiesRepository extends BaseRepository<Facility> {
|
||||
constructor(@InjectRepository(Facility) repository: Repository<Facility>) {
|
||||
super(repository);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { CreateFacilityDto } from './dto/create-facility.dto';
|
||||
import { UpdateFacilityDto } from './dto/update-facility.dto';
|
||||
import { Facility } from './entities/facility.entity';
|
||||
import { FacilitiesRepository } from './facilities.repository';
|
||||
|
||||
@Injectable()
|
||||
export class FacilitiesService {
|
||||
constructor(private readonly facilitiesRepository: FacilitiesRepository) {}
|
||||
|
||||
async create(createFacilityDto: CreateFacilityDto): Promise<Facility> {
|
||||
return this.facilitiesRepository.create(createFacilityDto);
|
||||
}
|
||||
|
||||
async findAll(): Promise<Facility[]> {
|
||||
return this.facilitiesRepository.findAll({ relations: ['warehouses'] });
|
||||
}
|
||||
|
||||
async findOne(id: string): Promise<Facility | null> {
|
||||
return this.facilitiesRepository.findById(id);
|
||||
}
|
||||
|
||||
async update(id: string, updateFacilityDto: UpdateFacilityDto): Promise<Facility | null> {
|
||||
return this.facilitiesRepository.update(id, updateFacilityDto);
|
||||
}
|
||||
|
||||
async remove(id: string): Promise<void> {
|
||||
return this.facilitiesRepository.softDelete(id);
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from "@nestjs/common";
|
||||
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
|
||||
import { FreightAdmin } from "../../common/booking-guards";
|
||||
import { CreateFileUploadFieldDto } from "./dto/create-file-upload-field.dto";
|
||||
import { CreateFileUploadSettingDto } from "./dto/create-file-upload-setting.dto";
|
||||
import { UpdateFileUploadFieldDto } from "./dto/update-file-upload-field.dto";
|
||||
@@ -24,6 +25,9 @@ import { FileUploadSettingsService } from "./file-upload-settings.service";
|
||||
export class FileUploadSettingsController {
|
||||
constructor(private readonly service: FileUploadSettingsService) {}
|
||||
|
||||
// Reads stay open: the customer portal fetches these to render dynamic
|
||||
// upload forms (by-code / by-entity). Only writes are admin-guarded.
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: "List all file upload settings" })
|
||||
list() {
|
||||
@@ -49,12 +53,14 @@ export class FileUploadSettingsController {
|
||||
}
|
||||
|
||||
@Post()
|
||||
@FreightAdmin()
|
||||
@ApiOperation({ summary: "Create a new file upload setting" })
|
||||
create(@Body() dto: CreateFileUploadSettingDto) {
|
||||
return this.service.create(dto);
|
||||
}
|
||||
|
||||
@Patch(":id")
|
||||
@FreightAdmin()
|
||||
@ApiOperation({ summary: "Update a file upload setting's metadata" })
|
||||
update(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@@ -64,6 +70,7 @@ export class FileUploadSettingsController {
|
||||
}
|
||||
|
||||
@Delete(":id")
|
||||
@FreightAdmin()
|
||||
@ApiOperation({ summary: "Soft-delete a file upload setting" })
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
remove(@Param("id", ParseUUIDPipe) id: string) {
|
||||
@@ -73,6 +80,7 @@ export class FileUploadSettingsController {
|
||||
/* ------------------------- field routes ------------------------- */
|
||||
|
||||
@Put(":id/fields")
|
||||
@FreightAdmin()
|
||||
@ApiOperation({ summary: "Replace the full field list for a setting" })
|
||||
replaceFields(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@@ -82,6 +90,7 @@ export class FileUploadSettingsController {
|
||||
}
|
||||
|
||||
@Post(":id/fields")
|
||||
@FreightAdmin()
|
||||
@ApiOperation({ summary: "Append a single field to a setting" })
|
||||
addField(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@@ -91,6 +100,7 @@ export class FileUploadSettingsController {
|
||||
}
|
||||
|
||||
@Patch("fields/:fieldId")
|
||||
@FreightAdmin()
|
||||
@ApiOperation({ summary: "Update a single field" })
|
||||
updateField(
|
||||
@Param("fieldId", ParseUUIDPipe) fieldId: string,
|
||||
@@ -100,6 +110,7 @@ export class FileUploadSettingsController {
|
||||
}
|
||||
|
||||
@Delete("fields/:fieldId")
|
||||
@FreightAdmin()
|
||||
@ApiOperation({ summary: "Soft-delete a single field" })
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
removeField(@Param("fieldId", ParseUUIDPipe) fieldId: string) {
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsIn, IsNumber, IsOptional, IsString, MaxLength, Min } from 'class-validator';
|
||||
import { IsIn, IsNumber, IsOptional, IsString, MaxLength, Min, IsUUID } from 'class-validator';
|
||||
|
||||
import { LOCOMOTIVE_STATUSES, LOCOMOTIVE_TYPES } from '../entities/locomotive.entity';
|
||||
import {
|
||||
LOCOMOTIVE_STATUSES,
|
||||
LOCOMOTIVE_TYPES,
|
||||
} from '../entities/locomotive.entity';
|
||||
|
||||
export class CreateLocomotiveDto {
|
||||
@ApiProperty({ example: 'LOCO-001' })
|
||||
@@ -24,6 +27,11 @@ export class CreateLocomotiveDto {
|
||||
@IsIn([...LOCOMOTIVE_STATUSES])
|
||||
status!: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Current yard location' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
currentYardId?: string;
|
||||
|
||||
@ApiProperty({ example: 3500 })
|
||||
@Transform(({ value }) => Number(value))
|
||||
@IsNumber()
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsIn, IsOptional } from 'class-validator';
|
||||
import { IsIn, IsOptional, IsUUID } from 'class-validator';
|
||||
|
||||
import { LOCOMOTIVE_STATUSES, LOCOMOTIVE_TYPES } from '../entities/locomotive.entity';
|
||||
import {
|
||||
LOCOMOTIVE_STATUSES,
|
||||
LOCOMOTIVE_TYPES,
|
||||
} from '../entities/locomotive.entity';
|
||||
|
||||
export class FilterLocomotivesDto {
|
||||
@ApiPropertyOptional({ enum: LOCOMOTIVE_STATUSES })
|
||||
@@ -13,4 +16,9 @@ export class FilterLocomotivesDto {
|
||||
@IsOptional()
|
||||
@IsIn([...LOCOMOTIVE_TYPES])
|
||||
locomotiveType?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Filter by current yard' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
currentYardId?: string;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index, OneToMany } from 'typeorm';
|
||||
import { Column, Entity, Index, OneToMany, ManyToOne, JoinColumn } from 'typeorm';
|
||||
|
||||
import { TrainSet } from '../../train-sets/entities/train-set.entity';
|
||||
import { Yard } from '../../rule-engine/entities/yard.entity';
|
||||
|
||||
export const LOCOMOTIVE_STATUSES = [
|
||||
'AVAILABLE',
|
||||
@@ -21,6 +22,7 @@ export type LocomotiveType = (typeof LOCOMOTIVE_TYPES)[number];
|
||||
@Entity({ schema: 'freight', name: 'locomotives' })
|
||||
@Index(['code'])
|
||||
@Index(['status'])
|
||||
@Index(['currentYardId'])
|
||||
export class Locomotive extends BaseEntity {
|
||||
@Column({ name: 'code', type: 'varchar', length: 32, unique: true })
|
||||
code!: string;
|
||||
@@ -40,6 +42,13 @@ export class Locomotive extends BaseEntity {
|
||||
@Column({ name: 'status', type: 'varchar', length: 20, default: 'AVAILABLE' })
|
||||
status!: LocomotiveStatus;
|
||||
|
||||
@Column({ name: 'current_yard_id', type: 'uuid', nullable: true })
|
||||
currentYardId!: string | null;
|
||||
|
||||
@ManyToOne(() => Yard, { nullable: true, onDelete: 'SET NULL' })
|
||||
@JoinColumn({ name: 'current_yard_id' })
|
||||
currentYard?: Yard | null;
|
||||
|
||||
@Column({ name: 'power_kw', type: 'numeric', precision: 10, scale: 3, nullable: true })
|
||||
powerKw?: number | null;
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { FleetManage, FleetView } from '../../common/booking-guards';
|
||||
import { CreateLocomotiveDto } from './dto/create-locomotive.dto';
|
||||
import { FilterLocomotivesDto } from './dto/filter-locomotives.dto';
|
||||
import { UpdateLocomotiveDto } from './dto/update-locomotive.dto';
|
||||
@@ -9,6 +10,7 @@ import { LocomotivesService } from './locomotives.service';
|
||||
@ApiTags('locomotives')
|
||||
@ApiBearerAuth()
|
||||
@Controller('locomotives')
|
||||
@FleetView()
|
||||
export class LocomotivesController {
|
||||
constructor(private readonly locomotivesService: LocomotivesService) {}
|
||||
|
||||
@@ -25,18 +27,21 @@ export class LocomotivesController {
|
||||
}
|
||||
|
||||
@Post()
|
||||
@FleetManage()
|
||||
@ApiOperation({ summary: 'Create a locomotive' })
|
||||
create(@Body() dto: CreateLocomotiveDto) {
|
||||
return this.locomotivesService.create(dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@FleetManage()
|
||||
@ApiOperation({ summary: 'Update a locomotive' })
|
||||
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateLocomotiveDto) {
|
||||
return this.locomotivesService.update(id, dto);
|
||||
}
|
||||
|
||||
@Post(':id/decommission')
|
||||
@FleetManage()
|
||||
@ApiOperation({ summary: 'Decommission a locomotive' })
|
||||
decommission(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.locomotivesService.decommission(id);
|
||||
|
||||
@@ -3,7 +3,12 @@ import { ConflictException, Injectable, NotFoundException } from '@nestjs/common
|
||||
import { CreateLocomotiveDto } from './dto/create-locomotive.dto';
|
||||
import { FilterLocomotivesDto } from './dto/filter-locomotives.dto';
|
||||
import { UpdateLocomotiveDto } from './dto/update-locomotive.dto';
|
||||
import { Locomotive, type LocomotiveStatus, type LocomotiveType } from './entities/locomotive.entity';
|
||||
|
||||
import {
|
||||
Locomotive,
|
||||
type LocomotiveStatus,
|
||||
type LocomotiveType,
|
||||
} from './entities/locomotive.entity';
|
||||
import { LocomotivesRepository } from './locomotives.repository';
|
||||
|
||||
@Injectable()
|
||||
@@ -17,7 +22,9 @@ export class LocomotivesService {
|
||||
...(filter.locomotiveType
|
||||
? { locomotiveType: filter.locomotiveType as LocomotiveType }
|
||||
: {}),
|
||||
...(filter.currentYardId ? { currentYardId: filter.currentYardId } : {}),
|
||||
},
|
||||
relations: { currentYard: true },
|
||||
order: { code: 'ASC' },
|
||||
});
|
||||
}
|
||||
@@ -34,6 +41,7 @@ export class LocomotivesService {
|
||||
name: dto.name?.trim() || null,
|
||||
locomotiveType: dto.locomotiveType as LocomotiveType,
|
||||
status: dto.status as LocomotiveStatus,
|
||||
currentYardId: dto.currentYardId ?? null,
|
||||
maxPullWeightTons: dto.maxPullWeightTons,
|
||||
maxTrainLengthMeters: dto.maxTrainLengthMeters,
|
||||
powerKw: dto.powerKw ?? null,
|
||||
@@ -43,7 +51,9 @@ export class LocomotivesService {
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<Locomotive> {
|
||||
const locomotive = await this.locomotivesRepository.findById(id);
|
||||
const locomotive = await this.locomotivesRepository.findById(id, {
|
||||
relations: { currentYard: true },
|
||||
});
|
||||
|
||||
if (!locomotive) {
|
||||
throw new NotFoundException(`Locomotive ${id} not found`);
|
||||
@@ -67,6 +77,10 @@ export class LocomotivesService {
|
||||
locomotiveType:
|
||||
dto.locomotiveType === undefined ? locomotive.locomotiveType : dto.locomotiveType as LocomotiveType,
|
||||
status: dto.status === undefined ? locomotive.status : dto.status as LocomotiveStatus,
|
||||
currentYardId:
|
||||
dto.currentYardId === undefined
|
||||
? locomotive.currentYardId
|
||||
: (dto.currentYardId ?? null),
|
||||
name: dto.name === undefined ? locomotive.name : dto.name?.trim() || null,
|
||||
powerKw: dto.powerKw === undefined ? locomotive.powerKw : dto.powerKw ?? null,
|
||||
tractionForceKn:
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
} from "typeorm";
|
||||
import { PaymentEntity } from "./payment.entity";
|
||||
|
||||
@Entity({ schema: "freight", name: "payment_refunds" })
|
||||
export class PaymentRefundEntity {
|
||||
@PrimaryGeneratedColumn("uuid")
|
||||
id!: string;
|
||||
|
||||
@Column({ type: "uuid", name: "payment_id" })
|
||||
paymentId!: string;
|
||||
|
||||
@Column({ type: "int", name: "amount_minor" })
|
||||
amountMinor!: number;
|
||||
|
||||
@Column({ type: "varchar", length: 255, nullable: true })
|
||||
reason?: string;
|
||||
|
||||
@Column({ type: "varchar", length: 255, nullable: true, name: "provider_refund_id" })
|
||||
providerRefundId?: string;
|
||||
|
||||
@Column({ type: "varchar", length: 50 })
|
||||
status!: string;
|
||||
|
||||
@CreateDateColumn({ name: "created_at" })
|
||||
createdAt!: Date;
|
||||
|
||||
@ManyToOne(() => PaymentEntity, (payment) => payment.refunds, { onDelete: "RESTRICT" })
|
||||
@JoinColumn({ name: "payment_id" })
|
||||
payment!: PaymentEntity;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
PrimaryGeneratedColumn,
|
||||
Unique,
|
||||
} from "typeorm";
|
||||
|
||||
export type WebhookPaymentMethod = "telebirr" | "cbe-birr" | "ebirr";
|
||||
|
||||
@Entity({ schema: "freight", name: "payment_webhook_events" })
|
||||
@Unique(["provider", "externalEventId"])
|
||||
@Index(["merchantOrderId"])
|
||||
export class PaymentWebhookEventEntity {
|
||||
@PrimaryGeneratedColumn("uuid")
|
||||
id!: string;
|
||||
|
||||
@Column({ type: "enum", enum: ["telebirr", "cbe-birr", "ebirr"] })
|
||||
provider!: WebhookPaymentMethod;
|
||||
|
||||
@Column({ type: "varchar", length: 255, name: "external_event_id" })
|
||||
externalEventId!: string;
|
||||
|
||||
@Column({ type: "varchar", length: 255, nullable: true, name: "merchant_order_id" })
|
||||
merchantOrderId?: string;
|
||||
|
||||
@Column({ type: "varchar", length: 255, nullable: true, name: "provider_txn_id" })
|
||||
providerTxnId?: string;
|
||||
|
||||
@Column({ type: "boolean", name: "signature_valid" })
|
||||
signatureValid!: boolean;
|
||||
|
||||
@Column({ type: "varchar", length: 100 })
|
||||
status!: string;
|
||||
|
||||
@Column({ type: "jsonb" })
|
||||
payload!: Record<string, unknown>;
|
||||
|
||||
@CreateDateColumn({ name: "received_at" })
|
||||
receivedAt!: Date;
|
||||
|
||||
@Column({ type: "timestamp", nullable: true, name: "processed_at" })
|
||||
processedAt?: Date;
|
||||
|
||||
@Column({ type: "text", nullable: true, name: "processing_error" })
|
||||
processingError?: string;
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import { BaseEntity, Column, CreateDateColumn, Entity, PrimaryGeneratedColumn } from "typeorm";
|
||||
import { BaseEntity, Column, CreateDateColumn, Entity, OneToMany, PrimaryGeneratedColumn } from "typeorm";
|
||||
import { PaymentRefundEntity } from "./payment-refund.entity";
|
||||
|
||||
|
||||
type PaymentType = "booking"
|
||||
type PaymentMethod = "telebirr" | "cbe-birr" | "ebirr"
|
||||
type PaymentMethod = "telebirr" | "cbe-birr" | "ebirr" | "waafi" | "card" | "dmoney" | "cac-bank"
|
||||
type Currency = "ETB" | "USD"
|
||||
export type PaymentStatus = "action-required" | "processing" | "success" | "failed" | "canceled" | "refunded"
|
||||
|
||||
@@ -17,7 +18,7 @@ export class PaymentEntity extends BaseEntity {
|
||||
@Column({ type: "enum", enum: ["booking"] })
|
||||
type!: PaymentType;
|
||||
|
||||
@Column({ type: "enum", enum: ["telebirr", "cbe-birr", "ebirr"] })
|
||||
@Column({ type: "enum", enum: ["telebirr", "cbe-birr", "ebirr", "waafi", "card", "dmoney", "cac-bank"] })
|
||||
method!: PaymentMethod
|
||||
|
||||
@Column({ type: "enum", enum: ["ETB", "USD"] })
|
||||
@@ -32,13 +33,13 @@ export class PaymentEntity extends BaseEntity {
|
||||
@Column({ type: "jsonb", default: {}, name: "raw_initiation" })
|
||||
rawInitiation?: Record<string, unknown>
|
||||
|
||||
@Column({ type: "jsonb", name: "client_action" })
|
||||
@Column({ type: "jsonb", nullable: true, name: "client_action" })
|
||||
clientAction?: Record<string, unknown>;
|
||||
|
||||
@Column({ type: "varchar", length: 255, unique: true, name: "merchant_order_id", })
|
||||
merchantOrderId!: string
|
||||
|
||||
@Column({ type: "varchar", length: 255, unique: true, name: "transaction_id", })
|
||||
@Column({ type: "varchar", length: 255, unique: true, nullable: true, name: "transaction_id", })
|
||||
transactionId?: string
|
||||
|
||||
@Column({ type: "enum", enum: ["action-required", "processing", "success", "failed", "canceled", "refunded"], default: "action-required" })
|
||||
@@ -62,4 +63,7 @@ export class PaymentEntity extends BaseEntity {
|
||||
@CreateDateColumn({ name: "created_at" })
|
||||
createdAt!: Date
|
||||
|
||||
@OneToMany(() => PaymentRefundEntity, (refund) => refund.payment)
|
||||
refunds!: PaymentRefundEntity[];
|
||||
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
import { ServiceAuthGuard } from "../../common/guards/service-auth.guard";
|
||||
import { PaymentEventDto, MarkPaidResponseDto } from "./internal-payment.dto";
|
||||
import { PaymentService } from "./payment.service";
|
||||
|
||||
/**
|
||||
* Consumer side of the payment microservice's outbox relay.
|
||||
* Only the payment service may call this (shared SERVICE_AUTH_TOKEN).
|
||||
* Idempotent by design — the relay delivers at-least-once, so duplicates must be harmless.
|
||||
* Becomes a queue consumer via PaymentEventsConsumer when RabbitMQ is available;
|
||||
* this HTTP endpoint remains as a transport-agnostic fallback.
|
||||
*/
|
||||
@ApiTags("Internal Payments")
|
||||
@UseGuards(ServiceAuthGuard)
|
||||
@Controller("internal/payments")
|
||||
export class InternalPaymentController {
|
||||
constructor(private readonly paymentService: PaymentService) { }
|
||||
|
||||
@Post("mark-paid")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({
|
||||
summary: "Apply a payment.succeeded / payment.failed event from the payment service (idempotent)",
|
||||
})
|
||||
async markPaid(@Body() event: PaymentEventDto): Promise<MarkPaidResponseDto> {
|
||||
return this.paymentService.handlePaymentEvent(event);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import {
|
||||
IsEnum,
|
||||
IsIn,
|
||||
IsInt,
|
||||
IsISO8601,
|
||||
IsOptional,
|
||||
IsPositive,
|
||||
IsString,
|
||||
IsUUID,
|
||||
} from "class-validator";
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import {
|
||||
PaymentEventType,
|
||||
PaymentReferenceType,
|
||||
PaymentService,
|
||||
ProviderMethod,
|
||||
} from "@edr/types";
|
||||
|
||||
/**
|
||||
* Wire shape of the PaymentEvent envelope (@edr/types) delivered by the payment
|
||||
* microservice's outbox relay. Delivery is at-least-once — the consumer is idempotent.
|
||||
*/
|
||||
export class PaymentEventDto {
|
||||
@ApiProperty({ enum: [1] }) @IsIn([1]) version!: 1;
|
||||
@ApiProperty() @IsUUID() eventId!: string;
|
||||
@ApiProperty({ enum: ["payment.succeeded", "payment.failed"] })
|
||||
@IsIn(["payment.succeeded", "payment.failed"])
|
||||
eventType!: PaymentEventType;
|
||||
|
||||
@ApiProperty() @IsISO8601() occurredAt!: string;
|
||||
@ApiProperty({ enum: PaymentService }) @IsEnum(PaymentService) service!: string;
|
||||
@ApiProperty() @IsUUID() intentId!: string;
|
||||
@ApiProperty({ enum: PaymentReferenceType })
|
||||
@IsEnum(PaymentReferenceType)
|
||||
referenceType!: string;
|
||||
|
||||
@ApiProperty() @IsString() referenceId!: string;
|
||||
@ApiProperty() @IsString() merchantOrderId!: string;
|
||||
@ApiProperty({ enum: ProviderMethod }) @IsEnum(ProviderMethod) provider!: string;
|
||||
@ApiProperty() @IsInt() @IsPositive() amountMinor!: number;
|
||||
@ApiProperty() @IsString() currency!: string;
|
||||
|
||||
@ApiPropertyOptional() @IsOptional() @IsString() providerTxnId?: string;
|
||||
@ApiPropertyOptional() @IsOptional() @IsISO8601() paidAt?: string;
|
||||
@ApiPropertyOptional() @IsOptional() @IsString() failureCode?: string;
|
||||
@ApiPropertyOptional() @IsOptional() @IsString() failureMessage?: string;
|
||||
}
|
||||
|
||||
export class MarkPaidResponseDto {
|
||||
@ApiProperty() processed!: boolean;
|
||||
@ApiPropertyOptional() alreadyFinalized?: boolean;
|
||||
@ApiPropertyOptional() reason?: string;
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { BadGatewayException, Injectable, Logger } from "@nestjs/common";
|
||||
import { HttpService } from "@nestjs/axios";
|
||||
import { AxiosError } from "axios";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
import {
|
||||
InitiatePaymentRequest,
|
||||
PaymentIntentSnapshot,
|
||||
PaymentReferenceType,
|
||||
PaymentService,
|
||||
} from "@edr/types";
|
||||
|
||||
/**
|
||||
* Thin HTTP client for the payment microservice (apps/edr-payment-api).
|
||||
* Domain validation stays in the freight API; provider calls, intents,
|
||||
* and webhooks live in the payment service.
|
||||
*/
|
||||
@Injectable()
|
||||
export class PaymentClientService {
|
||||
private readonly logger = new Logger(PaymentClientService.name);
|
||||
private readonly baseUrl = (
|
||||
process.env.PAYMENT_API_URL ?? "https://paymentcallback.triaplc.com"
|
||||
).replace(/\/$/, "");
|
||||
private readonly serviceToken = process.env.SERVICE_AUTH_TOKEN ?? "";
|
||||
|
||||
constructor(private readonly http: HttpService) { }
|
||||
|
||||
/** POST /payments/initiate — idempotent per (service, referenceType, referenceId). */
|
||||
async initiate(request: InitiatePaymentRequest): Promise<PaymentIntentSnapshot> {
|
||||
return this.call("POST", "/payments/initiate", request);
|
||||
}
|
||||
|
||||
/** GET /payments/intents?… — active intent by domain reference; null when none exists. */
|
||||
async getIntentByReference(
|
||||
referenceType: PaymentReferenceType,
|
||||
referenceId: string,
|
||||
): Promise<PaymentIntentSnapshot | null> {
|
||||
const query = new URLSearchParams({
|
||||
service: PaymentService.FREIGHT,
|
||||
referenceType,
|
||||
referenceId,
|
||||
});
|
||||
try {
|
||||
return await this.call("GET", `/payments/intents?${query.toString()}`);
|
||||
} catch (err) {
|
||||
if (err instanceof AxiosError && err.response?.status === 404) return null;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
private async call<T>(method: "GET" | "POST", path: string, body?: unknown): Promise<T> {
|
||||
const url = `${this.baseUrl}${path}`;
|
||||
try {
|
||||
const response = await firstValueFrom(
|
||||
this.http.request<T>({
|
||||
method,
|
||||
url,
|
||||
data: body,
|
||||
headers: this.serviceToken
|
||||
? { "x-service-token": this.serviceToken }
|
||||
: {},
|
||||
}),
|
||||
);
|
||||
return response.data;
|
||||
} catch (err) {
|
||||
if (err instanceof AxiosError && err.response) {
|
||||
if (err.response.status === 404) throw err;
|
||||
const detail =
|
||||
(err.response.data as { message?: string | string[] })?.message ??
|
||||
err.message;
|
||||
this.logger.error(
|
||||
`payment service ${method} ${path} → ${err.response.status}: ${detail}`,
|
||||
);
|
||||
throw new BadGatewayException(`Payment service error: ${detail}`);
|
||||
}
|
||||
const message = err instanceof Error && err.message ? err.message : String(err);
|
||||
this.logger.error(`payment service unreachable (${method} ${path}): ${message}`);
|
||||
throw new BadGatewayException("Payment service unreachable");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import { Nack, RabbitSubscribe } from "@golevelup/nestjs-rabbitmq";
|
||||
import { Public } from "@edr/api-common";
|
||||
import {
|
||||
PAYMENT_EVENTS_DLX,
|
||||
PAYMENT_EVENTS_EXCHANGE,
|
||||
PAYMENT_QUEUES,
|
||||
PaymentEvent,
|
||||
PaymentService,
|
||||
paymentServiceBindingPattern,
|
||||
} from "@edr/types";
|
||||
import { PaymentEventDto } from "./internal-payment.dto";
|
||||
import { PaymentService as PaymentSvc } from "./payment.service";
|
||||
|
||||
const FREIGHT_QUEUE = PAYMENT_QUEUES[PaymentService.FREIGHT];
|
||||
|
||||
@Injectable()
|
||||
export class PaymentEventsConsumer {
|
||||
private readonly logger = new Logger(PaymentEventsConsumer.name);
|
||||
|
||||
constructor(private readonly paymentService: PaymentSvc) { }
|
||||
|
||||
@Public()
|
||||
@RabbitSubscribe({
|
||||
exchange: PAYMENT_EVENTS_EXCHANGE,
|
||||
routingKey: paymentServiceBindingPattern(PaymentService.FREIGHT),
|
||||
queue: FREIGHT_QUEUE.main,
|
||||
queueOptions: {
|
||||
durable: true,
|
||||
deadLetterExchange: PAYMENT_EVENTS_DLX,
|
||||
},
|
||||
})
|
||||
async handle(event: PaymentEvent): Promise<Nack | void> {
|
||||
try {
|
||||
const result = await this.paymentService.handlePaymentEvent(
|
||||
event as unknown as PaymentEventDto,
|
||||
);
|
||||
this.logger.log(
|
||||
`processed ${event.eventType} (${event.eventId}) ref=${event.referenceId}: ${JSON.stringify(result)}`,
|
||||
);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
this.logger.error(
|
||||
`DEAD-LETTERING ${event.eventType} (${event.eventId}) ref=${event.referenceId}: ${message}`,
|
||||
);
|
||||
return new Nack(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,44 +1,219 @@
|
||||
import { Controller, Get, NotFoundException, Param, Post, Res } from "@nestjs/common";
|
||||
import { PaymentService } from "./payment.service";
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
HttpStatus,
|
||||
Param,
|
||||
Post,
|
||||
Query,
|
||||
Res,
|
||||
} from "@nestjs/common";
|
||||
import {
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
ApiQuery,
|
||||
ApiOkResponse,
|
||||
ApiProduces,
|
||||
} from "@nestjs/swagger";
|
||||
import { Response } from "express";
|
||||
import { Public } from "@edr/api-common";
|
||||
import { Response } from "express"
|
||||
import { BookingView, FreightAdmin } from "../../common/booking-guards";
|
||||
import { PaymentService } from "./payment.service";
|
||||
import {
|
||||
InitiatePaymentDto,
|
||||
InitiateResponseDto,
|
||||
IntentStatusDto,
|
||||
PaymentMethodTypeEnum,
|
||||
PaymentPlatformDto,
|
||||
RefundDto,
|
||||
} from "./payments.dto";
|
||||
|
||||
@Public()
|
||||
@ApiTags("Payment")
|
||||
@Controller("payments")
|
||||
export class PaymentController {
|
||||
constructor(private readonly paymentService: PaymentService,) { }
|
||||
constructor(private readonly paymentService: PaymentService) { }
|
||||
|
||||
@Post("/initiate")
|
||||
initiate() {
|
||||
return this.paymentService.initBookingTelebirr("123", "web")
|
||||
}
|
||||
|
||||
@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("summary")
|
||||
@BookingView()
|
||||
@ApiOperation({ summary: "Payment count/amount summary for dashboard cards" })
|
||||
getSummary() {
|
||||
return this.paymentService.getSummary();
|
||||
}
|
||||
|
||||
return res.send(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
@Get("all")
|
||||
@BookingView()
|
||||
@ApiOperation({ summary: "Get all payments with filters (view-only, any staff)" })
|
||||
@ApiQuery({ name: "search", required: false })
|
||||
@ApiQuery({ name: "status", required: false })
|
||||
@ApiQuery({ name: "method", required: false })
|
||||
@ApiQuery({ name: "page", required: false })
|
||||
@ApiQuery({ name: "pageSize", required: false })
|
||||
async getAll(
|
||||
@Query("search") search?: string,
|
||||
@Query("status") status?: string,
|
||||
@Query("method") method?: string,
|
||||
@Query("page") page?: string,
|
||||
@Query("pageSize") pageSize?: string,
|
||||
) {
|
||||
return this.paymentService.getAll({
|
||||
search,
|
||||
status,
|
||||
method,
|
||||
page: page ? parseInt(page) : 1,
|
||||
pageSize: pageSize ? parseInt(pageSize) : 10,
|
||||
});
|
||||
}
|
||||
|
||||
@Post("initiate")
|
||||
@ApiOperation({
|
||||
summary: "Initiate payment for a freight booking",
|
||||
description: `Initiates payment via the central payment microservice.\n\n**Supported methods:**\n- TELEBIRR — Ethiopian mobile money\n- CBE_BIRR — Commercial Bank of Ethiopia\n- EBIRR — Electronic payment gateway\n- WAAFI — Djibouti mobile money\n- CARD — Visa/Mastercard\n- DMONEY — Djibouti D-money\n- CAC_BANK — CAC Int Bank (OTP)`,
|
||||
})
|
||||
@ApiOkResponse({ type: InitiateResponseDto })
|
||||
initiatePayment(@Body() dto: InitiatePaymentDto) {
|
||||
return this.paymentService.initiatePayment(dto);
|
||||
}
|
||||
|
||||
@Get("intents/:bookingId")
|
||||
@ApiOperation({ summary: "Get payment intent status for a booking" })
|
||||
@ApiOkResponse({ type: IntentStatusDto })
|
||||
getIntent(@Param("bookingId") bookingId: string) {
|
||||
return this.paymentService.getIntentByBookingId(bookingId);
|
||||
}
|
||||
|
||||
@Post("refund")
|
||||
@FreightAdmin()
|
||||
@ApiOperation({ summary: "Refund a paid booking (staff/admin only)" })
|
||||
refund(@Body() dto: RefundDto) {
|
||||
return this.paymentService.refund(dto);
|
||||
}
|
||||
|
||||
@Get("checkout")
|
||||
@Public()
|
||||
@ApiOperation({
|
||||
summary: "Browser checkout redirect",
|
||||
description:
|
||||
"Initiates payment and returns an HTML page that auto-redirects to the provider checkout URL. Open directly in a browser tab.",
|
||||
})
|
||||
@ApiQuery({ name: "bookingId", required: true })
|
||||
@ApiQuery({ name: "method", enum: PaymentMethodTypeEnum, required: true })
|
||||
@ApiQuery({ name: "platform", enum: ["web", "mobile"], required: false })
|
||||
@ApiProduces("text/html")
|
||||
async checkout(
|
||||
@Query("bookingId") bookingId: string,
|
||||
@Query("method") method: PaymentMethodTypeEnum,
|
||||
@Query("platform") platform: PaymentPlatformDto = "web",
|
||||
@Res() res: Response,
|
||||
) {
|
||||
if (!bookingId) {
|
||||
return res
|
||||
.status(HttpStatus.BAD_REQUEST)
|
||||
.type("html")
|
||||
.send(this.buildErrorHtml("Missing required query parameter: bookingId"));
|
||||
}
|
||||
if (!method || !Object.values(PaymentMethodTypeEnum).includes(method)) {
|
||||
return res
|
||||
.status(HttpStatus.BAD_REQUEST)
|
||||
.type("html")
|
||||
.send(this.buildErrorHtml("Missing or invalid query parameter: method"));
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await this.paymentService.initiatePayment({ bookingId, method, platform });
|
||||
const url =
|
||||
result.clientAction?.type === "REDIRECT" ? result.clientAction.url : undefined;
|
||||
|
||||
if (url) {
|
||||
return res.status(HttpStatus.OK).type("html").send(this.buildRedirectHtml(url));
|
||||
}
|
||||
return res
|
||||
.status(HttpStatus.OK)
|
||||
.type("html")
|
||||
.send(this.buildStatusHtml(result.status, result.intentId));
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : "An unexpected error occurred";
|
||||
return res.status(HttpStatus.OK).type("html").send(this.buildErrorHtml(message));
|
||||
}
|
||||
}
|
||||
|
||||
@Get("receipt/:orderId")
|
||||
@Public()
|
||||
@ApiOperation({ summary: "Generate a payment receipt HTML page" })
|
||||
@ApiProduces("text/html")
|
||||
async receipt(@Param("orderId") orderId: string, @Res() res: Response) {
|
||||
const html = await this.paymentService.genReceiptHtml(orderId);
|
||||
return res.status(HttpStatus.OK).type("html").send(html);
|
||||
}
|
||||
|
||||
private buildRedirectHtml(url: string): string {
|
||||
const escaped = url.replace(/\"/g, """);
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>Redirecting...</title>
|
||||
<meta charset="UTF-8">
|
||||
<meta http-equiv="refresh" content="0;url=${escaped}">
|
||||
<title>Redirecting to payment…</title>
|
||||
<style>
|
||||
body { font-family: sans-serif; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; background: #f5f5f5; }
|
||||
.card { background: #fff; border-radius: 8px; padding: 40px; text-align: center; box-shadow: 0 2px 8px rgba(0,0,0,.1); max-width: 400px; }
|
||||
.spinner { width: 40px; height: 40px; border: 4px solid #e0e0e0; border-top-color: #1a73e8; border-radius: 50%; animation: spin .8s linear infinite; margin: 0 auto 20px; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
p { color: #555; margin: 0 0 16px; }
|
||||
a { color: #1a73e8; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<p>Redirecting...</p>
|
||||
|
||||
<script>
|
||||
window.location.href = "${payment.clientAction?.url}";
|
||||
</script>
|
||||
<div class="card">
|
||||
<div class="spinner"></div>
|
||||
<p>Redirecting to payment provider…</p>
|
||||
<p><a href="${escaped}">Click here if you are not redirected</a></p>
|
||||
</div>
|
||||
<script>window.location.href = "${escaped}";</script>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
}
|
||||
</html>`;
|
||||
}
|
||||
|
||||
private buildStatusHtml(status: string, intentId: string): string {
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Payment status</title>
|
||||
<style>
|
||||
body { font-family: sans-serif; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; background: #f5f5f5; }
|
||||
.card { background: #fff; border-radius: 8px; padding: 40px; text-align: center; box-shadow: 0 2px 8px rgba(0,0,0,.1); max-width: 400px; }
|
||||
.status { font-size: 1.1rem; font-weight: 600; color: #333; margin-bottom: 8px; }
|
||||
small { color: #888; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div class="status">${status}</div>
|
||||
<small>Intent: ${intentId}</small>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
private buildErrorHtml(message: string): string {
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Payment error</title>
|
||||
<style>
|
||||
body { font-family: sans-serif; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; background: #f5f5f5; }
|
||||
.card { background: #fff; border-radius: 8px; padding: 40px; text-align: center; box-shadow: 0 2px 8px rgba(0,0,0,.1); max-width: 400px; }
|
||||
.error { color: #d32f2f; font-weight: 600; margin-bottom: 8px; }
|
||||
p { color: #555; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div class="error">Payment could not be initiated</div>
|
||||
<p>${message}</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,63 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { PaymentService } from "./payment.service";
|
||||
import { Module, forwardRef } from "@nestjs/common";
|
||||
import { TypeOrmModule } from "@nestjs/typeorm";
|
||||
import { HttpModule } from "@nestjs/axios";
|
||||
import { ConfigModule, ConfigService } from "@nestjs/config";
|
||||
import { RabbitMQModule } from "@golevelup/nestjs-rabbitmq";
|
||||
import {
|
||||
PAYMENT_EVENTS_DLX,
|
||||
PAYMENT_EVENTS_EXCHANGE,
|
||||
PAYMENT_QUEUES,
|
||||
PaymentService as PaymentServiceEnum,
|
||||
paymentServiceBindingPattern,
|
||||
} from "@edr/types";
|
||||
import { PaymentService } from "./payment.service";
|
||||
import { PaymentClientService } from "./payment-client.service";
|
||||
import { PaymentController } from "./payment.controller";
|
||||
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";
|
||||
import { PaymentEventsConsumer } from "./payment-events.consumer";
|
||||
import { InternalPaymentController } from "./internal-payment.controller";
|
||||
import { ServiceAuthGuard } from "../../common/guards/service-auth.guard";
|
||||
import { TrainSchedulingModule } from "../train-scheduling/train-scheduling.module";
|
||||
import { PaymentWebhookEventEntity } from "./entities/payment-webhook-event.entity";
|
||||
import { PaymentRefundEntity } from "./entities/payment-refund.entity";
|
||||
|
||||
const FREIGHT_QUEUE = PAYMENT_QUEUES[PaymentServiceEnum.FREIGHT];
|
||||
|
||||
@Module({
|
||||
imports: [HttpModule, ConfigModule],
|
||||
providers: [PaymentRepository, PaymentService, TelebirrWebhookService, TelebirrProvider],
|
||||
controllers: [PaymentController, WebhookController],
|
||||
exports: [PaymentService]
|
||||
imports: [
|
||||
HttpModule.register({ timeout: 10_000 }),
|
||||
ConfigModule,
|
||||
forwardRef(() => TrainSchedulingModule),
|
||||
TypeOrmModule.forFeature([PaymentWebhookEventEntity, PaymentRefundEntity]),
|
||||
RabbitMQModule.forRootAsync({
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService) => ({
|
||||
uri: config.get<string>("rabbitmq.url") as string,
|
||||
exchanges: [
|
||||
{ name: PAYMENT_EVENTS_EXCHANGE, type: "topic", options: { durable: true } },
|
||||
{ name: PAYMENT_EVENTS_DLX, type: "topic", options: { durable: true } },
|
||||
],
|
||||
queues: [
|
||||
{
|
||||
name: FREIGHT_QUEUE.dlq,
|
||||
exchange: PAYMENT_EVENTS_DLX,
|
||||
routingKey: paymentServiceBindingPattern(PaymentServiceEnum.FREIGHT),
|
||||
options: { durable: true },
|
||||
},
|
||||
],
|
||||
prefetchCount: config.get<number>("rabbitmq.prefetch") ?? 10,
|
||||
connectionInitOptions: { wait: false },
|
||||
}),
|
||||
}),
|
||||
],
|
||||
providers: [
|
||||
PaymentRepository,
|
||||
PaymentService,
|
||||
PaymentClientService,
|
||||
PaymentEventsConsumer,
|
||||
ServiceAuthGuard,
|
||||
],
|
||||
controllers: [PaymentController, InternalPaymentController],
|
||||
exports: [PaymentService],
|
||||
})
|
||||
export class PaymentModule { }
|
||||
export class PaymentModule { }
|
||||
|
||||
@@ -57,6 +57,8 @@ export class PaymentRepository {
|
||||
.getOne();
|
||||
}
|
||||
|
||||
|
||||
createQueryBuilder(alias: string) {
|
||||
return this.paymentRepo.createQueryBuilder(alias);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,12 +1,16 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
forwardRef,
|
||||
Inject,
|
||||
Injectable,
|
||||
InternalServerErrorException,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import { DataSource } from "typeorm";
|
||||
import { PaymentEntity } from "./entities/payment.entity";
|
||||
import { PaymentRepository } from "./payment.repository";
|
||||
import { PaymentClientService } from "./payment-client.service";
|
||||
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
@@ -16,125 +20,344 @@ import { Booking } from "../bookings/entities/booking.entity";
|
||||
|
||||
import {
|
||||
ClientAction,
|
||||
createMerchantOrderId,
|
||||
ProviderPaymentStatus,
|
||||
TelebirrProvider,
|
||||
} from "@edr/payment-providers";
|
||||
import { ProviderInitiationInput } from "@edr/types"
|
||||
import { InitiateResponseDto, PaymentPlatformDto } from "./payments.dto";
|
||||
import {
|
||||
PaymentService as PaymentServiceEnum,
|
||||
PaymentReferenceType,
|
||||
PaymentIntentSnapshot,
|
||||
ProviderMethod,
|
||||
} from "@edr/types";
|
||||
import {
|
||||
InitiatePaymentDto,
|
||||
InitiateResponseDto,
|
||||
IntentStatusDto,
|
||||
RefundDto,
|
||||
} from "./payments.dto";
|
||||
import { BookingBatchService } from "../train-scheduling/booking-batch.service";
|
||||
|
||||
const DEFAULT_CURRENCY = "ETB";
|
||||
const STATUS_MAP: Record<string, ProviderPaymentStatus> = {
|
||||
"action-required": ProviderPaymentStatus.REQUIRES_ACTION,
|
||||
"processing": ProviderPaymentStatus.PROCESSING,
|
||||
"success": ProviderPaymentStatus.SUCCEEDED,
|
||||
"failed": ProviderPaymentStatus.FAILED,
|
||||
"canceled": ProviderPaymentStatus.CANCELLED,
|
||||
"refunded": ProviderPaymentStatus.CANCELLED,
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class PaymentService {
|
||||
private readonly logger = new Logger(PaymentService.name);
|
||||
|
||||
constructor(
|
||||
private readonly configService: ConfigService,
|
||||
private readonly datasource: DataSource,
|
||||
private readonly paymentRepo: PaymentRepository,
|
||||
private readonly telebirrProvider: TelebirrProvider,
|
||||
private readonly paymentClient: PaymentClientService,
|
||||
@Inject(forwardRef(() => BookingBatchService))
|
||||
private readonly bookingBatchService: BookingBatchService,
|
||||
) { }
|
||||
|
||||
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 getAll(filters: {
|
||||
search?: string;
|
||||
status?: string;
|
||||
method?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}) {
|
||||
const { search, status, method, page = 1, pageSize = 10 } = filters;
|
||||
const skip = (page - 1) * pageSize;
|
||||
|
||||
// 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 qb = this.paymentRepo.createQueryBuilder("payment");
|
||||
|
||||
const input: ProviderInitiationInput = {
|
||||
merchantOrderId,
|
||||
orderRef: bookingId,
|
||||
if (search) {
|
||||
qb.andWhere(
|
||||
"(payment.merchantOrderId ILIKE :search OR payment.refId ILIKE :search OR payment.transactionId ILIKE :search)",
|
||||
{ search: `%${search}%` },
|
||||
);
|
||||
}
|
||||
if (status) {
|
||||
qb.andWhere("payment.status = :status", { status });
|
||||
}
|
||||
if (method) {
|
||||
qb.andWhere("payment.method = :method", { method });
|
||||
}
|
||||
|
||||
const [items, total] = await qb
|
||||
.orderBy("payment.createdAt", "DESC")
|
||||
.skip(skip)
|
||||
.take(pageSize)
|
||||
.getManyAndCount();
|
||||
|
||||
return {
|
||||
items: items.map((p) => ({
|
||||
id: p.id,
|
||||
bookingId: p.refId,
|
||||
amount: p.amount,
|
||||
currency: p.currency,
|
||||
method: p.method,
|
||||
status: p.status,
|
||||
merchantOrderId: p.merchantOrderId,
|
||||
paidAt: p.paidAt,
|
||||
createdAt: p.createdAt,
|
||||
})),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
};
|
||||
}
|
||||
|
||||
/** Aggregate counts across ALL payments for the dashboard summary cards. */
|
||||
async getSummary() {
|
||||
const rows = await this.paymentRepo
|
||||
.createQueryBuilder("payment")
|
||||
.select("payment.status", "status")
|
||||
.addSelect("COUNT(*)::int", "count")
|
||||
.groupBy("payment.status")
|
||||
.getRawMany<{ status: string; count: number }>();
|
||||
|
||||
const byStatus: Record<string, number> = {};
|
||||
let total = 0;
|
||||
for (const row of rows) {
|
||||
byStatus[row.status] = row.count;
|
||||
total += row.count;
|
||||
}
|
||||
|
||||
// Sum of successfully collected amounts.
|
||||
const paidAgg = await this.paymentRepo
|
||||
.createQueryBuilder("payment")
|
||||
.select("COALESCE(SUM(payment.amount), 0)", "sum")
|
||||
.where("payment.status = :status", { status: "success" })
|
||||
.getRawOne<{ sum: string }>();
|
||||
|
||||
return {
|
||||
total,
|
||||
success: byStatus["success"] ?? 0,
|
||||
processing:
|
||||
(byStatus["processing"] ?? 0) + (byStatus["action-required"] ?? 0),
|
||||
failed: (byStatus["failed"] ?? 0) + (byStatus["canceled"] ?? 0),
|
||||
refunded: byStatus["refunded"] ?? 0,
|
||||
paidAmount: Number(paidAgg?.sum ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
async initiatePayment(dto: InitiatePaymentDto): Promise<InitiateResponseDto> {
|
||||
const booking = await this.datasource
|
||||
.getRepository(Booking)
|
||||
.findOneBy({ id: dto.bookingId });
|
||||
if (!booking) throw new NotFoundException("Booking not found");
|
||||
|
||||
console.log("bookingbooking",booking)
|
||||
const amountMinor = Math.round(Number(booking.totalAmount) * 100);
|
||||
console.log("amountminor",amountMinor)
|
||||
|
||||
const snapshot = await this.paymentClient.initiate({
|
||||
service: PaymentServiceEnum.FREIGHT,
|
||||
referenceType: PaymentReferenceType.SHIPMENT,
|
||||
referenceId: booking.id,
|
||||
orderRef: booking.reference,
|
||||
amountMinor,
|
||||
currency: DEFAULT_CURRENCY,
|
||||
platform: platform || "web",
|
||||
redirectUrl,
|
||||
currency: booking.paymentCurrency,
|
||||
provider: dto.method as unknown as ProviderMethod,
|
||||
platform: dto.platform,
|
||||
payerAccount: dto.payerAccount,
|
||||
returnUrl: dto.returnUrl ?? process.env.PAYMENT_RETURN_URL,
|
||||
failureUrl: dto.failureUrl ?? process.env.PAYMENT_FAILURE_URL,
|
||||
});
|
||||
|
||||
const intent = await this.syncIntentProjection(booking.id, booking, snapshot);
|
||||
|
||||
if (snapshot.status === ProviderPaymentStatus.SUCCEEDED) {
|
||||
await this.finalizePaymentSuccess({
|
||||
intentId: intent.id,
|
||||
bookingId: booking.id,
|
||||
providerTxnId: snapshot.providerTxnId,
|
||||
paidAt: snapshot.paidAt ? new Date(snapshot.paidAt) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
return this.formatIntentResponse(intent);
|
||||
}
|
||||
|
||||
private async syncIntentProjection(
|
||||
bookingId: string,
|
||||
booking: Booking,
|
||||
snapshot: PaymentIntentSnapshot,
|
||||
): Promise<PaymentEntity> {
|
||||
const existing = await this.paymentRepo.findOneBy({ refId: bookingId, type: "booking" });
|
||||
|
||||
const PROVIDER_TO_METHOD: Record<string, PaymentEntity["method"]> = {
|
||||
TELEBIRR: "telebirr",
|
||||
CBE_BIRR: "cbe-birr",
|
||||
EBIRR: "ebirr",
|
||||
WAAFI: "waafi",
|
||||
CARD: "card",
|
||||
DMONEY: "dmoney",
|
||||
CAC_BANK: "cac-bank",
|
||||
};
|
||||
const method: PaymentEntity["method"] =
|
||||
PROVIDER_TO_METHOD[snapshot.provider ?? ""] ?? "telebirr";
|
||||
const status = snapshot.status === ProviderPaymentStatus.SUCCEEDED
|
||||
? "processing"
|
||||
: this.toLocalStatus(snapshot.status);
|
||||
|
||||
const clientAction = (snapshot.clientAction ?? undefined) as Record<string, unknown> | undefined;
|
||||
const data = {
|
||||
status,
|
||||
method,
|
||||
merchantOrderId: snapshot.merchantOrderId ?? existing?.merchantOrderId ?? "",
|
||||
transactionId: snapshot.providerTxnId ?? existing?.transactionId,
|
||||
expiresAt: snapshot.expiresAt ? new Date(snapshot.expiresAt) : existing?.expiresAt,
|
||||
failerCode: snapshot.failureCode ?? undefined,
|
||||
failureMessage: snapshot.failureMessage ?? undefined,
|
||||
};
|
||||
|
||||
const result = await this.telebirrProvider.initiate(input);
|
||||
if (existing) {
|
||||
await this.paymentRepo.update({ id: existing.id }, { ...data, clientAction } as any);
|
||||
return { ...existing, ...data, clientAction } as PaymentEntity;
|
||||
}
|
||||
|
||||
const payment = await this.paymentRepo.create({
|
||||
amount: amount,
|
||||
currency: DEFAULT_CURRENCY,
|
||||
method: "telebirr",
|
||||
return this.paymentRepo.create({
|
||||
refId: bookingId,
|
||||
type: "booking",
|
||||
merchantOrderId,
|
||||
rawInitiation: result.rawInitiation,
|
||||
clientAction: result.clientAction as Record<string, unknown>,
|
||||
expiresAt: result.expiresAt,
|
||||
reason: `Payment for booking`,
|
||||
});
|
||||
|
||||
return {
|
||||
redirectUrl: `${this.configService.get<string>("TELEBIRR_REDIRECT_BASE_URL")}/${payment.merchantOrderId}`
|
||||
}
|
||||
amount: booking.totalAmount,
|
||||
currency: booking.paymentCurrency,
|
||||
reason: `Payment for booking ${booking.reference}`,
|
||||
rawInitiation: snapshot as unknown as Record<string, unknown>,
|
||||
clientAction: clientAction ?? {},
|
||||
...data,
|
||||
} as any);
|
||||
}
|
||||
|
||||
async getIntentByBookingId(bookingId: string): Promise<IntentStatusDto> {
|
||||
const local = await this.paymentRepo.findOneBy({ refId: bookingId, type: "booking" });
|
||||
|
||||
let snapshot: PaymentIntentSnapshot | null = null;
|
||||
try {
|
||||
snapshot = await this.paymentClient.getIntentByReference(
|
||||
PaymentReferenceType.SHIPMENT,
|
||||
bookingId,
|
||||
);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
this.logger.warn(
|
||||
`payment service lookup failed for booking ${bookingId}: ${message}; using local intent`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!snapshot) {
|
||||
if (!local) throw new NotFoundException("PaymentIntent not found");
|
||||
return this.formatIntentStatus(local);
|
||||
}
|
||||
|
||||
const booking = await this.datasource
|
||||
.getRepository(Booking)
|
||||
.findOneBy({ id: bookingId });
|
||||
|
||||
if (!booking) throw new NotFoundException("Booking not found");
|
||||
|
||||
const intent = await this.syncIntentProjection(bookingId, booking, snapshot);
|
||||
|
||||
if (snapshot.status === ProviderPaymentStatus.SUCCEEDED) {
|
||||
await this.finalizePaymentSuccess({
|
||||
intentId: intent.id,
|
||||
bookingId: booking.id,
|
||||
providerTxnId: snapshot.providerTxnId,
|
||||
paidAt: snapshot.paidAt ? new Date(snapshot.paidAt) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
const refreshed = await this.paymentRepo.findOneBy({ id: intent.id });
|
||||
return this.formatIntentStatus(refreshed ?? intent);
|
||||
}
|
||||
|
||||
async refund(dto: RefundDto) {
|
||||
const intent = await this.paymentRepo.findOneBy({ refId: dto.bookingId, type: "booking" });
|
||||
if (!intent || intent.status !== "success") {
|
||||
throw new BadRequestException("No successful payment to refund");
|
||||
}
|
||||
|
||||
await this.datasource.transaction(async (mg) => {
|
||||
await mg.update(PaymentEntity, { id: intent.id }, { status: "refunded", refundedAt: new Date() });
|
||||
await mg.update(Booking, { id: dto.bookingId }, { paymentStatus: "FAILED", status: "CANCELLED" });
|
||||
});
|
||||
|
||||
return { refunded: true, bookingId: dto.bookingId };
|
||||
}
|
||||
|
||||
async finalizePaymentSuccess(input: {
|
||||
intentId: string;
|
||||
bookingId: string;
|
||||
providerTxnId?: string;
|
||||
paidAt?: Date;
|
||||
}): Promise<{ alreadyFinalized: boolean }> {
|
||||
const intent = await this.paymentRepo.findOneBy({ id: input.intentId });
|
||||
if (!intent) throw new NotFoundException("PaymentIntent not found");
|
||||
if (intent.status === "success") return { alreadyFinalized: true };
|
||||
|
||||
const paidAt = input.paidAt ?? new Date();
|
||||
|
||||
await this.datasource.transaction(async (mg) => {
|
||||
await mg.update(
|
||||
PaymentEntity,
|
||||
{ id: intent.id },
|
||||
{ status: "success", paidAt, transactionId: input.providerTxnId ?? intent.transactionId },
|
||||
);
|
||||
await mg.update(Booking, { id: input.bookingId }, { paymentStatus: "PAID" ,status:"PAID"});
|
||||
});
|
||||
|
||||
try {
|
||||
await this.bookingBatchService.ensurePaidBookingAllocated(input.bookingId);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Error allocating booking after payment: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
|
||||
return { alreadyFinalized: false };
|
||||
}
|
||||
|
||||
async markPaymentFailed(input: {
|
||||
intentId: string;
|
||||
failureCode?: string;
|
||||
failureMessage?: string;
|
||||
}): Promise<void> {
|
||||
const intent = await this.paymentRepo.findOneBy({ id: input.intentId });
|
||||
if (!intent) throw new NotFoundException("PaymentIntent not found");
|
||||
if (intent.status === "success" || intent.status === "canceled") return;
|
||||
|
||||
await this.paymentRepo.update(
|
||||
{ id: intent.id },
|
||||
{ status: "failed", failerCode: input.failureCode, failureMessage: input.failureMessage },
|
||||
);
|
||||
}
|
||||
|
||||
async getActivePaymentByOrderIdAndMethod(orderId: string, method: PaymentEntity["method"]): Promise<PaymentEntity | null> {
|
||||
return this.paymentRepo.getActivePaymentByOrderIdAndMethod(orderId, method)
|
||||
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 payment = await this.paymentRepo.findOneBy({ merchantOrderId: orderId, status: "success" });
|
||||
if (!payment) throw new BadRequestException("No successful payment found for this order");
|
||||
|
||||
const filePath = path.join(__dirname, "templates", "receipt.hbs");
|
||||
if (!fs.existsSync(filePath)) {
|
||||
throw new InternalServerErrorException()
|
||||
}
|
||||
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",
|
||||
return template({
|
||||
vendorName: "Ethio Djibouti Railway Freight Booking",
|
||||
vendorAddress: "Addis Ababa",
|
||||
receiptDate: payment.paidAt,
|
||||
paymentMethod: payment?.method,
|
||||
subtotal: payment?.amount.toString(),
|
||||
total: payment?.amount.toString(),
|
||||
currency: payment?.currency,
|
||||
reason: payment?.reason
|
||||
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" })
|
||||
return this.paymentRepo.findOneBy({ refId: id, type: "booking" });
|
||||
}
|
||||
|
||||
formatIntentResponse(intent: PaymentEntity): InitiateResponseDto {
|
||||
@@ -142,19 +365,70 @@ export class PaymentService {
|
||||
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,
|
||||
status: STATUS_MAP[intent.status] ?? ProviderPaymentStatus.PROCESSING,
|
||||
clientAction,
|
||||
merchantOrderId: intent.merchantOrderId ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
private formatIntentStatus(intent: PaymentEntity): IntentStatusDto {
|
||||
return {
|
||||
...this.formatIntentResponse(intent),
|
||||
paidAt: intent.paidAt?.toISOString(),
|
||||
failureCode: intent.failerCode ?? undefined,
|
||||
failureMessage: intent.failureMessage ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
async handlePaymentEvent(event: {
|
||||
eventType: string;
|
||||
eventId: string;
|
||||
referenceId: string;
|
||||
intentId: string;
|
||||
providerTxnId?: string;
|
||||
paidAt?: string;
|
||||
failureCode?: string;
|
||||
failureMessage?: string;
|
||||
}): Promise<{ processed: boolean; alreadyFinalized?: boolean; reason?: string }> {
|
||||
if (event.eventType === "payment.succeeded") {
|
||||
const intent = await this.paymentRepo.findOneBy({ refId: event.referenceId, type: "booking" });
|
||||
if (!intent) {
|
||||
return { processed: false, reason: `No local intent for booking ${event.referenceId}` };
|
||||
}
|
||||
const { alreadyFinalized } = await this.finalizePaymentSuccess({
|
||||
intentId: intent.id,
|
||||
bookingId: event.referenceId,
|
||||
providerTxnId: event.providerTxnId,
|
||||
paidAt: event.paidAt ? new Date(event.paidAt) : undefined,
|
||||
});
|
||||
return { processed: true, alreadyFinalized };
|
||||
}
|
||||
|
||||
if (event.eventType === "payment.failed") {
|
||||
const intent = await this.paymentRepo.findOneBy({ refId: event.referenceId, type: "booking" });
|
||||
if (!intent) {
|
||||
return { processed: false, reason: `No local intent for booking ${event.referenceId}` };
|
||||
}
|
||||
await this.markPaymentFailed({
|
||||
intentId: intent.id,
|
||||
failureCode: event.failureCode,
|
||||
failureMessage: event.failureMessage,
|
||||
});
|
||||
return { processed: true };
|
||||
}
|
||||
|
||||
return { processed: false, reason: `Unknown event type: ${event.eventType}` };
|
||||
}
|
||||
|
||||
private toLocalStatus(status: ProviderPaymentStatus): PaymentEntity["status"] {
|
||||
switch (status) {
|
||||
case ProviderPaymentStatus.SUCCEEDED: return "success";
|
||||
case ProviderPaymentStatus.FAILED: return "failed";
|
||||
case ProviderPaymentStatus.CANCELLED: return "canceled";
|
||||
case ProviderPaymentStatus.PROCESSING: return "processing";
|
||||
default: return "action-required";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,27 +1,67 @@
|
||||
import { ProviderPaymentStatus } from "@edr/types";
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { IsIn, IsOptional, IsString } from "class-validator";
|
||||
import { IsEnum, IsIn, IsOptional, IsString } from "class-validator";
|
||||
|
||||
export type PaymentPlatformDto = "web" | "mobile";
|
||||
|
||||
export enum PaymentMethodTypeEnum {
|
||||
TELEBIRR = "TELEBIRR",
|
||||
CBE_BIRR = "CBE_BIRR",
|
||||
EBIRR = "EBIRR",
|
||||
WAAFI = "WAAFI",
|
||||
CARD = "CARD",
|
||||
DMONEY = "DMONEY",
|
||||
CAC_BANK = "CAC_BANK",
|
||||
}
|
||||
|
||||
export class InitiatePaymentDto {
|
||||
@ApiProperty({ example: "booking-uuid" })
|
||||
@IsString()
|
||||
bookingId!: string;
|
||||
|
||||
@ApiProperty({ enum: ["TELEBIRR"], example: "TELEBIRR" })
|
||||
@IsIn(["TELEBIRR"])
|
||||
method!: "TELEBIRR";
|
||||
@ApiProperty({
|
||||
enum: PaymentMethodTypeEnum,
|
||||
description: "Payment method: TELEBIRR/CBE_BIRR/EBIRR (Ethiopia), WAAFI (Djibouti), CARD (International), DMONEY",
|
||||
example: "TELEBIRR",
|
||||
})
|
||||
@IsEnum(PaymentMethodTypeEnum)
|
||||
method!: PaymentMethodTypeEnum;
|
||||
|
||||
@ApiPropertyOptional({ enum: ["web", "mobile"], default: "web" })
|
||||
@IsOptional()
|
||||
@IsIn(["web", "mobile"])
|
||||
platform?: PaymentPlatformDto;
|
||||
|
||||
@ApiPropertyOptional({ description: "Payer account / mobile number (e.g. for Waafi MWALLET)" })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
payerAccount?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: "Browser return URL after successful payment" })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
returnUrl?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: "Browser return URL after failed/cancelled payment" })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
failureUrl?: string;
|
||||
}
|
||||
|
||||
export class RefundDto {
|
||||
@ApiProperty({ example: "booking-uuid" })
|
||||
@IsString()
|
||||
bookingId!: string;
|
||||
|
||||
@ApiPropertyOptional({ description: "Optional reason for refund" })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export class ClientActionDto {
|
||||
@ApiProperty({ enum: ["REDIRECT", "LAUNCH_APP"] })
|
||||
type!: "REDIRECT" | "LAUNCH_APP";
|
||||
@ApiProperty({ enum: ["REDIRECT", "LAUNCH_APP", "COLLECT_OTP"] })
|
||||
type!: "REDIRECT" | "LAUNCH_APP" | "COLLECT_OTP";
|
||||
|
||||
@ApiPropertyOptional({ description: "Set when type=REDIRECT (web flow)" })
|
||||
url?: string;
|
||||
@@ -34,6 +74,12 @@ export class ClientActionDto {
|
||||
|
||||
@ApiPropertyOptional({ description: "Set when type=LAUNCH_APP (mobile flow)" })
|
||||
shortCode?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: "Set when type=COLLECT_OTP (e.g. CAC Bank)" })
|
||||
providerOrderId?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: "Set when type=COLLECT_OTP" })
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export class InitiateResponseDto {
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { IsOptional, IsString } from "class-validator";
|
||||
|
||||
export class TelebirrDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
merch_order_id!: string;
|
||||
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
payment_order_id!: string;
|
||||
|
||||
@ApiProperty({ default: "SUCCEEDED"})
|
||||
@IsString()
|
||||
trade_status!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
trans_id?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
total_amount?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
trans_currency?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
notify_time?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
trans_end_time?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
sign!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
sign_type?: string;
|
||||
|
||||
|
||||
[key: string]: unknown;
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { TelebirrDto } from '../dto/telebirr.dto';
|
||||
import { PaymentRepository } from '../../payment.repository';
|
||||
import { DataSource } from 'typeorm';
|
||||
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);
|
||||
|
||||
constructor(
|
||||
private readonly datasource: DataSource,
|
||||
private readonly paymentRepo: PaymentRepository,
|
||||
private readonly telebirrProvider: TelebirrProvider,
|
||||
) { }
|
||||
|
||||
verifyTelebirrNotification(payload: TelebirrDto) {
|
||||
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) {
|
||||
this.logger.warn(`Webhook received for unknown merchantOrderId: ${payload.merch_order_id}`);
|
||||
return;
|
||||
}
|
||||
|
||||
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 ProviderPaymentStatus.FAILED:
|
||||
await this.paymentRepo.update({ id: payment.id }, { status: "failed" });
|
||||
break;
|
||||
case ProviderPaymentStatus.PROCESSING:
|
||||
await this.paymentRepo.update({ id: payment.id }, { status: "processing" });
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
import { Body, Controller, HttpCode, HttpStatus, Logger, Post, } from '@nestjs/common';
|
||||
import { TelebirrWebhookService } from './providers/telebirr.service';
|
||||
import { ApiOperation } from '@nestjs/swagger';
|
||||
import { TelebirrDto } from './dto/telebirr.dto';
|
||||
import { Public } from '@edr/api-common';
|
||||
|
||||
@Controller("payments-webhooks")
|
||||
@Public()
|
||||
export class WebhookController {
|
||||
constructor(private readonly telebirr: TelebirrWebhookService) { }
|
||||
private readonly logger = new Logger(WebhookController.name);
|
||||
|
||||
@Post('telebirr')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({
|
||||
summary: 'Telebirr payment notification callback (Ethiopia)',
|
||||
description: 'Webhook endpoint for Telebirr payment status updates. Used by Ethiopian passengers.'
|
||||
})
|
||||
async receiveTelebirr(@Body() payload: TelebirrDto) {
|
||||
this.logger.log(
|
||||
`Telebirr webhook Called`,
|
||||
);
|
||||
|
||||
try {
|
||||
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}`);
|
||||
}
|
||||
return { code: '0', message: 'OK' };
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Body, Controller, Delete, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { FleetManage, FleetView } from '../../common/booking-guards';
|
||||
import { CreateRouteDto } from './dto/create-route.dto';
|
||||
import { FilterRoutesDto } from './dto/filter-routes.dto';
|
||||
import { UpdateRouteDto } from './dto/update-route.dto';
|
||||
@@ -9,6 +10,7 @@ import { RoutesService } from './routes.service';
|
||||
@ApiTags('routes')
|
||||
@ApiBearerAuth()
|
||||
@Controller('routes')
|
||||
@FleetView()
|
||||
export class RoutesController {
|
||||
constructor(private readonly routesService: RoutesService) {}
|
||||
|
||||
@@ -25,18 +27,21 @@ export class RoutesController {
|
||||
}
|
||||
|
||||
@Post()
|
||||
@FleetManage()
|
||||
@ApiOperation({ summary: 'Create route' })
|
||||
create(@Body() dto: CreateRouteDto) {
|
||||
return this.routesService.create(dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@FleetManage()
|
||||
@ApiOperation({ summary: 'Update route' })
|
||||
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateRouteDto) {
|
||||
return this.routesService.update(id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@FleetManage()
|
||||
@ApiOperation({ summary: 'Deactivate route' })
|
||||
remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.routesService.deactivate(id);
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import {
|
||||
Body, Controller, Delete, Get, HttpCode, HttpStatus,
|
||||
Param, ParseUUIDPipe, Patch, Post, Query,
|
||||
} from '@nestjs/common';
|
||||
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { CreatePriorityConfigDto } from '../dto/create-priority-config.dto';
|
||||
import { UpdatePriorityConfigDto } from '../dto/update-priority-config.dto';
|
||||
import { MoveOrderDto } from '../dto/move-order.dto';
|
||||
import { ReorderItemsDto } from '../dto/reorder-items.dto';
|
||||
import { PriorityConfigsService } from '../services/priority-configs.service';
|
||||
|
||||
@ApiTags('priority-configs')
|
||||
@Controller('priority-configs')
|
||||
@ApiBearerAuth()
|
||||
export class PriorityConfigsController {
|
||||
constructor(private readonly service: PriorityConfigsService) {}
|
||||
|
||||
@Get()
|
||||
@RuleEngineView('priority-configs')
|
||||
@ApiOperation({ summary: 'List priority configs' })
|
||||
findAll(@Query() query: Record<string, string>) {
|
||||
return this.service.findAll({
|
||||
type: (query['type'] as 'WAGON' | 'CURRENCY') || undefined,
|
||||
isActive: query['isActive'] !== undefined ? query['isActive'] === 'true' : undefined,
|
||||
page: query['page'] ? parseInt(query['page'], 10) : undefined,
|
||||
pageSize: query['pageSize'] ? parseInt(query['pageSize'], 10) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RuleEngineView('priority-configs')
|
||||
@ApiOperation({ summary: 'Get a priority config by ID' })
|
||||
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.service.findById(id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@RuleEngineManage('priority-configs')
|
||||
@ApiOperation({ summary: 'Create a priority config' })
|
||||
create(@Body() dto: CreatePriorityConfigDto) {
|
||||
return this.service.create(dto);
|
||||
}
|
||||
|
||||
@Post('reorder')
|
||||
@RuleEngineManage('priority-configs')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@ApiOperation({ summary: 'Bulk reorder priority configs by ID list' })
|
||||
reorder(@Body() dto: ReorderItemsDto) {
|
||||
return this.service.reorder(dto.ids);
|
||||
}
|
||||
|
||||
@Post(':id/move-order')
|
||||
@RuleEngineManage('priority-configs')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@ApiOperation({ summary: 'Move a priority config up or down in display order' })
|
||||
moveOrder(@Param('id', ParseUUIDPipe) id: string, @Body() dto: MoveOrderDto) {
|
||||
return this.service.moveOrder(id, dto.direction);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@RuleEngineManage('priority-configs')
|
||||
@ApiOperation({ summary: 'Update a priority config' })
|
||||
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdatePriorityConfigDto) {
|
||||
return this.service.update(id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@RuleEngineManage('priority-configs')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@ApiOperation({ summary: 'Soft-delete a priority config' })
|
||||
remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.service.remove(id);
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
import {
|
||||
Body, Controller, Delete, Get, HttpCode, HttpStatus,
|
||||
Param, ParseUUIDPipe, Patch, Post, Query,
|
||||
} from '@nestjs/common';
|
||||
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { CreatePriorityRuleDto } from '../dto/create-priority-rule.dto';
|
||||
import { UpdatePriorityRuleDto } from '../dto/update-priority-rule.dto';
|
||||
import { PriorityRulesService } from '../services/priority-rules.service';
|
||||
|
||||
@ApiTags('priority-rules')
|
||||
@Controller('priority-rules')
|
||||
@ApiBearerAuth()
|
||||
export class PriorityRulesController {
|
||||
constructor(private readonly service: PriorityRulesService) {}
|
||||
|
||||
@Get()
|
||||
@RuleEngineView('priority-rules')
|
||||
@ApiOperation({ summary: 'List priority rules' })
|
||||
findAll(@Query() query: Record<string, string>) {
|
||||
return this.service.findAll({
|
||||
isActive: query['isActive'] !== undefined ? query['isActive'] === 'true' : undefined,
|
||||
page: query['page'] ? parseInt(query['page'], 10) : undefined,
|
||||
pageSize: query['pageSize'] ? parseInt(query['pageSize'], 10) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RuleEngineView('priority-rules')
|
||||
@ApiOperation({ summary: 'Get a priority rule by ID' })
|
||||
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.service.findById(id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@RuleEngineManage('priority-rules')
|
||||
@ApiOperation({ summary: 'Create a priority rule' })
|
||||
create(@Body() dto: CreatePriorityRuleDto) {
|
||||
return this.service.create(dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@RuleEngineManage('priority-rules')
|
||||
@ApiOperation({ summary: 'Update a priority rule' })
|
||||
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdatePriorityRuleDto) {
|
||||
return this.service.update(id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@RuleEngineManage('priority-rules')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@ApiOperation({ summary: 'Soft-delete a priority rule' })
|
||||
remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.service.remove(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsBoolean, IsIn, IsInt, IsOptional, IsString, MaxLength, Min } from 'class-validator';
|
||||
|
||||
export class CreatePriorityConfigDto {
|
||||
@ApiProperty({ description: 'Config type: WAGON or CURRENCY', enum: ['WAGON', 'CURRENCY'] })
|
||||
@IsIn(['WAGON', 'CURRENCY'])
|
||||
type!: 'WAGON' | 'CURRENCY';
|
||||
|
||||
@ApiProperty({ description: 'Human-readable label', maxLength: 100 })
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
label!: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Currency code (e.g., USD, ETB). Required for type=CURRENCY, must be null for type=WAGON',
|
||||
maxLength: 5,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(5)
|
||||
currency?: string;
|
||||
|
||||
@ApiProperty({ description: 'Minimum wagon count in range (inclusive)' })
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
minWagonCount!: number;
|
||||
|
||||
@ApiProperty({ description: 'Maximum wagon count in range (inclusive)' })
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
maxWagonCount!: number;
|
||||
|
||||
@ApiProperty({ description: 'Points awarded when booking matches this rule', default: 0 })
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
scorePoints!: number;
|
||||
|
||||
@ApiPropertyOptional({ default: false, description: 'Feature flag — toggle without code deploy' })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsBoolean, IsInt, IsOptional, IsString, MaxLength, Min } from 'class-validator';
|
||||
|
||||
export class CreatePriorityRuleDto {
|
||||
@ApiProperty({ description: 'Human-readable label', maxLength: 100 })
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
label!: string;
|
||||
|
||||
@ApiProperty({ description: 'Points added to booking.priority_score when condition matches', default: 0 })
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
score!: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'If set, rule only matches bookings with this payment currency (e.g. USD). Null = matches all.',
|
||||
maxLength: 5,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(5)
|
||||
conditionCurrency?: string;
|
||||
|
||||
@ApiPropertyOptional({ default: false, description: 'Feature flag — toggle without code deploy' })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { IsDateString, IsIn, IsNumber, IsOptional, IsString, IsUUID, MaxLength,
|
||||
import { RATE_TYPES, RATE_UNITS } from '../entities/rate.entity';
|
||||
|
||||
const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'BOTH'] as const;
|
||||
const CURRENCIES = ['ETB', 'USD'] as const;
|
||||
const CURRENCIES = ['USD'] as const;
|
||||
|
||||
export class CreateRateDto {
|
||||
@ApiProperty({ enum: RATE_TYPES, description: 'Rate type identifier' })
|
||||
|
||||
@@ -2,14 +2,17 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsDateString, IsIn, IsNumber, IsOptional, IsUUID, Min } from 'class-validator';
|
||||
|
||||
const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'BOTH'] as const;
|
||||
const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'BOTH', 'DOMESTIC'] as const;
|
||||
|
||||
export class CreateWeightLimitRuleDto {
|
||||
@ApiProperty({ description: 'FK to container_types.id' })
|
||||
@IsUUID()
|
||||
containerTypeId!: string;
|
||||
|
||||
@ApiProperty({ enum: TRADE_DIRECTIONS, description: 'Trade direction: IMPORT, EXPORT, or BOTH' })
|
||||
@ApiProperty({
|
||||
enum: TRADE_DIRECTIONS,
|
||||
description: 'Trade direction: IMPORT, EXPORT, BOTH, or DOMESTIC',
|
||||
})
|
||||
@IsIn([...TRADE_DIRECTIONS])
|
||||
tradeDirection!: string;
|
||||
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
import { PartialType } from '@nestjs/swagger';
|
||||
import { CreatePriorityConfigDto } from './create-priority-config.dto';
|
||||
|
||||
export class UpdatePriorityConfigDto extends PartialType(CreatePriorityConfigDto) {}
|
||||
@@ -1,4 +0,0 @@
|
||||
import { PartialType } from '@nestjs/mapped-types';
|
||||
import { CreatePriorityRuleDto } from './create-priority-rule.dto';
|
||||
|
||||
export class UpdatePriorityRuleDto extends PartialType(CreatePriorityRuleDto) {}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index } from 'typeorm';
|
||||
|
||||
@Entity({ schema: 'freight', name: 'priority_configs' })
|
||||
@Index(['type', 'isActive'])
|
||||
@Index(['currency', 'type'])
|
||||
export class PriorityConfig extends BaseEntity {
|
||||
@Column({ name: 'type', type: 'varchar', length: 20 })
|
||||
type!: 'WAGON' | 'CURRENCY';
|
||||
|
||||
@Column({ name: 'label', type: 'varchar', length: 100 })
|
||||
label!: string;
|
||||
|
||||
@Column({ name: 'currency', type: 'varchar', length: 5, nullable: true })
|
||||
currency?: string | null;
|
||||
|
||||
@Column({ name: 'min_wagon_count', type: 'int' })
|
||||
minWagonCount!: number;
|
||||
|
||||
@Column({ name: 'max_wagon_count', type: 'int' })
|
||||
maxWagonCount!: number;
|
||||
|
||||
@Column({ name: 'score_points', type: 'int', default: 0 })
|
||||
scorePoints!: number;
|
||||
|
||||
@Column({ name: 'is_active', type: 'boolean', default: false })
|
||||
isActive!: boolean;
|
||||
|
||||
@Column({ name: 'display_order', type: 'int', default: 1 })
|
||||
displayOrder!: number;
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index } from 'typeorm';
|
||||
|
||||
@Entity({ schema: 'freight', name: 'priority_rules' })
|
||||
@Index(['code'])
|
||||
@Index(['isActive'])
|
||||
export class PriorityRule extends BaseEntity {
|
||||
@Column({ name: 'code', type: 'varchar', length: 40, unique: true })
|
||||
code!: string;
|
||||
|
||||
@Column({ name: 'label', type: 'varchar', length: 100, nullable: true })
|
||||
label!: string;
|
||||
|
||||
@Column({ name: 'score', type: 'int', default: 0, nullable: true })
|
||||
score!: number;
|
||||
|
||||
@Column({ name: 'condition_currency', type: 'varchar', length: 5, nullable: true })
|
||||
conditionCurrency?: string | null;
|
||||
|
||||
@Column({ name: 'is_active', type: 'boolean', default: false })
|
||||
isActive!: boolean;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { FindManyOptions } from 'typeorm';
|
||||
import { PriorityConfig } from '../entities/priority-config.entity';
|
||||
|
||||
export const PRIORITY_CONFIGS_REPOSITORY = Symbol('PRIORITY_CONFIGS_REPOSITORY');
|
||||
|
||||
export interface IPriorityConfigsRepository {
|
||||
findById(id: string): Promise<PriorityConfig | null>;
|
||||
findAll(options?: FindManyOptions<PriorityConfig>): Promise<PriorityConfig[]>;
|
||||
findAndCount(options?: FindManyOptions<PriorityConfig>): Promise<[PriorityConfig[], number]>;
|
||||
findAllActive(): Promise<PriorityConfig[]>;
|
||||
create(data: Partial<PriorityConfig>): Promise<PriorityConfig>;
|
||||
update(id: string, data: Partial<PriorityConfig>): Promise<PriorityConfig | null>;
|
||||
softDelete(id: string): Promise<void>;
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import { FindManyOptions } from 'typeorm';
|
||||
import { PriorityRule } from '../entities/priority-rule.entity';
|
||||
|
||||
export interface IPriorityRulesRepository {
|
||||
findById(id: string): Promise<PriorityRule | null>;
|
||||
findAllActive(): Promise<PriorityRule[]>;
|
||||
findAll(options?: FindManyOptions<PriorityRule>): Promise<PriorityRule[]>;
|
||||
findAndCount(options?: FindManyOptions<PriorityRule>): Promise<[PriorityRule[], number]>;
|
||||
create(data: Partial<PriorityRule>): Promise<PriorityRule>;
|
||||
update(id: string, data: Partial<PriorityRule>): Promise<PriorityRule | null>;
|
||||
softDelete(id: string): Promise<void>;
|
||||
}
|
||||
|
||||
export const PRIORITY_RULES_REPOSITORY = Symbol('PRIORITY_RULES_REPOSITORY');
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DataSource, FindManyOptions, Repository } from 'typeorm';
|
||||
import { PriorityConfig } from '../entities/priority-config.entity';
|
||||
import { IPriorityConfigsRepository } from '../interfaces/priority-configs.repository.interface';
|
||||
|
||||
@Injectable()
|
||||
export class PriorityConfigsRepository implements IPriorityConfigsRepository {
|
||||
private readonly repo: Repository<PriorityConfig>;
|
||||
|
||||
constructor(private readonly dataSource: DataSource) {
|
||||
this.repo = this.dataSource.getRepository(PriorityConfig);
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<PriorityConfig | null> {
|
||||
return this.repo.findOne({ where: { id } });
|
||||
}
|
||||
|
||||
async findAll(options?: FindManyOptions<PriorityConfig>): Promise<PriorityConfig[]> {
|
||||
return this.repo.find(options);
|
||||
}
|
||||
|
||||
async findAndCount(options?: FindManyOptions<PriorityConfig>): Promise<[PriorityConfig[], number]> {
|
||||
return this.repo.findAndCount(options);
|
||||
}
|
||||
|
||||
async findAllActive(): Promise<PriorityConfig[]> {
|
||||
return this.repo.find({
|
||||
where: { isActive: true },
|
||||
order: { displayOrder: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
async create(data: Partial<PriorityConfig>): Promise<PriorityConfig> {
|
||||
const entity = this.repo.create(data);
|
||||
return this.repo.save(entity);
|
||||
}
|
||||
|
||||
async update(id: string, data: Partial<PriorityConfig>): Promise<PriorityConfig | null> {
|
||||
await this.repo.update(id, data);
|
||||
return this.findById(id);
|
||||
}
|
||||
|
||||
async softDelete(id: string): Promise<void> {
|
||||
await this.repo.softDelete(id);
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DataSource, FindManyOptions, Repository } from 'typeorm';
|
||||
import { PriorityRule } from '../entities/priority-rule.entity';
|
||||
import { IPriorityRulesRepository } from '../interfaces/priority-rules.repository.interface';
|
||||
|
||||
@Injectable()
|
||||
export class PriorityRulesRepository implements IPriorityRulesRepository {
|
||||
private readonly repo: Repository<PriorityRule>;
|
||||
|
||||
constructor(private readonly dataSource: DataSource) {
|
||||
this.repo = this.dataSource.getRepository(PriorityRule);
|
||||
}
|
||||
|
||||
findById(id: string): Promise<PriorityRule | null> {
|
||||
return this.repo.findOne({ where: { id } });
|
||||
}
|
||||
|
||||
findAllActive(): Promise<PriorityRule[]> {
|
||||
return this.repo.find({ where: { isActive: true } });
|
||||
}
|
||||
|
||||
findAll(options?: FindManyOptions<PriorityRule>): Promise<PriorityRule[]> {
|
||||
return this.repo.find(options);
|
||||
}
|
||||
|
||||
findAndCount(options?: FindManyOptions<PriorityRule>): Promise<[PriorityRule[], number]> {
|
||||
return this.repo.findAndCount(options);
|
||||
}
|
||||
|
||||
async create(data: Partial<PriorityRule>): Promise<PriorityRule> {
|
||||
const entity = this.repo.create(data);
|
||||
return this.repo.save(entity);
|
||||
}
|
||||
|
||||
async update(id: string, data: Partial<PriorityRule>): Promise<PriorityRule | null> {
|
||||
await this.repo.update(id, data);
|
||||
return this.findById(id);
|
||||
}
|
||||
|
||||
async softDelete(id: string): Promise<void> {
|
||||
await this.repo.softDelete(id);
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { ApprovalRulesController } from './controllers/approval-rules.controller';
|
||||
import { CargoTypesController } from './controllers/cargo-types.controller';
|
||||
import { ContainerTypesController } from './controllers/container-types.controller';
|
||||
import { PriorityRulesController } from './controllers/priority-rules.controller';
|
||||
import { PriorityConfigsController } from './controllers/priority-configs.controller';
|
||||
import { RatesController } from './controllers/rates.controller';
|
||||
import { ServiceTypesController } from './controllers/service-types.controller';
|
||||
import { ShippingLinesController } from './controllers/shipping-lines.controller';
|
||||
@@ -15,7 +15,7 @@ import { YardsController } from './controllers/yards.controller';
|
||||
import { ApprovalRule } from './entities/approval-rule.entity';
|
||||
import { CargoType } from './entities/cargo-type.entity';
|
||||
import { ContainerType } from './entities/container-type.entity';
|
||||
import { PriorityRule } from './entities/priority-rule.entity';
|
||||
import { PriorityConfig } from './entities/priority-config.entity';
|
||||
import { Rate } from './entities/rate.entity';
|
||||
import { ServiceType } from './entities/service-type.entity';
|
||||
import { ShippingLine } from './entities/shipping-line.entity';
|
||||
@@ -26,7 +26,7 @@ import { Yard } from './entities/yard.entity';
|
||||
import { APPROVAL_RULES_REPOSITORY } from './interfaces/approval-rules.repository.interface';
|
||||
import { CARGO_TYPES_REPOSITORY } from './interfaces/cargo-types.repository.interface';
|
||||
import { CONTAINER_TYPES_REPOSITORY } from './interfaces/container-types.repository.interface';
|
||||
import { PRIORITY_RULES_REPOSITORY } from './interfaces/priority-rules.repository.interface';
|
||||
import { PRIORITY_CONFIGS_REPOSITORY } from './interfaces/priority-configs.repository.interface';
|
||||
import { RATES_REPOSITORY } from './interfaces/rates.repository.interface';
|
||||
import { SERVICE_TYPES_REPOSITORY } from './interfaces/service-types.repository.interface';
|
||||
import { SHIPPING_LINES_REPOSITORY } from './interfaces/shipping-lines.repository.interface';
|
||||
@@ -37,7 +37,7 @@ import { YARDS_REPOSITORY } from './interfaces/yards.repository.interface';
|
||||
import { ApprovalRulesRepository } from './repositories/approval-rules.repository';
|
||||
import { CargoTypesRepository } from './repositories/cargo-types.repository';
|
||||
import { ContainerTypesRepository } from './repositories/container-types.repository';
|
||||
import { PriorityRulesRepository } from './repositories/priority-rules.repository';
|
||||
import { PriorityConfigsRepository } from './repositories/priority-configs.repository';
|
||||
import { RatesRepository } from './repositories/rates.repository';
|
||||
import { ServiceTypesRepository } from './repositories/service-types.repository';
|
||||
import { ShippingLinesRepository } from './repositories/shipping-lines.repository';
|
||||
@@ -49,7 +49,7 @@ 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';
|
||||
import { PriorityConfigsService } from './services/priority-configs.service';
|
||||
import { RatesService } from './services/rates.service';
|
||||
import { ServiceTypesService } from './services/service-types.service';
|
||||
import { ShippingLinesService } from './services/shipping-lines.service';
|
||||
@@ -70,7 +70,7 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.
|
||||
TypeOrmModule.forFeature([
|
||||
CargoType,
|
||||
ContainerType,
|
||||
PriorityRule,
|
||||
PriorityConfig,
|
||||
SurchargeType,
|
||||
ServiceType,
|
||||
WeightLimitRule,
|
||||
@@ -87,7 +87,7 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.
|
||||
controllers: [
|
||||
CargoTypesController,
|
||||
ContainerTypesController,
|
||||
PriorityRulesController,
|
||||
PriorityConfigsController,
|
||||
SurchargeTypesController,
|
||||
ServiceTypesController,
|
||||
WeightLimitRulesController,
|
||||
@@ -101,8 +101,8 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.
|
||||
{ provide: CARGO_TYPES_REPOSITORY, useExisting: CargoTypesRepository },
|
||||
ContainerTypesRepository,
|
||||
{ provide: CONTAINER_TYPES_REPOSITORY, useExisting: ContainerTypesRepository },
|
||||
PriorityRulesRepository,
|
||||
{ provide: PRIORITY_RULES_REPOSITORY, useExisting: PriorityRulesRepository },
|
||||
PriorityConfigsRepository,
|
||||
{ provide: PRIORITY_CONFIGS_REPOSITORY, useExisting: PriorityConfigsRepository },
|
||||
SurchargeTypesRepository,
|
||||
{ provide: SURCHARGE_TYPES_REPOSITORY, useExisting: SurchargeTypesRepository },
|
||||
ServiceTypesRepository,
|
||||
@@ -119,7 +119,7 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.
|
||||
{ provide: APPROVAL_RULES_REPOSITORY, useExisting: ApprovalRulesRepository },
|
||||
CargoTypesService,
|
||||
ContainerTypesService,
|
||||
PriorityRulesService,
|
||||
PriorityConfigsService,
|
||||
SurchargeTypesService,
|
||||
ServiceTypesService,
|
||||
WeightLimitRulesService,
|
||||
@@ -137,7 +137,7 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.
|
||||
ContainerTypesService,
|
||||
SurchargeTypesService,
|
||||
WeightLimitRulesService,
|
||||
PriorityRulesService,
|
||||
PriorityConfigsService,
|
||||
YardsService,
|
||||
ShippingLinesService,
|
||||
RatesService,
|
||||
|
||||
@@ -16,9 +16,9 @@ import {
|
||||
WEIGHT_LIMIT_RULES_REPOSITORY,
|
||||
} from './interfaces/weight-limit-rules.repository.interface';
|
||||
import {
|
||||
IPriorityRulesRepository,
|
||||
PRIORITY_RULES_REPOSITORY,
|
||||
} from './interfaces/priority-rules.repository.interface';
|
||||
IPriorityConfigsRepository,
|
||||
PRIORITY_CONFIGS_REPOSITORY,
|
||||
} from './interfaces/priority-configs.repository.interface';
|
||||
import {
|
||||
ISurchargeTypesRepository,
|
||||
SURCHARGE_TYPES_REPOSITORY,
|
||||
@@ -58,6 +58,7 @@ export interface BookingEvaluationInput {
|
||||
isGovernment?: boolean;
|
||||
allowConsolidation?: boolean;
|
||||
shippingLineId?: string | null;
|
||||
totalWagons: number;
|
||||
containers: BookingContainerEvalInput[];
|
||||
}
|
||||
|
||||
@@ -95,8 +96,8 @@ export class RuleEngineService {
|
||||
private readonly serviceTypesRepo: IServiceTypesRepository,
|
||||
@Inject(WEIGHT_LIMIT_RULES_REPOSITORY)
|
||||
private readonly weightLimitRulesRepo: IWeightLimitRulesRepository,
|
||||
@Inject(PRIORITY_RULES_REPOSITORY)
|
||||
private readonly priorityRulesRepo: IPriorityRulesRepository,
|
||||
@Inject(PRIORITY_CONFIGS_REPOSITORY)
|
||||
private readonly priorityConfigsRepo: IPriorityConfigsRepository,
|
||||
@Inject(SURCHARGE_TYPES_REPOSITORY)
|
||||
private readonly surchargeTypesRepo: ISurchargeTypesRepository,
|
||||
@Inject(RATES_REPOSITORY)
|
||||
@@ -172,13 +173,20 @@ export class RuleEngineService {
|
||||
priorityScore += serviceType.priorityBonusPoints;
|
||||
}
|
||||
|
||||
const priorityRules = await this.priorityRulesRepo.findAllActive();
|
||||
for (const rule of priorityRules) {
|
||||
if (
|
||||
rule.conditionCurrency === null ||
|
||||
rule.conditionCurrency === input.paymentCurrency
|
||||
) {
|
||||
priorityScore += rule.score;
|
||||
// Additive priority blocks, each keyed on the booking's total wagon count:
|
||||
// - WAGON rules apply regardless of currency.
|
||||
// - CURRENCY rules apply only when the payment currency matches.
|
||||
const priorityConfigs = await this.priorityConfigsRepo.findAllActive();
|
||||
const wagonsInRange = (cfg: { minWagonCount: number; maxWagonCount: number }) =>
|
||||
input.totalWagons >= cfg.minWagonCount &&
|
||||
input.totalWagons <= cfg.maxWagonCount;
|
||||
|
||||
for (const cfg of priorityConfigs) {
|
||||
const applies =
|
||||
cfg.type === 'WAGON' ||
|
||||
(cfg.type === 'CURRENCY' && cfg.currency === input.paymentCurrency);
|
||||
if (applies && wagonsInRange(cfg)) {
|
||||
priorityScore += cfg.scorePoints;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { CreatePriorityConfigDto } from '../dto/create-priority-config.dto';
|
||||
import { UpdatePriorityConfigDto } from '../dto/update-priority-config.dto';
|
||||
import { PriorityConfig } from '../entities/priority-config.entity';
|
||||
import {
|
||||
IPriorityConfigsRepository,
|
||||
PRIORITY_CONFIGS_REPOSITORY,
|
||||
} from '../interfaces/priority-configs.repository.interface';
|
||||
import { DisplayOrderService } from './display-order.service';
|
||||
|
||||
@Injectable()
|
||||
export class PriorityConfigsService {
|
||||
constructor(
|
||||
@Inject(PRIORITY_CONFIGS_REPOSITORY)
|
||||
private readonly repository: IPriorityConfigsRepository,
|
||||
private readonly displayOrder: DisplayOrderService,
|
||||
) {}
|
||||
|
||||
async findAll(filter: {
|
||||
type?: 'WAGON' | 'CURRENCY';
|
||||
isActive?: boolean;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}): Promise<{ data: PriorityConfig[]; meta: { total: number; page: number; pageSize: number; totalPages: number } }> {
|
||||
const page = filter.page ?? 1;
|
||||
const pageSize = filter.pageSize ?? 20;
|
||||
const where: Record<string, unknown> = {};
|
||||
if (filter.type !== undefined) where.type = filter.type;
|
||||
if (filter.isActive !== undefined) where.isActive = filter.isActive;
|
||||
|
||||
const [data, total] = await this.repository.findAndCount({
|
||||
where,
|
||||
order: { displayOrder: 'ASC' },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
});
|
||||
return { data, meta: { total, page, pageSize, totalPages: Math.ceil(total / pageSize) } };
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<PriorityConfig> {
|
||||
const entity = await this.repository.findById(id);
|
||||
if (!entity) throw new NotFoundException(`Priority config ${id} not found`);
|
||||
return entity;
|
||||
}
|
||||
|
||||
async create(dto: CreatePriorityConfigDto): Promise<PriorityConfig> {
|
||||
this.validateCurrencyField(dto.type, dto.currency);
|
||||
|
||||
const displayOrder = await this.displayOrder.resolveCreateOrder(PriorityConfig, 'displayOrder', {});
|
||||
|
||||
return this.repository.create({
|
||||
type: dto.type,
|
||||
label: dto.label,
|
||||
currency: dto.currency ?? null,
|
||||
minWagonCount: dto.minWagonCount,
|
||||
maxWagonCount: dto.maxWagonCount,
|
||||
scorePoints: dto.scorePoints ?? 0,
|
||||
isActive: dto.isActive ?? false,
|
||||
displayOrder,
|
||||
});
|
||||
}
|
||||
|
||||
async update(id: string, dto: UpdatePriorityConfigDto): Promise<PriorityConfig> {
|
||||
const existing = await this.findById(id);
|
||||
|
||||
const type = dto.type ?? existing.type;
|
||||
const currency = dto.currency !== undefined ? dto.currency : existing.currency;
|
||||
this.validateCurrencyField(type, currency);
|
||||
|
||||
const { ...patch } = dto;
|
||||
const updated = await this.repository.update(id, patch);
|
||||
if (!updated) throw new NotFoundException(`Priority config ${id} not found`);
|
||||
return updated;
|
||||
}
|
||||
|
||||
async remove(id: string): Promise<void> {
|
||||
await this.findById(id);
|
||||
await this.repository.softDelete(id);
|
||||
}
|
||||
|
||||
async reorder(ids: string[]): Promise<void> {
|
||||
await this.displayOrder.reorderByIds(PriorityConfig, 'displayOrder', ids);
|
||||
}
|
||||
|
||||
async moveOrder(id: string, direction: 'up' | 'down'): Promise<void> {
|
||||
await this.findById(id);
|
||||
await this.displayOrder.moveOne(PriorityConfig, 'displayOrder', id, direction);
|
||||
}
|
||||
|
||||
private validateCurrencyField(type: 'WAGON' | 'CURRENCY', currency: string | undefined | null): void {
|
||||
if (type === 'CURRENCY' && !currency) {
|
||||
throw new BadRequestException('currency field is required when type is CURRENCY');
|
||||
}
|
||||
if (type === 'WAGON' && currency) {
|
||||
throw new BadRequestException('currency field must be null when type is WAGON');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { generateCode } from '../../../common/utils/generate-code.util';
|
||||
import { CreatePriorityRuleDto } from '../dto/create-priority-rule.dto';
|
||||
import { UpdatePriorityRuleDto } from '../dto/update-priority-rule.dto';
|
||||
import { PriorityRule } from '../entities/priority-rule.entity';
|
||||
import {
|
||||
IPriorityRulesRepository,
|
||||
PRIORITY_RULES_REPOSITORY,
|
||||
} from '../interfaces/priority-rules.repository.interface';
|
||||
|
||||
@Injectable()
|
||||
export class PriorityRulesService {
|
||||
constructor(
|
||||
@Inject(PRIORITY_RULES_REPOSITORY)
|
||||
private readonly repository: IPriorityRulesRepository,
|
||||
) {}
|
||||
|
||||
/** List priority rules with pagination. */
|
||||
async findAll(filter: {
|
||||
isActive?: boolean;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}): Promise<{ data: PriorityRule[]; meta: { total: number; page: number; pageSize: number; totalPages: number } }> {
|
||||
const page = filter.page ?? 1;
|
||||
const pageSize = filter.pageSize ?? 20;
|
||||
const where: Record<string, unknown> = {};
|
||||
if (filter.isActive !== undefined) where.isActive = filter.isActive;
|
||||
|
||||
const [data, total] = await this.repository.findAndCount({
|
||||
where,
|
||||
order: { label: 'ASC' },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
});
|
||||
return { data, meta: { total, page, pageSize, totalPages: Math.ceil(total / pageSize) } };
|
||||
}
|
||||
|
||||
/** Get a single priority rule by ID. */
|
||||
async findById(id: string): Promise<PriorityRule> {
|
||||
const entity = await this.repository.findById(id);
|
||||
if (!entity) throw new NotFoundException(`Priority rule ${id} not found`);
|
||||
return entity;
|
||||
}
|
||||
|
||||
/** Create a new priority rule. */
|
||||
async create(dto: CreatePriorityRuleDto): Promise<PriorityRule> {
|
||||
const code = generateCode(dto.label);
|
||||
const existing = await this.repository.findAll({ where: { code } });
|
||||
if (existing.length > 0) {
|
||||
throw new ConflictException(`Priority rule with label "${dto.label}" conflicts with existing code "${code}"`);
|
||||
}
|
||||
return this.repository.create({
|
||||
code,
|
||||
label: dto.label,
|
||||
score: dto.score,
|
||||
conditionCurrency: dto.conditionCurrency ?? null,
|
||||
isActive: dto.isActive ?? false,
|
||||
});
|
||||
}
|
||||
|
||||
/** Update an existing priority rule. */
|
||||
async update(id: string, dto: UpdatePriorityRuleDto): Promise<PriorityRule> {
|
||||
await this.findById(id);
|
||||
const { ...patch } = dto;
|
||||
const updated = await this.repository.update(id, patch);
|
||||
if (!updated) throw new NotFoundException(`Priority rule ${id} not found`);
|
||||
return updated;
|
||||
}
|
||||
|
||||
/** Soft-delete a priority rule. */
|
||||
async remove(id: string): Promise<void> {
|
||||
await this.findById(id);
|
||||
await this.repository.softDelete(id);
|
||||
}
|
||||
}
|
||||
@@ -51,7 +51,7 @@ export class RatesService {
|
||||
rateType: dto.rateType as Rate['rateType'],
|
||||
containerTypeId: dto.containerTypeId,
|
||||
tradeDirection: dto.tradeDirection,
|
||||
currency: dto.currency,
|
||||
currency: dto.currency ?? 'USD',
|
||||
rateValue: dto.rateValue,
|
||||
rateUnit: dto.rateUnit as Rate['rateUnit'],
|
||||
status: 'DRAFT',
|
||||
@@ -71,7 +71,7 @@ export class RatesService {
|
||||
if (dto.rateType) updates.rateType = dto.rateType as Rate['rateType'];
|
||||
if (dto.containerTypeId !== undefined) updates.containerTypeId = dto.containerTypeId;
|
||||
if (dto.tradeDirection !== undefined) updates.tradeDirection = dto.tradeDirection;
|
||||
if (dto.currency) updates.currency = dto.currency;
|
||||
updates.currency = dto.currency ?? existing.currency ?? 'USD';
|
||||
if (dto.rateValue !== undefined) updates.rateValue = dto.rateValue;
|
||||
if (dto.rateUnit) updates.rateUnit = dto.rateUnit as Rate['rateUnit'];
|
||||
if (dto.effectiveFrom) updates.effectiveFrom = new Date(dto.effectiveFrom);
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsString, MinLength } from 'class-validator';
|
||||
|
||||
export class SaveSignatureDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
signerDisplayName!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'PNG signature image as base64 (with or without data URL prefix)',
|
||||
})
|
||||
@IsString()
|
||||
@MinLength(20)
|
||||
signatureImageBase64!: string;
|
||||
}
|
||||
|
||||
export class SavedSignatureDto {
|
||||
@ApiProperty()
|
||||
signerDisplayName!: string;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
signatureImageUrl!: string | null;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
||||
import { FileRecord } from '../../files/entities/file.entity';
|
||||
|
||||
/**
|
||||
* A reusable signature that belongs to a single user (customer or staff).
|
||||
* Captured once and applied to many booking contracts so the signer does not
|
||||
* have to redraw it every time. One active saved signature per user.
|
||||
*/
|
||||
@Entity({ schema: 'freight', name: 'saved_signatures' })
|
||||
@Index(['userId'], { unique: true })
|
||||
export class SavedSignature extends BaseEntity {
|
||||
@Column({ name: 'user_id', type: 'uuid' })
|
||||
userId!: string;
|
||||
|
||||
@Column({ name: 'signer_display_name', type: 'varchar', length: 200 })
|
||||
signerDisplayName!: string;
|
||||
|
||||
@Column({ name: 'signature_file_id', type: 'uuid', nullable: true })
|
||||
signatureFileId?: string | null;
|
||||
|
||||
@ManyToOne(() => FileRecord, { nullable: true })
|
||||
@JoinColumn({ name: 'signature_file_id' })
|
||||
signatureFile?: FileRecord | null;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Body, Controller, Get, Put, Request } from '@nestjs/common';
|
||||
import { ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { SignaturesService } from './signatures.service';
|
||||
import { SaveSignatureDto, SavedSignatureDto } from './dto/save-signature.dto';
|
||||
|
||||
@ApiTags('Signatures')
|
||||
@Controller('me/signature')
|
||||
export class SignaturesController {
|
||||
constructor(private readonly signaturesService: SignaturesService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOkResponse({ type: SavedSignatureDto })
|
||||
@ApiOperation({ summary: "Current user's reusable saved signature" })
|
||||
getMySignature(
|
||||
@Request() req: { user?: { id?: string; sub?: string } },
|
||||
): Promise<SavedSignatureDto | null> {
|
||||
const userId = req.user?.id ?? req.user?.sub;
|
||||
if (!userId) return Promise.resolve(null);
|
||||
return this.signaturesService.getForUser(userId);
|
||||
}
|
||||
|
||||
@Put()
|
||||
@ApiOkResponse({ type: SavedSignatureDto })
|
||||
@ApiOperation({ summary: 'Create or update the reusable saved signature' })
|
||||
async saveMySignature(
|
||||
@Body() dto: SaveSignatureDto,
|
||||
@Request() req: { user?: { id?: string; sub?: string } },
|
||||
): Promise<SavedSignatureDto | null> {
|
||||
const userId = req.user?.id ?? req.user?.sub;
|
||||
if (!userId) return null;
|
||||
await this.signaturesService.upsertForUser({
|
||||
userId,
|
||||
signerDisplayName: dto.signerDisplayName,
|
||||
signatureImageBase64: dto.signatureImageBase64,
|
||||
});
|
||||
return this.signaturesService.getForUser(userId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { FilesModule } from '../files/files.module';
|
||||
import { MinioModule } from '../minio/minio.module';
|
||||
import { SignaturesController } from './signatures.controller';
|
||||
import { SignaturesService } from './signatures.service';
|
||||
import { SignaturesRepository } from './signatures.repository';
|
||||
import { SavedSignature } from './entities/saved-signature.entity';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([SavedSignature]),
|
||||
FilesModule,
|
||||
MinioModule,
|
||||
],
|
||||
controllers: [SignaturesController],
|
||||
providers: [SignaturesService, SignaturesRepository],
|
||||
exports: [SignaturesService],
|
||||
})
|
||||
export class SignaturesModule {}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { BaseRepository } from '@edr/api-common';
|
||||
import { SavedSignature } from './entities/saved-signature.entity';
|
||||
|
||||
@Injectable()
|
||||
export class SignaturesRepository extends BaseRepository<SavedSignature> {
|
||||
constructor(
|
||||
@InjectRepository(SavedSignature)
|
||||
repo: Repository<SavedSignature>,
|
||||
) {
|
||||
super(repo);
|
||||
}
|
||||
|
||||
findByUserId(userId: string): Promise<SavedSignature | null> {
|
||||
return this.repository.findOne({
|
||||
where: { userId } as never,
|
||||
relations: ['signatureFile'],
|
||||
});
|
||||
}
|
||||
|
||||
/** Insert or update the single saved signature for a user. */
|
||||
async upsert(data: Partial<SavedSignature>): Promise<SavedSignature> {
|
||||
const existing = await this.repository.findOne({
|
||||
where: { userId: data.userId! } as never,
|
||||
});
|
||||
if (existing) {
|
||||
Object.assign(existing, data);
|
||||
return this.repository.save(existing);
|
||||
}
|
||||
return this.repository.save(this.repository.create(data));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Readable } from 'stream';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
import { FilesService } from '../files/files.service';
|
||||
import { FileRecord } from '../files/entities/file.entity';
|
||||
import { MinioService } from '../minio/minio.service';
|
||||
import { SignaturesRepository } from './signatures.repository';
|
||||
import { SavedSignature } from './entities/saved-signature.entity';
|
||||
import { SavedSignatureDto } from './dto/save-signature.dto';
|
||||
|
||||
export interface UpsertSignatureInput {
|
||||
userId: string;
|
||||
signerDisplayName: string;
|
||||
signatureImageBase64: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SignaturesService {
|
||||
constructor(
|
||||
private readonly signaturesRepository: SignaturesRepository,
|
||||
private readonly filesService: FilesService,
|
||||
private readonly minioService: MinioService,
|
||||
private readonly dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
/** Saved signature for a user, with the image inlined as a data URL (or null). */
|
||||
async getForUser(userId: string): Promise<SavedSignatureDto | null> {
|
||||
const saved = await this.signaturesRepository.findByUserId(userId);
|
||||
if (!saved) return null;
|
||||
return {
|
||||
signerDisplayName: saved.signerDisplayName,
|
||||
signatureImageUrl: await this.inlineImageUrl(saved.signatureFile?.url),
|
||||
};
|
||||
}
|
||||
|
||||
/** Insert or update the user's reusable signature, storing the image in MinIO. */
|
||||
async upsertForUser(input: UpsertSignatureInput): Promise<SavedSignature> {
|
||||
const buffer = this.decodeSignatureImage(input.signatureImageBase64);
|
||||
const file: Express.Multer.File = {
|
||||
fieldname: 'signature',
|
||||
originalname: `signature-${input.userId}.png`,
|
||||
encoding: '7bit',
|
||||
mimetype: 'image/png',
|
||||
size: buffer.length,
|
||||
buffer,
|
||||
stream: Readable.from(buffer),
|
||||
destination: '',
|
||||
filename: '',
|
||||
path: '',
|
||||
};
|
||||
|
||||
// Capture the previously referenced file so we can remove it only AFTER the
|
||||
// saved_signatures row is repointed — deleting it first would violate the
|
||||
// FK constraint (saved_signatures.signature_file_id -> files.id).
|
||||
const existing = await this.signaturesRepository.findByUserId(input.userId);
|
||||
const previousFileId = existing?.signatureFileId ?? null;
|
||||
|
||||
const fileRecord = await this.filesService.upload({
|
||||
resourceId: input.userId,
|
||||
resource: 'saved_signatures',
|
||||
code: 'signature',
|
||||
file,
|
||||
});
|
||||
|
||||
const saved = await this.signaturesRepository.upsert({
|
||||
userId: input.userId,
|
||||
signerDisplayName: input.signerDisplayName,
|
||||
signatureFileId: fileRecord.id,
|
||||
});
|
||||
|
||||
if (previousFileId && previousFileId !== fileRecord.id) {
|
||||
await this.dataSource
|
||||
.getRepository(FileRecord)
|
||||
.delete({ id: previousFileId });
|
||||
}
|
||||
|
||||
return saved;
|
||||
}
|
||||
|
||||
private async inlineImageUrl(
|
||||
url?: string | null,
|
||||
): Promise<string | null> {
|
||||
if (!url) return null;
|
||||
if (url.startsWith('data:')) return url;
|
||||
try {
|
||||
const objectName = this.minioService.getObjectNameFromUrl(url);
|
||||
const stream = await this.minioService.getFileStream(objectName);
|
||||
const buffer = await this.streamToBuffer(stream);
|
||||
return `data:image/png;base64,${buffer.toString('base64')}`;
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
private decodeSignatureImage(base64: string): Buffer {
|
||||
const raw = base64.includes(',') ? base64.split(',')[1]! : base64;
|
||||
return Buffer.from(raw, 'base64');
|
||||
}
|
||||
|
||||
private streamToBuffer(stream: Readable): Promise<Buffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
stream.on('data', (chunk: Buffer | string) => {
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||
});
|
||||
stream.on('error', reject);
|
||||
stream.on('end', () => resolve(Buffer.concat(chunks)));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Column, Entity, Index } from 'typeorm';
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
|
||||
@Entity({ schema: 'freight', name: 'train_composition_removal_logs' })
|
||||
@Index(['scheduleId'])
|
||||
export class TrainCompositionRemovalLog extends BaseEntity {
|
||||
@Column({ name: 'schedule_id', type: 'uuid' }) scheduleId!: string;
|
||||
|
||||
@Column({ name: 'booking_id', type: 'uuid' }) bookingId!: string;
|
||||
|
||||
@Column({ name: 'booking_reference', type: 'varchar', length: 64, nullable: true })
|
||||
bookingReference?: string | null;
|
||||
|
||||
@Column({ name: 'removed_by_user_id', type: 'uuid', nullable: true })
|
||||
removedByUserId?: string | null;
|
||||
|
||||
@Column({ name: 'removed_at', type: 'timestamptz', default: () => 'NOW()' })
|
||||
removedAt!: Date;
|
||||
|
||||
@Column({ name: 'notes', type: 'text', nullable: true })
|
||||
notes?: string | null;
|
||||
}
|
||||
@@ -79,6 +79,10 @@ export class TrainSchedule extends BaseEntity {
|
||||
@Column({ name: 'max_wagons', type: 'int', default: 53 })
|
||||
maxWagons!: number;
|
||||
|
||||
/** OPEN = accepting/holding bookings; FULL = train filled; CLOSED = manually closed. Orthogonal to `status`. */
|
||||
@Column({ name: 'booking_window_status', type: 'varchar', length: 10, default: 'OPEN' })
|
||||
bookingWindowStatus!: string;
|
||||
|
||||
@OneToMany(() => TrainScheduleBooking, (scheduleBooking) => scheduleBooking.trainSchedule)
|
||||
scheduleBookings?: TrainScheduleBooking[];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { BaseRepository } from '@edr/api-common';
|
||||
import { TrainCompositionRemovalLog } from './entities/train-composition-removal-log.entity';
|
||||
|
||||
@Injectable()
|
||||
export class TrainCompositionRemovalLogRepository extends BaseRepository<TrainCompositionRemovalLog> {
|
||||
constructor(dataSource: DataSource) {
|
||||
super(dataSource.getRepository(TrainCompositionRemovalLog));
|
||||
}
|
||||
|
||||
async findByScheduleId(scheduleId: string): Promise<TrainCompositionRemovalLog[]> {
|
||||
return this.findAll({
|
||||
where: { scheduleId },
|
||||
order: { removedAt: 'DESC' },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -3,11 +3,13 @@ import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { TrainScheduleBooking } from './entities/train-schedule-booking.entity';
|
||||
import { TrainSchedule } from './entities/train-schedule.entity';
|
||||
import { TrainCompositionRemovalLog } from './entities/train-composition-removal-log.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 { TrainCompositionRemovalLogRepository } from './train-composition-removal-log.repository';
|
||||
import { WagonAllocationBulkLoadsRepository } from './wagon-allocation-bulk-loads.repository';
|
||||
import { WagonAllocationContainerItemsRepository } from './wagon-allocation-container-items.repository';
|
||||
import { WagonBookingAllocationsRepository } from './wagon-booking-allocations.repository';
|
||||
@@ -17,6 +19,7 @@ import { WagonBookingAllocationsRepository } from './wagon-booking-allocations.r
|
||||
TypeOrmModule.forFeature([
|
||||
TrainSchedule,
|
||||
TrainScheduleBooking,
|
||||
TrainCompositionRemovalLog,
|
||||
WagonBookingAllocation,
|
||||
WagonAllocationContainerItem,
|
||||
WagonAllocationBulkLoad,
|
||||
@@ -25,6 +28,7 @@ import { WagonBookingAllocationsRepository } from './wagon-booking-allocations.r
|
||||
providers: [
|
||||
TrainSchedulesRepository,
|
||||
TrainScheduleBookingsRepository,
|
||||
TrainCompositionRemovalLogRepository,
|
||||
WagonBookingAllocationsRepository,
|
||||
WagonAllocationContainerItemsRepository,
|
||||
WagonAllocationBulkLoadsRepository,
|
||||
@@ -32,6 +36,7 @@ import { WagonBookingAllocationsRepository } from './wagon-booking-allocations.r
|
||||
exports: [
|
||||
TrainSchedulesRepository,
|
||||
TrainScheduleBookingsRepository,
|
||||
TrainCompositionRemovalLogRepository,
|
||||
WagonBookingAllocationsRepository,
|
||||
WagonAllocationContainerItemsRepository,
|
||||
WagonAllocationBulkLoadsRepository,
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import {
|
||||
getBatchWindowForTimestamp,
|
||||
listBatchWindowsForDate,
|
||||
listBatchWindowsForBookings,
|
||||
BATCH_WINDOW_START_HOURS,
|
||||
boardWindowForTimestamp,
|
||||
listBoardWindowsForRange,
|
||||
groupBookingsIntoBoardWindows,
|
||||
} from './batch-window.util';
|
||||
|
||||
describe('batch-window.util', () => {
|
||||
it('maps 20:15 EAT to the 19:00–22:00 window', () => {
|
||||
// 20:15 EAT = 17:15 UTC on 11 Jun 2026
|
||||
const ts = new Date('2026-06-11T17:15:00.000Z');
|
||||
const window = getBatchWindowForTimestamp(ts);
|
||||
|
||||
expect(window.label).toContain('19:00');
|
||||
expect(window.label).toContain('22:00');
|
||||
expect(window.label).toContain('11 Jun 2026');
|
||||
});
|
||||
|
||||
it('maps 08:30 EAT to the 07:00–10:00 window', () => {
|
||||
const ts = new Date('2026-06-11T05:30:00.000Z'); // 08:30 EAT
|
||||
const window = getBatchWindowForTimestamp(ts);
|
||||
expect(window.label).toContain('07:00');
|
||||
expect(window.label).toContain('10:00');
|
||||
});
|
||||
|
||||
it('maps 02:00 EAT to the previous day 22:00–07:00 window', () => {
|
||||
const ts = new Date('2026-06-11T23:00:00.000Z'); // 02:00 EAT on 12 Jun
|
||||
const window = getBatchWindowForTimestamp(ts);
|
||||
expect(window.label).toContain('22:00');
|
||||
expect(window.label).toContain('07:00');
|
||||
expect(window.label).toContain('11 Jun 2026');
|
||||
});
|
||||
|
||||
it('lists six windows for a calendar day', () => {
|
||||
const ref = new Date('2026-06-11T12:00:00.000Z');
|
||||
const windows = listBatchWindowsForDate(ref);
|
||||
expect(windows).toHaveLength(BATCH_WINDOW_START_HOURS.length);
|
||||
expect(windows[0].label).toContain('07:00');
|
||||
expect(windows[windows.length - 1].label).toContain('22:00');
|
||||
});
|
||||
|
||||
it('includes cross-day overnight window when booking signed at 00:02 EAT', () => {
|
||||
// 21:02 UTC = 00:02 EAT on 12 Jun → belongs to 11 Jun 22:00–07:00 window
|
||||
const fullyExecutedAt = new Date('2026-06-11T21:02:05.153Z');
|
||||
const scheduleDate = new Date('2026-06-12T06:00:00.000Z');
|
||||
const windows = listBatchWindowsForBookings([fullyExecutedAt], scheduleDate);
|
||||
const overnight = windows.find((w) => w.label.includes('22:00') && w.label.includes('07:00'));
|
||||
expect(overnight).toBeDefined();
|
||||
expect(overnight!.label).toContain('11 Jun 2026');
|
||||
expect(getBatchWindowForTimestamp(fullyExecutedAt).key).toBe(overnight!.key);
|
||||
});
|
||||
});
|
||||
|
||||
describe('batch-window board windows (midnight-based 3h slots)', () => {
|
||||
it('maps 04:00 EAT to the 03:00–06:00 slot', () => {
|
||||
// 01:00 UTC = 04:00 EAT on 11 Jun
|
||||
const w = boardWindowForTimestamp(new Date('2026-06-11T01:00:00.000Z'));
|
||||
expect(w.label).toContain('03:00');
|
||||
expect(w.label).toContain('06:00');
|
||||
expect(w.date).toBe('2026-06-11');
|
||||
expect(w.dateLabel).toContain('11 Jun');
|
||||
});
|
||||
|
||||
it('maps 00:30 EAT to the 00:00–03:00 slot of that EAT day', () => {
|
||||
// 21:30 UTC on 10 Jun = 00:30 EAT on 11 Jun
|
||||
const w = boardWindowForTimestamp(new Date('2026-06-10T21:30:00.000Z'));
|
||||
expect(w.label).toContain('00:00');
|
||||
expect(w.label).toContain('03:00');
|
||||
expect(w.date).toBe('2026-06-11');
|
||||
});
|
||||
|
||||
it('maps 23:00 EAT to the final 21:00–24:00 slot', () => {
|
||||
// 20:00 UTC = 23:00 EAT on 11 Jun
|
||||
const w = boardWindowForTimestamp(new Date('2026-06-11T20:00:00.000Z'));
|
||||
expect(w.label).toContain('21:00');
|
||||
expect(w.label).toContain('24:00');
|
||||
expect(w.date).toBe('2026-06-11');
|
||||
});
|
||||
|
||||
it('lists a continuous range open→departure clamped at both ends', () => {
|
||||
// open 05 Jun 08:00 EAT (05:00 UTC) → departs 08 Jun 14:00 EAT (11:00 UTC)
|
||||
const open = new Date('2026-06-05T05:00:00.000Z');
|
||||
const departure = new Date('2026-06-08T11:00:00.000Z');
|
||||
const windows = listBoardWindowsForRange(open, departure);
|
||||
|
||||
// Day 5: 06,09,12,15,18,21 = 6 ; Days 6,7: 8 each ; Day 8: 00,03,06,09,12 = 5
|
||||
expect(windows).toHaveLength(6 + 8 + 8 + 5);
|
||||
expect(windows[0].date).toBe('2026-06-05');
|
||||
expect(windows[0].label).toContain('06:00');
|
||||
expect(windows[0].label).toContain('09:00');
|
||||
const last = windows[windows.length - 1];
|
||||
expect(last.date).toBe('2026-06-08');
|
||||
expect(last.label).toContain('12:00');
|
||||
expect(last.label).toContain('15:00');
|
||||
// chronological + unique keys
|
||||
const keys = windows.map((w) => w.key);
|
||||
expect(new Set(keys).size).toBe(keys.length);
|
||||
});
|
||||
|
||||
it('handles a same-day open→departure range', () => {
|
||||
const open = new Date('2026-06-05T05:00:00.000Z'); // 08:00 EAT (06–09 slot)
|
||||
const departure = new Date('2026-06-05T11:00:00.000Z'); // 14:00 EAT (12–15 slot)
|
||||
const windows = listBoardWindowsForRange(open, departure);
|
||||
// 06,09,12 = 3 slots
|
||||
expect(windows).toHaveLength(3);
|
||||
expect(windows.every((w) => w.date === '2026-06-05')).toBe(true);
|
||||
});
|
||||
|
||||
it('buckets bookings by fullyExecutedAt and keeps empty + pending windows', () => {
|
||||
const open = new Date('2026-06-05T05:00:00.000Z');
|
||||
const departure = new Date('2026-06-06T11:00:00.000Z');
|
||||
const items = [
|
||||
{ id: 'a', ts: new Date('2026-06-05T05:30:00.000Z') }, // 08:30 EAT → 06–09 on 5th
|
||||
{ id: 'b', ts: null }, // pending
|
||||
];
|
||||
const map = groupBookingsIntoBoardWindows(
|
||||
items,
|
||||
(i) => i.ts,
|
||||
open,
|
||||
departure,
|
||||
'pending-contract',
|
||||
);
|
||||
const pending = map.get('pending-contract');
|
||||
expect(pending?.items.map((i) => i.id)).toEqual(['b']);
|
||||
const withA = [...map.values()].find((b) => b.items.some((i) => i.id === 'a'));
|
||||
expect(withA?.window?.date).toBe('2026-06-05');
|
||||
// empty slots are retained for the UI
|
||||
const emptyCount = [...map.values()].filter(
|
||||
(b) => b.window && b.items.length === 0,
|
||||
).length;
|
||||
expect(emptyCount).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,340 @@
|
||||
import { BATCH_TIMEZONE } from './booking-batch.constants';
|
||||
|
||||
/** EAT intake boundaries — cron runs at these hours; each window spans to the next. */
|
||||
export const BATCH_WINDOW_START_HOURS = [7, 10, 13, 16, 19, 22] as const;
|
||||
|
||||
export interface BatchWindow {
|
||||
key: string;
|
||||
label: string;
|
||||
start: Date;
|
||||
end: Date;
|
||||
}
|
||||
|
||||
type EatDateParts = {
|
||||
year: number;
|
||||
month: number;
|
||||
day: number;
|
||||
hour: number;
|
||||
minute: number;
|
||||
};
|
||||
|
||||
const dateFmt = new Intl.DateTimeFormat('en-GB', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
timeZone: BATCH_TIMEZONE,
|
||||
});
|
||||
|
||||
const timeFmt = new Intl.DateTimeFormat('en-GB', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
timeZone: BATCH_TIMEZONE,
|
||||
});
|
||||
|
||||
function eatParts(date: Date): EatDateParts {
|
||||
const parts = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: BATCH_TIMEZONE,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
}).formatToParts(date);
|
||||
|
||||
const get = (type: Intl.DateTimeFormatPartTypes) =>
|
||||
Number(parts.find((p) => p.type === type)?.value ?? 0);
|
||||
|
||||
return {
|
||||
year: get('year'),
|
||||
month: get('month'),
|
||||
day: get('day'),
|
||||
hour: get('hour'),
|
||||
minute: get('minute'),
|
||||
};
|
||||
}
|
||||
|
||||
/** Build a UTC Date for a given EAT local wall-clock time on a calendar day. */
|
||||
function eatToUtc(
|
||||
year: number,
|
||||
month: number,
|
||||
day: number,
|
||||
hour: number,
|
||||
minute = 0,
|
||||
): Date {
|
||||
// EAT is UTC+3 year-round (no DST). Binary search would be safer across DST zones;
|
||||
// for Africa/Addis_Ababa the offset is fixed.
|
||||
const utcMs = Date.UTC(year, month - 1, day, hour - 3, minute, 0, 0);
|
||||
return new Date(utcMs);
|
||||
}
|
||||
|
||||
function formatWindowLabel(start: Date, end: Date, endHourLabel?: string): string {
|
||||
const endTime = endHourLabel ?? timeFmt.format(new Date(end.getTime() - 60_000));
|
||||
return `${dateFmt.format(start)} · ${timeFmt.format(start)} – ${endTime} EAT`;
|
||||
}
|
||||
|
||||
function windowFromEatStart(
|
||||
year: number,
|
||||
month: number,
|
||||
day: number,
|
||||
startHour: number,
|
||||
): BatchWindow {
|
||||
const start = eatToUtc(year, month, day, startHour);
|
||||
let endYear = year;
|
||||
let endMonth = month;
|
||||
let endDay = day;
|
||||
let endHour: number;
|
||||
let endHourLabel: string;
|
||||
|
||||
const idx = BATCH_WINDOW_START_HOURS.indexOf(startHour as (typeof BATCH_WINDOW_START_HOURS)[number]);
|
||||
if (idx === BATCH_WINDOW_START_HOURS.length - 1) {
|
||||
endHour = 7;
|
||||
endHourLabel = '07:00';
|
||||
const next = new Date(eatToUtc(year, month, day, 0));
|
||||
next.setUTCDate(next.getUTCDate() + 1);
|
||||
const nextParts = eatParts(next);
|
||||
endYear = nextParts.year;
|
||||
endMonth = nextParts.month;
|
||||
endDay = nextParts.day;
|
||||
} else {
|
||||
endHour = BATCH_WINDOW_START_HOURS[idx + 1];
|
||||
endHourLabel = `${String(endHour).padStart(2, '0')}:00`;
|
||||
}
|
||||
|
||||
const end = eatToUtc(endYear, endMonth, endDay, endHour);
|
||||
return {
|
||||
key: start.toISOString(),
|
||||
start,
|
||||
end,
|
||||
label: formatWindowLabel(start, end, endHourLabel),
|
||||
};
|
||||
}
|
||||
|
||||
/** Which 3h EAT intake window a timestamp (e.g. fullyExecutedAt) belongs to. */
|
||||
export function getBatchWindowForTimestamp(date: Date): BatchWindow {
|
||||
const { year, month, day, hour } = eatParts(date);
|
||||
|
||||
if (hour < 7) {
|
||||
const prev = new Date(eatToUtc(year, month, day, 0));
|
||||
prev.setUTCDate(prev.getUTCDate() - 1);
|
||||
const prevParts = eatParts(prev);
|
||||
return windowFromEatStart(prevParts.year, prevParts.month, prevParts.day, 22);
|
||||
}
|
||||
|
||||
let startHour: (typeof BATCH_WINDOW_START_HOURS)[number] = 7;
|
||||
for (const h of BATCH_WINDOW_START_HOURS) {
|
||||
if (hour >= h) startHour = h;
|
||||
}
|
||||
|
||||
return windowFromEatStart(year, month, day, startHour);
|
||||
}
|
||||
|
||||
/** All six intake windows for an EAT calendar day (includes overnight 22:00–07:00). */
|
||||
export function listBatchWindowsForDate(reference: Date): BatchWindow[] {
|
||||
const { year, month, day } = eatParts(reference);
|
||||
return BATCH_WINDOW_START_HOURS.map((startHour) =>
|
||||
windowFromEatStart(year, month, day, startHour),
|
||||
);
|
||||
}
|
||||
|
||||
export function compareBatchWindows(a: BatchWindow, b: BatchWindow): number {
|
||||
return a.start.getTime() - b.start.getTime();
|
||||
}
|
||||
|
||||
/** Schedule-day windows plus any extra windows that contain booking timestamps (cross-day). */
|
||||
export function listBatchWindowsForBookings(
|
||||
timestamps: Array<Date | null | undefined>,
|
||||
referenceDate: Date,
|
||||
): BatchWindow[] {
|
||||
const byKey = new Map<string, BatchWindow>();
|
||||
for (const w of listBatchWindowsForDate(referenceDate)) {
|
||||
byKey.set(w.key, w);
|
||||
}
|
||||
for (const ts of timestamps) {
|
||||
if (!ts) continue;
|
||||
const w = getBatchWindowForTimestamp(ts);
|
||||
byKey.set(w.key, w);
|
||||
}
|
||||
return [...byKey.values()].sort(compareBatchWindows);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Board-display windows: full-day, midnight-based 3h slots over a date range.
|
||||
// These are used ONLY for the batch-board UI grouping (not persisted, and
|
||||
// independent of the cron intake hours above).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Midnight-based 3-hour slot starts (00–03, 03–06, … 21–24). */
|
||||
export const BOARD_WINDOW_HOURS = [0, 3, 6, 9, 12, 15, 18, 21] as const;
|
||||
|
||||
/** A board window carries an EAT calendar date in addition to the slot times. */
|
||||
export interface BoardWindow extends BatchWindow {
|
||||
/** EAT calendar day as ISO `YYYY-MM-DD`. */
|
||||
date: string;
|
||||
/** Human label for the day, e.g. `Thu, 05 Jun`. */
|
||||
dateLabel: string;
|
||||
}
|
||||
|
||||
const dayLabelFmt = new Intl.DateTimeFormat('en-GB', {
|
||||
weekday: 'short',
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
timeZone: BATCH_TIMEZONE,
|
||||
});
|
||||
|
||||
function pad2(n: number): string {
|
||||
return String(n).padStart(2, '0');
|
||||
}
|
||||
|
||||
/** Build a midnight-based 3h board window for an EAT calendar day + slot start hour. */
|
||||
function boardWindowFromEatStart(
|
||||
year: number,
|
||||
month: number,
|
||||
day: number,
|
||||
startHour: number,
|
||||
): BoardWindow {
|
||||
const start = eatToUtc(year, month, day, startHour);
|
||||
const endHour = startHour + 3; // 21 -> 24 (handled by Date.UTC roll-over)
|
||||
const end = eatToUtc(year, month, day, endHour);
|
||||
const endLabel = endHour >= 24 ? '24:00' : `${pad2(endHour)}:00`;
|
||||
return {
|
||||
key: start.toISOString(),
|
||||
start,
|
||||
end,
|
||||
label: formatWindowLabel(start, end, endLabel),
|
||||
date: `${year}-${pad2(month)}-${pad2(day)}`,
|
||||
dateLabel: dayLabelFmt.format(start),
|
||||
};
|
||||
}
|
||||
|
||||
/** Which midnight-based 3h EAT slot a timestamp falls in. */
|
||||
export function boardWindowForTimestamp(date: Date): BoardWindow {
|
||||
const { year, month, day, hour } = eatParts(date);
|
||||
let startHour: (typeof BOARD_WINDOW_HOURS)[number] = 0;
|
||||
for (const h of BOARD_WINDOW_HOURS) {
|
||||
if (hour >= h) startHour = h;
|
||||
}
|
||||
return boardWindowFromEatStart(year, month, day, startHour);
|
||||
}
|
||||
|
||||
/**
|
||||
* Continuous list of board windows from `openDate` to `departureDate` (inclusive),
|
||||
* clamped to the slot containing `openDate` on the first day and the slot
|
||||
* containing `departureDate` on the last day. Returned in chronological order.
|
||||
*/
|
||||
export function listBoardWindowsForRange(
|
||||
openDate: Date,
|
||||
departureDate: Date,
|
||||
): BoardWindow[] {
|
||||
const startWin = boardWindowForTimestamp(openDate);
|
||||
const endWin = boardWindowForTimestamp(departureDate);
|
||||
// Guard against an inverted range (departure before open).
|
||||
if (endWin.start.getTime() < startWin.start.getTime()) {
|
||||
return [startWin];
|
||||
}
|
||||
|
||||
const windows: BoardWindow[] = [];
|
||||
const seen = new Set<string>();
|
||||
// Walk day-by-day in EAT, emitting each day's slots, stepping via UTC noon to
|
||||
// avoid any boundary ambiguity, then filter to [startWin.start, endWin.start].
|
||||
let cursor = new Date(eatToUtc(
|
||||
Number(startWin.date.slice(0, 4)),
|
||||
Number(startWin.date.slice(5, 7)),
|
||||
Number(startWin.date.slice(8, 10)),
|
||||
12,
|
||||
));
|
||||
const lastDayMs = eatToUtc(
|
||||
Number(endWin.date.slice(0, 4)),
|
||||
Number(endWin.date.slice(5, 7)),
|
||||
Number(endWin.date.slice(8, 10)),
|
||||
12,
|
||||
).getTime();
|
||||
|
||||
while (cursor.getTime() <= lastDayMs) {
|
||||
const { year, month, day } = eatParts(cursor);
|
||||
for (const h of BOARD_WINDOW_HOURS) {
|
||||
const w = boardWindowFromEatStart(year, month, day, h);
|
||||
if (
|
||||
w.start.getTime() >= startWin.start.getTime() &&
|
||||
w.start.getTime() <= endWin.start.getTime() &&
|
||||
!seen.has(w.key)
|
||||
) {
|
||||
seen.add(w.key);
|
||||
windows.push(w);
|
||||
}
|
||||
}
|
||||
cursor = new Date(cursor.getTime() + 24 * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
windows.sort(compareBatchWindows);
|
||||
return windows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Group items into board windows spanning [openDate, departureDate]. Empty
|
||||
* windows are kept so the UI shows every slot. Items whose timestamp falls
|
||||
* outside the range still get their own window (nothing hidden). Items without
|
||||
* a timestamp go to `pendingKey`.
|
||||
*/
|
||||
export function groupBookingsIntoBoardWindows<T>(
|
||||
items: T[],
|
||||
getTimestamp: (item: T) => Date | null | undefined,
|
||||
openDate: Date,
|
||||
departureDate: Date,
|
||||
pendingKey = 'pending-contract',
|
||||
): Map<string, { window: BoardWindow | null; items: T[] }> {
|
||||
const map = new Map<string, { window: BoardWindow | null; items: T[] }>();
|
||||
|
||||
for (const w of listBoardWindowsForRange(openDate, departureDate)) {
|
||||
map.set(w.key, { window: w, items: [] });
|
||||
}
|
||||
map.set(pendingKey, { window: null, items: [] });
|
||||
|
||||
for (const item of items) {
|
||||
const ts = getTimestamp(item);
|
||||
if (!ts) {
|
||||
map.get(pendingKey)!.items.push(item);
|
||||
continue;
|
||||
}
|
||||
const w = boardWindowForTimestamp(ts);
|
||||
if (!map.has(w.key)) {
|
||||
map.set(w.key, { window: w, items: [] });
|
||||
}
|
||||
map.get(w.key)!.items.push(item);
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
/** Group items by batch window key; items without a timestamp go to `pendingKey`. */
|
||||
export function groupByBatchWindow<T>(
|
||||
items: T[],
|
||||
getTimestamp: (item: T) => Date | null | undefined,
|
||||
referenceDate: Date,
|
||||
pendingKey = 'pending-contract',
|
||||
): Map<string, { window: BatchWindow | null; items: T[] }> {
|
||||
const timestamps = items.map(getTimestamp);
|
||||
const windows = listBatchWindowsForBookings(timestamps, referenceDate);
|
||||
const map = new Map<string, { window: BatchWindow | null; items: T[] }>();
|
||||
|
||||
for (const w of windows) {
|
||||
map.set(w.key, { window: w, items: [] });
|
||||
}
|
||||
map.set(pendingKey, { window: null, items: [] });
|
||||
|
||||
for (const item of items) {
|
||||
const ts = getTimestamp(item);
|
||||
if (!ts) {
|
||||
map.get(pendingKey)!.items.push(item);
|
||||
continue;
|
||||
}
|
||||
const w = getBatchWindowForTimestamp(ts);
|
||||
if (!map.has(w.key)) {
|
||||
map.set(w.key, { window: w, items: [] });
|
||||
}
|
||||
map.get(w.key)!.items.push(item);
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Tunables for the demand-batching booking → allocation flow.
|
||||
* Times run in EAT so the 07:00/10:00/… boundaries match the local operating clock.
|
||||
*/
|
||||
|
||||
/** Batch boundaries — every 3h from 07:00 (the 07:00–10:00 intake settles at 10:00, etc.). */
|
||||
// export const BATCH_CRON = '0 7,10,13,16,19,22 * * *';
|
||||
// export const BATCH_CRON = '*/3 * * * *';
|
||||
export const BATCH_CRON = '*/5 * * * *';
|
||||
|
||||
export const BATCH_TIMEZONE = 'Africa/Addis_Ababa';
|
||||
|
||||
/** How long a selected commercial customer has to pay before their slot expires. */
|
||||
// export const PAYMENT_WINDOW_MS = 60 * 60 * 1000; // 1 hour
|
||||
export const PAYMENT_WINDOW_MS = 5 * 60 * 1000; // 5 minutes (test mode)
|
||||
|
||||
/** Fallback wagons-per-booking when a booking has no computed `wagonsRequired`. */
|
||||
export const DEFAULT_WAGONS_PER_BOOKING = 1;
|
||||
|
||||
/**
|
||||
* Fallback per-wagon length (m) for the batch length budget when global rules don't yet
|
||||
* define maxTrainLength / maxWagons to derive it from. Used only to estimate train length
|
||||
* against the locomotive's max train length.
|
||||
*/
|
||||
export const DEFAULT_WAGON_LENGTH_METERS = 14;
|
||||
|
||||
/** Default NW5 flat wagon length for container bookings (m). */
|
||||
export const DEFAULT_CONTAINER_WAGON_LENGTH_METERS = 14;
|
||||
|
||||
/** Default CW3 covered wagon length for bulk bookings (m). */
|
||||
export const DEFAULT_BULK_WAGON_LENGTH_METERS = 14;
|
||||
@@ -0,0 +1,144 @@
|
||||
import { BookingBatchService } from './booking-batch.service';
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
|
||||
describe('BookingBatchService — PAID reconcile', () => {
|
||||
const scheduleId = 'schedule-1';
|
||||
const bookingId = 'booking-1';
|
||||
|
||||
const paidBooking = {
|
||||
id: bookingId,
|
||||
reference: 'BK-2026-000034',
|
||||
trainScheduleId: scheduleId,
|
||||
status: 'PAID',
|
||||
paymentStatus: 'PAID',
|
||||
isGovernment: false,
|
||||
cargoTotalWeightVgm: 20,
|
||||
bookingContainers: [],
|
||||
} as unknown as Booking;
|
||||
|
||||
let service: BookingBatchService;
|
||||
let bookingsRepository: {
|
||||
findPaidUnlinkedForSchedule: jest.Mock;
|
||||
findBatchPool: jest.Mock;
|
||||
findReservedForSchedule: jest.Mock;
|
||||
update: jest.Mock;
|
||||
};
|
||||
let trainScheduleBookingsRepository: {
|
||||
existsForBooking: jest.Mock;
|
||||
createMany: jest.Mock;
|
||||
};
|
||||
let trainSchedulesRepository: {
|
||||
findByIdWithFullGraph: jest.Mock;
|
||||
findAll: jest.Mock;
|
||||
};
|
||||
let trainSchedulingService: {
|
||||
tryAutoWagonAllocation: jest.Mock;
|
||||
};
|
||||
let dataSource: {
|
||||
getRepository: jest.Mock;
|
||||
transaction: jest.Mock;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
bookingsRepository = {
|
||||
findPaidUnlinkedForSchedule: jest.fn().mockResolvedValue([]),
|
||||
findBatchPool: jest.fn().mockResolvedValue([]),
|
||||
findReservedForSchedule: jest.fn().mockResolvedValue([]),
|
||||
update: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
trainScheduleBookingsRepository = {
|
||||
existsForBooking: jest.fn().mockResolvedValue(false),
|
||||
createMany: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
trainSchedulesRepository = {
|
||||
findByIdWithFullGraph: jest.fn().mockResolvedValue({
|
||||
id: scheduleId,
|
||||
maxWagons: 10,
|
||||
bookingWindowStatus: 'OPEN',
|
||||
trainSet: { locomotive: { maxPullWeightTons: 3500, maxTrainLengthMeters: 760 } },
|
||||
scheduleBookings: [],
|
||||
}),
|
||||
findAll: jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
trainSchedulingService = {
|
||||
tryAutoWagonAllocation: jest.fn().mockResolvedValue({
|
||||
assignedBookingIds: [],
|
||||
deferred: [],
|
||||
issues: [],
|
||||
violations: [],
|
||||
}),
|
||||
};
|
||||
|
||||
const bookingRepo = {
|
||||
findOne: jest.fn().mockResolvedValue(paidBooking),
|
||||
update: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
dataSource = {
|
||||
getRepository: jest.fn().mockReturnValue(bookingRepo),
|
||||
transaction: jest.fn(async (fn: (m: unknown) => Promise<void>) => {
|
||||
const manager = {
|
||||
getRepository: () => bookingRepo,
|
||||
};
|
||||
await fn(manager);
|
||||
}),
|
||||
};
|
||||
|
||||
service = new BookingBatchService(
|
||||
dataSource as never,
|
||||
bookingsRepository as never,
|
||||
trainSchedulesRepository as never,
|
||||
trainScheduleBookingsRepository as never,
|
||||
{ payNow: jest.fn(), secured: jest.fn(), expired: jest.fn() } as never,
|
||||
{ addTimeout: jest.fn(), deleteTimeout: jest.fn(), doesExist: jest.fn() } as never,
|
||||
trainSchedulingService as never,
|
||||
);
|
||||
});
|
||||
|
||||
it('reconcilePaidUnlinked links PAID bookings without a schedule row', async () => {
|
||||
bookingsRepository.findPaidUnlinkedForSchedule.mockResolvedValue([paidBooking]);
|
||||
|
||||
await service.reconcilePaidUnlinked(scheduleId);
|
||||
|
||||
expect(bookingsRepository.findPaidUnlinkedForSchedule).toHaveBeenCalledWith(scheduleId);
|
||||
expect(trainScheduleBookingsRepository.createMany).toHaveBeenCalledWith(
|
||||
[{ trainScheduleId: scheduleId, bookingId }],
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('ensurePaidBookingAllocated links PAID booking when not yet linked', async () => {
|
||||
await service.ensurePaidBookingAllocated(bookingId);
|
||||
|
||||
expect(trainScheduleBookingsRepository.createMany).toHaveBeenCalledTimes(1);
|
||||
expect(trainSchedulingService.tryAutoWagonAllocation).toHaveBeenCalledWith(scheduleId);
|
||||
});
|
||||
|
||||
it('ensurePaidBookingAllocated is idempotent when already linked', async () => {
|
||||
trainScheduleBookingsRepository.existsForBooking.mockResolvedValue(true);
|
||||
|
||||
await service.ensurePaidBookingAllocated(bookingId);
|
||||
await service.ensurePaidBookingAllocated(bookingId);
|
||||
|
||||
expect(trainScheduleBookingsRepository.createMany).not.toHaveBeenCalled();
|
||||
expect(trainSchedulingService.tryAutoWagonAllocation).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('processSchedule reconciles PAID-unlinked before wagon allocation', async () => {
|
||||
const fillSpy = jest.spyOn(service, 'fillSchedule').mockResolvedValue(undefined);
|
||||
const settleSpy = jest.spyOn(service, 'settleDueReservations').mockResolvedValue(undefined);
|
||||
const reconcileSpy = jest.spyOn(service, 'reconcilePaidUnlinked').mockResolvedValue(undefined);
|
||||
|
||||
await service.processSchedule(scheduleId);
|
||||
|
||||
expect(fillSpy).toHaveBeenCalledWith(scheduleId);
|
||||
expect(settleSpy).toHaveBeenCalledWith(scheduleId);
|
||||
expect(reconcileSpy).toHaveBeenCalledWith(scheduleId);
|
||||
expect(trainSchedulingService.tryAutoWagonAllocation).toHaveBeenCalledWith(scheduleId);
|
||||
|
||||
const fillOrder = fillSpy.mock.invocationCallOrder[0];
|
||||
const reconcileOrder = reconcileSpy.mock.invocationCallOrder[0];
|
||||
const wagonOrder = trainSchedulingService.tryAutoWagonAllocation.mock.invocationCallOrder[0];
|
||||
expect(fillOrder).toBeLessThan(reconcileOrder);
|
||||
expect(reconcileOrder).toBeLessThan(wagonOrder);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user