mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 12:58:13 +00:00
telebirr out in the payment
This commit is contained in:
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,66 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Post,
|
||||
Query,
|
||||
Put,
|
||||
} from "@nestjs/common";
|
||||
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
|
||||
import { BackofficeService } from "./backoffice.service";
|
||||
import { CreateOrganizationUserDto } from "./dto/create-organization-user.dto";
|
||||
import { UpdateEmployeeUserRolesDto } from "./dto/update-employee-user-roles.dto";
|
||||
|
||||
@ApiTags("backoffice")
|
||||
@Controller("backoffice")
|
||||
export class BackofficeController {
|
||||
constructor(private readonly backofficeService: BackofficeService) {}
|
||||
|
||||
@Post("organizations/:orgId/users")
|
||||
@ApiOperation({ summary: "Create an organization user without assigning positions" })
|
||||
createOrganizationUser(
|
||||
@Param("orgId", ParseUUIDPipe) organizationId: string,
|
||||
@Body() dto: CreateOrganizationUserDto,
|
||||
) {
|
||||
return this.backofficeService.createOrganizationUser(organizationId, dto);
|
||||
}
|
||||
|
||||
@Get("organizations/:orgId/employees")
|
||||
@ApiOperation({ summary: "Get deduplicated organization employees for backoffice" })
|
||||
getOrganizationEmployees(
|
||||
@Param("orgId", ParseUUIDPipe) organizationId: string,
|
||||
@Query("skip") skip?: string,
|
||||
@Query("take") take?: string,
|
||||
) {
|
||||
return this.backofficeService.getOrganizationEmployees(organizationId, {
|
||||
skip,
|
||||
take,
|
||||
});
|
||||
}
|
||||
|
||||
@Get("organizations/:orgId/employee-users/:userId/roles")
|
||||
@ApiOperation({ summary: "Get org-scoped roles assigned to an employee user" })
|
||||
getEmployeeUserRoles(
|
||||
@Param("orgId", ParseUUIDPipe) organizationId: string,
|
||||
@Param("userId", ParseUUIDPipe) userId: string,
|
||||
) {
|
||||
return this.backofficeService.getEmployeeUserRoles(organizationId, userId);
|
||||
}
|
||||
|
||||
@Put("organizations/:orgId/employee-users/:userId/roles")
|
||||
@ApiOperation({ summary: "Replace org-scoped roles assigned to an employee user" })
|
||||
replaceEmployeeUserRoles(
|
||||
@Param("orgId", ParseUUIDPipe) organizationId: string,
|
||||
@Param("userId", ParseUUIDPipe) userId: string,
|
||||
@Body() dto: UpdateEmployeeUserRolesDto,
|
||||
) {
|
||||
return this.backofficeService.replaceEmployeeUserRoles(
|
||||
organizationId,
|
||||
userId,
|
||||
dto.roleIds,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { TypeOrmModule } from "@nestjs/typeorm";
|
||||
import {
|
||||
Employee,
|
||||
Organization,
|
||||
UserCredential,
|
||||
} from "@tria-plc/iamapi-common";
|
||||
|
||||
import { Role } from "@tria-plc/iamapi-common/entities/iam/user/role.entity";
|
||||
import { UserRole } from "@tria-plc/iamapi-common/entities/iam/user/user-role.entity";
|
||||
import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity";
|
||||
|
||||
import { BackofficeController } from "./backoffice.controller";
|
||||
import { BackofficeService } from "./backoffice.service";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([
|
||||
Employee,
|
||||
Organization,
|
||||
Role,
|
||||
User,
|
||||
UserCredential,
|
||||
UserRole,
|
||||
]),
|
||||
],
|
||||
controllers: [BackofficeController],
|
||||
providers: [BackofficeService],
|
||||
exports: [BackofficeService],
|
||||
})
|
||||
export class BackofficeModule {}
|
||||
@@ -0,0 +1,425 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import { InjectRepository } from "@nestjs/typeorm";
|
||||
import { hashPassword } from "@tria-plc/api-common/utils/argon";
|
||||
import { EUserStatus } from "@tria-plc/api-common/utils/enums/user.enum";
|
||||
import { DataSource, EntityManager, In, IsNull, Repository } from "typeorm";
|
||||
|
||||
import { Employee, Organization, UserCredential } from "@tria-plc/iamapi-common";
|
||||
import { Role } from "@tria-plc/iamapi-common/entities/iam/user/role.entity";
|
||||
import { UserRole } from "@tria-plc/iamapi-common/entities/iam/user/user-role.entity";
|
||||
import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity";
|
||||
|
||||
import { CreateOrganizationUserDto } from "./dto/create-organization-user.dto";
|
||||
|
||||
const RESERVED_ROLE_KEYS = new Set([
|
||||
"super_admin",
|
||||
"organization_admin",
|
||||
"unit_admin",
|
||||
]);
|
||||
const DEFAULT_USER_PASSWORD = "12345678";
|
||||
const ORGANIZATION_ADMIN_ROLE_KEY = "organization_admin";
|
||||
const EDR_ORG_MANAGER_ROLE_KEY = "edr_org_manager";
|
||||
|
||||
@Injectable()
|
||||
export class BackofficeService {
|
||||
constructor(
|
||||
@InjectRepository(Employee)
|
||||
private readonly employeeRepository: Repository<Employee>,
|
||||
@InjectRepository(Organization)
|
||||
private readonly organizationRepository: Repository<Organization>,
|
||||
@InjectRepository(Role)
|
||||
private readonly roleRepository: Repository<Role>,
|
||||
@InjectRepository(UserRole)
|
||||
private readonly userRoleRepository: Repository<UserRole>,
|
||||
@InjectRepository(User)
|
||||
private readonly userRepository: Repository<User>,
|
||||
private readonly dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
async createOrganizationUser(
|
||||
organizationId: string,
|
||||
dto: CreateOrganizationUserDto,
|
||||
) {
|
||||
const organizationExists = await this.organizationRepository.exists({
|
||||
where: { id: organizationId },
|
||||
});
|
||||
|
||||
if (!organizationExists) {
|
||||
throw new NotFoundException("organization_not_found");
|
||||
}
|
||||
|
||||
const email = dto.email.trim().toLowerCase();
|
||||
const username = dto.username.trim().toLowerCase();
|
||||
const phoneNumber = dto.phoneNumber?.trim() || undefined;
|
||||
const assignOrganizationAdmin = dto.assignOrganizationAdmin === true;
|
||||
const name = {
|
||||
en: dto.name.en.trim(),
|
||||
...(dto.name.am?.trim() ? { am: dto.name.am.trim() } : {}),
|
||||
};
|
||||
|
||||
const existingUsers = await this.userRepository.find({
|
||||
where: [{ email }, { username }],
|
||||
select: { id: true, email: true, username: true },
|
||||
});
|
||||
|
||||
const emailUser = existingUsers.find((user) => user.email === email);
|
||||
const usernameUser = existingUsers.find((user) => user.username === username);
|
||||
|
||||
if (emailUser && usernameUser && emailUser.id !== usernameUser.id) {
|
||||
throw new BadRequestException("email_or_username_already_in_use");
|
||||
}
|
||||
|
||||
const existingUser = emailUser ?? usernameUser;
|
||||
const hashedPassword = await hashPassword(DEFAULT_USER_PASSWORD);
|
||||
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
let user = existingUser;
|
||||
|
||||
if (!user) {
|
||||
user = await manager.getRepository(User).save(
|
||||
manager.getRepository(User).create({
|
||||
email,
|
||||
username,
|
||||
phoneNumber,
|
||||
name,
|
||||
isActive: true,
|
||||
hasSetPassword: true,
|
||||
status: EUserStatus.ACCEPTED,
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
await manager.getRepository(User).update(
|
||||
{ id: user.id },
|
||||
{
|
||||
email,
|
||||
username,
|
||||
phoneNumber,
|
||||
name,
|
||||
isActive: true,
|
||||
hasSetPassword: true,
|
||||
status: EUserStatus.ACCEPTED,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const activeCredentialExists = await manager.getRepository(UserCredential).exists({
|
||||
where: {
|
||||
userId: user.id,
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!activeCredentialExists) {
|
||||
await manager.getRepository(UserCredential).insert({
|
||||
userId: user.id,
|
||||
password: hashedPassword,
|
||||
isActive: true,
|
||||
});
|
||||
}
|
||||
|
||||
let employee = await manager.getRepository(Employee).findOne({
|
||||
where: {
|
||||
userId: user.id,
|
||||
organizationId,
|
||||
isCurrent: true,
|
||||
},
|
||||
relations: {
|
||||
user: true,
|
||||
employeePositions: {
|
||||
position: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!employee) {
|
||||
const insertResult = await manager.getRepository(Employee).insert({
|
||||
userId: user.id,
|
||||
organizationId,
|
||||
isCurrent: true,
|
||||
name,
|
||||
});
|
||||
|
||||
employee = await manager.getRepository(Employee).findOne({
|
||||
where: { id: insertResult.identifiers[0]?.id as string },
|
||||
relations: {
|
||||
user: true,
|
||||
employeePositions: {
|
||||
position: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await manager.getRepository(Employee).update(
|
||||
{ id: employee.id },
|
||||
{ name },
|
||||
);
|
||||
|
||||
employee = await manager.getRepository(Employee).findOne({
|
||||
where: { id: employee.id },
|
||||
relations: {
|
||||
user: true,
|
||||
employeePositions: {
|
||||
position: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (!employee) {
|
||||
throw new NotFoundException("employee_create_failed");
|
||||
}
|
||||
|
||||
const userId = user.id;
|
||||
|
||||
if (!userId) {
|
||||
throw new NotFoundException("user_create_failed");
|
||||
}
|
||||
|
||||
if (assignOrganizationAdmin) {
|
||||
await this.ensureOrganizationAdminAccess(manager, organizationId, userId);
|
||||
}
|
||||
|
||||
return employee;
|
||||
});
|
||||
}
|
||||
|
||||
async getEmployeeUserRoles(organizationId: string, userId: string) {
|
||||
await this.assertUserBelongsToOrganization(organizationId, userId);
|
||||
|
||||
const userRoles = await this.userRoleRepository.find({
|
||||
where: {
|
||||
userId,
|
||||
organizationId,
|
||||
unitId: IsNull(),
|
||||
},
|
||||
relations: {
|
||||
role: true,
|
||||
},
|
||||
order: {
|
||||
role: {
|
||||
key: "ASC",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return userRoles
|
||||
.map((userRole) => userRole.role)
|
||||
.filter((role): role is Role => Boolean(role))
|
||||
.map((role) => ({
|
||||
id: role.id,
|
||||
key: role.key,
|
||||
name: role.name,
|
||||
}));
|
||||
}
|
||||
|
||||
async getOrganizationEmployees(
|
||||
organizationId: string,
|
||||
query: { skip?: string; take?: string },
|
||||
) {
|
||||
const organizationExists = await this.organizationRepository.exists({
|
||||
where: { id: organizationId },
|
||||
});
|
||||
|
||||
if (!organizationExists) {
|
||||
throw new NotFoundException("organization_not_found");
|
||||
}
|
||||
|
||||
const take = Number.parseInt(query.take ?? "1000", 10);
|
||||
const skip = Number.parseInt(query.skip ?? "0", 10);
|
||||
|
||||
const employees = await this.employeeRepository.find({
|
||||
where: {
|
||||
organizationId,
|
||||
isCurrent: true,
|
||||
},
|
||||
relations: {
|
||||
user: true,
|
||||
employeePositions: {
|
||||
position: true,
|
||||
},
|
||||
},
|
||||
order: {
|
||||
createdAt: "DESC",
|
||||
},
|
||||
});
|
||||
|
||||
const deduplicated = this.mergeEmployeesByUser(employees);
|
||||
|
||||
return {
|
||||
count: deduplicated.length,
|
||||
items: deduplicated.slice(skip, skip + take),
|
||||
};
|
||||
}
|
||||
|
||||
async replaceEmployeeUserRoles(
|
||||
organizationId: string,
|
||||
userId: string,
|
||||
roleIds: string[],
|
||||
) {
|
||||
await this.assertUserBelongsToOrganization(organizationId, userId);
|
||||
|
||||
const uniqueRoleIds = [...new Set(roleIds)];
|
||||
const roles = uniqueRoleIds.length
|
||||
? await this.roleRepository.find({
|
||||
where: {
|
||||
id: In(uniqueRoleIds),
|
||||
},
|
||||
})
|
||||
: [];
|
||||
|
||||
if (roles.length !== uniqueRoleIds.length) {
|
||||
throw new NotFoundException("one_or_more_roles_not_found");
|
||||
}
|
||||
|
||||
const reservedRoles = roles.filter((role) => RESERVED_ROLE_KEYS.has(role.key));
|
||||
if (reservedRoles.length) {
|
||||
throw new BadRequestException("reserved_roles_must_use_admin_actions");
|
||||
}
|
||||
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
await manager.getRepository(UserRole).delete({
|
||||
userId,
|
||||
organizationId,
|
||||
unitId: IsNull(),
|
||||
});
|
||||
|
||||
if (!roles.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
await manager.getRepository(UserRole).insert(
|
||||
roles.map((role) => ({
|
||||
userId,
|
||||
roleId: role.id,
|
||||
organizationId,
|
||||
})),
|
||||
);
|
||||
});
|
||||
|
||||
return this.getEmployeeUserRoles(organizationId, userId);
|
||||
}
|
||||
|
||||
private async assertUserBelongsToOrganization(
|
||||
organizationId: string,
|
||||
userId: string,
|
||||
) {
|
||||
const exists = await this.userRepository
|
||||
.createQueryBuilder("user")
|
||||
.innerJoin(
|
||||
"user.employee",
|
||||
"employee",
|
||||
"employee.organizationId = :organizationId AND employee.isCurrent = true",
|
||||
{ organizationId },
|
||||
)
|
||||
.where("user.id = :userId", { userId })
|
||||
.getExists();
|
||||
|
||||
if (!exists) {
|
||||
throw new NotFoundException("user_not_found_in_organization");
|
||||
}
|
||||
}
|
||||
|
||||
private mergeEmployeesByUser(employees: Employee[]) {
|
||||
const employeesByUserId = new Map<string, Employee>();
|
||||
|
||||
for (const employee of employees) {
|
||||
const userId = employee.userId;
|
||||
const employeeId = employee.id;
|
||||
|
||||
if (!userId) {
|
||||
if (employeeId) {
|
||||
employeesByUserId.set(employeeId, employee);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const existing = employeesByUserId.get(userId);
|
||||
|
||||
if (!existing) {
|
||||
employeesByUserId.set(userId, employee);
|
||||
continue;
|
||||
}
|
||||
|
||||
const existingPositions = existing.employeePositions ?? [];
|
||||
const nextPositions = employee.employeePositions ?? [];
|
||||
const mergedEmployeePositions = Array.from(
|
||||
new Map(
|
||||
[...existingPositions, ...nextPositions].map((employeePosition) => [
|
||||
employeePosition.id,
|
||||
employeePosition,
|
||||
]),
|
||||
).values(),
|
||||
);
|
||||
|
||||
employeesByUserId.set(userId, {
|
||||
...existing,
|
||||
...employee,
|
||||
id: existing.id,
|
||||
user: existing.user ?? employee.user,
|
||||
userId,
|
||||
name: existing.name ?? employee.name,
|
||||
status: existing.status ?? employee.status,
|
||||
employeePositions: mergedEmployeePositions,
|
||||
});
|
||||
}
|
||||
|
||||
return [...employeesByUserId.values()];
|
||||
}
|
||||
|
||||
private async ensureOrganizationAdminAccess(
|
||||
manager: EntityManager,
|
||||
organizationId: string,
|
||||
userId: string,
|
||||
) {
|
||||
const roles = await manager.getRepository(Role).find({
|
||||
where: [
|
||||
{ key: ORGANIZATION_ADMIN_ROLE_KEY },
|
||||
{ key: EDR_ORG_MANAGER_ROLE_KEY },
|
||||
],
|
||||
select: { id: true, key: true },
|
||||
});
|
||||
|
||||
const requiredRoles = [ORGANIZATION_ADMIN_ROLE_KEY, EDR_ORG_MANAGER_ROLE_KEY].map((key) => {
|
||||
const role = roles.find((item) => item.key === key);
|
||||
|
||||
if (!role?.id) {
|
||||
throw new NotFoundException(`required_role_not_seeded:${key}`);
|
||||
}
|
||||
|
||||
return {
|
||||
id: role.id,
|
||||
key: role.key,
|
||||
};
|
||||
});
|
||||
|
||||
const existingRoleIds = new Set(
|
||||
(
|
||||
await manager.getRepository(UserRole).find({
|
||||
where: {
|
||||
userId,
|
||||
organizationId,
|
||||
},
|
||||
select: { roleId: true },
|
||||
})
|
||||
).map((userRole) => userRole.roleId),
|
||||
);
|
||||
|
||||
const rolesToInsert = requiredRoles
|
||||
.filter((role) => !existingRoleIds.has(role.id))
|
||||
.map((role) => ({
|
||||
userId,
|
||||
roleId: role.id,
|
||||
organizationId,
|
||||
}));
|
||||
|
||||
if (!rolesToInsert.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
await manager.getRepository(UserRole).insert(rolesToInsert);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { IsBoolean, IsEmail, IsObject, IsOptional, IsString, MinLength } from "class-validator";
|
||||
|
||||
class CreateOrganizationUserNameDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
en!: string;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
am?: string;
|
||||
}
|
||||
|
||||
export class CreateOrganizationUserDto {
|
||||
@ApiProperty()
|
||||
@IsEmail()
|
||||
email!: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
username!: string;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
phoneNumber?: string;
|
||||
|
||||
@ApiProperty({ type: CreateOrganizationUserNameDto })
|
||||
@IsObject()
|
||||
name!: CreateOrganizationUserNameDto;
|
||||
|
||||
@ApiProperty({ required: false, default: false })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
assignOrganizationAdmin?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { IsArray, IsUUID } from "class-validator";
|
||||
|
||||
export class UpdateEmployeeUserRolesDto {
|
||||
@ApiProperty({ type: [String] })
|
||||
@IsArray()
|
||||
@IsUUID("4", { each: true })
|
||||
roleIds!: string[];
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
import { BillingService } from "./billing.service";
|
||||
|
||||
@ApiTags("billing")
|
||||
// @UseGuards(JwtAuthGuard) — TODO: integrate @edr/auth
|
||||
@Controller("billing")
|
||||
export class BillingController {
|
||||
constructor(private readonly billingService: BillingService) {}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { BaseEntity } from "@edr/api-common";
|
||||
import { Freight } from "@edr/types";
|
||||
import { Column, Entity } from "typeorm";
|
||||
|
||||
@Entity({ name: "invoices" })
|
||||
@Entity({schema:"freight", name: "invoices" })
|
||||
export class Invoice extends BaseEntity {
|
||||
@Column({ name: "booking_id", type: "uuid" })
|
||||
bookingId!: string;
|
||||
|
||||
@@ -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'] },
|
||||
{
|
||||
key: 'operations',
|
||||
statuses: ['IN_TRANSIT', 'PENDING_CONSOLIDATION', 'CONSOLIDATED','PAID'],
|
||||
},
|
||||
{ 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: 'CUSTOMER_SIGN',
|
||||
description: 'Contract generated; customer must sign',
|
||||
};
|
||||
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: 'AWAIT_PAYMENT',
|
||||
description: 'Awaiting customer 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';
|
||||
import { PaymentService } from '../payment/payment.service';
|
||||
import { PaymentStatus } from '../payment/entities/payment.entity';
|
||||
export interface InAppPaymentReceipt extends InAppPaymentReceiptDto { }
|
||||
|
||||
const NON_TERMINAL_STATUSES: PaymentStatus[] = [
|
||||
"action-required",
|
||||
"processing",
|
||||
"success",
|
||||
];
|
||||
|
||||
@Injectable()
|
||||
export class BookingPaymentService {
|
||||
constructor(
|
||||
private readonly bookingsRepository: BookingsRepository,
|
||||
private readonly paymentService: PaymentService,
|
||||
) { }
|
||||
|
||||
async pay(bookingId: string): Promise<{ redirectUrl: string }> {
|
||||
const booking = await this.requireBooking(bookingId);
|
||||
assertBookingStatus(booking, ['FULLY_EXECUTED', '']);
|
||||
|
||||
const existing = await this.paymentService.findBookingById(bookingId);
|
||||
if (existing && NON_TERMINAL_STATUSES.includes(existing.status)) {
|
||||
if (existing.clientAction) {
|
||||
const action = existing.clientAction as { type?: string; url?: string };
|
||||
if (action.type === "REDIRECT" && action.url) {
|
||||
return { redirectUrl: action.url };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const resp = await this.paymentService.initBookingTelebirr(bookingId, "web");
|
||||
|
||||
return {
|
||||
redirectUrl:
|
||||
resp.redirectUrl ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
private 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,186 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { In, Not } from 'typeorm';
|
||||
|
||||
import { CargoType } from '../rule-engine/entities/cargo-type.entity';
|
||||
import { ContainerType } from '../rule-engine/entities/container-type.entity';
|
||||
import {
|
||||
CARGO_TYPES_REPOSITORY,
|
||||
ICargoTypesRepository,
|
||||
} from '../rule-engine/interfaces/cargo-types.repository.interface';
|
||||
import {
|
||||
CONTAINER_TYPES_REPOSITORY,
|
||||
IContainerTypesRepository,
|
||||
} from '../rule-engine/interfaces/container-types.repository.interface';
|
||||
import {
|
||||
IServiceTypesRepository,
|
||||
SERVICE_TYPES_REPOSITORY,
|
||||
} from '../rule-engine/interfaces/service-types.repository.interface';
|
||||
import {
|
||||
IShippingLinesRepository,
|
||||
SHIPPING_LINES_REPOSITORY,
|
||||
} from '../rule-engine/interfaces/shipping-lines.repository.interface';
|
||||
import {
|
||||
IYardsRepository,
|
||||
YARDS_REPOSITORY,
|
||||
} from '../rule-engine/interfaces/yards.repository.interface';
|
||||
import {
|
||||
BookingReferenceCargoTypeChildDto,
|
||||
BookingReferenceCargoTypeGroupDto,
|
||||
BookingReferenceContainerSizeGroupDto,
|
||||
BookingReferenceContainerTypeDto,
|
||||
BookingReferenceDataDto,
|
||||
BookingReferenceServiceDto,
|
||||
BookingReferenceShippingLineDto,
|
||||
BookingReferenceYardDto,
|
||||
} from './dto/booking-reference-data.dto';
|
||||
|
||||
const LEGACY_YARD_CODES = ['LEGACY_ORIGIN', 'LEGACY_DEST'] as const;
|
||||
|
||||
export function buildCargoTypeTree(
|
||||
rows: CargoType[],
|
||||
): BookingReferenceCargoTypeGroupDto[] {
|
||||
const active = rows.filter((r) => r.isActive);
|
||||
const parents = active
|
||||
.filter((r) => !r.parentGroupId)
|
||||
.sort((a, b) => a.displayOrder - b.displayOrder || a.code.localeCompare(b.code));
|
||||
|
||||
return parents.map((parent) => {
|
||||
const children = active
|
||||
.filter((r) => r.parentGroupId === parent.id)
|
||||
.sort(
|
||||
(a, b) => a.displayOrder - b.displayOrder || a.code.localeCompare(b.code),
|
||||
)
|
||||
.map(
|
||||
(child): BookingReferenceCargoTypeChildDto => ({
|
||||
id: child.id,
|
||||
name: child.cargoTypeName,
|
||||
code: child.code,
|
||||
show_free_text_box: child.showFreeTextBox,
|
||||
}),
|
||||
);
|
||||
|
||||
const group: BookingReferenceCargoTypeGroupDto = {
|
||||
id: parent.id,
|
||||
name: parent.cargoTypeName,
|
||||
code: parent.code,
|
||||
};
|
||||
if (children.length > 0) {
|
||||
group.children = children;
|
||||
}
|
||||
return group;
|
||||
});
|
||||
}
|
||||
|
||||
export function groupContainersBySize(
|
||||
rows: ContainerType[],
|
||||
): BookingReferenceContainerSizeGroupDto[] {
|
||||
const active = rows.filter((r) => r.isActive);
|
||||
const bySize = new Map<string, ContainerType[]>();
|
||||
|
||||
for (const ct of active) {
|
||||
const sizeKey =
|
||||
ct.sizeFt != null && ct.sizeFt > 0 ? `${ct.sizeFt}ft` : 'other';
|
||||
const list = bySize.get(sizeKey) ?? [];
|
||||
list.push(ct);
|
||||
bySize.set(sizeKey, list);
|
||||
}
|
||||
|
||||
const sortSizeKey = (key: string): number => {
|
||||
if (key === 'other') return Number.MAX_SAFE_INTEGER;
|
||||
const n = parseInt(key, 10);
|
||||
return Number.isNaN(n) ? Number.MAX_SAFE_INTEGER - 1 : n;
|
||||
};
|
||||
|
||||
return [...bySize.entries()]
|
||||
.sort(([a], [b]) => sortSizeKey(a) - sortSizeKey(b))
|
||||
.map(([size, types]) => ({
|
||||
size,
|
||||
types: types
|
||||
.sort(
|
||||
(a, b) =>
|
||||
(a.displayOrder ?? 0) - (b.displayOrder ?? 0) ||
|
||||
a.code.localeCompare(b.code),
|
||||
)
|
||||
.map(
|
||||
(ct): BookingReferenceContainerTypeDto => ({
|
||||
id: ct.id,
|
||||
name: ct.label?.trim() ? ct.label : ct.code,
|
||||
code: ct.code,
|
||||
is_reefer: ct.isReefer ?? false,
|
||||
wagons_per_unit: Number(ct.wagonsPerUnit ?? 1),
|
||||
}),
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class BookingReferenceDataService {
|
||||
constructor(
|
||||
@Inject(YARDS_REPOSITORY)
|
||||
private readonly yardsRepository: IYardsRepository,
|
||||
@Inject(CONTAINER_TYPES_REPOSITORY)
|
||||
private readonly containerTypesRepository: IContainerTypesRepository,
|
||||
@Inject(SERVICE_TYPES_REPOSITORY)
|
||||
private readonly serviceTypesRepository: IServiceTypesRepository,
|
||||
@Inject(SHIPPING_LINES_REPOSITORY)
|
||||
private readonly shippingLinesRepository: IShippingLinesRepository,
|
||||
@Inject(CARGO_TYPES_REPOSITORY)
|
||||
private readonly cargoTypesRepository: ICargoTypesRepository,
|
||||
) {}
|
||||
|
||||
async getReferenceData(): Promise<BookingReferenceDataDto> {
|
||||
const [yards, containerTypes, serviceTypes, shippingLines, cargoTypes] =
|
||||
await Promise.all([
|
||||
this.yardsRepository.findAll({
|
||||
where: {
|
||||
isActive: true,
|
||||
code: Not(In([...LEGACY_YARD_CODES])),
|
||||
},
|
||||
order: { displayOrder: 'ASC', code: 'ASC' },
|
||||
}),
|
||||
this.containerTypesRepository.findAll({
|
||||
where: { isActive: true },
|
||||
order: { displayOrder: 'ASC', code: 'ASC' },
|
||||
}),
|
||||
this.serviceTypesRepository.findAll({
|
||||
where: { isActive: true },
|
||||
order: { displayOrder: 'ASC', code: 'ASC' },
|
||||
}),
|
||||
this.shippingLinesRepository.findAll({
|
||||
where: { isActive: true },
|
||||
order: { label: 'ASC', code: 'ASC' },
|
||||
}),
|
||||
this.cargoTypesRepository.findAll({
|
||||
where: { isActive: true },
|
||||
order: { displayOrder: 'ASC', code: 'ASC' },
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
yard: yards.map(
|
||||
(y): BookingReferenceYardDto => ({
|
||||
id: y.id,
|
||||
name: y.label,
|
||||
code: y.code,
|
||||
country: y.country,
|
||||
}),
|
||||
),
|
||||
containers: groupContainersBySize(containerTypes),
|
||||
service: serviceTypes.map(
|
||||
(s): BookingReferenceServiceDto => ({
|
||||
id: s.id,
|
||||
name: s.serviceName,
|
||||
code: s.code,
|
||||
}),
|
||||
),
|
||||
shipping_line: shippingLines.map(
|
||||
(sl): BookingReferenceShippingLineDto => ({
|
||||
id: sl.id,
|
||||
name: sl.label,
|
||||
code: sl.code,
|
||||
}),
|
||||
),
|
||||
cargo_type: buildCargoTypeTree(cargoTypes),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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,324 @@
|
||||
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);
|
||||
}
|
||||
|
||||
if (allDone) {
|
||||
const generated = await this.contractService.generateContract(bookingId);
|
||||
return this.bookingsService.findById(generated.id);
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -6,43 +6,412 @@ import {
|
||||
HttpCode,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
} from "@nestjs/common";
|
||||
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
Request,
|
||||
Res,
|
||||
UploadedFiles,
|
||||
UseInterceptors,
|
||||
} 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,
|
||||
ApiConsumes,
|
||||
ApiOkResponse,
|
||||
ApiOperation,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
import type { Response } from 'express';
|
||||
|
||||
import { BookingsService } from "./bookings.service";
|
||||
import { CreateBookingDto } from "./dto/create-booking.dto";
|
||||
import { FilterBookingDto } from "./dto/filter-booking.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")
|
||||
// @UseGuards(JwtAuthGuard) — TODO: integrate @edr/auth
|
||||
@Controller("bookings")
|
||||
@ApiTags('bookings')
|
||||
@Controller('bookings')
|
||||
@ApiBearerAuth()
|
||||
export class BookingsController {
|
||||
constructor(private readonly bookingsService: BookingsService) {}
|
||||
constructor(
|
||||
private readonly bookingsService: BookingsService,
|
||||
private readonly bookingReferenceDataService: BookingReferenceDataService,
|
||||
private readonly pricingService: BookingPricingService,
|
||||
private readonly transitionService: BookingTransitionService,
|
||||
private readonly contractService: BookingContractService,
|
||||
) {}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: "Create a new freight booking" })
|
||||
create(@Body() dto: CreateBookingDto) {
|
||||
return this.bookingsService.create(dto);
|
||||
@UseInterceptors(AnyFilesInterceptor())
|
||||
@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: { user?: { id?: string; sub?: string } },
|
||||
) {
|
||||
const userId = req.user?.id ?? req.user?.sub;
|
||||
return this.bookingsService.create(dto, files ?? [], userId);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@UseInterceptors(AnyFilesInterceptor())
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiOperation({
|
||||
summary: 'Update booking',
|
||||
description: 'Allowed when status is DRAFT or CHANGES_REQUESTED.',
|
||||
})
|
||||
@ApiBody({ type: UpdateBookingDto })
|
||||
update(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: UpdateBookingDto,
|
||||
@UploadedFiles() files: Express.Multer.File[],
|
||||
) {
|
||||
return this.bookingsService.update(id, dto, files ?? []);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: "List freight bookings (paginated)" })
|
||||
@ApiOperation({ summary: 'List freight bookings (paginated)' })
|
||||
findAll(@Query() filter: FilterBookingDto) {
|
||||
return this.bookingsService.findAll(filter);
|
||||
}
|
||||
|
||||
@Get(":id")
|
||||
@ApiOperation({ summary: "Get a freight booking by ID" })
|
||||
findOne(@Param("id", ParseUUIDPipe) id: string) {
|
||||
return this.bookingsService.findById(id);
|
||||
@Get('list-summary')
|
||||
@ApiOperation({ summary: 'Booking list metrics and tab counts (backoffice)' })
|
||||
@ApiOkResponse({ type: BookingListSummaryDto })
|
||||
findListSummary(@Query() filter: FilterBookingDto) {
|
||||
return this.bookingsService.getListSummary(filter);
|
||||
}
|
||||
|
||||
@Delete(":id")
|
||||
@Get('queues/:queue')
|
||||
@ApiOperation({
|
||||
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();
|
||||
}
|
||||
|
||||
@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);
|
||||
}
|
||||
|
||||
@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);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@HttpCode(204)
|
||||
@ApiOperation({ summary: "Soft-delete a freight booking" })
|
||||
remove(@Param("id", ParseUUIDPipe) id: string) {
|
||||
@ApiOperation({ summary: 'Soft-delete DRAFT booking' })
|
||||
remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.bookingsService.remove(id);
|
||||
}
|
||||
|
||||
@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[],
|
||||
) {
|
||||
const booking = await this.bookingsService.uploadDocuments(id, files ?? []);
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@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: 'Customer digital signature (deprecated — use POST contract/sign)',
|
||||
})
|
||||
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);
|
||||
}
|
||||
|
||||
@Delete(':id/consolidation')
|
||||
@ApiOperation({ summary: 'Remove consolidation pairing' })
|
||||
removeConsolidation(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.bookingsService.removeConsolidation(id);
|
||||
}
|
||||
|
||||
@Get(':id/consolidation')
|
||||
@ApiOperation({ summary: 'Get consolidation details' })
|
||||
getConsolidationDetails(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.bookingsService.getConsolidationDetails(id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,69 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { TypeOrmModule } from "@nestjs/typeorm";
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { BookingsController } from "./bookings.controller";
|
||||
import { BookingsRepository } from "./bookings.repository";
|
||||
import { BookingsService } from "./bookings.service";
|
||||
import { Booking } from "./entities/booking.entity";
|
||||
// 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';
|
||||
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';
|
||||
import { PaymentModule } from '../payment/payment.module';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Booking])],
|
||||
controllers: [BookingsController],
|
||||
providers: [BookingsService, BookingsRepository],
|
||||
exports: [BookingsService],
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([
|
||||
Booking,
|
||||
BookingContainer,
|
||||
BookingCargoModifier,
|
||||
BookingApprovalStep,
|
||||
BookingRateSnapshot,
|
||||
BookingReviewNote,
|
||||
BookingContractSignature,
|
||||
]),
|
||||
PaymentModule,
|
||||
FilesModule,
|
||||
MinioModule,
|
||||
CompaniesModule,
|
||||
// CustomersModule,
|
||||
RuleEngineModule,
|
||||
],
|
||||
controllers: [BookingsController, PayController],
|
||||
providers: [
|
||||
BookingsService,
|
||||
BookingsRepository,
|
||||
ConsolidationService,
|
||||
BookingReferenceDataService,
|
||||
BookingPricingService,
|
||||
BookingTransitionService,
|
||||
BookingContractService,
|
||||
BookingPaymentService,
|
||||
ContractTemplateResolver,
|
||||
ContractViewModelBuilder,
|
||||
ContractPricingScheduleBuilder,
|
||||
ContractRendererService,
|
||||
ContractPdfService,
|
||||
],
|
||||
exports: [BookingsService, BookingsRepository],
|
||||
})
|
||||
export class BookingsModule {}
|
||||
|
||||
@@ -1,15 +1,42 @@
|
||||
import { BaseRepository } from "@edr/api-common";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { InjectRepository } from "@nestjs/typeorm";
|
||||
import { Repository } from "typeorm";
|
||||
import { BaseRepository } from '@edr/api-common';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { DataSource, FindOptionsWhere, Repository, SelectQueryBuilder } from 'typeorm';
|
||||
|
||||
import { Booking } from "./entities/booking.entity";
|
||||
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(
|
||||
@InjectRepository(Booking)
|
||||
repository: Repository<Booking>,
|
||||
private readonly dataSource: DataSource,
|
||||
) {
|
||||
super(repository);
|
||||
}
|
||||
@@ -18,4 +45,544 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
findByReference(reference: string): Promise<Booking | null> {
|
||||
return this.repository.findOne({ where: { reference } });
|
||||
}
|
||||
|
||||
/** Count bookings created in a specific year. */
|
||||
async countByYear(year: number): Promise<number> {
|
||||
const startDate = new Date(year, 0, 1);
|
||||
const endDate = new Date(year + 1, 0, 1);
|
||||
|
||||
return this.repository
|
||||
.createQueryBuilder('booking')
|
||||
.where('booking.created_at >= :startDate', { startDate })
|
||||
.andWhere('booking.created_at < :endDate', { endDate })
|
||||
.getCount();
|
||||
}
|
||||
|
||||
/** Find a booking by reference with files and relations. */
|
||||
async findByReferenceWithFiles(reference: string): Promise<Booking | null> {
|
||||
return this.findByIdWithFiles(
|
||||
(
|
||||
await this.repository.findOne({ where: { reference }, select: ['id'] })
|
||||
)?.id ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
/** Find a booking by ID with files, containers, and config relations. */
|
||||
async findByIdWithFiles(id: string): Promise<Booking | null> {
|
||||
if (!id) return null;
|
||||
|
||||
const booking = await this.repository
|
||||
.createQueryBuilder('booking')
|
||||
.leftJoinAndSelect('booking.bookingContainers', 'bc')
|
||||
.leftJoinAndSelect('bc.containerType', 'ct')
|
||||
.leftJoinAndSelect('booking.company', 'company')
|
||||
// .leftJoinAndSelect('booking.customer', 'customer')
|
||||
.leftJoinAndSelect('booking.train', 'train')
|
||||
.leftJoinAndSelect('booking.serviceType', 'st')
|
||||
.leftJoinAndSelect('booking.cargoType', 'cargo')
|
||||
.leftJoinAndSelect('booking.originYard', 'oy')
|
||||
.leftJoinAndSelect('booking.destinationYard', 'dy')
|
||||
.leftJoinAndSelect('booking.shippingLine', 'sl')
|
||||
.leftJoinAndSelect('booking.approvalSteps', 'steps')
|
||||
.leftJoinAndSelect('booking.rateSnapshots', 'snapshots')
|
||||
.leftJoinAndSelect('booking.cargoModifiers', 'modifiers')
|
||||
.leftJoinAndSelect('booking.reviewNotes', 'reviewNotes')
|
||||
.where('booking.id = :id', { id })
|
||||
.leftJoinAndMapMany(
|
||||
'booking.files',
|
||||
FileRecord,
|
||||
'file',
|
||||
"file.resource_id = booking.id AND file.resource = 'bookings'",
|
||||
)
|
||||
.getOne();
|
||||
|
||||
return booking ?? null;
|
||||
}
|
||||
|
||||
/** Persist booking container rows with weight rule results. */
|
||||
async createContainers(
|
||||
bookingId: string,
|
||||
containers: Array<{
|
||||
containerTypeId: string;
|
||||
quantity: number;
|
||||
vgmPerUnitTons: number;
|
||||
weightResult: ContainerWeightResult;
|
||||
}>,
|
||||
): Promise<BookingContainer[]> {
|
||||
const containerRepo = this.dataSource.getRepository(BookingContainer);
|
||||
const typeRepo = this.dataSource.getRepository(ContainerType);
|
||||
const saved: BookingContainer[] = [];
|
||||
|
||||
for (const item of containers) {
|
||||
const ct = await typeRepo.findOne({ where: { id: item.containerTypeId } });
|
||||
const wagonsPerUnit = ct ? Number(ct.wagonsPerUnit) : 1;
|
||||
const totalVgm = item.quantity * item.vgmPerUnitTons;
|
||||
const wagonsRequired = Math.ceil(item.quantity * wagonsPerUnit);
|
||||
|
||||
const row = containerRepo.create({
|
||||
bookingId,
|
||||
containerTypeId: item.containerTypeId,
|
||||
quantity: item.quantity,
|
||||
vgmPerUnitTons: item.vgmPerUnitTons,
|
||||
totalVgmTons: totalVgm,
|
||||
wagonsRequired,
|
||||
weightLimitRuleId: item.weightResult.weightLimitRuleId,
|
||||
isOverweight: item.weightResult.isOverweight,
|
||||
overweightExcessTons: item.weightResult.overweightExcessTons,
|
||||
});
|
||||
saved.push(await containerRepo.save(row));
|
||||
}
|
||||
|
||||
return saved;
|
||||
}
|
||||
|
||||
/** SQL aggregate wagon count for a booking. */
|
||||
async calculateWagonCount(bookingId: string): Promise<number> {
|
||||
const result = await this.dataSource
|
||||
.createQueryBuilder()
|
||||
.select('CEILING(SUM(bc.quantity * ct.wagons_per_unit))', 'total')
|
||||
.from(BookingContainer, 'bc')
|
||||
.innerJoin(ContainerType, 'ct', 'ct.id = bc.container_type_id')
|
||||
.where('bc.booking_id = :bookingId', { bookingId })
|
||||
.getRawOne<{ total: string }>();
|
||||
|
||||
return Number(result?.total ?? 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find another booking whose container quantity complements this one to fill whole wagon(s)
|
||||
* (same route, same container type, partial wagon on both sides).
|
||||
*/
|
||||
async findComplementaryConsolidationPartner(
|
||||
booking: Booking,
|
||||
slot: {
|
||||
containerTypeId: string;
|
||||
quantity: number;
|
||||
containersPerWagon: number;
|
||||
},
|
||||
): Promise<Booking | null> {
|
||||
const { containerTypeId, quantity, containersPerWagon: perWagon } = slot;
|
||||
|
||||
return this.repository
|
||||
.createQueryBuilder('b')
|
||||
.innerJoinAndSelect('b.bookingContainers', 'bc')
|
||||
.innerJoin('bc.containerType', 'ct')
|
||||
.where('b.id != :bookingId', { bookingId: booking.id })
|
||||
.andWhere('b.allowConsolidation = true')
|
||||
.andWhere('b.consolidationPartnerId IS NULL')
|
||||
.andWhere('b.status IN (:...statuses)', {
|
||||
statuses: ['DRAFT', 'PENDING_CONSOLIDATION'],
|
||||
})
|
||||
.andWhere('b.originYardId = :originYardId', {
|
||||
originYardId: booking.originYardId,
|
||||
})
|
||||
.andWhere('b.destinationYardId = :destinationYardId', {
|
||||
destinationYardId: booking.destinationYardId,
|
||||
})
|
||||
.andWhere('b.tradeDirection = :tradeDirection', {
|
||||
tradeDirection: booking.tradeDirection,
|
||||
})
|
||||
.andWhere('bc.containerTypeId = :containerTypeId', { containerTypeId })
|
||||
.andWhere('(bc.quantity % :perWagon) > 0', { perWagon })
|
||||
.andWhere('((:quantity + bc.quantity) % :perWagon) = 0', {
|
||||
quantity,
|
||||
perWagon,
|
||||
})
|
||||
.orderBy('b.createdAt', 'ASC')
|
||||
.getOne();
|
||||
}
|
||||
|
||||
/** Try each partial-wagon line until a complementary partner booking is found. */
|
||||
async findConsolidationPartner(
|
||||
booking: Booking,
|
||||
slots: Array<{
|
||||
containerTypeId: string;
|
||||
quantity: number;
|
||||
containersPerWagon: number;
|
||||
}>,
|
||||
): Promise<Booking | null> {
|
||||
for (const slot of slots) {
|
||||
const partner = await this.findComplementaryConsolidationPartner(booking, slot);
|
||||
if (partner) return partner;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Pair two bookings for consolidation. */
|
||||
async pairConsolidation(bookingId: string, partnerId: string): Promise<void> {
|
||||
await this.repository.update(bookingId, {
|
||||
consolidationPartnerId: partnerId,
|
||||
status: 'CONSOLIDATED',
|
||||
} as never);
|
||||
await this.repository.update(partnerId, {
|
||||
consolidationPartnerId: bookingId,
|
||||
status: 'CONSOLIDATED',
|
||||
} as never);
|
||||
}
|
||||
|
||||
/** Un-pair a consolidation. */
|
||||
async unpairConsolidation(bookingId: string, partnerId: string): Promise<void> {
|
||||
await this.repository.update(bookingId, {
|
||||
consolidationPartnerId: null,
|
||||
status: 'PENDING_CONSOLIDATION',
|
||||
} as never);
|
||||
await this.repository.update(partnerId, {
|
||||
consolidationPartnerId: null,
|
||||
status: 'PENDING_CONSOLIDATION',
|
||||
} as never);
|
||||
}
|
||||
|
||||
/** Delete all containers for a booking (used on draft update). */
|
||||
async deleteContainers(bookingId: string): Promise<void> {
|
||||
await this.dataSource.getRepository(BookingContainer).delete({ bookingId });
|
||||
}
|
||||
|
||||
/** 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> {
|
||||
const next = await this.findNextPendingApprovalStep(bookingId);
|
||||
if (!next || next.requiredRole !== requiredRole) return null;
|
||||
return next;
|
||||
}
|
||||
|
||||
/** Mark an approval step complete. */
|
||||
async completeApprovalStep(
|
||||
stepId: string,
|
||||
actorId: string,
|
||||
status: 'APPROVED' | 'REJECTED',
|
||||
remarks?: string,
|
||||
): Promise<void> {
|
||||
await this.dataSource.getRepository(BookingApprovalStep).update(stepId, {
|
||||
status,
|
||||
actionedByStaffId: actorId,
|
||||
actionedAt: new Date(),
|
||||
remarks,
|
||||
});
|
||||
}
|
||||
|
||||
/** Check if all approval steps are approved. */
|
||||
async allApprovalStepsComplete(bookingId: string): Promise<boolean> {
|
||||
const pending = await this.dataSource.getRepository(BookingApprovalStep).count({
|
||||
where: { bookingId, status: 'PENDING' },
|
||||
});
|
||||
return pending === 0;
|
||||
}
|
||||
|
||||
/** Persist cargo modifiers linked to rate snapshots. */
|
||||
async createCargoModifiers(
|
||||
rows: Array<{
|
||||
bookingId: string;
|
||||
surchargeTypeId: string;
|
||||
triggerValue: number | null;
|
||||
calculatedAmount: number;
|
||||
rateSnapshotId: string;
|
||||
}>,
|
||||
): Promise<BookingCargoModifier[]> {
|
||||
const repo = this.dataSource.getRepository(BookingCargoModifier);
|
||||
const saved: BookingCargoModifier[] = [];
|
||||
for (const row of rows) {
|
||||
saved.push(await repo.save(repo.create(row)));
|
||||
}
|
||||
return saved;
|
||||
}
|
||||
|
||||
/** Find rate snapshot by rate id for a booking. */
|
||||
async findRateSnapshotByRateId(
|
||||
bookingId: string,
|
||||
rateId: string,
|
||||
): Promise<BookingRateSnapshot | null> {
|
||||
return this.dataSource.getRepository(BookingRateSnapshot).findOne({
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +1,416 @@
|
||||
import { Injectable, NotFoundException } from "@nestjs/common";
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
// 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';
|
||||
import {
|
||||
BookingEvaluationInput,
|
||||
RuleEngineService,
|
||||
} 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 {
|
||||
BOOKING_STATUSES,
|
||||
CUSTOMER_EDITABLE_STATUSES,
|
||||
FreightType,
|
||||
} from './entities/booking.entity';
|
||||
import { Booking } from './entities/booking.entity';
|
||||
import { FileRecord } from '../files/entities/file.entity';
|
||||
|
||||
import { BookingsRepository } from "./bookings.repository";
|
||||
import { CreateBookingDto } from "./dto/create-booking.dto";
|
||||
import { FilterBookingDto } from "./dto/filter-booking.dto";
|
||||
import { Booking } from "./entities/booking.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) {}
|
||||
constructor(
|
||||
private readonly bookingsRepository: BookingsRepository,
|
||||
private readonly filesService: FilesService,
|
||||
private readonly minioService: MinioService,
|
||||
// private readonly customersService: CustomersService,
|
||||
private readonly companiesService: CompaniesService,
|
||||
private readonly ruleEngineService: RuleEngineService,
|
||||
private readonly containerTypesService: ContainerTypesService,
|
||||
private readonly consolidationService: ConsolidationService,
|
||||
) {}
|
||||
|
||||
/** Generate a unique booking reference number. */
|
||||
private async generateReference(): Promise<string> {
|
||||
const year = new Date().getFullYear();
|
||||
const count = await this.bookingsRepository.countByYear(year);
|
||||
return `BK-${year}-${String(count + 1).padStart(6, '0')}`;
|
||||
}
|
||||
|
||||
/** 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(
|
||||
containerLines.map(async (c) => {
|
||||
const ct = await this.containerTypesService.findById(c.containerTypeId);
|
||||
const totalVgmTons = c.quantity * c.vgmPerUnitTons;
|
||||
return {
|
||||
containerTypeId: c.containerTypeId,
|
||||
quantity: c.quantity,
|
||||
vgmPerUnitTons: c.vgmPerUnitTons,
|
||||
totalVgmTons,
|
||||
isReefer: ct.isReefer,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
freightType: dto.freightType,
|
||||
cargoTypeId: dto.cargoTypeId ?? null,
|
||||
serviceTypeId: dto.serviceTypeId,
|
||||
paymentCurrency: dto.paymentCurrency,
|
||||
tradeDirection: dto.tradeDirection,
|
||||
isHazardous: dto.isHazardous ?? false,
|
||||
allowConsolidation:
|
||||
dto.freightType === 'CONTAINER' ? dto.allowConsolidation : false,
|
||||
shippingLineId: dto.shippingLineId,
|
||||
containers,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable consolidation when any container line leaves a wagon partially filled
|
||||
* (e.g. 1×20ft on a 2-slot wagon, 1×10ft on a 4-slot wagon), unless opted out.
|
||||
*/
|
||||
private async resolveConsolidation(
|
||||
containers: CreateBookingContainerDto[],
|
||||
explicit?: boolean,
|
||||
): Promise<boolean> {
|
||||
if (explicit === false) return false;
|
||||
const needs = await this.consolidationService.needsConsolidation(
|
||||
containers.map((c) => ({
|
||||
containerTypeId: c.containerTypeId,
|
||||
quantity: c.quantity,
|
||||
})),
|
||||
);
|
||||
if (needs) return true;
|
||||
return explicit ?? false;
|
||||
}
|
||||
|
||||
/** Search for a complementary partner; pair or queue as PENDING_CONSOLIDATION. */
|
||||
private async tryAutoConsolidate(booking: Booking): Promise<{
|
||||
booking: Booking;
|
||||
messages: string[];
|
||||
}> {
|
||||
const messages: string[] = [];
|
||||
|
||||
if (!booking.allowConsolidation || booking.consolidationPartnerId) {
|
||||
return { booking, messages };
|
||||
}
|
||||
|
||||
const slots = await this.consolidationService.slotsFromBooking(booking);
|
||||
if (slots.length === 0) {
|
||||
return { booking, messages };
|
||||
}
|
||||
|
||||
const partner = await this.bookingsRepository.findConsolidationPartner(
|
||||
booking,
|
||||
slots,
|
||||
);
|
||||
|
||||
if (partner) {
|
||||
await this.bookingsRepository.pairConsolidation(booking.id, partner.id);
|
||||
const paired = await this.findById(booking.id);
|
||||
messages.push(
|
||||
this.consolidationService.describePaired(partner.reference, slots),
|
||||
);
|
||||
return { booking: paired, messages };
|
||||
}
|
||||
|
||||
if (booking.status === 'DRAFT') {
|
||||
await this.bookingsRepository.update(booking.id, {
|
||||
status: 'PENDING_CONSOLIDATION',
|
||||
} as never);
|
||||
}
|
||||
|
||||
const pending = await this.findById(booking.id);
|
||||
messages.push(this.consolidationService.describePending(pending, slots));
|
||||
return { booking: pending, messages };
|
||||
}
|
||||
|
||||
/** Create a new freight booking. */
|
||||
async create(dto: CreateBookingDto): Promise<Booking> {
|
||||
return this.bookingsRepository.create({
|
||||
...dto,
|
||||
scheduledDate: new Date(dto.scheduledDate),
|
||||
async create(
|
||||
dto: CreateBookingDto,
|
||||
files: Express.Multer.File[],
|
||||
userId?: string,
|
||||
): Promise<{ booking: Booking; warnings: string[] }> {
|
||||
const warnings: string[] = [];
|
||||
|
||||
// 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(
|
||||
'companyId is required or must be resolvable from auth token',
|
||||
);
|
||||
}
|
||||
const { company } = await this.companiesService.getCompanyInfoByUserId(userId);
|
||||
companyId = company.id;
|
||||
}
|
||||
|
||||
const reference = dto.reference || (await this.generateReference());
|
||||
const containers = dto.containers ?? [];
|
||||
assertFreightShape({
|
||||
freightType: dto.freightType,
|
||||
cargoTypeId: dto.cargoTypeId,
|
||||
containers,
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
warnings.push(...ruleResult.warnings);
|
||||
|
||||
const booking = await this.bookingsRepository.create({
|
||||
reference,
|
||||
companyId,
|
||||
trainId: dto.trainId,
|
||||
contractType: dto.contractType,
|
||||
previousContractId: dto.previousContractId,
|
||||
serviceTypeId: dto.serviceTypeId,
|
||||
firstMilePickupAddress: dto.firstMilePickupAddress,
|
||||
lastMileDeliveryAddress: dto.lastMileDeliveryAddress,
|
||||
equipmentReturn: dto.equipmentReturn,
|
||||
originYardId: dto.originYardId,
|
||||
destinationYardId: dto.destinationYardId,
|
||||
tradeDirection: dto.tradeDirection,
|
||||
freightType: dto.freightType,
|
||||
cargoTypeId: dto.freightType === 'BULK' ? dto.cargoTypeId! : null,
|
||||
cargoFreeText: dto.cargoFreeText,
|
||||
shippingLineId: dto.shippingLineId,
|
||||
cargoTotalWeightVgm: dto.cargoTotalWeightVgm,
|
||||
isHazardous: dto.isHazardous ?? false,
|
||||
paymentCurrency: dto.paymentCurrency,
|
||||
pnrCode: dto.pnrCode,
|
||||
financialTerms: dto.financialTerms,
|
||||
scheduledDate: new Date(dto.scheduledDate),
|
||||
startDate: dto.startDate ? new Date(dto.startDate) : undefined,
|
||||
endDate: dto.endDate ? new Date(dto.endDate) : undefined,
|
||||
status: 'DRAFT',
|
||||
allowConsolidation,
|
||||
priorityScore: ruleResult.priorityScore,
|
||||
totalAmount: 0,
|
||||
paymentStatus: 'PENDING',
|
||||
});
|
||||
|
||||
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 {
|
||||
await this.filesService.uploadMany(booking.id, 'bookings', files);
|
||||
} catch {
|
||||
warnings.push('File upload failed — booking was created without attached files.');
|
||||
}
|
||||
}
|
||||
|
||||
let full = await this.findById(booking.id);
|
||||
|
||||
if (allowConsolidation) {
|
||||
const consolidation = await this.tryAutoConsolidate(full);
|
||||
full = consolidation.booking;
|
||||
warnings.push(...consolidation.messages);
|
||||
}
|
||||
|
||||
return { booking: full, warnings };
|
||||
}
|
||||
|
||||
/** Update a draft booking. */
|
||||
async update(
|
||||
id: string,
|
||||
dto: UpdateBookingDto,
|
||||
files: Express.Multer.File[],
|
||||
): Promise<{ booking: Booking; warnings: string[] }> {
|
||||
const existing = await this.findById(id);
|
||||
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 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),
|
||||
})) ??
|
||||
[];
|
||||
|
||||
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({
|
||||
freightType,
|
||||
cargoTypeId,
|
||||
serviceTypeId: dto.serviceTypeId ?? existing.serviceTypeId,
|
||||
paymentCurrency: dto.paymentCurrency ?? existing.paymentCurrency,
|
||||
tradeDirection: dto.tradeDirection ?? existing.tradeDirection,
|
||||
isHazardous: dto.isHazardous ?? existing.isHazardous,
|
||||
allowConsolidation,
|
||||
shippingLineId: dto.shippingLineId ?? existing.shippingLineId ?? undefined,
|
||||
containers,
|
||||
});
|
||||
|
||||
const ruleResult = await this.ruleEngineService.evaluate(evalInput);
|
||||
this.ruleEngineService.assertNoHardBlocks(ruleResult);
|
||||
warnings.push(...ruleResult.warnings);
|
||||
|
||||
const updates: Record<string, unknown> = {
|
||||
...dto,
|
||||
freightType,
|
||||
cargoTypeId: freightType === 'BULK' ? cargoTypeId : null,
|
||||
allowConsolidation,
|
||||
priorityScore: ruleResult.priorityScore,
|
||||
};
|
||||
if (dto.scheduledDate) updates.scheduledDate = new Date(dto.scheduledDate);
|
||||
if (dto.startDate) updates.startDate = new Date(dto.startDate);
|
||||
if (dto.endDate) updates.endDate = new Date(dto.endDate);
|
||||
delete updates.containers;
|
||||
|
||||
await this.bookingsRepository.update(id, updates);
|
||||
|
||||
if (freightType === 'CONTAINER' && dto.containers) {
|
||||
await this.bookingsRepository.deleteContainers(id);
|
||||
await this.bookingsRepository.createContainers(
|
||||
id,
|
||||
dto.containers.map((c, i) => ({
|
||||
containerTypeId: c.containerTypeId,
|
||||
quantity: c.quantity,
|
||||
vgmPerUnitTons: c.vgmPerUnitTons,
|
||||
weightResult: ruleResult.containerWeightResults[i],
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
if (files.length > 0) {
|
||||
await this.filesService.uploadMany(id, 'bookings', files);
|
||||
}
|
||||
|
||||
let booking = await this.findById(id);
|
||||
|
||||
if (allowConsolidation && !booking.consolidationPartnerId) {
|
||||
const consolidation = await this.tryAutoConsolidate(booking);
|
||||
booking = consolidation.booking;
|
||||
warnings.push(...consolidation.messages);
|
||||
}
|
||||
|
||||
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. */
|
||||
@@ -23,30 +419,233 @@ export class BookingsService {
|
||||
): Promise<{ items: Booking[]; total: number }> {
|
||||
const page = filter.page ?? 1;
|
||||
const pageSize = filter.pageSize ?? 20;
|
||||
const [items, total] = await this.bookingsRepository.findAndCount({
|
||||
where: {
|
||||
...(filter.status ? { status: filter.status } : {}),
|
||||
...(filter.customerId ? { customerId: filter.customerId } : {}),
|
||||
},
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
order: { createdAt: "DESC" },
|
||||
const statusFilter = this.parseStatusFilter(filter);
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
/** Get a single booking by ID, throwing if not found. */
|
||||
/** 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. */
|
||||
async findById(id: string): Promise<Booking> {
|
||||
const booking = await this.bookingsRepository.findById(id);
|
||||
const booking = await this.bookingsRepository.findByIdWithFiles(id);
|
||||
if (!booking) {
|
||||
throw new NotFoundException(`Booking ${id} not found`);
|
||||
}
|
||||
|
||||
if (booking.files && booking.files.length > 0) {
|
||||
booking.files = await Promise.all(
|
||||
booking.files.map(async (file: FileRecord) => {
|
||||
const objectName = this.minioService.getObjectNameFromUrl(file.url);
|
||||
const signedUrl = await this.minioService.getSignedUrl(objectName, 300);
|
||||
return { ...file, signedUrl };
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return booking;
|
||||
}
|
||||
|
||||
/** Soft-delete a booking. */
|
||||
async findByReference(reference: string): Promise<Booking> {
|
||||
const booking = await this.bookingsRepository.findByReferenceWithFiles(reference);
|
||||
if (!booking) {
|
||||
throw new NotFoundException(`Booking with reference "${reference}" not found`);
|
||||
}
|
||||
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> {
|
||||
await this.findById(id);
|
||||
const booking = await this.findById(id);
|
||||
if (booking.status !== 'DRAFT') {
|
||||
throw new BadRequestException('Only DRAFT bookings can be deleted');
|
||||
}
|
||||
await this.bookingsRepository.softDelete(id);
|
||||
}
|
||||
|
||||
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',
|
||||
};
|
||||
|
||||
const status = statusMap[queue];
|
||||
if (!status) {
|
||||
throw new BadRequestException(`Unknown queue: ${queue}`);
|
||||
}
|
||||
|
||||
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<{
|
||||
booking: Booking;
|
||||
partner: Booking | null;
|
||||
paired: boolean;
|
||||
message: string;
|
||||
}> {
|
||||
const booking = await this.findById(id);
|
||||
|
||||
if (!booking.allowConsolidation) {
|
||||
throw new BadRequestException('Booking is not eligible for consolidation');
|
||||
}
|
||||
|
||||
const needs = await this.consolidationService.needsConsolidationFromBooking(
|
||||
booking,
|
||||
);
|
||||
if (!needs) {
|
||||
throw new BadRequestException(
|
||||
'Booking already fills whole wagon(s) for all container lines; consolidation is not required',
|
||||
);
|
||||
}
|
||||
|
||||
if (booking.consolidationPartnerId) {
|
||||
throw new ConflictException('Booking is already paired for consolidation');
|
||||
}
|
||||
|
||||
const result = await this.tryAutoConsolidate(booking);
|
||||
const partner = result.booking.consolidationPartnerId
|
||||
? await this.findById(result.booking.consolidationPartnerId)
|
||||
: null;
|
||||
|
||||
return {
|
||||
booking: result.booking,
|
||||
partner,
|
||||
paired: partner !== null,
|
||||
message: result.messages[0] ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
async removeConsolidation(id: string): Promise<{ booking: Booking; partner: Booking }> {
|
||||
const booking = await this.findById(id);
|
||||
if (!booking.consolidationPartnerId) {
|
||||
throw new BadRequestException('Booking has no consolidation partner');
|
||||
}
|
||||
|
||||
const partnerId = booking.consolidationPartnerId;
|
||||
await this.bookingsRepository.unpairConsolidation(id, partnerId);
|
||||
|
||||
return {
|
||||
booking: await this.findById(id),
|
||||
partner: await this.findById(partnerId),
|
||||
};
|
||||
}
|
||||
|
||||
async getConsolidationDetails(id: string): Promise<{
|
||||
booking: Booking;
|
||||
partner: Booking | null;
|
||||
splitBilling: { bookingShare: number; partnerShare: number } | null;
|
||||
wagonSlots: Awaited<ReturnType<ConsolidationService['slotsFromBooking']>>;
|
||||
statusMessage: string;
|
||||
}> {
|
||||
const booking = await this.findById(id);
|
||||
const wagonSlots = await this.consolidationService.slotsFromBooking(booking);
|
||||
|
||||
if (!booking.consolidationPartnerId) {
|
||||
const statusMessage =
|
||||
booking.status === 'PENDING_CONSOLIDATION'
|
||||
? this.consolidationService.describePending(booking, wagonSlots)
|
||||
: wagonSlots.length > 0
|
||||
? 'Consolidation may be required; no partner paired yet.'
|
||||
: 'No wagon consolidation needed.';
|
||||
return {
|
||||
booking,
|
||||
partner: null,
|
||||
splitBilling: null,
|
||||
wagonSlots,
|
||||
statusMessage,
|
||||
};
|
||||
}
|
||||
|
||||
const partner = await this.findById(booking.consolidationPartnerId);
|
||||
return {
|
||||
booking,
|
||||
partner,
|
||||
splitBilling: {
|
||||
bookingShare: Number(booking.totalAmount),
|
||||
partnerShare: Number(partner.totalAmount),
|
||||
},
|
||||
wagonSlots,
|
||||
statusMessage: this.consolidationService.describePaired(
|
||||
partner.reference,
|
||||
wagonSlots,
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
|
||||
import { Booking } from './entities/booking.entity';
|
||||
|
||||
export interface ConsolidationSlot {
|
||||
containerTypeId: string;
|
||||
containerTypeCode: string;
|
||||
quantity: number;
|
||||
containersPerWagon: number;
|
||||
remainder: number;
|
||||
slotsNeeded: number;
|
||||
}
|
||||
|
||||
export interface ConsolidationAttemptResult {
|
||||
booking: Booking;
|
||||
partner: Booking | null;
|
||||
paired: boolean;
|
||||
messages: string[];
|
||||
}
|
||||
|
||||
/** Containers that fit on one wagon for a given container type (inverse of wagons_per_unit). */
|
||||
export function containersPerWagon(wagonsPerUnit: number): number {
|
||||
const wpu = Number(wagonsPerUnit);
|
||||
if (!wpu || wpu <= 0) return 1;
|
||||
return Math.max(1, Math.round(1 / wpu));
|
||||
}
|
||||
|
||||
export function wagonRemainder(quantity: number, perWagon: number): number {
|
||||
const r = quantity % perWagon;
|
||||
return r;
|
||||
}
|
||||
|
||||
export function slotsNeededToFillWagon(quantity: number, perWagon: number): number {
|
||||
const remainder = wagonRemainder(quantity, perWagon);
|
||||
if (remainder === 0) return 0;
|
||||
return perWagon - remainder;
|
||||
}
|
||||
|
||||
/** Two bookings' quantities for the same type complete whole wagon(s). */
|
||||
export function quantitiesComplementWagon(
|
||||
q1: number,
|
||||
q2: number,
|
||||
perWagon: number,
|
||||
): boolean {
|
||||
return (
|
||||
wagonRemainder(q1, perWagon) > 0 &&
|
||||
wagonRemainder(q2, perWagon) > 0 &&
|
||||
(q1 + q2) % perWagon === 0
|
||||
);
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ConsolidationService {
|
||||
constructor(private readonly containerTypesService: ContainerTypesService) {}
|
||||
|
||||
async slotsFromContainerLines(
|
||||
lines: Array<{ containerTypeId: string; quantity: number }>,
|
||||
): Promise<ConsolidationSlot[]> {
|
||||
const slots: ConsolidationSlot[] = [];
|
||||
for (const line of lines) {
|
||||
const ct = await this.containerTypesService.findById(line.containerTypeId);
|
||||
const perWagon = containersPerWagon(Number(ct.wagonsPerUnit));
|
||||
const remainder = wagonRemainder(line.quantity, perWagon);
|
||||
if (remainder === 0) continue;
|
||||
slots.push({
|
||||
containerTypeId: line.containerTypeId,
|
||||
containerTypeCode: ct.code,
|
||||
quantity: line.quantity,
|
||||
containersPerWagon: perWagon,
|
||||
remainder,
|
||||
slotsNeeded: perWagon - remainder,
|
||||
});
|
||||
}
|
||||
return slots;
|
||||
}
|
||||
|
||||
async slotsFromBooking(booking: Booking): Promise<ConsolidationSlot[]> {
|
||||
const lines =
|
||||
booking.bookingContainers?.map((bc) => ({
|
||||
containerTypeId: bc.containerTypeId,
|
||||
quantity: bc.quantity,
|
||||
})) ?? [];
|
||||
return this.slotsFromContainerLines(lines);
|
||||
}
|
||||
|
||||
async needsConsolidation(
|
||||
lines: Array<{ containerTypeId: string; quantity: number }>,
|
||||
): Promise<boolean> {
|
||||
const slots = await this.slotsFromContainerLines(lines);
|
||||
return slots.length > 0;
|
||||
}
|
||||
|
||||
async needsConsolidationFromBooking(booking: Booking): Promise<boolean> {
|
||||
const slots = await this.slotsFromBooking(booking);
|
||||
return slots.length > 0;
|
||||
}
|
||||
|
||||
describePending(_booking: Booking, slots: ConsolidationSlot[]): string {
|
||||
if (slots.length === 0) {
|
||||
return 'Booking does not require wagon consolidation.';
|
||||
}
|
||||
const parts = slots.map(
|
||||
(s) =>
|
||||
`${s.slotsNeeded} more × ${s.containerTypeCode} (${s.containersPerWagon} per wagon; you have ${s.quantity})`,
|
||||
);
|
||||
return (
|
||||
`No compatible partner found yet. Your booking is queued (PENDING_CONSOLIDATION). ` +
|
||||
`Waiting for: ${parts.join('; ')}. You will be notified when another customer fills the wagon.`
|
||||
);
|
||||
}
|
||||
|
||||
describePaired(partnerReference: string, slots: ConsolidationSlot[]): string {
|
||||
const parts = slots.map(
|
||||
(s) =>
|
||||
`${s.containerTypeCode}: ${s.quantity} + partner fills wagon (${s.containersPerWagon} per wagon)`,
|
||||
);
|
||||
return (
|
||||
`Consolidation partner found (${partnerReference}). ` +
|
||||
`Shared wagon confirmed: ${parts.join('; ')}.`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,107 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class BookingReferenceYardDto {
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({ example: 'Mojo Dry Port' })
|
||||
name!: string;
|
||||
|
||||
@ApiProperty({ example: 'MOJO' })
|
||||
code!: string;
|
||||
|
||||
@ApiProperty({ example: 'Ethiopia' })
|
||||
country!: string;
|
||||
}
|
||||
|
||||
export class BookingReferenceContainerTypeDto {
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({ example: 'Dry' })
|
||||
name!: string;
|
||||
|
||||
@ApiProperty({ example: '20GP' })
|
||||
code!: string;
|
||||
|
||||
@ApiProperty()
|
||||
is_reefer!: boolean;
|
||||
|
||||
@ApiProperty({ example: 0.5, description: 'Wagon fraction per container' })
|
||||
wagons_per_unit!: number;
|
||||
}
|
||||
|
||||
export class BookingReferenceContainerSizeGroupDto {
|
||||
@ApiProperty({ example: '20ft' })
|
||||
size!: string;
|
||||
|
||||
@ApiProperty({ type: [BookingReferenceContainerTypeDto] })
|
||||
types!: BookingReferenceContainerTypeDto[];
|
||||
}
|
||||
|
||||
export class BookingReferenceServiceDto {
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({ example: 'Rail Transport Only' })
|
||||
name!: string;
|
||||
|
||||
@ApiProperty({ example: 'RAIL' })
|
||||
code!: string;
|
||||
}
|
||||
|
||||
export class BookingReferenceShippingLineDto {
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({ example: 'MSC' })
|
||||
name!: string;
|
||||
|
||||
@ApiProperty({ example: 'MSC' })
|
||||
code!: string;
|
||||
}
|
||||
|
||||
export class BookingReferenceCargoTypeChildDto {
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({ example: 'Coffee' })
|
||||
name!: string;
|
||||
|
||||
@ApiProperty({ example: 'BULK_COFFEE' })
|
||||
code!: string;
|
||||
|
||||
@ApiProperty()
|
||||
show_free_text_box!: boolean;
|
||||
}
|
||||
|
||||
export class BookingReferenceCargoTypeGroupDto {
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({ example: 'Bulk Cargo' })
|
||||
name!: string;
|
||||
|
||||
@ApiProperty({ example: 'BULK' })
|
||||
code!: string;
|
||||
|
||||
@ApiPropertyOptional({ type: [BookingReferenceCargoTypeChildDto] })
|
||||
children?: BookingReferenceCargoTypeChildDto[];
|
||||
}
|
||||
|
||||
export class BookingReferenceDataDto {
|
||||
@ApiProperty({ type: [BookingReferenceYardDto] })
|
||||
yard!: BookingReferenceYardDto[];
|
||||
|
||||
@ApiProperty({ type: [BookingReferenceContainerSizeGroupDto] })
|
||||
containers!: BookingReferenceContainerSizeGroupDto[];
|
||||
|
||||
@ApiProperty({ type: [BookingReferenceServiceDto] })
|
||||
service!: BookingReferenceServiceDto[];
|
||||
|
||||
@ApiProperty({ type: [BookingReferenceShippingLineDto] })
|
||||
shipping_line!: BookingReferenceShippingLineDto[];
|
||||
|
||||
@ApiProperty({ type: [BookingReferenceCargoTypeGroupDto] })
|
||||
cargo_type!: BookingReferenceCargoTypeGroupDto[];
|
||||
}
|
||||
@@ -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,33 +1,197 @@
|
||||
import { Freight } from "@edr/types";
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Transform, Type } from 'class-transformer';
|
||||
import {
|
||||
ArrayMinSize,
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsDateString,
|
||||
IsEnum,
|
||||
IsIn,
|
||||
IsInt,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
Min,
|
||||
} from "class-validator";
|
||||
Validate,
|
||||
ValidateIf,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
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;
|
||||
const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'DOMESTIC'] as const;
|
||||
const PAYMENT_CURRENCIES = ['ETB', 'USD'] as const;
|
||||
|
||||
export {
|
||||
BOOKING_STATUSES,
|
||||
CONTRACT_TYPES,
|
||||
EQUIPMENT_RETURNS,
|
||||
FREIGHT_TYPES,
|
||||
TRADE_DIRECTIONS,
|
||||
PAYMENT_CURRENCIES,
|
||||
};
|
||||
|
||||
export class CreateBookingContainerDto {
|
||||
@ApiProperty({ format: 'uuid', description: 'FK to container_types.id' })
|
||||
@IsUUID()
|
||||
containerTypeId!: string;
|
||||
|
||||
@ApiProperty({ description: 'Quantity of containers', minimum: 1 })
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Transform(({ value }) => Number(value))
|
||||
quantity!: number;
|
||||
|
||||
@ApiProperty({ description: 'VGM per container in tons', minimum: 0 })
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Transform(({ value }) => Number(value))
|
||||
vgmPerUnitTons!: number;
|
||||
}
|
||||
|
||||
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()
|
||||
reference!: string;
|
||||
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
|
||||
reference?: string;
|
||||
|
||||
// @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()
|
||||
@IsUUID()
|
||||
trainId?: string;
|
||||
|
||||
@ApiProperty({ example: '2026-06-15T00:00:00.000Z' })
|
||||
@IsDateString()
|
||||
scheduledDate!: string;
|
||||
|
||||
@ApiProperty({ enum: CONTRACT_TYPES })
|
||||
@IsIn([...CONTRACT_TYPES])
|
||||
contractType!: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
@Transform(({ value }) => (value === '' || value == null ? undefined : value))
|
||||
previousContractId?: string;
|
||||
|
||||
@ApiProperty({ format: 'uuid', description: 'FK to service_types.id' })
|
||||
@IsUUID()
|
||||
serviceTypeId!: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
firstMilePickupAddress?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
lastMileDeliveryAddress?: string;
|
||||
|
||||
@ApiProperty({ enum: EQUIPMENT_RETURNS })
|
||||
@IsIn([...EQUIPMENT_RETURNS])
|
||||
equipmentReturn!: string;
|
||||
|
||||
@ApiProperty({ format: 'uuid', description: 'FK to yards.id (origin)' })
|
||||
@IsUUID()
|
||||
originYardId!: string;
|
||||
|
||||
@ApiProperty({ format: 'uuid', description: 'FK to yards.id (destination)' })
|
||||
@IsUUID()
|
||||
destinationYardId!: string;
|
||||
|
||||
@ApiProperty({ enum: TRADE_DIRECTIONS })
|
||||
@IsIn([...TRADE_DIRECTIONS])
|
||||
tradeDirection!: string;
|
||||
|
||||
@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;
|
||||
|
||||
@ApiPropertyOptional({ maxLength: 200 })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
cargoFreeText?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid', description: 'FK to shipping_lines.id' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
shippingLineId?: string;
|
||||
|
||||
@ApiProperty({ description: 'Total cargo weight VGM in tons', minimum: 0 })
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
totalAmount!: number;
|
||||
@Transform(({ value }) => Number(value))
|
||||
cargoTotalWeightVgm!: number;
|
||||
|
||||
@ApiPropertyOptional({ default: false })
|
||||
@IsOptional()
|
||||
@IsEnum(Freight.BookingStatus)
|
||||
status?: Freight.BookingStatus;
|
||||
@IsBoolean()
|
||||
@Transform(({ value }) => value === 'true' || value === true)
|
||||
isHazardous?: boolean;
|
||||
|
||||
@ApiProperty({ enum: PAYMENT_CURRENCIES })
|
||||
@IsIn([...PAYMENT_CURRENCIES])
|
||||
paymentCurrency!: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
pnrCode?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
startDate?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
endDate?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
financialTerms?: string;
|
||||
|
||||
@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[];
|
||||
|
||||
@ApiPropertyOptional({ default: false })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
@Transform(({ value }) => value === 'true' || value === true)
|
||||
allowConsolidation?: boolean;
|
||||
}
|
||||
|
||||
@@ -1,25 +1,95 @@
|
||||
import { Freight } from "@edr/types";
|
||||
import { Type } from "class-transformer";
|
||||
import { IsEnum, IsInt, IsOptional, IsUUID, Min } from "class-validator";
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsIn, IsOptional, IsUUID } from 'class-validator';
|
||||
import {
|
||||
BOOKING_STATUSES,
|
||||
FREIGHT_TYPES,
|
||||
PAYMENT_CURRENCIES,
|
||||
TRADE_DIRECTIONS,
|
||||
} from './create-booking.dto';
|
||||
|
||||
export class FilterBookingDto {
|
||||
@ApiPropertyOptional({ enum: BOOKING_STATUSES })
|
||||
@IsOptional()
|
||||
@IsEnum(Freight.BookingStatus)
|
||||
status?: Freight.BookingStatus;
|
||||
@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()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page?: number = 1;
|
||||
contractType?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
pageSize?: number = 20;
|
||||
@IsUUID()
|
||||
serviceTypeId?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
cargoTypeId?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: FREIGHT_TYPES })
|
||||
@IsOptional()
|
||||
@IsIn([...FREIGHT_TYPES])
|
||||
freightType?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: TRADE_DIRECTIONS })
|
||||
@IsOptional()
|
||||
@IsIn([...TRADE_DIRECTIONS])
|
||||
tradeDirection?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: PAYMENT_CURRENCIES })
|
||||
@IsOptional()
|
||||
@IsIn([...PAYMENT_CURRENCIES])
|
||||
paymentCurrency?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => value === 'true' || value === true)
|
||||
allowConsolidation?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ description: 'true | false — filter paired consolidation' })
|
||||
@IsOptional()
|
||||
consolidationPaired?: string;
|
||||
|
||||
@ApiPropertyOptional({ default: 1 })
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => (value ? parseInt(value, 10) : 1))
|
||||
page?: number;
|
||||
|
||||
@ApiPropertyOptional({ default: 20 })
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => (value ? parseInt(value, 10) : 20))
|
||||
pageSize?: number;
|
||||
|
||||
@ApiPropertyOptional({ default: 'createdAt' })
|
||||
@IsOptional()
|
||||
sortBy?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: ['ASC', 'DESC'], default: 'DESC' })
|
||||
@IsOptional()
|
||||
@IsIn(['ASC', 'DESC'])
|
||||
sortOrder?: 'ASC' | 'DESC';
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { PartialType } from '@nestjs/mapped-types';
|
||||
import { Validate } from 'class-validator';
|
||||
|
||||
import { CreateBookingDto } from './create-booking.dto';
|
||||
import { BookingFreightShapeConstraint } from './validators/booking-freight.validator';
|
||||
|
||||
export class UpdateBookingDto extends PartialType(CreateBookingDto) {
|
||||
@Validate(BookingFreightShapeConstraint)
|
||||
freightShapeValidation?: boolean;
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
||||
import { ApprovalRule } from '../../rule-engine/entities/approval-rule.entity';
|
||||
import { Booking } from './booking.entity';
|
||||
|
||||
export const APPROVAL_STEP_STATUSES = ['PENDING', 'APPROVED', 'REJECTED', 'SKIPPED'] as const;
|
||||
export type ApprovalStepStatus = typeof APPROVAL_STEP_STATUSES[number];
|
||||
|
||||
@Entity({ schema: 'freight', name: 'booking_approval_step' })
|
||||
@Index(['bookingId'])
|
||||
@Index(['status'])
|
||||
@Index(['bookingId', 'stepOrder'])
|
||||
export class BookingApprovalStep extends BaseEntity {
|
||||
@Column({ name: 'booking_id', type: 'uuid' })
|
||||
bookingId!: string;
|
||||
|
||||
@ManyToOne(() => Booking, (b) => b.approvalSteps, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'booking_id' })
|
||||
booking?: Booking;
|
||||
|
||||
@Column({ name: 'approval_rule_id', type: 'uuid' })
|
||||
approvalRuleId!: string;
|
||||
|
||||
@ManyToOne(() => ApprovalRule)
|
||||
@JoinColumn({ name: 'approval_rule_id' })
|
||||
approvalRule?: ApprovalRule;
|
||||
|
||||
@Column({ name: 'step_order', type: 'smallint' })
|
||||
stepOrder!: number;
|
||||
|
||||
@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;
|
||||
|
||||
@Column({ name: 'actioned_by_staff_id', type: 'uuid', nullable: true })
|
||||
actionedByStaffId?: string | null;
|
||||
|
||||
@Column({ name: 'actioned_at', type: 'timestamptz', nullable: true })
|
||||
actionedAt?: Date | null;
|
||||
|
||||
@Column({ name: 'remarks', type: 'text', nullable: true })
|
||||
remarks?: string | null;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
||||
import { SurchargeType } from '../../rule-engine/entities/surcharge-type.entity';
|
||||
import { Booking } from './booking.entity';
|
||||
import { BookingRateSnapshot } from './booking-rate-snapshot.entity';
|
||||
|
||||
@Entity({ schema: 'freight', name: 'booking_cargo_modifier' })
|
||||
@Index(['bookingId'])
|
||||
@Index(['surchargeTypeId'])
|
||||
export class BookingCargoModifier extends BaseEntity {
|
||||
@Column({ name: 'booking_id', type: 'uuid' })
|
||||
bookingId!: string;
|
||||
|
||||
@ManyToOne(() => Booking, (b) => b.cargoModifiers, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'booking_id' })
|
||||
booking?: Booking;
|
||||
|
||||
@Column({ name: 'surcharge_type_id', type: 'uuid' })
|
||||
surchargeTypeId!: string;
|
||||
|
||||
@ManyToOne(() => SurchargeType)
|
||||
@JoinColumn({ name: 'surcharge_type_id' })
|
||||
surchargeType?: SurchargeType;
|
||||
|
||||
@Column({ name: 'trigger_value', type: 'numeric', precision: 14, scale: 4, nullable: true })
|
||||
triggerValue?: number | null;
|
||||
|
||||
@Column({ name: 'calculated_amount', type: 'numeric', precision: 14, scale: 2 })
|
||||
calculatedAmount!: number;
|
||||
|
||||
@Column({ name: 'rate_snapshot_id', type: 'uuid' })
|
||||
rateSnapshotId!: string;
|
||||
|
||||
@ManyToOne(() => BookingRateSnapshot)
|
||||
@JoinColumn({ name: 'rate_snapshot_id' })
|
||||
rateSnapshot?: BookingRateSnapshot;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
||||
import { ContainerType } from '../../rule-engine/entities/container-type.entity';
|
||||
import { WeightLimitRule } from '../../rule-engine/entities/weight-limit-rule.entity';
|
||||
import { Booking } from './booking.entity';
|
||||
|
||||
@Entity({ schema: 'freight', name: 'booking_container' })
|
||||
@Index(['bookingId'])
|
||||
@Index(['isOverweight'])
|
||||
export class BookingContainer extends BaseEntity {
|
||||
@Column({ name: 'booking_id', type: 'uuid' })
|
||||
bookingId!: string;
|
||||
|
||||
@ManyToOne(() => Booking, (b) => b.bookingContainers, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'booking_id' })
|
||||
booking?: Booking;
|
||||
|
||||
@Column({ name: 'container_type_id', type: 'uuid' })
|
||||
containerTypeId!: string;
|
||||
|
||||
@ManyToOne(() => ContainerType)
|
||||
@JoinColumn({ name: 'container_type_id' })
|
||||
containerType?: ContainerType;
|
||||
|
||||
@Column({ name: 'quantity', type: 'smallint' })
|
||||
quantity!: number;
|
||||
|
||||
@Column({ name: 'vgm_per_unit_tons', type: 'numeric', precision: 10, scale: 3 })
|
||||
vgmPerUnitTons!: number;
|
||||
|
||||
@Column({ name: 'total_vgm_tons', type: 'numeric', precision: 12, scale: 3 })
|
||||
totalVgmTons!: number;
|
||||
|
||||
@Column({ name: 'wagons_required', type: 'numeric', precision: 6, scale: 2 })
|
||||
wagonsRequired!: number;
|
||||
|
||||
@Column({ name: 'weight_limit_rule_id', type: 'uuid', nullable: true })
|
||||
weightLimitRuleId?: string | null;
|
||||
|
||||
@ManyToOne(() => WeightLimitRule, { nullable: true })
|
||||
@JoinColumn({ name: 'weight_limit_rule_id' })
|
||||
weightLimitRule?: WeightLimitRule | null;
|
||||
|
||||
@Column({ name: 'is_overweight', type: 'boolean', default: false })
|
||||
isOverweight!: boolean;
|
||||
|
||||
@Column({ name: 'overweight_excess_tons', type: 'numeric', precision: 10, scale: 3, nullable: true })
|
||||
overweightExcessTons?: number | null;
|
||||
}
|
||||
@@ -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,39 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
||||
import { Rate } from '../../rule-engine/entities/rate.entity';
|
||||
import { Booking } from './booking.entity';
|
||||
|
||||
@Entity({ schema: 'freight', name: 'booking_rate_snapshot' })
|
||||
@Index(['bookingId'])
|
||||
@Index(['rateId'])
|
||||
@Index(['rateType'])
|
||||
export class BookingRateSnapshot extends BaseEntity {
|
||||
@Column({ name: 'booking_id', type: 'uuid' })
|
||||
bookingId!: string;
|
||||
|
||||
@ManyToOne(() => Booking, (b) => b.rateSnapshots, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'booking_id' })
|
||||
booking?: Booking;
|
||||
|
||||
@Column({ name: 'rate_id', type: 'uuid' })
|
||||
rateId!: string;
|
||||
|
||||
@ManyToOne(() => Rate)
|
||||
@JoinColumn({ name: 'rate_id' })
|
||||
rate?: Rate;
|
||||
|
||||
@Column({ name: 'rate_type', type: 'varchar', length: 50 })
|
||||
rateType!: string;
|
||||
|
||||
@Column({ name: 'rate_value', type: 'numeric', precision: 14, scale: 4 })
|
||||
rateValue!: number;
|
||||
|
||||
@Column({ name: 'rate_unit', type: 'varchar', length: 30 })
|
||||
rateUnit!: string;
|
||||
|
||||
@Column({ name: 'currency', type: 'varchar', length: 5 })
|
||||
currency!: string;
|
||||
|
||||
@Column({ name: 'snapshotted_at', type: 'timestamptz' })
|
||||
snapshottedAt!: Date;
|
||||
}
|
||||
@@ -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,43 +1,261 @@
|
||||
import { BaseEntity } from "@edr/api-common";
|
||||
import { Freight } from "@edr/types";
|
||||
import { Column, Entity } from "typeorm";
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, JoinColumn, ManyToOne, OneToMany } from 'typeorm';
|
||||
// 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';
|
||||
import { Yard } from '../../rule-engine/entities/yard.entity';
|
||||
import { Train } from '../../trains/entities/train.entity';
|
||||
import { FileRecord } from '../../files/entities/file.entity';
|
||||
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';
|
||||
|
||||
@Entity({ name: "bookings" })
|
||||
export const BOOKING_STATUSES = [
|
||||
'DRAFT',
|
||||
'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 })
|
||||
@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;
|
||||
|
||||
@Column({ name: "train_id", type: "uuid", nullable: true })
|
||||
@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;
|
||||
|
||||
@Column({
|
||||
name: "status",
|
||||
type: "enum",
|
||||
enum: Freight.BookingStatus,
|
||||
default: Freight.BookingStatus.Draft,
|
||||
})
|
||||
status!: Freight.BookingStatus;
|
||||
@ManyToOne(() => Train, { nullable: true })
|
||||
@JoinColumn({ name: 'train_id' })
|
||||
train?: Train | null;
|
||||
|
||||
@Column({ name: "scheduled_date", type: "timestamptz" })
|
||||
@Column({ name: 'status', type: 'varchar', length: 40, default: 'DRAFT' })
|
||||
status!: string;
|
||||
|
||||
@Column({ name: 'scheduled_date', type: 'timestamptz' })
|
||||
scheduledDate!: Date;
|
||||
|
||||
@Column({
|
||||
name: "total_amount",
|
||||
type: "numeric",
|
||||
precision: 14,
|
||||
scale: 2,
|
||||
default: 0,
|
||||
})
|
||||
@Column({ name: 'total_amount', type: 'numeric', precision: 14, scale: 2, default: 0 })
|
||||
totalAmount!: number;
|
||||
|
||||
@Column({
|
||||
name: "payment_status",
|
||||
type: "enum",
|
||||
enum: Freight.PaymentStatus,
|
||||
default: Freight.PaymentStatus.Pending,
|
||||
@Column({ name: 'payment_status', type: 'varchar', length: 20, default: 'PENDING' })
|
||||
paymentStatus!: string;
|
||||
|
||||
@Column({ name: 'contract_type', type: 'varchar', length: 20 })
|
||||
contractType!: string;
|
||||
|
||||
@Column({ name: 'previous_contract_id', type: 'uuid', nullable: true })
|
||||
previousContractId?: string | null;
|
||||
|
||||
@ManyToOne(() => Booking, { nullable: true })
|
||||
@JoinColumn({ name: 'previous_contract_id' })
|
||||
previousContract?: Booking | null;
|
||||
|
||||
@Column({ name: 'service_type_id', type: 'uuid' })
|
||||
serviceTypeId!: string;
|
||||
|
||||
@ManyToOne(() => ServiceType)
|
||||
@JoinColumn({ name: 'service_type_id' })
|
||||
serviceType?: ServiceType;
|
||||
|
||||
@Column({ name: 'first_mile_pickup_address', type: 'text', nullable: true })
|
||||
firstMilePickupAddress?: string | null;
|
||||
|
||||
@Column({ name: 'last_mile_delivery_address', type: 'text', nullable: true })
|
||||
lastMileDeliveryAddress?: string | null;
|
||||
|
||||
@Column({ name: 'equipment_return', type: 'varchar', length: 20 })
|
||||
equipmentReturn!: string;
|
||||
|
||||
@Column({ name: 'origin_yard_id', type: 'uuid' })
|
||||
originYardId!: string;
|
||||
|
||||
@ManyToOne(() => Yard)
|
||||
@JoinColumn({ name: 'origin_yard_id' })
|
||||
originYard?: Yard;
|
||||
|
||||
@Column({ name: 'destination_yard_id', type: 'uuid' })
|
||||
destinationYardId!: string;
|
||||
|
||||
@ManyToOne(() => Yard)
|
||||
@JoinColumn({ name: 'destination_yard_id' })
|
||||
destinationYard?: Yard;
|
||||
|
||||
@Column({ name: 'trade_direction', type: 'varchar', length: 10 })
|
||||
tradeDirection!: 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' })
|
||||
cargoType?: CargoType;
|
||||
|
||||
@Column({ name: 'cargo_free_text', type: 'varchar', length: 200, nullable: true })
|
||||
cargoFreeText?: string | null;
|
||||
|
||||
@Column({ name: 'shipping_line_id', type: 'uuid', nullable: true })
|
||||
shippingLineId?: string | null;
|
||||
|
||||
@ManyToOne(() => ShippingLine, { nullable: true })
|
||||
@JoinColumn({ name: 'shipping_line_id' })
|
||||
shippingLine?: ShippingLine | null;
|
||||
|
||||
@Column({ name: 'cargo_total_weight_vgm', type: 'numeric', precision: 12, scale: 3 })
|
||||
cargoTotalWeightVgm!: number;
|
||||
|
||||
@Column({ name: 'is_hazardous', type: 'boolean', default: false })
|
||||
isHazardous!: boolean;
|
||||
|
||||
@Column({ name: 'payment_currency', type: 'varchar', length: 5 })
|
||||
paymentCurrency!: string;
|
||||
|
||||
@Column({ name: 'pnr_code', type: 'varchar', length: 50, nullable: true })
|
||||
pnrCode?: string | null;
|
||||
|
||||
@Column({ name: 'start_date', type: 'date', nullable: true })
|
||||
startDate?: Date | null;
|
||||
|
||||
@Column({ name: 'end_date', type: 'date', nullable: true })
|
||||
endDate?: Date | null;
|
||||
|
||||
@Column({ name: 'financial_terms', type: 'text', nullable: true })
|
||||
financialTerms?: string | null;
|
||||
|
||||
@Column({ name: 'version_number', type: 'int', default: 1 })
|
||||
versionNumber!: number;
|
||||
|
||||
@Column({ name: 'approved_by_staff_id', type: 'uuid', nullable: true })
|
||||
approvedByStaffId?: string | null;
|
||||
|
||||
@Column({ name: 'approved_by_staff_at', type: 'timestamptz', nullable: true })
|
||||
approvedByStaffAt?: Date | null;
|
||||
|
||||
@Column({ name: 'signed_by_director_id', type: 'uuid', nullable: true })
|
||||
signedByDirectorId?: string | null;
|
||||
|
||||
@Column({ name: 'signed_by_director_at', type: 'timestamptz', nullable: true })
|
||||
signedByDirectorAt?: Date | null;
|
||||
|
||||
@Column({ name: 'signed_by_ceo_id', type: 'uuid', nullable: true })
|
||||
signedByCeoId?: string | null;
|
||||
|
||||
@Column({ name: 'signed_by_ceo_at', type: 'timestamptz', nullable: true })
|
||||
signedByCeoAt?: Date | null;
|
||||
|
||||
@Column({ name: 'customer_signed_at', type: 'timestamptz', nullable: true })
|
||||
customerSignedAt?: Date | null;
|
||||
|
||||
@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;
|
||||
|
||||
@Column({ name: 'allow_consolidation', type: 'boolean', default: false })
|
||||
allowConsolidation!: boolean;
|
||||
|
||||
@Column({ name: 'consolidation_partner_id', type: 'uuid', nullable: true })
|
||||
consolidationPartnerId?: string | null;
|
||||
|
||||
@ManyToOne(() => Booking, { nullable: true })
|
||||
@JoinColumn({ name: 'consolidation_partner_id' })
|
||||
consolidationPartner?: Booking | null;
|
||||
|
||||
@OneToMany(() => BookingContainer, (bc) => bc.booking)
|
||||
bookingContainers?: BookingContainer[];
|
||||
|
||||
@OneToMany(() => BookingCargoModifier, (m) => m.booking)
|
||||
cargoModifiers?: BookingCargoModifier[];
|
||||
|
||||
@OneToMany(() => BookingApprovalStep, (s) => s.booking)
|
||||
approvalSteps?: BookingApprovalStep[];
|
||||
|
||||
@OneToMany(() => BookingRateSnapshot, (s) => s.booking)
|
||||
rateSnapshots?: BookingRateSnapshot[];
|
||||
|
||||
@OneToMany(() => BookingReviewNote, (n) => n.booking)
|
||||
reviewNotes?: BookingReviewNote[];
|
||||
|
||||
@OneToMany(() => FileRecord, (file) => file.resourceId, {
|
||||
createForeignKeyConstraints: false,
|
||||
})
|
||||
paymentStatus!: Freight.PaymentStatus;
|
||||
files?: FileRecord[];
|
||||
}
|
||||
|
||||
27
apps/edr-freight-api/src/modules/bookings/pay.controller.ts
Normal file
27
apps/edr-freight-api/src/modules/bookings/pay.controller.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
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) {
|
||||
return await this.paymentService.pay(id);
|
||||
// const abstract = await this.transitionService.enrichBookingResponse(booking);
|
||||
// return { ...abstract, paymentReceipt: receipt };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
} 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(@Query() query: Record<string, string | undefined>) {
|
||||
return this.cargoesService.findAll(query);
|
||||
}
|
||||
|
||||
@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);
|
||||
}
|
||||
}
|
||||
15
apps/edr-freight-api/src/modules/cargoes/cargoes.module.ts
Normal file
15
apps/edr-freight-api/src/modules/cargoes/cargoes.module.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
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 { CargoType } from '../rule-engine/entities/cargo-type.entity';
|
||||
import { CargoesController } from './cargoes.controller';
|
||||
import { CargoesService } from './cargoes.service';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Cargo, Container, CargoType])],
|
||||
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);
|
||||
}
|
||||
}
|
||||
172
apps/edr-freight-api/src/modules/cargoes/cargoes.service.ts
Normal file
172
apps/edr-freight-api/src/modules/cargoes/cargoes.service.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { FindOptionsOrder, FindOptionsWhere, ILike, 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';
|
||||
import { CargoType } from '../rule-engine/entities/cargo-type.entity';
|
||||
|
||||
@Injectable()
|
||||
export class CargoesService {
|
||||
constructor(
|
||||
@InjectRepository(Cargo)
|
||||
private readonly cargoRepo: Repository<Cargo>,
|
||||
@InjectRepository(Container)
|
||||
private readonly containerRepo: Repository<Container>,
|
||||
@InjectRepository(CargoType)
|
||||
private readonly cargoTypeRepo: Repository<CargoType>,
|
||||
) {}
|
||||
|
||||
async create(dto: CreateCargoDto): Promise<Cargo> {
|
||||
const existing = await this.cargoRepo.findOne({
|
||||
where: { cargoReference: dto.cargoReference },
|
||||
});
|
||||
if (existing) {
|
||||
throw new ConflictException(`Cargo reference "${dto.cargoReference}" already exists`);
|
||||
}
|
||||
|
||||
const container = await this.containerRepo.findOne({ where: { id: dto.containerId } });
|
||||
if (!container) {
|
||||
throw new NotFoundException(`Container ${dto.containerId} not found`);
|
||||
}
|
||||
|
||||
if (dto.cargoTypeId) {
|
||||
const cargoType = await this.cargoTypeRepo.findOne({
|
||||
where: { id: dto.cargoTypeId, isActive: true },
|
||||
});
|
||||
if (!cargoType) throw new NotFoundException(`Cargo type ${dto.cargoTypeId} not found`);
|
||||
}
|
||||
|
||||
const cargo = this.cargoRepo.create(dto);
|
||||
return this.cargoRepo.save(cargo);
|
||||
}
|
||||
|
||||
async findAll(query: Record<string, string | undefined> = {}): Promise<Cargo[]> {
|
||||
const where: FindOptionsWhere<Cargo>[] | FindOptionsWhere<Cargo> = [];
|
||||
const search = query.search?.trim();
|
||||
const status = query.status?.trim();
|
||||
const containerId = query.containerId?.trim();
|
||||
|
||||
if (search) {
|
||||
where.push({
|
||||
cargoReference: ILike(`%${search}%`),
|
||||
...(status ? { status } : {}),
|
||||
...(containerId ? { containerId } : {}),
|
||||
});
|
||||
where.push({
|
||||
description: ILike(`%${search}%`),
|
||||
...(status ? { status } : {}),
|
||||
...(containerId ? { containerId } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
const sortBy = ['cargoReference', 'quantity', 'weight', 'volume', 'status'].includes(query.sortBy ?? '')
|
||||
? (query.sortBy as keyof Cargo)
|
||||
: 'cargoReference';
|
||||
const sortOrder = query.sortOrder?.toUpperCase() === 'DESC' ? 'DESC' : 'ASC';
|
||||
|
||||
return this.cargoRepo.find({
|
||||
where: search ? where : { ...(status ? { status } : {}), ...(containerId ? { containerId } : {}) },
|
||||
order: { [sortBy]: sortOrder } as FindOptionsOrder<Cargo>,
|
||||
skip: query.page && query.limit ? (Number(query.page) - 1) * Number(query.limit) : undefined,
|
||||
take: query.limit ? Number(query.limit) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
if (dto.cargoReference && dto.cargoReference !== cargo.cargoReference) {
|
||||
const existing = await this.cargoRepo.findOne({
|
||||
where: { cargoReference: dto.cargoReference },
|
||||
});
|
||||
if (existing) {
|
||||
throw new ConflictException(`Cargo reference "${dto.cargoReference}" already exists`);
|
||||
}
|
||||
}
|
||||
if (dto.containerId) {
|
||||
const container = await this.containerRepo.findOne({ where: { id: dto.containerId } });
|
||||
if (!container) throw new NotFoundException(`Container ${dto.containerId} not found`);
|
||||
}
|
||||
if (dto.cargoTypeId) {
|
||||
const cargoType = await this.cargoTypeRepo.findOne({
|
||||
where: { id: dto.cargoTypeId, isActive: true },
|
||||
});
|
||||
if (!cargoType) throw new NotFoundException(`Cargo type ${dto.cargoTypeId} not found`);
|
||||
}
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
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';
|
||||
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 { 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;
|
||||
name?: { en: string; am: string };
|
||||
email?: string;
|
||||
phoneNumber?: string;
|
||||
}
|
||||
|
||||
@ApiTags('Companies')
|
||||
@Controller('companies')
|
||||
export class CompaniesController {
|
||||
constructor(
|
||||
private readonly companiesService: CompaniesService,
|
||||
private readonly filesService: FilesService,
|
||||
) {}
|
||||
|
||||
@Get('getInfo')
|
||||
@ApiOperation({ summary: 'Get company info for the current user' })
|
||||
async getInfo(@CurrentUser() user: CurrentIamUser): Promise<CompanyInfoResponseDto> {
|
||||
const { profile, company } = await this.companiesService.getCompanyInfoByUserId(user.id);
|
||||
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(
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
@Body() dto: CreateCompanyWithProfileDto,
|
||||
): Promise<CompanyInfoResponseDto> {
|
||||
const nameParts = (user.name?.en ?? '').split(' ');
|
||||
const { profile, company } = await this.companiesService.createCompanyWithProfile(
|
||||
{
|
||||
userId: user.id,
|
||||
firstName: nameParts[0] || '',
|
||||
lastName: nameParts.slice(-1)[0] || '',
|
||||
email: user.email ?? '',
|
||||
phone: user.phoneNumber ?? '',
|
||||
},
|
||||
dto,
|
||||
);
|
||||
return new CompanyInfoResponseDto(profile, company);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: 'Create a new company (customer, forwarder, transporter, broker)' })
|
||||
async create(@Body() dto: CreateCompanyDto): Promise<ResponseCompanyDto> {
|
||||
const company = await this.companiesService.createCompany(dto);
|
||||
return new ResponseCompanyDto(company);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List all companies' })
|
||||
async findAll(): Promise<ResponseCompanyDto[]> {
|
||||
const companies = await this.companiesService.findAllCompanies();
|
||||
return companies.map((c) => new ResponseCompanyDto(c));
|
||||
}
|
||||
|
||||
@Get('type/:type')
|
||||
@ApiOperation({ summary: 'Find companies by type' })
|
||||
async findByType(@Param('type') type: string): Promise<ResponseCompanyDto[]> {
|
||||
const companies = await this.companiesService.findAllCompanies();
|
||||
return companies.filter((c) => c.type === type).map((c) => new ResponseCompanyDto(c));
|
||||
}
|
||||
|
||||
@Get('search')
|
||||
@ApiOperation({ summary: 'Search companies by name' })
|
||||
async search(@Query('name') name: string): Promise<ResponseCompanyDto[]> {
|
||||
const companies = await this.companiesService.findAllCompanies();
|
||||
return companies
|
||||
.filter((c) => c.name.toLowerCase().includes(name.toLowerCase()))
|
||||
.map((c) => new ResponseCompanyDto(c));
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get company by ID' })
|
||||
async findById(@Param('id', ParseUUIDPipe) id: string): Promise<ResponseCompanyDto> {
|
||||
const company = await this.companiesService.findCompanyById(id);
|
||||
return new ResponseCompanyDto(company);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@ApiOperation({ summary: 'Update a company' })
|
||||
async update(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: UpdateCompanyDto,
|
||||
): Promise<ResponseCompanyDto> {
|
||||
const company = await this.companiesService.updateCompany(id, dto);
|
||||
return new ResponseCompanyDto(company);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@ApiOperation({ summary: 'Soft-delete a company' })
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
async remove(@Param('id', ParseUUIDPipe) id: string): Promise<void> {
|
||||
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(
|
||||
@Param('companyId', ParseUUIDPipe) companyId: string,
|
||||
@Body() dto: CreateExternalProfileDto,
|
||||
): Promise<ResponseExternalProfileDto> {
|
||||
const profile = await this.companiesService.createProfile({ ...dto, companyId });
|
||||
return new ResponseExternalProfileDto(profile);
|
||||
}
|
||||
|
||||
@Get(':companyId/profiles')
|
||||
@ApiOperation({ summary: 'List profiles for a company' })
|
||||
async listProfiles(
|
||||
@Param('companyId', ParseUUIDPipe) companyId: string,
|
||||
): Promise<ResponseExternalProfileDto[]> {
|
||||
const profiles = await this.companiesService.findProfilesByCompany(companyId);
|
||||
return profiles.map((p) => new ResponseExternalProfileDto(p));
|
||||
}
|
||||
|
||||
@Get('profile/user/:userId')
|
||||
@ApiOperation({ summary: 'Get profile by IAM user ID' })
|
||||
async findProfileByUser(
|
||||
@Param('userId', ParseUUIDPipe) userId: string,
|
||||
): Promise<ResponseExternalProfileDto> {
|
||||
const profile = await this.companiesService.findProfileByUserId(userId);
|
||||
return new ResponseExternalProfileDto(profile);
|
||||
}
|
||||
|
||||
@Post('ff-clients')
|
||||
@ApiOperation({ summary: 'Link a forwarder to a client company' })
|
||||
async createFFClient(@Body() dto: CreateFFClientDto): Promise<ResponseFFClientDto> {
|
||||
const client = await this.companiesService.createFFClient(dto);
|
||||
return new ResponseFFClientDto(client);
|
||||
}
|
||||
|
||||
@Get(':forwarderCompanyId/clients')
|
||||
@ApiOperation({ summary: 'List clients of a forwarder' })
|
||||
async listFFClients(
|
||||
@Param('forwarderCompanyId', ParseUUIDPipe) forwarderCompanyId: string,
|
||||
): Promise<ResponseFFClientDto[]> {
|
||||
const clients = await this.companiesService.findForwarderClients(forwarderCompanyId);
|
||||
return clients.map((c) => new ResponseFFClientDto(c));
|
||||
}
|
||||
|
||||
@Delete('ff-clients/:id')
|
||||
@ApiOperation({ summary: 'Remove a forwarder-client relationship' })
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
async removeFFClient(@Param('id', ParseUUIDPipe) id: string): Promise<void> {
|
||||
await this.companiesService.deleteFFClient(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
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';
|
||||
import { ExternalProfileRepository } from './external-profile.repository';
|
||||
import { FFClientRepository } from './ff-client.repository';
|
||||
import { Company } from './entities/company.entity';
|
||||
import { ExternalProfile } from './entities/external-profile.entity';
|
||||
import { FFClient } from './entities/ff-client.entity';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Company, ExternalProfile, FFClient]), FilesModule],
|
||||
controllers: [CompaniesController],
|
||||
providers: [CompaniesService, CompaniesRepository, ExternalProfileRepository, FFClientRepository],
|
||||
exports: [CompaniesService],
|
||||
})
|
||||
export class CompaniesModule {}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { BaseRepository } from '@edr/api-common';
|
||||
import { Company } from './entities/company.entity';
|
||||
|
||||
@Injectable()
|
||||
export class CompaniesRepository extends BaseRepository<Company> {
|
||||
constructor(
|
||||
@InjectRepository(Company)
|
||||
repo: Repository<Company>,
|
||||
) {
|
||||
super(repo);
|
||||
}
|
||||
|
||||
async findByTin(tin: string): Promise<Company | null> {
|
||||
return this.repository.findOne({ where: { tin } as any });
|
||||
}
|
||||
|
||||
async findByType(type: string): Promise<Company[]> {
|
||||
return this.repository.find({ where: { type } as any, order: { name: 'ASC' } });
|
||||
}
|
||||
|
||||
async findByName(name: string): Promise<Company[]> {
|
||||
return this.repository
|
||||
.createQueryBuilder('company')
|
||||
.where('company.name ILIKE :name', { name: `%${name}%` })
|
||||
.getMany();
|
||||
}
|
||||
|
||||
async existsByTin(tin: string): Promise<boolean> {
|
||||
const count = await this.repository.count({ where: { tin } as any });
|
||||
return count > 0;
|
||||
}
|
||||
}
|
||||
194
apps/edr-freight-api/src/modules/companies/companies.service.ts
Normal file
194
apps/edr-freight-api/src/modules/companies/companies.service.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
|
||||
import { CompaniesRepository } from './companies.repository';
|
||||
import { ExternalProfileRepository } from './external-profile.repository';
|
||||
import { FFClientRepository } from './ff-client.repository';
|
||||
import { CreateCompanyDto } from './dto/create-company.dto';
|
||||
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';
|
||||
|
||||
export interface UserIdentity {
|
||||
userId: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class CompaniesService {
|
||||
constructor(
|
||||
private readonly companiesRepo: CompaniesRepository,
|
||||
private readonly profilesRepo: ExternalProfileRepository,
|
||||
private readonly ffClientsRepo: FFClientRepository,
|
||||
) {}
|
||||
|
||||
async createCompany(dto: CreateCompanyDto): Promise<Company> {
|
||||
const exists = await this.companiesRepo.existsByTin(dto.tin);
|
||||
if (exists) {
|
||||
throw new ConflictException(`Company with TIN ${dto.tin} already exists`);
|
||||
}
|
||||
return this.companiesRepo.create(dto);
|
||||
}
|
||||
|
||||
async createCompanyWithProfile(identity: UserIdentity, dto: CreateCompanyWithProfileDto): Promise<{ company: Company; profile: ExternalProfile }> {
|
||||
if (dto.tin) {
|
||||
const exists = await this.companiesRepo.existsByTin(dto.tin);
|
||||
if (exists) {
|
||||
throw new ConflictException(`Company with TIN ${dto.tin} already exists`);
|
||||
}
|
||||
}
|
||||
|
||||
const existingProfile = await this.profilesRepo.findByEmail(identity.email);
|
||||
if (existingProfile) {
|
||||
throw new ConflictException(`Profile with email ${identity.email} already exists`);
|
||||
}
|
||||
|
||||
const company = await this.companiesRepo.create({
|
||||
name: dto.companyName,
|
||||
type: dto.companyType,
|
||||
tin: dto.tin ?? '',
|
||||
vatNumber: dto.vatNumber ?? null,
|
||||
businessLicense: dto.fanNumber ?? null,
|
||||
fanNumber: dto.fanNumber ?? null,
|
||||
country: dto.companyLocation ?? 'Ethiopia',
|
||||
address: dto.companyAddress ?? null,
|
||||
phone: dto.companyPhone ?? null,
|
||||
email: dto.companyEmail ?? null,
|
||||
attributes: dto.attributes ?? null,
|
||||
});
|
||||
|
||||
const profile = await this.profilesRepo.create({
|
||||
userId: identity.userId,
|
||||
companyId: company.id,
|
||||
firstName: identity.firstName,
|
||||
lastName: identity.lastName,
|
||||
email: identity.email,
|
||||
phone: identity.phone,
|
||||
jobTitle: dto.jobTitle ?? null,
|
||||
isPrimaryContact: dto.isPrimaryContact ?? true,
|
||||
});
|
||||
|
||||
return { company, profile };
|
||||
}
|
||||
|
||||
async findAllCompanies(): Promise<Company[]> {
|
||||
return this.companiesRepo.findAll({ order: { name: 'ASC' as any } });
|
||||
}
|
||||
|
||||
async findCompanyById(id: string): Promise<Company> {
|
||||
const company = await this.companiesRepo.findById(id);
|
||||
if (!company) throw new NotFoundException(`Company ${id} not found`);
|
||||
return company;
|
||||
}
|
||||
|
||||
async getCompanyInfoByUserId(userId: string): Promise<{ profile: ExternalProfile; company: Company }> {
|
||||
const profile = await this.profilesRepo.findByUserId(userId);
|
||||
if (!profile) throw new NotFoundException(`Profile for user ${userId} not found`);
|
||||
|
||||
const company = profile.company;
|
||||
if (!company) throw new NotFoundException(`Company for profile ${profile.id} not found`);
|
||||
|
||||
return { profile, company };
|
||||
}
|
||||
|
||||
async updateCompany(id: string, dto: UpdateCompanyDto): Promise<Company> {
|
||||
await this.findCompanyById(id);
|
||||
const updated = await this.companiesRepo.update(id, dto);
|
||||
if (!updated) throw new NotFoundException(`Company ${id} not found`);
|
||||
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);
|
||||
}
|
||||
|
||||
async createProfile(dto: CreateExternalProfileDto): Promise<ExternalProfile> {
|
||||
await this.findCompanyById(dto.companyId);
|
||||
|
||||
const existing = await this.profilesRepo.findByEmail(dto.email);
|
||||
if (existing) {
|
||||
throw new ConflictException(`Profile with email ${dto.email} already exists`);
|
||||
}
|
||||
|
||||
return this.profilesRepo.create(dto);
|
||||
}
|
||||
|
||||
async findProfileByUserId(userId: string): Promise<ExternalProfile> {
|
||||
const profile = await this.profilesRepo.findByUserId(userId);
|
||||
if (!profile) throw new NotFoundException(`Profile for user ${userId} not found`);
|
||||
return profile;
|
||||
}
|
||||
|
||||
async findProfilesByCompany(companyId: string): Promise<ExternalProfile[]> {
|
||||
return this.profilesRepo.findByCompanyId(companyId);
|
||||
}
|
||||
|
||||
async createFFClient(dto: CreateFFClientDto): Promise<FFClient> {
|
||||
await this.findCompanyById(dto.forwarderCompanyId);
|
||||
await this.findCompanyById(dto.clientCompanyId);
|
||||
|
||||
const existing = await this.ffClientsRepo.findRelationship(
|
||||
dto.forwarderCompanyId,
|
||||
dto.clientCompanyId,
|
||||
);
|
||||
if (existing) {
|
||||
throw new ConflictException('This forwarder-client relationship already exists');
|
||||
}
|
||||
|
||||
return this.ffClientsRepo.create(dto);
|
||||
}
|
||||
|
||||
async findForwarderClients(forwarderCompanyId: string): Promise<FFClient[]> {
|
||||
return this.ffClientsRepo.findByForwarder(forwarderCompanyId);
|
||||
}
|
||||
|
||||
async deleteFFClient(id: string): Promise<void> {
|
||||
const client = await this.ffClientsRepo.findById(id);
|
||||
if (!client) throw new NotFoundException(`FFClient ${id} not found`);
|
||||
await this.ffClientsRepo.softDelete(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Company } from '../entities/company.entity';
|
||||
import { ExternalProfile } from '../entities/external-profile.entity';
|
||||
import { ResponseCompanyDto } from './response-company.dto';
|
||||
import { ResponseExternalProfileDto } from './response-external-profile.dto';
|
||||
|
||||
export class CompanyInfoResponseDto {
|
||||
profile: ResponseExternalProfileDto;
|
||||
company: ResponseCompanyDto;
|
||||
|
||||
constructor(profile: ExternalProfile, company: Company) {
|
||||
this.profile = new ResponseExternalProfileDto(profile);
|
||||
this.company = new ResponseCompanyDto(company);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { IsString, IsNotEmpty, IsOptional, IsEmail, MaxLength, IsBoolean, IsEnum } from 'class-validator';
|
||||
import { CompanyType } from '../entities/company.entity';
|
||||
|
||||
export class CreateCompanyWithProfileDto {
|
||||
@IsEnum(CompanyType)
|
||||
companyType!: CompanyType;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@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()
|
||||
@MaxLength(10)
|
||||
tin?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(50)
|
||||
vatNumber?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(16)
|
||||
fanNumber?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
jobTitle?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isPrimaryContact?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
attributes?: Record<string, any>;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { IsString, IsNotEmpty, IsOptional, IsEnum, MaxLength, Length, Matches, IsEmail } from 'class-validator';
|
||||
import { CompanyType, CompanyStatus } from '../entities/company.entity';
|
||||
|
||||
export class CreateCompanyDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(200)
|
||||
name!: string;
|
||||
|
||||
@IsEnum(CompanyType)
|
||||
type!: CompanyType;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(CompanyStatus)
|
||||
status?: CompanyStatus;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@Length(10, 10)
|
||||
@Matches(/^\d+$/, { message: 'TIN must contain only digits' })
|
||||
tin!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(50)
|
||||
vatNumber?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
businessLicense?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(32)
|
||||
country?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
address?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(20)
|
||||
phone?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEmail()
|
||||
@MaxLength(150)
|
||||
email?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
website?: string;
|
||||
|
||||
@IsOptional()
|
||||
attributes?: Record<string, any>;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { IsString, IsNotEmpty, IsOptional, IsEmail, MaxLength, IsBoolean, IsUUID } from 'class-validator';
|
||||
|
||||
export class CreateExternalProfileDto {
|
||||
@IsUUID()
|
||||
@IsNotEmpty()
|
||||
userId!: string;
|
||||
|
||||
@IsUUID()
|
||||
@IsNotEmpty()
|
||||
companyId!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(100)
|
||||
firstName!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(100)
|
||||
lastName!: string;
|
||||
|
||||
@IsEmail()
|
||||
@IsNotEmpty()
|
||||
email!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(20)
|
||||
phone?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(50)
|
||||
nationalId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
jobTitle?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isPrimaryContact?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { IsUUID, IsNotEmpty, IsOptional, IsBoolean, IsEnum } from 'class-validator';
|
||||
import { FFClientRelationship } from '../entities/ff-client.entity';
|
||||
|
||||
export class CreateFFClientDto {
|
||||
@IsUUID()
|
||||
@IsNotEmpty()
|
||||
forwarderCompanyId!: string;
|
||||
|
||||
@IsUUID()
|
||||
@IsNotEmpty()
|
||||
clientCompanyId!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(FFClientRelationship)
|
||||
relationshipType?: FFClientRelationship;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
canBookOnBehalf?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
canViewDocuments?: boolean;
|
||||
}
|
||||
@@ -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,42 @@
|
||||
import { Company, CompanyType, CompanyStatus } from '../entities/company.entity';
|
||||
import { ResponseExternalProfileDto } from './response-external-profile.dto';
|
||||
|
||||
export class ResponseCompanyDto {
|
||||
id: string;
|
||||
name: string;
|
||||
type: CompanyType;
|
||||
status: CompanyStatus;
|
||||
tin: string;
|
||||
vatNumber?: string | null;
|
||||
businessLicense?: string | null;
|
||||
fanNumber?: string | null;
|
||||
country: string;
|
||||
address?: string | null;
|
||||
phone?: string | null;
|
||||
email?: string | null;
|
||||
website?: string | null;
|
||||
attributes?: Record<string, any> | null;
|
||||
profiles?: ResponseExternalProfileDto[];
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
|
||||
constructor(company: Company) {
|
||||
this.id = company.id;
|
||||
this.name = company.name;
|
||||
this.type = company.type;
|
||||
this.status = company.status;
|
||||
this.tin = company.tin;
|
||||
this.vatNumber = company.vatNumber;
|
||||
this.businessLicense = company.businessLicense;
|
||||
this.fanNumber = company.fanNumber;
|
||||
this.country = company.country;
|
||||
this.address = company.address;
|
||||
this.phone = company.phone;
|
||||
this.email = company.email;
|
||||
this.website = company.website;
|
||||
this.attributes = company.attributes;
|
||||
this.profiles = company.profiles?.map((p) => new ResponseExternalProfileDto(p));
|
||||
this.createdAt = company.createdAt;
|
||||
this.updatedAt = company.updatedAt;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { ExternalProfile } from '../entities/external-profile.entity';
|
||||
|
||||
export class ResponseExternalProfileDto {
|
||||
id: string;
|
||||
userId: string;
|
||||
companyId: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
phone?: string | null;
|
||||
nationalId?: string | null;
|
||||
jobTitle?: string | null;
|
||||
isPrimaryContact: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
|
||||
constructor(profile: ExternalProfile) {
|
||||
this.id = profile.id;
|
||||
this.userId = profile.userId;
|
||||
this.companyId = profile.companyId;
|
||||
this.firstName = profile.firstName;
|
||||
this.lastName = profile.lastName;
|
||||
this.email = profile.email;
|
||||
this.phone = profile.phone;
|
||||
this.nationalId = profile.nationalId;
|
||||
this.jobTitle = profile.jobTitle;
|
||||
this.isPrimaryContact = profile.isPrimaryContact;
|
||||
this.createdAt = profile.createdAt;
|
||||
this.updatedAt = profile.updatedAt;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { FFClient, FFClientRelationship } from '../entities/ff-client.entity';
|
||||
|
||||
export class ResponseFFClientDto {
|
||||
id: string;
|
||||
forwarderCompanyId: string;
|
||||
clientCompanyId: string;
|
||||
relationshipType: FFClientRelationship;
|
||||
canBookOnBehalf: boolean;
|
||||
canViewDocuments: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
|
||||
constructor(client: FFClient) {
|
||||
this.id = client.id;
|
||||
this.forwarderCompanyId = client.forwarderCompanyId;
|
||||
this.clientCompanyId = client.clientCompanyId;
|
||||
this.relationshipType = client.relationshipType;
|
||||
this.canBookOnBehalf = client.canBookOnBehalf;
|
||||
this.canViewDocuments = client.canViewDocuments;
|
||||
this.createdAt = client.createdAt;
|
||||
this.updatedAt = client.updatedAt;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { PartialType } from '@nestjs/mapped-types';
|
||||
import { CreateCompanyDto } from './create-company.dto';
|
||||
|
||||
export class UpdateCompanyDto extends PartialType(CreateCompanyDto) {}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { PartialType } from '@nestjs/mapped-types';
|
||||
import { CreateExternalProfileDto } from './create-external-profile.dto';
|
||||
|
||||
export class UpdateExternalProfileDto extends PartialType(CreateExternalProfileDto) {}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { PartialType } from '@nestjs/mapped-types';
|
||||
import { CreateFFClientDto } from './create-ff-client.dto';
|
||||
|
||||
export class UpdateFFClientDto extends PartialType(CreateFFClientDto) {}
|
||||
@@ -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,79 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index, OneToMany } from 'typeorm';
|
||||
import { ExternalProfile } from './external-profile.entity';
|
||||
|
||||
export enum CompanyType {
|
||||
Customer = 'customer',
|
||||
Forwarder = 'forwarder',
|
||||
Transporter = 'transporter',
|
||||
Broker = 'broker',
|
||||
}
|
||||
|
||||
export enum CompanyStatus {
|
||||
Active = 'active',
|
||||
Pending = 'pending',
|
||||
Suspended = 'suspended',
|
||||
Blacklisted = 'blacklisted',
|
||||
}
|
||||
|
||||
@Entity({ schema: 'freight', name: 'companies' })
|
||||
@Index(['tin'])
|
||||
@Index(['type'])
|
||||
export class Company extends BaseEntity {
|
||||
@Column({ name: 'name', type: 'varchar', length: 200 })
|
||||
name!: string;
|
||||
|
||||
@Column({ name: 'type', type: 'varchar', length: 32, enum: CompanyType })
|
||||
type!: CompanyType;
|
||||
|
||||
@Column({ name: 'status', type: 'varchar', length: 32, default: CompanyStatus.Pending })
|
||||
status!: CompanyStatus;
|
||||
|
||||
@Column({ name: 'tin', type: 'varchar', length: 10, unique: true })
|
||||
tin!: string;
|
||||
|
||||
@Column({ name: 'vat_number', type: 'varchar', length: 50, nullable: true })
|
||||
vatNumber?: string | null;
|
||||
|
||||
@Column({ name: 'business_license', type: 'varchar', length: 100, nullable: true })
|
||||
businessLicense?: string | null;
|
||||
|
||||
@Column({ name: 'fan_number', type: 'varchar', length: 16, nullable: true })
|
||||
fanNumber?: string | null;
|
||||
|
||||
@Column({ name: 'country', type: 'varchar', length: 32, default: 'Ethiopia' })
|
||||
country!: string;
|
||||
|
||||
@Column({ name: 'address', type: 'text', nullable: true })
|
||||
address?: string | null;
|
||||
|
||||
@Column({ name: 'phone', type: 'varchar', length: 20, nullable: true })
|
||||
phone?: string | null;
|
||||
|
||||
@Column({ name: 'email', type: 'varchar', length: 150, nullable: true })
|
||||
email?: string | null;
|
||||
|
||||
@Column({ name: 'contact_person_name', type: 'varchar', length: 100, nullable: true })
|
||||
contactPersonName?: string | null;
|
||||
|
||||
@Column({ name: 'contact_person_phone', type: 'varchar', length: 20, nullable: true })
|
||||
contactPersonPhone?: string | null;
|
||||
|
||||
@Column({ name: 'general_manager_name', type: 'varchar', length: 100, nullable: true })
|
||||
generalManagerName?: string | null;
|
||||
|
||||
@Column({ name: 'general_manager_email', type: 'varchar', length: 150, nullable: true })
|
||||
generalManagerEmail?: string | null;
|
||||
|
||||
@Column({ name: 'general_manager_phone', type: 'varchar', length: 20, nullable: true })
|
||||
generalManagerPhone?: string | null;
|
||||
|
||||
@Column({ name: 'website', type: 'varchar', length: 200, nullable: true })
|
||||
website?: string | null;
|
||||
|
||||
@Column({ name: 'attributes', type: 'jsonb', nullable: true })
|
||||
attributes?: Record<string, any> | null;
|
||||
|
||||
@OneToMany(() => ExternalProfile, (profile) => profile.company)
|
||||
profiles?: ExternalProfile[];
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index, ManyToOne, JoinColumn } from 'typeorm';
|
||||
import { Company } from './company.entity';
|
||||
|
||||
@Entity({ schema: 'freight', name: 'external_profiles' })
|
||||
@Index(['userId'])
|
||||
@Index(['companyId'])
|
||||
export class ExternalProfile extends BaseEntity {
|
||||
@Column({ name: 'user_id', type: 'uuid' })
|
||||
userId!: string;
|
||||
|
||||
@Column({ name: 'company_id', type: 'uuid' })
|
||||
companyId!: string;
|
||||
|
||||
@ManyToOne(() => Company, (company) => company.profiles)
|
||||
@JoinColumn({ name: 'company_id' })
|
||||
company!: Company;
|
||||
|
||||
@Column({ name: 'first_name', type: 'varchar', length: 100 })
|
||||
firstName!: string;
|
||||
|
||||
@Column({ name: 'last_name', type: 'varchar', length: 100 })
|
||||
lastName!: string;
|
||||
|
||||
@Column({ name: 'email', type: 'varchar', length: 150, unique: true })
|
||||
email!: string;
|
||||
|
||||
@Column({ name: 'phone', type: 'varchar', length: 20, nullable: true })
|
||||
phone?: string | null;
|
||||
|
||||
@Column({ name: 'national_id', type: 'varchar', length: 50, nullable: true })
|
||||
nationalId?: string | null;
|
||||
|
||||
@Column({ name: 'job_title', type: 'varchar', length: 100, nullable: true })
|
||||
jobTitle?: string | null;
|
||||
|
||||
@Column({ name: 'is_primary_contact', type: 'boolean', default: false })
|
||||
isPrimaryContact!: boolean;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index, ManyToOne, JoinColumn, Unique } from 'typeorm';
|
||||
import { Company } from './company.entity';
|
||||
|
||||
export enum FFClientRelationship {
|
||||
ManagedAccount = 'managed_account',
|
||||
SubAgent = 'sub_agent',
|
||||
}
|
||||
|
||||
@Entity({ schema: 'freight', name: 'ff_clients' })
|
||||
@Unique(['forwarderCompanyId', 'clientCompanyId'])
|
||||
@Index(['forwarderCompanyId'])
|
||||
@Index(['clientCompanyId'])
|
||||
export class FFClient extends BaseEntity {
|
||||
@Column({ name: 'forwarder_company_id', type: 'uuid' })
|
||||
forwarderCompanyId!: string;
|
||||
|
||||
@ManyToOne(() => Company)
|
||||
@JoinColumn({ name: 'forwarder_company_id' })
|
||||
forwarderCompany!: Company;
|
||||
|
||||
@Column({ name: 'client_company_id', type: 'uuid' })
|
||||
clientCompanyId!: string;
|
||||
|
||||
@ManyToOne(() => Company)
|
||||
@JoinColumn({ name: 'client_company_id' })
|
||||
clientCompany!: Company;
|
||||
|
||||
@Column({ name: 'relationship_type', type: 'varchar', length: 32, default: FFClientRelationship.ManagedAccount })
|
||||
relationshipType!: FFClientRelationship;
|
||||
|
||||
@Column({ name: 'can_book_on_behalf', type: 'boolean', default: true })
|
||||
canBookOnBehalf!: boolean;
|
||||
|
||||
@Column({ name: 'can_view_documents', type: 'boolean', default: true })
|
||||
canViewDocuments!: boolean;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { BaseRepository } from '@edr/api-common';
|
||||
import { ExternalProfile } from './entities/external-profile.entity';
|
||||
|
||||
@Injectable()
|
||||
export class ExternalProfileRepository extends BaseRepository<ExternalProfile> {
|
||||
constructor(
|
||||
@InjectRepository(ExternalProfile)
|
||||
repo: Repository<ExternalProfile>,
|
||||
) {
|
||||
super(repo);
|
||||
}
|
||||
|
||||
async findByUserId(userId: string): Promise<ExternalProfile | null> {
|
||||
return this.repository.findOne({
|
||||
where: { userId } as any,
|
||||
relations: ['company'],
|
||||
});
|
||||
}
|
||||
|
||||
async findByCompanyId(companyId: string): Promise<ExternalProfile[]> {
|
||||
return this.repository.find({ where: { companyId } as any });
|
||||
}
|
||||
|
||||
async findByEmail(email: string): Promise<ExternalProfile | null> {
|
||||
return this.repository.findOne({ where: { email } as any });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { BaseRepository } from '@edr/api-common';
|
||||
import { FFClient } from './entities/ff-client.entity';
|
||||
|
||||
@Injectable()
|
||||
export class FFClientRepository extends BaseRepository<FFClient> {
|
||||
constructor(
|
||||
@InjectRepository(FFClient)
|
||||
repo: Repository<FFClient>,
|
||||
) {
|
||||
super(repo);
|
||||
}
|
||||
|
||||
async findByForwarder(forwarderCompanyId: string): Promise<FFClient[]> {
|
||||
return this.repository.find({ where: { forwarderCompanyId } as any });
|
||||
}
|
||||
|
||||
async findByClient(clientCompanyId: string): Promise<FFClient[]> {
|
||||
return this.repository.find({ where: { clientCompanyId } as any });
|
||||
}
|
||||
|
||||
async findRelationship(
|
||||
forwarderCompanyId: string,
|
||||
clientCompanyId: string,
|
||||
): Promise<FFClient | null> {
|
||||
return this.repository.findOne({
|
||||
where: { forwarderCompanyId, clientCompanyId } as any,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,6 @@ import { CreateConsignmentDto } from "./dto/create-consignment.dto";
|
||||
import { FilterConsignmentDto } from "./dto/filter-consignment.dto";
|
||||
|
||||
@ApiTags("consignments")
|
||||
// @UseGuards(JwtAuthGuard) — TODO: integrate @edr/auth
|
||||
@Controller("consignments")
|
||||
export class ConsignmentsController {
|
||||
constructor(private readonly consignmentsService: ConsignmentsService) {}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { BaseEntity } from "@edr/api-common";
|
||||
import { Freight } from "@edr/types";
|
||||
import { Column, Entity } from "typeorm";
|
||||
|
||||
@Entity({ name: "consignments" })
|
||||
@Entity({schema:"freight", name: "consignments" })
|
||||
export class Consignment extends BaseEntity {
|
||||
@Column({ name: "booking_id", type: "uuid" })
|
||||
bookingId!: string;
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
} 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(@Query() query: Record<string, string | undefined>) {
|
||||
return this.containersService.findAll(query);
|
||||
}
|
||||
|
||||
@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,15 @@
|
||||
// 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 { ContainerType } from '../rule-engine/entities/container-type.entity';
|
||||
import { ContainersController } from './containers.controller';
|
||||
import { ContainersService } from './containers.service';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Container, Wagon, ContainerType])],
|
||||
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,148 @@
|
||||
// apps/edr-freight-api/src/modules/container-management/containers.service.ts
|
||||
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { FindOptionsOrder, FindOptionsWhere, ILike, 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';
|
||||
import { ContainerType } from '../rule-engine/entities/container-type.entity';
|
||||
|
||||
@Injectable()
|
||||
export class ContainersService {
|
||||
constructor(
|
||||
@InjectRepository(Container)
|
||||
private readonly containerRepo: Repository<Container>,
|
||||
@InjectRepository(Wagon)
|
||||
private readonly wagonRepo: Repository<Wagon>, // ✅ use raw repository
|
||||
@InjectRepository(ContainerType)
|
||||
private readonly containerTypeRepo: Repository<ContainerType>,
|
||||
) {}
|
||||
|
||||
async create(dto: CreateContainerDto): Promise<Container> {
|
||||
const existing = await this.containerRepo.findOne({
|
||||
where: { containerNumber: dto.containerNumber },
|
||||
});
|
||||
if (existing) {
|
||||
throw new ConflictException(`Container number "${dto.containerNumber}" already exists`);
|
||||
}
|
||||
|
||||
const containerType = await this.containerTypeRepo.findOne({
|
||||
where: { id: dto.containerTypeId, isActive: true },
|
||||
});
|
||||
if (!containerType) {
|
||||
throw new NotFoundException(`Container type ${dto.containerTypeId} not found`);
|
||||
}
|
||||
|
||||
if (dto.wagonId) {
|
||||
const wagon = await this.wagonRepo.findOne({ where: { id: dto.wagonId } });
|
||||
if (!wagon) throw new NotFoundException(`Wagon ${dto.wagonId} not found`);
|
||||
}
|
||||
|
||||
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(query: Record<string, string | undefined> = {}): Promise<Container[]> {
|
||||
const where: FindOptionsWhere<Container>[] | FindOptionsWhere<Container> = [];
|
||||
const search = query.search?.trim();
|
||||
const status = query.status?.trim();
|
||||
const wagonId = query.wagonId?.trim();
|
||||
|
||||
if (search) {
|
||||
where.push({
|
||||
containerNumber: ILike(`%${search}%`),
|
||||
...(status ? { status } : {}),
|
||||
...(wagonId ? { wagonId } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
const sortBy = ['containerNumber', 'tareWeight', 'maxGrossWeight', 'status', 'position'].includes(query.sortBy ?? '')
|
||||
? (query.sortBy as keyof Container)
|
||||
: 'containerNumber';
|
||||
const sortOrder = query.sortOrder?.toUpperCase() === 'DESC' ? 'DESC' : 'ASC';
|
||||
|
||||
return this.containerRepo.find({
|
||||
where: search ? where : { ...(status ? { status } : {}), ...(wagonId ? { wagonId } : {}) },
|
||||
order: { [sortBy]: sortOrder } as FindOptionsOrder<Container>,
|
||||
skip: query.page && query.limit ? (Number(query.page) - 1) * Number(query.limit) : undefined,
|
||||
take: query.limit ? Number(query.limit) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
if (dto.containerNumber && dto.containerNumber !== container.containerNumber) {
|
||||
const existing = await this.containerRepo.findOne({
|
||||
where: { containerNumber: dto.containerNumber },
|
||||
});
|
||||
if (existing) {
|
||||
throw new ConflictException(`Container number "${dto.containerNumber}" already exists`);
|
||||
}
|
||||
}
|
||||
if (dto.containerTypeId) {
|
||||
const containerType = await this.containerTypeRepo.findOne({
|
||||
where: { id: dto.containerTypeId, isActive: true },
|
||||
});
|
||||
if (!containerType) {
|
||||
throw new NotFoundException(`Container type ${dto.containerTypeId} not found`);
|
||||
}
|
||||
}
|
||||
if (dto.wagonId) {
|
||||
const wagon = await this.wagonRepo.findOne({ where: { id: dto.wagonId } });
|
||||
if (!wagon) throw new NotFoundException(`Wagon ${dto.wagonId} not found`);
|
||||
}
|
||||
Object.assign(container, dto);
|
||||
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[];
|
||||
}
|
||||
@@ -1,37 +1,84 @@
|
||||
// src/modules/customers/customers.controller.ts
|
||||
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
Post,
|
||||
Body,
|
||||
Query,
|
||||
} from "@nestjs/common";
|
||||
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
|
||||
import { ApiOperation } from "@nestjs/swagger";
|
||||
|
||||
import { CustomersService } from "./customers.service";
|
||||
import { CreateCustomerDto } from "./dto/create-customer.dto";
|
||||
import { UpdateCustomerDto } from "./dto/update-customer.dto";
|
||||
import { Customer } from "./entities/customer.entity";
|
||||
|
||||
@ApiTags("customers")
|
||||
// @UseGuards(JwtAuthGuard) — TODO: integrate @edr/auth
|
||||
@Controller("customers")
|
||||
export class CustomersController {
|
||||
constructor(private readonly customersService: CustomersService) {}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: "Create a new customer" })
|
||||
create(@Body() dto: CreateCustomerDto) {
|
||||
return this.customersService.create(dto);
|
||||
create(@Body() createCustomerDto: CreateCustomerDto): Promise<Customer> {
|
||||
return this.customersService.create(createCustomerDto);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: "List all customers" })
|
||||
findAll() {
|
||||
findAll(): Promise<Customer[]> {
|
||||
return this.customersService.findAll();
|
||||
}
|
||||
|
||||
@Get("stats")
|
||||
@ApiOperation({ summary: "Get customer statistics" })
|
||||
getStats(): Promise<{ total: number; withVatNumber: number }> {
|
||||
return this.customersService.getStats();
|
||||
}
|
||||
|
||||
@Get("search")
|
||||
searchByName(@Query("name") name: string): Promise<Customer[]> {
|
||||
return this.customersService.searchByName(name);
|
||||
}
|
||||
|
||||
@Get("email/:email")
|
||||
findByEmail(@Param("email") email: string): Promise<Customer> {
|
||||
return this.customersService.findByEmail(email);
|
||||
}
|
||||
|
||||
@Get("vat/:vatNumber")
|
||||
findByVatNumber(@Param("vatNumber") vatNumber: string): Promise<Customer> {
|
||||
return this.customersService.findByVatNumber(vatNumber);
|
||||
}
|
||||
|
||||
@Get(":id")
|
||||
@ApiOperation({ summary: "Get a customer by ID" })
|
||||
findOne(@Param("id", ParseUUIDPipe) id: string) {
|
||||
findById(@Param("id", ParseUUIDPipe) id: string): Promise<Customer> {
|
||||
return this.customersService.findById(id);
|
||||
}
|
||||
}
|
||||
|
||||
// @Get("user/:userId")
|
||||
// findByUserId(@Param("userId", ParseUUIDPipe) userId: string): Promise<any> {
|
||||
// return this.customersService.findByUserId(userId);
|
||||
// }
|
||||
|
||||
@Patch(":id")
|
||||
@ApiOperation({ summary: "Update a customer" })
|
||||
update(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() dto: UpdateCustomerDto,
|
||||
): Promise<Customer> {
|
||||
return this.customersService.update(id, dto);
|
||||
}
|
||||
|
||||
@Delete(":id")
|
||||
@ApiOperation({ summary: "Soft-delete a customer" })
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
remove(@Param("id", ParseUUIDPipe) id: string): Promise<void> {
|
||||
return this.customersService.delete(id);
|
||||
}
|
||||
}
|
||||
@@ -1,21 +1,117 @@
|
||||
import { BaseRepository } from "@edr/api-common";
|
||||
// import { BaseRepository } from "@edr/api-common";
|
||||
// import { EntityRepository } from "typeorm";
|
||||
|
||||
// src/modules/customers/customers.repository.ts
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { InjectRepository } from "@nestjs/typeorm";
|
||||
import { Repository } from "typeorm";
|
||||
|
||||
import { Repository, FindManyOptions, FindOptionsWhere } from "typeorm";
|
||||
import { Customer } from "./entities/customer.entity";
|
||||
import { CreateCustomerDto } from "./dto/create-customer.dto";
|
||||
// import { UpdateCustomerDto } from "./dto/update-customer.dto";
|
||||
|
||||
@Injectable()
|
||||
export class CustomersRepository extends BaseRepository<Customer> {
|
||||
export class CustomersRepository {
|
||||
constructor(
|
||||
@InjectRepository(Customer)
|
||||
repository: Repository<Customer>,
|
||||
) {
|
||||
super(repository);
|
||||
private readonly repository: Repository<Customer>,
|
||||
) { }
|
||||
|
||||
async create(dto: CreateCustomerDto): Promise<Customer> {
|
||||
const customer = this.repository.create(dto);
|
||||
return await this.repository.save(customer);
|
||||
}
|
||||
|
||||
/** Find a customer by their unique email. */
|
||||
findByEmail(email: string): Promise<Customer | null> {
|
||||
return this.repository.findOne({ where: { email } });
|
||||
async findAll(options?: FindManyOptions<Customer>): Promise<Customer[]> {
|
||||
return await this.repository.find(options);
|
||||
}
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<Customer | null> {
|
||||
return await this.repository.findOne({ where: { id } as FindOptionsWhere<Customer> });
|
||||
}
|
||||
|
||||
async findByUserId(userId: string): Promise<Customer | null> {
|
||||
return await this.repository.findOne({ where: { userId } as FindOptionsWhere<Customer> });
|
||||
}
|
||||
|
||||
async findByEmail(email: string): Promise<Customer | null> {
|
||||
return await this.repository.findOne({ where: { email } as FindOptionsWhere<Customer> });
|
||||
}
|
||||
|
||||
async findByVatNumber(vatNumber: string): Promise<Customer | null> {
|
||||
return await this.repository.findOne({ where: { vatNumber } as FindOptionsWhere<Customer> });
|
||||
}
|
||||
|
||||
async findByName(name: string): Promise<Customer[]> {
|
||||
return await this.repository
|
||||
.createQueryBuilder("customer")
|
||||
.where("customer.companyName ILIKE :name", { name: `%${name}%` })
|
||||
.getMany();
|
||||
}
|
||||
|
||||
async findOneByEmailOrVat(email?: string, vatNumber?: string): Promise<Customer | null> {
|
||||
if (!email && !vatNumber) return null;
|
||||
|
||||
const queryBuilder = this.repository.createQueryBuilder('customer');
|
||||
|
||||
if (email && vatNumber) {
|
||||
queryBuilder.where('customer.email = :email', { email })
|
||||
.orWhere('customer.vatNumber = :vatNumber', { vatNumber });
|
||||
} else if (email) {
|
||||
queryBuilder.where('customer.email = :email', { email });
|
||||
} else if (vatNumber) {
|
||||
queryBuilder.where('customer.vatNumber = :vatNumber', { vatNumber });
|
||||
}
|
||||
|
||||
return await queryBuilder.getOne();
|
||||
}
|
||||
|
||||
async update(id: string, updates: Partial<Customer>): Promise<Customer | null> {
|
||||
await this.repository.update(id, updates);
|
||||
return this.findById(id);
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<boolean> {
|
||||
const result = await this.repository.delete(id);
|
||||
return (result.affected ?? 0) > 0;
|
||||
}
|
||||
|
||||
async count(where?: any): Promise<number> {
|
||||
if (where?.createdAt) {
|
||||
const result = await this.repository
|
||||
.createQueryBuilder('customer')
|
||||
.where('customer.createdAt >= :date', { date: where.createdAt })
|
||||
.getCount();
|
||||
return result;
|
||||
}
|
||||
return await this.repository.count();
|
||||
}
|
||||
|
||||
async existsByUniqueFields(email: string, vatNumber?: string): Promise<boolean> {
|
||||
const queryBuilder = this.repository.createQueryBuilder('customer')
|
||||
.where('customer.email = :email', { email });
|
||||
|
||||
if (vatNumber) {
|
||||
queryBuilder.orWhere('customer.vatNumber = :vatNumber', { vatNumber });
|
||||
}
|
||||
|
||||
const count = await queryBuilder.getCount();
|
||||
return count > 0;
|
||||
}
|
||||
|
||||
async countWithVatNumber(): Promise<number> {
|
||||
const count = await this.repository
|
||||
.createQueryBuilder('customer')
|
||||
.where('customer.vatNumber IS NOT NULL')
|
||||
.andWhere("customer.vatNumber != ''")
|
||||
.getCount();
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
getRepository(): Repository<Customer> {
|
||||
return this.repository;
|
||||
}
|
||||
softDelete(id: string): any {
|
||||
return id;
|
||||
}
|
||||
}
|
||||
@@ -1,29 +1,140 @@
|
||||
import { Injectable, NotFoundException } from "@nestjs/common";
|
||||
import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
ConflictException,
|
||||
BadRequestException,
|
||||
} from "@nestjs/common";
|
||||
|
||||
import { CustomersRepository } from "./customers.repository";
|
||||
import { CreateCustomerDto } from "./dto/create-customer.dto";
|
||||
import { UpdateCustomerDto } from "./dto/update-customer.dto";
|
||||
import { Customer } from "./entities/customer.entity";
|
||||
|
||||
@Injectable()
|
||||
export class CustomersService {
|
||||
constructor(private readonly customersRepository: CustomersRepository) {}
|
||||
|
||||
/** Create a new freight customer. */
|
||||
create(dto: CreateCustomerDto): Promise<Customer> {
|
||||
/** Create a new customer */
|
||||
async create(dto: CreateCustomerDto): Promise<Customer> {
|
||||
const exists = await this.customersRepository.existsByUniqueFields(
|
||||
dto.email,
|
||||
dto.vatNumber,
|
||||
);
|
||||
|
||||
if (exists) {
|
||||
throw new ConflictException(
|
||||
"Customer with same email or VAT number already exists",
|
||||
);
|
||||
}
|
||||
|
||||
if (dto.vatNumber && dto.vatNumber.length !== 10) {
|
||||
throw new BadRequestException("VAT number must be exactly 10 digits");
|
||||
}
|
||||
|
||||
return this.customersRepository.create(dto);
|
||||
}
|
||||
|
||||
/** List every customer (alphabetical). */
|
||||
/** Get all customers */
|
||||
findAll(): Promise<Customer[]> {
|
||||
return this.customersRepository.findAll({ order: { name: "ASC" } });
|
||||
return this.customersRepository.findAll({
|
||||
order: { companyName: "ASC" },
|
||||
});
|
||||
}
|
||||
|
||||
/** Get a single customer by ID. */
|
||||
/** Get customer by ID */
|
||||
async findById(id: string): Promise<Customer> {
|
||||
const customer = await this.customersRepository.findById(id);
|
||||
|
||||
if (!customer) {
|
||||
throw new NotFoundException(`Customer ${id} not found`);
|
||||
throw new NotFoundException(`Customer with ID ${id} not found`);
|
||||
}
|
||||
|
||||
return customer;
|
||||
}
|
||||
}
|
||||
|
||||
// async findByUserId(userId: string): Promise<Customer> {
|
||||
// const customer = await this.customersRepository.findByUserId(userId);
|
||||
|
||||
// if (!customer) {
|
||||
// throw new NotFoundException(`Customer with ID ${userId} not found`);
|
||||
// }
|
||||
|
||||
// return customer;
|
||||
//}
|
||||
|
||||
/** Get customer by email */
|
||||
async findByEmail(email: string): Promise<Customer> {
|
||||
const customer = await this.customersRepository.findByEmail(email);
|
||||
|
||||
if (!customer) {
|
||||
throw new NotFoundException(`Customer with email ${email} not found`);
|
||||
}
|
||||
|
||||
return customer;
|
||||
}
|
||||
|
||||
/** Get customer by VAT number */
|
||||
async findByVatNumber(vatNumber: string): Promise<Customer> {
|
||||
const customer = await this.customersRepository.findByVatNumber(vatNumber);
|
||||
|
||||
if (!customer) {
|
||||
throw new NotFoundException(
|
||||
`Customer with VAT number ${vatNumber} not found`,
|
||||
);
|
||||
}
|
||||
|
||||
return customer;
|
||||
}
|
||||
|
||||
/** Search customers by name */
|
||||
searchByName(name: string): Promise<Customer[]> {
|
||||
return this.customersRepository.findByName(name);
|
||||
}
|
||||
|
||||
/** Update customer */
|
||||
async update(id: string, dto: UpdateCustomerDto): Promise<Customer> {
|
||||
await this.findById(id);
|
||||
|
||||
// Validate VAT number if provided
|
||||
if (dto.vatNumber && dto.vatNumber.length !== 10) {
|
||||
throw new BadRequestException("VAT number must be exactly 10 digits");
|
||||
}
|
||||
|
||||
// // Check email conflict
|
||||
// if (dto.email) {
|
||||
// const existing = await this.customersRepository.findByEmail(dto.email);
|
||||
|
||||
// // if (existing && existing.userId !== id) {
|
||||
// // throw new ConflictException(
|
||||
// // `Customer with email "${dto.email}" already exists`,
|
||||
// // );
|
||||
// // }
|
||||
// }
|
||||
|
||||
const updated = await this.customersRepository.update(id, dto);
|
||||
|
||||
if (!updated) {
|
||||
throw new NotFoundException(`Customer ${id} not found`);
|
||||
}
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
/** Delete customer (soft delete) */
|
||||
async remove(id: string): Promise<void> {
|
||||
await this.findById(id);
|
||||
await this.customersRepository.softDelete(id);
|
||||
}
|
||||
|
||||
/** Get customer statistics */
|
||||
async getStats(): Promise<{ total: number; withVatNumber: number }> {
|
||||
const total = await this.customersRepository.count();
|
||||
const withVatNumber = await this.customersRepository.countWithVatNumber();
|
||||
|
||||
return { total, withVatNumber };
|
||||
}
|
||||
|
||||
delete(id: string): any {
|
||||
return id;
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,156 @@
|
||||
import { IsEmail, IsOptional, IsString } from "class-validator";
|
||||
import {
|
||||
IsEmail,
|
||||
IsEnum,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MaxLength,
|
||||
IsNotEmpty,
|
||||
Length,
|
||||
Matches,
|
||||
} from "class-validator";
|
||||
|
||||
// Enums
|
||||
export enum CustomerStatusDto {
|
||||
Active = "Active",
|
||||
Pending = "Pending",
|
||||
Inactive = "Inactive",
|
||||
}
|
||||
|
||||
export enum CustomerTypeDto {
|
||||
Importer = "Importer",
|
||||
Exporter = "Exporter",
|
||||
Supplier = "Supplier",
|
||||
}
|
||||
|
||||
// DTO
|
||||
export class CreateCustomerDto {
|
||||
// Basic identity
|
||||
@IsString()
|
||||
name!: string;
|
||||
@IsNotEmpty()
|
||||
userId!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(100)
|
||||
firstName!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(100)
|
||||
lastName!: string;
|
||||
|
||||
@IsEmail()
|
||||
@IsNotEmpty()
|
||||
email!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(20)
|
||||
phone!: string;
|
||||
|
||||
// Company info
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(200)
|
||||
companyName!: string;
|
||||
|
||||
@IsEmail()
|
||||
@IsNotEmpty()
|
||||
companyEmail!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(20)
|
||||
companyPhone!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(100)
|
||||
companyLocation!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
companyAddress!: string;
|
||||
|
||||
// Classification
|
||||
@IsOptional()
|
||||
@IsEnum(CustomerTypeDto)
|
||||
customerType?: CustomerTypeDto;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(CustomerStatusDto)
|
||||
status?: CustomerStatusDto;
|
||||
|
||||
// Legal identifiers
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@Length(10, 10)
|
||||
@Matches(/^\d+$/, { message: "TIN must contain only digits" })
|
||||
tinNumber!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@Length(16, 16)
|
||||
@Matches(/^\d+$/, { message: "FAN must contain only digits" })
|
||||
fanNumber!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(50)
|
||||
vatNumber!: string;
|
||||
|
||||
// Contact person
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(100)
|
||||
contactPersonName!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(20)
|
||||
contactPersonPhone!: string;
|
||||
|
||||
// Management
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(100)
|
||||
generalManagerName!: string;
|
||||
|
||||
@IsEmail()
|
||||
@IsNotEmpty()
|
||||
generalManagerEmail!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(20)
|
||||
generalManagerPhone!: string;
|
||||
|
||||
// POA (Power of Attorney)
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
address?: string;
|
||||
@MaxLength(100)
|
||||
poaName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
taxId?: string;
|
||||
}
|
||||
@MaxLength(20)
|
||||
poaPhone?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
poaAddress?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEmail()
|
||||
poaEmail?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
poaLocation?: string;
|
||||
|
||||
// Extra
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
notes?: string;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
// src/modules/customers/dto/response-customer.dto.ts
|
||||
import { Customer } from '../entities/customer.entity';
|
||||
|
||||
export class ResponseCustomerDto {
|
||||
//UserId: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
companyName: string;
|
||||
companyEmail: string;
|
||||
companyPhone: string;
|
||||
companyLocation: string;
|
||||
companyAddress: string;
|
||||
contactPersonName: string;
|
||||
contactPersonPhone: string;
|
||||
tinNumber: string;
|
||||
vatNumber?: string;
|
||||
fanNumber: string;
|
||||
generalManagerName: string;
|
||||
generalManagerEmail: string;
|
||||
generalManagerPhone: string;
|
||||
poaName?: string;
|
||||
poaPhone?: string;
|
||||
poaAddress?: string;
|
||||
poaEmail?: string;
|
||||
poaLocation?: string;
|
||||
notes?: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
|
||||
constructor(customer: Customer) {
|
||||
//this.UserId = customer.userId;
|
||||
this.firstName = customer.firstName;
|
||||
this.lastName = customer.lastName;
|
||||
this.email = customer.email;
|
||||
this.phone = customer.phone;
|
||||
this.companyName = customer.companyName;
|
||||
this.companyEmail = customer.companyEmail;
|
||||
this.companyPhone = customer.companyPhone;
|
||||
this.companyLocation = customer.companyLocation;
|
||||
this.companyAddress = customer.companyAddress;
|
||||
this.contactPersonName = customer.contactPersonName;
|
||||
this.contactPersonPhone = customer.contactPersonPhone;
|
||||
this.tinNumber = customer.tinNumber;
|
||||
this.vatNumber = customer.vatNumber ?? undefined;
|
||||
this.fanNumber = customer.fanNumber;
|
||||
this.generalManagerName = customer.generalManagerName;
|
||||
this.generalManagerEmail = customer.generalManagerEmail;
|
||||
this.generalManagerPhone = customer.generalManagerPhone;
|
||||
this.poaName = customer.poaName ?? '';
|
||||
this.poaPhone = customer.poaPhone ?? '';
|
||||
this.poaAddress = customer.poaAddress ?? '';
|
||||
this.poaEmail = customer.poaEmail ?? '';
|
||||
this.poaLocation = customer.poaLocation ?? '';
|
||||
this.notes = customer.notes ?? '';
|
||||
this.createdAt = customer.createdAt;
|
||||
this.updatedAt = customer.updatedAt;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
// src/modules/customers/dto/update-customer.dto.ts
|
||||
import { PartialType } from '@nestjs/mapped-types';
|
||||
import { CreateCustomerDto } from './create-customer.dto';
|
||||
|
||||
export class UpdateCustomerDto extends PartialType(CreateCustomerDto) {
|
||||
email?: string;
|
||||
vatNumber?: string;
|
||||
// Add any other properties you need to access directly
|
||||
}
|
||||
@@ -1,20 +1,87 @@
|
||||
import { BaseEntity } from "@edr/api-common";
|
||||
import { Column, Entity } from "typeorm";
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index } from 'typeorm';
|
||||
|
||||
@Entity({ name: "customers" })
|
||||
@Entity({ schema: 'freight', name: 'customers' })
|
||||
@Index(['email'])
|
||||
//@Index(['userId'])
|
||||
@Index(['tinNumber'])
|
||||
@Index(['fanNumber'])
|
||||
export class Customer extends BaseEntity {
|
||||
@Column({ name: "name", type: "varchar", length: 256 })
|
||||
name!: string;
|
||||
//@Column({ name: 'user_id', type: 'uuid' })
|
||||
//userId!: string;
|
||||
|
||||
@Column({ name: "email", type: "varchar", length: 256, unique: true })
|
||||
@Column({ name: 'first_name', type: 'varchar', length: 100 })
|
||||
firstName!: string;
|
||||
|
||||
@Column({ name: 'last_name', type: 'varchar', length: 100 })
|
||||
lastName!: string;
|
||||
|
||||
@Column({ name: 'email', type: 'varchar', length: 150, unique: true })
|
||||
email!: string;
|
||||
|
||||
@Column({ name: "phone", type: "varchar", length: 32 })
|
||||
@Column({ name: 'phone', type: 'varchar', length: 20 })
|
||||
phone!: string;
|
||||
|
||||
@Column({ name: "address", type: "text", nullable: true })
|
||||
address?: string | null;
|
||||
@Column({ name: 'company_name', type: 'varchar', length: 200 })
|
||||
companyName!: string;
|
||||
|
||||
@Column({ name: "tax_id", type: "varchar", length: 64, nullable: true })
|
||||
taxId?: string | null;
|
||||
@Column({ name: 'company_email', type: 'varchar', length: 150 })
|
||||
companyEmail!: string;
|
||||
|
||||
@Column({ name: 'company_phone', type: 'varchar', length: 20 })
|
||||
companyPhone!: string;
|
||||
|
||||
@Column({ name: 'company_location', type: 'varchar', length: 100 })
|
||||
companyLocation!: string;
|
||||
|
||||
@Column({ name: 'company_address', type: 'text' })
|
||||
companyAddress!: string;
|
||||
|
||||
@Column({ name: 'customer_type', type: 'varchar', length: 32, nullable: true })
|
||||
customerType?: string | null;
|
||||
|
||||
@Column({ name: 'status', type: 'varchar', length: 32, nullable: true })
|
||||
status?: string | null;
|
||||
|
||||
@Column({ name: 'contact_person_name', type: 'varchar', length: 100 })
|
||||
contactPersonName!: string;
|
||||
|
||||
@Column({ name: 'contact_person_phone', type: 'varchar', length: 20 })
|
||||
contactPersonPhone!: string;
|
||||
|
||||
@Column({ name: 'tin_number', type: 'varchar', length: 10, unique: true })
|
||||
tinNumber!: string;
|
||||
|
||||
@Column({ name: 'vat_number', type: 'varchar', length: 50, nullable: true })
|
||||
vatNumber?: string | null;
|
||||
|
||||
@Column({ name: 'fan_number', type: 'varchar', length: 16, unique: true })
|
||||
fanNumber!: string;
|
||||
|
||||
@Column({ name: 'general_manager_name', type: 'varchar', length: 100 })
|
||||
generalManagerName!: string;
|
||||
|
||||
@Column({ name: 'general_manager_email', type: 'varchar', length: 150 })
|
||||
generalManagerEmail!: string;
|
||||
|
||||
@Column({ name: 'general_manager_phone', type: 'varchar', length: 20 })
|
||||
generalManagerPhone!: string;
|
||||
|
||||
@Column({ name: 'poa_name', type: 'varchar', length: 100, nullable: true })
|
||||
poaName?: string | null;
|
||||
|
||||
@Column({ name: 'poa_phone', type: 'varchar', length: 20, nullable: true })
|
||||
poaPhone?: string | null;
|
||||
|
||||
@Column({ name: 'poa_address', type: 'text', nullable: true })
|
||||
poaAddress?: string | null;
|
||||
|
||||
@Column({ name: 'poa_email', type: 'varchar', length: 150, nullable: true })
|
||||
poaEmail?: string | null;
|
||||
|
||||
@Column({ name: 'poa_location', type: 'varchar', length: 100, nullable: true })
|
||||
poaLocation?: string | null;
|
||||
|
||||
@Column({ name: 'notes', type: 'text', nullable: true })
|
||||
notes?: string | null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Controller, Get, UseGuards } from "@nestjs/common";
|
||||
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
|
||||
import { PermissionGuard } from "@tria-plc/api-common/modules/auth/services/permission.guard";
|
||||
|
||||
@ApiTags("demo-permissions")
|
||||
@Controller()
|
||||
export class DemoPermissionsController {
|
||||
@Get("test_user1")
|
||||
@ApiOperation({ summary: "Permission demo (can:demo:user1)" })
|
||||
@UseGuards(PermissionGuard(["can:demo:user1"]))
|
||||
testUser1() {
|
||||
return { ok: true, permission: "can:demo:user1" };
|
||||
}
|
||||
|
||||
@Get("test_user2")
|
||||
@ApiOperation({ summary: "Permission demo (can:demo:user2)" })
|
||||
@UseGuards(PermissionGuard(["can:demo:user2"]))
|
||||
testUser2() {
|
||||
return { ok: true, permission: "can:demo:user2" };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
import { DemoPermissionsController } from "./demo-permissions.controller";
|
||||
|
||||
@Module({
|
||||
controllers: [DemoPermissionsController],
|
||||
})
|
||||
export class DemoPermissionsModule {}
|
||||
@@ -0,0 +1,102 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
Post,
|
||||
Put,
|
||||
} from "@nestjs/common";
|
||||
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
|
||||
import { CreateDropdownOptionDto } from "./dto/create-dropdown-option.dto";
|
||||
import { CreateDropdownSettingDto } from "./dto/create-dropdown-setting.dto";
|
||||
import { UpdateDropdownOptionDto } from "./dto/update-dropdown-option.dto";
|
||||
import { UpdateDropdownSettingDto } from "./dto/update-dropdown-setting.dto";
|
||||
import { DropdownSettingsService } from "./dropdown-settings.service";
|
||||
|
||||
@ApiTags("dropdown-settings")
|
||||
@Controller("dropdown-settings")
|
||||
export class DropdownSettingsController {
|
||||
constructor(private readonly service: DropdownSettingsService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: "List all dropdown settings" })
|
||||
list() {
|
||||
return this.service.list();
|
||||
}
|
||||
|
||||
@Get(":id")
|
||||
@ApiOperation({ summary: "Get a dropdown setting by ID" })
|
||||
getById(@Param("id", ParseUUIDPipe) id: string) {
|
||||
return this.service.getById(id);
|
||||
}
|
||||
|
||||
@Get("by-code/:code")
|
||||
@ApiOperation({ summary: "Get a dropdown setting by its stable code" })
|
||||
getByCode(@Param("code") code: string) {
|
||||
return this.service.getByCode(code);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: "Create a new dropdown setting" })
|
||||
create(@Body() dto: CreateDropdownSettingDto) {
|
||||
return this.service.create(dto);
|
||||
}
|
||||
|
||||
@Patch(":id")
|
||||
@ApiOperation({ summary: "Update a dropdown setting's metadata" })
|
||||
update(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() dto: UpdateDropdownSettingDto,
|
||||
) {
|
||||
return this.service.update(id, dto);
|
||||
}
|
||||
|
||||
@Delete(":id")
|
||||
@ApiOperation({ summary: "Soft-delete a dropdown setting" })
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
remove(@Param("id", ParseUUIDPipe) id: string) {
|
||||
return this.service.remove(id);
|
||||
}
|
||||
|
||||
/* ------------------------- option routes ------------------------- */
|
||||
|
||||
@Put(":id/options")
|
||||
@ApiOperation({ summary: "Replace the full option list for a setting" })
|
||||
replaceOptions(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() options: CreateDropdownOptionDto[],
|
||||
) {
|
||||
return this.service.replaceOptions(id, options);
|
||||
}
|
||||
|
||||
@Post(":id/options")
|
||||
@ApiOperation({ summary: "Append a single option to a setting" })
|
||||
addOption(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() dto: CreateDropdownOptionDto,
|
||||
) {
|
||||
return this.service.addOption(id, dto);
|
||||
}
|
||||
|
||||
@Patch("options/:optionId")
|
||||
@ApiOperation({ summary: "Update a single option" })
|
||||
updateOption(
|
||||
@Param("optionId", ParseUUIDPipe) optionId: string,
|
||||
@Body() dto: UpdateDropdownOptionDto,
|
||||
) {
|
||||
return this.service.updateOption(optionId, dto);
|
||||
}
|
||||
|
||||
@Delete("options/:optionId")
|
||||
@ApiOperation({ summary: "Soft-delete a single option" })
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
removeOption(@Param("optionId", ParseUUIDPipe) optionId: string) {
|
||||
return this.service.removeOption(optionId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { TypeOrmModule } from "@nestjs/typeorm";
|
||||
|
||||
import { DropdownOption } from "./entities/dropdown-option.entity";
|
||||
import { DropdownSetting } from "./entities/dropdown-setting.entity";
|
||||
import { DropdownSettingsController } from "./dropdown-settings.controller";
|
||||
import { DropdownSettingsRepository } from "./dropdown-settings.repository";
|
||||
import { DropdownSettingsService } from "./dropdown-settings.service";
|
||||
import { DROPDOWN_SETTINGS_REPOSITORY } from "./interfaces/dropdown-settings.repository.interface";
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([DropdownSetting, DropdownOption])],
|
||||
controllers: [DropdownSettingsController],
|
||||
providers: [
|
||||
DropdownSettingsRepository,
|
||||
{
|
||||
provide: DROPDOWN_SETTINGS_REPOSITORY,
|
||||
useExisting: DropdownSettingsRepository,
|
||||
},
|
||||
DropdownSettingsService,
|
||||
],
|
||||
exports: [DropdownSettingsService],
|
||||
})
|
||||
export class DropdownSettingsModule {}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { BaseRepository } from "@edr/api-common";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { InjectRepository } from "@nestjs/typeorm";
|
||||
import { Repository } from "typeorm";
|
||||
|
||||
import { DropdownOption } from "./entities/dropdown-option.entity";
|
||||
import { DropdownSetting } from "./entities/dropdown-setting.entity";
|
||||
import type { IDropdownSettingsRepository } from "./interfaces/dropdown-settings.repository.interface";
|
||||
|
||||
@Injectable()
|
||||
export class DropdownSettingsRepository
|
||||
extends BaseRepository<DropdownSetting>
|
||||
implements IDropdownSettingsRepository
|
||||
{
|
||||
constructor(
|
||||
@InjectRepository(DropdownSetting)
|
||||
repository: Repository<DropdownSetting>,
|
||||
@InjectRepository(DropdownOption)
|
||||
private readonly optionsRepository: Repository<DropdownOption>,
|
||||
) {
|
||||
super(repository);
|
||||
}
|
||||
|
||||
findByCode(code: string): Promise<DropdownSetting | null> {
|
||||
return this.repository.findOne({
|
||||
where: { code },
|
||||
relations: { children: true },
|
||||
order: { children: { order: "ASC" } },
|
||||
});
|
||||
}
|
||||
|
||||
override findById(id: string): Promise<DropdownSetting | null> {
|
||||
return this.repository.findOne({
|
||||
where: { id },
|
||||
relations: { children: true },
|
||||
order: { children: { order: "ASC" } },
|
||||
});
|
||||
}
|
||||
|
||||
override findAll(): Promise<DropdownSetting[]> {
|
||||
return this.repository.find({
|
||||
order: { label: "ASC", children: { order: "ASC" } },
|
||||
relations: { children: true },
|
||||
});
|
||||
}
|
||||
|
||||
async replaceOptions(
|
||||
settingId: string,
|
||||
options: Array<Partial<DropdownOption>>,
|
||||
): Promise<DropdownOption[]> {
|
||||
await this.optionsRepository.delete({ settingId });
|
||||
if (options.length === 0) return [];
|
||||
const entities = options.map((o, idx) =>
|
||||
this.optionsRepository.create({
|
||||
...o,
|
||||
settingId,
|
||||
order: o.order ?? idx + 1,
|
||||
}),
|
||||
);
|
||||
return this.optionsRepository.save(entities);
|
||||
}
|
||||
|
||||
async addOption(
|
||||
settingId: string,
|
||||
option: Partial<DropdownOption>,
|
||||
): Promise<DropdownOption> {
|
||||
const entity = this.optionsRepository.create({ ...option, settingId });
|
||||
return this.optionsRepository.save(entity);
|
||||
}
|
||||
|
||||
async updateOption(
|
||||
optionId: string,
|
||||
data: Partial<DropdownOption>,
|
||||
): Promise<DropdownOption | null> {
|
||||
await this.optionsRepository.update(optionId, data as never);
|
||||
return this.optionsRepository.findOne({ where: { id: optionId } });
|
||||
}
|
||||
|
||||
async removeOption(optionId: string): Promise<void> {
|
||||
await this.optionsRepository.softDelete(optionId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import {
|
||||
ConflictException,
|
||||
Inject,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
|
||||
import { CreateDropdownOptionDto } from "./dto/create-dropdown-option.dto";
|
||||
import { CreateDropdownSettingDto } from "./dto/create-dropdown-setting.dto";
|
||||
import { UpdateDropdownOptionDto } from "./dto/update-dropdown-option.dto";
|
||||
import { UpdateDropdownSettingDto } from "./dto/update-dropdown-setting.dto";
|
||||
import { DropdownOption } from "./entities/dropdown-option.entity";
|
||||
import { DropdownSetting } from "./entities/dropdown-setting.entity";
|
||||
import {
|
||||
DROPDOWN_SETTINGS_REPOSITORY,
|
||||
IDropdownSettingsRepository,
|
||||
} from "./interfaces/dropdown-settings.repository.interface";
|
||||
|
||||
const STATIONS_TER_CODE = "stations_ter";
|
||||
|
||||
const DEFAULT_STATION_OPTIONS: CreateDropdownOptionDto[] = [
|
||||
{
|
||||
value: "inside_addis_ababa",
|
||||
label: "Addis Ababa",
|
||||
note: "Inside country",
|
||||
order: 1,
|
||||
},
|
||||
{
|
||||
value: "inside_adama",
|
||||
label: "Adama",
|
||||
note: "Inside country",
|
||||
order: 2,
|
||||
},
|
||||
{
|
||||
value: "inside_mojo",
|
||||
label: "Mojo",
|
||||
note: "Inside country",
|
||||
order: 3,
|
||||
},
|
||||
{
|
||||
value: "inside_awash",
|
||||
label: "Awash",
|
||||
note: "Inside country",
|
||||
order: 4,
|
||||
},
|
||||
{
|
||||
value: "inside_mieso",
|
||||
label: "Mieso",
|
||||
note: "Inside country",
|
||||
order: 5,
|
||||
},
|
||||
{
|
||||
value: "inside_dire_dawa",
|
||||
label: "Dire Dawa",
|
||||
note: "Inside country",
|
||||
order: 6,
|
||||
},
|
||||
{
|
||||
value: "outside_ali_sabieh",
|
||||
label: "Ali Sabieh",
|
||||
note: "Outside country",
|
||||
order: 7,
|
||||
},
|
||||
{
|
||||
value: "outside_holhol",
|
||||
label: "Holhol",
|
||||
note: "Outside country",
|
||||
order: 8,
|
||||
},
|
||||
{
|
||||
value: "outside_djibouti_city",
|
||||
label: "Djibouti City",
|
||||
note: "Outside country",
|
||||
order: 9,
|
||||
},
|
||||
{
|
||||
value: "outside_doraleh_terminal",
|
||||
label: "Doraleh Terminal",
|
||||
note: "Outside country",
|
||||
order: 10,
|
||||
},
|
||||
];
|
||||
|
||||
@Injectable()
|
||||
export class DropdownSettingsService {
|
||||
constructor(
|
||||
@Inject(DROPDOWN_SETTINGS_REPOSITORY)
|
||||
private readonly repository: IDropdownSettingsRepository,
|
||||
) {}
|
||||
|
||||
list(): Promise<DropdownSetting[]> {
|
||||
return this.repository.findAll();
|
||||
}
|
||||
|
||||
async getById(id: string): Promise<DropdownSetting> {
|
||||
const setting = await this.repository.findById(id);
|
||||
if (!setting) throw new NotFoundException(`Setting ${id} not found`);
|
||||
return setting;
|
||||
}
|
||||
|
||||
async getByCode(code: string): Promise<DropdownSetting> {
|
||||
const setting = await this.repository.findByCode(code);
|
||||
if (!setting) throw new NotFoundException(`Setting "${code}" not found`);
|
||||
return setting;
|
||||
}
|
||||
|
||||
async create(dto: CreateDropdownSettingDto): Promise<DropdownSetting> {
|
||||
const existing = await this.repository.findByCode(dto.code);
|
||||
if (existing) {
|
||||
throw new ConflictException(
|
||||
`Dropdown setting with code "${dto.code}" already exists`,
|
||||
);
|
||||
}
|
||||
|
||||
const setting = await this.repository.create({
|
||||
code: dto.code,
|
||||
label: dto.label,
|
||||
description: dto.description ?? null,
|
||||
multiple: dto.multiple ?? false,
|
||||
meta: dto.meta ?? null,
|
||||
});
|
||||
|
||||
if (dto.children && dto.children.length > 0) {
|
||||
await this.repository.replaceOptions(setting.id, dto.children);
|
||||
}
|
||||
|
||||
return this.getById(setting.id);
|
||||
}
|
||||
|
||||
async seedDefaultStations(): Promise<void> {
|
||||
const existing = await this.repository.findByCode(STATIONS_TER_CODE);
|
||||
|
||||
if (!existing) {
|
||||
await this.create({
|
||||
code: STATIONS_TER_CODE,
|
||||
label: "Stations TER",
|
||||
description:
|
||||
"Temporary freight station list used by booking origin and destination yards.",
|
||||
multiple: false,
|
||||
meta: {
|
||||
searchable: true,
|
||||
clearable: true,
|
||||
version: "temporary",
|
||||
},
|
||||
children: DEFAULT_STATION_OPTIONS,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if ((existing.children?.length ?? 0) === 0) {
|
||||
await this.repository.replaceOptions(
|
||||
existing.id,
|
||||
DEFAULT_STATION_OPTIONS,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
dto: UpdateDropdownSettingDto,
|
||||
): Promise<DropdownSetting> {
|
||||
await this.getById(id);
|
||||
const updated = await this.repository.update(id, dto);
|
||||
if (!updated) throw new NotFoundException(`Setting ${id} not found`);
|
||||
return this.getById(id);
|
||||
}
|
||||
|
||||
async remove(id: string): Promise<void> {
|
||||
await this.getById(id);
|
||||
await this.repository.softDelete(id);
|
||||
}
|
||||
|
||||
/* ------------------------ option operations ------------------------ */
|
||||
|
||||
async replaceOptions(
|
||||
settingId: string,
|
||||
options: CreateDropdownOptionDto[],
|
||||
): Promise<DropdownOption[]> {
|
||||
await this.getById(settingId);
|
||||
return this.repository.replaceOptions(settingId, options);
|
||||
}
|
||||
|
||||
async addOption(
|
||||
settingId: string,
|
||||
dto: CreateDropdownOptionDto,
|
||||
): Promise<DropdownOption> {
|
||||
await this.getById(settingId);
|
||||
return this.repository.addOption(settingId, dto);
|
||||
}
|
||||
|
||||
async updateOption(
|
||||
optionId: string,
|
||||
dto: UpdateDropdownOptionDto,
|
||||
): Promise<DropdownOption> {
|
||||
const updated = await this.repository.updateOption(optionId, dto);
|
||||
if (!updated) throw new NotFoundException(`Option ${optionId} not found`);
|
||||
return updated;
|
||||
}
|
||||
|
||||
async removeOption(optionId: string): Promise<void> {
|
||||
await this.repository.removeOption(optionId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { Type } from "class-transformer";
|
||||
import {
|
||||
IsBoolean,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MaxLength,
|
||||
Min,
|
||||
ValidateNested,
|
||||
} from "class-validator";
|
||||
|
||||
import { DropdownOptionMetaDto } from "./dropdown-option-meta.dto";
|
||||
|
||||
export class CreateDropdownOptionDto {
|
||||
@IsString()
|
||||
@MaxLength(256)
|
||||
value!: string;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(256)
|
||||
label!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
note?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
disabled?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
order?: number;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => DropdownOptionMetaDto)
|
||||
meta?: DropdownOptionMetaDto;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user