From e9149242b2e57939270598b73fe15a00e67ee476 Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Thu, 18 Jun 2026 16:37:18 +0300 Subject: [PATCH] feat: setup the company profiles --- .../contracts/contract-view-model.builder.ts | 4 +- ...2000000001-MoveBusinessLicenseToProfile.ts | 39 ++++++++ .../modules/companies/companies.controller.ts | 5 +- .../src/modules/companies/companies.module.ts | 5 +- .../modules/companies/companies.service.ts | 88 ++++++++++++++++++- .../companies/company-profile.repository.ts | 61 +++++++++++++ .../dto/create-company-with-profile.dto.ts | 21 ++++- .../companies/dto/response-company.dto.ts | 25 ++++++ .../portal/src/services/api.ts | 32 +++---- .../portal/src/services/companies.service.ts | 18 ++++ 10 files changed, 271 insertions(+), 27 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/1752000000001-MoveBusinessLicenseToProfile.ts create mode 100644 apps/edr-freight-api/src/modules/companies/company-profile.repository.ts diff --git a/apps/edr-freight-api/src/contracts/contract-view-model.builder.ts b/apps/edr-freight-api/src/contracts/contract-view-model.builder.ts index 676e4f4ba..41a2f3b44 100644 --- a/apps/edr-freight-api/src/contracts/contract-view-model.builder.ts +++ b/apps/edr-freight-api/src/contracts/contract-view-model.builder.ts @@ -114,7 +114,9 @@ export class ContractViewModelBuilder { tinNumber: this.valueOrDash(booking.company?.tin), vatNumber: this.valueOrDash(booking.company?.vatNumber), fanNumber: this.valueOrDash(booking.company?.fanNumber), - businessLicense: this.valueOrDash(booking.company?.businessLicense), + businessLicense: this.valueOrDash( + booking.company?.companyProfiles?.[0]?.businessLicense, + ), }, provider: { name: 'Ethio-Djibouti Standard Gauge Railway Share Company', diff --git a/apps/edr-freight-api/src/migrations/1752000000001-MoveBusinessLicenseToProfile.ts b/apps/edr-freight-api/src/migrations/1752000000001-MoveBusinessLicenseToProfile.ts new file mode 100644 index 000000000..f15996773 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1752000000001-MoveBusinessLicenseToProfile.ts @@ -0,0 +1,39 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class MoveBusinessLicenseToProfile1752000000001 + implements MigrationInterface +{ + name = 'MoveBusinessLicenseToProfile1752000000001'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + UPDATE freight.company_profiles cp + SET business_license = c.business_license + FROM freight.companies c + WHERE cp.company_id = c.id AND c.business_license IS NOT NULL + `); + + await queryRunner.query( + `ALTER TABLE freight.companies DROP COLUMN IF EXISTS business_license`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.companies ADD COLUMN business_license varchar(100) NULL`, + ); + + await queryRunner.query(` + UPDATE freight.companies c + SET business_license = cp.business_license + FROM ( + SELECT DISTINCT ON (cp2.company_id) + cp2.company_id, cp2.business_license + FROM freight.company_profiles cp2 + WHERE cp2.business_license IS NOT NULL + ORDER BY cp2.company_id, cp2.created_at + ) cp + WHERE cp.company_id = c.id + `); + } +} diff --git a/apps/edr-freight-api/src/modules/companies/companies.controller.ts b/apps/edr-freight-api/src/modules/companies/companies.controller.ts index 684e9e9bd..8e74c7528 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.controller.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.controller.ts @@ -85,6 +85,7 @@ export class CompaniesController { return this.companiesService.updateProfile(user.id, dto); } + // Used by portal @Post("create") @ApiOperation({ summary: @@ -109,10 +110,12 @@ export class CompaniesController { return new CompanyInfoResponseDto(profile, company); } + // Used by backoffice @Post() @FreightAdmin() @ApiOperation({ - summary: "Create a new company (customer, forwarder, transporter, broker)", + summary: + "Create a new company (customer, freight_forwarder, dj_freight_forwarder, transporter)", }) async create(@Body() dto: CreateCompanyDto): Promise { const company = await this.companiesService.createCompany(dto); diff --git a/apps/edr-freight-api/src/modules/companies/companies.module.ts b/apps/edr-freight-api/src/modules/companies/companies.module.ts index 6c809e8b7..53d3de4c8 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.module.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.module.ts @@ -8,11 +8,13 @@ import { ExternalProfileRepository } from "./external-profile.repository"; import { CompanyDashboardRepository } from "./company-dashboard.repository"; import { Company } from "./entities/company.entity"; import { ExternalProfile } from "./entities/external-profile.entity"; +import { CompanyProfile } from "./entities/company-profile.entity"; import { Booking } from "../bookings/entities/booking.entity"; +import { CompanyProfileRepository } from "./company-profile.repository"; @Module({ imports: [ - TypeOrmModule.forFeature([Company, ExternalProfile, Booking]), + TypeOrmModule.forFeature([Company, ExternalProfile, CompanyProfile, Booking]), FilesModule, ], controllers: [CompaniesController], @@ -20,6 +22,7 @@ import { Booking } from "../bookings/entities/booking.entity"; CompaniesService, CompaniesRepository, ExternalProfileRepository, + CompanyProfileRepository, CompanyDashboardRepository, ], exports: [CompaniesService], diff --git a/apps/edr-freight-api/src/modules/companies/companies.service.ts b/apps/edr-freight-api/src/modules/companies/companies.service.ts index 702c25d4e..ee0262413 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -2,8 +2,10 @@ import { Injectable, NotFoundException, ConflictException, + BadRequestException, } from "@nestjs/common"; import { CompaniesRepository } from "./companies.repository"; +import { CompanyProfileRepository } from "./company-profile.repository"; import { ExternalProfileRepository } from "./external-profile.repository"; import { CompanyDashboardRepository } from "./company-dashboard.repository"; import { CreateCompanyDto } from "./dto/create-company.dto"; @@ -15,6 +17,11 @@ import { ProfileResponseDto } from "./dto/profile-response.dto"; import { DashboardSummaryResponseDto } from "./dto/dashboard-summary-response.dto"; import { Company } from "./entities/company.entity"; import { ExternalProfile } from "./entities/external-profile.entity"; +import { + CompanyProfile, + ProfileType, + ProfileStatus, +} from "./entities/company-profile.entity"; export interface UserIdentity { userId: string; @@ -28,6 +35,7 @@ export interface UserIdentity { export class CompaniesService { constructor( private readonly companiesRepo: CompaniesRepository, + private readonly companyProfilesRepo: CompanyProfileRepository, private readonly profilesRepo: ExternalProfileRepository, private readonly dashboardRepo: CompanyDashboardRepository, ) { } @@ -65,7 +73,6 @@ export class CompaniesService { 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, @@ -89,7 +96,7 @@ export class CompaniesService { } async findAllCompanies(): Promise { - return this.companiesRepo.findAll({ order: { name: "ASC" as any } }); + return this.companiesRepo.findAll({ order: { name: "ASC" } }); } async findCompanyById(id: string): Promise { @@ -111,6 +118,9 @@ export class CompaniesService { `Company for profile ${profile.id} not found`, ); + company.companyProfiles = + await this.companyProfilesRepo.findByCompanyId(company.id); + return { profile, company }; } @@ -295,7 +305,6 @@ export class CompaniesService { 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; } @@ -352,4 +361,77 @@ export class CompaniesService { async findProfilesByCompany(companyId: string): Promise { return this.profilesRepo.findByCompanyId(companyId); } + + private getProfileTypeForCompanyType(companyType: string): ProfileType[] { + switch (companyType) { + case "customer": + return [ProfileType.importer, ProfileType.exporter]; + case "freight_forwarder": + return [ProfileType.freightForwarder]; + case "dj_freight_forwarder": + return [ProfileType.djFreightForwarder]; + case "transporter": + return [ProfileType.transporter]; + default: + return []; + } + } + + async createCompanyProfile( + companyId: string, + profileType?: ProfileType, + ): Promise { + const company = await this.findCompanyById(companyId); + + const allowedTypes = this.getProfileTypeForCompanyType(company.type); + + const type = profileType ?? allowedTypes[0]; + if (!allowedTypes.includes(type)) { + throw new BadRequestException( + `Profile type "${type}" is not allowed for company type "${company.type}"`, + ); + } + + const existing = await this.companyProfilesRepo.findByType(companyId, type); + if (existing) { + throw new ConflictException( + `Company already has a ${type} profile (${existing.reference})`, + ); + } + + const reference = await this.companyProfilesRepo.generateReference(type); + + return this.companyProfilesRepo.create({ + companyId, + type, + reference, + status: ProfileStatus.Active, + }); + } + + async createDefaultProfilesForCompany( + companyId: string, + ): Promise { + const company = await this.findCompanyById(companyId); + const types = this.getProfileTypeForCompanyType(company.type); + + const profiles: CompanyProfile[] = []; + for (const type of types) { + const existing = await this.companyProfilesRepo.findByType( + companyId, + type, + ); + if (!existing) { + profiles.push(await this.createCompanyProfile(companyId, type)); + } + } + + if (profiles.length === 0) { + throw new BadRequestException( + `Company of type "${company.type}" must have at least one operational profile`, + ); + } + + return profiles; + } } diff --git a/apps/edr-freight-api/src/modules/companies/company-profile.repository.ts b/apps/edr-freight-api/src/modules/companies/company-profile.repository.ts new file mode 100644 index 000000000..db7427112 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/company-profile.repository.ts @@ -0,0 +1,61 @@ +import { Injectable } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { Repository } from "typeorm"; +import { BaseRepository } from "@edr/api-common"; +import { CompanyProfile, ProfileType } from "./entities/company-profile.entity"; + +const SEQUENCE_MAP: Record = { + [ProfileType.exporter]: "seq_company_profile_ex", + [ProfileType.importer]: "seq_company_profile_im", + [ProfileType.freightForwarder]: "seq_company_profile_ffe", + [ProfileType.djFreightForwarder]: "seq_company_profile_fwj", + [ProfileType.transporter]: "seq_company_profile_tr", +}; + +const PREFIX_MAP: Record = { + [ProfileType.exporter]: "EX", + [ProfileType.importer]: "IM", + [ProfileType.freightForwarder]: "FFE", + [ProfileType.djFreightForwarder]: "FWJ", + [ProfileType.transporter]: "TR", +}; + +@Injectable() +export class CompanyProfileRepository extends BaseRepository { + constructor( + @InjectRepository(CompanyProfile) + repo: Repository, + ) { + super(repo); + } + + async generateReference(type: ProfileType): Promise { + const seqName = SEQUENCE_MAP[type]; + const result = await this.repository.query( + `SELECT nextval('${seqName}') AS next_id`, + ); + const nextId = result[0].next_id as number; + const prefix = PREFIX_MAP[type]; + return `${prefix}-${String(nextId).padStart(5, "0")}`; + } + + async findByCompanyId(companyId: string): Promise { + return this.repository.find({ + where: { companyId }, + relations: ["company"], + }); + } + + async findByType( + companyId: string, + type: ProfileType, + ): Promise { + return this.repository.findOne({ + where: { companyId, type }, + }); + } + + async findByReference(reference: string): Promise { + return this.repository.findOne({ where: { reference } }); + } +} diff --git a/apps/edr-freight-api/src/modules/companies/dto/create-company-with-profile.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/create-company-with-profile.dto.ts index 4287676d6..aa0bb72a2 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/create-company-with-profile.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/create-company-with-profile.dto.ts @@ -1,5 +1,17 @@ -import { IsString, IsNotEmpty, IsOptional, IsEmail, MaxLength, IsBoolean, IsEnum } from 'class-validator'; +import { IsString, IsNotEmpty, IsOptional, IsEmail, MaxLength, IsBoolean, IsEnum, IsArray, ValidateNested, ArrayMinSize } from 'class-validator'; +import { Type } from 'class-transformer'; import { CompanyType } from '../entities/company.entity'; +import { ProfileType } from '../entities/company-profile.entity'; + +export class CompanyProfileInputDto { + @IsEnum(ProfileType) + type!: ProfileType; + + @IsOptional() + @IsString() + @MaxLength(100) + businessLicense?: string; +} export class CreateCompanyWithProfileDto { @IsEnum(CompanyType) @@ -55,4 +67,11 @@ export class CreateCompanyWithProfileDto { @IsOptional() attributes?: Record; + + @IsOptional() + @IsArray() + @ArrayMinSize(1) + @ValidateNested({ each: true }) + @Type(() => CompanyProfileInputDto) + companyProfiles?: CompanyProfileInputDto[]; } diff --git a/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts index 748fbf73d..cb7777e8b 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts @@ -1,6 +1,29 @@ import { Company, CompanyType, CompanyStatus } from '../entities/company.entity'; +import { CompanyProfile } from '../entities/company-profile.entity'; import { ResponseExternalProfileDto } from './response-external-profile.dto'; +export class ResponseCompanyProfileDto { + id: string; + type: string; + reference: string; + status: string; + businessLicense?: string | null; + attributes?: Record | null; + createdAt: Date; + updatedAt: Date; + + constructor(profile: CompanyProfile) { + this.id = profile.id; + this.type = profile.type; + this.reference = profile.reference; + this.status = profile.status; + this.businessLicense = profile.businessLicense; + this.attributes = profile.attributes; + this.createdAt = profile.createdAt; + this.updatedAt = profile.updatedAt; + } +} + export class ResponseCompanyDto { id: string; name: string; @@ -16,6 +39,7 @@ export class ResponseCompanyDto { website?: string | null; attributes?: Record | null; profiles?: ResponseExternalProfileDto[]; + companyProfiles?: ResponseCompanyProfileDto[]; createdAt: Date; updatedAt: Date; @@ -34,6 +58,7 @@ export class ResponseCompanyDto { this.website = company.website; this.attributes = company.attributes; this.profiles = company.profiles?.map((p) => new ResponseExternalProfileDto(p)); + this.companyProfiles = company.companyProfiles?.map((p) => new ResponseCompanyProfileDto(p)); this.createdAt = company.createdAt; this.updatedAt = company.updatedAt; } diff --git a/apps/edr-freight-web/portal/src/services/api.ts b/apps/edr-freight-web/portal/src/services/api.ts index 32ef5bbc0..ea6616799 100644 --- a/apps/edr-freight-web/portal/src/services/api.ts +++ b/apps/edr-freight-web/portal/src/services/api.ts @@ -129,11 +129,10 @@ export const api = { }, bookings: { - list: endpoint>( - "bookings", - "list", - bookingsService.list, - ), + list: endpoint< + BookingListFilter | void, + PaginatedResponse + >("bookings", "list", bookingsService.list), get: endpoint<{ id: string }, Freight.IBooking>( "bookings", @@ -196,8 +195,14 @@ export const api = { getBookableSchedules: endpoint< { originYardId?: string; destinationYardId?: string }, Freight.BookableScheduleItem[] - >("train-scheduling", "bookableSchedules", ({ originYardId, destinationYardId }) => - bookingsService.getBookableSchedules({ originYardId, destinationYardId }), + >( + "train-scheduling", + "bookableSchedules", + ({ originYardId, destinationYardId }) => + bookingsService.getBookableSchedules({ + originYardId, + destinationYardId, + }), ), }, @@ -263,19 +268,6 @@ export const api = { ({ entity }) => fileUploadSettingsService.getByEntity(entity), ), - create: endpoint( - "file-upload-settings", - "create", - (payload) => fileUploadSettingsService.create(payload), - ), - - update: endpoint< - { id: string; dto: UpdateFileUploadSettingDto }, - FileUploadSetting - >("file-upload-settings", "update", ({ id, dto }) => - fileUploadSettingsService.update(id, dto), - ), - remove: endpoint<{ id: string }, void>( "file-upload-settings", "remove", diff --git a/apps/edr-freight-web/portal/src/services/companies.service.ts b/apps/edr-freight-web/portal/src/services/companies.service.ts index 409d9045b..eef7f31fe 100644 --- a/apps/edr-freight-web/portal/src/services/companies.service.ts +++ b/apps/edr-freight-web/portal/src/services/companies.service.ts @@ -35,6 +35,18 @@ export interface CompanyResponse { email: string | null; website: string | null; attributes: Record | null; + companyProfiles?: CompanyProfileResponse[]; + createdAt: string; + updatedAt: string; +} + +export interface CompanyProfileResponse { + id: string; + type: string; + reference: string; + status: string; + businessLicense: string | null; + attributes: Record | null; createdAt: string; updatedAt: string; } @@ -44,6 +56,11 @@ export interface CompanyInfoResponse { company: CompanyResponse; } +export interface CompanyProfileInput { + type: "importer" | "exporter" | "freight_forwarder" | "dj_freight_forwarder" | "transporter"; + businessLicense?: string; +} + export interface CreateCompanyPayload { companyType?: string; companyName: string; @@ -57,6 +74,7 @@ export interface CreateCompanyPayload { jobTitle?: string; isPrimaryContact?: boolean; attributes?: Record; + companyProfiles?: CompanyProfileInput[]; } export interface FreightVolumePoint {