mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
add fan number
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
|
||||
|
||||
@@ -4,7 +4,10 @@
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true,
|
||||
"assets": [{ "include": "migrations/**/*", "outDir": "dist" }],
|
||||
"assets": [
|
||||
{ "include": "migrations/**/*", "outDir": "dist" },
|
||||
{ "include": "contracts/templates/**/*", "watchAssets": true }
|
||||
],
|
||||
"watchAssets": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,11 +32,12 @@
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
"dotenv": "^17.4.2",
|
||||
"handlebars": "^4.7.9",
|
||||
"minio": "7.1.3",
|
||||
"puppeteer": "^24.2.0",
|
||||
"pg": "^8.13.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1",
|
||||
"typeorm": "^0.3.20"
|
||||
"rxjs": "^7.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@edr/api-common": "workspace:*",
|
||||
@@ -58,6 +59,7 @@
|
||||
"ts-loader": "^9.5.1",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"typeorm": "^1.0.0",
|
||||
"typescript": "^5.5.4"
|
||||
},
|
||||
"jest": {
|
||||
|
||||
@@ -12,7 +12,13 @@ import databaseConfig from "./config/database.config";
|
||||
import { BookingsModule } from "./modules/bookings/bookings.module";
|
||||
import { FilesModule } from "./modules/files/files.module";
|
||||
import { ConsignmentsModule } from "./modules/consignments/consignments.module";
|
||||
import { TrainsModule } from "./modules/trains/trains.module";
|
||||
|
||||
//import { TrainsModule } from "./modules/trains/trains.module";
|
||||
import { LocomotivesModule } from "./modules/locomotives/locomotives.module";
|
||||
import { WagonTypesModule } from "./modules/wagon-types/wagon-types.module";
|
||||
import { TrainSetsModule } from "./modules/train-sets/train-sets.module";
|
||||
import { TrainSchedulesModule } from "./modules/train-schedules/train-schedules.module";
|
||||
import { TrainSchedulingModule } from "./modules/train-scheduling/train-scheduling.module";
|
||||
import { CustomersModule } from "./modules/customers/customers.module";
|
||||
import { CompaniesModule } from "./modules/companies/companies.module";
|
||||
import { TrackingModule } from "./modules/tracking/tracking.module";
|
||||
@@ -24,13 +30,23 @@ 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";
|
||||
import { PaymentModule } from "./modules/payment/payment.module";
|
||||
import { DemoBookingsSeeder } from "./seed/demo-bookings.seeder";
|
||||
import { PricingDataSeeder } from "./seed/pricing-data.seeder";
|
||||
import { FileUploadSettingsSeeder } from "./seed/file-upload-settings.seeder";
|
||||
//New Trains, Wagons, Container and Cargo management modules
|
||||
import { TrainsModule } from "./modules/trains/trains.module";
|
||||
import { WagonsModule } from './modules/wagons/wagons.module';
|
||||
import { ContainersModule } from './modules/container-management/containers.module';
|
||||
import { CargoesModule } from './modules/cargoes/cargoes.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -60,6 +76,11 @@ import { PaymentModule } from "./modules/payment/payment.module";
|
||||
FilesModule,
|
||||
ConsignmentsModule,
|
||||
TrainsModule,
|
||||
LocomotivesModule,
|
||||
WagonTypesModule,
|
||||
TrainSetsModule,
|
||||
TrainSchedulesModule,
|
||||
TrainSchedulingModule,
|
||||
CustomersModule,
|
||||
CompaniesModule,
|
||||
TrackingModule,
|
||||
@@ -71,20 +92,34 @@ import { PaymentModule } from "./modules/payment/payment.module";
|
||||
RuleEngineModule,
|
||||
BackofficeModule,
|
||||
DemoPermissionsModule,
|
||||
PaymentModule
|
||||
FreightAuthModule,
|
||||
PaymentModule,
|
||||
//New Modules
|
||||
TrainsModule,
|
||||
WagonsModule,
|
||||
ContainersModule,
|
||||
CargoesModule,
|
||||
],
|
||||
providers: [EdrOrgSeeder, DemoUsersSeeder],
|
||||
providers: [EdrOrgSeeder, DemoUsersSeeder,FreightStaffUsersSeeder, DemoBookingsSeeder, PricingDataSeeder, FileUploadSettingsSeeder],
|
||||
})
|
||||
export class AppModule implements OnApplicationBootstrap {
|
||||
constructor(
|
||||
private readonly seeder: DataSeeder,
|
||||
private readonly edrOrgSeeder: EdrOrgSeeder,
|
||||
private readonly demoUsersSeeder: DemoUsersSeeder,
|
||||
private readonly freightStaffUsersSeeder: FreightStaffUsersSeeder,
|
||||
private readonly demoBookingsSeeder: DemoBookingsSeeder,
|
||||
private readonly pricingDataSeeder: PricingDataSeeder,
|
||||
private readonly fileUploadSettingsSeeder: FileUploadSettingsSeeder,
|
||||
) { }
|
||||
|
||||
async onApplicationBootstrap() {
|
||||
await this.seeder.run();
|
||||
await this.edrOrgSeeder.run();
|
||||
await this.demoUsersSeeder.run();
|
||||
await this.freightStaffUsersSeeder.run();
|
||||
await this.demoBookingsSeeder.run();
|
||||
await this.pricingDataSeeder.run();
|
||||
await this.fileUploadSettingsSeeder.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;
|
||||
}
|
||||
109
apps/edr-freight-api/src/common/freight-permission.util.ts
Normal file
109
apps/edr-freight-api/src/common/freight-permission.util.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
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';
|
||||
const ORGANIZATION_ADMIN_ROLE = 'organization_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);
|
||||
}
|
||||
|
||||
export function isOrganizationAdmin(user: MeLikeUser | null | undefined): boolean {
|
||||
if (!user?.roles?.length) return false;
|
||||
return user.roles.some((r) => r.key === ORGANIZATION_ADMIN_ROLE);
|
||||
}
|
||||
|
||||
export function isFreightApprovalAdmin(user: MeLikeUser | null | undefined): boolean {
|
||||
return isSuperAdmin(user) || isOrganizationAdmin(user);
|
||||
}
|
||||
|
||||
/** 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 (isFreightApprovalAdmin(user)) return;
|
||||
const perm = APPROVE_ROLE_PERMISSION[requiredRole];
|
||||
if (!perm) {
|
||||
throw new ForbiddenException(`Unknown approval role: ${requiredRole}`);
|
||||
}
|
||||
assertFreightPermission(user, perm);
|
||||
}
|
||||
12
apps/edr-freight-api/src/common/resolve-auth-user-id.ts
Normal file
12
apps/edr-freight-api/src/common/resolve-auth-user-id.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { UnauthorizedException } from '@nestjs/common';
|
||||
|
||||
export type AuthUserPayload = { id?: string; sub?: string } | null | undefined;
|
||||
|
||||
/** Resolve IAM user id from JWT payload attached by JwtGuard. */
|
||||
export function resolveAuthUserId(user: AuthUserPayload): string {
|
||||
const id = user?.id ?? user?.sub;
|
||||
if (!id) {
|
||||
throw new UnauthorizedException('Authentication required');
|
||||
}
|
||||
return id;
|
||||
}
|
||||
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)])),
|
||||
);
|
||||
224
apps/edr-freight-api/src/contracts/contract-clause-packs.ts
Normal file
224
apps/edr-freight-api/src/contracts/contract-clause-packs.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
import type {
|
||||
Article1Clause,
|
||||
ContractClausePack,
|
||||
ContractDirection,
|
||||
ContractFreight,
|
||||
ContractServiceScope,
|
||||
} from './contract-template.types';
|
||||
|
||||
const STANDARD_CONTRACT_DOCUMENTS = [
|
||||
'Amendments (if any)',
|
||||
'This Contract Agreement',
|
||||
'Final Minutes of Negotiation (if any)',
|
||||
];
|
||||
|
||||
const PAYMENT_OBLIGATION =
|
||||
'Pay 100% transportation fees in advance per train set in accordance with Article 5.';
|
||||
|
||||
const HAZARDOUS_OBLIGATION =
|
||||
'Notify EDR 48 hours in advance for hazardous or valuable cargo.';
|
||||
|
||||
function clonePack(pack: ContractClausePack): ContractClausePack {
|
||||
return {
|
||||
article1: {
|
||||
objective: pack.article1.objective,
|
||||
scope: [...pack.article1.scope],
|
||||
},
|
||||
clientObligations: [...pack.clientObligations],
|
||||
providerObligations: [...pack.providerObligations],
|
||||
contractDocuments: [...pack.contractDocuments],
|
||||
};
|
||||
}
|
||||
|
||||
function applyForwardingOverlay(
|
||||
pack: ContractClausePack,
|
||||
service: ContractServiceScope,
|
||||
): ContractClausePack {
|
||||
if (service !== 'FORWARDING') return pack;
|
||||
|
||||
const next = clonePack(pack);
|
||||
next.article1.scope.push(
|
||||
'First-mile and/or last-mile coordination, documentation, and handover with road or port partners where included in the agreed service scope.',
|
||||
);
|
||||
next.providerObligations.push(
|
||||
'Coordinate first-mile and last-mile logistics with designated partners and keep the Client informed of handover milestones.',
|
||||
);
|
||||
return next;
|
||||
}
|
||||
|
||||
function buildImportContainerPack(): ContractClausePack {
|
||||
return {
|
||||
article1: {
|
||||
objective:
|
||||
'To provide railway transportation for 40ft and/or 20ft full containers from SGTD to Dire Dawa, Modjo dry port and/or Galaan Multipurpose port (GMP), and empty container return from those terminals to SGTD.',
|
||||
scope: [
|
||||
'Railway transport service on the agreed import corridor.',
|
||||
'Cargo handling at Galaan Multipurpose port (GMP) where applicable.',
|
||||
],
|
||||
},
|
||||
clientObligations: [
|
||||
'Provide shipment instructions to EDR for container movements on the agreed corridor.',
|
||||
'Meet minimum container supply per terminal (Modjo, Dire Dawa, GMP) as per EDR operational rules.',
|
||||
'Submit required documents to Djibouti Nagad station at least 24 hours before loading.',
|
||||
PAYMENT_OBLIGATION,
|
||||
HAZARDOUS_OBLIGATION,
|
||||
],
|
||||
providerObligations: [
|
||||
'Assign voyage per operational schedule and notify train schedule 48 hours in advance.',
|
||||
'Provide safe transportation and deliver within agreed timelines when documents are complete.',
|
||||
'Return empty containers from Dire Dawa, Modjo and GMP to SGTD within seven (7) calendar days of receipt.',
|
||||
'Maintain cargo liability insurance per wagon.',
|
||||
],
|
||||
contractDocuments: [...STANDARD_CONTRACT_DOCUMENTS],
|
||||
};
|
||||
}
|
||||
|
||||
function buildImportBulkPack(): ContractClausePack {
|
||||
return {
|
||||
article1: {
|
||||
objective:
|
||||
'To provide railway transportation for bulk cargo from SGTD railway freight station at Djibouti to designated Ethiopian rail terminals on the import corridor.',
|
||||
scope: [
|
||||
'Railway bulk transport service on the agreed import corridor.',
|
||||
'Loading and unloading coordination at designated terminals per EDR operational rules.',
|
||||
],
|
||||
},
|
||||
clientObligations: [
|
||||
'Provide accurate commodity description, weight, and shipment instructions for each train movement.',
|
||||
'Ensure cargo is prepared and available at origin per the agreed loading window.',
|
||||
'Submit required customs and operational documents at least 24 hours before loading where applicable.',
|
||||
PAYMENT_OBLIGATION,
|
||||
HAZARDOUS_OBLIGATION,
|
||||
],
|
||||
providerObligations: [
|
||||
'Assign train capacity per operational schedule and notify departure timing 48 hours in advance where practicable.',
|
||||
'Provide safe bulk transportation and deliver within agreed timelines when documents are complete.',
|
||||
'Maintain cargo liability insurance per wagon or train consist as applicable.',
|
||||
],
|
||||
contractDocuments: [...STANDARD_CONTRACT_DOCUMENTS],
|
||||
};
|
||||
}
|
||||
|
||||
function buildExportContainerPack(): ContractClausePack {
|
||||
return {
|
||||
article1: {
|
||||
objective:
|
||||
'To provide railway transportation for 40ft and/or 20ft full containers from designated Ethiopian dry ports and terminals to SGTD and related export corridors.',
|
||||
scope: [
|
||||
'Railway export transport service on the agreed corridor.',
|
||||
'Terminal coordination at origin yards for export dispatch where applicable.',
|
||||
],
|
||||
},
|
||||
clientObligations: [
|
||||
'Provide export shipment instructions and container release details for each movement.',
|
||||
'Ensure containers are available at origin terminals per EDR operational windows.',
|
||||
'Submit required export, customs, and operational documents at origin at least 24 hours before loading.',
|
||||
PAYMENT_OBLIGATION,
|
||||
HAZARDOUS_OBLIGATION,
|
||||
],
|
||||
providerObligations: [
|
||||
'Assign voyage per operational schedule and notify train schedule 48 hours in advance.',
|
||||
'Provide safe transportation to SGTD and hand over for export processing when documents are complete.',
|
||||
'Maintain cargo liability insurance per wagon.',
|
||||
],
|
||||
contractDocuments: [...STANDARD_CONTRACT_DOCUMENTS],
|
||||
};
|
||||
}
|
||||
|
||||
function buildExportBulkPack(): ContractClausePack {
|
||||
return {
|
||||
article1: {
|
||||
objective:
|
||||
'To provide railway transportation for bulk export cargo from designated Ethiopian rail terminals to SGTD and related export corridors.',
|
||||
scope: [
|
||||
'Railway bulk export transport on the agreed corridor.',
|
||||
'Loading coordination at origin terminals per EDR operational rules.',
|
||||
],
|
||||
},
|
||||
clientObligations: [
|
||||
'Provide accurate commodity description, weight, and export shipment instructions.',
|
||||
'Ensure bulk cargo is prepared and available at origin per the agreed loading window.',
|
||||
'Submit required export and customs documents at least 24 hours before loading where applicable.',
|
||||
PAYMENT_OBLIGATION,
|
||||
HAZARDOUS_OBLIGATION,
|
||||
],
|
||||
providerObligations: [
|
||||
'Assign train capacity per operational schedule and notify departure timing 48 hours in advance where practicable.',
|
||||
'Provide safe bulk transportation to SGTD within agreed timelines when documents are complete.',
|
||||
'Maintain cargo liability insurance per wagon or train consist as applicable.',
|
||||
],
|
||||
contractDocuments: [...STANDARD_CONTRACT_DOCUMENTS],
|
||||
};
|
||||
}
|
||||
|
||||
function buildDomesticContainerPack(): ContractClausePack {
|
||||
return {
|
||||
article1: {
|
||||
objective:
|
||||
'To provide railway transportation for 40ft and/or 20ft containers between designated Ethiopian rail terminals on the domestic corridor.',
|
||||
scope: ['Domestic railway container transport between agreed origin and destination yards.'],
|
||||
},
|
||||
clientObligations: [
|
||||
'Provide shipment instructions for each domestic container movement.',
|
||||
'Ensure containers are available at origin per EDR operational rules.',
|
||||
PAYMENT_OBLIGATION,
|
||||
HAZARDOUS_OBLIGATION,
|
||||
],
|
||||
providerObligations: [
|
||||
'Assign voyage per operational schedule and notify train schedule 48 hours in advance where practicable.',
|
||||
'Provide safe transportation and deliver within agreed timelines when instructions are complete.',
|
||||
'Maintain cargo liability insurance per wagon.',
|
||||
],
|
||||
contractDocuments: [...STANDARD_CONTRACT_DOCUMENTS],
|
||||
};
|
||||
}
|
||||
|
||||
function buildDomesticBulkPack(): ContractClausePack {
|
||||
return {
|
||||
article1: {
|
||||
objective:
|
||||
'To provide railway transportation for bulk cargo between designated Ethiopian rail terminals on the domestic corridor.',
|
||||
scope: ['Domestic railway bulk transport between agreed origin and destination terminals.'],
|
||||
},
|
||||
clientObligations: [
|
||||
'Provide commodity description, weight, and shipment instructions for each movement.',
|
||||
'Ensure cargo is prepared at origin per the agreed loading window.',
|
||||
PAYMENT_OBLIGATION,
|
||||
HAZARDOUS_OBLIGATION,
|
||||
],
|
||||
providerObligations: [
|
||||
'Assign train capacity per operational schedule and notify departure timing 48 hours in advance where practicable.',
|
||||
'Provide safe bulk transportation within agreed timelines.',
|
||||
'Maintain cargo liability insurance per wagon or train consist as applicable.',
|
||||
],
|
||||
contractDocuments: [...STANDARD_CONTRACT_DOCUMENTS],
|
||||
};
|
||||
}
|
||||
|
||||
const BASE_PACKS: Record<ContractDirection, Record<ContractFreight, () => ContractClausePack>> = {
|
||||
IMP: {
|
||||
CON: buildImportContainerPack,
|
||||
BULK: buildImportBulkPack,
|
||||
},
|
||||
EXP: {
|
||||
CON: buildExportContainerPack,
|
||||
BULK: buildExportBulkPack,
|
||||
},
|
||||
DOM: {
|
||||
CON: buildDomesticContainerPack,
|
||||
BULK: buildDomesticBulkPack,
|
||||
},
|
||||
};
|
||||
|
||||
export function buildClausePack(
|
||||
direction: ContractDirection,
|
||||
freight: ContractFreight,
|
||||
service: ContractServiceScope,
|
||||
): ContractClausePack {
|
||||
const base = BASE_PACKS[direction][freight]();
|
||||
return applyForwardingOverlay(base, service);
|
||||
}
|
||||
|
||||
export function article1ObjectiveFromClause(article1: Article1Clause): string {
|
||||
return article1.objective;
|
||||
}
|
||||
116
apps/edr-freight-api/src/contracts/contract-pdf.service.ts
Normal file
116
apps/edr-freight-api/src/contracts/contract-pdf.service.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
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 launchOptions: import('puppeteer').LaunchOptions = {
|
||||
headless: true,
|
||||
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.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,
|
||||
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' },
|
||||
});
|
||||
|
||||
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.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.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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-'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { BookingPricingService } from '../modules/bookings/booking-pricing.service';
|
||||
import { Booking } from '../modules/bookings/entities/booking.entity';
|
||||
import { PriceLineItemDto } from '../modules/bookings/dto/generate-price-response.dto';
|
||||
|
||||
export interface PricingScheduleRow {
|
||||
label: string;
|
||||
description: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
}
|
||||
|
||||
export interface PricingSchedule {
|
||||
lineItems: PricingScheduleRow[];
|
||||
surcharges: PricingScheduleRow[];
|
||||
totalAmount: number;
|
||||
currency: string;
|
||||
equipmentReturn?: string;
|
||||
originLabel: string;
|
||||
destinationLabel: string;
|
||||
containerLines: Array<{
|
||||
label: string;
|
||||
quantity: number;
|
||||
vgmPerUnitTons: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ContractPricingScheduleBuilder {
|
||||
constructor(private readonly pricingService: BookingPricingService) {}
|
||||
|
||||
async build(booking: Booking): Promise<PricingSchedule> {
|
||||
const { lineItems, totalAmount, currency } =
|
||||
await this.pricingService.computeContractLineItems(booking);
|
||||
|
||||
const isSurcharge = (l: PriceLineItemDto) =>
|
||||
l.code.includes('SURCHARGE') || l.description.toLowerCase().includes('surcharge');
|
||||
|
||||
const baseLines = lineItems.filter((l) => !isSurcharge(l));
|
||||
const surchargeLines = lineItems.filter(isSurcharge);
|
||||
|
||||
return {
|
||||
lineItems: baseLines.map((l) => ({
|
||||
label: l.code,
|
||||
description: l.description,
|
||||
amount: l.amount,
|
||||
currency: l.currency,
|
||||
})),
|
||||
surcharges: surchargeLines.map((l) => ({
|
||||
label: l.code,
|
||||
description: l.description,
|
||||
amount: l.amount,
|
||||
currency: l.currency,
|
||||
})),
|
||||
totalAmount,
|
||||
currency,
|
||||
equipmentReturn: booking.equipmentReturn ?? '—',
|
||||
originLabel: booking.originYard?.label ?? booking.originYard?.code ?? '—',
|
||||
destinationLabel:
|
||||
booking.destinationYard?.label ?? booking.destinationYard?.code ?? '—',
|
||||
containerLines: (booking.bookingContainers ?? []).map((c) => ({
|
||||
label:
|
||||
c.containerType?.label ?? c.containerType?.code ?? c.containerTypeId,
|
||||
quantity: c.quantity,
|
||||
vgmPerUnitTons: Number(c.vgmPerUnitTons),
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { ContractRendererService } from './contract-renderer.service';
|
||||
import { getTemplateMeta } from './contract-template.registry';
|
||||
import type { ContractViewModel } from './contract-view-model.builder';
|
||||
|
||||
describe('ContractRendererService', () => {
|
||||
const renderer = new ContractRendererService();
|
||||
renderer.onModuleInit();
|
||||
|
||||
function minimalView(templateKey: string): ContractViewModel {
|
||||
const template = getTemplateMeta(templateKey);
|
||||
return {
|
||||
bookingId: 'test-id',
|
||||
reference: 'BK-TEST-001',
|
||||
status: 'CONTRACT_READY',
|
||||
templateKey,
|
||||
template,
|
||||
contractDate: '1 January 2026',
|
||||
contractYear: 2026,
|
||||
client: {
|
||||
companyName: 'Test Co',
|
||||
companyAddress: 'Addis Ababa',
|
||||
companyLocation: 'Ethiopia',
|
||||
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' }],
|
||||
surcharges: [],
|
||||
totalAmount: 1000,
|
||||
currency: 'ETB',
|
||||
originLabel: 'SGTD',
|
||||
destinationLabel: 'Modjo',
|
||||
containerLines: [{ label: '40ft', quantity: 2, vgmPerUnitTons: 12 }],
|
||||
},
|
||||
signatures: [],
|
||||
canSignCustomer: true,
|
||||
canSignStaff: false,
|
||||
hasContractDocument: false,
|
||||
hasCustomerSignature: false,
|
||||
hasStaffSignature: false,
|
||||
};
|
||||
}
|
||||
|
||||
it('renders import flagship with Nagad and Article 5', () => {
|
||||
const html = renderer.render(minimalView('IMP_CON_ETB_TRANSPORT_ONLY'));
|
||||
expect(html).toContain('Djibouti Nagad');
|
||||
expect(html).toContain('Article 5: Contract Price');
|
||||
expect(html).toContain('Article 2: Obligations of the Client');
|
||||
});
|
||||
|
||||
it('renders export variant without import empty-return clause', () => {
|
||||
const html = renderer.render(minimalView('EXP_CON_USD_TRANSPORT_ONLY'));
|
||||
expect(html).toContain('export corridors');
|
||||
expect(html).not.toContain('Return empty containers from Dire Dawa');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Injectable, OnModuleInit } from '@nestjs/common';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import Handlebars from 'handlebars';
|
||||
|
||||
import { ContractViewModel } from './contract-view-model.builder';
|
||||
|
||||
@Injectable()
|
||||
export class ContractRendererService implements OnModuleInit {
|
||||
private readonly templatesDir = path.join(__dirname, 'templates');
|
||||
private readonly compiled = new Map<string, Handlebars.TemplateDelegate>();
|
||||
|
||||
onModuleInit(): void {
|
||||
Handlebars.registerHelper('eq', (a: unknown, b: unknown) => a === b);
|
||||
|
||||
const partialsDir = path.join(this.templatesDir, '_partials');
|
||||
if (fs.existsSync(partialsDir)) {
|
||||
for (const file of fs.readdirSync(partialsDir)) {
|
||||
if (!file.endsWith('.hbs')) continue;
|
||||
const name = file.replace(/\.hbs$/, '');
|
||||
const content = fs.readFileSync(path.join(partialsDir, file), 'utf-8');
|
||||
Handlebars.registerPartial(name, content);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
render(view: ContractViewModel): string {
|
||||
const fileName =
|
||||
view.template.templateFile ?? 'generic.hbs';
|
||||
const template = this.getCompiled(fileName);
|
||||
return template({
|
||||
...view,
|
||||
paymentArticle: view.pricing.currency === 'ETB' ? 'ETB' : 'USD',
|
||||
});
|
||||
}
|
||||
|
||||
private getCompiled(fileName: string): Handlebars.TemplateDelegate {
|
||||
const cached = this.compiled.get(fileName);
|
||||
if (cached) return cached;
|
||||
|
||||
const filePath = path.join(this.templatesDir, fileName);
|
||||
const fallbackPath = path.join(this.templatesDir, 'generic.hbs');
|
||||
const source = fs.existsSync(filePath)
|
||||
? fs.readFileSync(filePath, 'utf-8')
|
||||
: fs.readFileSync(fallbackPath, 'utf-8');
|
||||
|
||||
const compiled = Handlebars.compile(source);
|
||||
this.compiled.set(fileName, compiled);
|
||||
return compiled;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import {
|
||||
CONTRACT_TEMPLATE_KEYS,
|
||||
CONTRACT_TEMPLATE_REGISTRY,
|
||||
getTemplateMeta,
|
||||
isValidTemplateKey,
|
||||
} from './contract-template.registry';
|
||||
|
||||
describe('ContractTemplateRegistry', () => {
|
||||
it('defines exactly 24 template keys', () => {
|
||||
expect(CONTRACT_TEMPLATE_KEYS).toHaveLength(24);
|
||||
expect(Object.keys(CONTRACT_TEMPLATE_REGISTRY)).toHaveLength(24);
|
||||
});
|
||||
|
||||
it('keys match the direction_freight_currency_service pattern', () => {
|
||||
for (const key of CONTRACT_TEMPLATE_KEYS) {
|
||||
expect(isValidTemplateKey(key)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('each meta has non-empty obligations and article1 scope', () => {
|
||||
for (const key of CONTRACT_TEMPLATE_KEYS) {
|
||||
const meta = CONTRACT_TEMPLATE_REGISTRY[key]!;
|
||||
expect(meta.clientObligations.length).toBeGreaterThan(0);
|
||||
expect(meta.providerObligations.length).toBeGreaterThan(0);
|
||||
expect(meta.article1.scope.length).toBeGreaterThan(0);
|
||||
expect(meta.article1.objective.length).toBeGreaterThan(0);
|
||||
expect(meta.contractDocuments.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('IMP_CON_ETB_TRANSPORT_ONLY retains import container flagship clauses', () => {
|
||||
const meta = getTemplateMeta('IMP_CON_ETB_TRANSPORT_ONLY');
|
||||
expect(meta.direction).toBe('IMP');
|
||||
expect(meta.freight).toBe('CON');
|
||||
expect(meta.article1.objective).toContain('SGTD');
|
||||
expect(meta.article1.objective).toContain('empty container return');
|
||||
const clientText = meta.clientObligations.join(' ');
|
||||
expect(clientText).toContain('Djibouti Nagad');
|
||||
const providerText = meta.providerObligations.join(' ');
|
||||
expect(providerText).toContain('seven (7) calendar days');
|
||||
});
|
||||
|
||||
it('EXP_CON_USD_TRANSPORT_ONLY uses export-oriented article1', () => {
|
||||
const meta = getTemplateMeta('EXP_CON_USD_TRANSPORT_ONLY');
|
||||
expect(meta.direction).toBe('EXP');
|
||||
expect(meta.article1.objective).toContain('SGTD');
|
||||
expect(meta.providerObligations.join(' ')).not.toContain(
|
||||
'Return empty containers from Dire Dawa',
|
||||
);
|
||||
});
|
||||
|
||||
it('FORWARDING adds scope and provider obligations', () => {
|
||||
const transport = getTemplateMeta('IMP_CON_ETB_TRANSPORT_ONLY');
|
||||
const forwarding = getTemplateMeta('IMP_CON_ETB_FORWARDING');
|
||||
expect(forwarding.article1.scope.length).toBeGreaterThan(
|
||||
transport.article1.scope.length,
|
||||
);
|
||||
expect(forwarding.providerObligations.length).toBeGreaterThan(
|
||||
transport.providerObligations.length,
|
||||
);
|
||||
});
|
||||
|
||||
it('getTemplateMeta fallback includes clause arrays for unknown keys', () => {
|
||||
const meta = getTemplateMeta('UNKNOWN_KEY');
|
||||
expect(meta.clientObligations.length).toBeGreaterThan(0);
|
||||
expect(meta.article1.scope.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
119
apps/edr-freight-api/src/contracts/contract-template.registry.ts
Normal file
119
apps/edr-freight-api/src/contracts/contract-template.registry.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import {
|
||||
article1ObjectiveFromClause,
|
||||
buildClausePack,
|
||||
} from './contract-clause-packs';
|
||||
import type {
|
||||
ContractDirection,
|
||||
ContractFreight,
|
||||
ContractServiceScope,
|
||||
ContractTemplateMeta,
|
||||
} from './contract-template.types';
|
||||
|
||||
export type { ContractTemplateMeta } from './contract-template.types';
|
||||
|
||||
const DIRECTION_LABELS: Record<ContractDirection, string> = {
|
||||
IMP: 'Import',
|
||||
EXP: 'Export',
|
||||
DOM: 'Domestic',
|
||||
};
|
||||
|
||||
const FREIGHT_LABELS: Record<ContractFreight, string> = {
|
||||
CON: 'Container',
|
||||
BULK: 'Bulk',
|
||||
};
|
||||
|
||||
const DIRECTIONS: ContractDirection[] = ['IMP', 'EXP', 'DOM'];
|
||||
const FREIGHTS: ContractFreight[] = ['CON', 'BULK'];
|
||||
const CURRENCIES = ['ETB', 'USD'] as const;
|
||||
const SERVICES: ContractServiceScope[] = ['TRANSPORT_ONLY', 'FORWARDING'];
|
||||
|
||||
const KEY_PATTERN =
|
||||
/^(IMP|EXP|DOM)_(CON|BULK)_(ETB|USD)_(TRANSPORT_ONLY|FORWARDING)$/;
|
||||
|
||||
function buildMeta(
|
||||
dir: ContractDirection,
|
||||
freight: ContractFreight,
|
||||
currency: string,
|
||||
service: ContractServiceScope,
|
||||
): ContractTemplateMeta {
|
||||
const key = `${dir}_${freight}_${currency}_${service}`;
|
||||
const dirLabel = DIRECTION_LABELS[dir];
|
||||
const freightLabel = FREIGHT_LABELS[freight];
|
||||
const serviceLabel =
|
||||
service === 'FORWARDING' ? 'Rail and Forwarding' : 'Transport Only';
|
||||
|
||||
const corridor =
|
||||
dir === 'IMP'
|
||||
? 'from SGTD railway freight station at Djibouti to Ethiopian dry ports and return of empty containers as applicable'
|
||||
: dir === 'EXP'
|
||||
? 'from Ethiopian dry ports to SGTD and related export corridors'
|
||||
: 'between designated Ethiopian rail terminals';
|
||||
|
||||
const clauses = buildClausePack(dir, freight, service);
|
||||
|
||||
return {
|
||||
key,
|
||||
direction: dir,
|
||||
freight,
|
||||
currency,
|
||||
serviceScope: service,
|
||||
title: `${dirLabel} ${freightLabel} Transport Service by Railway (${serviceLabel})`,
|
||||
directionLabel: dirLabel,
|
||||
freightLabel,
|
||||
whereas: `The Client has requested transportation of ${freightLabel.toLowerCase()} cargo ${corridor} using the Addis Ababa–Djibouti Railway line. The Service Provider has agreed to provide services per this contract.`,
|
||||
article1Objective: article1ObjectiveFromClause(clauses.article1),
|
||||
article1: clauses.article1,
|
||||
clientObligations: clauses.clientObligations,
|
||||
providerObligations: clauses.providerObligations,
|
||||
contractDocuments: clauses.contractDocuments,
|
||||
};
|
||||
}
|
||||
|
||||
/** Full template matrix (24 keys). */
|
||||
export const CONTRACT_TEMPLATE_REGISTRY: Record<string, ContractTemplateMeta> =
|
||||
{};
|
||||
|
||||
for (const dir of DIRECTIONS) {
|
||||
for (const freight of FREIGHTS) {
|
||||
for (const currency of CURRENCIES) {
|
||||
for (const service of SERVICES) {
|
||||
const meta = buildMeta(dir, freight, currency, service);
|
||||
CONTRACT_TEMPLATE_REGISTRY[meta.key] = meta;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const CONTRACT_TEMPLATE_KEYS = Object.keys(CONTRACT_TEMPLATE_REGISTRY);
|
||||
|
||||
export function listTemplateKeys(): string[] {
|
||||
return CONTRACT_TEMPLATE_KEYS;
|
||||
}
|
||||
|
||||
export function getTemplateMeta(key: string): ContractTemplateMeta {
|
||||
const found = CONTRACT_TEMPLATE_REGISTRY[key];
|
||||
if (found) return found;
|
||||
|
||||
const fallbackClauses = buildClausePack('IMP', 'CON', 'TRANSPORT_ONLY');
|
||||
return {
|
||||
key,
|
||||
direction: 'IMP',
|
||||
freight: 'CON',
|
||||
currency: 'USD',
|
||||
serviceScope: 'TRANSPORT_ONLY',
|
||||
title: 'Freight Contract Agreement',
|
||||
directionLabel: 'Freight',
|
||||
freightLabel: 'Cargo',
|
||||
whereas:
|
||||
'The parties agree to railway freight services as described in the schedule below.',
|
||||
article1Objective: article1ObjectiveFromClause(fallbackClauses.article1),
|
||||
article1: fallbackClauses.article1,
|
||||
clientObligations: fallbackClauses.clientObligations,
|
||||
providerObligations: fallbackClauses.providerObligations,
|
||||
contractDocuments: fallbackClauses.contractDocuments,
|
||||
};
|
||||
}
|
||||
|
||||
export function isValidTemplateKey(key: string): boolean {
|
||||
return KEY_PATTERN.test(key);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { ContractTemplateResolver } from './contract-template.resolver';
|
||||
import { Booking } from '../modules/bookings/entities/booking.entity';
|
||||
import { ServiceType } from '../modules/rule-engine/entities/service-type.entity';
|
||||
import { CargoType } from '../modules/rule-engine/entities/cargo-type.entity';
|
||||
|
||||
describe('ContractTemplateResolver', () => {
|
||||
const resolver = new ContractTemplateResolver();
|
||||
|
||||
function booking(partial: Partial<Booking>): Booking {
|
||||
return partial as Booking;
|
||||
}
|
||||
|
||||
it('resolves import container ETB transport-only', () => {
|
||||
const key = resolver.resolve(
|
||||
booking({
|
||||
tradeDirection: 'IMPORT',
|
||||
freightType: 'CONTAINER',
|
||||
paymentCurrency: 'ETB',
|
||||
serviceType: { code: 'RAIL_ONLY', includesFirstMile: false, includesLastMile: false } as ServiceType,
|
||||
}),
|
||||
);
|
||||
expect(key).toBe('IMP_CON_ETB_TRANSPORT_ONLY');
|
||||
});
|
||||
|
||||
it('resolves export bulk USD forwarding', () => {
|
||||
const key = resolver.resolve(
|
||||
booking({
|
||||
tradeDirection: 'EXPORT',
|
||||
freightType: 'BULK',
|
||||
paymentCurrency: 'USD',
|
||||
serviceType: {
|
||||
code: 'RAIL_FORWARDING',
|
||||
includesFirstMile: true,
|
||||
includesLastMile: false,
|
||||
} as ServiceType,
|
||||
}),
|
||||
);
|
||||
expect(key).toBe('EXP_BULK_USD_FORWARDING');
|
||||
});
|
||||
|
||||
it('maps BREAK_BULK cargo to BULK freight', () => {
|
||||
const key = resolver.resolve(
|
||||
booking({
|
||||
tradeDirection: 'IMPORT',
|
||||
freightType: 'CONTAINER',
|
||||
paymentCurrency: 'ETB',
|
||||
cargoType: { code: 'BREAK_BULK_GENERAL' } as CargoType,
|
||||
serviceType: undefined,
|
||||
}),
|
||||
);
|
||||
expect(key).toBe('IMP_BULK_ETB_TRANSPORT_ONLY');
|
||||
});
|
||||
|
||||
it('resolves domestic container', () => {
|
||||
const key = resolver.resolve(
|
||||
booking({
|
||||
tradeDirection: 'DOMESTIC',
|
||||
freightType: 'CONTAINER',
|
||||
paymentCurrency: 'USD',
|
||||
serviceType: undefined,
|
||||
}),
|
||||
);
|
||||
expect(key).toBe('DOM_CON_USD_TRANSPORT_ONLY');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { Booking } from '../modules/bookings/entities/booking.entity';
|
||||
import { ServiceType } from '../modules/rule-engine/entities/service-type.entity';
|
||||
import { CargoType } from '../modules/rule-engine/entities/cargo-type.entity';
|
||||
|
||||
@Injectable()
|
||||
export class ContractTemplateResolver {
|
||||
resolve(booking: Booking): string {
|
||||
const dir =
|
||||
booking.tradeDirection === 'IMPORT'
|
||||
? 'IMP'
|
||||
: booking.tradeDirection === 'EXPORT'
|
||||
? 'EXP'
|
||||
: 'DOM';
|
||||
|
||||
let freight = booking.freightType === 'BULK' ? 'BULK' : 'CON';
|
||||
const cargoCode = (booking.cargoType as CargoType | undefined)?.code ?? '';
|
||||
if (cargoCode.startsWith('BREAK_BULK')) {
|
||||
freight = 'BULK';
|
||||
}
|
||||
|
||||
const currency = booking.paymentCurrency === 'ETB' ? 'ETB' : 'USD';
|
||||
const service = this.resolveServiceScope(booking.serviceType);
|
||||
|
||||
return `${dir}_${freight}_${currency}_${service}`;
|
||||
}
|
||||
|
||||
private resolveServiceScope(
|
||||
serviceType?: ServiceType | null,
|
||||
): 'TRANSPORT_ONLY' | 'FORWARDING' {
|
||||
if (!serviceType) return 'TRANSPORT_ONLY';
|
||||
const code = (serviceType.code ?? '').toUpperCase();
|
||||
if (
|
||||
serviceType.includesFirstMile ||
|
||||
serviceType.includesLastMile ||
|
||||
code.includes('FORWARD')
|
||||
) {
|
||||
return 'FORWARDING';
|
||||
}
|
||||
return 'TRANSPORT_ONLY';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
export type ContractDirection = 'IMP' | 'EXP' | 'DOM';
|
||||
export type ContractFreight = 'CON' | 'BULK';
|
||||
export type ContractServiceScope = 'TRANSPORT_ONLY' | 'FORWARDING';
|
||||
|
||||
export interface Article1Clause {
|
||||
objective: string;
|
||||
scope: string[];
|
||||
}
|
||||
|
||||
export interface ContractClausePack {
|
||||
article1: Article1Clause;
|
||||
clientObligations: string[];
|
||||
providerObligations: string[];
|
||||
contractDocuments: string[];
|
||||
}
|
||||
|
||||
export interface ContractTemplateMeta {
|
||||
key: string;
|
||||
direction: ContractDirection;
|
||||
freight: ContractFreight;
|
||||
currency: string;
|
||||
serviceScope: ContractServiceScope;
|
||||
title: string;
|
||||
directionLabel: string;
|
||||
freightLabel: string;
|
||||
whereas: string;
|
||||
/** Summary line for APIs; mirrors article1.objective */
|
||||
article1Objective: string;
|
||||
article1: Article1Clause;
|
||||
clientObligations: string[];
|
||||
providerObligations: string[];
|
||||
contractDocuments: string[];
|
||||
/** Optional dedicated .hbs file; otherwise uses generic.hbs */
|
||||
templateFile?: string;
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
|
||||
import { BookingsRepository } from '../modules/bookings/bookings.repository';
|
||||
import { Booking } from '../modules/bookings/entities/booking.entity';
|
||||
import {
|
||||
BookingContractSignature,
|
||||
ContractSignerRole,
|
||||
} from '../modules/bookings/entities/booking-contract-signature.entity';
|
||||
import { ContractPricingScheduleBuilder, PricingSchedule } from './contract-pricing-schedule.builder';
|
||||
import { ContractTemplateResolver } from './contract-template.resolver';
|
||||
import { ContractTemplateMeta, getTemplateMeta } from './contract-template.registry';
|
||||
|
||||
export interface ContractSignatureView {
|
||||
role: ContractSignerRole;
|
||||
signerDisplayName: string;
|
||||
signedAt: string;
|
||||
signatureImageUrl?: string | null;
|
||||
}
|
||||
|
||||
export interface ContractViewModel {
|
||||
bookingId: string;
|
||||
reference: string;
|
||||
status: string;
|
||||
templateKey: string;
|
||||
template: ContractTemplateMeta;
|
||||
contractDate: string;
|
||||
contractYear: number;
|
||||
client: {
|
||||
companyName: string;
|
||||
companyAddress: string;
|
||||
companyLocation: string;
|
||||
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[];
|
||||
canSignCustomer: boolean;
|
||||
canSignStaff: boolean;
|
||||
hasContractDocument: boolean;
|
||||
hasCustomerSignature: boolean;
|
||||
hasStaffSignature: boolean;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ContractViewModelBuilder {
|
||||
constructor(
|
||||
private readonly bookingsRepository: BookingsRepository,
|
||||
private readonly templateResolver: ContractTemplateResolver,
|
||||
private readonly pricingBuilder: ContractPricingScheduleBuilder,
|
||||
) {}
|
||||
|
||||
async build(bookingId: string): Promise<{ booking: Booking; view: ContractViewModel }> {
|
||||
const booking = await this.bookingsRepository.findByIdWithFiles(bookingId);
|
||||
if (!booking) {
|
||||
throw new NotFoundException(`Booking ${bookingId} not found`);
|
||||
}
|
||||
|
||||
const templateKey =
|
||||
booking.contractTemplateKey ?? this.templateResolver.resolve(booking);
|
||||
const template = getTemplateMeta(templateKey);
|
||||
const pricing = await this.pricingBuilder.build(booking);
|
||||
const signatures = await this.loadSignatures(bookingId);
|
||||
|
||||
const hasCustomer = signatures.some((s) => s.role === 'CUSTOMER');
|
||||
const hasStaff = signatures.some((s) => s.role === 'STAFF');
|
||||
const hasContractFile = Boolean(
|
||||
booking.files?.some((f) => f.code === 'contract'),
|
||||
);
|
||||
|
||||
const view: ContractViewModel = {
|
||||
bookingId: booking.id,
|
||||
reference: booking.reference,
|
||||
status: booking.status,
|
||||
templateKey,
|
||||
template,
|
||||
contractDate: new Date().toLocaleDateString('en-GB', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
}),
|
||||
contractYear: new Date().getFullYear(),
|
||||
client: {
|
||||
companyName: booking.company?.name ?? 'Client',
|
||||
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:
|
||||
booking.status === 'CONTRACT_READY' && !hasCustomer,
|
||||
canSignStaff:
|
||||
booking.status === 'SIGNED_CUSTOMER' && hasCustomer && !hasStaff,
|
||||
hasContractDocument: hasContractFile,
|
||||
hasCustomerSignature: hasCustomer,
|
||||
hasStaffSignature: hasStaff,
|
||||
};
|
||||
|
||||
return { booking, view };
|
||||
}
|
||||
|
||||
private async loadSignatures(bookingId: string): Promise<ContractSignatureView[]> {
|
||||
const rows = await this.bookingsRepository.findContractSignatures(bookingId);
|
||||
return rows.map((s) => this.toSignatureView(s));
|
||||
}
|
||||
|
||||
toSignatureView(row: BookingContractSignature): ContractSignatureView {
|
||||
return {
|
||||
role: row.signerRole,
|
||||
signerDisplayName: row.signerDisplayName,
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<div class="article">
|
||||
<h2>Article 1: Objective and Scope of Services</h2>
|
||||
<p>
|
||||
<strong>1.1 Objective.</strong>
|
||||
{{template.article1.objective}}
|
||||
</p>
|
||||
{{#if template.article1.scope.length}}
|
||||
<p><strong>1.2 Scope of Services.</strong></p>
|
||||
<ol>
|
||||
{{#each template.article1.scope}}
|
||||
<li>{{this}}</li>
|
||||
{{/each}}
|
||||
</ol>
|
||||
{{/if}}
|
||||
</div>
|
||||
@@ -0,0 +1,66 @@
|
||||
<h2>Article 5: Contract Price and Terms of Payment</h2>
|
||||
<div class="article">
|
||||
<h3>Contract Price</h3>
|
||||
<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}}
|
||||
|
||||
<h3>Charges</h3>
|
||||
<table class="schedule">
|
||||
<thead>
|
||||
<tr><th>Item</th><th>Description</th><th>Amount</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{#each pricing.lineItems}}
|
||||
<tr>
|
||||
<td>{{label}}</td>
|
||||
<td>{{description}}</td>
|
||||
<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>
|
||||
<td>{{description}}</td>
|
||||
<td>{{currency}} {{amount}}</td>
|
||||
</tr>
|
||||
{{/each}}
|
||||
{{/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>
|
||||
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>
|
||||
@@ -0,0 +1,19 @@
|
||||
<div class="article">
|
||||
<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>
|
||||
{{/each}}
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div class="article">
|
||||
<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>
|
||||
{{/each}}
|
||||
</ol>
|
||||
</div>
|
||||
@@ -0,0 +1,9 @@
|
||||
<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>
|
||||
{{/each}}
|
||||
</ol>
|
||||
</div>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,12 @@
|
||||
<div class="article">
|
||||
<h2>Article 4: Force Majeure</h2>
|
||||
<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>
|
||||
@@ -0,0 +1,44 @@
|
||||
<div class="signatures">
|
||||
<div class="sig-block">
|
||||
<p class="sig-title">For the Service Provider</p>
|
||||
<p><strong>{{provider.name}}</strong></p>
|
||||
{{#if hasStaffSignature}}
|
||||
{{#each signatures}}
|
||||
{{#if (eq role "STAFF")}}
|
||||
<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}}
|
||||
<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 class="sig-title">For the Client</p>
|
||||
<p><strong>{{client.companyName}}</strong></p>
|
||||
{{#if hasCustomerSignature}}
|
||||
{{#each signatures}}
|
||||
{{#if (eq role "CUSTOMER")}}
|
||||
<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}}
|
||||
<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>
|
||||
@@ -0,0 +1,258 @@
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
@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>
|
||||
91
apps/edr-freight-api/src/contracts/templates/generic.hbs
Normal file
91
apps/edr-freight-api/src/contracts/templates/generic.hbs
Normal file
@@ -0,0 +1,91 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>{{template.title}} — {{reference}}</title>
|
||||
{{> styles}}
|
||||
</head>
|
||||
<body>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
20
apps/edr-freight-api/src/data-source.ts
Normal file
20
apps/edr-freight-api/src/data-source.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
// apps/edr-freight-api/src/data-source.ts
|
||||
import { DataSource } from 'typeorm';
|
||||
//import { ensurePostgresSchemas } from './utils/ensure-postgres-schemas'; // adjust path if needed
|
||||
|
||||
export const AppDataSource = new DataSource({
|
||||
type: 'postgres',
|
||||
host: 'localhost',
|
||||
port: 5432,
|
||||
username: 'postgres',
|
||||
password: '', // Laragon default: empty
|
||||
database: 'edr_freight',
|
||||
schema: 'freight', // default schema for entities without an explicit schema
|
||||
entities: [__dirname + '/**/*.entity{.ts,.js}'],
|
||||
migrations: [__dirname + '/migrations/*{.ts,.js}'],
|
||||
synchronize: false,
|
||||
logging: true,
|
||||
});
|
||||
|
||||
// Optional: call ensurePostgresSchemas before initializing
|
||||
// But you can also run it separately.
|
||||
@@ -43,10 +43,10 @@ export class MoveCustomersToFreightSchema1748900000000 implements MigrationInter
|
||||
CREATE INDEX IF NOT EXISTS "IDX_freight_customers_email"
|
||||
ON freight.customers (email);
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS "IDX_freight_customers_user_id"
|
||||
ON freight.customers (user_id);
|
||||
`);
|
||||
// await queryRunner.query(`
|
||||
// CREATE INDEX IF NOT EXISTS "IDX_freight_customers_user_id"
|
||||
// ON freight.customers (user_id);
|
||||
//`);
|
||||
|
||||
// Copy rows from public.customers when that legacy table exists
|
||||
await queryRunner.query(`
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class BookingFlowRefactor1749200000000 implements MigrationInterface {
|
||||
name = 'BookingFlowRefactor1749200000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.booking_review_note (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
booking_id UUID NOT NULL REFERENCES freight.bookings(id) ON DELETE CASCADE,
|
||||
author_id UUID,
|
||||
note TEXT NOT NULL,
|
||||
type VARCHAR(30) NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
deleted_at TIMESTAMPTZ
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_booking_review_note_booking_id
|
||||
ON freight.booking_review_note(booking_id);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
ADD COLUMN IF NOT EXISTS marketing_approved_by_id UUID,
|
||||
ADD COLUMN IF NOT EXISTS marketing_approved_at TIMESTAMPTZ,
|
||||
ADD COLUMN IF NOT EXISTS contract_summary TEXT,
|
||||
ADD COLUMN IF NOT EXISTS locked_at TIMESTAMPTZ;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.bookings SET status = 'SUBMITTED'
|
||||
WHERE status IN ('RFQ_SUBMITTED', 'QUOTATION_SENT', 'QUOTATION_APPROVED');
|
||||
UPDATE freight.bookings SET status = 'REJECTED'
|
||||
WHERE status = 'QUOTATION_REJECTED';
|
||||
UPDATE freight.bookings SET status = 'CANCELLED'
|
||||
WHERE status = 'CANCELLED';
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
DROP COLUMN IF EXISTS locked_at,
|
||||
DROP COLUMN IF EXISTS contract_summary,
|
||||
DROP COLUMN IF EXISTS marketing_approved_at,
|
||||
DROP COLUMN IF EXISTS marketing_approved_by_id;
|
||||
`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.booking_review_note;`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddBookingFreightType1749300000000 implements MigrationInterface {
|
||||
name = 'AddBookingFreightType1749300000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
ADD COLUMN IF NOT EXISTS freight_type VARCHAR(20);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.bookings b
|
||||
SET freight_type = 'CONTAINER'
|
||||
WHERE EXISTS (
|
||||
SELECT 1 FROM freight.booking_container bc WHERE bc.booking_id = b.id
|
||||
);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.bookings b
|
||||
SET freight_type = 'BULK'
|
||||
WHERE freight_type IS NULL
|
||||
AND b.cargo_type_id IS NOT NULL
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM freight.cargo_types ct
|
||||
WHERE ct.id = b.cargo_type_id AND ct.requires_director_approval = true
|
||||
);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.bookings
|
||||
SET freight_type = 'CONTAINER'
|
||||
WHERE freight_type IS NULL;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
ALTER COLUMN cargo_type_id DROP NOT NULL;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
ALTER COLUMN freight_type SET NOT NULL;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
ADD CONSTRAINT chk_bookings_freight_type
|
||||
CHECK (freight_type IN ('CONTAINER', 'BULK'));
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings DROP CONSTRAINT IF EXISTS chk_bookings_freight_type;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings DROP COLUMN IF EXISTS freight_type;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.bookings SET cargo_type_id = (
|
||||
SELECT id FROM freight.cargo_types LIMIT 1
|
||||
) WHERE cargo_type_id IS NULL;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
ALTER COLUMN cargo_type_id SET NOT NULL;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,20 @@
|
||||
// import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
// export class AddFanNumberToCompanies1749300000000 implements MigrationInterface {
|
||||
// name = 'AddFanNumberToCompanies1749300000000';
|
||||
export class AddFanNumberToCompanies1749300000000 implements MigrationInterface {
|
||||
name = 'AddFanNumberToCompanies1749300000000';
|
||||
|
||||
// public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
// await queryRunner.query(`
|
||||
// ALTER TABLE freight.companies
|
||||
// ADD COLUMN fan_number varchar(16) NULL;
|
||||
// `);
|
||||
// }
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
// fan_number may already exist when CreateCompaniesModule ran with the full schema
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.companies
|
||||
ADD COLUMN IF NOT EXISTS fan_number varchar(16) NULL;
|
||||
`);
|
||||
}
|
||||
|
||||
// public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
// await queryRunner.query(`
|
||||
// ALTER TABLE freight.companies
|
||||
// DROP COLUMN fan_number;
|
||||
// `);
|
||||
// }
|
||||
// }
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.companies
|
||||
DROP COLUMN IF EXISTS fan_number;
|
||||
`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddContractSignatures1749400000000 implements MigrationInterface {
|
||||
name = 'AddContractSignatures1749400000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
ADD COLUMN IF NOT EXISTS contract_template_key VARCHAR(80),
|
||||
ADD COLUMN IF NOT EXISTS contract_generated_at TIMESTAMPTZ,
|
||||
ADD COLUMN IF NOT EXISTS pricing_breakdown JSONB;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.booking_contract_signatures (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
booking_id UUID NOT NULL REFERENCES freight.bookings(id) ON DELETE CASCADE,
|
||||
signer_role VARCHAR(20) NOT NULL,
|
||||
signer_user_id UUID,
|
||||
signer_display_name VARCHAR(200) NOT NULL,
|
||||
signed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
signature_file_id UUID REFERENCES freight.files(id) ON DELETE SET NULL,
|
||||
consent_text TEXT,
|
||||
ip_address VARCHAR(64),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
deleted_at TIMESTAMPTZ,
|
||||
CONSTRAINT uq_booking_contract_signatures_role
|
||||
UNIQUE (booking_id, signer_role)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_booking_contract_signatures_booking_id
|
||||
ON freight.booking_contract_signatures(booking_id);
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.booking_contract_signatures;`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
DROP COLUMN IF EXISTS pricing_breakdown,
|
||||
DROP COLUMN IF EXISTS contract_generated_at,
|
||||
DROP COLUMN IF EXISTS contract_template_key;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddTrainScheduling1749400000000 implements MigrationInterface {
|
||||
name = 'AddTrainScheduling1749400000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.wagon_types (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
code VARCHAR(32) NOT NULL UNIQUE,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
capacity_tons NUMERIC(10,3) NOT NULL,
|
||||
length_meters NUMERIC(10,3) NOT NULL,
|
||||
max_wagons_per_train INT NULL,
|
||||
supported_load_types TEXT[] NOT NULL DEFAULT '{}',
|
||||
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
deleted_at TIMESTAMPTZ NULL
|
||||
);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.locomotives (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
code VARCHAR(32) NOT NULL UNIQUE,
|
||||
name VARCHAR(100) NULL,
|
||||
max_pull_weight_tons NUMERIC(10,3) NOT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'AVAILABLE',
|
||||
available_from TIMESTAMPTZ NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
deleted_at TIMESTAMPTZ NULL
|
||||
);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.train_sets (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
locomotive_id UUID NOT NULL,
|
||||
total_weight_tons NUMERIC(10,3) NOT NULL,
|
||||
total_length_meters NUMERIC(10,3) NOT NULL,
|
||||
wagon_count INT NOT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'DRAFT',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
deleted_at TIMESTAMPTZ NULL,
|
||||
CONSTRAINT fk_train_sets_locomotive FOREIGN KEY (locomotive_id)
|
||||
REFERENCES freight.locomotives(id)
|
||||
);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.train_set_wagons (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
train_set_id UUID NOT NULL,
|
||||
wagon_type_id UUID NOT NULL,
|
||||
sequence_no INT NOT NULL,
|
||||
capacity_tons NUMERIC(10,3) NOT NULL,
|
||||
length_meters NUMERIC(10,3) NOT NULL,
|
||||
assigned_weight_tons NUMERIC(10,3) NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
deleted_at TIMESTAMPTZ NULL,
|
||||
CONSTRAINT uq_train_set_wagons_sequence UNIQUE (train_set_id, sequence_no),
|
||||
CONSTRAINT fk_train_set_wagons_train_set FOREIGN KEY (train_set_id)
|
||||
REFERENCES freight.train_sets(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_train_set_wagons_wagon_type FOREIGN KEY (wagon_type_id)
|
||||
REFERENCES freight.wagon_types(id)
|
||||
);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.train_schedules (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
train_set_id UUID NOT NULL UNIQUE,
|
||||
origin_station_id UUID NOT NULL,
|
||||
destination_station_id UUID NOT NULL,
|
||||
scheduled_departure_date TIMESTAMPTZ NOT NULL,
|
||||
scheduled_arrival_date TIMESTAMPTZ NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'DRAFT',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
deleted_at TIMESTAMPTZ NULL,
|
||||
CONSTRAINT fk_train_schedules_train_set FOREIGN KEY (train_set_id)
|
||||
REFERENCES freight.train_sets(id),
|
||||
CONSTRAINT fk_train_schedules_origin FOREIGN KEY (origin_station_id)
|
||||
REFERENCES freight.yards(id),
|
||||
CONSTRAINT fk_train_schedules_destination FOREIGN KEY (destination_station_id)
|
||||
REFERENCES freight.yards(id)
|
||||
);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.train_schedule_bookings (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
train_schedule_id UUID NOT NULL,
|
||||
booking_id UUID NOT NULL UNIQUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
deleted_at TIMESTAMPTZ NULL,
|
||||
CONSTRAINT uq_train_schedule_booking UNIQUE (train_schedule_id, booking_id),
|
||||
CONSTRAINT fk_train_schedule_bookings_schedule FOREIGN KEY (train_schedule_id)
|
||||
REFERENCES freight.train_schedules(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_train_schedule_bookings_booking FOREIGN KEY (booking_id)
|
||||
REFERENCES freight.bookings(id)
|
||||
);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.wagon_booking_allocations (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
train_set_wagon_id UUID NOT NULL,
|
||||
booking_id UUID NOT NULL,
|
||||
allocated_weight_tons NUMERIC(10,3) NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
deleted_at TIMESTAMPTZ NULL,
|
||||
CONSTRAINT fk_wagon_booking_allocations_wagon FOREIGN KEY (train_set_wagon_id)
|
||||
REFERENCES freight.train_set_wagons(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_wagon_booking_allocations_booking FOREIGN KEY (booking_id)
|
||||
REFERENCES freight.bookings(id)
|
||||
);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_locomotives_status
|
||||
ON freight.locomotives(status);
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_train_sets_status
|
||||
ON freight.train_sets(status);
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_train_schedules_departure_status
|
||||
ON freight.train_schedules(scheduled_departure_date, status);
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_wagon_booking_allocations_booking
|
||||
ON freight.wagon_booking_allocations(booking_id);
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.wagon_booking_allocations;`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.train_schedule_bookings;`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.train_schedules;`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.train_set_wagons;`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.train_sets;`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.locomotives;`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.wagon_types;`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddCompanyIdToBookings1749500000000 implements MigrationInterface {
|
||||
name = 'AddCompanyIdToBookings1749500000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
ALTER COLUMN customer_id DROP NOT NULL;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
ADD COLUMN IF NOT EXISTS company_id UUID;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_bookings_company_id
|
||||
ON freight.bookings(company_id);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'FK_bookings_company_id'
|
||||
) THEN
|
||||
ALTER TABLE freight.bookings
|
||||
ADD CONSTRAINT "FK_bookings_company_id"
|
||||
FOREIGN KEY (company_id)
|
||||
REFERENCES freight.companies(id);
|
||||
END IF;
|
||||
END $$;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
DROP CONSTRAINT IF EXISTS "FK_bookings_company_id";
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.bookings SET customer_id = company_id WHERE customer_id IS NULL AND company_id IS NOT NULL;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
ALTER COLUMN customer_id SET NOT NULL;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
DROP INDEX IF EXISTS freight.idx_bookings_company_id;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
DROP COLUMN IF EXISTS company_id;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { MigrationInterface, QueryRunner, TableIndex } from 'typeorm';
|
||||
|
||||
/**
|
||||
* shipping_lines was created without a unique index on code; seeder upserts require it.
|
||||
*/
|
||||
export class AddShippingLinesCodeUniqueIndex1749800000000 implements MigrationInterface {
|
||||
name = 'AddShippingLinesCodeUniqueIndex1749800000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
const existing = await queryRunner.query(
|
||||
`SELECT 1 FROM pg_indexes WHERE schemaname = 'freight' AND tablename = 'shipping_lines' AND indexdef ILIKE '%UNIQUE%code%' LIMIT 1`,
|
||||
);
|
||||
if (existing.length === 0) {
|
||||
await queryRunner.createIndex(
|
||||
'freight.shipping_lines',
|
||||
new TableIndex({
|
||||
name: 'UQ_shipping_lines_code',
|
||||
columnNames: ['code'],
|
||||
isUnique: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.dropIndex('freight.shipping_lines', 'UQ_shipping_lines_code');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* file_upload_settings / file_upload_fields entities had no migration; seeder requires both tables.
|
||||
*/
|
||||
export class CreateFileUploadSettingsTables1749900000000 implements MigrationInterface {
|
||||
name = 'CreateFileUploadSettingsTables1749900000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.file_upload_settings (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
code VARCHAR(128) NOT NULL,
|
||||
label VARCHAR(256) NOT NULL,
|
||||
description TEXT,
|
||||
entity VARCHAR(32) NOT NULL DEFAULT 'other',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
deleted_at TIMESTAMPTZ
|
||||
);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_file_upload_settings_code"
|
||||
ON freight.file_upload_settings (code);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.file_upload_fields (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
setting_id UUID NOT NULL,
|
||||
file_key VARCHAR(128) NOT NULL,
|
||||
file_label VARCHAR(256) NOT NULL,
|
||||
help_text TEXT,
|
||||
is_required BOOLEAN NOT NULL DEFAULT false,
|
||||
is_multiple BOOLEAN NOT NULL DEFAULT false,
|
||||
max_files INTEGER NOT NULL DEFAULT 1,
|
||||
allowed_extensions TEXT[] NOT NULL DEFAULT '{}'::text[],
|
||||
max_size_mb INTEGER NOT NULL DEFAULT 10,
|
||||
display_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
deleted_at TIMESTAMPTZ,
|
||||
CONSTRAINT "CHK_file_upload_fields_max_files" CHECK (max_files > 0),
|
||||
CONSTRAINT "CHK_file_upload_fields_max_size_mb" CHECK (max_size_mb > 0),
|
||||
CONSTRAINT "FK_file_upload_fields_setting"
|
||||
FOREIGN KEY (setting_id)
|
||||
REFERENCES freight.file_upload_settings(id)
|
||||
ON DELETE CASCADE
|
||||
);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_file_upload_fields_setting_file_key"
|
||||
ON freight.file_upload_fields (setting_id, file_key);
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.file_upload_fields CASCADE`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.file_upload_settings CASCADE`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Train entity gained extended fields; baseline trains table only had code/capacity/status/notes.
|
||||
*/
|
||||
export class AddTrainExtendedColumns1750000000000 implements MigrationInterface {
|
||||
name = 'AddTrainExtendedColumns1750000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.trains
|
||||
ADD COLUMN IF NOT EXISTS train_number VARCHAR(20),
|
||||
ADD COLUMN IF NOT EXISTS train_name VARCHAR(100),
|
||||
ADD COLUMN IF NOT EXISTS route_id UUID,
|
||||
ADD COLUMN IF NOT EXISTS origin_station_id UUID,
|
||||
ADD COLUMN IF NOT EXISTS destination_station_id UUID,
|
||||
ADD COLUMN IF NOT EXISTS departure_time TIMESTAMPTZ,
|
||||
ADD COLUMN IF NOT EXISTS arrival_time TIMESTAMPTZ,
|
||||
ADD COLUMN IF NOT EXISTS locomotive_number VARCHAR(50),
|
||||
ADD COLUMN IF NOT EXISTS remarks TEXT;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_trains_train_number"
|
||||
ON freight.trains (train_number)
|
||||
WHERE train_number IS NOT NULL;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_trains_train_number"`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.trains
|
||||
DROP COLUMN IF EXISTS remarks,
|
||||
DROP COLUMN IF EXISTS locomotive_number,
|
||||
DROP COLUMN IF EXISTS arrival_time,
|
||||
DROP COLUMN IF EXISTS departure_time,
|
||||
DROP COLUMN IF EXISTS destination_station_id,
|
||||
DROP COLUMN IF EXISTS origin_station_id,
|
||||
DROP COLUMN IF EXISTS route_id,
|
||||
DROP COLUMN IF EXISTS train_name,
|
||||
DROP COLUMN IF EXISTS train_number;
|
||||
`);
|
||||
}
|
||||
}
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { Readable } from 'stream';
|
||||
|
||||
import { ContractPdfService } from '../../contracts/contract-pdf.service';
|
||||
import { ContractRendererService } from '../../contracts/contract-renderer.service';
|
||||
import { getTemplateMeta } from '../../contracts/contract-template.registry';
|
||||
import { ContractTemplateResolver } from '../../contracts/contract-template.resolver';
|
||||
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';
|
||||
import { ContractViewDto } from './dto/contract-view.dto';
|
||||
import { SignContractDto } from './dto/sign-contract.dto';
|
||||
import { ContractSignerRole } from './entities/booking-contract-signature.entity';
|
||||
|
||||
@Injectable()
|
||||
export class BookingContractService {
|
||||
constructor(
|
||||
private readonly bookingsRepository: BookingsRepository,
|
||||
private readonly filesService: FilesService,
|
||||
private readonly minioService: MinioService,
|
||||
private readonly templateResolver: ContractTemplateResolver,
|
||||
private readonly viewModelBuilder: ContractViewModelBuilder,
|
||||
private readonly renderer: ContractRendererService,
|
||||
private readonly pdfService: ContractPdfService,
|
||||
) {}
|
||||
|
||||
buildContractSummary(booking: Booking): string {
|
||||
const direction =
|
||||
booking.tradeDirection === 'IMPORT'
|
||||
? 'Import'
|
||||
: booking.tradeDirection === 'EXPORT'
|
||||
? 'Export'
|
||||
: booking.tradeDirection;
|
||||
|
||||
const cargo = booking.cargoType;
|
||||
const isBulk = booking.freightType === 'BULK';
|
||||
|
||||
let cargoLabel: string;
|
||||
if (isBulk) {
|
||||
cargoLabel = `Bulk (${booking.cargoFreeText || cargo?.cargoTypeName || 'Commodity'})`;
|
||||
} else {
|
||||
const lines =
|
||||
booking.bookingContainers?.map((bc) => {
|
||||
const label = bc.containerType?.label ?? bc.containerType?.code ?? 'Container';
|
||||
return `${bc.quantity}× ${label}`;
|
||||
}) ?? [];
|
||||
cargoLabel =
|
||||
lines.length > 0
|
||||
? `Container (${lines.join(', ')})`
|
||||
: 'Container (Standard)';
|
||||
}
|
||||
|
||||
return `Operation: ${direction} | Cargo Type: ${cargoLabel}`;
|
||||
}
|
||||
|
||||
async getSummary(bookingId: string): Promise<{ summary: string }> {
|
||||
const booking = await this.requireBooking(bookingId);
|
||||
const summary = booking.contractSummary ?? this.buildContractSummary(booking);
|
||||
return { summary };
|
||||
}
|
||||
|
||||
async getContractView(bookingId: string): Promise<ContractViewDto> {
|
||||
const { view } = await this.viewModelBuilder.build(bookingId);
|
||||
await this.inlineSignatureImages(view.signatures);
|
||||
const html = this.renderer.render(view);
|
||||
return {
|
||||
bookingId: view.bookingId,
|
||||
reference: view.reference,
|
||||
status: view.status,
|
||||
templateKey: view.templateKey,
|
||||
title: view.template.title,
|
||||
html,
|
||||
canSignCustomer: view.canSignCustomer,
|
||||
canSignStaff: view.canSignStaff,
|
||||
hasContractDocument: view.hasContractDocument,
|
||||
signatures: view.signatures,
|
||||
pricingSchedule: view.pricing as unknown as Record<string, unknown>,
|
||||
};
|
||||
}
|
||||
|
||||
async generateContract(bookingId: string): Promise<Booking> {
|
||||
const booking = await this.requireBooking(bookingId);
|
||||
assertBookingStatus(booking, ['APPROVED']);
|
||||
|
||||
const templateKey = this.templateResolver.resolve(booking);
|
||||
const summary = this.buildContractSummary(booking);
|
||||
await this.upsertContractPdf(bookingId, booking.reference, templateKey);
|
||||
|
||||
const now = new Date();
|
||||
const updated = await this.bookingsRepository.update(bookingId, {
|
||||
status: 'CONTRACT_READY',
|
||||
contractSummary: summary,
|
||||
contractTemplateKey: templateKey,
|
||||
contractGeneratedAt: now,
|
||||
} as never);
|
||||
return updated!;
|
||||
}
|
||||
|
||||
async streamContract(bookingId: string) {
|
||||
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(
|
||||
bookingId: string,
|
||||
dto: SignContractDto,
|
||||
options: { signerUserId?: string; ipAddress?: string },
|
||||
): Promise<Booking> {
|
||||
const booking = await this.requireBooking(bookingId);
|
||||
const role = dto.role as ContractSignerRole;
|
||||
|
||||
if (role === 'CUSTOMER') {
|
||||
assertBookingStatus(booking, ['CONTRACT_READY']);
|
||||
const existing = await this.bookingsRepository.findContractSignature(
|
||||
bookingId,
|
||||
'CUSTOMER',
|
||||
);
|
||||
if (existing) {
|
||||
throw new BadRequestException('Customer has already signed this contract');
|
||||
}
|
||||
} else {
|
||||
assertBookingStatus(booking, ['SIGNED_CUSTOMER']);
|
||||
const existing = await this.bookingsRepository.findContractSignature(
|
||||
bookingId,
|
||||
'STAFF',
|
||||
);
|
||||
if (existing) {
|
||||
throw new BadRequestException('Staff has already signed this contract');
|
||||
}
|
||||
}
|
||||
|
||||
const buffer = this.decodeSignatureImage(dto.signatureImageBase64);
|
||||
const sigFile: Express.Multer.File = {
|
||||
fieldname: `signature_${role.toLowerCase()}`,
|
||||
originalname: `signature-${role.toLowerCase()}-${booking.reference}.png`,
|
||||
encoding: '7bit',
|
||||
mimetype: 'image/png',
|
||||
size: buffer.length,
|
||||
buffer,
|
||||
stream: Readable.from(buffer),
|
||||
destination: '',
|
||||
filename: '',
|
||||
path: '',
|
||||
};
|
||||
|
||||
const fileRecord = await this.filesService.upsertByCode({
|
||||
resourceId: bookingId,
|
||||
resource: 'bookings',
|
||||
code: role === 'CUSTOMER' ? 'signature_customer' : 'signature_staff',
|
||||
file: sigFile,
|
||||
});
|
||||
|
||||
const now = new Date();
|
||||
await this.bookingsRepository.saveContractSignature({
|
||||
bookingId,
|
||||
signerRole: role,
|
||||
signerUserId: options.signerUserId ?? null,
|
||||
signerDisplayName: dto.signerDisplayName,
|
||||
signedAt: now,
|
||||
signatureFileId: fileRecord.id,
|
||||
consentText: dto.consentText ?? null,
|
||||
ipAddress: options.ipAddress ?? null,
|
||||
});
|
||||
|
||||
const updates: Record<string, unknown> = {};
|
||||
|
||||
if (role === 'CUSTOMER') {
|
||||
updates.status = 'SIGNED_CUSTOMER';
|
||||
updates.customerSignedAt = now;
|
||||
} else {
|
||||
updates.status = 'FULLY_EXECUTED';
|
||||
updates.fullyExecutedAt = now;
|
||||
updates.marketingApprovedAt = now;
|
||||
updates.marketingApprovedById = options.signerUserId ?? null;
|
||||
updates.lockedAt = now;
|
||||
}
|
||||
|
||||
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.inlineSignatureImages(views);
|
||||
return { signatures: views };
|
||||
}
|
||||
|
||||
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 {
|
||||
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 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 {
|
||||
const raw = base64.includes(',') ? base64.split(',')[1]! : base64;
|
||||
return Buffer.from(raw, 'base64');
|
||||
}
|
||||
|
||||
private async requireBooking(id: string): Promise<Booking> {
|
||||
const booking = await this.bookingsRepository.findByIdWithFiles(id);
|
||||
if (!booking) throw new NotFoundException(`Booking ${id} not found`);
|
||||
return booking;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
|
||||
import { FREIGHT_TYPES, FreightType } from './entities/booking.entity';
|
||||
import { BookingFreightShapeInput } from './dto/validators/booking-freight.validator';
|
||||
|
||||
/** Normalize and validate booking freight shape (used on create and after update merge). */
|
||||
export function assertFreightShape(input: BookingFreightShapeInput): void {
|
||||
if (!input.freightType || !FREIGHT_TYPES.includes(input.freightType as FreightType)) {
|
||||
throw new BadRequestException(
|
||||
`freightType is required and must be one of: ${FREIGHT_TYPES.join(', ')}`,
|
||||
);
|
||||
}
|
||||
//
|
||||
|
||||
const containers = input.containers ?? [];
|
||||
const hasContainers = containers.length > 0;
|
||||
const hasCargoType = Boolean(input.cargoTypeId);
|
||||
|
||||
if (input.freightType === 'BULK') {
|
||||
if (hasContainers) {
|
||||
throw new BadRequestException(
|
||||
'BULK freight cannot include container lines; use cargoTypeId only',
|
||||
);
|
||||
}
|
||||
if (!hasCargoType) {
|
||||
throw new BadRequestException('cargoTypeId is required for BULK freight');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasCargoType) {
|
||||
throw new BadRequestException('cargoTypeId is not allowed for CONTAINER freight');
|
||||
}
|
||||
if (!hasContainers) {
|
||||
throw new BadRequestException(
|
||||
'CONTAINER freight requires at least one container line with containerTypeId',
|
||||
);
|
||||
}
|
||||
for (const line of containers) {
|
||||
if (!line.containerTypeId) {
|
||||
throw new BadRequestException('Each container line must include containerTypeId');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
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';
|
||||
|
||||
export interface InAppPaymentReceipt extends InAppPaymentReceiptDto {}
|
||||
|
||||
@Injectable()
|
||||
export class BookingPaymentService {
|
||||
constructor(private readonly bookingsRepository: BookingsRepository) {}
|
||||
|
||||
async pay(
|
||||
bookingId: string,
|
||||
): Promise<{ booking: Booking; receipt: InAppPaymentReceipt }> {
|
||||
const booking = await this.requireBooking(bookingId);
|
||||
assertBookingStatus(booking, ['FULLY_EXECUTED']);
|
||||
|
||||
const receipt = this.buildMockReceipt(booking);
|
||||
|
||||
const updated = await this.bookingsRepository.update(bookingId, {
|
||||
status: 'PAID',
|
||||
paymentStatus: 'PAID',
|
||||
} as never);
|
||||
|
||||
return { booking: updated!, receipt };
|
||||
}
|
||||
|
||||
private buildMockReceipt(booking: Booking): InAppPaymentReceipt {
|
||||
const timestamp = Date.now();
|
||||
const isEtb = booking.paymentCurrency === 'ETB';
|
||||
const prefix = isEtb ? 'TB' : 'CARD';
|
||||
const provider = isEtb ? 'TELEBIRR' : 'CARD';
|
||||
|
||||
return {
|
||||
success: true,
|
||||
provider,
|
||||
providerRef: `${prefix}-${booking.reference}-${timestamp}`,
|
||||
amount: booking.totalAmount,
|
||||
currency: booking.paymentCurrency,
|
||||
paidAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
private async requireBooking(id: string): Promise<Booking> {
|
||||
const booking = await this.bookingsRepository.findById(id);
|
||||
if (!booking) throw new NotFoundException(`Booking ${id} not found`);
|
||||
return booking;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
|
||||
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
|
||||
import { RatesService } from '../rule-engine/services/rates.service';
|
||||
import { ServiceTypesService } from '../rule-engine/services/service-types.service';
|
||||
import { Rate } from '../rule-engine/entities/rate.entity';
|
||||
import {
|
||||
AppliedCargoModifier,
|
||||
BookingEvaluationInput,
|
||||
RuleEngineService,
|
||||
} from '../rule-engine/rule-engine.service';
|
||||
import { BookingsRepository } from './bookings.repository';
|
||||
import { GeneratePriceResponseDto, PriceLineItemDto } from './dto/generate-price-response.dto';
|
||||
import { Booking } from './entities/booking.entity';
|
||||
import { assertBookingStatus } from './booking-status.util';
|
||||
|
||||
@Injectable()
|
||||
export class BookingPricingService {
|
||||
constructor(
|
||||
private readonly bookingsRepository: BookingsRepository,
|
||||
private readonly ruleEngineService: RuleEngineService,
|
||||
private readonly containerTypesService: ContainerTypesService,
|
||||
private readonly ratesService: RatesService,
|
||||
private readonly serviceTypesService: ServiceTypesService,
|
||||
) {}
|
||||
|
||||
async generatePrice(bookingId: string): Promise<GeneratePriceResponseDto> {
|
||||
const booking = await this.requireBooking(bookingId);
|
||||
assertBookingStatus(booking, ['DRAFT']);
|
||||
|
||||
const evalInput = await this.buildEvalInputForBooking(booking);
|
||||
console.log('evalInput----', evalInput);
|
||||
const ruleResult = await this.ruleEngineService.evaluate(evalInput);
|
||||
this.ruleEngineService.assertNoHardBlocks(ruleResult);
|
||||
|
||||
const lineItems: PriceLineItemDto[] = [];
|
||||
let total = 0;
|
||||
|
||||
const baseLines = await this.computeBaseRailLines(booking, evalInput);
|
||||
for (const line of baseLines) {
|
||||
lineItems.push(line);
|
||||
total += line.amount;
|
||||
}
|
||||
|
||||
for (const mod of ruleResult.appliedModifiers) {
|
||||
const item: PriceLineItemDto = {
|
||||
code: mod.surchargeTypeCode,
|
||||
description: `Surcharge: ${mod.surchargeTypeCode}`,
|
||||
amount: mod.calculatedAmount,
|
||||
currency: mod.currency,
|
||||
};
|
||||
lineItems.push(item);
|
||||
total += mod.calculatedAmount;
|
||||
}
|
||||
|
||||
await this.persistPriceRun(bookingId, ruleResult.appliedModifiers, total);
|
||||
|
||||
await this.bookingsRepository.update(bookingId, {
|
||||
totalAmount: total,
|
||||
priorityScore: ruleResult.priorityScore,
|
||||
pricingBreakdown: {
|
||||
lineItems,
|
||||
totalAmount: total,
|
||||
currency: booking.paymentCurrency,
|
||||
generatedAt: new Date().toISOString(),
|
||||
},
|
||||
} as never);
|
||||
|
||||
return {
|
||||
bookingId,
|
||||
totalAmount: total,
|
||||
currency: booking.paymentCurrency,
|
||||
lineItems,
|
||||
warnings: ruleResult.warnings,
|
||||
};
|
||||
}
|
||||
|
||||
async buildEvalInputForBooking(booking: Booking): Promise<BookingEvaluationInput> {
|
||||
const containers = await Promise.all(
|
||||
(booking.bookingContainers ?? []).map(async (bc) => {
|
||||
const ct = await this.containerTypesService.findById(bc.containerTypeId);
|
||||
const vgm = Number(bc.vgmPerUnitTons);
|
||||
const qty = bc.quantity;
|
||||
return {
|
||||
containerTypeId: bc.containerTypeId,
|
||||
quantity: qty,
|
||||
vgmPerUnitTons: vgm,
|
||||
totalVgmTons: qty * vgm,
|
||||
isReefer: ct.isReefer,
|
||||
};
|
||||
}),
|
||||
);
|
||||
return {
|
||||
freightType: booking.freightType as 'CONTAINER' | 'BULK',
|
||||
cargoTypeId: booking.cargoTypeId ?? null,
|
||||
serviceTypeId: booking.serviceTypeId,
|
||||
paymentCurrency: booking.paymentCurrency,
|
||||
tradeDirection: booking.tradeDirection,
|
||||
isHazardous: booking.isHazardous,
|
||||
allowConsolidation: booking.allowConsolidation,
|
||||
shippingLineId: booking.shippingLineId,
|
||||
containers,
|
||||
};
|
||||
}
|
||||
|
||||
private async requireBooking(id: string): Promise<Booking> {
|
||||
const booking = await this.bookingsRepository.findByIdWithFiles(id);
|
||||
if (!booking) throw new NotFoundException(`Booking ${id} not found`);
|
||||
return booking;
|
||||
}
|
||||
|
||||
/** Line items for contract schedule (uses stored breakdown or recomputes). */
|
||||
async computeContractLineItems(booking: Booking): Promise<{
|
||||
lineItems: PriceLineItemDto[];
|
||||
totalAmount: number;
|
||||
currency: string;
|
||||
}> {
|
||||
const stored = booking.pricingBreakdown as {
|
||||
lineItems?: PriceLineItemDto[];
|
||||
totalAmount?: number;
|
||||
currency?: string;
|
||||
} | null;
|
||||
|
||||
if (stored?.lineItems?.length) {
|
||||
return {
|
||||
lineItems: stored.lineItems,
|
||||
totalAmount: Number(stored.totalAmount ?? booking.totalAmount),
|
||||
currency: stored.currency ?? booking.paymentCurrency,
|
||||
};
|
||||
}
|
||||
|
||||
const evalInput = await this.buildEvalInputForBooking(booking);
|
||||
const ruleResult = await this.ruleEngineService.evaluate(evalInput);
|
||||
const lineItems: PriceLineItemDto[] = [];
|
||||
let total = 0;
|
||||
|
||||
const baseLines = await this.computeBaseRailLines(booking, evalInput);
|
||||
for (const line of baseLines) {
|
||||
lineItems.push(line);
|
||||
total += line.amount;
|
||||
}
|
||||
|
||||
for (const mod of ruleResult.appliedModifiers) {
|
||||
lineItems.push({
|
||||
code: mod.surchargeTypeCode,
|
||||
description: `Surcharge: ${mod.surchargeTypeCode}`,
|
||||
amount: mod.calculatedAmount,
|
||||
currency: mod.currency,
|
||||
});
|
||||
total += mod.calculatedAmount;
|
||||
}
|
||||
|
||||
if (lineItems.length === 0) {
|
||||
total = Number(booking.totalAmount);
|
||||
lineItems.push({
|
||||
code: 'TOTAL',
|
||||
description: 'Contract total',
|
||||
amount: total,
|
||||
currency: booking.paymentCurrency,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
lineItems,
|
||||
totalAmount: total || Number(booking.totalAmount),
|
||||
currency: booking.paymentCurrency,
|
||||
};
|
||||
}
|
||||
|
||||
/** Recompute priority on submit (USD + service tier). */
|
||||
async computeSubmitPriorityScore(booking: Booking): Promise<number> {
|
||||
const evalInput = await this.buildEvalInputForBooking(booking);
|
||||
const ruleResult = await this.ruleEngineService.evaluate(evalInput);
|
||||
let score = ruleResult.priorityScore;
|
||||
|
||||
const serviceType = await this.serviceTypesService.findById(booking.serviceTypeId);
|
||||
if (booking.paymentCurrency === 'USD' && serviceType) {
|
||||
const code = (serviceType.code ?? '').toUpperCase();
|
||||
const hasForwarding =
|
||||
serviceType.includesFirstMile ||
|
||||
serviceType.includesLastMile ||
|
||||
code.includes('FORWARD') ||
|
||||
code.includes('Y');
|
||||
const railOnly = code.includes('RAIL') && !hasForwarding;
|
||||
|
||||
if (hasForwarding) score += 1000;
|
||||
else if (railOnly || code.includes('X')) score += 500;
|
||||
}
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
private async computeBaseRailLines(
|
||||
booking: Booking,
|
||||
evalInput: BookingEvaluationInput,
|
||||
): Promise<PriceLineItemDto[]> {
|
||||
const liveRates = await this.ratesService.findLiveRates();
|
||||
const currency = booking.paymentCurrency;
|
||||
const isBulk = booking.freightType === 'BULK';
|
||||
console.log('liveRates----', liveRates);
|
||||
const rateType =
|
||||
booking.tradeDirection === 'IMPORT'
|
||||
? isBulk
|
||||
? 'BULK_IMPORT'
|
||||
: 'CONTAINER_IMPORT'
|
||||
: booking.tradeDirection === 'EXPORT'
|
||||
? isBulk
|
||||
? 'BULK_EXPORT'
|
||||
: 'CONTAINER_EXPORT'
|
||||
: 'INTERCITY_CONTAINER';
|
||||
|
||||
|
||||
console.log('rateType----', rateType);
|
||||
|
||||
const lines: PriceLineItemDto[] = [];
|
||||
const wagonCount = await this.bookingsRepository.calculateWagonCount(booking.id);
|
||||
|
||||
for (const container of evalInput.containers) {
|
||||
console.log('container----', container);
|
||||
const rate = this.pickRate(liveRates, rateType, container.containerTypeId, currency);
|
||||
console.log('rate----', rate);
|
||||
if (!rate) continue;
|
||||
|
||||
const amount = this.amountForRate(rate, container.quantity, wagonCount);
|
||||
lines.push({
|
||||
code: rateType,
|
||||
description: `Base rail (${rateType})`,
|
||||
amount,
|
||||
currency: rate.currency,
|
||||
});
|
||||
}
|
||||
|
||||
if (lines.length === 0) {
|
||||
const fallback = liveRates.find(
|
||||
(r) => r.rateType === rateType && r.currency === currency && r.status === 'LIVE',
|
||||
);
|
||||
if (fallback) {
|
||||
const amount = this.amountForRate(fallback, 1, wagonCount);
|
||||
lines.push({
|
||||
code: rateType,
|
||||
description: `Base rail (${rateType})`,
|
||||
amount,
|
||||
currency: fallback.currency,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
private pickRate(
|
||||
rates: Rate[],
|
||||
rateType: string,
|
||||
containerTypeId: string,
|
||||
currency: string,
|
||||
): Rate | undefined {
|
||||
return (
|
||||
rates.find(
|
||||
(r) =>
|
||||
r.rateType === rateType &&
|
||||
r.currency === currency &&
|
||||
r.containerTypeId === containerTypeId,
|
||||
) ??
|
||||
rates.find((r) => r.rateType === rateType && r.currency === currency && !r.containerTypeId)
|
||||
);
|
||||
}
|
||||
|
||||
private amountForRate(rate: Rate, quantity: number, wagonCount: number): number {
|
||||
const value = Number(rate.rateValue);
|
||||
switch (rate.rateUnit) {
|
||||
case 'PER_CONTAINER':
|
||||
return value * quantity;
|
||||
case 'PER_WAGON':
|
||||
return value * wagonCount;
|
||||
case 'PER_TON':
|
||||
return value * quantity;
|
||||
case 'FLAT':
|
||||
return value;
|
||||
default:
|
||||
return value * quantity;
|
||||
}
|
||||
}
|
||||
|
||||
private async persistPriceRun(
|
||||
bookingId: string,
|
||||
modifiers: AppliedCargoModifier[],
|
||||
_total: number,
|
||||
): Promise<void> {
|
||||
await this.bookingsRepository.clearPricingArtifacts(bookingId);
|
||||
const snapshots = await this.ruleEngineService.snapshotLiveRates(bookingId);
|
||||
|
||||
const snapshotByRateId = new Map(snapshots.map((s) => [s.rateId, s.id]));
|
||||
const rows = modifiers
|
||||
.map((m) => {
|
||||
const snapshotId = snapshotByRateId.get(m.rateId);
|
||||
if (!snapshotId) return null;
|
||||
return {
|
||||
bookingId,
|
||||
surchargeTypeId: m.surchargeTypeId,
|
||||
triggerValue: m.triggerValue,
|
||||
calculatedAmount: m.calculatedAmount,
|
||||
rateSnapshotId: snapshotId,
|
||||
};
|
||||
})
|
||||
.filter((r): r is NonNullable<typeof r> => r !== null);
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.bookingsRepository.createCargoModifiers(rows);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { ConflictException } from '@nestjs/common';
|
||||
import { Booking } from './entities/booking.entity';
|
||||
|
||||
export function assertBookingStatus(booking: Booking, allowed: string[]): void {
|
||||
if (!allowed.includes(booking.status)) {
|
||||
throw new ConflictException(
|
||||
`Cannot perform this action on status "${booking.status}". Allowed: ${allowed.join(', ')}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
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';
|
||||
|
||||
@Injectable()
|
||||
export class BookingTransitionService {
|
||||
constructor(
|
||||
private readonly bookingsRepository: BookingsRepository,
|
||||
private readonly ruleEngineService: RuleEngineService,
|
||||
private readonly pricingService: BookingPricingService,
|
||||
private readonly contractService: BookingContractService,
|
||||
@Inject(forwardRef(() => BookingsService))
|
||||
private readonly bookingsService: BookingsService,
|
||||
) {}
|
||||
|
||||
async submit(bookingId: string): Promise<Booking> {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
assertBookingStatus(booking, ['DRAFT', 'CHANGES_REQUESTED']);
|
||||
|
||||
if (Number(booking.totalAmount) <= 0) {
|
||||
throw new BadRequestException(
|
||||
'Generate a price before submitting (POST /bookings/:id/generate-price)',
|
||||
);
|
||||
}
|
||||
|
||||
const priorityScore = await this.pricingService.computeSubmitPriorityScore(booking);
|
||||
await this.ruleEngineService.snapshotLiveRates(bookingId);
|
||||
|
||||
const updated = await this.bookingsRepository.update(bookingId, {
|
||||
status: 'SUBMITTED',
|
||||
priorityScore,
|
||||
} as never);
|
||||
return this.bookingsService.findById(updated!.id);
|
||||
}
|
||||
|
||||
async requestChanges(
|
||||
bookingId: string,
|
||||
note: string,
|
||||
actorId: string,
|
||||
): Promise<Booking> {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
assertBookingStatus(booking, ['SUBMITTED']);
|
||||
|
||||
await this.bookingsRepository.createReviewNote(
|
||||
bookingId,
|
||||
note,
|
||||
'CHANGES_REQUESTED',
|
||||
actorId,
|
||||
);
|
||||
|
||||
const updated = await this.bookingsRepository.update(bookingId, {
|
||||
status: 'CHANGES_REQUESTED',
|
||||
} as never);
|
||||
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']);
|
||||
|
||||
await this.ruleEngineService.instantiateApprovalSteps(bookingId, {
|
||||
freightType: booking.freightType as 'CONTAINER' | 'BULK',
|
||||
cargoTypeId: booking.cargoTypeId,
|
||||
});
|
||||
|
||||
const updated = await this.bookingsRepository.update(bookingId, {
|
||||
status: 'PENDING_APPROVAL',
|
||||
approvedByStaffId: actorId,
|
||||
approvedByStaffAt: new Date(),
|
||||
} as never);
|
||||
return this.bookingsService.findById(updated!.id);
|
||||
}
|
||||
|
||||
async staffReject(
|
||||
bookingId: string,
|
||||
reason: string,
|
||||
actorId: string,
|
||||
): Promise<Booking> {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
assertBookingStatus(booking, ['SUBMITTED', 'PENDING_APPROVAL']);
|
||||
|
||||
await this.bookingsRepository.createReviewNote(
|
||||
bookingId,
|
||||
reason,
|
||||
'REJECTION',
|
||||
actorId,
|
||||
);
|
||||
|
||||
const updated = await this.bookingsRepository.update(bookingId, {
|
||||
status: 'REJECTED',
|
||||
} as never);
|
||||
return this.bookingsService.findById(updated!.id);
|
||||
}
|
||||
|
||||
async approveStep(
|
||||
bookingId: string,
|
||||
stepId: string,
|
||||
actorId: string,
|
||||
requiredRole: string,
|
||||
authUser?: TCurrentUser,
|
||||
): Promise<Booking> {
|
||||
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,
|
||||
);
|
||||
if (!step || step.status !== 'PENDING') {
|
||||
throw new BadRequestException('Approval step not found or already actioned');
|
||||
}
|
||||
|
||||
const next = await this.bookingsRepository.findNextPendingApprovalStep(bookingId);
|
||||
if (!next || next.id !== step.id) {
|
||||
throw new BadRequestException(
|
||||
'Approval steps must be completed in order',
|
||||
);
|
||||
}
|
||||
|
||||
if (step.requiredRole !== requiredRole) {
|
||||
throw new BadRequestException(
|
||||
`Step requires role ${step.requiredRole}, not ${requiredRole}`,
|
||||
);
|
||||
}
|
||||
|
||||
const blocksRole = step.blocksRole;
|
||||
if (blocksRole && blocksRole === requiredRole) {
|
||||
throw new BadRequestException(`Role ${requiredRole} is blocked for this step`);
|
||||
}
|
||||
|
||||
await this.bookingsRepository.completeApprovalStep(step.id, actorId, 'APPROVED');
|
||||
|
||||
const updates: Record<string, unknown> = {};
|
||||
const now = new Date();
|
||||
|
||||
if (requiredRole === 'LINE_STAFF') {
|
||||
updates.status = 'APPROVED_PENDING_SIGNATURE';
|
||||
updates.approvedByStaffId = actorId;
|
||||
updates.approvedByStaffAt = now;
|
||||
} else if (requiredRole === 'DIRECTOR') {
|
||||
updates.signedByDirectorId = actorId;
|
||||
updates.signedByDirectorAt = now;
|
||||
} else if (requiredRole === 'CEO') {
|
||||
updates.signedByCeoId = actorId;
|
||||
updates.signedByCeoAt = now;
|
||||
}
|
||||
|
||||
const allDone = await this.bookingsRepository.allApprovalStepsComplete(bookingId);
|
||||
if (allDone) {
|
||||
updates.status = 'APPROVED';
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length > 0) {
|
||||
await this.bookingsRepository.update(bookingId, updates as never);
|
||||
}
|
||||
|
||||
return this.bookingsService.findById(bookingId);
|
||||
}
|
||||
|
||||
async rejectStep(
|
||||
bookingId: string,
|
||||
stepId: string,
|
||||
actorId: string,
|
||||
reason: string,
|
||||
): Promise<Booking> {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
assertBookingStatus(booking, ['PENDING_APPROVAL', 'APPROVED_PENDING_SIGNATURE']);
|
||||
|
||||
const step = await this.bookingsRepository.findApprovalStepById(
|
||||
bookingId,
|
||||
stepId,
|
||||
);
|
||||
if (!step) throw new BadRequestException('Approval step not found');
|
||||
|
||||
await this.bookingsRepository.completeApprovalStep(
|
||||
step.id,
|
||||
actorId,
|
||||
'REJECTED',
|
||||
reason,
|
||||
);
|
||||
|
||||
await this.bookingsRepository.createReviewNote(
|
||||
bookingId,
|
||||
reason,
|
||||
'REJECTION',
|
||||
actorId,
|
||||
);
|
||||
|
||||
const updated = await this.bookingsRepository.update(bookingId, {
|
||||
status: 'REJECTED',
|
||||
} as never);
|
||||
return this.bookingsService.findById(updated!.id);
|
||||
}
|
||||
|
||||
async customerSign(bookingId: string): Promise<Booking> {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
assertBookingStatus(booking, ['CONTRACT_READY']);
|
||||
|
||||
const updated = await this.bookingsRepository.update(bookingId, {
|
||||
status: 'SIGNED_CUSTOMER',
|
||||
customerSignedAt: new Date(),
|
||||
} as never);
|
||||
return this.bookingsService.findById(updated!.id);
|
||||
}
|
||||
|
||||
async marketingApprove(bookingId: string, actorId: string): Promise<Booking> {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
assertBookingStatus(booking, ['SIGNED_CUSTOMER']);
|
||||
|
||||
const updated = await this.bookingsRepository.update(bookingId, {
|
||||
status: 'FULLY_EXECUTED',
|
||||
fullyExecutedAt: new Date(),
|
||||
marketingApprovedById: actorId,
|
||||
marketingApprovedAt: new Date(),
|
||||
lockedAt: new Date(),
|
||||
} as never);
|
||||
return this.bookingsService.findById(updated!.id);
|
||||
}
|
||||
|
||||
async startTransit(bookingId: string): Promise<Booking> {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
assertBookingStatus(booking, ['PAID']);
|
||||
|
||||
const updated = await this.bookingsRepository.update(bookingId, {
|
||||
status: 'IN_TRANSIT',
|
||||
} as never);
|
||||
return this.bookingsService.findById(updated!.id);
|
||||
}
|
||||
|
||||
async complete(bookingId: string): Promise<Booking> {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
assertBookingStatus(booking, ['IN_TRANSIT']);
|
||||
|
||||
const updated = await this.bookingsRepository.update(bookingId, {
|
||||
status: 'COMPLETED',
|
||||
endDate: new Date(),
|
||||
} as never);
|
||||
return this.bookingsService.findById(updated!.id);
|
||||
}
|
||||
|
||||
async cancel(bookingId: string, reason: string): Promise<Booking> {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
assertBookingStatus(booking, [
|
||||
'DRAFT',
|
||||
'SUBMITTED',
|
||||
'CHANGES_REQUESTED',
|
||||
'PENDING_APPROVAL',
|
||||
'CONTRACT_READY',
|
||||
]);
|
||||
|
||||
await this.bookingsRepository.createReviewNote(
|
||||
bookingId,
|
||||
reason,
|
||||
'REJECTION',
|
||||
);
|
||||
|
||||
const updated = await this.bookingsRepository.update(bookingId, {
|
||||
status: 'CANCELLED',
|
||||
} as never);
|
||||
return this.bookingsService.findById(updated!.id);
|
||||
}
|
||||
|
||||
async enrichBookingResponse(booking: Booking): Promise<Booking & {
|
||||
latestChangeRequestNote?: string | null;
|
||||
contractSummary?: string | null;
|
||||
nextStep: BookingNextStep | null;
|
||||
}> {
|
||||
const note = await this.bookingsRepository.findLatestReviewNote(
|
||||
booking.id,
|
||||
'CHANGES_REQUESTED',
|
||||
);
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -10,10 +10,15 @@ import {
|
||||
Post,
|
||||
Query,
|
||||
Request,
|
||||
Res,
|
||||
UploadedFiles,
|
||||
UseInterceptors,
|
||||
} from "@nestjs/common";
|
||||
import { AnyFilesInterceptor } from "@nestjs/platform-express";
|
||||
} from '@nestjs/common';
|
||||
import { CurrentUser } from '@edr/api-common';
|
||||
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,
|
||||
ApiBody,
|
||||
@@ -21,181 +26,392 @@ import {
|
||||
ApiOkResponse,
|
||||
ApiOperation,
|
||||
ApiTags,
|
||||
} from "@nestjs/swagger";
|
||||
} from '@nestjs/swagger';
|
||||
import type { Response } from 'express';
|
||||
|
||||
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 { FilterBookingDto } from "./dto/filter-booking.dto";
|
||||
import { UpdateBookingDto } from "./dto/update-booking.dto";
|
||||
import { UpdateStatusDto } from "./dto/update-status.dto";
|
||||
import { BookingContractService } from './booking-contract.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 {
|
||||
ApproveStepDto,
|
||||
CancelBookingDto,
|
||||
RejectStepDto,
|
||||
RequestChangesDto,
|
||||
StaffRejectDto,
|
||||
} from './dto/request-changes.dto';
|
||||
import { ContractViewDto } from './dto/contract-view.dto';
|
||||
import { SignContractDto } from './dto/sign-contract.dto';
|
||||
import { UpdateBookingDto } from './dto/update-booking.dto';
|
||||
import {
|
||||
type AuthUserPayload,
|
||||
resolveAuthUserId,
|
||||
} from '../../common/resolve-auth-user-id';
|
||||
|
||||
@ApiTags("bookings")
|
||||
@Controller("bookings")
|
||||
@ApiTags('bookings')
|
||||
@Controller('bookings')
|
||||
@ApiBearerAuth()
|
||||
export class BookingsController {
|
||||
constructor(
|
||||
private readonly bookingsService: BookingsService,
|
||||
private readonly bookingReferenceDataService: BookingReferenceDataService,
|
||||
private readonly pricingService: BookingPricingService,
|
||||
private readonly transitionService: BookingTransitionService,
|
||||
private readonly contractService: BookingContractService,
|
||||
) {}
|
||||
|
||||
// ── 1. Create booking (multipart/form-data) ──────────────────────────
|
||||
@Post()
|
||||
@UseInterceptors(AnyFilesInterceptor())
|
||||
@ApiConsumes("multipart/form-data")
|
||||
@ApiOperation({
|
||||
summary: "Create a new freight booking",
|
||||
description:
|
||||
"Accepts all booking fields as form fields + dynamic file keys (e.g. passport, license). " +
|
||||
"Auto-enables consolidation when container quantity does not fill a whole wagon; attempts partner match or PENDING_CONSOLIDATION.",
|
||||
})
|
||||
@ApiBody({
|
||||
description:
|
||||
"Booking form data. Attach files with any field name (e.g. passport, tin_certificate). " +
|
||||
"Each uploaded file is saved as a row in the files table (resource=bookings).",
|
||||
type: CreateBookingDto,
|
||||
})
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiOperation({ summary: 'Create a new freight booking (DRAFT)' })
|
||||
@ApiBody({ type: CreateBookingDto })
|
||||
create(
|
||||
@Body() dto: CreateBookingDto,
|
||||
@UploadedFiles() files: Express.Multer.File[],
|
||||
@Request() req: any,
|
||||
@Request() req: { user?: { id?: string; sub?: string } },
|
||||
) {
|
||||
console.log(
|
||||
"[BookingsController] Files received:",
|
||||
files?.length,
|
||||
files?.map((f) => ({
|
||||
fieldname: f.fieldname,
|
||||
originalname: f.originalname,
|
||||
size: f.size,
|
||||
mimetype: f.mimetype,
|
||||
})),
|
||||
);
|
||||
const userId: string | undefined = req.user?.id ?? req.user?.sub;
|
||||
const userId = req.user?.id ?? req.user?.sub;
|
||||
return this.bookingsService.create(dto, files ?? [], userId);
|
||||
}
|
||||
|
||||
// ── 2. Update draft booking (multipart/form-data) ─────────────────────
|
||||
@Patch(":id")
|
||||
@Patch(':id')
|
||||
@UseInterceptors(AnyFilesInterceptor())
|
||||
@ApiConsumes("multipart/form-data")
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiOperation({
|
||||
summary: "Update a draft booking",
|
||||
description:
|
||||
"Only DRAFT bookings can be updated. New files are merged into existing documents.",
|
||||
summary: 'Update booking',
|
||||
description: 'Allowed when status is DRAFT or CHANGES_REQUESTED.',
|
||||
})
|
||||
@ApiBody({ type: UpdateBookingDto })
|
||||
update(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: UpdateBookingDto,
|
||||
@UploadedFiles() files: Express.Multer.File[],
|
||||
) {
|
||||
return this.bookingsService.update(id, dto, files ?? []);
|
||||
}
|
||||
|
||||
// ── 3. List bookings (paginated + filtered) ───────────────────────────
|
||||
@Get()
|
||||
@ApiOperation({
|
||||
summary: "List freight bookings (paginated)",
|
||||
description:
|
||||
"Filter by status, customerId, contractType, serviceTypeId, cargoTypeId, tradeDirection, " +
|
||||
"paymentCurrency, allowConsolidation, consolidationPaired. " +
|
||||
"Sort by createdAt or priorityScore.",
|
||||
})
|
||||
@ApiOperation({ summary: 'List freight bookings (paginated)' })
|
||||
findAll(@Query() filter: FilterBookingDto) {
|
||||
return this.bookingsService.findAll(filter);
|
||||
}
|
||||
|
||||
// ── Booking form catalog (must be before :id) ─────────────────────────
|
||||
@Get("reference-data")
|
||||
@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: "Booking form catalog",
|
||||
description:
|
||||
"Returns yards, container types (grouped by size), service types, shipping lines, " +
|
||||
"and hierarchical cargo types for the booking UI in a single payload.",
|
||||
summary: 'List bookings for a dashboard queue',
|
||||
description: 'Queues: intake, approval, signatures, marketing, finance',
|
||||
})
|
||||
findQueue(
|
||||
@Param('queue') queue: string,
|
||||
@Query() filter: FilterBookingDto,
|
||||
@Query('excludeBulk') excludeBulk?: string,
|
||||
) {
|
||||
return this.bookingsService.findQueue(queue, filter, {
|
||||
excludeBulk: excludeBulk === 'true',
|
||||
});
|
||||
}
|
||||
|
||||
@Get('reference-data')
|
||||
@ApiOperation({ summary: 'Booking form catalog' })
|
||||
@ApiOkResponse({ type: BookingReferenceDataDto })
|
||||
getReferenceData(): Promise<BookingReferenceDataDto> {
|
||||
return this.bookingReferenceDataService.getReferenceData();
|
||||
}
|
||||
|
||||
// ── 5. Lookup by reference (must be before :id to avoid conflict) ─────
|
||||
@Get("by-reference/:reference")
|
||||
@ApiOperation({
|
||||
summary: "Get a freight booking by reference number",
|
||||
description: "Lookup booking by its human-readable reference string.",
|
||||
})
|
||||
findByReference(@Param("reference") reference: string) {
|
||||
return this.bookingsService.findByReference(reference);
|
||||
@Get('by-reference/:reference')
|
||||
@ApiOperation({ summary: 'Get booking by reference' })
|
||||
async findByReference(@Param('reference') reference: string) {
|
||||
const booking = await this.bookingsService.findByReference(reference);
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
// ── 4. Get single booking by ID ───────────────────────────────────────
|
||||
@Get(":id")
|
||||
@ApiOperation({ summary: "Get a freight booking by ID" })
|
||||
findOne(@Param("id", ParseUUIDPipe) id: string) {
|
||||
return this.bookingsService.findById(id);
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get booking by ID' })
|
||||
async findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||
const booking = await this.bookingsService.findById(id);
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
// ── 6. Soft-delete (DRAFT only) ───────────────────────────────────────
|
||||
@Delete(":id")
|
||||
@Delete(':id')
|
||||
@HttpCode(204)
|
||||
@ApiOperation({
|
||||
summary: "Soft-delete a freight booking",
|
||||
description: "Only DRAFT bookings can be deleted.",
|
||||
})
|
||||
remove(@Param("id", ParseUUIDPipe) id: string) {
|
||||
@ApiOperation({ summary: 'Soft-delete DRAFT booking' })
|
||||
remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.bookingsService.remove(id);
|
||||
}
|
||||
|
||||
// ── 7. Unified status transition ──────────────────────────────────────
|
||||
@Patch(":id/status")
|
||||
@ApiOperation({
|
||||
summary: "Transition booking status",
|
||||
description:
|
||||
"Unified endpoint for all status transitions. Actions: " +
|
||||
"SUBMIT, APPROVE_STAFF, APPROVE_DIRECTOR, APPROVE_CEO, REJECT, CANCEL, ACTIVATE, EXPIRE. " +
|
||||
"Approval routing: Standard → LINE_STAFF → DIRECTOR → SIGNED. " +
|
||||
"Bulk/high-volume → DIRECTOR → CEO → SIGNED.",
|
||||
})
|
||||
updateStatus(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() dto: UpdateStatusDto,
|
||||
@Post(':id/documents')
|
||||
@UseInterceptors(AnyFilesInterceptor())
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiOperation({ summary: 'Upload documents for a booking (DRAFT only)' })
|
||||
async uploadDocuments(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@UploadedFiles() files: Express.Multer.File[],
|
||||
) {
|
||||
return this.bookingsService.updateStatus(id, dto);
|
||||
const booking = await this.bookingsService.uploadDocuments(id, files ?? []);
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
// ── 8. Request or auto-pair consolidation ─────────────────────────────
|
||||
@Post(":id/consolidation")
|
||||
@Post(':id/generate-price')
|
||||
@ApiOperation({ summary: 'Generate price preview (DRAFT only)' })
|
||||
@ApiOkResponse({ type: GeneratePriceResponseDto })
|
||||
generatePrice(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.pricingService.generatePrice(id);
|
||||
}
|
||||
|
||||
@Post(':id/submit')
|
||||
@ApiOperation({ summary: 'Customer submit booking' })
|
||||
async submit(@Param('id', ParseUUIDPipe) id: string) {
|
||||
const booking = await this.transitionService.submit(id);
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(':id/staff/request-changes')
|
||||
@BookingStaff(FREIGHT_PERMS.bookings.requestChanges)
|
||||
@ApiOperation({ summary: 'Staff return booking for customer updates' })
|
||||
async requestChanges(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: RequestChangesDto,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
const booking = await this.transitionService.requestChanges(
|
||||
id,
|
||||
dto.note,
|
||||
resolveAuthUserId(user),
|
||||
);
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(':id/staff/accept')
|
||||
@BookingStaff(FREIGHT_PERMS.bookings.staffAccept)
|
||||
@ApiOperation({ summary: 'Staff accept intake → start approval chain' })
|
||||
async acceptIntake(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
const booking = await this.transitionService.acceptIntake(
|
||||
id,
|
||||
resolveAuthUserId(user),
|
||||
);
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(':id/staff/reject')
|
||||
@BookingStaff(FREIGHT_PERMS.bookings.reject)
|
||||
@ApiOperation({ summary: 'Staff final reject' })
|
||||
async staffReject(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: StaffRejectDto,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
const booking = await this.transitionService.staffReject(
|
||||
id,
|
||||
dto.reason,
|
||||
resolveAuthUserId(user),
|
||||
);
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(':id/approval-steps/:stepId/approve')
|
||||
@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: 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')
|
||||
@BookingStaff(FREIGHT_PERMS.bookings.rejectApproval)
|
||||
@ApiOperation({ summary: 'Reject at approval step' })
|
||||
async rejectStep(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param('stepId', ParseUUIDPipe) stepId: string,
|
||||
@Body() dto: RejectStepDto,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
const booking = await this.transitionService.rejectStep(
|
||||
id,
|
||||
stepId,
|
||||
resolveAuthUserId(user),
|
||||
dto.reason,
|
||||
);
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(':id/contract/generate')
|
||||
@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);
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Get(':id/contract/view')
|
||||
@ApiOkResponse({ type: ContractViewDto })
|
||||
@ApiOperation({ summary: 'Contract HTML view for portal and backoffice' })
|
||||
getContractView(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.contractService.getContractView(id);
|
||||
}
|
||||
|
||||
@Get(':id/contract/document')
|
||||
@ApiOperation({ summary: 'Download contract PDF' })
|
||||
async downloadContractDocument(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Res() res: Response,
|
||||
): Promise<void> {
|
||||
const { stream, record } = await this.contractService.streamContract(id);
|
||||
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() res: Response,
|
||||
): Promise<void> {
|
||||
return this.downloadContractDocument(id, res);
|
||||
}
|
||||
|
||||
@Post(':id/contract/sign')
|
||||
@ApiOperation({ summary: 'Apply digital signature (customer or staff)' })
|
||||
async signContract(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: SignContractDto,
|
||||
@Request() req: { user?: { id?: string; sub?: string }; ip?: string },
|
||||
) {
|
||||
const userId = req.user?.id ?? req.user?.sub;
|
||||
const booking = await this.contractService.signContract(id, dto, {
|
||||
signerUserId: userId,
|
||||
ipAddress: req.ip,
|
||||
});
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Get(':id/contract/signatures')
|
||||
@ApiOperation({ summary: 'List contract signatures' })
|
||||
getContractSignatures(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.contractService.getSignatures(id);
|
||||
}
|
||||
|
||||
@Get(':id/summary')
|
||||
@ApiOperation({ summary: 'Contract summary string for dashboard' })
|
||||
getSummary(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.contractService.getSummary(id);
|
||||
}
|
||||
|
||||
@Post(':id/customer/sign')
|
||||
@ApiOperation({
|
||||
summary: "Request freight consolidation",
|
||||
description:
|
||||
"Searches for a partner whose container quantity complements yours to fill whole wagon(s) " +
|
||||
"(same route, same container type). Pairs on match or sets PENDING_CONSOLIDATION with a status message.",
|
||||
summary: 'Customer digital signature (deprecated — use POST contract/sign)',
|
||||
})
|
||||
requestConsolidation(@Param("id", ParseUUIDPipe) id: string) {
|
||||
async customerSign(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: SignContractDto,
|
||||
@Request() req: { user?: { id?: string; sub?: string }; ip?: string },
|
||||
) {
|
||||
const payload: SignContractDto = { ...dto, role: 'CUSTOMER' };
|
||||
const booking = await this.contractService.signContract(id, payload, {
|
||||
signerUserId: req.user?.id ?? req.user?.sub,
|
||||
ipAddress: req.ip,
|
||||
});
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(':id/marketing/approve')
|
||||
@BookingStaff(FREIGHT_PERMS.bookings.signStaff)
|
||||
@ApiOperation({
|
||||
summary: 'Staff contract signature and fully execute (use contract/sign STAFF preferred)',
|
||||
})
|
||||
async marketingApprove(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: SignContractDto,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
@Request() req: { ip?: string },
|
||||
) {
|
||||
const payload: SignContractDto = {
|
||||
...dto,
|
||||
role: 'STAFF',
|
||||
};
|
||||
const booking = await this.contractService.signContract(id, payload, {
|
||||
signerUserId: resolveAuthUserId(user),
|
||||
ipAddress: req.ip,
|
||||
});
|
||||
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);
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@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);
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(':id/cancel')
|
||||
@BookingStaff(FREIGHT_PERMS.bookings.cancel)
|
||||
@ApiOperation({ summary: 'Cancel booking' })
|
||||
async cancel(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: CancelBookingDto,
|
||||
) {
|
||||
const booking = await this.transitionService.cancel(id, dto.reason);
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(':id/consolidation')
|
||||
@ApiOperation({ summary: 'Request freight consolidation' })
|
||||
requestConsolidation(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.bookingsService.requestConsolidation(id);
|
||||
}
|
||||
|
||||
// ── 9. Remove consolidation pairing ───────────────────────────────────
|
||||
@Delete(":id/consolidation")
|
||||
@ApiOperation({
|
||||
summary: "Remove consolidation pairing",
|
||||
description:
|
||||
"Unpairs both bookings and returns them to PENDING_CONSOLIDATION status.",
|
||||
})
|
||||
removeConsolidation(@Param("id", ParseUUIDPipe) id: string) {
|
||||
@Delete(':id/consolidation')
|
||||
@ApiOperation({ summary: 'Remove consolidation pairing' })
|
||||
removeConsolidation(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.bookingsService.removeConsolidation(id);
|
||||
}
|
||||
|
||||
// ── 10. Get consolidation details ─────────────────────────────────────
|
||||
@Get(":id/consolidation")
|
||||
@ApiOperation({
|
||||
summary: "Get consolidation details",
|
||||
description:
|
||||
"Returns partner booking details and split billing information.",
|
||||
})
|
||||
getConsolidationDetails(@Param("id", ParseUUIDPipe) id: string) {
|
||||
@Get(':id/consolidation')
|
||||
@ApiOperation({ summary: 'Get consolidation details' })
|
||||
getConsolidationDetails(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.bookingsService.getConsolidationDetails(id);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { CustomersModule } from '../customers/customers.module';
|
||||
// import { CustomersModule } from '../customers/customers.module';
|
||||
import { CompaniesModule } from '../companies/companies.module';
|
||||
import { FilesModule } from '../files/files.module';
|
||||
import { MinioModule } from '../minio/minio.module';
|
||||
import { RuleEngineModule } from '../rule-engine/rule-engine.module';
|
||||
import { BookingContractService } from './booking-contract.service';
|
||||
import { BookingPaymentService } from './booking-payment.service';
|
||||
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';
|
||||
@@ -14,7 +20,14 @@ import { BookingApprovalStep } from './entities/booking-approval-step.entity';
|
||||
import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity';
|
||||
import { BookingContainer } from './entities/booking-container.entity';
|
||||
import { BookingRateSnapshot } from './entities/booking-rate-snapshot.entity';
|
||||
import { BookingContractSignature } from './entities/booking-contract-signature.entity';
|
||||
import { BookingReviewNote } from './entities/booking-review-note.entity';
|
||||
import { Booking } from './entities/booking.entity';
|
||||
import { ContractPdfService } from '../../contracts/contract-pdf.service';
|
||||
import { ContractPricingScheduleBuilder } from '../../contracts/contract-pricing-schedule.builder';
|
||||
import { ContractRendererService } from '../../contracts/contract-renderer.service';
|
||||
import { ContractTemplateResolver } from '../../contracts/contract-template.resolver';
|
||||
import { ContractViewModelBuilder } from '../../contracts/contract-view-model.builder';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -24,19 +37,31 @@ import { Booking } from './entities/booking.entity';
|
||||
BookingCargoModifier,
|
||||
BookingApprovalStep,
|
||||
BookingRateSnapshot,
|
||||
BookingReviewNote,
|
||||
BookingContractSignature,
|
||||
]),
|
||||
FilesModule,
|
||||
MinioModule,
|
||||
CustomersModule,
|
||||
CompaniesModule,
|
||||
// CustomersModule,
|
||||
RuleEngineModule,
|
||||
],
|
||||
controllers: [BookingsController],
|
||||
controllers: [BookingsController, PayController],
|
||||
providers: [
|
||||
BookingsService,
|
||||
BookingsRepository,
|
||||
ConsolidationService,
|
||||
BookingReferenceDataService,
|
||||
BookingPricingService,
|
||||
BookingTransitionService,
|
||||
BookingContractService,
|
||||
BookingPaymentService,
|
||||
ContractTemplateResolver,
|
||||
ContractViewModelBuilder,
|
||||
ContractPricingScheduleBuilder,
|
||||
ContractRendererService,
|
||||
ContractPdfService,
|
||||
],
|
||||
exports: [BookingsService],
|
||||
exports: [BookingsService, BookingsRepository],
|
||||
})
|
||||
export class BookingsModule {}
|
||||
|
||||
@@ -1,17 +1,36 @@
|
||||
import { BaseRepository } from '@edr/api-common';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { DataSource, 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';
|
||||
import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity';
|
||||
import { BookingContainer } from './entities/booking-container.entity';
|
||||
import { BookingRateSnapshot } from './entities/booking-rate-snapshot.entity';
|
||||
import { BookingReviewNote, ReviewNoteType } from './entities/booking-review-note.entity';
|
||||
import { Booking } from './entities/booking.entity';
|
||||
import {
|
||||
BookingContractSignature,
|
||||
ContractSignerRole,
|
||||
} from './entities/booking-contract-signature.entity';
|
||||
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(
|
||||
@@ -56,7 +75,8 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
.createQueryBuilder('booking')
|
||||
.leftJoinAndSelect('booking.bookingContainers', 'bc')
|
||||
.leftJoinAndSelect('bc.containerType', 'ct')
|
||||
.leftJoinAndSelect('booking.customer', 'customer')
|
||||
.leftJoinAndSelect('booking.company', 'company')
|
||||
// .leftJoinAndSelect('booking.customer', 'customer')
|
||||
.leftJoinAndSelect('booking.train', 'train')
|
||||
.leftJoinAndSelect('booking.serviceType', 'st')
|
||||
.leftJoinAndSelect('booking.cargoType', 'cargo')
|
||||
@@ -66,6 +86,7 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
.leftJoinAndSelect('booking.approvalSteps', 'steps')
|
||||
.leftJoinAndSelect('booking.rateSnapshots', 'snapshots')
|
||||
.leftJoinAndSelect('booking.cargoModifiers', 'modifiers')
|
||||
.leftJoinAndSelect('booking.reviewNotes', 'reviewNotes')
|
||||
.where('booking.id = :id', { id })
|
||||
.leftJoinAndMapMany(
|
||||
'booking.files',
|
||||
@@ -216,15 +237,33 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
await this.dataSource.getRepository(BookingContainer).delete({ bookingId });
|
||||
}
|
||||
|
||||
/** Get pending approval step for a role. */
|
||||
/** Lowest-order pending approval step (sequential enforcement). */
|
||||
async findNextPendingApprovalStep(
|
||||
bookingId: string,
|
||||
): Promise<BookingApprovalStep | null> {
|
||||
return this.dataSource.getRepository(BookingApprovalStep).findOne({
|
||||
where: { bookingId, status: 'PENDING' },
|
||||
order: { stepOrder: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
async findApprovalStepById(
|
||||
bookingId: string,
|
||||
stepId: string,
|
||||
): Promise<BookingApprovalStep | null> {
|
||||
return this.dataSource.getRepository(BookingApprovalStep).findOne({
|
||||
where: { bookingId, id: stepId },
|
||||
});
|
||||
}
|
||||
|
||||
/** Get pending approval step for a role (must match next in sequence). */
|
||||
async findPendingApprovalStep(
|
||||
bookingId: string,
|
||||
requiredRole: string,
|
||||
): Promise<BookingApprovalStep | null> {
|
||||
return this.dataSource.getRepository(BookingApprovalStep).findOne({
|
||||
where: { bookingId, requiredRole, status: 'PENDING' },
|
||||
order: { stepOrder: 'ASC' },
|
||||
});
|
||||
const next = await this.findNextPendingApprovalStep(bookingId);
|
||||
if (!next || next.requiredRole !== requiredRole) return null;
|
||||
return next;
|
||||
}
|
||||
|
||||
/** Mark an approval step complete. */
|
||||
@@ -277,4 +316,273 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
where: { bookingId, rateId },
|
||||
});
|
||||
}
|
||||
|
||||
async createReviewNote(
|
||||
bookingId: string,
|
||||
note: string,
|
||||
type: ReviewNoteType,
|
||||
authorId?: string,
|
||||
): Promise<BookingReviewNote> {
|
||||
const repo = this.dataSource.getRepository(BookingReviewNote);
|
||||
return repo.save(
|
||||
repo.create({ bookingId, note, type, authorId: authorId ?? null }),
|
||||
);
|
||||
}
|
||||
|
||||
async findLatestReviewNote(
|
||||
bookingId: string,
|
||||
type?: ReviewNoteType,
|
||||
): Promise<BookingReviewNote | null> {
|
||||
const repo = this.dataSource.getRepository(BookingReviewNote);
|
||||
return repo.findOne({
|
||||
where: type ? { bookingId, type } : { bookingId },
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
async clearPricingArtifacts(bookingId: string): Promise<void> {
|
||||
await this.dataSource.getRepository(BookingCargoModifier).delete({ bookingId });
|
||||
await this.dataSource.getRepository(BookingRateSnapshot).delete({ bookingId });
|
||||
}
|
||||
|
||||
/** Queue listing with optional bulk exclusion for LINE_STAFF. */
|
||||
async findQueue(options: {
|
||||
status: string | string[];
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
excludeBulk?: boolean;
|
||||
sortBy?: string;
|
||||
sortOrder?: 'ASC' | 'DESC';
|
||||
}): Promise<{ items: Booking[]; total: number }> {
|
||||
const page = options.page ?? 1;
|
||||
const pageSize = options.pageSize ?? 20;
|
||||
const statuses = Array.isArray(options.status) ? options.status : [options.status];
|
||||
|
||||
const qb = this.repository
|
||||
.createQueryBuilder('booking')
|
||||
.leftJoinAndSelect('booking.company', 'company')
|
||||
.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) {
|
||||
qb.andWhere("booking.freight_type = 'CONTAINER'");
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
/** 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;
|
||||
order: Record<string, 'ASC' | 'DESC'>;
|
||||
}): Promise<[Booking[], number]> {
|
||||
return this.repository.findAndCount({
|
||||
where,
|
||||
skip: options.skip,
|
||||
take: options.take,
|
||||
order: options.order,
|
||||
});
|
||||
}
|
||||
|
||||
findContractSignatures(bookingId: string): Promise<BookingContractSignature[]> {
|
||||
return this.dataSource.getRepository(BookingContractSignature).find({
|
||||
where: { bookingId },
|
||||
relations: ['signatureFile'],
|
||||
order: { signedAt: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
findContractSignature(
|
||||
bookingId: string,
|
||||
role: ContractSignerRole,
|
||||
): Promise<BookingContractSignature | null> {
|
||||
return this.dataSource.getRepository(BookingContractSignature).findOne({
|
||||
where: { bookingId, signerRole: role },
|
||||
relations: ['signatureFile'],
|
||||
});
|
||||
}
|
||||
|
||||
async saveContractSignature(
|
||||
data: Partial<BookingContractSignature>,
|
||||
): Promise<BookingContractSignature> {
|
||||
const repo = this.dataSource.getRepository(BookingContractSignature);
|
||||
const existing = await repo.findOne({
|
||||
where: {
|
||||
bookingId: data.bookingId!,
|
||||
signerRole: data.signerRole!,
|
||||
},
|
||||
});
|
||||
if (existing) {
|
||||
Object.assign(existing, data);
|
||||
return repo.save(existing);
|
||||
}
|
||||
return repo.save(repo.create(data));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,9 +4,8 @@ import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { IsNull, Not } from 'typeorm';
|
||||
|
||||
import { CustomersService } from '../customers/customers.service';
|
||||
// import { CustomersService } from '../customers/customers.service';
|
||||
import { CompaniesService } from '../companies/companies.service';
|
||||
import { FilesService } from '../files/files.service';
|
||||
import { MinioService } from '../minio/minio.service';
|
||||
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
|
||||
@@ -16,20 +15,35 @@ import {
|
||||
} from '../rule-engine/rule-engine.service';
|
||||
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 { UpdateStatusDto } from './dto/update-status.dto';
|
||||
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(
|
||||
private readonly bookingsRepository: BookingsRepository,
|
||||
private readonly filesService: FilesService,
|
||||
private readonly minioService: MinioService,
|
||||
private readonly customersService: CustomersService,
|
||||
// private readonly customersService: CustomersService,
|
||||
private readonly companiesService: CompaniesService,
|
||||
private readonly ruleEngineService: RuleEngineService,
|
||||
private readonly containerTypesService: ContainerTypesService,
|
||||
private readonly consolidationService: ConsolidationService,
|
||||
@@ -42,22 +56,23 @@ export class BookingsService {
|
||||
return `BK-${year}-${String(count + 1).padStart(6, '0')}`;
|
||||
}
|
||||
|
||||
/** Build evaluation input from DTO containers. */
|
||||
private async buildEvalInput(
|
||||
dto: Pick<
|
||||
CreateBookingDto,
|
||||
| 'cargoTypeId'
|
||||
| 'serviceTypeId'
|
||||
| 'paymentCurrency'
|
||||
| 'tradeDirection'
|
||||
| 'isHazardous'
|
||||
| 'allowConsolidation'
|
||||
| 'shippingLineId'
|
||||
| 'containers'
|
||||
>,
|
||||
): Promise<BookingEvaluationInput> {
|
||||
/** Build evaluation input from booking freight shape. */
|
||||
private async buildEvalInput(dto: {
|
||||
freightType: FreightType;
|
||||
cargoTypeId?: string | null;
|
||||
serviceTypeId: string;
|
||||
paymentCurrency: string;
|
||||
tradeDirection: string;
|
||||
isHazardous?: boolean;
|
||||
allowConsolidation?: boolean;
|
||||
shippingLineId?: string | null;
|
||||
containers: CreateBookingContainerDto[];
|
||||
}): Promise<BookingEvaluationInput> {
|
||||
const containerLines =
|
||||
dto.freightType === 'CONTAINER' ? dto.containers : [];
|
||||
|
||||
const containers = await Promise.all(
|
||||
dto.containers.map(async (c) => {
|
||||
containerLines.map(async (c) => {
|
||||
const ct = await this.containerTypesService.findById(c.containerTypeId);
|
||||
const totalVgmTons = c.quantity * c.vgmPerUnitTons;
|
||||
return {
|
||||
@@ -69,13 +84,16 @@ export class BookingsService {
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
cargoTypeId: dto.cargoTypeId,
|
||||
freightType: dto.freightType,
|
||||
cargoTypeId: dto.cargoTypeId ?? null,
|
||||
serviceTypeId: dto.serviceTypeId,
|
||||
paymentCurrency: dto.paymentCurrency,
|
||||
tradeDirection: dto.tradeDirection,
|
||||
isHazardous: dto.isHazardous ?? false,
|
||||
allowConsolidation: dto.allowConsolidation,
|
||||
allowConsolidation:
|
||||
dto.freightType === 'CONTAINER' ? dto.allowConsolidation : false,
|
||||
shippingLineId: dto.shippingLineId,
|
||||
containers,
|
||||
};
|
||||
@@ -149,24 +167,52 @@ export class BookingsService {
|
||||
): Promise<{ booking: Booking; warnings: string[] }> {
|
||||
const warnings: string[] = [];
|
||||
|
||||
let customerId = dto.customerId;
|
||||
if (!customerId) {
|
||||
// let customerId = dto.customerId;
|
||||
// if (!customerId) {
|
||||
// if (!userId) {
|
||||
// throw new BadRequestException(
|
||||
// 'customerId is required or must be resolvable from auth token',
|
||||
// );
|
||||
// }
|
||||
// const customer = await this.customersService.findByUserId(userId);
|
||||
// customerId = customer.id;
|
||||
// }
|
||||
|
||||
let companyId = dto.companyId;
|
||||
if (!companyId) {
|
||||
if (!userId) {
|
||||
throw new BadRequestException(
|
||||
'customerId is required or must be resolvable from auth token',
|
||||
'companyId is required or must be resolvable from auth token',
|
||||
);
|
||||
}
|
||||
const customer = await this.customersService.findByUserId(userId);
|
||||
customerId = customer.id;
|
||||
const { company } = await this.companiesService.getCompanyInfoByUserId(userId);
|
||||
companyId = company.id;
|
||||
}
|
||||
|
||||
const reference = dto.reference || (await this.generateReference());
|
||||
const allowConsolidation = await this.resolveConsolidation(
|
||||
dto.containers,
|
||||
dto.allowConsolidation,
|
||||
);
|
||||
const containers = dto.containers ?? [];
|
||||
assertFreightShape({
|
||||
freightType: dto.freightType,
|
||||
cargoTypeId: dto.cargoTypeId,
|
||||
containers,
|
||||
});
|
||||
|
||||
const evalInput = await this.buildEvalInput({ ...dto, allowConsolidation });
|
||||
const allowConsolidation =
|
||||
dto.freightType === 'CONTAINER'
|
||||
? await this.resolveConsolidation(containers, dto.allowConsolidation)
|
||||
: false;
|
||||
|
||||
const evalInput = await this.buildEvalInput({
|
||||
freightType: dto.freightType as FreightType,
|
||||
cargoTypeId: dto.freightType === 'BULK' ? dto.cargoTypeId : null,
|
||||
serviceTypeId: dto.serviceTypeId,
|
||||
paymentCurrency: dto.paymentCurrency,
|
||||
tradeDirection: dto.tradeDirection,
|
||||
isHazardous: dto.isHazardous,
|
||||
allowConsolidation,
|
||||
shippingLineId: dto.shippingLineId,
|
||||
containers,
|
||||
});
|
||||
const ruleResult = await this.ruleEngineService.evaluate(evalInput);
|
||||
this.ruleEngineService.assertNoHardBlocks(ruleResult);
|
||||
|
||||
@@ -174,7 +220,7 @@ export class BookingsService {
|
||||
|
||||
const booking = await this.bookingsRepository.create({
|
||||
reference,
|
||||
customerId,
|
||||
companyId,
|
||||
trainId: dto.trainId,
|
||||
contractType: dto.contractType,
|
||||
previousContractId: dto.previousContractId,
|
||||
@@ -185,7 +231,8 @@ export class BookingsService {
|
||||
originYardId: dto.originYardId,
|
||||
destinationYardId: dto.destinationYardId,
|
||||
tradeDirection: dto.tradeDirection,
|
||||
cargoTypeId: dto.cargoTypeId,
|
||||
freightType: dto.freightType,
|
||||
cargoTypeId: dto.freightType === 'BULK' ? dto.cargoTypeId! : null,
|
||||
cargoFreeText: dto.cargoFreeText,
|
||||
shippingLineId: dto.shippingLineId,
|
||||
cargoTotalWeightVgm: dto.cargoTotalWeightVgm,
|
||||
@@ -203,18 +250,19 @@ export class BookingsService {
|
||||
paymentStatus: 'PENDING',
|
||||
});
|
||||
|
||||
await this.bookingsRepository.createContainers(
|
||||
booking.id,
|
||||
dto.containers.map((c, i) => ({
|
||||
containerTypeId: c.containerTypeId,
|
||||
quantity: c.quantity,
|
||||
vgmPerUnitTons: c.vgmPerUnitTons,
|
||||
weightResult: ruleResult.containerWeightResults[i],
|
||||
})),
|
||||
);
|
||||
|
||||
const wagonCount = await this.bookingsRepository.calculateWagonCount(booking.id);
|
||||
warnings.push(`Estimated wagons required: ${wagonCount}`);
|
||||
if (dto.freightType === 'CONTAINER') {
|
||||
await this.bookingsRepository.createContainers(
|
||||
booking.id,
|
||||
containers.map((c, i) => ({
|
||||
containerTypeId: c.containerTypeId,
|
||||
quantity: c.quantity,
|
||||
vgmPerUnitTons: c.vgmPerUnitTons,
|
||||
weightResult: ruleResult.containerWeightResults[i],
|
||||
})),
|
||||
);
|
||||
const wagonCount = await this.bookingsRepository.calculateWagonCount(booking.id);
|
||||
warnings.push(`Estimated wagons required: ${wagonCount}`);
|
||||
}
|
||||
|
||||
if (files.length > 0) {
|
||||
try {
|
||||
@@ -242,24 +290,51 @@ export class BookingsService {
|
||||
files: Express.Multer.File[],
|
||||
): Promise<{ booking: Booking; warnings: string[] }> {
|
||||
const existing = await this.findById(id);
|
||||
if (existing.status !== 'DRAFT') {
|
||||
throw new BadRequestException('Only DRAFT bookings can be updated');
|
||||
if (!CUSTOMER_EDITABLE_STATUSES.includes(existing.status as never)) {
|
||||
throw new BadRequestException(
|
||||
'Only DRAFT or CHANGES_REQUESTED bookings can be updated',
|
||||
);
|
||||
}
|
||||
|
||||
const warnings: string[] = [];
|
||||
const containers = dto.containers ?? existing.bookingContainers?.map((bc) => ({
|
||||
containerTypeId: bc.containerTypeId,
|
||||
quantity: bc.quantity,
|
||||
vgmPerUnitTons: Number(bc.vgmPerUnitTons),
|
||||
})) ?? [];
|
||||
const freightType = (dto.freightType ?? existing.freightType) as FreightType;
|
||||
let containers =
|
||||
dto.containers ??
|
||||
existing.bookingContainers?.map((bc) => ({
|
||||
containerTypeId: bc.containerTypeId,
|
||||
quantity: bc.quantity,
|
||||
vgmPerUnitTons: Number(bc.vgmPerUnitTons),
|
||||
})) ??
|
||||
[];
|
||||
|
||||
const allowConsolidation = await this.resolveConsolidation(
|
||||
containers,
|
||||
dto.allowConsolidation ?? existing.allowConsolidation,
|
||||
);
|
||||
let cargoTypeId =
|
||||
dto.cargoTypeId !== undefined ? dto.cargoTypeId : existing.cargoTypeId;
|
||||
|
||||
if (freightType === 'BULK') {
|
||||
containers = [];
|
||||
if (dto.containers !== undefined) {
|
||||
await this.bookingsRepository.deleteContainers(id);
|
||||
}
|
||||
} else {
|
||||
cargoTypeId = null;
|
||||
if (dto.cargoTypeId !== undefined && dto.cargoTypeId !== null) {
|
||||
throw new BadRequestException('cargoTypeId is not allowed for CONTAINER freight');
|
||||
}
|
||||
}
|
||||
|
||||
assertFreightShape({ freightType, cargoTypeId, containers });
|
||||
|
||||
const allowConsolidation =
|
||||
freightType === 'CONTAINER'
|
||||
? await this.resolveConsolidation(
|
||||
containers,
|
||||
dto.allowConsolidation ?? existing.allowConsolidation,
|
||||
)
|
||||
: false;
|
||||
|
||||
const evalInput = await this.buildEvalInput({
|
||||
cargoTypeId: dto.cargoTypeId ?? existing.cargoTypeId,
|
||||
freightType,
|
||||
cargoTypeId,
|
||||
serviceTypeId: dto.serviceTypeId ?? existing.serviceTypeId,
|
||||
paymentCurrency: dto.paymentCurrency ?? existing.paymentCurrency,
|
||||
tradeDirection: dto.tradeDirection ?? existing.tradeDirection,
|
||||
@@ -275,6 +350,8 @@ export class BookingsService {
|
||||
|
||||
const updates: Record<string, unknown> = {
|
||||
...dto,
|
||||
freightType,
|
||||
cargoTypeId: freightType === 'BULK' ? cargoTypeId : null,
|
||||
allowConsolidation,
|
||||
priorityScore: ruleResult.priorityScore,
|
||||
};
|
||||
@@ -285,7 +362,7 @@ export class BookingsService {
|
||||
|
||||
await this.bookingsRepository.update(id, updates);
|
||||
|
||||
if (dto.containers) {
|
||||
if (freightType === 'CONTAINER' && dto.containers) {
|
||||
await this.bookingsRepository.deleteContainers(id);
|
||||
await this.bookingsRepository.createContainers(
|
||||
id,
|
||||
@@ -313,40 +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.contractType) where.contractType = filter.contractType;
|
||||
if (filter.serviceTypeId) where.serviceTypeId = filter.serviceTypeId;
|
||||
if (filter.cargoTypeId) where.cargoTypeId = filter.cargoTypeId;
|
||||
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 },
|
||||
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. */
|
||||
@@ -359,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 };
|
||||
}),
|
||||
@@ -369,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) {
|
||||
@@ -382,6 +502,21 @@ export class BookingsService {
|
||||
return this.findById(booking.id);
|
||||
}
|
||||
|
||||
/** Upload documents for a DRAFT booking. */
|
||||
async uploadDocuments(
|
||||
id: string,
|
||||
files: Express.Multer.File[],
|
||||
): Promise<Booking> {
|
||||
const booking = await this.findById(id);
|
||||
if (booking.status !== 'DRAFT') {
|
||||
throw new BadRequestException(
|
||||
'Documents can only be uploaded for DRAFT bookings',
|
||||
);
|
||||
}
|
||||
await this.filesService.uploadMany(id, 'bookings', files);
|
||||
return this.findById(id);
|
||||
}
|
||||
|
||||
async remove(id: string): Promise<void> {
|
||||
const booking = await this.findById(id);
|
||||
if (booking.status !== 'DRAFT') {
|
||||
@@ -390,201 +525,33 @@ export class BookingsService {
|
||||
await this.bookingsRepository.softDelete(id);
|
||||
}
|
||||
|
||||
/** Unified status transition handler. */
|
||||
async updateStatus(id: string, dto: UpdateStatusDto): Promise<Booking> {
|
||||
const booking = await this.findById(id);
|
||||
const { action, actorId, reason, requiredRole } = dto;
|
||||
async findQueue(
|
||||
queue: string,
|
||||
filter: FilterBookingDto,
|
||||
options?: { excludeBulk?: boolean },
|
||||
): Promise<{ items: Booking[]; total: number }> {
|
||||
const statusMap: Record<string, string | string[]> = {
|
||||
intake: 'SUBMITTED',
|
||||
approval: ['PENDING_APPROVAL', 'APPROVED_PENDING_SIGNATURE'],
|
||||
signatures: ['APPROVED_PENDING_SIGNATURE', 'PENDING_APPROVAL'],
|
||||
contract: ['APPROVED', 'CONTRACT_READY', 'SIGNED_CUSTOMER', 'FULLY_EXECUTED'],
|
||||
marketing: 'SIGNED_CUSTOMER',
|
||||
finance: 'FULLY_EXECUTED',
|
||||
};
|
||||
|
||||
switch (action) {
|
||||
case 'SUBMIT':
|
||||
return this.handleSubmit(booking);
|
||||
case 'SEND_QUOTATION':
|
||||
return this.handleSendQuotation(booking);
|
||||
case 'APPROVE_QUOTATION':
|
||||
return this.handleApproveQuotation(booking);
|
||||
case 'REJECT_QUOTATION':
|
||||
return this.handleRejectQuotation(booking, reason);
|
||||
case 'APPROVE_STEP':
|
||||
return this.handleApproveStep(booking, actorId, requiredRole);
|
||||
case 'APPROVE':
|
||||
return this.handleFullyApproved(booking);
|
||||
case 'CUSTOMER_SIGN':
|
||||
return this.handleCustomerSign(booking);
|
||||
case 'MARK_FULLY_EXECUTED':
|
||||
return this.handleFullyExecuted(booking);
|
||||
case 'MARK_PAID':
|
||||
return this.handleMarkPaid(booking);
|
||||
case 'START_TRANSIT':
|
||||
return this.handleStartTransit(booking);
|
||||
case 'COMPLETE':
|
||||
return this.handleComplete(booking);
|
||||
case 'REJECT':
|
||||
return this.handleReject(booking, actorId, reason);
|
||||
case 'CANCEL':
|
||||
return this.handleCancel(booking, reason);
|
||||
default:
|
||||
throw new BadRequestException(`Unknown action: ${action}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** SUBMIT: DRAFT → RFQ_SUBMITTED → PENDING_APPROVAL with approval steps and rate snapshots. */
|
||||
private async handleSubmit(booking: Booking): Promise<Booking> {
|
||||
this.assertStatus(booking, ['DRAFT']);
|
||||
|
||||
await this.bookingsRepository.update(booking.id, { status: 'RFQ_SUBMITTED' } as never);
|
||||
await this.ruleEngineService.snapshotLiveRates(booking.id);
|
||||
await this.ruleEngineService.instantiateApprovalSteps(booking.id, booking.cargoTypeId);
|
||||
|
||||
const updated = await this.bookingsRepository.update(booking.id, {
|
||||
status: 'PENDING_APPROVAL',
|
||||
} as never);
|
||||
return updated!;
|
||||
}
|
||||
|
||||
private async handleSendQuotation(booking: Booking): Promise<Booking> {
|
||||
this.assertStatus(booking, ['RFQ_SUBMITTED']);
|
||||
const updated = await this.bookingsRepository.update(booking.id, {
|
||||
status: 'QUOTATION_SENT',
|
||||
} as never);
|
||||
return updated!;
|
||||
}
|
||||
|
||||
private async handleApproveQuotation(booking: Booking): Promise<Booking> {
|
||||
this.assertStatus(booking, ['QUOTATION_SENT']);
|
||||
const updated = await this.bookingsRepository.update(booking.id, {
|
||||
status: 'QUOTATION_APPROVED',
|
||||
} as never);
|
||||
return updated!;
|
||||
}
|
||||
|
||||
private async handleRejectQuotation(booking: Booking, reason?: string): Promise<Booking> {
|
||||
this.assertStatus(booking, ['QUOTATION_SENT']);
|
||||
if (!reason) throw new BadRequestException('reason is required for REJECT_QUOTATION');
|
||||
const updated = await this.bookingsRepository.update(booking.id, {
|
||||
status: 'QUOTATION_REJECTED',
|
||||
} as never);
|
||||
return updated!;
|
||||
}
|
||||
|
||||
private async handleApproveStep(
|
||||
booking: Booking,
|
||||
actorId?: string,
|
||||
requiredRole?: string,
|
||||
): Promise<Booking> {
|
||||
this.assertStatus(booking, ['PENDING_APPROVAL', 'QUOTATION_APPROVED']);
|
||||
if (!actorId || !requiredRole) {
|
||||
throw new BadRequestException('actorId and requiredRole are required for APPROVE_STEP');
|
||||
const status = statusMap[queue];
|
||||
if (!status) {
|
||||
throw new BadRequestException(`Unknown queue: ${queue}`);
|
||||
}
|
||||
|
||||
const step = await this.bookingsRepository.findPendingApprovalStep(
|
||||
booking.id,
|
||||
requiredRole,
|
||||
);
|
||||
if (!step) {
|
||||
throw new BadRequestException(`No pending approval step for role ${requiredRole}`);
|
||||
}
|
||||
|
||||
await this.bookingsRepository.completeApprovalStep(step.id, actorId, 'APPROVED');
|
||||
|
||||
const allDone = await this.bookingsRepository.allApprovalStepsComplete(booking.id);
|
||||
if (allDone) {
|
||||
const updated = await this.bookingsRepository.update(booking.id, {
|
||||
status: 'APPROVED',
|
||||
} as never);
|
||||
return updated!;
|
||||
}
|
||||
|
||||
return this.findById(booking.id);
|
||||
}
|
||||
|
||||
private async handleFullyApproved(booking: Booking): Promise<Booking> {
|
||||
this.assertStatus(booking, ['PENDING_APPROVAL', 'QUOTATION_APPROVED']);
|
||||
const updated = await this.bookingsRepository.update(booking.id, {
|
||||
status: 'APPROVED',
|
||||
} as never);
|
||||
return updated!;
|
||||
}
|
||||
|
||||
private async handleCustomerSign(booking: Booking): Promise<Booking> {
|
||||
this.assertStatus(booking, ['APPROVED']);
|
||||
const updated = await this.bookingsRepository.update(booking.id, {
|
||||
status: 'SIGNED_CUSTOMER',
|
||||
customerSignedAt: new Date(),
|
||||
} as never);
|
||||
return updated!;
|
||||
}
|
||||
|
||||
private async handleFullyExecuted(booking: Booking): Promise<Booking> {
|
||||
this.assertStatus(booking, ['SIGNED_CUSTOMER']);
|
||||
const updated = await this.bookingsRepository.update(booking.id, {
|
||||
status: 'FULLY_EXECUTED',
|
||||
fullyExecutedAt: new Date(),
|
||||
} as never);
|
||||
return updated!;
|
||||
}
|
||||
|
||||
private async handleMarkPaid(booking: Booking): Promise<Booking> {
|
||||
this.assertStatus(booking, ['FULLY_EXECUTED', 'APPROVED', 'SIGNED_CUSTOMER']);
|
||||
const updated = await this.bookingsRepository.update(booking.id, {
|
||||
status: 'PAID',
|
||||
paymentStatus: 'PAID',
|
||||
} as never);
|
||||
return updated!;
|
||||
}
|
||||
|
||||
private async handleStartTransit(booking: Booking): Promise<Booking> {
|
||||
this.assertStatus(booking, ['PAID']);
|
||||
const updated = await this.bookingsRepository.update(booking.id, {
|
||||
status: 'IN_TRANSIT',
|
||||
} as never);
|
||||
return updated!;
|
||||
}
|
||||
|
||||
private async handleComplete(booking: Booking): Promise<Booking> {
|
||||
this.assertStatus(booking, ['IN_TRANSIT']);
|
||||
const updated = await this.bookingsRepository.update(booking.id, {
|
||||
status: 'COMPLETED',
|
||||
endDate: new Date(),
|
||||
} as never);
|
||||
return updated!;
|
||||
}
|
||||
|
||||
private async handleReject(
|
||||
booking: Booking,
|
||||
actorId?: string,
|
||||
reason?: string,
|
||||
): Promise<Booking> {
|
||||
this.assertStatus(booking, ['PENDING_APPROVAL', 'QUOTATION_APPROVED']);
|
||||
if (!actorId || !reason) {
|
||||
throw new BadRequestException('actorId and reason are required for REJECT');
|
||||
}
|
||||
const updated = await this.bookingsRepository.update(booking.id, {
|
||||
status: 'CANCELLED',
|
||||
} as never);
|
||||
return updated!;
|
||||
}
|
||||
|
||||
private async handleCancel(booking: Booking, reason?: string): Promise<Booking> {
|
||||
this.assertStatus(booking, [
|
||||
'DRAFT',
|
||||
'RFQ_SUBMITTED',
|
||||
'QUOTATION_SENT',
|
||||
'QUOTATION_APPROVED',
|
||||
'PENDING_APPROVAL',
|
||||
]);
|
||||
if (!reason) throw new BadRequestException('reason is required for CANCEL');
|
||||
const updated = await this.bookingsRepository.update(booking.id, {
|
||||
status: 'CANCELLED',
|
||||
} as never);
|
||||
return updated!;
|
||||
}
|
||||
|
||||
private assertStatus(booking: Booking, allowed: string[]): void {
|
||||
if (!allowed.includes(booking.status)) {
|
||||
throw new ConflictException(
|
||||
`Cannot perform this action on status "${booking.status}". Allowed: ${allowed.join(', ')}`,
|
||||
);
|
||||
}
|
||||
return this.bookingsRepository.findQueue({
|
||||
status,
|
||||
page: filter.page,
|
||||
pageSize: filter.pageSize,
|
||||
excludeBulk: options?.excludeBulk ?? queue === 'approval',
|
||||
sortBy: filter.sortBy,
|
||||
sortOrder: filter.sortOrder,
|
||||
});
|
||||
}
|
||||
|
||||
async requestConsolidation(id: string): Promise<{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class ContractSignatureDto {
|
||||
@ApiProperty({ enum: ['CUSTOMER', 'STAFF'] })
|
||||
role!: string;
|
||||
|
||||
@ApiProperty()
|
||||
signerDisplayName!: string;
|
||||
|
||||
@ApiProperty()
|
||||
signedAt!: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
signatureImageUrl?: string | null;
|
||||
}
|
||||
|
||||
export class ContractViewDto {
|
||||
@ApiProperty()
|
||||
bookingId!: string;
|
||||
|
||||
@ApiProperty()
|
||||
reference!: string;
|
||||
|
||||
@ApiProperty()
|
||||
status!: string;
|
||||
|
||||
@ApiProperty()
|
||||
templateKey!: string;
|
||||
|
||||
@ApiProperty()
|
||||
title!: string;
|
||||
|
||||
@ApiProperty({ description: 'Full HTML document for in-browser display' })
|
||||
html!: string;
|
||||
|
||||
@ApiProperty()
|
||||
canSignCustomer!: boolean;
|
||||
|
||||
@ApiProperty()
|
||||
canSignStaff!: boolean;
|
||||
|
||||
@ApiProperty()
|
||||
hasContractDocument!: boolean;
|
||||
|
||||
@ApiProperty({ type: [ContractSignatureDto] })
|
||||
signatures!: ContractSignatureDto[];
|
||||
|
||||
@ApiPropertyOptional()
|
||||
pricingSchedule?: Record<string, unknown>;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Transform, Type } from 'class-transformer';
|
||||
import {
|
||||
ArrayMinSize,
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsDateString,
|
||||
@@ -11,9 +12,12 @@ import {
|
||||
IsString,
|
||||
IsUUID,
|
||||
Min,
|
||||
Validate,
|
||||
ValidateIf,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import { BOOKING_STATUSES } from '../entities/booking.entity';
|
||||
import { BOOKING_STATUSES, FREIGHT_TYPES } from '../entities/booking.entity';
|
||||
import { BookingFreightShapeConstraint } from './validators/booking-freight.validator';
|
||||
|
||||
const CONTRACT_TYPES = ['NEW', 'RENEWAL'] as const;
|
||||
const EQUIPMENT_RETURNS = ['WITH_RETURN', 'WITHOUT_RETURN', 'NA'] as const;
|
||||
@@ -24,6 +28,7 @@ export {
|
||||
BOOKING_STATUSES,
|
||||
CONTRACT_TYPES,
|
||||
EQUIPMENT_RETURNS,
|
||||
FREIGHT_TYPES,
|
||||
TRADE_DIRECTIONS,
|
||||
PAYMENT_CURRENCIES,
|
||||
};
|
||||
@@ -47,16 +52,24 @@ export class CreateBookingContainerDto {
|
||||
}
|
||||
|
||||
export class CreateBookingDto {
|
||||
/** Class-level freight shape check (not a request field). */
|
||||
@Validate(BookingFreightShapeConstraint)
|
||||
freightShapeValidation?: boolean;
|
||||
@ApiPropertyOptional({ description: 'Unique booking reference (auto-generated if omitted)' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
|
||||
reference?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid', description: 'Admin only: target customer' })
|
||||
// @ApiPropertyOptional({ format: 'uuid', description: 'Admin only: target customer (legacy)' })
|
||||
// @IsOptional()
|
||||
// @IsUUID()
|
||||
// customerId?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid', description: 'Admin only: target company' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
customerId?: string;
|
||||
companyId?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@@ -107,9 +120,17 @@ export class CreateBookingDto {
|
||||
@IsIn([...TRADE_DIRECTIONS])
|
||||
tradeDirection!: string;
|
||||
|
||||
@ApiProperty({ format: 'uuid', description: 'FK to cargo_types.id' })
|
||||
@ApiProperty({ enum: FREIGHT_TYPES, description: 'CONTAINER or BULK (mutually exclusive cargo shape)' })
|
||||
@IsIn([...FREIGHT_TYPES])
|
||||
freightType!: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
format: 'uuid',
|
||||
description: 'Required for BULK; must be omitted for CONTAINER',
|
||||
})
|
||||
@ValidateIf((o) => o.freightType === 'BULK')
|
||||
@IsUUID()
|
||||
cargoTypeId!: string;
|
||||
cargoTypeId?: string;
|
||||
|
||||
@ApiPropertyOptional({ maxLength: 200 })
|
||||
@IsOptional()
|
||||
@@ -157,11 +178,16 @@ export class CreateBookingDto {
|
||||
@IsString()
|
||||
financialTerms?: string;
|
||||
|
||||
@ApiProperty({ type: [CreateBookingContainerDto] })
|
||||
@ApiPropertyOptional({
|
||||
type: [CreateBookingContainerDto],
|
||||
description: 'Required for CONTAINER (min 1 line); must be empty for BULK',
|
||||
})
|
||||
@ValidateIf((o) => o.freightType === 'CONTAINER')
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => CreateBookingContainerDto)
|
||||
containers!: CreateBookingContainerDto[];
|
||||
containers?: CreateBookingContainerDto[];
|
||||
|
||||
@ApiPropertyOptional({ default: false })
|
||||
@IsOptional()
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsIn, IsOptional, IsUUID } from 'class-validator';
|
||||
import { BOOKING_STATUSES, PAYMENT_CURRENCIES, TRADE_DIRECTIONS } from './create-booking.dto';
|
||||
import {
|
||||
BOOKING_STATUSES,
|
||||
FREIGHT_TYPES,
|
||||
PAYMENT_CURRENCIES,
|
||||
TRADE_DIRECTIONS,
|
||||
} from './create-booking.dto';
|
||||
|
||||
export class FilterBookingDto {
|
||||
@ApiPropertyOptional({ enum: BOOKING_STATUSES })
|
||||
@@ -9,10 +14,27 @@ 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()
|
||||
// customerId?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
customerId?: string;
|
||||
companyId?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@@ -28,6 +50,11 @@ export class FilterBookingDto {
|
||||
@IsUUID()
|
||||
cargoTypeId?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: FREIGHT_TYPES })
|
||||
@IsOptional()
|
||||
@IsIn([...FREIGHT_TYPES])
|
||||
freightType?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: TRADE_DIRECTIONS })
|
||||
@IsOptional()
|
||||
@IsIn([...TRADE_DIRECTIONS])
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class PriceLineItemDto {
|
||||
@ApiProperty()
|
||||
code!: string;
|
||||
|
||||
@ApiProperty()
|
||||
description!: string;
|
||||
|
||||
@ApiProperty()
|
||||
amount!: number;
|
||||
|
||||
@ApiProperty()
|
||||
currency!: string;
|
||||
}
|
||||
|
||||
export class GeneratePriceResponseDto {
|
||||
@ApiProperty()
|
||||
bookingId!: string;
|
||||
|
||||
@ApiProperty()
|
||||
totalAmount!: number;
|
||||
|
||||
@ApiProperty()
|
||||
currency!: string;
|
||||
|
||||
@ApiProperty({ type: [PriceLineItemDto] })
|
||||
lineItems!: PriceLineItemDto[];
|
||||
|
||||
@ApiProperty({ type: [String] })
|
||||
warnings!: string[];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsString, MinLength } from 'class-validator';
|
||||
|
||||
export class RequestChangesDto {
|
||||
@ApiProperty({ description: 'Staff note explaining what the customer must fix' })
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
note!: string;
|
||||
}
|
||||
|
||||
export class StaffRejectDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
reason!: string;
|
||||
}
|
||||
|
||||
export class ApproveStepDto {
|
||||
@ApiProperty({ description: 'LINE_STAFF | DIRECTOR | CEO' })
|
||||
@IsString()
|
||||
requiredRole!: string;
|
||||
}
|
||||
|
||||
export class RejectStepDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
reason!: string;
|
||||
}
|
||||
|
||||
export class CancelBookingDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
reason!: string;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsIn, IsOptional, IsString, MinLength } from 'class-validator';
|
||||
|
||||
export class SignContractDto {
|
||||
@ApiProperty({ enum: ['CUSTOMER', 'STAFF'] })
|
||||
@IsIn(['CUSTOMER', 'STAFF'])
|
||||
role!: 'CUSTOMER' | 'STAFF';
|
||||
|
||||
@ApiProperty({ description: 'PNG signature image as base64 (with or without data URL prefix)' })
|
||||
@IsString()
|
||||
@MinLength(20)
|
||||
signatureImageBase64!: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
signerDisplayName!: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
consentText?: string;
|
||||
}
|
||||
@@ -1,5 +1,10 @@
|
||||
import { PartialType } from "@nestjs/mapped-types";
|
||||
import { PartialType } from '@nestjs/mapped-types';
|
||||
import { Validate } from 'class-validator';
|
||||
|
||||
import { CreateBookingDto } from "./create-booking.dto";
|
||||
import { CreateBookingDto } from './create-booking.dto';
|
||||
import { BookingFreightShapeConstraint } from './validators/booking-freight.validator';
|
||||
|
||||
export class UpdateBookingDto extends PartialType(CreateBookingDto) {}
|
||||
export class UpdateBookingDto extends PartialType(CreateBookingDto) {
|
||||
@Validate(BookingFreightShapeConstraint)
|
||||
freightShapeValidation?: boolean;
|
||||
}
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsIn, IsOptional, IsString, IsUUID } from 'class-validator';
|
||||
|
||||
const STATUS_ACTIONS = [
|
||||
'SUBMIT',
|
||||
'SEND_QUOTATION',
|
||||
'APPROVE_QUOTATION',
|
||||
'REJECT_QUOTATION',
|
||||
'APPROVE_STEP',
|
||||
'APPROVE',
|
||||
'CUSTOMER_SIGN',
|
||||
'MARK_FULLY_EXECUTED',
|
||||
'MARK_PAID',
|
||||
'START_TRANSIT',
|
||||
'COMPLETE',
|
||||
'REJECT',
|
||||
'CANCEL',
|
||||
] as const;
|
||||
|
||||
export { STATUS_ACTIONS };
|
||||
|
||||
export class UpdateStatusDto {
|
||||
@ApiProperty({ enum: STATUS_ACTIONS })
|
||||
@IsIn([...STATUS_ACTIONS])
|
||||
action!: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid', description: 'Staff/director/CEO actor' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
actorId?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Required role for APPROVE_STEP (LINE_STAFF, DIRECTOR, CEO)' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
requiredRole?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Required for REJECT, REJECT_QUOTATION, CANCEL' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
reason?: string;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import {
|
||||
ValidationArguments,
|
||||
ValidatorConstraint,
|
||||
ValidatorConstraintInterface,
|
||||
} from 'class-validator';
|
||||
|
||||
import { FREIGHT_TYPES, FreightType } from '../../entities/booking.entity';
|
||||
|
||||
export interface BookingFreightShapeInput {
|
||||
freightType?: string;
|
||||
cargoTypeId?: string | null;
|
||||
containers?: Array<{ containerTypeId?: string }> | null;
|
||||
}
|
||||
|
||||
@ValidatorConstraint({ name: 'BookingFreightShape', async: false })
|
||||
export class BookingFreightShapeConstraint implements ValidatorConstraintInterface {
|
||||
validate(_value: unknown, args: ValidationArguments): boolean {
|
||||
const dto = args.object as BookingFreightShapeInput;
|
||||
if (!dto.freightType || !FREIGHT_TYPES.includes(dto.freightType as FreightType)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const containers = dto.containers ?? [];
|
||||
const hasContainers = containers.length > 0;
|
||||
const hasCargoType =
|
||||
dto.cargoTypeId !== undefined &&
|
||||
dto.cargoTypeId !== null &&
|
||||
String(dto.cargoTypeId).trim() !== '';
|
||||
|
||||
if (dto.freightType === 'BULK') {
|
||||
if (hasContainers) return false;
|
||||
if (!hasCargoType) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (dto.freightType === 'CONTAINER') {
|
||||
if (hasCargoType) return false;
|
||||
if (!hasContainers) return false;
|
||||
return containers.every(
|
||||
(c) =>
|
||||
c.containerTypeId !== undefined &&
|
||||
c.containerTypeId !== null &&
|
||||
String(c.containerTypeId).trim() !== '',
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
defaultMessage(args: ValidationArguments): string {
|
||||
const dto = args.object as BookingFreightShapeInput;
|
||||
if (dto.freightType === 'BULK') {
|
||||
return 'BULK freight requires cargoTypeId and must not include container lines';
|
||||
}
|
||||
if (dto.freightType === 'CONTAINER') {
|
||||
return 'CONTAINER freight requires at least one container line with containerTypeId and must not include cargoTypeId';
|
||||
}
|
||||
return 'Invalid freight type shape';
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne, Unique } from 'typeorm';
|
||||
import { FileRecord } from '../../files/entities/file.entity';
|
||||
import { Booking } from './booking.entity';
|
||||
|
||||
export const CONTRACT_SIGNER_ROLES = ['CUSTOMER', 'STAFF'] as const;
|
||||
export type ContractSignerRole = (typeof CONTRACT_SIGNER_ROLES)[number];
|
||||
|
||||
@Entity({ schema: 'freight', name: 'booking_contract_signatures' })
|
||||
@Unique(['bookingId', 'signerRole'])
|
||||
@Index(['bookingId'])
|
||||
export class BookingContractSignature extends BaseEntity {
|
||||
@Column({ name: 'booking_id', type: 'uuid' })
|
||||
bookingId!: string;
|
||||
|
||||
@ManyToOne(() => Booking, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'booking_id' })
|
||||
booking?: Booking;
|
||||
|
||||
@Column({ name: 'signer_role', type: 'varchar', length: 20 })
|
||||
signerRole!: ContractSignerRole;
|
||||
|
||||
@Column({ name: 'signer_user_id', type: 'uuid', nullable: true })
|
||||
signerUserId?: string | null;
|
||||
|
||||
@Column({ name: 'signer_display_name', type: 'varchar', length: 200 })
|
||||
signerDisplayName!: string;
|
||||
|
||||
@Column({ name: 'signed_at', type: 'timestamptz' })
|
||||
signedAt!: Date;
|
||||
|
||||
@Column({ name: 'signature_file_id', type: 'uuid', nullable: true })
|
||||
signatureFileId?: string | null;
|
||||
|
||||
@ManyToOne(() => FileRecord, { nullable: true })
|
||||
@JoinColumn({ name: 'signature_file_id' })
|
||||
signatureFile?: FileRecord | null;
|
||||
|
||||
@Column({ name: 'consent_text', type: 'text', nullable: true })
|
||||
consentText?: string | null;
|
||||
|
||||
@Column({ name: 'ip_address', type: 'varchar', length: 64, nullable: true })
|
||||
ipAddress?: string | null;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
||||
import { Booking } from './booking.entity';
|
||||
|
||||
export const REVIEW_NOTE_TYPES = ['CHANGES_REQUESTED', 'REJECTION'] as const;
|
||||
export type ReviewNoteType = (typeof REVIEW_NOTE_TYPES)[number];
|
||||
|
||||
@Entity({ schema: 'freight', name: 'booking_review_note' })
|
||||
@Index(['bookingId'])
|
||||
export class BookingReviewNote extends BaseEntity {
|
||||
@Column({ name: 'booking_id', type: 'uuid' })
|
||||
bookingId!: string;
|
||||
|
||||
@ManyToOne(() => Booking, (b) => b.reviewNotes, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'booking_id' })
|
||||
booking?: Booking;
|
||||
|
||||
@Column({ name: 'author_id', type: 'uuid', nullable: true })
|
||||
authorId?: string | null;
|
||||
|
||||
@Column({ name: 'note', type: 'text' })
|
||||
note!: string;
|
||||
|
||||
@Column({ name: 'type', type: 'varchar', length: 30 })
|
||||
type!: ReviewNoteType;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, JoinColumn, ManyToOne, OneToMany } from 'typeorm';
|
||||
import { Customer } from '../../customers/entities/customer.entity';
|
||||
// import { Customer } from '../../customers/entities/customer.entity';
|
||||
import { Company } from '../../companies/entities/company.entity';
|
||||
import { CargoType } from '../../rule-engine/entities/cargo-type.entity';
|
||||
import { ServiceType } from '../../rule-engine/entities/service-type.entity';
|
||||
import { ShippingLine } from '../../rule-engine/entities/shipping-line.entity';
|
||||
@@ -11,36 +12,68 @@ import { BookingApprovalStep } from './booking-approval-step.entity';
|
||||
import { BookingCargoModifier } from './booking-cargo-modifier.entity';
|
||||
import { BookingContainer } from './booking-container.entity';
|
||||
import { BookingRateSnapshot } from './booking-rate-snapshot.entity';
|
||||
import { BookingReviewNote } from './booking-review-note.entity';
|
||||
|
||||
export const BOOKING_STATUSES = [
|
||||
'DRAFT',
|
||||
'RFQ_SUBMITTED',
|
||||
'QUOTATION_SENT',
|
||||
'QUOTATION_APPROVED',
|
||||
'QUOTATION_REJECTED',
|
||||
'SUBMITTED',
|
||||
'CHANGES_REQUESTED',
|
||||
'PENDING_APPROVAL',
|
||||
'APPROVED_PENDING_SIGNATURE',
|
||||
'APPROVED',
|
||||
'CONTRACT_READY',
|
||||
'SIGNED_CUSTOMER',
|
||||
'FULLY_EXECUTED',
|
||||
'PNR_GENERATED',
|
||||
'PAYMENT_VERIFICATION_IN_PROGRESS',
|
||||
'PAID',
|
||||
'IN_TRANSIT',
|
||||
'COMPLETED',
|
||||
'REJECTED',
|
||||
'CANCELLED',
|
||||
'PENDING_CONSOLIDATION',
|
||||
'CONSOLIDATED',
|
||||
] as const;
|
||||
|
||||
export type BookingStatus = (typeof BOOKING_STATUSES)[number];
|
||||
|
||||
export const PAYMENT_STATUSES = [
|
||||
'PENDING',
|
||||
'PNR_GENERATED',
|
||||
'VERIFICATION_IN_PROGRESS',
|
||||
'PAID',
|
||||
'FAILED',
|
||||
] as const;
|
||||
|
||||
export type PaymentStatus = (typeof PAYMENT_STATUSES)[number];
|
||||
|
||||
export const FREIGHT_TYPES = ['CONTAINER', 'BULK'] as const;
|
||||
export type FreightType = (typeof FREIGHT_TYPES)[number];
|
||||
|
||||
/** Statuses where the customer may edit booking fields. */
|
||||
export const CUSTOMER_EDITABLE_STATUSES: BookingStatus[] = [
|
||||
'DRAFT',
|
||||
'CHANGES_REQUESTED',
|
||||
];
|
||||
|
||||
@Entity({ schema: 'freight', name: 'bookings' })
|
||||
export class Booking extends BaseEntity {
|
||||
@Column({ name: 'reference', type: 'varchar', length: 64, unique: true })
|
||||
reference!: string;
|
||||
|
||||
@Column({ name: 'customer_id', type: 'uuid' })
|
||||
customerId!: string;
|
||||
// Legacy — superseded by companyId (column kept in DB)
|
||||
// @Column({ name: 'customer_id', type: 'uuid' })
|
||||
// customerId!: string;
|
||||
// @ManyToOne(() => Customer)
|
||||
// @JoinColumn({ name: 'customer_id' })
|
||||
// customer?: Customer;
|
||||
|
||||
@ManyToOne(() => Customer)
|
||||
@JoinColumn({ name: 'customer_id' })
|
||||
customer?: Customer;
|
||||
@Column({ name: 'company_id', type: 'uuid' })
|
||||
companyId!: string;
|
||||
|
||||
@ManyToOne(() => Company)
|
||||
@JoinColumn({ name: 'company_id' })
|
||||
company?: Company;
|
||||
|
||||
@Column({ name: 'train_id', type: 'uuid', nullable: true })
|
||||
trainId?: string | null;
|
||||
@@ -104,8 +137,11 @@ export class Booking extends BaseEntity {
|
||||
@Column({ name: 'trade_direction', type: 'varchar', length: 10 })
|
||||
tradeDirection!: string;
|
||||
|
||||
@Column({ name: 'cargo_type_id', type: 'uuid' })
|
||||
cargoTypeId!: string;
|
||||
@Column({ name: 'freight_type', type: 'varchar', length: 20 })
|
||||
freightType!: string;
|
||||
|
||||
@Column({ name: 'cargo_type_id', type: 'uuid', nullable: true })
|
||||
cargoTypeId?: string | null;
|
||||
|
||||
@ManyToOne(() => CargoType)
|
||||
@JoinColumn({ name: 'cargo_type_id' })
|
||||
@@ -169,6 +205,27 @@ export class Booking extends BaseEntity {
|
||||
@Column({ name: 'fully_executed_at', type: 'timestamptz', nullable: true })
|
||||
fullyExecutedAt?: Date | null;
|
||||
|
||||
@Column({ name: 'marketing_approved_by_id', type: 'uuid', nullable: true })
|
||||
marketingApprovedById?: string | null;
|
||||
|
||||
@Column({ name: 'marketing_approved_at', type: 'timestamptz', nullable: true })
|
||||
marketingApprovedAt?: Date | null;
|
||||
|
||||
@Column({ name: 'contract_summary', type: 'text', nullable: true })
|
||||
contractSummary?: string | null;
|
||||
|
||||
@Column({ name: 'contract_template_key', type: 'varchar', length: 80, nullable: true })
|
||||
contractTemplateKey?: string | null;
|
||||
|
||||
@Column({ name: 'contract_generated_at', type: 'timestamptz', nullable: true })
|
||||
contractGeneratedAt?: Date | null;
|
||||
|
||||
@Column({ name: 'pricing_breakdown', type: 'jsonb', nullable: true })
|
||||
pricingBreakdown?: Record<string, unknown> | null;
|
||||
|
||||
@Column({ name: 'locked_at', type: 'timestamptz', nullable: true })
|
||||
lockedAt?: Date | null;
|
||||
|
||||
@Column({ name: 'priority_score', type: 'int', default: 0 })
|
||||
priorityScore!: number;
|
||||
|
||||
@@ -194,6 +251,9 @@ export class Booking extends BaseEntity {
|
||||
@OneToMany(() => BookingRateSnapshot, (s) => s.booking)
|
||||
rateSnapshots?: BookingRateSnapshot[];
|
||||
|
||||
@OneToMany(() => BookingReviewNote, (n) => n.booking)
|
||||
reviewNotes?: BookingReviewNote[];
|
||||
|
||||
@OneToMany(() => FileRecord, (file) => file.resourceId, {
|
||||
createForeignKeyConstraints: false,
|
||||
})
|
||||
|
||||
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 };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
Post,
|
||||
} from '@nestjs/common';
|
||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { CreateCargoDto } from './dto/create-cargo.dto';
|
||||
import { UpdateCargoDto } from './dto/update-cargo.dto';
|
||||
import { LoadCargoDto } from './dto/load-cargo.dto';
|
||||
import { DeliverCargoDto } from './dto/deliver-cargo.dto';
|
||||
import { CargoesService } from './cargoes.service';
|
||||
|
||||
@ApiTags('cargoes')
|
||||
@Controller('cargoes')
|
||||
export class CargoesController {
|
||||
constructor(private readonly cargoesService: CargoesService) {}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: 'Create a new cargo' })
|
||||
create(@Body() dto: CreateCargoDto) {
|
||||
return this.cargoesService.create(dto);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List all cargoes' })
|
||||
findAll() {
|
||||
return this.cargoesService.findAll();
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get a cargo by ID' })
|
||||
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.cargoesService.findById(id);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@ApiOperation({ summary: 'Update a cargo' })
|
||||
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateCargoDto) {
|
||||
return this.cargoesService.update(id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@ApiOperation({ summary: 'Delete a cargo' })
|
||||
remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.cargoesService.remove(id);
|
||||
}
|
||||
|
||||
@Post(':id/load')
|
||||
@ApiOperation({ summary: 'Load cargo into a container' })
|
||||
load(@Param('id', ParseUUIDPipe) id: string, @Body() dto: LoadCargoDto) {
|
||||
return this.cargoesService.loadCargo(id, dto);
|
||||
}
|
||||
|
||||
@Post(':id/unload')
|
||||
@ApiOperation({ summary: 'Unload cargo from container' })
|
||||
unload(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.cargoesService.unloadCargo(id);
|
||||
}
|
||||
|
||||
@Post(':id/deliver')
|
||||
@ApiOperation({ summary: 'Mark cargo as delivered' })
|
||||
deliver(@Param('id', ParseUUIDPipe) id: string, @Body() dto?: DeliverCargoDto) {
|
||||
return this.cargoesService.deliverCargo(id, dto);
|
||||
}
|
||||
}
|
||||
14
apps/edr-freight-api/src/modules/cargoes/cargoes.module.ts
Normal file
14
apps/edr-freight-api/src/modules/cargoes/cargoes.module.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { Cargo } from './entities/cargoes.entity';
|
||||
import { Container } from '../container-management/entities/container.entity';
|
||||
import { CargoesController } from './cargoes.controller';
|
||||
import { CargoesService } from './cargoes.service';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Cargo, Container])],
|
||||
controllers: [CargoesController],
|
||||
providers: [CargoesService],
|
||||
exports: [CargoesService],
|
||||
})
|
||||
export class CargoesModule {}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { BaseRepository } from '@edr/api-common';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { Cargo } from './entities/cargoes.entity';
|
||||
|
||||
@Injectable()
|
||||
export class CargoesRepository extends BaseRepository<Cargo> {
|
||||
constructor(
|
||||
@InjectRepository(Cargo)
|
||||
repository: Repository<Cargo>,
|
||||
) {
|
||||
super(repository);
|
||||
}
|
||||
}
|
||||
104
apps/edr-freight-api/src/modules/cargoes/cargoes.service.ts
Normal file
104
apps/edr-freight-api/src/modules/cargoes/cargoes.service.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { CreateCargoDto } from './dto/create-cargo.dto';
|
||||
import { UpdateCargoDto } from './dto/update-cargo.dto';
|
||||
import { LoadCargoDto } from './dto/load-cargo.dto';
|
||||
import { DeliverCargoDto } from './dto/deliver-cargo.dto';
|
||||
import { Cargo } from './entities/cargoes.entity';
|
||||
import { Container } from '../container-management/entities/container.entity';
|
||||
|
||||
@Injectable()
|
||||
export class CargoesService {
|
||||
constructor(
|
||||
@InjectRepository(Cargo)
|
||||
private readonly cargoRepo: Repository<Cargo>,
|
||||
@InjectRepository(Container)
|
||||
private readonly containerRepo: Repository<Container>,
|
||||
) {}
|
||||
|
||||
async create(dto: CreateCargoDto): Promise<Cargo> {
|
||||
const cargo = this.cargoRepo.create(dto);
|
||||
return this.cargoRepo.save(cargo);
|
||||
}
|
||||
|
||||
async findAll(): Promise<Cargo[]> {
|
||||
return this.cargoRepo.find({ order: { cargoReference: 'ASC' } });
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<Cargo> {
|
||||
const cargo = await this.cargoRepo.findOne({ where: { id } });
|
||||
if (!cargo) throw new NotFoundException(`Cargo ${id} not found`);
|
||||
return cargo;
|
||||
}
|
||||
|
||||
async update(id: string, dto: UpdateCargoDto): Promise<Cargo> {
|
||||
const cargo = await this.findById(id);
|
||||
Object.assign(cargo, dto);
|
||||
return this.cargoRepo.save(cargo);
|
||||
}
|
||||
|
||||
async remove(id: string): Promise<void> {
|
||||
const cargo = await this.findById(id);
|
||||
await this.cargoRepo.remove(cargo);
|
||||
}
|
||||
|
||||
async loadCargo(id: string, dto: LoadCargoDto): Promise<Cargo> {
|
||||
const cargo = await this.cargoRepo.findOne({
|
||||
where: { id },
|
||||
relations: { container: true }, // ✅ fixed
|
||||
});
|
||||
if (!cargo) throw new NotFoundException('Cargo not found');
|
||||
if (cargo.status !== 'PENDING') {
|
||||
throw new ConflictException('Cargo already loaded or delivered');
|
||||
}
|
||||
|
||||
cargo.status = 'LOADED';
|
||||
cargo.loadedAt = new Date();
|
||||
cargo.quantity = dto.quantity;
|
||||
cargo.weight = dto.weight;
|
||||
cargo.volume = dto.volume ?? null;
|
||||
if (dto.description) cargo.description = dto.description;
|
||||
|
||||
if (cargo.container) {
|
||||
cargo.container.status = 'LOADED';
|
||||
await this.containerRepo.save(cargo.container);
|
||||
}
|
||||
|
||||
return this.cargoRepo.save(cargo);
|
||||
}
|
||||
|
||||
async unloadCargo(id: string): Promise<Cargo> {
|
||||
const cargo = await this.findById(id);
|
||||
if (cargo.status !== 'LOADED') {
|
||||
throw new ConflictException('Cargo is not loaded');
|
||||
}
|
||||
cargo.status = 'UNLOADED';
|
||||
cargo.unloadedAt = new Date();
|
||||
return this.cargoRepo.save(cargo);
|
||||
}
|
||||
|
||||
async deliverCargo(id: string, dto?: DeliverCargoDto): Promise<Cargo> {
|
||||
const cargo = await this.cargoRepo.findOne({
|
||||
where: { id },
|
||||
relations: { container: true }, // ✅ fixed
|
||||
});
|
||||
if (!cargo) throw new NotFoundException('Cargo not found');
|
||||
if (cargo.status !== 'LOADED') {
|
||||
throw new ConflictException('Only loaded cargo can be delivered');
|
||||
}
|
||||
|
||||
cargo.status = 'DELIVERED';
|
||||
if (dto?.deliveryRemarks) cargo.description = dto.deliveryRemarks;
|
||||
|
||||
const remaining = await this.cargoRepo.count({
|
||||
where: { containerId: cargo.containerId, status: 'LOADED' },
|
||||
});
|
||||
if (remaining === 0 && cargo.container) {
|
||||
cargo.container.status = 'AVAILABLE';
|
||||
await this.containerRepo.save(cargo.container);
|
||||
}
|
||||
|
||||
return this.cargoRepo.save(cargo);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { IsString, IsUUID, IsOptional, IsNumber, Min, IsIn, IsDateString } from 'class-validator';
|
||||
|
||||
export class CreateCargoDto {
|
||||
@IsString()
|
||||
cargoReference!: string;
|
||||
|
||||
@IsUUID()
|
||||
shipmentId!: string;
|
||||
|
||||
@IsUUID()
|
||||
containerId!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
cargoTypeId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
|
||||
@IsNumber()
|
||||
@Min(0.001)
|
||||
quantity!: number;
|
||||
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
weight!: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
volume?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['PENDING', 'LOADED', 'IN_TRANSIT', 'DELIVERED', 'UNLOADED'])
|
||||
status?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
loadedAt?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
unloadedAt?: string;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { IsOptional, IsString } from 'class-validator';
|
||||
|
||||
export class DeliverCargoDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
deliveryRemarks?: string;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { IsNumber, Min, IsOptional, IsString } from 'class-validator';
|
||||
|
||||
export class LoadCargoDto {
|
||||
@IsNumber()
|
||||
@Min(0.001)
|
||||
quantity!: number;
|
||||
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
weight!: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
volume?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { PartialType } from '@nestjs/swagger';
|
||||
import { CreateCargoDto } from './create-cargo.dto';
|
||||
|
||||
export class UpdateCargoDto extends PartialType(CreateCargoDto) {}
|
||||
@@ -0,0 +1,45 @@
|
||||
// apps/edr-freight-api/src/modules/cargoes/entities/cargo.entity.ts
|
||||
import { Entity, Column, ManyToOne, JoinColumn } from 'typeorm';
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Container } from '../../container-management/entities/container.entity';
|
||||
|
||||
@Entity({ name: 'cargoes', schema: 'freight' })
|
||||
export class Cargo extends BaseEntity {
|
||||
@Column({ unique: true, name: 'cargo_reference' })
|
||||
cargoReference!: string;
|
||||
|
||||
@Column({ name: 'shipment_id', type: 'uuid' })
|
||||
shipmentId!: string;
|
||||
|
||||
@Column({ name: 'container_id', type: 'uuid' })
|
||||
containerId!: string;
|
||||
|
||||
@Column({ name: 'cargo_type_id', type: 'uuid', nullable: true })
|
||||
cargoTypeId!: string | null; // optional link to cargo_types table
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
description!: string | null;
|
||||
|
||||
@Column({ type: 'decimal', precision: 12, scale: 3 })
|
||||
quantity!: number;
|
||||
|
||||
@Column({ type: 'decimal', precision: 10, scale: 2 })
|
||||
weight!: number; // kg
|
||||
|
||||
@Column({ type: 'decimal', precision: 10, scale: 2, nullable: true })
|
||||
volume!: number | null; // m³
|
||||
|
||||
@Column({ type: 'varchar', default: 'PENDING' })
|
||||
status!: string; // PENDING, LOADED, IN_TRANSIT, DELIVERED, UNLOADED
|
||||
|
||||
@Column({ name: 'loaded_at', type: 'timestamp', nullable: true })
|
||||
loadedAt!: Date | null;
|
||||
|
||||
@Column({ name: 'unloaded_at', type: 'timestamp', nullable: true })
|
||||
unloadedAt!: Date | null;
|
||||
|
||||
// Relationship to Container
|
||||
@ManyToOne(() => Container, (container) => container.cargoes, { onDelete: 'RESTRICT' })
|
||||
@JoinColumn({ name: 'container_id' })
|
||||
container!: Container;
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Controller, Get, Post, Patch, Delete, Body, Param, Query, ParseUUIDPipe, HttpCode, HttpStatus } from '@nestjs/common';
|
||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { Controller, Get, Post, Patch, Delete, Body, Param, Query, ParseUUIDPipe, HttpCode, HttpStatus, UseInterceptors, UploadedFiles } from '@nestjs/common';
|
||||
import { AnyFilesInterceptor } from '@nestjs/platform-express';
|
||||
import { ApiOperation, ApiTags, ApiConsumes } from '@nestjs/swagger';
|
||||
import { CurrentUser } from '@edr/api-common';
|
||||
import { FilesService } from '../files/files.service';
|
||||
import { CompaniesService } from './companies.service';
|
||||
import { CreateCompanyDto } from './dto/create-company.dto';
|
||||
import { UpdateCompanyDto } from './dto/update-company.dto';
|
||||
@@ -11,6 +13,8 @@ import { ResponseCompanyDto } from './dto/response-company.dto';
|
||||
import { ResponseExternalProfileDto } from './dto/response-external-profile.dto';
|
||||
import { ResponseFFClientDto } from './dto/response-ff-client.dto';
|
||||
import { CompanyInfoResponseDto } from './dto/company-info-response.dto';
|
||||
import { UpdateProfileDto } from './dto/update-profile.dto';
|
||||
import { ProfileResponseDto } from './dto/profile-response.dto';
|
||||
|
||||
interface CurrentIamUser {
|
||||
id: string;
|
||||
@@ -22,7 +26,10 @@ interface CurrentIamUser {
|
||||
@ApiTags('Companies')
|
||||
@Controller('companies')
|
||||
export class CompaniesController {
|
||||
constructor(private readonly companiesService: CompaniesService) {}
|
||||
constructor(
|
||||
private readonly companiesService: CompaniesService,
|
||||
private readonly filesService: FilesService,
|
||||
) {}
|
||||
|
||||
@Get('getInfo')
|
||||
@ApiOperation({ summary: 'Get company info for the current user' })
|
||||
@@ -31,6 +38,22 @@ export class CompaniesController {
|
||||
return new CompanyInfoResponseDto(profile, company);
|
||||
}
|
||||
|
||||
@Get('profile')
|
||||
@ApiOperation({ summary: 'Get flattened profile for the settings page' })
|
||||
async getProfile(@CurrentUser() user: CurrentIamUser): Promise<ProfileResponseDto> {
|
||||
const { profile, company } = await this.companiesService.getCompanyInfoByUserId(user.id);
|
||||
return new ProfileResponseDto(profile, company);
|
||||
}
|
||||
|
||||
@Patch('profile')
|
||||
@ApiOperation({ summary: 'Update profile (flattened settings page)' })
|
||||
async updateProfile(
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
@Body() dto: UpdateProfileDto,
|
||||
): Promise<ProfileResponseDto> {
|
||||
return this.companiesService.updateProfile(user.id, dto);
|
||||
}
|
||||
|
||||
@Post('create')
|
||||
@ApiOperation({ summary: 'Create a company with its associated external profile (onboarding)' })
|
||||
async createWithProfile(
|
||||
@@ -105,6 +128,17 @@ export class CompaniesController {
|
||||
await this.companiesService.deleteCompany(id);
|
||||
}
|
||||
|
||||
@Post(':companyId/documents')
|
||||
@UseInterceptors(AnyFilesInterceptor())
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiOperation({ summary: 'Upload documents for a company (onboarding)' })
|
||||
async uploadDocuments(
|
||||
@Param('companyId', ParseUUIDPipe) companyId: string,
|
||||
@UploadedFiles() files: Array<Express.Multer.File>,
|
||||
) {
|
||||
return this.filesService.uploadMany(companyId, 'companies', files);
|
||||
}
|
||||
|
||||
@Post(':companyId/profiles')
|
||||
@ApiOperation({ summary: 'Add a profile (employee) to a company' })
|
||||
async createProfile(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { FilesModule } from '../files/files.module';
|
||||
import { CompaniesController } from './companies.controller';
|
||||
import { CompaniesService } from './companies.service';
|
||||
import { CompaniesRepository } from './companies.repository';
|
||||
@@ -10,7 +11,7 @@ import { ExternalProfile } from './entities/external-profile.entity';
|
||||
import { FFClient } from './entities/ff-client.entity';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Company, ExternalProfile, FFClient])],
|
||||
imports: [TypeOrmModule.forFeature([Company, ExternalProfile, FFClient]), FilesModule],
|
||||
controllers: [CompaniesController],
|
||||
providers: [CompaniesService, CompaniesRepository, ExternalProfileRepository, FFClientRepository],
|
||||
exports: [CompaniesService],
|
||||
|
||||
@@ -7,6 +7,8 @@ import { UpdateCompanyDto } from './dto/update-company.dto';
|
||||
import { CreateExternalProfileDto } from './dto/create-external-profile.dto';
|
||||
import { CreateFFClientDto } from './dto/create-ff-client.dto';
|
||||
import { CreateCompanyWithProfileDto } from './dto/create-company-with-profile.dto';
|
||||
import { UpdateProfileDto } from './dto/update-profile.dto';
|
||||
import { ProfileResponseDto } from './dto/profile-response.dto';
|
||||
import { Company } from './entities/company.entity';
|
||||
import { ExternalProfile } from './entities/external-profile.entity';
|
||||
import { FFClient } from './entities/ff-client.entity';
|
||||
@@ -103,6 +105,42 @@ export class CompaniesService {
|
||||
return updated;
|
||||
}
|
||||
|
||||
async updateProfile(userId: string, dto: UpdateProfileDto): Promise<ProfileResponseDto> {
|
||||
const { profile, company } = await this.getCompanyInfoByUserId(userId);
|
||||
|
||||
const companyUpdates: Record<string, any> = {};
|
||||
const attrUpdates: Record<string, any> = { ...(company.attributes ?? {}) };
|
||||
|
||||
if (dto.companyName !== undefined) companyUpdates.name = dto.companyName;
|
||||
if (dto.companyEmail !== undefined) companyUpdates.email = dto.companyEmail;
|
||||
if (dto.companyPhone !== undefined) companyUpdates.phone = dto.companyPhone;
|
||||
if (dto.companyLocation !== undefined) companyUpdates.country = dto.companyLocation;
|
||||
if (dto.companyAddress !== undefined) companyUpdates.address = dto.companyAddress;
|
||||
if (dto.tin !== undefined) companyUpdates.tin = dto.tin;
|
||||
if (dto.vatNumber !== undefined) companyUpdates.vatNumber = dto.vatNumber;
|
||||
if (dto.fanNumber !== undefined) {
|
||||
companyUpdates.businessLicense = dto.fanNumber;
|
||||
companyUpdates.fanNumber = dto.fanNumber;
|
||||
}
|
||||
|
||||
if (dto.contactPersonName !== undefined) attrUpdates.contactPersonName = dto.contactPersonName;
|
||||
if (dto.contactPersonPhone !== undefined) attrUpdates.contactPersonPhone = dto.contactPersonPhone;
|
||||
if (dto.generalManagerName !== undefined) attrUpdates.generalManagerName = dto.generalManagerName;
|
||||
if (dto.generalManagerEmail !== undefined) attrUpdates.generalManagerEmail = dto.generalManagerEmail;
|
||||
if (dto.generalManagerPhone !== undefined) attrUpdates.generalManagerPhone = dto.generalManagerPhone;
|
||||
if (dto.poaName !== undefined) attrUpdates.poaName = dto.poaName;
|
||||
if (dto.poaPhone !== undefined) attrUpdates.poaPhone = dto.poaPhone;
|
||||
if (dto.poaEmail !== undefined) attrUpdates.poaEmail = dto.poaEmail;
|
||||
if (dto.poaLocation !== undefined) attrUpdates.poaLocation = dto.poaLocation;
|
||||
if (dto.poaAddress !== undefined) attrUpdates.poaAddress = dto.poaAddress;
|
||||
|
||||
companyUpdates.attributes = attrUpdates;
|
||||
|
||||
const updated = await this.companiesRepo.update(company.id, companyUpdates);
|
||||
if (!updated) throw new NotFoundException(`Company ${company.id} not found`);
|
||||
return new ProfileResponseDto(profile, updated);
|
||||
}
|
||||
|
||||
async deleteCompany(id: string): Promise<void> {
|
||||
await this.findCompanyById(id);
|
||||
await this.companiesRepo.softDelete(id);
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { Company } from '../entities/company.entity';
|
||||
import { ExternalProfile } from '../entities/external-profile.entity';
|
||||
|
||||
export class ProfileResponseDto {
|
||||
companyId: string;
|
||||
companyName: string;
|
||||
companyEmail: string | null;
|
||||
companyPhone: string | null;
|
||||
companyLocation: string;
|
||||
companyAddress: string | null;
|
||||
tinNumber: string;
|
||||
vatNumber: string | null;
|
||||
fanNumber: string | null;
|
||||
|
||||
contactPersonName: string | null;
|
||||
contactPersonPhone: string | null;
|
||||
generalManagerName: string | null;
|
||||
generalManagerEmail: string | null;
|
||||
generalManagerPhone: string | null;
|
||||
|
||||
poaName: string | null;
|
||||
poaPhone: string | null;
|
||||
poaEmail: string | null;
|
||||
poaLocation: string | null;
|
||||
poaAddress: string | null;
|
||||
|
||||
profileId: string;
|
||||
|
||||
constructor(profile: ExternalProfile, company: Company) {
|
||||
this.companyId = company.id;
|
||||
this.companyName = company.name;
|
||||
this.companyEmail = company.email ?? null;
|
||||
this.companyPhone = company.phone ?? null;
|
||||
this.companyLocation = company.country;
|
||||
this.companyAddress = company.address ?? null;
|
||||
this.tinNumber = company.tin;
|
||||
this.vatNumber = company.vatNumber ?? null;
|
||||
this.fanNumber = company.fanNumber ?? null;
|
||||
this.profileId = profile.id;
|
||||
|
||||
const attrs = company.attributes ?? {};
|
||||
this.contactPersonName = attrs.contactPersonName ?? null;
|
||||
this.contactPersonPhone = attrs.contactPersonPhone ?? null;
|
||||
this.generalManagerName = attrs.generalManagerName ?? null;
|
||||
this.generalManagerEmail = attrs.generalManagerEmail ?? null;
|
||||
this.generalManagerPhone = attrs.generalManagerPhone ?? null;
|
||||
this.poaName = attrs.poaName ?? null;
|
||||
this.poaPhone = attrs.poaPhone ?? null;
|
||||
this.poaEmail = attrs.poaEmail ?? null;
|
||||
this.poaLocation = attrs.poaLocation ?? null;
|
||||
this.poaAddress = attrs.poaAddress ?? null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { IsString, IsOptional, IsEmail, MaxLength, Length, Matches } from 'class-validator';
|
||||
|
||||
export class UpdateProfileDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
companyName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEmail()
|
||||
@MaxLength(150)
|
||||
companyEmail?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(20)
|
||||
companyPhone?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(32)
|
||||
companyLocation?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
companyAddress?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Length(10, 10)
|
||||
@Matches(/^\d+$/, { message: 'TIN must contain only digits' })
|
||||
tin?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(50)
|
||||
vatNumber?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(16)
|
||||
fanNumber?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
contactPersonName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
contactPersonPhone?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
generalManagerName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEmail()
|
||||
generalManagerEmail?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
generalManagerPhone?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
poaName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
poaPhone?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEmail()
|
||||
poaEmail?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
poaLocation?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
poaAddress?: string;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
Post,
|
||||
} from '@nestjs/common';
|
||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { CreateContainerDto } from './dto/create-container.dto';
|
||||
import { UpdateContainerDto } from './dto/update-container.dto';
|
||||
import { AssignContainerToWagonDto } from './dto/assign-container-to-wagon.dto';
|
||||
import { ContainersService } from './containers.service';
|
||||
|
||||
@ApiTags('containers')
|
||||
@Controller('containers')
|
||||
export class ContainersController {
|
||||
constructor(private readonly containersService: ContainersService) {}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: 'Create a new container' })
|
||||
create(@Body() dto: CreateContainerDto) {
|
||||
return this.containersService.create(dto);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List all containers' })
|
||||
findAll() {
|
||||
return this.containersService.findAll();
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get a container by ID' })
|
||||
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.containersService.findById(id);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@ApiOperation({ summary: 'Update a container' })
|
||||
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateContainerDto) {
|
||||
return this.containersService.update(id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@ApiOperation({ summary: 'Delete a container' })
|
||||
remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.containersService.remove(id);
|
||||
}
|
||||
|
||||
@Post(':id/assign-wagon')
|
||||
@ApiOperation({ summary: 'Assign container to a wagon' })
|
||||
assignToWagon(@Param('id', ParseUUIDPipe) id: string, @Body() dto: AssignContainerToWagonDto) {
|
||||
return this.containersService.assignToWagon(id, dto);
|
||||
}
|
||||
|
||||
@Post(':id/unassign-wagon')
|
||||
@ApiOperation({ summary: 'Unassign container from wagon' })
|
||||
unassignFromWagon(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.containersService.unassignFromWagon(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// apps/edr-freight-api/src/modules/container-management/containers.module.ts
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { Container } from './entities/container.entity';
|
||||
import { Wagon } from '../wagons/entities/wagon.entity';
|
||||
import { ContainersController } from './containers.controller';
|
||||
import { ContainersService } from './containers.service';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Container, Wagon])], // ✅ add Wagon
|
||||
controllers: [ContainersController],
|
||||
providers: [ContainersService],
|
||||
})
|
||||
export class ContainersModule {}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { BaseRepository } from '@edr/api-common';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { Container } from './entities/container.entity';
|
||||
|
||||
@Injectable()
|
||||
export class ContainersRepository extends BaseRepository<Container> {
|
||||
constructor(
|
||||
@InjectRepository(Container)
|
||||
repository: Repository<Container>,
|
||||
) {
|
||||
super(repository);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { CreateContainerDto } from './dto/create-container.dto';
|
||||
import { UpdateContainerDto } from './dto/update-container.dto';
|
||||
import { AssignContainerToWagonDto } from './dto/assign-container-to-wagon.dto';
|
||||
import { Container } from './entities/container.entity';
|
||||
//import { ContainersRepository } from './containers.repository';
|
||||
import { WagonsRepository } from '../wagons/wagons.repository';
|
||||
|
||||
@Injectable()
|
||||
export class ContainersService {
|
||||
constructor(
|
||||
@InjectRepository(Container)
|
||||
private readonly containerRepo: Repository<Container>,
|
||||
private readonly wagonsRepository: WagonsRepository,
|
||||
) {}
|
||||
|
||||
async create(dto: CreateContainerDto): Promise<Container> {
|
||||
const container = this.containerRepo.create(dto);
|
||||
// Convert undefined to null for optional fields
|
||||
if (dto.wagonId === undefined) container.wagonId = null;
|
||||
if (dto.position === undefined) container.position = null;
|
||||
return this.containerRepo.save(container);
|
||||
}
|
||||
|
||||
async findAll(): Promise<Container[]> {
|
||||
return this.containerRepo.find({ order: { containerNumber: 'ASC' } });
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<Container> {
|
||||
const container = await this.containerRepo.findOne({ where: { id } });
|
||||
if (!container) throw new NotFoundException(`Container ${id} not found`);
|
||||
return container;
|
||||
}
|
||||
|
||||
async update(id: string, dto: UpdateContainerDto): Promise<Container> {
|
||||
const container = await this.findById(id);
|
||||
Object.assign(container, dto);
|
||||
// Convert undefined to null for nullable fields
|
||||
if (dto.wagonId === undefined) container.wagonId = null;
|
||||
if (dto.position === undefined) container.position = null;
|
||||
return this.containerRepo.save(container);
|
||||
}
|
||||
|
||||
async remove(id: string): Promise<void> {
|
||||
const container = await this.findById(id);
|
||||
await this.containerRepo.remove(container);
|
||||
}
|
||||
|
||||
async assignToWagon(containerId: string, dto: AssignContainerToWagonDto): Promise<Container> {
|
||||
const container = await this.findById(containerId);
|
||||
if (container.status === 'LOADED') {
|
||||
throw new ConflictException('Cannot reassign a loaded container');
|
||||
}
|
||||
|
||||
const wagon = await this.wagonsRepository.findById(dto.wagonId);
|
||||
if (!wagon) throw new NotFoundException('Wagon not found');
|
||||
|
||||
let position: number | null = dto.position ?? null; // convert undefined to null
|
||||
if (position === null) {
|
||||
const maxPos = await this.containerRepo
|
||||
.createQueryBuilder('c')
|
||||
.select('MAX(c.position)', 'max')
|
||||
.where('c.wagonId = :wagonId', { wagonId: wagon.id })
|
||||
.getRawOne();
|
||||
position = (maxPos?.max ?? 0) + 1;
|
||||
}
|
||||
|
||||
container.wagonId = wagon.id;
|
||||
container.position = position; // now position is number | null, safe
|
||||
container.status = 'AVAILABLE';
|
||||
return this.containerRepo.save(container);
|
||||
}
|
||||
|
||||
async unassignFromWagon(containerId: string): Promise<Container> {
|
||||
const container = await this.findById(containerId);
|
||||
if (container.status === 'LOADED') {
|
||||
throw new ConflictException('Cannot unassign a loaded container');
|
||||
}
|
||||
container.wagonId = null;
|
||||
container.position = null;
|
||||
container.status = 'AVAILABLE';
|
||||
return this.containerRepo.save(container);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
// apps/edr-freight-api/src/modules/container-management/containers.service.ts
|
||||
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { CreateContainerDto } from './dto/create-container.dto';
|
||||
import { UpdateContainerDto } from './dto/update-container.dto';
|
||||
import { AssignContainerToWagonDto } from './dto/assign-container-to-wagon.dto';
|
||||
import { Container } from './entities/container.entity';
|
||||
import { Wagon } from '../wagons/entities/wagon.entity';
|
||||
|
||||
@Injectable()
|
||||
export class ContainersService {
|
||||
constructor(
|
||||
@InjectRepository(Container)
|
||||
private readonly containerRepo: Repository<Container>,
|
||||
@InjectRepository(Wagon)
|
||||
private readonly wagonRepo: Repository<Wagon>, // ✅ use raw repository
|
||||
) {}
|
||||
|
||||
async create(dto: CreateContainerDto): Promise<Container> {
|
||||
const container = this.containerRepo.create(dto);
|
||||
if (dto.wagonId === undefined) container.wagonId = null;
|
||||
if (dto.position === undefined) container.position = null;
|
||||
return this.containerRepo.save(container);
|
||||
}
|
||||
|
||||
async findAll(): Promise<Container[]> {
|
||||
return this.containerRepo.find({ order: { containerNumber: 'ASC' } });
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<Container> {
|
||||
const container = await this.containerRepo.findOne({ where: { id } });
|
||||
if (!container) throw new NotFoundException(`Container ${id} not found`);
|
||||
return container;
|
||||
}
|
||||
|
||||
async update(id: string, dto: UpdateContainerDto): Promise<Container> {
|
||||
const container = await this.findById(id);
|
||||
Object.assign(container, dto);
|
||||
if (dto.wagonId === undefined) container.wagonId = null;
|
||||
if (dto.position === undefined) container.position = null;
|
||||
return this.containerRepo.save(container);
|
||||
}
|
||||
|
||||
async remove(id: string): Promise<void> {
|
||||
const container = await this.findById(id);
|
||||
await this.containerRepo.remove(container);
|
||||
}
|
||||
|
||||
async assignToWagon(containerId: string, dto: AssignContainerToWagonDto): Promise<Container> {
|
||||
const container = await this.findById(containerId);
|
||||
if (container.status === 'LOADED') {
|
||||
throw new ConflictException('Cannot reassign a loaded container');
|
||||
}
|
||||
|
||||
const wagon = await this.wagonRepo.findOne({ where: { id: dto.wagonId } });
|
||||
if (!wagon) throw new NotFoundException(`Wagon ${dto.wagonId} not found`);
|
||||
|
||||
let position: number | null = dto.position ?? null;
|
||||
if (position === null) {
|
||||
const maxPos = await this.containerRepo
|
||||
.createQueryBuilder('c')
|
||||
.select('MAX(c.position)', 'max')
|
||||
.where('c.wagonId = :wagonId', { wagonId: wagon.id })
|
||||
.getRawOne();
|
||||
position = (maxPos?.max ?? 0) + 1;
|
||||
}
|
||||
|
||||
container.wagonId = wagon.id;
|
||||
container.position = position;
|
||||
container.status = 'AVAILABLE';
|
||||
return this.containerRepo.save(container);
|
||||
}
|
||||
|
||||
async unassignFromWagon(containerId: string): Promise<Container> {
|
||||
const container = await this.findById(containerId);
|
||||
if (container.status === 'LOADED') {
|
||||
throw new ConflictException('Cannot unassign a loaded container');
|
||||
}
|
||||
container.wagonId = null;
|
||||
container.position = null;
|
||||
container.status = 'AVAILABLE';
|
||||
return this.containerRepo.save(container);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { IsUUID, IsOptional, IsInt, Min } from 'class-validator';
|
||||
|
||||
export class AssignContainerToWagonDto {
|
||||
@IsUUID()
|
||||
wagonId!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
position?: number;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { IsString, IsUUID, IsOptional, IsInt, Min, IsNumber, IsIn } from 'class-validator';
|
||||
|
||||
export class CreateContainerDto {
|
||||
@IsString()
|
||||
containerNumber!: string;
|
||||
|
||||
@IsUUID()
|
||||
containerTypeId!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
wagonId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
position?: number;
|
||||
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
tareWeight!: number;
|
||||
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
maxGrossWeight!: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
sealNumber?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['AVAILABLE', 'LOADED', 'IN_TRANSIT', 'MAINTENANCE', 'DAMAGED'])
|
||||
status?: string;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { PartialType } from '@nestjs/swagger';
|
||||
import { CreateContainerDto } from './create-container.dto';
|
||||
|
||||
export class UpdateContainerDto extends PartialType(CreateContainerDto) {}
|
||||
@@ -0,0 +1,45 @@
|
||||
// apps/edr-freight-api/src/modules/container-management/entities/container.entity.ts
|
||||
import { Entity, Column, ManyToOne, OneToMany, JoinColumn } from 'typeorm';
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Wagon } from '../../wagons/entities/wagon.entity';
|
||||
import { Cargo } from '../../cargoes/entities/cargoes.entity';
|
||||
|
||||
@Entity({ name: 'containers', schema: 'freight' })
|
||||
export class Container extends BaseEntity {
|
||||
@Column({ unique: true, name: 'container_number' })
|
||||
containerNumber!: string;
|
||||
|
||||
@Column({ name: 'container_type_id', type: 'uuid' })
|
||||
containerTypeId!: string;
|
||||
|
||||
@Column({ name: 'wagon_id', type: 'uuid', nullable: true })
|
||||
wagonId!: string | null;
|
||||
|
||||
@Column({ type: 'int', nullable: true })
|
||||
position!: number | null; // position on the wagon (1..N)
|
||||
|
||||
@Column({ name: 'tare_weight', type: 'decimal', precision: 10, scale: 2 })
|
||||
tareWeight!: number;
|
||||
|
||||
@Column({ name: 'max_gross_weight', type: 'decimal', precision: 10, scale: 2 })
|
||||
maxGrossWeight!: number;
|
||||
|
||||
@Column({
|
||||
name: 'seal_number',
|
||||
type: 'varchar',
|
||||
nullable: true,
|
||||
})
|
||||
sealNumber!: string | null;
|
||||
|
||||
@Column({ type: 'varchar', default: 'AVAILABLE' })
|
||||
status!: string; // AVAILABLE, LOADED, IN_TRANSIT, MAINTENANCE, DAMAGED
|
||||
|
||||
// Relationship to Wagon
|
||||
@ManyToOne(() => Wagon, (wagon) => wagon.containers, { onDelete: 'SET NULL' })
|
||||
@JoinColumn({ name: 'wagon_id' })
|
||||
wagon!: Wagon | null;
|
||||
|
||||
// Relationship to Cargo
|
||||
@OneToMany(() => Cargo, (cargo) => cargo.container)
|
||||
cargoes!: Cargo[];
|
||||
}
|
||||
@@ -25,4 +25,12 @@ export class FilesRepository extends BaseRepository<FileRecord> {
|
||||
): Promise<FileRecord | null> {
|
||||
return this.repository.findOne({ where: { resourceId, resource, code } });
|
||||
}
|
||||
|
||||
async deleteByCode(
|
||||
resourceId: string,
|
||||
resource: string,
|
||||
code: string,
|
||||
): Promise<void> {
|
||||
await this.repository.delete({ resourceId, resource, code });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,13 @@ export class FilesService {
|
||||
});
|
||||
}
|
||||
|
||||
/** Replace existing file row for the same resource + code (e.g. contract PDF). */
|
||||
async upsertByCode(input: CreateFileInput): Promise<FileRecord> {
|
||||
const { resourceId, resource, code } = input;
|
||||
await this.filesRepository.deleteByCode(resourceId, resource, code);
|
||||
return this.upload(input);
|
||||
}
|
||||
|
||||
async uploadMany(
|
||||
resourceId: string,
|
||||
resource: string,
|
||||
@@ -72,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("/");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsIn, IsOptional } from 'class-validator';
|
||||
|
||||
import { LOCOMOTIVE_STATUSES } from '../entities/locomotive.entity';
|
||||
|
||||
export class FilterLocomotivesDto {
|
||||
@ApiPropertyOptional({ enum: LOCOMOTIVE_STATUSES })
|
||||
@IsOptional()
|
||||
@IsIn([...LOCOMOTIVE_STATUSES])
|
||||
status?: string;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user