mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 19:58:11 +00:00
booking flow,summtion, approval, contract, mock payemnt and integration to back office, and also add permissions
This commit is contained in:
@@ -23,6 +23,10 @@ SUPER_ADMIN_EMAIL=superadmin@tria.com
|
||||
SUPER_ADMIN_PHONE=
|
||||
DEFAULT_PASSWORD=password@tria
|
||||
|
||||
# Freight org + staff (bookings / rule-engine IAM)
|
||||
SEED_EDR_ORG=true
|
||||
SEED_FREIGHT_STAFF=true
|
||||
|
||||
# MinIO (used by @tria-plc/iamapi-common for file storage)
|
||||
MINIO_ENDPOINT=localhost
|
||||
MINIO_PORT=9000
|
||||
|
||||
@@ -24,12 +24,14 @@ import { OtpModule } from './modules/otp/otp.module';
|
||||
import { RuleEngineModule } from "./modules/rule-engine/rule-engine.module";
|
||||
import { BackofficeModule } from "./modules/backoffice/backoffice.module";
|
||||
import { DemoPermissionsModule } from "./modules/demo-permissions/demo-permissions.module";
|
||||
import { FreightAuthModule } from "./modules/auth/freight-auth.module";
|
||||
import {
|
||||
EDR_FREIGHT_APPLICATION,
|
||||
EDR_FREIGHT_PERMISSIONS,
|
||||
} from "./seed/edr-freight.seed";
|
||||
import { EdrOrgSeeder } from "./seed/edr-org.seeder";
|
||||
import { DemoUsersSeeder } from "./seed/demo-users.seeder";
|
||||
import { FreightStaffUsersSeeder } from "./seed/freight-staff-users.seeder";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -70,19 +72,22 @@ import { DemoUsersSeeder } from "./seed/demo-users.seeder";
|
||||
RuleEngineModule,
|
||||
BackofficeModule,
|
||||
DemoPermissionsModule,
|
||||
FreightAuthModule,
|
||||
],
|
||||
providers: [EdrOrgSeeder, DemoUsersSeeder],
|
||||
providers: [EdrOrgSeeder, DemoUsersSeeder, FreightStaffUsersSeeder],
|
||||
})
|
||||
export class AppModule implements OnApplicationBootstrap {
|
||||
constructor(
|
||||
private readonly seeder: DataSeeder,
|
||||
private readonly edrOrgSeeder: EdrOrgSeeder,
|
||||
private readonly demoUsersSeeder: DemoUsersSeeder,
|
||||
private readonly freightStaffUsersSeeder: FreightStaffUsersSeeder,
|
||||
) { }
|
||||
|
||||
async onApplicationBootstrap() {
|
||||
await this.seeder.run();
|
||||
await this.edrOrgSeeder.run();
|
||||
await this.demoUsersSeeder.run();
|
||||
await this.freightStaffUsersSeeder.run();
|
||||
}
|
||||
}
|
||||
|
||||
17
apps/edr-freight-api/src/common/booking-guards.ts
Normal file
17
apps/edr-freight-api/src/common/booking-guards.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { applyDecorators, UseGuards } from '@nestjs/common';
|
||||
import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard';
|
||||
|
||||
import { FreightPermissionGuard } from './freight-permission.guard';
|
||||
import { FREIGHT_PERMS } from '../seed/freight-permissions.registry';
|
||||
|
||||
export const BookingStaff = (permission: string | string[]) =>
|
||||
applyDecorators(
|
||||
UseGuards(
|
||||
JwtGuard,
|
||||
FreightPermissionGuard(
|
||||
Array.isArray(permission) ? permission : [permission],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
export const BookingView = () => BookingStaff(FREIGHT_PERMS.bookings.view);
|
||||
38
apps/edr-freight-api/src/common/freight-permission.guard.ts
Normal file
38
apps/edr-freight-api/src/common/freight-permission.guard.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
Type,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
||||
|
||||
import { hasFreightPermission } from './freight-permission.util';
|
||||
|
||||
export function FreightPermissionGuard(
|
||||
permissions: string[],
|
||||
): Type<CanActivate> {
|
||||
@Injectable()
|
||||
class FreightPermissionsGuard implements CanActivate {
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const request = context.switchToHttp().getRequest<{ user?: TCurrentUser }>();
|
||||
const user = request.user;
|
||||
|
||||
if (!permissions?.length) return true;
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('Authentication required');
|
||||
}
|
||||
|
||||
if (permissions.some((p) => hasFreightPermission(user, p))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
throw new ForbiddenException(
|
||||
`Missing permission. Required one of: ${permissions.join(', ')}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return FreightPermissionsGuard;
|
||||
}
|
||||
99
apps/edr-freight-api/src/common/freight-permission.util.ts
Normal file
99
apps/edr-freight-api/src/common/freight-permission.util.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { ForbiddenException } from '@nestjs/common';
|
||||
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
||||
|
||||
import { FREIGHT_PERMS } from '../seed/freight-permissions.registry';
|
||||
|
||||
const SUPER_ADMIN_ROLE = 'super_admin';
|
||||
|
||||
type PermissionLike = { key?: string };
|
||||
type MeLikeUser = {
|
||||
roles?: { key?: string }[];
|
||||
permissions?: PermissionLike[];
|
||||
employee?:
|
||||
| {
|
||||
position?: { permissions?: PermissionLike[] };
|
||||
delegatedPositions?: { permissions?: PermissionLike[] }[];
|
||||
}
|
||||
| {
|
||||
positions?: { permissions?: PermissionLike[] }[];
|
||||
}[]
|
||||
| null;
|
||||
};
|
||||
|
||||
export function isSuperAdmin(user: MeLikeUser | null | undefined): boolean {
|
||||
if (!user?.roles?.length) return false;
|
||||
return user.roles.some((r) => r.key === SUPER_ADMIN_ROLE);
|
||||
}
|
||||
|
||||
/** Flat permission keys from JWT / session user (roles + position permissions). */
|
||||
export function collectPermissionKeys(user: MeLikeUser | null | undefined): string[] {
|
||||
if (!user) return [];
|
||||
|
||||
const keys = new Set<string>();
|
||||
|
||||
for (const p of user.permissions ?? []) {
|
||||
if (p.key) keys.add(p.key);
|
||||
}
|
||||
|
||||
const employee = user.employee;
|
||||
if (!employee) {
|
||||
return [...keys];
|
||||
}
|
||||
|
||||
if (Array.isArray(employee)) {
|
||||
for (const emp of employee) {
|
||||
for (const pos of emp.positions ?? []) {
|
||||
for (const p of pos.permissions ?? []) {
|
||||
if (p.key) keys.add(p.key);
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...keys];
|
||||
}
|
||||
|
||||
for (const p of employee.position?.permissions ?? []) {
|
||||
if (p.key) keys.add(p.key);
|
||||
}
|
||||
for (const delegated of employee.delegatedPositions ?? []) {
|
||||
for (const p of delegated.permissions ?? []) {
|
||||
if (p.key) keys.add(p.key);
|
||||
}
|
||||
}
|
||||
|
||||
return [...keys];
|
||||
}
|
||||
|
||||
export function hasFreightPermission(
|
||||
user: MeLikeUser | null | undefined,
|
||||
permissionKey: string,
|
||||
): boolean {
|
||||
if (!user) return false;
|
||||
if (isSuperAdmin(user)) return true;
|
||||
return collectPermissionKeys(user).includes(permissionKey);
|
||||
}
|
||||
|
||||
export function assertFreightPermission(
|
||||
user: TCurrentUser | MeLikeUser | null | undefined,
|
||||
permissionKey: string,
|
||||
): void {
|
||||
if (hasFreightPermission(user, permissionKey)) return;
|
||||
throw new ForbiddenException(`Missing permission: ${permissionKey}`);
|
||||
}
|
||||
|
||||
const APPROVE_ROLE_PERMISSION: Record<string, string> = {
|
||||
LINE_STAFF: FREIGHT_PERMS.bookings.approveLineStaff,
|
||||
DIRECTOR: FREIGHT_PERMS.bookings.approveDirector,
|
||||
CEO: FREIGHT_PERMS.bookings.approveCeo,
|
||||
};
|
||||
|
||||
export function assertCanApproveBookingStep(
|
||||
user: TCurrentUser | MeLikeUser | null | undefined,
|
||||
requiredRole: string,
|
||||
): void {
|
||||
if (isSuperAdmin(user)) return;
|
||||
const perm = APPROVE_ROLE_PERMISSION[requiredRole];
|
||||
if (!perm) {
|
||||
throw new ForbiddenException(`Unknown approval role: ${requiredRole}`);
|
||||
}
|
||||
assertFreightPermission(user, perm);
|
||||
}
|
||||
18
apps/edr-freight-api/src/common/rule-engine-guards.ts
Normal file
18
apps/edr-freight-api/src/common/rule-engine-guards.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { applyDecorators, UseGuards } from '@nestjs/common';
|
||||
import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard';
|
||||
|
||||
import { FreightPermissionGuard } from './freight-permission.guard';
|
||||
import {
|
||||
FREIGHT_PERMS,
|
||||
type RuleEngineResourceSlug,
|
||||
} from '../seed/freight-permissions.registry';
|
||||
|
||||
export const RuleEngineView = (slug: RuleEngineResourceSlug) =>
|
||||
applyDecorators(
|
||||
UseGuards(JwtGuard, FreightPermissionGuard([FREIGHT_PERMS.ruleEngine.view(slug)])),
|
||||
);
|
||||
|
||||
export const RuleEngineManage = (slug: RuleEngineResourceSlug) =>
|
||||
applyDecorators(
|
||||
UseGuards(JwtGuard, FreightPermissionGuard([FREIGHT_PERMS.ruleEngine.manage(slug)])),
|
||||
);
|
||||
@@ -1,57 +1,116 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { existsSync } from 'fs';
|
||||
|
||||
import { Injectable, InternalServerErrorException, Logger } from '@nestjs/common';
|
||||
|
||||
const MIN_VALID_PDF_BYTES = 2_000;
|
||||
|
||||
const PDF_PRINT_STYLES = `
|
||||
<style id="contract-pdf-print-fix">
|
||||
@media print {
|
||||
html, body {
|
||||
background: #fff !important;
|
||||
-webkit-print-color-adjust: exact;
|
||||
print-color-adjust: exact;
|
||||
}
|
||||
.cover {
|
||||
min-height: auto !important;
|
||||
page-break-after: always;
|
||||
}
|
||||
.cover-title {
|
||||
margin: 24mm 0 20mm !important;
|
||||
}
|
||||
}
|
||||
</style>`;
|
||||
|
||||
@Injectable()
|
||||
export class ContractPdfService {
|
||||
private readonly logger = new Logger(ContractPdfService.name);
|
||||
|
||||
async htmlToPdfBuffer(html: string): Promise<Buffer> {
|
||||
const preparedHtml = this.injectPdfPrintStyles(html);
|
||||
const executablePath = this.resolveExecutablePath();
|
||||
|
||||
try {
|
||||
const puppeteer = await import('puppeteer');
|
||||
const browser = await puppeteer.default.launch({
|
||||
const launchOptions: import('puppeteer').LaunchOptions = {
|
||||
headless: true,
|
||||
args: ['--no-sandbox', '--disable-setuid-sandbox'],
|
||||
});
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
'--disable-dev-shm-usage',
|
||||
],
|
||||
...(executablePath ? { executablePath } : {}),
|
||||
};
|
||||
|
||||
const browser = await puppeteer.default.launch(launchOptions);
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await page.setContent(html, { waitUntil: 'load' });
|
||||
await page.setViewport({ width: 794, height: 1123, deviceScaleFactor: 1 });
|
||||
await page.setContent(preparedHtml, {
|
||||
waitUntil: 'load',
|
||||
timeout: 60_000,
|
||||
});
|
||||
await page.emulateMediaType('print');
|
||||
await new Promise((resolve) => setTimeout(resolve, 400));
|
||||
|
||||
const pdf = await page.pdf({
|
||||
format: 'A4',
|
||||
printBackground: true,
|
||||
margin: { top: '20mm', bottom: '20mm', left: '15mm', right: '15mm' },
|
||||
displayHeaderFooter: true,
|
||||
headerTemplate: '<span></span>',
|
||||
footerTemplate:
|
||||
'<div style="width:100%;font-size:8px;color:#64748b;text-align:center;font-family:Arial,sans-serif;">Page <span class="pageNumber"></span> of <span class="totalPages"></span></div>',
|
||||
margin: { top: '18mm', bottom: '22mm', left: '14mm', right: '14mm' },
|
||||
});
|
||||
return Buffer.from(pdf);
|
||||
|
||||
const buffer = Buffer.from(pdf);
|
||||
if (!this.isValidPdf(buffer)) {
|
||||
throw new Error(
|
||||
`Puppeteer produced invalid PDF (${buffer.length} bytes)`,
|
||||
);
|
||||
}
|
||||
this.logger.log(
|
||||
`Contract PDF rendered (${buffer.length} bytes) via ${executablePath ?? 'bundled Chromium'}`,
|
||||
);
|
||||
return buffer;
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Puppeteer PDF failed, falling back to minimal PDF stub: ${err}`,
|
||||
this.logger.error(
|
||||
`Puppeteer PDF failed (executable=${executablePath ?? 'default'}): ${err}`,
|
||||
);
|
||||
throw new InternalServerErrorException(
|
||||
'Contract PDF could not be generated. Ensure Chromium is installed or set PUPPETEER_EXECUTABLE_PATH.',
|
||||
);
|
||||
return this.fallbackPdfBuffer(html);
|
||||
}
|
||||
}
|
||||
|
||||
/** Minimal valid PDF when Chromium is unavailable. */
|
||||
private fallbackPdfBuffer(html: string): Buffer {
|
||||
const text = html.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').slice(0, 2000);
|
||||
const escaped = text.replace(/\\/g, '\\\\').replace(/\(/g, '\\(').replace(/\)/g, '\\)');
|
||||
const stream = `BT /F1 10 Tf 50 750 Td (${escaped}) Tj ET`;
|
||||
const len = stream.length;
|
||||
const pdf = `%PDF-1.4
|
||||
1 0 obj<< /Type /Catalog /Pages 2 0 R >>endobj
|
||||
2 0 obj<< /Type /Pages /Kids [3 0 R] /Count 1 >>endobj
|
||||
3 0 obj<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>endobj
|
||||
4 0 obj<< /Length ${len} >>stream
|
||||
${stream}
|
||||
endstream endobj
|
||||
5 0 obj<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>endobj
|
||||
xref
|
||||
0 6
|
||||
0000000000 65535 f
|
||||
trailer<< /Size 6 /Root 1 0 R >>
|
||||
startxref
|
||||
0
|
||||
%%EOF`;
|
||||
return Buffer.from(pdf, 'utf-8');
|
||||
private injectPdfPrintStyles(html: string): string {
|
||||
if (html.includes('contract-pdf-print-fix')) return html;
|
||||
if (html.includes('</head>')) {
|
||||
return html.replace('</head>', `${PDF_PRINT_STYLES}</head>`);
|
||||
}
|
||||
return `${PDF_PRINT_STYLES}${html}`;
|
||||
}
|
||||
|
||||
private resolveExecutablePath(): string | undefined {
|
||||
const fromEnv = process.env.PUPPETEER_EXECUTABLE_PATH?.trim();
|
||||
if (fromEnv && existsSync(fromEnv)) return fromEnv;
|
||||
|
||||
const candidates = [
|
||||
'/usr/bin/chromium',
|
||||
'/usr/bin/chromium-browser',
|
||||
'/usr/bin/google-chrome-stable',
|
||||
'/usr/bin/google-chrome',
|
||||
];
|
||||
return candidates.find((p) => existsSync(p));
|
||||
}
|
||||
|
||||
private isValidPdf(buffer: Buffer): boolean {
|
||||
return (
|
||||
buffer.length >= MIN_VALID_PDF_BYTES &&
|
||||
buffer.subarray(0, 5).toString('ascii') === '%PDF-'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@ export class ContractPricingScheduleBuilder {
|
||||
})),
|
||||
totalAmount,
|
||||
currency,
|
||||
equipmentReturn: booking.equipmentReturn ?? undefined,
|
||||
equipmentReturn: booking.equipmentReturn ?? '—',
|
||||
originLabel: booking.originYard?.label ?? booking.originYard?.code ?? '—',
|
||||
destinationLabel:
|
||||
booking.destinationYard?.label ?? booking.destinationYard?.code ?? '—',
|
||||
|
||||
@@ -23,6 +23,31 @@ describe('ContractRendererService', () => {
|
||||
phone: '+251900000000',
|
||||
email: 'test@example.com',
|
||||
tinNumber: '1234567890',
|
||||
vatNumber: 'VAT-001',
|
||||
fanNumber: 'FAN-001',
|
||||
businessLicense: 'BL-001',
|
||||
},
|
||||
provider: {
|
||||
name: 'Ethio-Djibouti Standard Gauge Railway Share Company',
|
||||
address: 'Addis Ababa, Ethiopia',
|
||||
phone: '+251 11 872 0000',
|
||||
email: 'info@edr.gov.et',
|
||||
tinNumber: '—',
|
||||
},
|
||||
schedule: {
|
||||
originLabel: 'SGTD',
|
||||
destinationLabel: 'Modjo',
|
||||
tradeDirection: 'IMPORT',
|
||||
freightType: 'CONTAINER',
|
||||
serviceType: 'Rail transport',
|
||||
scheduledDate: '1 January 2026',
|
||||
contractType: 'NEW',
|
||||
cargoDescription: 'Container cargo',
|
||||
totalWeightVgm: '24 tons',
|
||||
equipmentReturn: 'RETURN',
|
||||
hazardousLabel: 'No',
|
||||
firstMilePickupAddress: '—',
|
||||
lastMileDeliveryAddress: '—',
|
||||
},
|
||||
pricing: {
|
||||
lineItems: [{ label: 'RAIL', description: 'Rail transport', amount: 1000, currency: 'ETB' }],
|
||||
|
||||
@@ -32,6 +32,31 @@ export interface ContractViewModel {
|
||||
phone: string;
|
||||
email: string;
|
||||
tinNumber: string;
|
||||
vatNumber: string;
|
||||
fanNumber: string;
|
||||
businessLicense: string;
|
||||
};
|
||||
provider: {
|
||||
name: string;
|
||||
address: string;
|
||||
phone: string;
|
||||
email: string;
|
||||
tinNumber: string;
|
||||
};
|
||||
schedule: {
|
||||
originLabel: string;
|
||||
destinationLabel: string;
|
||||
tradeDirection: string;
|
||||
freightType: string;
|
||||
serviceType: string;
|
||||
scheduledDate: string;
|
||||
contractType: string;
|
||||
cargoDescription: string;
|
||||
totalWeightVgm: string;
|
||||
equipmentReturn: string;
|
||||
hazardousLabel: string;
|
||||
firstMilePickupAddress: string;
|
||||
lastMileDeliveryAddress: string;
|
||||
};
|
||||
pricing: PricingSchedule;
|
||||
signatures: ContractSignatureView[];
|
||||
@@ -81,19 +106,24 @@ export class ContractViewModelBuilder {
|
||||
}),
|
||||
contractYear: new Date().getFullYear(),
|
||||
client: {
|
||||
// companyName: booking.customer?.companyName ?? 'Client',
|
||||
// companyAddress: booking.customer?.companyAddress ?? '—',
|
||||
// companyLocation: booking.customer?.companyLocation ?? '—',
|
||||
// phone: booking.customer?.companyPhone ?? booking.customer?.phone ?? '—',
|
||||
// email: booking.customer?.companyEmail ?? booking.customer?.email ?? '—',
|
||||
// tinNumber: booking.customer?.tinNumber ?? '—',
|
||||
companyName: booking.company?.name ?? 'Client',
|
||||
companyAddress: booking.company?.address ?? '—',
|
||||
companyLocation: booking.company?.country ?? '—',
|
||||
phone: booking.company?.phone ?? '—',
|
||||
email: booking.company?.email ?? '—',
|
||||
tinNumber: booking.company?.tin ?? '—',
|
||||
companyAddress: this.valueOrDash(booking.company?.address),
|
||||
companyLocation: this.valueOrDash(booking.company?.country),
|
||||
phone: this.valueOrDash(booking.company?.phone),
|
||||
email: this.valueOrDash(booking.company?.email),
|
||||
tinNumber: this.valueOrDash(booking.company?.tin),
|
||||
vatNumber: this.valueOrDash(booking.company?.vatNumber),
|
||||
fanNumber: this.valueOrDash(booking.company?.fanNumber),
|
||||
businessLicense: this.valueOrDash(booking.company?.businessLicense),
|
||||
},
|
||||
provider: {
|
||||
name: 'Ethio-Djibouti Standard Gauge Railway Share Company',
|
||||
address: 'Addis Ababa, Ethiopia',
|
||||
phone: '+251 11 872 0000',
|
||||
email: 'info@edr.gov.et',
|
||||
tinNumber: '—',
|
||||
},
|
||||
schedule: this.buildSchedule(booking),
|
||||
pricing,
|
||||
signatures,
|
||||
canSignCustomer:
|
||||
@@ -117,8 +147,61 @@ export class ContractViewModelBuilder {
|
||||
return {
|
||||
role: row.signerRole,
|
||||
signerDisplayName: row.signerDisplayName,
|
||||
signedAt: row.signedAt.toISOString(),
|
||||
signedAt: this.formatDate(row.signedAt),
|
||||
signatureImageUrl: row.signatureFile?.url ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
private buildSchedule(booking: Booking): ContractViewModel['schedule'] {
|
||||
const cargoName =
|
||||
booking.freightType === 'BULK'
|
||||
? booking.cargoFreeText ||
|
||||
booking.cargoType?.cargoTypeName ||
|
||||
'Bulk commodity'
|
||||
: booking.cargoType?.cargoTypeName || 'Container cargo';
|
||||
const totalWeight = Number(booking.cargoTotalWeightVgm || 0);
|
||||
|
||||
return {
|
||||
originLabel: this.yardLabel(booking.originYard),
|
||||
destinationLabel: this.yardLabel(booking.destinationYard),
|
||||
tradeDirection: this.valueOrDash(booking.tradeDirection),
|
||||
freightType: this.valueOrDash(booking.freightType),
|
||||
serviceType: this.valueOrDash(
|
||||
booking.serviceType?.serviceName ?? booking.serviceType?.code,
|
||||
),
|
||||
scheduledDate: this.formatDate(booking.scheduledDate),
|
||||
contractType: this.valueOrDash(booking.contractType),
|
||||
cargoDescription: this.valueOrDash(cargoName),
|
||||
totalWeightVgm:
|
||||
totalWeight > 0 ? `${totalWeight.toLocaleString()} tons` : '—',
|
||||
equipmentReturn: this.valueOrDash(booking.equipmentReturn),
|
||||
hazardousLabel: booking.isHazardous ? 'Yes' : 'No',
|
||||
firstMilePickupAddress: this.valueOrDash(
|
||||
booking.firstMilePickupAddress,
|
||||
),
|
||||
lastMileDeliveryAddress: this.valueOrDash(
|
||||
booking.lastMileDeliveryAddress,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
private yardLabel(yard?: { label?: string; code?: string } | null): string {
|
||||
return this.valueOrDash(yard?.label ?? yard?.code);
|
||||
}
|
||||
|
||||
private formatDate(value?: Date | string | null): string {
|
||||
if (!value) return '—';
|
||||
const date = value instanceof Date ? value : new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return '—';
|
||||
return date.toLocaleDateString('en-GB', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
private valueOrDash(value?: string | number | null): string {
|
||||
if (value === undefined || value === null || value === '') return '—';
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
<div class="article">
|
||||
<h2>Article 1: Objective and Scope of Services</h2>
|
||||
<p><strong>Objective:</strong> {{template.article1.objective}}</p>
|
||||
<p>
|
||||
<strong>1.1 Objective.</strong>
|
||||
{{template.article1.objective}}
|
||||
</p>
|
||||
{{#if template.article1.scope.length}}
|
||||
<p><strong>Scope:</strong></p>
|
||||
<p><strong>1.2 Scope of Services.</strong></p>
|
||||
<ol>
|
||||
{{#each template.article1.scope}}
|
||||
<li>{{this}}</li>
|
||||
|
||||
@@ -1,22 +1,31 @@
|
||||
<h2>Article 5: Contract Price and Terms of Payment</h2>
|
||||
<div class="article">
|
||||
<h3>Contract Price</h3>
|
||||
<p><strong>Corridor:</strong> {{pricing.originLabel}} → {{pricing.destinationLabel}}</p>
|
||||
<p>
|
||||
The contract price is calculated based on the agreed railway corridor, cargo details, applicable rate
|
||||
schedule, and any approved operational surcharges.
|
||||
</p>
|
||||
<table class="details-table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>Corridor</th>
|
||||
<td>{{pricing.originLabel}} → {{pricing.destinationLabel}}</td>
|
||||
<th>Currency</th>
|
||||
<td>{{pricing.currency}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Payment currency</th>
|
||||
<td>{{paymentArticle}}</td>
|
||||
<th>Equipment return</th>
|
||||
<td>{{pricing.equipmentReturn}}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
{{#if pricing.equipmentReturn}}
|
||||
<p><strong>Equipment return:</strong> {{pricing.equipmentReturn}}</p>
|
||||
{{/if}}
|
||||
{{#if pricing.containerLines.length}}
|
||||
<table class="schedule">
|
||||
<thead>
|
||||
<tr><th>Container type</th><th>Quantity</th><th>VGM / unit (t)</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{#each pricing.containerLines}}
|
||||
<tr><td>{{label}}</td><td>{{quantity}}</td><td>{{vgmPerUnitTons}}</td></tr>
|
||||
{{/each}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{/if}}
|
||||
|
||||
<h3>Charges</h3>
|
||||
<table class="schedule">
|
||||
<thead>
|
||||
<tr><th>Item</th><th>Description</th><th>Amount</th></tr>
|
||||
@@ -29,6 +38,10 @@
|
||||
<td>{{currency}} {{amount}}</td>
|
||||
</tr>
|
||||
{{/each}}
|
||||
{{#if pricing.surcharges.length}}
|
||||
<tr>
|
||||
<th colspan="3">Surcharges and Adjustments</th>
|
||||
</tr>
|
||||
{{#each pricing.surcharges}}
|
||||
<tr>
|
||||
<td>{{label}}</td>
|
||||
@@ -36,12 +49,18 @@
|
||||
<td>{{currency}} {{amount}}</td>
|
||||
</tr>
|
||||
{{/each}}
|
||||
<tr>
|
||||
{{/if}}
|
||||
<tr class="total-row">
|
||||
<td colspan="2"><strong>Total contract value</strong></td>
|
||||
<td><strong>{{pricing.currency}} {{pricing.totalAmount}}</strong></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<h3>Terms of payment</h3>
|
||||
<p>All payments shall be made in accordance with EDR policy in <strong>{{paymentArticle}}</strong>, unless otherwise agreed in writing.</p>
|
||||
<p>
|
||||
Unless otherwise agreed in writing, the Client shall settle the contract value in
|
||||
<strong>{{paymentArticle}}</strong> before the service is performed and in accordance with EDR payment
|
||||
instructions. Bank charges, penalties, demurrage, storage, and third-party charges remain the
|
||||
responsibility of the Client where applicable.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<div class="article">
|
||||
<h2>Article 2: Obligations of the Client (summary)</h2>
|
||||
<h2>Article 2: Obligations of the Client</h2>
|
||||
<p>The Client shall perform the following obligations in good faith and within the operational timelines communicated by EDR:</p>
|
||||
<ol>
|
||||
{{#each template.clientObligations}}
|
||||
<li>{{this}}</li>
|
||||
@@ -8,7 +9,8 @@
|
||||
</div>
|
||||
|
||||
<div class="article">
|
||||
<h2>Article 3: Obligations of the Service Provider (summary)</h2>
|
||||
<h2>Article 3: Obligations of the Service Provider</h2>
|
||||
<p>EDR shall provide the agreed railway freight services in accordance with this Agreement and applicable operational rules:</p>
|
||||
<ol>
|
||||
{{#each template.providerObligations}}
|
||||
<li>{{this}}</li>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<div class="article">
|
||||
<h2>Article 6: Contract Documents</h2>
|
||||
<p>The following documents form part of this Agreement and shall be read together with the signed contract:</p>
|
||||
<ol>
|
||||
{{#each template.contractDocuments}}
|
||||
<li>{{this}}</li>
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
<section class="page-section">
|
||||
<h2>Booking Schedule and Commercial Summary</h2>
|
||||
<table class="details-table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>Route</th>
|
||||
<td>{{schedule.originLabel}} → {{schedule.destinationLabel}}</td>
|
||||
<th>Trade direction</th>
|
||||
<td>{{schedule.tradeDirection}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Freight type</th>
|
||||
<td>{{schedule.freightType}}</td>
|
||||
<th>Service type</th>
|
||||
<td>{{schedule.serviceType}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Scheduled date</th>
|
||||
<td>{{schedule.scheduledDate}}</td>
|
||||
<th>Contract type</th>
|
||||
<td>{{schedule.contractType}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Cargo</th>
|
||||
<td>{{schedule.cargoDescription}}</td>
|
||||
<th>Total VGM</th>
|
||||
<td>{{schedule.totalWeightVgm}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Equipment return</th>
|
||||
<td>{{schedule.equipmentReturn}}</td>
|
||||
<th>Hazardous cargo</th>
|
||||
<td>{{schedule.hazardousLabel}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>First mile</th>
|
||||
<td>{{schedule.firstMilePickupAddress}}</td>
|
||||
<th>Last mile</th>
|
||||
<td>{{schedule.lastMileDeliveryAddress}}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{{#if pricing.containerLines.length}}
|
||||
<h3>Container Details</h3>
|
||||
<table class="schedule">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Container type</th>
|
||||
<th>Quantity</th>
|
||||
<th>VGM / unit (tons)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{#each pricing.containerLines}}
|
||||
<tr>
|
||||
<td>{{label}}</td>
|
||||
<td>{{quantity}}</td>
|
||||
<td>{{vgmPerUnitTons}}</td>
|
||||
</tr>
|
||||
{{/each}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{/if}}
|
||||
</section>
|
||||
@@ -1,4 +1,12 @@
|
||||
<div class="article">
|
||||
<h2>Article 4: Force Majeure</h2>
|
||||
<p>Neither party is liable for delays due to force majeure interpreted under the Ethiopian Civil Code.</p>
|
||||
<p>
|
||||
Neither party shall be liable for delay or non-performance caused by events beyond its reasonable control,
|
||||
including natural disaster, war, civil unrest, government restriction, railway interruption, port closure,
|
||||
or other force majeure events interpreted under the Ethiopian Civil Code.
|
||||
</p>
|
||||
<p>
|
||||
The affected party shall notify the other party promptly and shall use reasonable efforts to reduce the
|
||||
effect of the force majeure event on the performance of this Agreement.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -1,30 +1,44 @@
|
||||
<div class="signatures">
|
||||
<div class="sig-block">
|
||||
<p><strong>Service Provider (EDR)</strong></p>
|
||||
<p class="sig-title">For the Service Provider</p>
|
||||
<p><strong>{{provider.name}}</strong></p>
|
||||
{{#if hasStaffSignature}}
|
||||
{{#each signatures}}
|
||||
{{#if (eq role "STAFF")}}
|
||||
{{#if signatureImageUrl}}<img src="{{signatureImageUrl}}" alt="Staff signature" />{{/if}}
|
||||
<p>{{signerDisplayName}}</p>
|
||||
<p class="sig-line">Signed: {{signedAt}}</p>
|
||||
<div class="sig-image-box">
|
||||
{{#if signatureImageUrl}}<img src="{{signatureImageUrl}}" alt="Staff signature" />{{/if}}
|
||||
</div>
|
||||
<p class="sig-line"><strong>Name:</strong> {{signerDisplayName}}</p>
|
||||
<p class="sig-meta"><strong>Role:</strong> Authorized EDR representative</p>
|
||||
<p class="sig-meta"><strong>Date:</strong> {{signedAt}}</p>
|
||||
{{/if}}
|
||||
{{/each}}
|
||||
{{else}}
|
||||
<p class="sig-line">Authorized representative (pending)</p>
|
||||
<div class="sig-image-box"><span class="sig-placeholder">Signature pending</span></div>
|
||||
<p class="sig-line"><strong>Name:</strong> Authorized representative</p>
|
||||
<p class="sig-meta"><strong>Role:</strong> EDR representative</p>
|
||||
<p class="sig-meta"><strong>Date:</strong></p>
|
||||
{{/if}}
|
||||
</div>
|
||||
<div class="sig-block">
|
||||
<p><strong>Client — {{client.companyName}}</strong></p>
|
||||
<p class="sig-title">For the Client</p>
|
||||
<p><strong>{{client.companyName}}</strong></p>
|
||||
{{#if hasCustomerSignature}}
|
||||
{{#each signatures}}
|
||||
{{#if (eq role "CUSTOMER")}}
|
||||
{{#if signatureImageUrl}}<img src="{{signatureImageUrl}}" alt="Customer signature" />{{/if}}
|
||||
<p>{{signerDisplayName}}</p>
|
||||
<p class="sig-line">Signed: {{signedAt}}</p>
|
||||
<div class="sig-image-box">
|
||||
{{#if signatureImageUrl}}<img src="{{signatureImageUrl}}" alt="Customer signature" />{{/if}}
|
||||
</div>
|
||||
<p class="sig-line"><strong>Name:</strong> {{signerDisplayName}}</p>
|
||||
<p class="sig-meta"><strong>Role:</strong> Authorized client representative</p>
|
||||
<p class="sig-meta"><strong>Date:</strong> {{signedAt}}</p>
|
||||
{{/if}}
|
||||
{{/each}}
|
||||
{{else}}
|
||||
<p class="sig-line">Client representative (pending)</p>
|
||||
<div class="sig-image-box"><span class="sig-placeholder">Signature pending</span></div>
|
||||
<p class="sig-line"><strong>Name:</strong> Client representative</p>
|
||||
<p class="sig-meta"><strong>Role:</strong> Authorized client representative</p>
|
||||
<p class="sig-meta"><strong>Date:</strong></p>
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,22 +1,258 @@
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
body { font-family: 'Times New Roman', Times, serif; font-size: 11pt; line-height: 1.45; color: #111; margin: 0; padding: 24px; }
|
||||
h1 { text-align: center; font-size: 14pt; text-transform: uppercase; margin: 0 0 8px; }
|
||||
h2 { font-size: 12pt; margin: 20px 0 8px; text-transform: uppercase; }
|
||||
h3 { font-size: 11pt; margin: 14px 0 6px; }
|
||||
.cover { text-align: center; margin-bottom: 32px; border-bottom: 2px solid #1e3a5f; padding-bottom: 24px; }
|
||||
.meta { margin: 12px 0; }
|
||||
.meta strong { display: inline-block; min-width: 140px; }
|
||||
.article { margin-bottom: 16px; }
|
||||
.article ol { padding-left: 20px; }
|
||||
.article li { margin-bottom: 6px; }
|
||||
table.schedule { width: 100%; border-collapse: collapse; margin: 12px 0; font-size: 10pt; }
|
||||
table.schedule th, table.schedule td { border: 1px solid #333; padding: 6px 8px; text-align: left; }
|
||||
table.schedule th { background: #f0f4f8; }
|
||||
.signatures { display: flex; gap: 40px; margin-top: 40px; page-break-inside: avoid; }
|
||||
.sig-block { flex: 1; }
|
||||
.sig-block img { max-height: 64px; max-width: 200px; display: block; margin: 8px 0; }
|
||||
.sig-line { border-top: 1px solid #000; margin-top: 48px; padding-top: 4px; font-size: 10pt; }
|
||||
.pending-banner { background: #fff8e6; border: 1px solid #e6c200; padding: 8px 12px; margin-bottom: 16px; font-size: 10pt; }
|
||||
@media print { body { padding: 0; } }
|
||||
@page { size: A4; margin: 18mm 14mm; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: #f5f7fb;
|
||||
color: #111827;
|
||||
font-family: "Times New Roman", Times, serif;
|
||||
font-size: 10.5pt;
|
||||
line-height: 1.48;
|
||||
}
|
||||
|
||||
.contract {
|
||||
width: 210mm;
|
||||
min-height: 297mm;
|
||||
margin: 0 auto;
|
||||
background: #fff;
|
||||
padding: 18mm 15mm;
|
||||
}
|
||||
|
||||
h1, h2, h3, p { margin-top: 0; }
|
||||
h1 {
|
||||
color: #0f2742;
|
||||
font-size: 18pt;
|
||||
line-height: 1.25;
|
||||
margin-bottom: 10px;
|
||||
text-align: center;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
h2 {
|
||||
border-bottom: 1.5px solid #1e3a5f;
|
||||
color: #1e3a5f;
|
||||
font-size: 12pt;
|
||||
letter-spacing: 0.03em;
|
||||
margin: 18px 0 10px;
|
||||
padding-bottom: 5px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
h3 {
|
||||
color: #0f2742;
|
||||
font-size: 10.8pt;
|
||||
margin: 12px 0 6px;
|
||||
}
|
||||
p { margin-bottom: 8px; }
|
||||
ol { margin: 6px 0 0; padding-left: 20px; }
|
||||
li { margin-bottom: 5px; }
|
||||
|
||||
.page-section,
|
||||
.article {
|
||||
margin-bottom: 18px;
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
|
||||
.brand-row {
|
||||
align-items: center;
|
||||
border-bottom: 3px solid #1e3a5f;
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
padding-bottom: 14px;
|
||||
}
|
||||
.logo-mark {
|
||||
align-items: center;
|
||||
background: #1e3a5f;
|
||||
border-radius: 8px;
|
||||
color: #fff;
|
||||
display: flex;
|
||||
font-family: Arial, sans-serif;
|
||||
font-size: 16pt;
|
||||
font-weight: 700;
|
||||
height: 52px;
|
||||
justify-content: center;
|
||||
letter-spacing: 0.08em;
|
||||
width: 72px;
|
||||
}
|
||||
.kicker {
|
||||
color: #1e3a5f;
|
||||
font-family: Arial, sans-serif;
|
||||
font-size: 10pt;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
margin-bottom: 2px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.muted {
|
||||
color: #6b7280;
|
||||
font-family: Arial, sans-serif;
|
||||
font-size: 9pt;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.cover {
|
||||
min-height: 255mm;
|
||||
position: relative;
|
||||
}
|
||||
.cover-title {
|
||||
margin: 54mm 0 34mm;
|
||||
text-align: center;
|
||||
}
|
||||
.document-label {
|
||||
color: #6b7280;
|
||||
font-family: Arial, sans-serif;
|
||||
font-size: 10pt;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.12em;
|
||||
margin-bottom: 10px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.summary-line {
|
||||
color: #374151;
|
||||
font-family: Arial, sans-serif;
|
||||
font-size: 9.5pt;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
}
|
||||
.meta-grid,
|
||||
.details-table,
|
||||
.schedule {
|
||||
font-size: 9.5pt;
|
||||
margin: 10px 0 16px;
|
||||
}
|
||||
.meta-grid th,
|
||||
.meta-grid td,
|
||||
.details-table th,
|
||||
.details-table td,
|
||||
.schedule th,
|
||||
.schedule td {
|
||||
border: 1px solid #cbd5e1;
|
||||
padding: 7px 8px;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
.meta-grid th,
|
||||
.details-table th,
|
||||
.schedule th {
|
||||
background: #eef4fb;
|
||||
color: #1e3a5f;
|
||||
font-family: Arial, sans-serif;
|
||||
font-size: 8.5pt;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.schedule tbody tr:nth-child(even) td { background: #f8fafc; }
|
||||
.total-row td {
|
||||
background: #e8f0f8 !important;
|
||||
color: #0f2742;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.lead {
|
||||
color: #374151;
|
||||
font-size: 10.5pt;
|
||||
}
|
||||
.party-grid {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
.party-card {
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
}
|
||||
.party-card h3 {
|
||||
background: #1e3a5f;
|
||||
border-radius: 5px;
|
||||
color: #fff;
|
||||
font-family: Arial, sans-serif;
|
||||
font-size: 9pt;
|
||||
margin: 0 0 10px;
|
||||
padding: 7px 9px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.party-name {
|
||||
color: #0f2742;
|
||||
font-weight: 700;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
dl {
|
||||
display: grid;
|
||||
grid-template-columns: 32% 68%;
|
||||
margin: 0;
|
||||
}
|
||||
dt {
|
||||
color: #475569;
|
||||
font-family: Arial, sans-serif;
|
||||
font-size: 8.5pt;
|
||||
font-weight: 700;
|
||||
padding: 2px 6px 2px 0;
|
||||
}
|
||||
dd {
|
||||
margin: 0;
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.signatures {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
margin-top: 24px;
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
.sig-block {
|
||||
border: 1.5px solid #1e3a5f;
|
||||
border-radius: 8px;
|
||||
min-height: 96mm;
|
||||
padding: 12px;
|
||||
}
|
||||
.sig-title {
|
||||
color: #1e3a5f;
|
||||
font-family: Arial, sans-serif;
|
||||
font-size: 9pt;
|
||||
font-weight: 700;
|
||||
margin-bottom: 10px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.sig-image-box {
|
||||
align-items: center;
|
||||
border: 1px dashed #94a3b8;
|
||||
display: flex;
|
||||
height: 28mm;
|
||||
justify-content: center;
|
||||
margin: 14px 0;
|
||||
}
|
||||
.sig-image-box img {
|
||||
display: block;
|
||||
max-height: 24mm;
|
||||
max-width: 70mm;
|
||||
}
|
||||
.sig-placeholder {
|
||||
color: #94a3b8;
|
||||
font-family: Arial, sans-serif;
|
||||
font-size: 8.5pt;
|
||||
}
|
||||
.sig-line {
|
||||
border-top: 1px solid #111827;
|
||||
margin-top: 16px;
|
||||
padding-top: 5px;
|
||||
}
|
||||
.sig-meta {
|
||||
color: #475569;
|
||||
font-size: 9pt;
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
@media print {
|
||||
body { background: #fff; }
|
||||
.contract {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: auto;
|
||||
}
|
||||
.cover { page-break-after: always; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -6,25 +6,86 @@
|
||||
{{> styles}}
|
||||
</head>
|
||||
<body>
|
||||
<div class="cover">
|
||||
<h1>Contract Agreement</h1>
|
||||
<h1>{{template.title}}</h1>
|
||||
<p class="meta"><strong>Contract Ref No:</strong> {{reference}}</p>
|
||||
<p class="meta"><strong>Year:</strong> {{contractYear}}</p>
|
||||
</div>
|
||||
<main class="contract">
|
||||
<section class="cover page-section">
|
||||
<div class="brand-row">
|
||||
<div class="logo-mark">EDR</div>
|
||||
<div>
|
||||
<p class="kicker">Ethio-Djibouti Standard Gauge Railway Share Company</p>
|
||||
<p class="muted">Freight Transport Contract</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p>This Contract Agreement is made on <strong>{{contractDate}}</strong>.</p>
|
||||
<p><strong>Between</strong> Ethio-Djibouti Standard Gauge Railway Share Company (EDR), Addis Ababa (“Service Provider”), and <strong>{{client.companyName}}</strong> at {{client.companyAddress}}, {{client.companyLocation}} (“Client”). Phone {{client.phone}} / {{client.email}}. TIN {{client.tinNumber}}.</p>
|
||||
<div class="cover-title">
|
||||
<p class="document-label">Contract Agreement</p>
|
||||
<h1>{{template.title}}</h1>
|
||||
<p class="summary-line">{{template.directionLabel}} • {{template.freightLabel}} • {{template.currency}} • {{template.serviceScope}}</p>
|
||||
</div>
|
||||
|
||||
<h2>Whereas</h2>
|
||||
<p>{{template.whereas}}</p>
|
||||
<p>Now therefore, the parties agree as follows:</p>
|
||||
<table class="meta-grid">
|
||||
<tr>
|
||||
<th>Contract Ref No.</th>
|
||||
<td>{{reference}}</td>
|
||||
<th>Contract Year</th>
|
||||
<td>{{contractYear}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Contract Date</th>
|
||||
<td>{{contractDate}}</td>
|
||||
<th>Status</th>
|
||||
<td>{{status}}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
{{> article1}}
|
||||
{{> articles_obligations}}
|
||||
{{> article5_pricing}}
|
||||
{{> force_majeure}}
|
||||
{{> contract_documents}}
|
||||
{{> signatures_block}}
|
||||
<section class="page-section">
|
||||
<h2>Parties to the Agreement</h2>
|
||||
<p class="lead">
|
||||
This Contract Agreement is made on <strong>{{contractDate}}</strong> between the Service Provider and the Client named below.
|
||||
</p>
|
||||
|
||||
<div class="party-grid">
|
||||
<div class="party-card">
|
||||
<h3>Service Provider</h3>
|
||||
<p class="party-name">{{provider.name}}</p>
|
||||
<dl>
|
||||
<dt>Address</dt><dd>{{provider.address}}</dd>
|
||||
<dt>Phone</dt><dd>{{provider.phone}}</dd>
|
||||
<dt>Email</dt><dd>{{provider.email}}</dd>
|
||||
<dt>TIN</dt><dd>{{provider.tinNumber}}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
<div class="party-card">
|
||||
<h3>Client</h3>
|
||||
<p class="party-name">{{client.companyName}}</p>
|
||||
<dl>
|
||||
<dt>Address</dt><dd>{{client.companyAddress}}</dd>
|
||||
<dt>Location</dt><dd>{{client.companyLocation}}</dd>
|
||||
<dt>Phone</dt><dd>{{client.phone}}</dd>
|
||||
<dt>Email</dt><dd>{{client.email}}</dd>
|
||||
<dt>TIN</dt><dd>{{client.tinNumber}}</dd>
|
||||
<dt>VAT</dt><dd>{{client.vatNumber}}</dd>
|
||||
<dt>FAN</dt><dd>{{client.fanNumber}}</dd>
|
||||
<dt>Business license</dt><dd>{{client.businessLicense}}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{{> contract_schedule}}
|
||||
|
||||
<section class="page-section">
|
||||
<h2>Whereas</h2>
|
||||
<p>{{template.whereas}}</p>
|
||||
<p>Now therefore, the parties agree as follows:</p>
|
||||
</section>
|
||||
|
||||
{{> article1}}
|
||||
{{> articles_obligations}}
|
||||
{{> force_majeure}}
|
||||
{{> article5_pricing}}
|
||||
{{> contract_documents}}
|
||||
{{> signatures_block}}
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddBlocksRoleToApprovalStep1749600000000 implements MigrationInterface {
|
||||
name = 'AddBlocksRoleToApprovalStep1749600000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.booking_approval_step
|
||||
ADD COLUMN IF NOT EXISTS blocks_role VARCHAR(30) NULL;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.booking_approval_step
|
||||
DROP COLUMN IF EXISTS blocks_role;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Seed ITMLS US-06 approval chains if missing (standard + bulk).
|
||||
*/
|
||||
export class SeedDefaultApprovalRules1749700000000 implements MigrationInterface {
|
||||
name = 'SeedDefaultApprovalRules1749700000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
INSERT INTO freight.approval_rules
|
||||
(id, requires_director_approval, step_order, required_role, action_label, blocks_role, created_at, updated_at)
|
||||
SELECT uuid_generate_v4(), false, 1, 'LINE_STAFF', 'Review & Approve', NULL, now(), now()
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM freight.approval_rules
|
||||
WHERE requires_director_approval = false AND step_order = 1 AND deleted_at IS NULL
|
||||
);
|
||||
|
||||
INSERT INTO freight.approval_rules
|
||||
(id, requires_director_approval, step_order, required_role, action_label, blocks_role, created_at, updated_at)
|
||||
SELECT uuid_generate_v4(), false, 2, 'DIRECTOR', 'Final Signature', 'LINE_STAFF', now(), now()
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM freight.approval_rules
|
||||
WHERE requires_director_approval = false AND step_order = 2 AND deleted_at IS NULL
|
||||
);
|
||||
|
||||
INSERT INTO freight.approval_rules
|
||||
(id, requires_director_approval, step_order, required_role, action_label, blocks_role, created_at, updated_at)
|
||||
SELECT uuid_generate_v4(), true, 1, 'DIRECTOR', 'Review & Approve', 'LINE_STAFF', now(), now()
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM freight.approval_rules
|
||||
WHERE requires_director_approval = true AND step_order = 1 AND deleted_at IS NULL
|
||||
);
|
||||
|
||||
INSERT INTO freight.approval_rules
|
||||
(id, requires_director_approval, step_order, required_role, action_label, blocks_role, created_at, updated_at)
|
||||
SELECT uuid_generate_v4(), true, 2, 'CEO', 'Final Signature', NULL, now(), now()
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM freight.approval_rules
|
||||
WHERE requires_director_approval = true AND step_order = 2 AND deleted_at IS NULL
|
||||
);
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(_queryRunner: QueryRunner): Promise<void> {
|
||||
// Keep seeded rules on rollback to avoid breaking in-flight bookings.
|
||||
}
|
||||
}
|
||||
10
apps/edr-freight-api/src/modules/auth/freight-auth.module.ts
Normal file
10
apps/edr-freight-api/src/modules/auth/freight-auth.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { FreightMeController } from './freight-me.controller';
|
||||
import { FreightMeService } from './freight-me.service';
|
||||
|
||||
@Module({
|
||||
controllers: [FreightMeController],
|
||||
providers: [FreightMeService],
|
||||
})
|
||||
export class FreightAuthModule {}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Controller, Get, UseGuards } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { CurrentUser } from '@tria-plc/api-common/modules/auth/decorators/current-user.decorator';
|
||||
import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard';
|
||||
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
||||
|
||||
import { FreightMeService } from './freight-me.service';
|
||||
|
||||
@ApiTags('auth')
|
||||
@Controller('me')
|
||||
@ApiBearerAuth()
|
||||
export class FreightMeController {
|
||||
constructor(private readonly freightMeService: FreightMeService) {}
|
||||
|
||||
@Get()
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiOperation({
|
||||
summary: 'Current user with flat permissionKeys for backoffice gating',
|
||||
})
|
||||
getMe(@CurrentUser() user: TCurrentUser) {
|
||||
return this.freightMeService.getEnrichedProfile(user);
|
||||
}
|
||||
}
|
||||
57
apps/edr-freight-api/src/modules/auth/freight-me.service.ts
Normal file
57
apps/edr-freight-api/src/modules/auth/freight-me.service.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
||||
|
||||
import {
|
||||
collectPermissionKeys,
|
||||
isSuperAdmin,
|
||||
} from '../../common/freight-permission.util';
|
||||
import { PERMISSIONS_CATALOG } from '../../seed/freight-permissions.registry';
|
||||
|
||||
@Injectable()
|
||||
export class FreightMeService {
|
||||
getEnrichedProfile(user: TCurrentUser) {
|
||||
const employee = user.employee
|
||||
? [
|
||||
{
|
||||
id: user.employee.id,
|
||||
organizationId: user.employee.organizationId,
|
||||
unitId: user.employee.unitId,
|
||||
name: user.employee.name,
|
||||
positions: user.employee.position
|
||||
? [
|
||||
{
|
||||
id: user.employee.position.id,
|
||||
key: user.employee.position.key,
|
||||
employeePositionId: user.employee.position.employeePositionId,
|
||||
name: user.employee.position.name,
|
||||
isDelegate: user.employee.position.isDelegate,
|
||||
parentPositionId: user.employee.position.parentPositionId,
|
||||
permissions: user.employee.position.permissions ?? [],
|
||||
},
|
||||
]
|
||||
: [],
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
const permissionKeys = collectPermissionKeys(user);
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
username: user.username,
|
||||
phoneNumber: user.phoneNumber,
|
||||
userType: user.userType,
|
||||
status: user.status,
|
||||
hasFinishedRegistration: user.hasFinishedRegistration,
|
||||
hasFinishedDMSOnboarding: user.hasFinishedDMSOnboarding,
|
||||
roles: user.roles,
|
||||
permissions: user.permissions,
|
||||
employee,
|
||||
permissionKeys,
|
||||
isSuperAdmin: isSuperAdmin(user),
|
||||
permissionsCatalog: PERMISSIONS_CATALOG,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { ContractTemplateResolver } from '../../contracts/contract-template.reso
|
||||
import { ContractViewModelBuilder } from '../../contracts/contract-view-model.builder';
|
||||
import { MinioService } from '../minio/minio.service';
|
||||
import { FilesService } from '../files/files.service';
|
||||
import { FileRecord } from '../files/entities/file.entity';
|
||||
import { BookingsRepository } from './bookings.repository';
|
||||
import { Booking } from './entities/booking.entity';
|
||||
import { assertBookingStatus } from './booking-status.util';
|
||||
@@ -68,7 +69,7 @@ export class BookingContractService {
|
||||
|
||||
async getContractView(bookingId: string): Promise<ContractViewDto> {
|
||||
const { view } = await this.viewModelBuilder.build(bookingId);
|
||||
await this.enrichSignatureUrls(view.signatures);
|
||||
await this.inlineSignatureImages(view.signatures);
|
||||
const html = this.renderer.render(view);
|
||||
return {
|
||||
bookingId: view.bookingId,
|
||||
@@ -90,33 +91,8 @@ export class BookingContractService {
|
||||
assertBookingStatus(booking, ['APPROVED']);
|
||||
|
||||
const templateKey = this.templateResolver.resolve(booking);
|
||||
const { view } = await this.viewModelBuilder.build(bookingId);
|
||||
view.templateKey = templateKey;
|
||||
view.template = getTemplateMeta(templateKey);
|
||||
|
||||
const html = this.renderer.render(view);
|
||||
const pdfBuffer = await this.pdfService.htmlToPdfBuffer(html);
|
||||
const summary = this.buildContractSummary(booking);
|
||||
|
||||
const file: Express.Multer.File = {
|
||||
fieldname: 'contract',
|
||||
originalname: `contract-${booking.reference}.pdf`,
|
||||
encoding: '7bit',
|
||||
mimetype: 'application/pdf',
|
||||
size: pdfBuffer.length,
|
||||
buffer: pdfBuffer,
|
||||
stream: Readable.from(pdfBuffer),
|
||||
destination: '',
|
||||
filename: '',
|
||||
path: '',
|
||||
};
|
||||
|
||||
await this.filesService.upsertByCode({
|
||||
resourceId: bookingId,
|
||||
resource: 'bookings',
|
||||
code: 'contract',
|
||||
file,
|
||||
});
|
||||
await this.upsertContractPdf(bookingId, booking.reference, templateKey);
|
||||
|
||||
const now = new Date();
|
||||
const updated = await this.bookingsRepository.update(bookingId, {
|
||||
@@ -129,18 +105,15 @@ export class BookingContractService {
|
||||
}
|
||||
|
||||
async streamContract(bookingId: string) {
|
||||
try {
|
||||
const record = await this.filesService.findByCode(
|
||||
bookingId,
|
||||
'bookings',
|
||||
'contract',
|
||||
);
|
||||
return this.filesService.streamById(record.id);
|
||||
} catch {
|
||||
throw new NotFoundException(
|
||||
'Contract document not found. Generate the contract first.',
|
||||
);
|
||||
}
|
||||
const booking = await this.requireBooking(bookingId);
|
||||
const templateKey =
|
||||
booking.contractTemplateKey ?? this.templateResolver.resolve(booking);
|
||||
const record = await this.upsertContractPdf(
|
||||
bookingId,
|
||||
booking.reference,
|
||||
templateKey,
|
||||
);
|
||||
return this.filesService.streamById(record.id);
|
||||
}
|
||||
|
||||
async signContract(
|
||||
@@ -218,33 +191,84 @@ 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),
|
||||
);
|
||||
return updated!;
|
||||
}
|
||||
|
||||
async getSignatures(bookingId: string) {
|
||||
const rows = await this.bookingsRepository.findContractSignatures(bookingId);
|
||||
const views = rows.map((r) => this.viewModelBuilder.toSignatureView(r));
|
||||
await this.enrichSignatureUrls(views);
|
||||
await this.inlineSignatureImages(views);
|
||||
return { signatures: views };
|
||||
}
|
||||
|
||||
private async enrichSignatureUrls(
|
||||
private async upsertContractPdf(
|
||||
bookingId: string,
|
||||
reference: string,
|
||||
templateKey: string,
|
||||
): Promise<FileRecord> {
|
||||
const { view } = await this.viewModelBuilder.build(bookingId);
|
||||
view.templateKey = templateKey;
|
||||
view.template = getTemplateMeta(templateKey);
|
||||
await this.inlineSignatureImages(view.signatures);
|
||||
|
||||
const html = this.renderer.render(view);
|
||||
const pdfBuffer = await this.pdfService.htmlToPdfBuffer(html);
|
||||
const file: Express.Multer.File = {
|
||||
fieldname: 'contract',
|
||||
originalname: `contract-${reference}.pdf`,
|
||||
encoding: '7bit',
|
||||
mimetype: 'application/pdf',
|
||||
size: pdfBuffer.length,
|
||||
buffer: pdfBuffer,
|
||||
stream: Readable.from(pdfBuffer),
|
||||
destination: '',
|
||||
filename: '',
|
||||
path: '',
|
||||
};
|
||||
|
||||
return this.filesService.upsertByCode({
|
||||
resourceId: bookingId,
|
||||
resource: 'bookings',
|
||||
code: 'contract',
|
||||
file,
|
||||
});
|
||||
}
|
||||
|
||||
private async inlineSignatureImages(
|
||||
signatures: Array<{ signatureImageUrl?: string | null }>,
|
||||
): Promise<void> {
|
||||
for (const sig of signatures) {
|
||||
if (!sig.signatureImageUrl) continue;
|
||||
try {
|
||||
const objectName = this.extractObjectName(sig.signatureImageUrl);
|
||||
sig.signatureImageUrl = await this.minioService.getSignedUrl(objectName, 3600);
|
||||
if (sig.signatureImageUrl.startsWith('data:')) continue;
|
||||
const objectName = this.minioService.getObjectNameFromUrl(
|
||||
sig.signatureImageUrl,
|
||||
);
|
||||
const stream = await this.minioService.getFileStream(objectName);
|
||||
const buffer = await this.streamToBuffer(stream);
|
||||
sig.signatureImageUrl = `data:image/png;base64,${buffer.toString(
|
||||
'base64',
|
||||
)}`;
|
||||
} catch {
|
||||
/* keep original url */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extractObjectName(url: string): string {
|
||||
const parts = url.split('/');
|
||||
return parts.slice(4).join('/');
|
||||
private streamToBuffer(stream: Readable): Promise<Buffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
stream.on('data', (chunk: Buffer | string) => {
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||
});
|
||||
stream.on('error', reject);
|
||||
stream.on('end', () => resolve(Buffer.concat(chunks)));
|
||||
});
|
||||
}
|
||||
|
||||
private decodeSignatureImage(base64: string): Buffer {
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
export const BOOKING_LIST_TAB_KEYS = [
|
||||
'all',
|
||||
'intake',
|
||||
'in_approval',
|
||||
'approved_contract',
|
||||
'payment',
|
||||
'operations',
|
||||
'completed',
|
||||
'closed',
|
||||
] as const;
|
||||
|
||||
export type BookingListTabKey = (typeof BOOKING_LIST_TAB_KEYS)[number];
|
||||
|
||||
export const BOOKING_LIST_TABS: ReadonlyArray<{
|
||||
key: BookingListTabKey;
|
||||
statuses: readonly string[] | null;
|
||||
}> = [
|
||||
{ key: 'all', statuses: null },
|
||||
{ key: 'intake', statuses: ['SUBMITTED'] },
|
||||
{
|
||||
key: 'in_approval',
|
||||
statuses: ['PENDING_APPROVAL', 'APPROVED_PENDING_SIGNATURE'],
|
||||
},
|
||||
{
|
||||
key: 'approved_contract',
|
||||
statuses: ['APPROVED', 'CONTRACT_READY', 'SIGNED_CUSTOMER', 'FULLY_EXECUTED'],
|
||||
},
|
||||
{ key: 'payment', statuses: ['FULLY_EXECUTED', 'PAID'] },
|
||||
{
|
||||
key: 'operations',
|
||||
statuses: ['IN_TRANSIT', 'PENDING_CONSOLIDATION', 'CONSOLIDATED'],
|
||||
},
|
||||
{ key: 'completed', statuses: ['COMPLETED'] },
|
||||
{ key: 'closed', statuses: ['REJECTED', 'CANCELLED'] },
|
||||
];
|
||||
|
||||
export function mapStatusCountsToTabs(
|
||||
statusCounts: Record<string, number>,
|
||||
): Record<BookingListTabKey, number> {
|
||||
const result = {} as Record<BookingListTabKey, number>;
|
||||
|
||||
for (const tab of BOOKING_LIST_TABS) {
|
||||
if (!tab.statuses?.length) {
|
||||
result[tab.key] = Object.values(statusCounts).reduce((sum, n) => sum + n, 0);
|
||||
continue;
|
||||
}
|
||||
result[tab.key] = tab.statuses.reduce(
|
||||
(sum, status) => sum + (statusCounts[status] ?? 0),
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { BookingApprovalStep } from './entities/booking-approval-step.entity';
|
||||
import { Booking } from './entities/booking.entity';
|
||||
|
||||
export interface BookingNextStep {
|
||||
action: string;
|
||||
description: string;
|
||||
requiredRole?: string;
|
||||
}
|
||||
|
||||
export function computeNextStep(
|
||||
booking: Pick<Booking, 'status' | 'paymentCurrency'>,
|
||||
nextPendingStep?: Pick<BookingApprovalStep, 'requiredRole' | 'stepOrder'> | null,
|
||||
): BookingNextStep | null {
|
||||
const { status } = booking;
|
||||
|
||||
switch (status) {
|
||||
case 'SUBMITTED':
|
||||
return {
|
||||
action: 'ACCEPT_INTAKE',
|
||||
description: 'Line Staff must accept the submission to begin approval',
|
||||
};
|
||||
case 'PENDING_APPROVAL':
|
||||
case 'APPROVED_PENDING_SIGNATURE':
|
||||
if (nextPendingStep) {
|
||||
return {
|
||||
action: 'APPROVE_STEP',
|
||||
requiredRole: nextPendingStep.requiredRole,
|
||||
description: `${nextPendingStep.requiredRole} must approve step ${nextPendingStep.stepOrder}`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
action: 'APPROVE_STEP',
|
||||
description: 'Complete the pending approval step in sequence',
|
||||
};
|
||||
case 'APPROVED':
|
||||
return {
|
||||
action: 'GENERATE_CONTRACT',
|
||||
description: 'Generate the contract document',
|
||||
};
|
||||
case 'CONTRACT_READY':
|
||||
return {
|
||||
action: 'CUSTOMER_SIGN',
|
||||
description: 'Customer must sign the contract',
|
||||
};
|
||||
case 'SIGNED_CUSTOMER':
|
||||
return {
|
||||
action: 'STAFF_SIGN',
|
||||
description: 'Internal staff must counter-sign the contract',
|
||||
};
|
||||
case 'FULLY_EXECUTED':
|
||||
return {
|
||||
action: 'PAY',
|
||||
description: 'Complete in-app payment',
|
||||
};
|
||||
case 'PAID':
|
||||
return {
|
||||
action: 'START_TRANSIT',
|
||||
description: 'Mark shipment as in transit',
|
||||
};
|
||||
case 'IN_TRANSIT':
|
||||
return {
|
||||
action: 'COMPLETE',
|
||||
description: 'Mark shipment complete',
|
||||
};
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,127 +1,47 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { FilesService } from '../files/files.service';
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { BookingsRepository } from './bookings.repository';
|
||||
import { Booking } from './entities/booking.entity';
|
||||
import { assertBookingStatus } from './booking-status.util';
|
||||
import { InAppPaymentReceiptDto } from './dto/pay-booking.dto';
|
||||
|
||||
const PROOF_MAX_BYTES = 5 * 1024 * 1024;
|
||||
const PROOF_MIMES = ['application/pdf', 'image/jpeg', 'image/png'];
|
||||
export interface InAppPaymentReceipt extends InAppPaymentReceiptDto {}
|
||||
|
||||
@Injectable()
|
||||
export class BookingPaymentService {
|
||||
constructor(
|
||||
private readonly bookingsRepository: BookingsRepository,
|
||||
private readonly filesService: FilesService,
|
||||
) {}
|
||||
constructor(private readonly bookingsRepository: BookingsRepository) {}
|
||||
|
||||
async generatePnr(bookingId: string): Promise<Booking> {
|
||||
const booking = await this.requireBooking(bookingId);
|
||||
assertBookingStatus(booking, ['FULLY_EXECUTED']);
|
||||
|
||||
if (booking.paymentCurrency !== 'ETB') {
|
||||
throw new BadRequestException('PNR generation is only for ETB payers');
|
||||
}
|
||||
|
||||
const year = new Date().getFullYear();
|
||||
const pnrCode = `PNR-${year}-${Math.random().toString(36).slice(2, 10).toUpperCase()}`;
|
||||
|
||||
const updated = await this.bookingsRepository.update(bookingId, {
|
||||
status: 'PNR_GENERATED',
|
||||
pnrCode,
|
||||
paymentStatus: 'PNR_GENERATED',
|
||||
} as never);
|
||||
return updated!;
|
||||
}
|
||||
|
||||
async submitPaymentProof(
|
||||
async pay(
|
||||
bookingId: string,
|
||||
file: Express.Multer.File,
|
||||
): Promise<Booking> {
|
||||
): Promise<{ booking: Booking; receipt: InAppPaymentReceipt }> {
|
||||
const booking = await this.requireBooking(bookingId);
|
||||
assertBookingStatus(booking, ['FULLY_EXECUTED']);
|
||||
|
||||
if (booking.paymentCurrency !== 'USD') {
|
||||
throw new BadRequestException('Payment proof upload is only for USD payers');
|
||||
}
|
||||
|
||||
this.validateProofFile(file);
|
||||
|
||||
await this.filesService.upload({
|
||||
resourceId: bookingId,
|
||||
resource: 'bookings',
|
||||
code: 'payment_proof',
|
||||
file,
|
||||
});
|
||||
|
||||
const updated = await this.bookingsRepository.update(bookingId, {
|
||||
status: 'PAYMENT_VERIFICATION_IN_PROGRESS',
|
||||
paymentStatus: 'VERIFICATION_IN_PROGRESS',
|
||||
} as never);
|
||||
return updated!;
|
||||
}
|
||||
|
||||
async verifyPayment(bookingId: string): Promise<Booking> {
|
||||
const booking = await this.requireBooking(bookingId);
|
||||
assertBookingStatus(booking, ['PAYMENT_VERIFICATION_IN_PROGRESS']);
|
||||
const receipt = this.buildMockReceipt(booking);
|
||||
|
||||
const updated = await this.bookingsRepository.update(bookingId, {
|
||||
status: 'PAID',
|
||||
paymentStatus: 'PAID',
|
||||
} as never);
|
||||
return updated!;
|
||||
|
||||
return { booking: updated!, receipt };
|
||||
}
|
||||
|
||||
async handleBankCallback(pnrCode: string): Promise<Booking> {
|
||||
const booking = await this.bookingsRepository.findByPnrCode(pnrCode);
|
||||
if (!booking) {
|
||||
throw new NotFoundException(`No booking found for PNR ${pnrCode}`);
|
||||
}
|
||||
private buildMockReceipt(booking: Booking): InAppPaymentReceipt {
|
||||
const timestamp = Date.now();
|
||||
const isEtb = booking.paymentCurrency === 'ETB';
|
||||
const prefix = isEtb ? 'TB' : 'CARD';
|
||||
const provider = isEtb ? 'TELEBIRR' : 'CARD';
|
||||
|
||||
if (booking.status !== 'PNR_GENERATED') {
|
||||
throw new BadRequestException(
|
||||
`Booking ${booking.reference} is not awaiting bank payment (status: ${booking.status})`,
|
||||
);
|
||||
}
|
||||
|
||||
const updated = await this.bookingsRepository.update(booking.id, {
|
||||
status: 'PAID',
|
||||
paymentStatus: 'PAID',
|
||||
} as never);
|
||||
return updated!;
|
||||
}
|
||||
|
||||
async getPaymentRequestLetter(
|
||||
bookingId: string,
|
||||
): Promise<{ buffer: Buffer; filename: string }> {
|
||||
const booking = await this.requireBooking(bookingId);
|
||||
const body = [
|
||||
'PAYMENT REQUEST LETTER (STUB)',
|
||||
`Reference: ${booking.reference}`,
|
||||
`Amount: ${booking.totalAmount} ${booking.paymentCurrency}`,
|
||||
'Pay at your bank and upload stamped proof.',
|
||||
].join('\n');
|
||||
return {
|
||||
buffer: Buffer.from(body, 'utf-8'),
|
||||
filename: `payment-request-${booking.reference}.txt`,
|
||||
success: true,
|
||||
provider,
|
||||
providerRef: `${prefix}-${booking.reference}-${timestamp}`,
|
||||
amount: booking.totalAmount,
|
||||
currency: booking.paymentCurrency,
|
||||
paidAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
private validateProofFile(file: Express.Multer.File): void {
|
||||
if (!file?.buffer?.length) {
|
||||
throw new BadRequestException('Payment proof file is required');
|
||||
}
|
||||
if (file.size > PROOF_MAX_BYTES) {
|
||||
throw new BadRequestException('Payment proof must be 5MB or less');
|
||||
}
|
||||
if (!PROOF_MIMES.includes(file.mimetype)) {
|
||||
throw new BadRequestException('Payment proof must be PDF, JPG, or PNG');
|
||||
}
|
||||
}
|
||||
|
||||
private async requireBooking(id: string): Promise<Booking> {
|
||||
const booking = await this.bookingsRepository.findById(id);
|
||||
if (!booking) throw new NotFoundException(`Booking ${id} not found`);
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { BadRequestException, 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';
|
||||
import { RuleEngineService } from '../rule-engine/rule-engine.service';
|
||||
import { BookingContractService } from './booking-contract.service';
|
||||
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 { Booking } from './entities/booking.entity';
|
||||
import { BookingsService } from './bookings.service';
|
||||
|
||||
@@ -60,6 +63,16 @@ export class BookingTransitionService {
|
||||
return this.bookingsService.findById(updated!.id);
|
||||
}
|
||||
|
||||
/** Auto-create booking approval steps from system rules when none exist yet. */
|
||||
private async ensureBookingApprovalSteps(booking: Booking): Promise<void> {
|
||||
if ((booking.approvalSteps?.length ?? 0) > 0) return;
|
||||
|
||||
await this.ruleEngineService.instantiateApprovalSteps(booking.id, {
|
||||
freightType: booking.freightType as 'CONTAINER' | 'BULK',
|
||||
cargoTypeId: booking.cargoTypeId,
|
||||
});
|
||||
}
|
||||
|
||||
async acceptIntake(bookingId: string, actorId: string): Promise<Booking> {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
assertBookingStatus(booking, ['SUBMITTED']);
|
||||
@@ -103,13 +116,23 @@ export class BookingTransitionService {
|
||||
stepId: string,
|
||||
actorId: string,
|
||||
requiredRole: string,
|
||||
authUser?: TCurrentUser,
|
||||
): Promise<Booking> {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
if (authUser) {
|
||||
assertCanApproveBookingStep(authUser, requiredRole);
|
||||
}
|
||||
|
||||
let booking = await this.bookingsService.findById(bookingId);
|
||||
assertBookingStatus(booking, [
|
||||
'PENDING_APPROVAL',
|
||||
'APPROVED_PENDING_SIGNATURE',
|
||||
]);
|
||||
|
||||
if ((booking.approvalSteps?.length ?? 0) === 0) {
|
||||
await this.ensureBookingApprovalSteps(booking);
|
||||
booking = await this.bookingsService.findById(bookingId);
|
||||
}
|
||||
|
||||
const step = await this.bookingsRepository.findApprovalStepById(
|
||||
bookingId,
|
||||
stepId,
|
||||
@@ -131,7 +154,7 @@ export class BookingTransitionService {
|
||||
);
|
||||
}
|
||||
|
||||
const blocksRole = step.approvalRule?.blocksRole;
|
||||
const blocksRole = step.blocksRole;
|
||||
if (blocksRole && blocksRole === requiredRole) {
|
||||
throw new BadRequestException(`Role ${requiredRole} is blocked for this step`);
|
||||
}
|
||||
@@ -271,6 +294,7 @@ export class BookingTransitionService {
|
||||
async enrichBookingResponse(booking: Booking): Promise<Booking & {
|
||||
latestChangeRequestNote?: string | null;
|
||||
contractSummary?: string | null;
|
||||
nextStep: BookingNextStep | null;
|
||||
}> {
|
||||
const note = await this.bookingsRepository.findLatestReviewNote(
|
||||
booking.id,
|
||||
@@ -279,10 +303,17 @@ export class BookingTransitionService {
|
||||
const summary =
|
||||
booking.contractSummary ??
|
||||
this.contractService.buildContractSummary(booking);
|
||||
const nextPending =
|
||||
booking.status === 'PENDING_APPROVAL' ||
|
||||
booking.status === 'APPROVED_PENDING_SIGNATURE'
|
||||
? await this.bookingsRepository.findNextPendingApprovalStep(booking.id)
|
||||
: null;
|
||||
const nextStep = computeNextStep(booking, nextPending);
|
||||
return {
|
||||
...booking,
|
||||
latestChangeRequestNote: note?.note ?? null,
|
||||
contractSummary: summary,
|
||||
nextStep,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Header,
|
||||
HttpCode,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
@@ -12,13 +11,13 @@ import {
|
||||
Query,
|
||||
Request,
|
||||
Res,
|
||||
StreamableFile,
|
||||
UploadedFiles,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { CurrentUser } from '@edr/api-common';
|
||||
import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard';
|
||||
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
||||
import { BookingStaff } from '../../common/booking-guards';
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
import { AnyFilesInterceptor } from '@nestjs/platform-express';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
@@ -31,13 +30,13 @@ import {
|
||||
import type { Response } from 'express';
|
||||
|
||||
import { BookingContractService } from './booking-contract.service';
|
||||
import { BookingPaymentService } from './booking-payment.service';
|
||||
import { BookingPricingService } from './booking-pricing.service';
|
||||
import { BookingTransitionService } from './booking-transition.service';
|
||||
import { BookingReferenceDataService } from './booking-reference-data.service';
|
||||
import { BookingsService } from './bookings.service';
|
||||
import { BookingReferenceDataDto } from './dto/booking-reference-data.dto';
|
||||
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 {
|
||||
@@ -65,7 +64,6 @@ export class BookingsController {
|
||||
private readonly pricingService: BookingPricingService,
|
||||
private readonly transitionService: BookingTransitionService,
|
||||
private readonly contractService: BookingContractService,
|
||||
private readonly paymentService: BookingPaymentService,
|
||||
) {}
|
||||
|
||||
@Post()
|
||||
@@ -104,6 +102,13 @@ export class BookingsController {
|
||||
return this.bookingsService.findAll(filter);
|
||||
}
|
||||
|
||||
@Get('list-summary')
|
||||
@ApiOperation({ summary: 'Booking list metrics and tab counts (backoffice)' })
|
||||
@ApiOkResponse({ type: BookingListSummaryDto })
|
||||
findListSummary(@Query() filter: FilterBookingDto) {
|
||||
return this.bookingsService.getListSummary(filter);
|
||||
}
|
||||
|
||||
@Get('queues/:queue')
|
||||
@ApiOperation({
|
||||
summary: 'List bookings for a dashboard queue',
|
||||
@@ -162,7 +167,7 @@ export class BookingsController {
|
||||
}
|
||||
|
||||
@Post(':id/staff/request-changes')
|
||||
@UseGuards(JwtGuard)
|
||||
@BookingStaff(FREIGHT_PERMS.bookings.requestChanges)
|
||||
@ApiOperation({ summary: 'Staff return booking for customer updates' })
|
||||
async requestChanges(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@@ -178,7 +183,7 @@ export class BookingsController {
|
||||
}
|
||||
|
||||
@Post(':id/staff/accept')
|
||||
@UseGuards(JwtGuard)
|
||||
@BookingStaff(FREIGHT_PERMS.bookings.staffAccept)
|
||||
@ApiOperation({ summary: 'Staff accept intake → start approval chain' })
|
||||
async acceptIntake(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@@ -192,7 +197,7 @@ export class BookingsController {
|
||||
}
|
||||
|
||||
@Post(':id/staff/reject')
|
||||
@UseGuards(JwtGuard)
|
||||
@BookingStaff(FREIGHT_PERMS.bookings.reject)
|
||||
@ApiOperation({ summary: 'Staff final reject' })
|
||||
async staffReject(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@@ -208,25 +213,30 @@ export class BookingsController {
|
||||
}
|
||||
|
||||
@Post(':id/approval-steps/:stepId/approve')
|
||||
@UseGuards(JwtGuard)
|
||||
@BookingStaff([
|
||||
FREIGHT_PERMS.bookings.approveLineStaff,
|
||||
FREIGHT_PERMS.bookings.approveDirector,
|
||||
FREIGHT_PERMS.bookings.approveCeo,
|
||||
])
|
||||
@ApiOperation({ summary: 'Approve one approval step in sequence' })
|
||||
async approveStep(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param('stepId', ParseUUIDPipe) stepId: string,
|
||||
@Body() dto: ApproveStepDto,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
const booking = await this.transitionService.approveStep(
|
||||
id,
|
||||
stepId,
|
||||
resolveAuthUserId(user),
|
||||
dto.requiredRole,
|
||||
user,
|
||||
);
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(':id/approval-steps/:stepId/reject')
|
||||
@UseGuards(JwtGuard)
|
||||
@BookingStaff(FREIGHT_PERMS.bookings.rejectApproval)
|
||||
@ApiOperation({ summary: 'Reject at approval step' })
|
||||
async rejectStep(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@@ -244,7 +254,7 @@ export class BookingsController {
|
||||
}
|
||||
|
||||
@Post(':id/contract/generate')
|
||||
@UseGuards(JwtGuard)
|
||||
@BookingStaff(FREIGHT_PERMS.bookings.generateContract)
|
||||
@ApiOperation({ summary: 'Generate contract PDF from template' })
|
||||
async generateContract(@Param('id', ParseUUIDPipe) id: string) {
|
||||
const booking = await this.contractService.generateContract(id);
|
||||
@@ -262,22 +272,23 @@ export class BookingsController {
|
||||
@ApiOperation({ summary: 'Download contract PDF' })
|
||||
async downloadContractDocument(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
) {
|
||||
@Res() res: Response,
|
||||
): Promise<void> {
|
||||
const { stream, record } = await this.contractService.streamContract(id);
|
||||
res.set({
|
||||
'Content-Type': record.mimeType ?? 'application/pdf',
|
||||
'Content-Disposition': `attachment; filename="${record.name}"`,
|
||||
});
|
||||
return new StreamableFile(stream);
|
||||
res.setHeader('Content-Type', record.mimeType ?? 'application/pdf');
|
||||
res.setHeader(
|
||||
'Content-Disposition',
|
||||
`attachment; filename="${record.name}"`,
|
||||
);
|
||||
stream.pipe(res);
|
||||
}
|
||||
|
||||
@Get(':id/contract')
|
||||
@ApiOperation({ summary: 'Download contract file (alias)' })
|
||||
async downloadContract(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
) {
|
||||
@Res() res: Response,
|
||||
): Promise<void> {
|
||||
return this.downloadContractDocument(id, res);
|
||||
}
|
||||
|
||||
@@ -326,7 +337,7 @@ export class BookingsController {
|
||||
}
|
||||
|
||||
@Post(':id/marketing/approve')
|
||||
@UseGuards(JwtGuard)
|
||||
@BookingStaff(FREIGHT_PERMS.bookings.signStaff)
|
||||
@ApiOperation({
|
||||
summary: 'Staff contract signature and fully execute (use contract/sign STAFF preferred)',
|
||||
})
|
||||
@@ -347,47 +358,8 @@ export class BookingsController {
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(':id/payment/pnr')
|
||||
@ApiOperation({ summary: 'Generate PNR code (ETB)' })
|
||||
async generatePnr(@Param('id', ParseUUIDPipe) id: string) {
|
||||
const booking = await this.paymentService.generatePnr(id);
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(':id/payment/proof')
|
||||
@UseInterceptors(AnyFilesInterceptor())
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiOperation({ summary: 'Upload USD payment proof' })
|
||||
async submitPaymentProof(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@UploadedFiles() files: Express.Multer.File[],
|
||||
) {
|
||||
const file = files?.[0];
|
||||
const booking = await this.paymentService.submitPaymentProof(id, file);
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Get(':id/payment/request-letter')
|
||||
@ApiOperation({ summary: 'Download payment request letter (USD stub)' })
|
||||
@Header('Content-Type', 'text/plain')
|
||||
async paymentRequestLetter(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
) {
|
||||
const { buffer, filename } =
|
||||
await this.paymentService.getPaymentRequestLetter(id);
|
||||
res.set('Content-Disposition', `attachment; filename="${filename}"`);
|
||||
return new StreamableFile(buffer);
|
||||
}
|
||||
|
||||
@Post(':id/payment/verify')
|
||||
@ApiOperation({ summary: 'Finance verify USD payment' })
|
||||
async verifyPayment(@Param('id', ParseUUIDPipe) id: string) {
|
||||
const booking = await this.paymentService.verifyPayment(id);
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(':id/operations/start-transit')
|
||||
@BookingStaff(FREIGHT_PERMS.bookings.operations)
|
||||
@ApiOperation({ summary: 'Mark in transit' })
|
||||
async startTransit(@Param('id', ParseUUIDPipe) id: string) {
|
||||
const booking = await this.transitionService.startTransit(id);
|
||||
@@ -395,6 +367,7 @@ export class BookingsController {
|
||||
}
|
||||
|
||||
@Post(':id/operations/complete')
|
||||
@BookingStaff(FREIGHT_PERMS.bookings.operations)
|
||||
@ApiOperation({ summary: 'Mark completed' })
|
||||
async complete(@Param('id', ParseUUIDPipe) id: string) {
|
||||
const booking = await this.transitionService.complete(id);
|
||||
@@ -402,6 +375,7 @@ export class BookingsController {
|
||||
}
|
||||
|
||||
@Post(':id/cancel')
|
||||
@BookingStaff(FREIGHT_PERMS.bookings.cancel)
|
||||
@ApiOperation({ summary: 'Cancel booking' })
|
||||
async cancel(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
|
||||
@@ -12,10 +12,10 @@ import { BookingPricingService } from './booking-pricing.service';
|
||||
import { BookingReferenceDataService } from './booking-reference-data.service';
|
||||
import { BookingTransitionService } from './booking-transition.service';
|
||||
import { BookingsController } from './bookings.controller';
|
||||
import { PayController } from './pay.controller';
|
||||
import { BookingsRepository } from './bookings.repository';
|
||||
import { ConsolidationService } from './consolidation.service';
|
||||
import { BookingsService } from './bookings.service';
|
||||
import { PaymentsWebhookController } from './payments-webhook.controller';
|
||||
import { BookingApprovalStep } from './entities/booking-approval-step.entity';
|
||||
import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity';
|
||||
import { BookingContainer } from './entities/booking-container.entity';
|
||||
@@ -46,7 +46,7 @@ import { ContractViewModelBuilder } from '../../contracts/contract-view-model.bu
|
||||
// CustomersModule,
|
||||
RuleEngineModule,
|
||||
],
|
||||
controllers: [BookingsController, PaymentsWebhookController],
|
||||
controllers: [BookingsController, PayController],
|
||||
providers: [
|
||||
BookingsService,
|
||||
BookingsRepository,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { BaseRepository } from '@edr/api-common';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { DataSource, FindOptionsWhere, Repository } from 'typeorm';
|
||||
import { DataSource, FindOptionsWhere, Repository, SelectQueryBuilder } from 'typeorm';
|
||||
|
||||
import { ContainerType } from '../rule-engine/entities/container-type.entity';
|
||||
import { BookingApprovalStep } from './entities/booking-approval-step.entity';
|
||||
@@ -17,6 +17,20 @@ import {
|
||||
import { FileRecord } from '../files/entities/file.entity';
|
||||
import { ContainerWeightResult } from '../rule-engine/rule-engine.service';
|
||||
|
||||
export interface BookingListFilterOptions {
|
||||
statuses?: string[];
|
||||
status?: string;
|
||||
companyId?: string;
|
||||
contractType?: string;
|
||||
serviceTypeId?: string;
|
||||
cargoTypeId?: string;
|
||||
freightType?: string;
|
||||
tradeDirection?: string;
|
||||
paymentCurrency?: string;
|
||||
allowConsolidation?: boolean;
|
||||
consolidationPaired?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class BookingsRepository extends BaseRepository<Booking> {
|
||||
constructor(
|
||||
@@ -230,7 +244,6 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
return this.dataSource.getRepository(BookingApprovalStep).findOne({
|
||||
where: { bookingId, status: 'PENDING' },
|
||||
order: { stepOrder: 'ASC' },
|
||||
relations: ['approvalRule'],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -240,7 +253,6 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
): Promise<BookingApprovalStep | null> {
|
||||
return this.dataSource.getRepository(BookingApprovalStep).findOne({
|
||||
where: { bookingId, id: stepId },
|
||||
relations: ['approvalRule'],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -333,10 +345,6 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
await this.dataSource.getRepository(BookingRateSnapshot).delete({ bookingId });
|
||||
}
|
||||
|
||||
async findByPnrCode(pnrCode: string): Promise<Booking | null> {
|
||||
return this.repository.findOne({ where: { pnrCode } });
|
||||
}
|
||||
|
||||
/** Queue listing with optional bulk exclusion for LINE_STAFF. */
|
||||
async findQueue(options: {
|
||||
status: string | string[];
|
||||
@@ -353,9 +361,11 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
const qb = this.repository
|
||||
.createQueryBuilder('booking')
|
||||
.leftJoinAndSelect('booking.company', 'company')
|
||||
// .leftJoinAndSelect('booking.customer', 'customer')
|
||||
.leftJoinAndSelect('booking.originYard', 'originYard')
|
||||
.leftJoinAndSelect('booking.destinationYard', 'destinationYard')
|
||||
.leftJoinAndSelect('booking.cargoType', 'cargo')
|
||||
.leftJoinAndSelect('booking.serviceType', 'serviceType')
|
||||
.leftJoinAndSelect('booking.approvalSteps', 'approvalSteps')
|
||||
.where('booking.status IN (:...statuses)', { statuses });
|
||||
|
||||
if (options.excludeBulk) {
|
||||
@@ -363,7 +373,9 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
}
|
||||
|
||||
const sortField =
|
||||
options.sortBy === 'priorityScore' ? 'booking.priority_score' : 'booking.created_at';
|
||||
options.sortBy === 'priorityScore'
|
||||
? 'booking.priorityScore'
|
||||
: 'booking.createdAt';
|
||||
qb.orderBy(sortField, options.sortOrder ?? 'DESC');
|
||||
|
||||
const [items, total] = await qb
|
||||
@@ -374,6 +386,158 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
return { items, total };
|
||||
}
|
||||
|
||||
/** Paginated list with optional multi-status filter (API tab queues). */
|
||||
async findAllPaginated(options: BookingListFilterOptions & {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
sortBy?: string;
|
||||
sortOrder?: 'ASC' | 'DESC';
|
||||
}): Promise<{ items: Booking[]; total: number }> {
|
||||
const page = options.page;
|
||||
const pageSize = options.pageSize;
|
||||
|
||||
const qb = this.repository
|
||||
.createQueryBuilder('booking')
|
||||
.leftJoinAndSelect('booking.company', 'company')
|
||||
.leftJoinAndSelect('booking.originYard', 'originYard')
|
||||
.leftJoinAndSelect('booking.destinationYard', 'destinationYard')
|
||||
.leftJoinAndSelect('booking.serviceType', 'serviceType')
|
||||
.leftJoinAndSelect('booking.approvalSteps', 'approvalSteps')
|
||||
.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');
|
||||
|
||||
const [items, total] = await qb
|
||||
.skip((page - 1) * pageSize)
|
||||
.take(pageSize)
|
||||
.getManyAndCount();
|
||||
|
||||
return { items, total };
|
||||
}
|
||||
|
||||
async getStatusCounts(): Promise<Record<string, number>> {
|
||||
const rows = await this.repository
|
||||
.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 getListSummaryMetrics(
|
||||
options: BookingListFilterOptions & {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
needsActionStatuses: readonly string[];
|
||||
urgentPriorityThreshold: number;
|
||||
},
|
||||
): Promise<{
|
||||
inQueue: number;
|
||||
onThisPage: number;
|
||||
needsAction: number;
|
||||
urgent: number;
|
||||
}> {
|
||||
const baseQb = () => {
|
||||
const qb = this.repository
|
||||
.createQueryBuilder('booking')
|
||||
.where('booking.deleted_at IS NULL');
|
||||
this.applyListFilters(qb, options);
|
||||
return qb;
|
||||
};
|
||||
|
||||
const inQueue = await baseQb().getCount();
|
||||
|
||||
const needsAction = await baseQb()
|
||||
.andWhere('booking.status IN (:...needsActionStatuses)', {
|
||||
needsActionStatuses: [...options.needsActionStatuses],
|
||||
})
|
||||
.getCount();
|
||||
|
||||
const urgent = await baseQb()
|
||||
.andWhere('booking.priority_score >= :urgentPriorityThreshold', {
|
||||
urgentPriorityThreshold: options.urgentPriorityThreshold,
|
||||
})
|
||||
.getCount();
|
||||
|
||||
const offset = (options.page - 1) * options.pageSize;
|
||||
const onThisPage = Math.min(
|
||||
options.pageSize,
|
||||
Math.max(0, inQueue - offset),
|
||||
);
|
||||
|
||||
return { inQueue, onThisPage, needsAction, urgent };
|
||||
}
|
||||
|
||||
private applyListFilters(
|
||||
qb: SelectQueryBuilder<Booking>,
|
||||
options: BookingListFilterOptions,
|
||||
): void {
|
||||
if (options.statuses?.length) {
|
||||
qb.andWhere('booking.status IN (:...statuses)', {
|
||||
statuses: options.statuses,
|
||||
});
|
||||
} else if (options.status) {
|
||||
qb.andWhere('booking.status = :status', { status: options.status });
|
||||
}
|
||||
|
||||
if (options.companyId) {
|
||||
qb.andWhere('booking.company_id = :companyId', {
|
||||
companyId: options.companyId,
|
||||
});
|
||||
}
|
||||
if (options.contractType) {
|
||||
qb.andWhere('booking.contract_type = :contractType', {
|
||||
contractType: options.contractType,
|
||||
});
|
||||
}
|
||||
if (options.serviceTypeId) {
|
||||
qb.andWhere('booking.service_type_id = :serviceTypeId', {
|
||||
serviceTypeId: options.serviceTypeId,
|
||||
});
|
||||
}
|
||||
if (options.cargoTypeId) {
|
||||
qb.andWhere('booking.cargo_type_id = :cargoTypeId', {
|
||||
cargoTypeId: options.cargoTypeId,
|
||||
});
|
||||
}
|
||||
if (options.freightType) {
|
||||
qb.andWhere('booking.freight_type = :freightType', {
|
||||
freightType: options.freightType,
|
||||
});
|
||||
}
|
||||
if (options.tradeDirection) {
|
||||
qb.andWhere('booking.trade_direction = :tradeDirection', {
|
||||
tradeDirection: options.tradeDirection,
|
||||
});
|
||||
}
|
||||
if (options.paymentCurrency) {
|
||||
qb.andWhere('booking.payment_currency = :paymentCurrency', {
|
||||
paymentCurrency: options.paymentCurrency,
|
||||
});
|
||||
}
|
||||
if (options.allowConsolidation !== undefined) {
|
||||
qb.andWhere('booking.allow_consolidation = :allowConsolidation', {
|
||||
allowConsolidation: options.allowConsolidation,
|
||||
});
|
||||
}
|
||||
if (options.consolidationPaired === 'true') {
|
||||
qb.andWhere('booking.consolidation_partner_id IS NOT NULL');
|
||||
} else if (options.consolidationPaired === 'false') {
|
||||
qb.andWhere('booking.consolidation_partner_id IS NULL');
|
||||
}
|
||||
}
|
||||
|
||||
async findAndCountFiltered(where: FindOptionsWhere<Booking>, options: {
|
||||
skip: number;
|
||||
take: number;
|
||||
|
||||
@@ -4,8 +4,6 @@ import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { IsNull, Not } from 'typeorm';
|
||||
|
||||
// import { CustomersService } from '../customers/customers.service';
|
||||
import { CompaniesService } from '../companies/companies.service';
|
||||
import { FilesService } from '../files/files.service';
|
||||
@@ -19,12 +17,25 @@ import { BookingsRepository } from './bookings.repository';
|
||||
import { ConsolidationService } from './consolidation.service';
|
||||
import { assertFreightShape } from './booking-freight.util';
|
||||
import { CreateBookingContainerDto, CreateBookingDto } from './dto/create-booking.dto';
|
||||
import { mapStatusCountsToTabs } from './booking-list-tabs.config';
|
||||
import { BookingListSummaryDto } from './dto/booking-list-summary.dto';
|
||||
import { FilterBookingDto } from './dto/filter-booking.dto';
|
||||
import { UpdateBookingDto } from './dto/update-booking.dto';
|
||||
import { CUSTOMER_EDITABLE_STATUSES, FreightType } from './entities/booking.entity';
|
||||
import {
|
||||
BOOKING_STATUSES,
|
||||
CUSTOMER_EDITABLE_STATUSES,
|
||||
FreightType,
|
||||
} from './entities/booking.entity';
|
||||
import { Booking } from './entities/booking.entity';
|
||||
import { FileRecord } from '../files/entities/file.entity';
|
||||
|
||||
const URGENT_PRIORITY_THRESHOLD = 1000;
|
||||
const NEEDS_ACTION_STATUSES = [
|
||||
'SUBMITTED',
|
||||
'PENDING_APPROVAL',
|
||||
'APPROVED_PENDING_SIGNATURE',
|
||||
] as const;
|
||||
|
||||
@Injectable()
|
||||
export class BookingsService {
|
||||
constructor(
|
||||
@@ -379,44 +390,88 @@ export class BookingsService {
|
||||
return { booking, warnings };
|
||||
}
|
||||
|
||||
/** Parse comma-separated or repeated status query values. */
|
||||
private parseStatusFilter(filter: FilterBookingDto): {
|
||||
statuses?: string[];
|
||||
status?: string;
|
||||
} {
|
||||
const allowed = new Set<string>(BOOKING_STATUSES);
|
||||
const raw = filter.statuses;
|
||||
const statusList = raw
|
||||
? raw
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => allowed.has(s))
|
||||
: [];
|
||||
|
||||
if (statusList.length > 0) {
|
||||
return { statuses: statusList };
|
||||
}
|
||||
if (filter.status && allowed.has(filter.status)) {
|
||||
return { status: filter.status };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
/** Return a paginated list of bookings matching the filter. */
|
||||
async findAll(
|
||||
filter: FilterBookingDto,
|
||||
): Promise<{ items: Booking[]; total: number }> {
|
||||
const page = filter.page ?? 1;
|
||||
const pageSize = filter.pageSize ?? 20;
|
||||
const statusFilter = this.parseStatusFilter(filter);
|
||||
|
||||
const where: Record<string, unknown> = {};
|
||||
if (filter.status) where.status = filter.status;
|
||||
// if (filter.customerId) where.customerId = filter.customerId;
|
||||
if (filter.companyId) where.companyId = filter.companyId;
|
||||
if (filter.contractType) where.contractType = filter.contractType;
|
||||
if (filter.serviceTypeId) where.serviceTypeId = filter.serviceTypeId;
|
||||
if (filter.cargoTypeId) where.cargoTypeId = filter.cargoTypeId;
|
||||
if (filter.freightType) where.freightType = filter.freightType;
|
||||
if (filter.tradeDirection) where.tradeDirection = filter.tradeDirection;
|
||||
if (filter.paymentCurrency) where.paymentCurrency = filter.paymentCurrency;
|
||||
if (filter.allowConsolidation !== undefined) {
|
||||
where.allowConsolidation = filter.allowConsolidation;
|
||||
}
|
||||
if (filter.consolidationPaired === 'true') {
|
||||
where.consolidationPartnerId = Not(IsNull());
|
||||
} else if (filter.consolidationPaired === 'false') {
|
||||
where.consolidationPartnerId = IsNull();
|
||||
}
|
||||
|
||||
const sortField = filter.sortBy ?? 'createdAt';
|
||||
const sortDir = filter.sortOrder ?? 'DESC';
|
||||
|
||||
const [items, total] = await this.bookingsRepository.findAndCount({
|
||||
where,
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
order: { [sortField]: sortDir },
|
||||
relations: ['company', 'originYard', 'destinationYard', 'serviceType'],
|
||||
// relations: ['customer', 'originYard', 'destinationYard', 'serviceType'],
|
||||
return this.bookingsRepository.findAllPaginated({
|
||||
page,
|
||||
pageSize,
|
||||
...statusFilter,
|
||||
companyId: filter.companyId,
|
||||
contractType: filter.contractType,
|
||||
serviceTypeId: filter.serviceTypeId,
|
||||
cargoTypeId: filter.cargoTypeId,
|
||||
freightType: filter.freightType,
|
||||
tradeDirection: filter.tradeDirection,
|
||||
paymentCurrency: filter.paymentCurrency,
|
||||
allowConsolidation: filter.allowConsolidation,
|
||||
consolidationPaired: filter.consolidationPaired,
|
||||
sortBy: filter.sortBy,
|
||||
sortOrder: filter.sortOrder,
|
||||
});
|
||||
return { items, total };
|
||||
}
|
||||
|
||||
/** Aggregate metrics and tab counts for the backoffice booking list. */
|
||||
async getListSummary(filter: FilterBookingDto): Promise<BookingListSummaryDto> {
|
||||
const page = filter.page ?? 1;
|
||||
const pageSize = filter.pageSize ?? 20;
|
||||
const statusFilter = this.parseStatusFilter(filter);
|
||||
const listFilter = {
|
||||
...statusFilter,
|
||||
companyId: filter.companyId,
|
||||
contractType: filter.contractType,
|
||||
serviceTypeId: filter.serviceTypeId,
|
||||
cargoTypeId: filter.cargoTypeId,
|
||||
freightType: filter.freightType,
|
||||
tradeDirection: filter.tradeDirection,
|
||||
paymentCurrency: filter.paymentCurrency,
|
||||
allowConsolidation: filter.allowConsolidation,
|
||||
consolidationPaired: filter.consolidationPaired,
|
||||
};
|
||||
|
||||
const [statusCounts, metrics] = await Promise.all([
|
||||
this.bookingsRepository.getStatusCounts(),
|
||||
this.bookingsRepository.getListSummaryMetrics({
|
||||
...listFilter,
|
||||
page,
|
||||
pageSize,
|
||||
needsActionStatuses: NEEDS_ACTION_STATUSES,
|
||||
urgentPriorityThreshold: URGENT_PRIORITY_THRESHOLD,
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
metrics,
|
||||
tabs: mapStatusCountsToTabs(statusCounts),
|
||||
};
|
||||
}
|
||||
|
||||
/** Get a single booking by ID with files. */
|
||||
@@ -429,7 +484,7 @@ export class BookingsService {
|
||||
if (booking.files && booking.files.length > 0) {
|
||||
booking.files = await Promise.all(
|
||||
booking.files.map(async (file: FileRecord) => {
|
||||
const objectName = this.extractObjectName(file.url);
|
||||
const objectName = this.minioService.getObjectNameFromUrl(file.url);
|
||||
const signedUrl = await this.minioService.getSignedUrl(objectName, 300);
|
||||
return { ...file, signedUrl };
|
||||
}),
|
||||
@@ -439,11 +494,6 @@ export class BookingsService {
|
||||
return booking;
|
||||
}
|
||||
|
||||
private extractObjectName(url: string): string {
|
||||
const parts = url.split('/');
|
||||
return parts.slice(4).join('/');
|
||||
}
|
||||
|
||||
async findByReference(reference: string): Promise<Booking> {
|
||||
const booking = await this.bookingsRepository.findByReferenceWithFiles(reference);
|
||||
if (!booking) {
|
||||
@@ -467,10 +517,11 @@ export class BookingsService {
|
||||
): Promise<{ items: Booking[]; total: number }> {
|
||||
const statusMap: Record<string, string | string[]> = {
|
||||
intake: 'SUBMITTED',
|
||||
approval: 'PENDING_APPROVAL',
|
||||
approval: ['PENDING_APPROVAL', 'APPROVED_PENDING_SIGNATURE'],
|
||||
signatures: ['APPROVED_PENDING_SIGNATURE', 'PENDING_APPROVAL'],
|
||||
contract: ['APPROVED', 'CONTRACT_READY', 'SIGNED_CUSTOMER', 'FULLY_EXECUTED'],
|
||||
marketing: 'SIGNED_CUSTOMER',
|
||||
finance: 'PAYMENT_VERIFICATION_IN_PROGRESS',
|
||||
finance: 'FULLY_EXECUTED',
|
||||
};
|
||||
|
||||
const status = statusMap[queue];
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class BookingListSummaryMetricsDto {
|
||||
@ApiProperty({ example: 42 })
|
||||
inQueue!: number;
|
||||
|
||||
@ApiProperty({ example: 10 })
|
||||
onThisPage!: number;
|
||||
|
||||
@ApiProperty({ example: 8 })
|
||||
needsAction!: number;
|
||||
|
||||
@ApiProperty({ example: 3 })
|
||||
urgent!: number;
|
||||
}
|
||||
|
||||
export class BookingListSummaryTabsDto {
|
||||
@ApiProperty() all!: number;
|
||||
@ApiProperty() intake!: number;
|
||||
@ApiProperty() in_approval!: number;
|
||||
@ApiProperty() approved_contract!: number;
|
||||
@ApiProperty() payment!: number;
|
||||
@ApiProperty() operations!: number;
|
||||
@ApiProperty() completed!: number;
|
||||
@ApiProperty() closed!: number;
|
||||
}
|
||||
|
||||
export class BookingListSummaryDto {
|
||||
@ApiProperty({ type: BookingListSummaryMetricsDto })
|
||||
metrics!: BookingListSummaryMetricsDto;
|
||||
|
||||
@ApiProperty({ type: BookingListSummaryTabsDto })
|
||||
tabs!: BookingListSummaryTabsDto;
|
||||
}
|
||||
@@ -14,6 +14,18 @@ export class FilterBookingDto {
|
||||
@IsIn([...BOOKING_STATUSES])
|
||||
status?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'Filter by statuses: comma-separated (PENDING_APPROVAL,APPROVED) or repeated query params. Overrides status when set.',
|
||||
})
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => {
|
||||
if (value === undefined || value === null || value === '') return undefined;
|
||||
if (Array.isArray(value)) return value.map(String).join(',');
|
||||
return String(value);
|
||||
})
|
||||
statuses?: string;
|
||||
|
||||
// @ApiPropertyOptional({ format: 'uuid' })
|
||||
// @IsOptional()
|
||||
// @IsUUID()
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class InAppPaymentReceiptDto {
|
||||
@ApiProperty({ example: true })
|
||||
success!: boolean;
|
||||
|
||||
@ApiProperty({ example: 'TELEBIRR' })
|
||||
provider!: string;
|
||||
|
||||
@ApiProperty({ example: 'TB-BK-2026-000123-1717584000000' })
|
||||
providerRef!: string;
|
||||
|
||||
@ApiProperty({ example: 15000 })
|
||||
amount!: number;
|
||||
|
||||
@ApiProperty({ example: 'ETB' })
|
||||
currency!: string;
|
||||
|
||||
@ApiProperty({ example: '2026-06-05T12:00:00.000Z' })
|
||||
paidAt!: string;
|
||||
}
|
||||
|
||||
export class PayBookingResponseDto {
|
||||
@ApiProperty({ type: InAppPaymentReceiptDto })
|
||||
paymentReceipt!: InAppPaymentReceiptDto;
|
||||
}
|
||||
@@ -34,9 +34,3 @@ export class CancelBookingDto {
|
||||
@MinLength(1)
|
||||
reason!: string;
|
||||
}
|
||||
|
||||
export class BankCallbackDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
pnrCode!: string;
|
||||
}
|
||||
|
||||
@@ -31,6 +31,9 @@ export class BookingApprovalStep extends BaseEntity {
|
||||
@Column({ name: 'required_role', type: 'varchar', length: 30 })
|
||||
requiredRole!: string;
|
||||
|
||||
@Column({ name: 'blocks_role', type: 'varchar', length: 30, nullable: true })
|
||||
blocksRole?: string | null;
|
||||
|
||||
@Column({ name: 'status', type: 'varchar', length: 20, default: 'PENDING' })
|
||||
status!: ApprovalStepStatus;
|
||||
|
||||
|
||||
34
apps/edr-freight-api/src/modules/bookings/pay.controller.ts
Normal file
34
apps/edr-freight-api/src/modules/bookings/pay.controller.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { Controller, Param, ParseUUIDPipe, Post } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { BookingPaymentService } from './booking-payment.service';
|
||||
import { BookingTransitionService } from './booking-transition.service';
|
||||
import { InAppPaymentReceiptDto } from './dto/pay-booking.dto';
|
||||
import { Booking } from './entities/booking.entity';
|
||||
import { BookingNextStep } from './booking-next-step.util';
|
||||
|
||||
@ApiTags('payments')
|
||||
@ApiBearerAuth()
|
||||
@Controller('bookings')
|
||||
export class PayController {
|
||||
constructor(
|
||||
private readonly paymentService: BookingPaymentService,
|
||||
private readonly transitionService: BookingTransitionService,
|
||||
) {}
|
||||
|
||||
@Post(':id/payment/pay')
|
||||
@ApiOperation({ summary: 'Complete in-app payment (mock)' })
|
||||
@ApiOkResponse({ description: 'Enriched booking with ephemeral payment receipt' })
|
||||
async pay(@Param('id', ParseUUIDPipe) id: string): Promise<
|
||||
Booking & {
|
||||
latestChangeRequestNote?: string | null;
|
||||
contractSummary?: string | null;
|
||||
nextStep: BookingNextStep | null;
|
||||
paymentReceipt: InAppPaymentReceiptDto;
|
||||
}
|
||||
> {
|
||||
const { booking, receipt } = await this.paymentService.pay(id);
|
||||
const abstract = await this.transitionService.enrichBookingResponse(booking);
|
||||
return { ...abstract, paymentReceipt: receipt };
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import { Body, Controller, Post } from '@nestjs/common';
|
||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { BookingPaymentService } from './booking-payment.service';
|
||||
import { BankCallbackDto } from './dto/request-changes.dto';
|
||||
|
||||
@ApiTags('payments')
|
||||
@Controller('webhooks/payments')
|
||||
export class PaymentsWebhookController {
|
||||
constructor(private readonly paymentService: BookingPaymentService) {}
|
||||
|
||||
@Post('bank')
|
||||
@ApiOperation({ summary: 'Bank payment callback (stub)' })
|
||||
bankCallback(@Body() dto: BankCallbackDto) {
|
||||
return this.paymentService.handleBankCallback(dto.pnrCode);
|
||||
}
|
||||
}
|
||||
@@ -79,13 +79,8 @@ export class FilesService {
|
||||
|
||||
async streamById(id: string): Promise<{ stream: Readable; record: FileRecord }> {
|
||||
const record = await this.findById(id);
|
||||
const objectName = this.extractObjectName(record.url);
|
||||
const objectName = this.minioService.getObjectNameFromUrl(record.url);
|
||||
const stream = await this.minioService.getFileStream(objectName);
|
||||
return { stream, record };
|
||||
}
|
||||
|
||||
private extractObjectName(url: string): string {
|
||||
const parts = url.split("/");
|
||||
return parts.slice(4).join("/");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Inject, Injectable, Logger } from "@nestjs/common";
|
||||
import { Inject, Injectable, Logger, NotFoundException } from "@nestjs/common";
|
||||
import { ConfigType } from "@nestjs/config";
|
||||
import { Client } from "minio";
|
||||
import { Readable } from "stream";
|
||||
@@ -54,6 +54,30 @@ export class MinioService {
|
||||
return `${protocol}://${this.config.endPoint}:${this.config.port}/${this.bucket}/${objectName}`;
|
||||
}
|
||||
|
||||
getObjectNameFromUrl(value: string): string {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
throw new NotFoundException("File object path is empty");
|
||||
}
|
||||
|
||||
if (!/^https?:\/\//i.test(trimmed)) {
|
||||
return trimmed.replace(/^\/+/, "");
|
||||
}
|
||||
|
||||
const url = new URL(trimmed);
|
||||
const parts = url.pathname.split("/").filter(Boolean);
|
||||
if (parts[0] === this.bucket) {
|
||||
parts.shift();
|
||||
}
|
||||
|
||||
const objectName = parts.join("/");
|
||||
if (!objectName) {
|
||||
throw new NotFoundException("File object path is empty");
|
||||
}
|
||||
|
||||
return objectName;
|
||||
}
|
||||
|
||||
async deleteFile(objectName: string): Promise<void> {
|
||||
try {
|
||||
await this.client.removeObject(this.bucket, objectName);
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
/** ITMLS US-06 default approval chains — seeded automatically when missing. */
|
||||
export const DEFAULT_APPROVAL_RULE_ROWS = [
|
||||
{
|
||||
requiresDirectorApproval: false,
|
||||
stepOrder: 1,
|
||||
requiredRole: 'LINE_STAFF',
|
||||
actionLabel: 'Review & Approve',
|
||||
blocksRole: null as string | null,
|
||||
},
|
||||
{
|
||||
requiresDirectorApproval: false,
|
||||
stepOrder: 2,
|
||||
requiredRole: 'DIRECTOR',
|
||||
actionLabel: 'Final Signature',
|
||||
blocksRole: 'LINE_STAFF',
|
||||
},
|
||||
{
|
||||
requiresDirectorApproval: true,
|
||||
stepOrder: 1,
|
||||
requiredRole: 'DIRECTOR',
|
||||
actionLabel: 'Review & Approve',
|
||||
blocksRole: 'LINE_STAFF',
|
||||
},
|
||||
{
|
||||
requiresDirectorApproval: true,
|
||||
stepOrder: 2,
|
||||
requiredRole: 'CEO',
|
||||
actionLabel: 'Final Signature',
|
||||
blocksRole: null as string | null,
|
||||
},
|
||||
] as const;
|
||||
@@ -2,6 +2,7 @@ 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 { CreateApprovalRuleDto } from '../dto/create-approval-rule.dto';
|
||||
import { UpdateApprovalRuleDto } from '../dto/update-approval-rule.dto';
|
||||
@@ -9,12 +10,12 @@ import { ApprovalRulesService } from '../services/approval-rules.service';
|
||||
|
||||
@ApiTags('approval-rules')
|
||||
@Controller('approval-rules')
|
||||
// @UseGuards(JwtAuthGuard) — TODO: add when auth package integrated
|
||||
@ApiBearerAuth()
|
||||
export class ApprovalRulesController {
|
||||
constructor(private readonly service: ApprovalRulesService) {}
|
||||
|
||||
@Get()
|
||||
@RuleEngineView('approval-rules')
|
||||
@ApiOperation({ summary: 'List approval rules' })
|
||||
findAll(@Query() query: Record<string, string>) {
|
||||
return this.service.findAll({
|
||||
@@ -28,30 +29,35 @@ export class ApprovalRulesController {
|
||||
}
|
||||
|
||||
@Get('chain')
|
||||
@RuleEngineView('approval-rules')
|
||||
@ApiOperation({ summary: 'Get approval chain for cargo routing flag' })
|
||||
findChain(@Query('requiresDirectorApproval') flag: string) {
|
||||
return this.service.findChain(flag === 'true');
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RuleEngineView('approval-rules')
|
||||
@ApiOperation({ summary: 'Get an approval rule by ID' })
|
||||
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.service.findById(id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@RuleEngineManage('approval-rules')
|
||||
@ApiOperation({ summary: 'Create an approval rule step' })
|
||||
create(@Body() dto: CreateApprovalRuleDto) {
|
||||
return this.service.create(dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@RuleEngineManage('approval-rules')
|
||||
@ApiOperation({ summary: 'Update an approval rule' })
|
||||
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateApprovalRuleDto) {
|
||||
return this.service.update(id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@RuleEngineManage('approval-rules')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@ApiOperation({ summary: 'Soft-delete an approval rule' })
|
||||
remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||
|
||||
@@ -3,18 +3,19 @@ import {
|
||||
Param, ParseUUIDPipe, Patch, Post, Query,
|
||||
} from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
|
||||
import { CreateCargoTypeDto } from '../dto/create-cargo-type.dto';
|
||||
import { UpdateCargoTypeDto } from '../dto/update-cargo-type.dto';
|
||||
import { CargoTypesService } from '../services/cargo-types.service';
|
||||
|
||||
@ApiTags('cargo-types')
|
||||
@Controller('cargo-types')
|
||||
// @UseGuards(JwtAuthGuard) — TODO: add when auth package integrated
|
||||
@ApiBearerAuth()
|
||||
export class CargoTypesController {
|
||||
constructor(private readonly service: CargoTypesService) {}
|
||||
|
||||
@Get()
|
||||
@RuleEngineView('cargo-types')
|
||||
@ApiOperation({ summary: 'List cargo types' })
|
||||
findAll(@Query() query: Record<string, string>) {
|
||||
return this.service.findAll({
|
||||
@@ -32,24 +33,28 @@ export class CargoTypesController {
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RuleEngineView('cargo-types')
|
||||
@ApiOperation({ summary: 'Get a cargo type by ID' })
|
||||
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.service.findById(id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@RuleEngineManage('cargo-types')
|
||||
@ApiOperation({ summary: 'Create a cargo type' })
|
||||
create(@Body() dto: CreateCargoTypeDto) {
|
||||
return this.service.create(dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@RuleEngineManage('cargo-types')
|
||||
@ApiOperation({ summary: 'Update a cargo type' })
|
||||
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateCargoTypeDto) {
|
||||
return this.service.update(id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@RuleEngineManage('cargo-types')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@ApiOperation({ summary: 'Soft-delete a cargo type' })
|
||||
remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||
|
||||
@@ -3,18 +3,19 @@ import {
|
||||
Param, ParseUUIDPipe, Patch, Post, Query,
|
||||
} from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
|
||||
import { CreateContainerTypeDto } from '../dto/create-container-type.dto';
|
||||
import { UpdateContainerTypeDto } from '../dto/update-container-type.dto';
|
||||
import { ContainerTypesService } from '../services/container-types.service';
|
||||
|
||||
@ApiTags('container-types')
|
||||
@Controller('container-types')
|
||||
// @UseGuards(JwtAuthGuard) — TODO: add when auth package integrated
|
||||
@ApiBearerAuth()
|
||||
export class ContainerTypesController {
|
||||
constructor(private readonly service: ContainerTypesService) {}
|
||||
|
||||
@Get()
|
||||
@RuleEngineView('container-types')
|
||||
@ApiOperation({ summary: 'List container types' })
|
||||
findAll(@Query() query: Record<string, string>) {
|
||||
return this.service.findAll({
|
||||
@@ -25,24 +26,28 @@ export class ContainerTypesController {
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RuleEngineView('container-types')
|
||||
@ApiOperation({ summary: 'Get a container type by ID' })
|
||||
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.service.findById(id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@RuleEngineManage('container-types')
|
||||
@ApiOperation({ summary: 'Create a container type' })
|
||||
create(@Body() dto: CreateContainerTypeDto) {
|
||||
return this.service.create(dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@RuleEngineManage('container-types')
|
||||
@ApiOperation({ summary: 'Update a container type' })
|
||||
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateContainerTypeDto) {
|
||||
return this.service.update(id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@RuleEngineManage('container-types')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@ApiOperation({ summary: 'Soft-delete a container type' })
|
||||
remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||
|
||||
@@ -2,6 +2,7 @@ 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';
|
||||
@@ -9,12 +10,12 @@ import { PriorityRulesService } from '../services/priority-rules.service';
|
||||
|
||||
@ApiTags('priority-rules')
|
||||
@Controller('priority-rules')
|
||||
// @UseGuards(JwtAuthGuard) — TODO: add when auth package integrated
|
||||
@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({
|
||||
@@ -25,24 +26,28 @@ export class PriorityRulesController {
|
||||
}
|
||||
|
||||
@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) {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import {
|
||||
Body, Controller, Delete, Get, HttpCode, HttpStatus,
|
||||
Param, ParseUUIDPipe, Patch, Post, Query, UseGuards,
|
||||
Param, ParseUUIDPipe, Patch, Post, Query,
|
||||
} from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { CurrentUser } from '@edr/api-common';
|
||||
import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard';
|
||||
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
|
||||
import { CreateRateDto } from '../dto/create-rate.dto';
|
||||
import {
|
||||
type AuthUserPayload,
|
||||
@@ -15,12 +15,12 @@ import { RatesService } from '../services/rates.service';
|
||||
|
||||
@ApiTags('rates')
|
||||
@Controller('rates')
|
||||
// @UseGuards(JwtAuthGuard) — TODO: add when auth package integrated
|
||||
@ApiBearerAuth()
|
||||
export class RatesController {
|
||||
constructor(private readonly service: RatesService) {}
|
||||
|
||||
@Get()
|
||||
@RuleEngineView('rates')
|
||||
@ApiOperation({ summary: 'List rates' })
|
||||
findAll(@Query() query: Record<string, string>) {
|
||||
return this.service.findAll({
|
||||
@@ -32,19 +32,21 @@ export class RatesController {
|
||||
}
|
||||
|
||||
@Get('live')
|
||||
@RuleEngineView('rates')
|
||||
@ApiOperation({ summary: 'List all LIVE rates effective now' })
|
||||
findLive() {
|
||||
return this.service.findLiveRates();
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RuleEngineView('rates')
|
||||
@ApiOperation({ summary: 'Get a rate by ID' })
|
||||
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.service.findById(id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@UseGuards(JwtGuard)
|
||||
@RuleEngineManage('rates')
|
||||
@ApiOperation({ summary: 'Create a rate (DRAFT)' })
|
||||
create(
|
||||
@Body() dto: CreateRateDto,
|
||||
@@ -54,19 +56,21 @@ export class RatesController {
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@RuleEngineManage('rates')
|
||||
@ApiOperation({ summary: 'Update a DRAFT rate' })
|
||||
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateRateDto) {
|
||||
return this.service.update(id, dto);
|
||||
}
|
||||
|
||||
@Post(':id/submit')
|
||||
@RuleEngineManage('rates')
|
||||
@ApiOperation({ summary: 'Submit rate for CEO approval' })
|
||||
submit(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.service.submitForApproval(id);
|
||||
}
|
||||
|
||||
@Post(':id/approve')
|
||||
@UseGuards(JwtGuard)
|
||||
@RuleEngineManage('rates')
|
||||
@ApiOperation({ summary: 'CEO approves a rate' })
|
||||
approve(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@@ -76,6 +80,7 @@ export class RatesController {
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@RuleEngineManage('rates')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@ApiOperation({ summary: 'Soft-delete a rate' })
|
||||
remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||
|
||||
@@ -2,6 +2,7 @@ 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 { CreateServiceTypeDto } from '../dto/create-service-type.dto';
|
||||
import { UpdateServiceTypeDto } from '../dto/update-service-type.dto';
|
||||
@@ -9,12 +10,12 @@ import { ServiceTypesService } from '../services/service-types.service';
|
||||
|
||||
@ApiTags('service-types')
|
||||
@Controller('service-types')
|
||||
// @UseGuards(JwtAuthGuard) — TODO: add when auth package integrated
|
||||
@ApiBearerAuth()
|
||||
export class ServiceTypesController {
|
||||
constructor(private readonly service: ServiceTypesService) {}
|
||||
|
||||
@Get()
|
||||
@RuleEngineView('service-types')
|
||||
@ApiOperation({ summary: 'List service types' })
|
||||
findAll(@Query() query: Record<string, string>) {
|
||||
return this.service.findAll({
|
||||
@@ -29,24 +30,28 @@ export class ServiceTypesController {
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RuleEngineView('service-types')
|
||||
@ApiOperation({ summary: 'Get a service type by ID' })
|
||||
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.service.findById(id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@RuleEngineManage('service-types')
|
||||
@ApiOperation({ summary: 'Create a service type' })
|
||||
create(@Body() dto: CreateServiceTypeDto) {
|
||||
return this.service.create(dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@RuleEngineManage('service-types')
|
||||
@ApiOperation({ summary: 'Update a service type' })
|
||||
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateServiceTypeDto) {
|
||||
return this.service.update(id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@RuleEngineManage('service-types')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@ApiOperation({ summary: 'Soft-delete a service type' })
|
||||
remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||
|
||||
@@ -2,6 +2,7 @@ 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 { CreateShippingLineDto } from '../dto/create-shipping-line.dto';
|
||||
import { UpdateShippingLineDto } from '../dto/update-shipping-line.dto';
|
||||
@@ -9,12 +10,12 @@ import { ShippingLinesService } from '../services/shipping-lines.service';
|
||||
|
||||
@ApiTags('shipping-lines')
|
||||
@Controller('shipping-lines')
|
||||
// @UseGuards(JwtAuthGuard) — TODO: add when auth package integrated
|
||||
@ApiBearerAuth()
|
||||
export class ShippingLinesController {
|
||||
constructor(private readonly service: ShippingLinesService) {}
|
||||
|
||||
@Get()
|
||||
@RuleEngineView('shipping-lines')
|
||||
@ApiOperation({ summary: 'List shipping lines' })
|
||||
findAll(@Query() query: Record<string, string>) {
|
||||
return this.service.findAll({
|
||||
@@ -25,24 +26,28 @@ export class ShippingLinesController {
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RuleEngineView('shipping-lines')
|
||||
@ApiOperation({ summary: 'Get a shipping line by ID' })
|
||||
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.service.findById(id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@RuleEngineManage('shipping-lines')
|
||||
@ApiOperation({ summary: 'Create a shipping line' })
|
||||
create(@Body() dto: CreateShippingLineDto) {
|
||||
return this.service.create(dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@RuleEngineManage('shipping-lines')
|
||||
@ApiOperation({ summary: 'Update a shipping line' })
|
||||
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateShippingLineDto) {
|
||||
return this.service.update(id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@RuleEngineManage('shipping-lines')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@ApiOperation({ summary: 'Soft-delete a shipping line' })
|
||||
remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||
|
||||
@@ -2,6 +2,7 @@ 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 { CreateSurchargeTypeDto } from '../dto/create-surcharge-type.dto';
|
||||
import { UpdateSurchargeTypeDto } from '../dto/update-surcharge-type.dto';
|
||||
@@ -9,12 +10,12 @@ import { SurchargeTypesService } from '../services/surcharge-types.service';
|
||||
|
||||
@ApiTags('surcharge-types')
|
||||
@Controller('surcharge-types')
|
||||
// @UseGuards(JwtAuthGuard) — TODO: add when auth package integrated
|
||||
@ApiBearerAuth()
|
||||
export class SurchargeTypesController {
|
||||
constructor(private readonly service: SurchargeTypesService) {}
|
||||
|
||||
@Get()
|
||||
@RuleEngineView('surcharge-types')
|
||||
@ApiOperation({ summary: 'List surcharge types' })
|
||||
findAll(@Query() query: Record<string, string>) {
|
||||
return this.service.findAll({
|
||||
@@ -25,24 +26,28 @@ export class SurchargeTypesController {
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RuleEngineView('surcharge-types')
|
||||
@ApiOperation({ summary: 'Get a surcharge type by ID' })
|
||||
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.service.findById(id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@RuleEngineManage('surcharge-types')
|
||||
@ApiOperation({ summary: 'Create a surcharge type' })
|
||||
create(@Body() dto: CreateSurchargeTypeDto) {
|
||||
return this.service.create(dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@RuleEngineManage('surcharge-types')
|
||||
@ApiOperation({ summary: 'Update a surcharge type' })
|
||||
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateSurchargeTypeDto) {
|
||||
return this.service.update(id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@RuleEngineManage('surcharge-types')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@ApiOperation({ summary: 'Soft-delete a surcharge type' })
|
||||
remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||
|
||||
@@ -2,6 +2,7 @@ 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 { CreateWeightLimitRuleDto } from '../dto/create-weight-limit-rule.dto';
|
||||
import { UpdateWeightLimitRuleDto } from '../dto/update-weight-limit-rule.dto';
|
||||
@@ -9,12 +10,12 @@ import { WeightLimitRulesService } from '../services/weight-limit-rules.service'
|
||||
|
||||
@ApiTags('weight-limit-rules')
|
||||
@Controller('weight-limit-rules')
|
||||
// @UseGuards(JwtAuthGuard) — TODO: add when auth package integrated
|
||||
@ApiBearerAuth()
|
||||
export class WeightLimitRulesController {
|
||||
constructor(private readonly service: WeightLimitRulesService) {}
|
||||
|
||||
@Get()
|
||||
@RuleEngineView('weight-limit-rules')
|
||||
@ApiOperation({ summary: 'List weight limit rules' })
|
||||
findAll(@Query() query: Record<string, string>) {
|
||||
return this.service.findAll({
|
||||
@@ -26,24 +27,28 @@ export class WeightLimitRulesController {
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RuleEngineView('weight-limit-rules')
|
||||
@ApiOperation({ summary: 'Get a weight limit rule by ID' })
|
||||
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.service.findById(id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@RuleEngineManage('weight-limit-rules')
|
||||
@ApiOperation({ summary: 'Create a weight limit rule' })
|
||||
create(@Body() dto: CreateWeightLimitRuleDto) {
|
||||
return this.service.create(dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@RuleEngineManage('weight-limit-rules')
|
||||
@ApiOperation({ summary: 'Update a weight limit rule' })
|
||||
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateWeightLimitRuleDto) {
|
||||
return this.service.update(id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@RuleEngineManage('weight-limit-rules')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@ApiOperation({ summary: 'Soft-delete a weight limit rule' })
|
||||
remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||
|
||||
@@ -2,6 +2,7 @@ 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 { CreateYardDto } from '../dto/create-yard.dto';
|
||||
import { UpdateYardDto } from '../dto/update-yard.dto';
|
||||
@@ -9,12 +10,12 @@ import { YardsService } from '../services/yards.service';
|
||||
|
||||
@ApiTags('yards')
|
||||
@Controller('yards')
|
||||
// @UseGuards(JwtAuthGuard) — TODO: add when auth package integrated
|
||||
@ApiBearerAuth()
|
||||
export class YardsController {
|
||||
constructor(private readonly service: YardsService) {}
|
||||
|
||||
@Get()
|
||||
@RuleEngineView('yards')
|
||||
@ApiOperation({ summary: 'List yards' })
|
||||
findAll(@Query() query: Record<string, string>) {
|
||||
return this.service.findAll({
|
||||
@@ -26,24 +27,28 @@ export class YardsController {
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RuleEngineView('yards')
|
||||
@ApiOperation({ summary: 'Get a yard by ID' })
|
||||
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.service.findById(id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@RuleEngineManage('yards')
|
||||
@ApiOperation({ summary: 'Create a yard' })
|
||||
create(@Body() dto: CreateYardDto) {
|
||||
return this.service.create(dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@RuleEngineManage('yards')
|
||||
@ApiOperation({ summary: 'Update a yard' })
|
||||
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateYardDto) {
|
||||
return this.service.update(id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@RuleEngineManage('yards')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@ApiOperation({ summary: 'Soft-delete a yard' })
|
||||
remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
IShippingLinesRepository,
|
||||
SHIPPING_LINES_REPOSITORY,
|
||||
} from './interfaces/shipping-lines.repository.interface';
|
||||
import { DEFAULT_APPROVAL_RULE_ROWS } from './approval-rules.defaults';
|
||||
|
||||
export interface BookingContainerEvalInput {
|
||||
containerTypeId: string;
|
||||
@@ -238,6 +239,29 @@ export class RuleEngineService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure ITMLS default approval chains exist (container + bulk). Idempotent.
|
||||
*/
|
||||
async ensureDefaultApprovalRules(): Promise<void> {
|
||||
for (const flag of [false, true] as const) {
|
||||
const existing = await this.approvalRulesRepo.findChainForCargo(flag);
|
||||
if (existing.length > 0) continue;
|
||||
|
||||
const rows = DEFAULT_APPROVAL_RULE_ROWS.filter(
|
||||
(r) => r.requiresDirectorApproval === flag,
|
||||
);
|
||||
for (const row of rows) {
|
||||
await this.approvalRulesRepo.create({
|
||||
requiresDirectorApproval: row.requiresDirectorApproval,
|
||||
stepOrder: row.stepOrder,
|
||||
requiredRole: row.requiredRole,
|
||||
actionLabel: row.actionLabel,
|
||||
blocksRole: row.blocksRole,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Instantiate booking_approval_step rows from approval_rules by freight type.
|
||||
*/
|
||||
@@ -248,6 +272,8 @@ export class RuleEngineService {
|
||||
cargoTypeId?: string | null;
|
||||
},
|
||||
): Promise<BookingApprovalStep[]> {
|
||||
await this.ensureDefaultApprovalRules();
|
||||
|
||||
let requiresDirectorApproval = options.freightType === 'BULK';
|
||||
|
||||
if (options.cargoTypeId) {
|
||||
@@ -264,6 +290,12 @@ export class RuleEngineService {
|
||||
requiresDirectorApproval,
|
||||
);
|
||||
|
||||
if (chain.length === 0) {
|
||||
throw new BadRequestException(
|
||||
`Approval chain could not be loaded for requiresDirectorApproval=${requiresDirectorApproval}.`,
|
||||
);
|
||||
}
|
||||
|
||||
const stepRepo = this.dataSource.getRepository(BookingApprovalStep);
|
||||
const steps: BookingApprovalStep[] = [];
|
||||
|
||||
@@ -273,6 +305,7 @@ export class RuleEngineService {
|
||||
approvalRuleId: rule.id,
|
||||
stepOrder: rule.stepOrder,
|
||||
requiredRole: rule.requiredRole,
|
||||
blocksRole: rule.blocksRole ?? null,
|
||||
status: 'PENDING',
|
||||
});
|
||||
steps.push(await stepRepo.save(step));
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
import {
|
||||
BOOKING_RULE_ENGINE_PERMISSIONS,
|
||||
BOOKING_RULE_ENGINE_PERMISSION_KEYS,
|
||||
ROLE_PERMISSION_PRESETS,
|
||||
} from './freight-permissions.registry';
|
||||
|
||||
export type FreightSeedRole = {
|
||||
key: string;
|
||||
name: { en: string };
|
||||
@@ -183,8 +189,11 @@ export const EDR_FREIGHT_PERMISSIONS = [
|
||||
...HIERARCHY_POSITION_PERMISSIONS,
|
||||
...HIERARCHY_EMPLOYEE_ASSIGNMENT_PERMISSIONS,
|
||||
...POSITION_TYPE_PERMISSIONS,
|
||||
...BOOKING_RULE_ENGINE_PERMISSIONS,
|
||||
];
|
||||
|
||||
export { BOOKING_RULE_ENGINE_PERMISSION_KEYS } from './freight-permissions.registry';
|
||||
|
||||
export const EDR_FREIGHT_ROLES: FreightSeedRole[] = [
|
||||
{
|
||||
key: "edr_employee",
|
||||
@@ -198,11 +207,42 @@ export const EDR_FREIGHT_ROLES: FreightSeedRole[] = [
|
||||
"edr_freight_app:position_types:view",
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "edr_line_staff",
|
||||
name: { en: "EDR Line Staff" },
|
||||
permissionKeys: [...ROLE_PERMISSION_PRESETS.lineStaff],
|
||||
},
|
||||
{
|
||||
key: "edr_director",
|
||||
name: { en: "EDR Director" },
|
||||
permissionKeys: [...ROLE_PERMISSION_PRESETS.director],
|
||||
},
|
||||
{
|
||||
key: "edr_ceo",
|
||||
name: { en: "EDR CEO" },
|
||||
permissionKeys: [...ROLE_PERMISSION_PRESETS.ceo],
|
||||
},
|
||||
{
|
||||
key: "edr_finance",
|
||||
name: { en: "EDR Finance" },
|
||||
permissionKeys: [...ROLE_PERMISSION_PRESETS.finance],
|
||||
},
|
||||
{
|
||||
key: "edr_marketing",
|
||||
name: { en: "EDR Marketing" },
|
||||
permissionKeys: [...ROLE_PERMISSION_PRESETS.marketing],
|
||||
},
|
||||
{
|
||||
key: "edr_org_manager",
|
||||
name: { en: "EDR Org Manager" },
|
||||
permissionKeys: [
|
||||
...EDR_FREIGHT_PERMISSIONS.map((permission) => permission.key),
|
||||
...BOOKING_RULE_ENGINE_PERMISSION_KEYS,
|
||||
...EMPLOYEE_REGISTRATION_PERMISSIONS.map((p) => p.key),
|
||||
...ROLE_ASSIGNMENT_PERMISSIONS.map((p) => p.key),
|
||||
...HIERARCHY_UNIT_PERMISSIONS.map((p) => p.key),
|
||||
...HIERARCHY_POSITION_PERMISSIONS.map((p) => p.key),
|
||||
...HIERARCHY_EMPLOYEE_ASSIGNMENT_PERMISSIONS.map((p) => p.key),
|
||||
...POSITION_TYPE_PERMISSIONS.map((p) => p.key),
|
||||
IAM_PERMISSION_KEYS.createEmployee,
|
||||
IAM_PERMISSION_KEYS.deactivateEmployee,
|
||||
IAM_PERMISSION_KEYS.activateEmployee,
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
} from "@tria-plc/iamapi-common";
|
||||
import { DataSource, EntityManager, In } from "typeorm";
|
||||
|
||||
import { ERoleKey } from "@tria-plc/api-common/utils/enums/seed.enum";
|
||||
import { BOOKING_RULE_ENGINE_PERMISSION_KEYS } from "./freight-permissions.registry";
|
||||
import { EDR_FREIGHT_ROLES, type FreightSeedRole } from "./edr-freight.seed";
|
||||
|
||||
const EDR_ORG_KEY = "edr_freight";
|
||||
@@ -37,6 +39,7 @@ export class EdrOrgSeeder {
|
||||
await this.ensureOrganizationConfiguration(manager, organization.id);
|
||||
await this.ensureRoles(manager, EDR_FREIGHT_ROLES);
|
||||
await this.ensureRolePermissions(manager, EDR_FREIGHT_ROLES);
|
||||
await this.ensureSuperAdminPermissions(manager);
|
||||
});
|
||||
|
||||
this.logger.log(`Ensured EDR organization seed for '${EDR_ORG_KEY}'`);
|
||||
@@ -166,4 +169,40 @@ export class EdrOrgSeeder {
|
||||
|
||||
this.logger.log(`Ensured ${rolePermissions.length} EDR role-permission links`);
|
||||
}
|
||||
|
||||
private async ensureSuperAdminPermissions(manager: EntityManager) {
|
||||
const role = await manager.getRepository(Role).findOne({
|
||||
where: { key: ERoleKey.SUPER_ADMIN },
|
||||
select: { id: true, key: true },
|
||||
});
|
||||
|
||||
if (!role) {
|
||||
this.logger.warn(
|
||||
`Role ${ERoleKey.SUPER_ADMIN} not found; skipping booking/rule-engine super_admin links`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const permissions = await manager.getRepository(Permission).find({
|
||||
where: { key: In(BOOKING_RULE_ENGINE_PERMISSION_KEYS) },
|
||||
select: { id: true, key: true },
|
||||
});
|
||||
|
||||
if (!permissions.length) {
|
||||
this.logger.warn('No booking/rule-engine permissions found for super_admin');
|
||||
return;
|
||||
}
|
||||
|
||||
await manager.getRepository(RolePermission).upsert(
|
||||
permissions.map((permission) => ({
|
||||
roleId: role.id,
|
||||
permissionId: permission.id,
|
||||
})),
|
||||
{ conflictPaths: { roleId: true, permissionId: true } },
|
||||
);
|
||||
|
||||
this.logger.log(
|
||||
`Ensured ${permissions.length} booking+rule-engine permissions on super_admin`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
152
apps/edr-freight-api/src/seed/freight-permissions.registry.ts
Normal file
152
apps/edr-freight-api/src/seed/freight-permissions.registry.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
const EDR_FREIGHT_APP_KEY = 'edr_freight_app';
|
||||
|
||||
export type FreightPermissionSeed = {
|
||||
id: string;
|
||||
key: string;
|
||||
name: { am: string; en: string };
|
||||
applicationKey: string;
|
||||
};
|
||||
|
||||
export const RULE_ENGINE_RESOURCE_SLUGS = [
|
||||
'cargo-types',
|
||||
'container-types',
|
||||
'service-types',
|
||||
'yards',
|
||||
'shipping-lines',
|
||||
'weight-limit-rules',
|
||||
'surcharge-types',
|
||||
'priority-rules',
|
||||
'rates',
|
||||
'approval-rules',
|
||||
] as const;
|
||||
|
||||
export type RuleEngineResourceSlug = (typeof RULE_ENGINE_RESOURCE_SLUGS)[number];
|
||||
|
||||
const slugToResourceKey = (slug: RuleEngineResourceSlug): string =>
|
||||
slug.replace(/-/g, '_');
|
||||
|
||||
const perm = (
|
||||
id: string,
|
||||
key: string,
|
||||
en: string,
|
||||
): FreightPermissionSeed => ({
|
||||
id,
|
||||
key,
|
||||
name: { am: en, en },
|
||||
applicationKey: EDR_FREIGHT_APP_KEY,
|
||||
});
|
||||
|
||||
export const BOOKING_PERMISSIONS: FreightPermissionSeed[] = [
|
||||
perm('a1000001-0001-4000-8000-000000000001', 'edr_freight_app:bookings:view', 'View bookings'),
|
||||
perm('a1000001-0001-4000-8000-000000000002', 'edr_freight_app:bookings:staff_accept', 'Accept booking intake'),
|
||||
perm('a1000001-0001-4000-8000-000000000003', 'edr_freight_app:bookings:request_changes', 'Request booking changes'),
|
||||
perm('a1000001-0001-4000-8000-000000000004', 'edr_freight_app:bookings:reject', 'Reject booking submission'),
|
||||
perm('a1000001-0001-4000-8000-000000000005', 'edr_freight_app:bookings:approve_line_staff', 'Approve as line staff'),
|
||||
perm('a1000001-0001-4000-8000-000000000006', 'edr_freight_app:bookings:approve_director', 'Approve as director'),
|
||||
perm('a1000001-0001-4000-8000-000000000007', 'edr_freight_app:bookings:approve_ceo', 'Approve as CEO'),
|
||||
perm('a1000001-0001-4000-8000-000000000008', 'edr_freight_app:bookings:reject_approval', 'Reject at approval step'),
|
||||
perm('a1000001-0001-4000-8000-000000000009', 'edr_freight_app:bookings:generate_contract', 'Generate contract'),
|
||||
perm('a1000001-0001-4000-8000-00000000000a', 'edr_freight_app:bookings:sign_staff', 'Staff contract signature'),
|
||||
perm('a1000001-0001-4000-8000-00000000000b', 'edr_freight_app:bookings:payment_pnr', 'Generate PNR'),
|
||||
perm('a1000001-0001-4000-8000-00000000000c', 'edr_freight_app:bookings:payment_verify', 'Verify payment'),
|
||||
perm('a1000001-0001-4000-8000-00000000000d', 'edr_freight_app:bookings:operations', 'Booking operations'),
|
||||
perm('a1000001-0001-4000-8000-00000000000e', 'edr_freight_app:bookings:cancel', 'Cancel booking'),
|
||||
];
|
||||
|
||||
const RULE_ENGINE_PERMISSION_IDS: Record<RuleEngineResourceSlug, { view: string; manage: string }> = {
|
||||
'cargo-types': { view: 'b2000001-0001-4000-8000-000000000001', manage: 'b2000001-0001-4000-8000-000000000002' },
|
||||
'container-types': { view: 'b2000001-0001-4000-8000-000000000003', manage: 'b2000001-0001-4000-8000-000000000004' },
|
||||
'service-types': { view: 'b2000001-0001-4000-8000-000000000005', manage: 'b2000001-0001-4000-8000-000000000006' },
|
||||
yards: { view: 'b2000001-0001-4000-8000-000000000007', manage: 'b2000001-0001-4000-8000-000000000008' },
|
||||
'shipping-lines': { view: 'b2000001-0001-4000-8000-000000000009', manage: 'b2000001-0001-4000-8000-00000000000a' },
|
||||
'weight-limit-rules': { view: 'b2000001-0001-4000-8000-00000000000b', manage: 'b2000001-0001-4000-8000-00000000000c' },
|
||||
'surcharge-types': { view: 'b2000001-0001-4000-8000-00000000000d', manage: 'b2000001-0001-4000-8000-00000000000e' },
|
||||
'priority-rules': { view: 'b2000001-0001-4000-8000-00000000000f', manage: 'b2000001-0001-4000-8000-000000000010' },
|
||||
rates: { view: 'b2000001-0001-4000-8000-000000000011', manage: 'b2000001-0001-4000-8000-000000000012' },
|
||||
'approval-rules': { view: 'b2000001-0001-4000-8000-000000000013', manage: 'b2000001-0001-4000-8000-000000000014' },
|
||||
};
|
||||
|
||||
export const RULE_ENGINE_PERMISSIONS: FreightPermissionSeed[] = RULE_ENGINE_RESOURCE_SLUGS.flatMap(
|
||||
(slug) => {
|
||||
const resource = slugToResourceKey(slug);
|
||||
const ids = RULE_ENGINE_PERMISSION_IDS[slug];
|
||||
return [
|
||||
perm(ids.view, `edr_freight_app:rule_engine:${resource}:view`, `View ${slug}`),
|
||||
perm(ids.manage, `edr_freight_app:rule_engine:${resource}:manage`, `Manage ${slug}`),
|
||||
];
|
||||
},
|
||||
);
|
||||
|
||||
export const BOOKING_RULE_ENGINE_PERMISSIONS = [
|
||||
...BOOKING_PERMISSIONS,
|
||||
...RULE_ENGINE_PERMISSIONS,
|
||||
];
|
||||
|
||||
export const BOOKING_RULE_ENGINE_PERMISSION_KEYS = BOOKING_RULE_ENGINE_PERMISSIONS.map(
|
||||
(p) => p.key,
|
||||
);
|
||||
|
||||
export const FREIGHT_PERMS = {
|
||||
bookings: {
|
||||
view: 'edr_freight_app:bookings:view',
|
||||
staffAccept: 'edr_freight_app:bookings:staff_accept',
|
||||
requestChanges: 'edr_freight_app:bookings:request_changes',
|
||||
reject: 'edr_freight_app:bookings:reject',
|
||||
approveLineStaff: 'edr_freight_app:bookings:approve_line_staff',
|
||||
approveDirector: 'edr_freight_app:bookings:approve_director',
|
||||
approveCeo: 'edr_freight_app:bookings:approve_ceo',
|
||||
rejectApproval: 'edr_freight_app:bookings:reject_approval',
|
||||
generateContract: 'edr_freight_app:bookings:generate_contract',
|
||||
signStaff: 'edr_freight_app:bookings:sign_staff',
|
||||
operations: 'edr_freight_app:bookings:operations',
|
||||
cancel: 'edr_freight_app:bookings:cancel',
|
||||
},
|
||||
ruleEngine: {
|
||||
view: (slug: RuleEngineResourceSlug) =>
|
||||
`edr_freight_app:rule_engine:${slugToResourceKey(slug)}:view`,
|
||||
manage: (slug: RuleEngineResourceSlug) =>
|
||||
`edr_freight_app:rule_engine:${slugToResourceKey(slug)}:manage`,
|
||||
},
|
||||
} as const;
|
||||
|
||||
const allRuleEngineViewKeys = () =>
|
||||
RULE_ENGINE_RESOURCE_SLUGS.map((s) => FREIGHT_PERMS.ruleEngine.view(s));
|
||||
|
||||
export const ROLE_PERMISSION_PRESETS = {
|
||||
lineStaff: [
|
||||
FREIGHT_PERMS.bookings.view,
|
||||
FREIGHT_PERMS.bookings.staffAccept,
|
||||
FREIGHT_PERMS.bookings.requestChanges,
|
||||
FREIGHT_PERMS.bookings.reject,
|
||||
FREIGHT_PERMS.bookings.approveLineStaff,
|
||||
FREIGHT_PERMS.bookings.rejectApproval,
|
||||
FREIGHT_PERMS.bookings.cancel,
|
||||
...allRuleEngineViewKeys(),
|
||||
],
|
||||
director: [
|
||||
FREIGHT_PERMS.bookings.view,
|
||||
FREIGHT_PERMS.bookings.approveDirector,
|
||||
FREIGHT_PERMS.bookings.rejectApproval,
|
||||
FREIGHT_PERMS.bookings.generateContract,
|
||||
...allRuleEngineViewKeys(),
|
||||
],
|
||||
ceo: [
|
||||
FREIGHT_PERMS.bookings.view,
|
||||
FREIGHT_PERMS.bookings.approveCeo,
|
||||
FREIGHT_PERMS.bookings.rejectApproval,
|
||||
...allRuleEngineViewKeys(),
|
||||
],
|
||||
finance: [FREIGHT_PERMS.bookings.view],
|
||||
marketing: [
|
||||
FREIGHT_PERMS.bookings.view,
|
||||
FREIGHT_PERMS.bookings.generateContract,
|
||||
FREIGHT_PERMS.bookings.signStaff,
|
||||
],
|
||||
orgManager: [...BOOKING_RULE_ENGINE_PERMISSION_KEYS],
|
||||
} as const;
|
||||
|
||||
export const PERMISSIONS_CATALOG = BOOKING_RULE_ENGINE_PERMISSIONS.map((p) => ({
|
||||
key: p.key,
|
||||
label: p.name.en,
|
||||
module: p.key.includes(':bookings:') ? 'bookings' : 'rule_engine',
|
||||
}));
|
||||
127
apps/edr-freight-api/src/seed/freight-staff-users.seeder.ts
Normal file
127
apps/edr-freight-api/src/seed/freight-staff-users.seeder.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { hashPassword } from '@tria-plc/api-common/utils/argon';
|
||||
import { EUserStatus } from '@tria-plc/api-common/utils/enums/user.enum';
|
||||
import {
|
||||
Employee,
|
||||
Organization,
|
||||
Role,
|
||||
User,
|
||||
UserCredential,
|
||||
UserRole,
|
||||
} from '@tria-plc/iamapi-common';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
const SEED_FLAG = 'SEED_FREIGHT_STAFF';
|
||||
const EDR_ORG_KEY = 'edr_freight';
|
||||
|
||||
const STAFF_USERS = [
|
||||
{ email: 'linestaff@edr.local', username: 'linestaff', roleKey: 'edr_line_staff' },
|
||||
{ email: 'director@edr.local', username: 'director', roleKey: 'edr_director' },
|
||||
{ email: 'ceo@edr.local', username: 'ceo', roleKey: 'edr_ceo' },
|
||||
] as const;
|
||||
|
||||
@Injectable()
|
||||
export class FreightStaffUsersSeeder {
|
||||
private readonly logger = new Logger(FreightStaffUsersSeeder.name);
|
||||
|
||||
constructor(private readonly dataSource: DataSource) {}
|
||||
|
||||
async run() {
|
||||
if (process.env[SEED_FLAG]?.trim().toLowerCase() !== 'true') {
|
||||
this.logger.log(`Skipping freight staff seed because ${SEED_FLAG} is not enabled`);
|
||||
return;
|
||||
}
|
||||
|
||||
const password =
|
||||
process.env.DEFAULT_PASSWORD?.trim() || '12345678';
|
||||
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
const organization = await manager.getRepository(Organization).findOne({
|
||||
where: { key: EDR_ORG_KEY },
|
||||
select: { id: true, key: true },
|
||||
});
|
||||
|
||||
if (!organization) {
|
||||
throw new Error(`missing_organization:${EDR_ORG_KEY}`);
|
||||
}
|
||||
|
||||
const roleRepository = manager.getRepository(Role);
|
||||
const userRepository = manager.getRepository(User);
|
||||
const userCredentialRepository = manager.getRepository(UserCredential);
|
||||
const userRoleRepository = manager.getRepository(UserRole);
|
||||
const employeeRepository = manager.getRepository(Employee);
|
||||
|
||||
const hashedPassword = await hashPassword(password);
|
||||
|
||||
for (const staff of STAFF_USERS) {
|
||||
const role = await roleRepository.findOne({
|
||||
where: { key: staff.roleKey },
|
||||
select: { id: true, key: true },
|
||||
});
|
||||
|
||||
if (!role) {
|
||||
throw new Error(`missing_role:${staff.roleKey}`);
|
||||
}
|
||||
|
||||
let user = await userRepository.findOne({
|
||||
where: { email: staff.email },
|
||||
select: { id: true, email: true },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
user = await userRepository.save(
|
||||
userRepository.create({
|
||||
email: staff.email,
|
||||
username: staff.username,
|
||||
name: { en: staff.username },
|
||||
isActive: true,
|
||||
hasSetPassword: true,
|
||||
status: EUserStatus.ACCEPTED,
|
||||
}),
|
||||
);
|
||||
this.logger.log(`Seeded freight staff user ${staff.email}`);
|
||||
}
|
||||
|
||||
const activeCredentialExists = await userCredentialRepository.exists({
|
||||
where: { userId: user.id, isActive: true },
|
||||
});
|
||||
|
||||
if (!activeCredentialExists) {
|
||||
await userCredentialRepository.insert({
|
||||
userId: user.id,
|
||||
password: hashedPassword,
|
||||
isActive: true,
|
||||
});
|
||||
}
|
||||
|
||||
await userRoleRepository.upsert(
|
||||
{
|
||||
userId: user.id,
|
||||
roleId: role.id,
|
||||
organizationId: organization.id,
|
||||
},
|
||||
{ conflictPaths: { userId: true, roleId: true } },
|
||||
);
|
||||
|
||||
const employeeExists = await employeeRepository.exists({
|
||||
where: {
|
||||
userId: user.id,
|
||||
organizationId: organization.id,
|
||||
isCurrent: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!employeeExists) {
|
||||
await employeeRepository.insert({
|
||||
userId: user.id,
|
||||
organizationId: organization.id,
|
||||
isCurrent: true,
|
||||
name: { en: staff.username },
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.logger.log('Ensured freight staff users (linestaff@, director@, ceo@)');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user