automation of loading and unloading

This commit is contained in:
Hagernesh
2026-06-17 22:33:06 +00:00
1637 changed files with 375027 additions and 20437 deletions

View File

@@ -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) {}

View File

@@ -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) {}

View File

@@ -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!;
}

View File

@@ -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'] },

View File

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

View File

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

View File

@@ -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');
});
});

View File

@@ -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,
@@ -14,6 +15,24 @@ import { GeneratePriceResponseDto, PriceLineItemDto } from './dto/generate-price
import { Booking } from './entities/booking.entity';
import { assertBookingStatus } from './booking-status.util';
export interface ComputedPriceResult {
lineItems: PriceLineItemDto[];
totalAmount: number;
currency: string;
usedRates: Rate[];
appliedModifiers: AppliedCargoModifier[];
priorityScore: number;
warnings: string[];
hardBlocked: string[];
}
type StoredPricingBreakdown = {
lineItems?: PriceLineItemDto[];
totalAmount?: number;
currency?: string;
generatedAt?: string;
} | null;
@Injectable()
export class BookingPricingService {
constructor(
@@ -22,74 +41,158 @@ 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> {
const booking = await this.requireBooking(bookingId);
assertBookingStatus(booking, ['DRAFT']);
assertBookingStatus(booking, ['DRAFT', 'CHANGES_REQUESTED']);
const evalInput = await this.buildEvalInputForBooking(booking);
console.log('evalInput----', evalInput);
const ruleResult = await this.ruleEngineService.evaluate(evalInput);
this.ruleEngineService.assertNoHardBlocks(ruleResult);
const lineItems: PriceLineItemDto[] = [];
let total = 0;
const baseLines = await this.computeBaseRailLines(booking, evalInput);
for (const line of baseLines) {
lineItems.push(line);
total += line.amount;
}
for (const mod of ruleResult.appliedModifiers) {
const item: PriceLineItemDto = {
code: mod.surchargeTypeCode,
description: `Surcharge: ${mod.surchargeTypeCode}`,
amount: mod.calculatedAmount,
currency: mod.currency,
};
lineItems.push(item);
total += mod.calculatedAmount;
}
await this.persistPriceRun(bookingId, ruleResult.appliedModifiers, total);
const computed = await this.computePriceForBooking(booking);
this.ruleEngineService.assertNoHardBlocks({
priorityScore: computed.priorityScore,
appliedModifiers: computed.appliedModifiers,
containerWeightResults: [],
warnings: computed.warnings,
hardBlocked: computed.hardBlocked,
requiresDirectorApproval: false,
});
await this.bookingsRepository.update(bookingId, {
totalAmount: total,
priorityScore: ruleResult.priorityScore,
totalAmount: computed.totalAmount,
priorityScore: computed.priorityScore,
pricingBreakdown: {
lineItems,
totalAmount: total,
currency: booking.paymentCurrency,
lineItems: computed.lineItems,
totalAmount: computed.totalAmount,
currency: computed.currency,
generatedAt: new Date().toISOString(),
},
} as never);
return {
bookingId,
totalAmount: computed.totalAmount,
currency: computed.currency,
lineItems: computed.lineItems,
warnings: computed.warnings,
};
}
async computePriceForBooking(booking: Booking): Promise<ComputedPriceResult> {
const evalInput = await this.buildEvalInputForBooking(booking);
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;
const { lineItems: baseLines, usedRates: baseRates } =
await this.computeBaseRailLinesWithRates(booking, evalInput);
for (const line of baseLines) {
lineItems.push(line);
total += line.amount;
}
const liveRates = await this.ratesService.findLiveRates();
const rateById = new Map(liveRates.map((r) => [r.id, r]));
const usedRatesMap = new Map(baseRates.map((r) => [r.id, r]));
for (const mod of ruleResult.appliedModifiers) {
const usdAmount = mod.calculatedAmount;
const convertedAmount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount;
const item: PriceLineItemDto = {
code: mod.surchargeTypeCode,
description: `Surcharge: ${mod.surchargeTypeCode}`,
amount: convertedAmount,
currency: paymentCurrency,
};
lineItems.push(item);
total += convertedAmount;
const rate = rateById.get(mod.rateId);
if (rate) usedRatesMap.set(rate.id, rate);
}
return {
lineItems,
totalAmount: total,
currency: booking.paymentCurrency,
lineItems,
usedRates: [...usedRatesMap.values()],
appliedModifiers: ruleResult.appliedModifiers,
priorityScore: ruleResult.priorityScore,
warnings: ruleResult.warnings,
hardBlocked: ruleResult.hardBlocked,
};
}
pricesMatch(stored: StoredPricingBreakdown, computed: ComputedPriceResult): boolean {
if (!stored?.lineItems?.length) return false;
if (Number(stored.totalAmount) !== computed.totalAmount) return false;
return (
this.lineItemsSignature(stored.lineItems) ===
this.lineItemsSignature(computed.lineItems)
);
}
async createPricingSnapshots(
bookingId: string,
usedRates: Rate[],
appliedModifiers: AppliedCargoModifier[],
): Promise<void> {
await this.bookingsRepository.clearPricingArtifacts(bookingId);
const snapshots = await this.ruleEngineService.snapshotRates(bookingId, usedRates);
const snapshotByRateId = new Map(snapshots.map((s) => [s.rateId, s.id]));
const rows = appliedModifiers
.map((m) => {
const snapshotId = snapshotByRateId.get(m.rateId);
if (!snapshotId) return null;
return {
bookingId,
surchargeTypeId: m.surchargeTypeId,
triggerValue: m.triggerValue,
calculatedAmount: m.calculatedAmount,
rateSnapshotId: snapshotId,
};
})
.filter((r): r is NonNullable<typeof r> => r !== null);
if (rows.length > 0) {
await this.bookingsRepository.createCargoModifiers(rows);
}
}
async buildEvalInputForBooking(booking: Booking): Promise<BookingEvaluationInput> {
const containers = await Promise.all(
(booking.bookingContainers ?? []).map(async (bc) => {
const ct = await this.containerTypesService.findById(bc.containerTypeId);
const vgm = Number(bc.vgmPerUnitTons);
const qty = bc.quantity;
return {
containerTypeId: bc.containerTypeId,
quantity: qty,
vgmPerUnitTons: vgm,
totalVgmTons: qty * vgm,
isReefer: ct.isReefer,
};
}),
(booking.bookingContainers ?? [])
.filter((bc): bc is typeof bc & { containerTypeId: string } => bc.containerTypeId != null)
.map(async (bc) => {
const ct = await this.containerTypesService.findById(bc.containerTypeId);
const vgm = Number(bc.vgmPerUnitTons);
const qty = bc.quantity;
return {
containerTypeId: bc.containerTypeId,
quantity: qty,
vgmPerUnitTons: vgm,
totalVgmTons: qty * vgm,
isReefer: ct.isReefer,
};
}),
);
// 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,
@@ -97,8 +200,10 @@ export class BookingPricingService {
paymentCurrency: booking.paymentCurrency,
tradeDirection: booking.tradeDirection,
isHazardous: booking.isHazardous,
isGovernment: booking.isGovernment,
allowConsolidation: booking.allowConsolidation,
shippingLineId: booking.shippingLineId,
totalWagons,
containers,
};
}
@@ -115,11 +220,7 @@ export class BookingPricingService {
totalAmount: number;
currency: string;
}> {
const stored = booking.pricingBreakdown as {
lineItems?: PriceLineItemDto[];
totalAmount?: number;
currency?: string;
} | null;
const stored = booking.pricingBreakdown as StoredPricingBreakdown;
if (stored?.lineItems?.length) {
return {
@@ -129,41 +230,28 @@ export class BookingPricingService {
};
}
const evalInput = await this.buildEvalInputForBooking(booking);
const ruleResult = await this.ruleEngineService.evaluate(evalInput);
const lineItems: PriceLineItemDto[] = [];
let total = 0;
const computed = await this.computePriceForBooking(booking);
const baseLines = await this.computeBaseRailLines(booking, evalInput);
for (const line of baseLines) {
lineItems.push(line);
total += line.amount;
}
for (const mod of ruleResult.appliedModifiers) {
lineItems.push({
code: mod.surchargeTypeCode,
description: `Surcharge: ${mod.surchargeTypeCode}`,
amount: mod.calculatedAmount,
currency: mod.currency,
});
total += mod.calculatedAmount;
}
if (lineItems.length === 0) {
total = Number(booking.totalAmount);
lineItems.push({
code: 'TOTAL',
description: 'Contract total',
amount: total,
if (computed.lineItems.length === 0) {
const total = Number(booking.totalAmount);
return {
lineItems: [
{
code: 'TOTAL',
description: 'Contract total',
amount: total,
currency: booking.paymentCurrency,
},
],
totalAmount: total,
currency: booking.paymentCurrency,
});
};
}
return {
lineItems,
totalAmount: total || Number(booking.totalAmount),
currency: booking.paymentCurrency,
lineItems: computed.lineItems,
totalAmount: computed.totalAmount || Number(booking.totalAmount),
currency: computed.currency,
};
}
@@ -190,14 +278,16 @@ export class BookingPricingService {
return score;
}
private async computeBaseRailLines(
private async computeBaseRailLinesWithRates(
booking: Booking,
evalInput: BookingEvaluationInput,
): Promise<PriceLineItemDto[]> {
): Promise<{ lineItems: PriceLineItemDto[]; usedRates: Rate[] }> {
const liveRates = await this.ratesService.findLiveRates();
const currency = booking.paymentCurrency;
const paymentCurrency = booking.paymentCurrency;
const isEtbBooking = paymentCurrency === 'ETB';
const usdToEtb = isEtbBooking ? await this.cbeExchangeService.getUsdToEtbRate() : 1;
const isBulk = booking.freightType === 'BULK';
console.log('liveRates----', liveRates);
const rateType =
booking.tradeDirection === 'IMPORT'
? isBulk
@@ -207,45 +297,50 @@ console.log('liveRates----', liveRates);
? isBulk
? 'BULK_EXPORT'
: 'CONTAINER_EXPORT'
: 'INTERCITY_CONTAINER';
console.log('rateType----', rateType);
: 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) {
console.log('container----', container);
const rate = this.pickRate(liveRates, rateType, container.containerTypeId, currency);
console.log('rate----', rate);
const rate = this.pickRate(liveRates, rateType, container.containerTypeId, 'USD');
if (!rate) continue;
const amount = this.amountForRate(rate, container.quantity, wagonCount);
usedRatesMap.set(rate.id, rate);
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) {
const amount = this.amountForRate(fallback, 1, wagonCount);
usedRatesMap.set(fallback.id, fallback);
const bulkTons = Number(booking.cargoTotalWeightVgm ?? 0);
const quantity =
isBulk && fallback.rateUnit === 'PER_TON' ? Math.max(bulkTons, 0) : 1;
const usdAmount = this.amountForRate(fallback, quantity, wagonCount);
const amount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount;
lines.push({
code: rateType,
description: `Base rail (${rateType})`,
amount,
currency: fallback.currency,
currency: paymentCurrency,
});
}
}
return lines;
return { lineItems: lines, usedRates: [...usedRatesMap.values()] };
}
private pickRate(
@@ -281,31 +376,15 @@ console.log('liveRates----', liveRates);
}
}
private async persistPriceRun(
bookingId: string,
modifiers: AppliedCargoModifier[],
_total: number,
): Promise<void> {
await this.bookingsRepository.clearPricingArtifacts(bookingId);
const snapshots = await this.ruleEngineService.snapshotLiveRates(bookingId);
const snapshotByRateId = new Map(snapshots.map((s) => [s.rateId, s.id]));
const rows = modifiers
.map((m) => {
const snapshotId = snapshotByRateId.get(m.rateId);
if (!snapshotId) return null;
return {
bookingId,
surchargeTypeId: m.surchargeTypeId,
triggerValue: m.triggerValue,
calculatedAmount: m.calculatedAmount,
rateSnapshotId: snapshotId,
};
})
.filter((r): r is NonNullable<typeof r> => r !== null);
if (rows.length > 0) {
await this.bookingsRepository.createCargoModifiers(rows);
}
private lineItemsSignature(items: PriceLineItemDto[]): string {
return JSON.stringify(
[...items]
.map((item) => ({
code: item.code,
amount: item.amount,
currency: item.currency,
}))
.sort((a, b) => a.code.localeCompare(b.code)),
);
}
}

View File

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

View File

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

View File

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

View File

@@ -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],
})

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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(/&quot;/g, '"')
.replace(/&#34;/g, '"')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>');
}
}

View File

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

View File

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

View File

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

View File

@@ -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),
}));
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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) {}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,10 +1,11 @@
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"
type PaymentStatus = "action-required" | "processing" | "success" | "failed" | "canceled" | "refunded"
export type PaymentStatus = "action-required" | "processing" | "success" | "failed" | "canceled" | "refunded"
@Entity({ schema: 'freight', name: 'payments' })
export class PaymentEntity extends BaseEntity {
@@ -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[];
}

View File

@@ -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);
}
}

View File

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

View File

@@ -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");
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -1,67 +1,219 @@
import { Controller, Get, NotFoundException, Param, ParseUUIDPipe, 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 { randomUUID } from "crypto";
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) { }
// @Get("/receipts/:orderId/html")
// async genReceipt(@Param("orderId") orderId: string, @Res() res: Response) {
// const filled = await this.paymentService.genReceiptHtml(orderId);
// return res.send(filled)
// }
// @Post("/initiate/booking")
// async initiatePayment() {
// //Only for testing..
// const description = "Booking for contact"
// const price = 2000
// const data = await this.paymentService.pay(price, "ETB", "telebirr", description, "booking", (_) => {
// return new Promise((resp, _) => {
// resp({
// id: randomUUID(),
// type: "booking"
// })
// });
// })
// return data
// }
@Post("/bookings/check-payment/:orderId")
checkPayment(@Param("orderId") orderId: string) {
return this.paymentService.checkStatusAndUpdate(orderId)
@Get("summary")
@BookingView()
@ApiOperation({ summary: "Payment count/amount summary for dashboard cards" })
getSummary() {
return this.paymentService.getSummary();
}
@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,
});
}
@Get("/telebirr/:refId")
async pay(@Param("refId", ParseUUIDPipe) refId: string, @Res() res: Response) {
const payment = await this.paymentService.getActivePaymentByRefIdAndMethod(refId, "telebirr")
if (!payment) {
throw new NotFoundException('payment not found')
@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"));
}
return res.send(`
<!DOCTYPE html>
<html>
<head>
<title>Redirecting...</title>
</head>
<body>
<p>Redirecting...</p>
try {
const result = await this.paymentService.initiatePayment({ bookingId, method, platform });
const url =
result.clientAction?.type === "REDIRECT" ? result.clientAction.url : undefined;
<script>
window.location.href = "${payment.clientAction?.url}";
</script>
</body>
</html>
`);
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, "&quot;");
return `<!DOCTYPE html>
<html lang="en">
<head>
<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>
<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>`;
}
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>`;
}
}

View File

@@ -1,17 +1,63 @@
import { Module } from "@nestjs/common";
import { PaymentTelebirrStrategy } from "./strategies/payment.telebirr.strategy";
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 { 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, PaymentTelebirrStrategy, PaymentService, TelebirrWebhookService],
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 { }

View File

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

View File

@@ -1,189 +1,433 @@
import {
BadRequestException,
Injectable,
InternalServerErrorException,
NotFoundException,
BadRequestException,
forwardRef,
Inject,
Injectable,
InternalServerErrorException,
Logger,
NotFoundException,
} from "@nestjs/common";
import { DataSource, QueryRunner } from "typeorm";
import { DataSource } from "typeorm";
import { PaymentEntity } from "./entities/payment.entity";
import { PaymentStrategy } from "./strategies/payment.strategy";
import { PaymentTelebirrStrategy } from "./strategies/payment.telebirr.strategy";
import { PaymentRepository } from "./payment.repository";
import { ClientAction, PaymentPlatform } from "./strategies/payments.types";
import * as crypto from "crypto";
import { PaymentClientService } from "./payment-client.service";
import * as fs from "fs";
import * as path from "path";
import * as Handlebars from "handlebars";
import { ConfigService } from "@nestjs/config";
import { Booking } from "../bookings/entities/booking.entity";
type PaymentMethod = PaymentEntity["method"];
type CurrencyType = PaymentEntity["currency"];
import {
ClientAction,
ProviderPaymentStatus,
} from "@edr/payment-providers";
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 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 strategies: Map<PaymentMethod, PaymentStrategy>;
private readonly logger = new Logger(PaymentService.name);
constructor(
private readonly configService: ConfigService,
private readonly datasource: DataSource,
private readonly paymentRepo: PaymentRepository,
private readonly telebirrPaymentStategy: PaymentTelebirrStrategy,
) {
this.strategies = new Map([
["telebirr", this.telebirrPaymentStategy as PaymentStrategy],
]);
}
constructor(
private readonly datasource: DataSource,
private readonly paymentRepo: PaymentRepository,
private readonly paymentClient: PaymentClientService,
@Inject(forwardRef(() => BookingBatchService))
private readonly bookingBatchService: BookingBatchService,
) { }
async pay(
amount: number,
currency: CurrencyType,
method: PaymentMethod,
reason: string,
type: PaymentEntity["type"],
cb: (
qr: QueryRunner,
) => Promise<{ id: string; type: PaymentEntity["type"] }>,
payform: PaymentPlatform = "web",
): Promise<{
refId: string;
clientAction: ClientAction;
status: PaymentEntity["status"];
paidAt?: string;
failureCode?: string;
failureMessage?: string;
}> {
const strategy = this.strategies.get(method);
if (!strategy) {
throw new NotFoundException("strategy not found");
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 qb = this.paymentRepo.createQueryBuilder("payment");
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,
};
}
const orderId = `${Date.now()}${crypto.randomBytes(4).toString("hex")}`; //todo: make it dynamic
let redirectUrl: string;
switch (type) {
case "booking":
const url = this.configService.get<string>(
"TELEBIRR_SUCCESS_REDIRECT_BASE_URL",
);
redirectUrl = `${url}/${orderId}`;
break;
/** 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),
};
}
const paymentResp = await strategy.pay({
redirectUrl,
amountMinor: amount,
currency: currency,
merchantOrderId: orderId,
platform: payform,
});
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");
const queryRunner = this.datasource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
console.log("bookingbooking",booking)
const amountMinor = Math.round(Number(booking.totalAmount) * 100);
console.log("amountminor",amountMinor)
console.log(paymentResp.expiresAt);
try {
const resp = await cb(queryRunner);
const payment = await this.paymentRepo.createTr(queryRunner, {
amount,
currency,
method,
refId: resp.id,
type: resp.type,
merchantOrderId: orderId,
rawInitiation: paymentResp.rawInitiation,
clientAction: paymentResp.clientAction,
expiresAt: paymentResp.expiresAt,
reason,
});
await queryRunner.commitTransaction();
return {
refId: payment.refId,
clientAction: paymentResp.clientAction,
status: payment.status,
paidAt: payment.paidAt?.toISOString(),
failureCode: payment.failerCode ?? undefined,
failureMessage: payment.failureMessage ?? undefined,
};
} catch (err) {
await queryRunner.rollbackTransaction();
throw new Error("payment failed");
} finally {
await queryRunner.release();
}
}
async getActivePaymentByRefIdAndMethod(
refId: string,
method: PaymentEntity["method"],
): Promise<PaymentEntity | null> {
return this.paymentRepo.getActivePaymentByRefIdAndMethod(refId, method);
}
async genReceiptHtml(orderId: string) {
const payment = await this.paymentRepo.findOneBy({
merchantOrderId: orderId,
status: "success",
});
if (!payment) {
throw new BadRequestException();
}
const filePath = path.join(__dirname, "templates", "receipt.hbs");
if (!fs.existsSync(filePath)) {
throw new InternalServerErrorException();
}
const source = fs.readFileSync(filePath, "utf8");
const template = Handlebars.compile(source);
const html = template({
vendorName: "Ethio Djibouti Railway Ticket Booking",
vendorAddress: "Addis Ababa",
receiptDate: payment.paidAt,
paymentMethod: payment?.method,
subtotal: payment?.amount.toString(),
total: payment?.amount.toString(),
currency: payment?.currency,
reason: payment?.reason,
});
return html;
}
async checkStatusAndUpdate(orderId: string) {
const resp = await this.paymentRepo.findOneBy({ merchantOrderId: orderId });
if (!resp) {
throw new NotFoundException("order id not found");
}
try {
const result = await this.telebirrPaymentStategy.queryStatus(
resp.merchantOrderId,
);
const bizContent = result.rawResponse.biz_content as {
order_status: string;
};
const ordersStatus = bizContent.order_status;
if (ordersStatus == "PAY_SUCCESS") {
await this.datasource.transaction(async (mg) => {
await mg.update(Booking, { id: resp.refId }, { status: "PAID" });
await mg.update(PaymentEntity, { id: resp.id }, { status: "success" });
const snapshot = await this.paymentClient.initiate({
service: PaymentServiceEnum.FREIGHT,
referenceType: PaymentReferenceType.SHIPMENT,
referenceId: booking.id,
orderRef: booking.reference,
amountMinor,
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,
});
}
return {
status: result.status,
};
} catch {
// Telebirr API unavailable — fall back to current DB payment status
const dbStatus =
resp.status === "success"
? "success"
: resp.status === "failed"
? "failed"
: "processing";
return { status: dbStatus };
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,
};
if (existing) {
await this.paymentRepo.update({ id: existing.id }, { ...data, clientAction } as any);
return { ...existing, ...data, clientAction } as PaymentEntity;
}
return this.paymentRepo.create({
refId: bookingId,
type: "booking",
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);
}
async genReceiptHtml(orderId: string) {
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();
const source = fs.readFileSync(filePath, "utf8");
const template = Handlebars.compile(source);
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,
});
}
findBookingById(id: string) {
return this.paymentRepo.findOneBy({ refId: id, type: "booking" });
}
formatIntentResponse(intent: PaymentEntity): InitiateResponseDto {
const clientAction =
intent.clientAction && typeof intent.clientAction === "object"
? (intent.clientAction as unknown as ClientAction)
: undefined;
return {
intentId: intent.id,
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";
}
}
}
}

View File

@@ -0,0 +1,108 @@
import { ProviderPaymentStatus } from "@edr/types";
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
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: 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", "COLLECT_OTP"] })
type!: "REDIRECT" | "LAUNCH_APP" | "COLLECT_OTP";
@ApiPropertyOptional({ description: "Set when type=REDIRECT (web flow)" })
url?: string;
@ApiPropertyOptional({ description: "Set when type=LAUNCH_APP (mobile flow)" })
appId?: string;
@ApiPropertyOptional({ description: "Set when type=LAUNCH_APP (mobile flow)" })
receiveCode?: string;
@ApiPropertyOptional({ description: "Set when type=LAUNCH_APP (mobile flow)" })
shortCode?: string;
@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 {
@ApiProperty()
intentId!: string;
@ApiProperty({ enum: ProviderPaymentStatus })
status!: ProviderPaymentStatus;
@ApiPropertyOptional({ type: ClientActionDto })
clientAction?: ClientActionDto;
@ApiPropertyOptional()
merchantOrderId?: string;
}
export class IntentStatusDto extends InitiateResponseDto {
@ApiPropertyOptional()
paidAt?: string;
@ApiPropertyOptional()
failureCode?: string;
@ApiPropertyOptional()
failureMessage?: string;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,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;
}

View File

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

View File

@@ -1,40 +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("not valid")
// }
// const merchantOrderId = payload.merch_order_id;
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' };
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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' })

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/swagger';
import { CreatePriorityConfigDto } from './create-priority-config.dto';
export class UpdatePriorityConfigDto extends PartialType(CreatePriorityConfigDto) {}

View File

@@ -1,4 +0,0 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreatePriorityRuleDto } from './create-priority-rule.dto';
export class UpdatePriorityRuleDto extends PartialType(CreatePriorityRuleDto) {}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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');

View File

@@ -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);
}
}

View File

@@ -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);
}
}

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