import { Injectable, NotFoundException, ConflictException } from '@nestjs/common'; import { CompaniesRepository } from './companies.repository'; import { ExternalProfileRepository } from './external-profile.repository'; import { FFClientRepository } from './ff-client.repository'; import { CompanyDashboardRepository } from './company-dashboard.repository'; import { CreateCompanyDto } from './dto/create-company.dto'; import { UpdateCompanyDto } from './dto/update-company.dto'; import { CreateExternalProfileDto } from './dto/create-external-profile.dto'; import { CreateFFClientDto } from './dto/create-ff-client.dto'; import { CreateCompanyWithProfileDto } from './dto/create-company-with-profile.dto'; import { UpdateProfileDto } from './dto/update-profile.dto'; import { ProfileResponseDto } from './dto/profile-response.dto'; import { DashboardSummaryResponseDto } from './dto/dashboard-summary-response.dto'; import { Company } from './entities/company.entity'; import { ExternalProfile } from './entities/external-profile.entity'; import { FFClient } from './entities/ff-client.entity'; 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, private readonly dashboardRepo: CompanyDashboardRepository, ) {} async createCompany(dto: CreateCompanyDto): Promise { 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 { return this.companiesRepo.findAll({ order: { name: 'ASC' as any } }); } async findCompanyById(id: string): Promise { 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 }; } /** * Dashboard KPIs for the portal home (MyPortalPage), aggregated from the * current user's company bookings. All figures are scoped to that company. * * Note: delivered/spend/volume all derive from the bookings table — there is * no separate data source for them. On-time delivery rate is replaced by * completion rate (delivered ÷ committed): the schema has no ETA / * promised-delivery date, so on-time cannot be computed. * * Period attribution uses booking.created_at: there is no delivery-date * column, so "delivered YTD" counts bookings created this year that reached a * delivered/completed status. */ async getDashboardSummary(userId: string): Promise { // A user without a company profile has no bookings — return an empty summary // rather than 404, so the portal home still renders. const profile = await this.profilesRepo.findByUserId(userId); const companyId = profile?.company?.id ?? profile?.companyId ?? null; if (!companyId) return this.emptyDashboardSummary(); const now = new Date(); const yearStart = new Date(now.getFullYear(), 0, 1); const prevYearStart = new Date(now.getFullYear() - 1, 0, 1); // Same point in the previous year, so YoY compares like-for-like windows. const prevYearToDate = new Date(prevYearStart.getTime() + (now.getTime() - yearStart.getTime())); const [ deliveredThis, committedThis, spendThisByCcy, spendPrevByCcy, tonnageThis, tonnagePrev, monthlyRows, ] = await Promise.all([ this.dashboardRepo.countDelivered(companyId, yearStart, now), this.dashboardRepo.countCommitted(companyId, yearStart, now), this.dashboardRepo.sumPaidSpendByCurrency(companyId, yearStart, now), this.dashboardRepo.sumPaidSpendByCurrency(companyId, prevYearStart, prevYearToDate), this.dashboardRepo.sumCommittedTonnage(companyId, yearStart, now), this.dashboardRepo.sumCommittedTonnage(companyId, prevYearStart, prevYearToDate), this.dashboardRepo.monthlyCommittedTonnage(companyId, this.monthsAgo(now, 5), now), ]); // Spend can span currencies; report the dominant one (prefer ETB on ties). const spend = this.pickCurrencyTotal(spendThisByCcy); const spendPrev = spendPrevByCcy.find((c) => c.currency === spend.currency)?.total ?? 0; return { deliveredCount: deliveredThis, // Share of committed bookings that reached delivered/completed. completionRate: committedThis > 0 ? Math.round((deliveredThis / committedThis) * 100) : 0, spendYtd: spend.total, spendCurrency: spend.currency, spendYtdChangePct: this.changePct(spend.total, spendPrev), freightVolume: { totalTonnes: Math.round(tonnageThis), totalValue: spend.total, currency: spend.currency, ytdChangePct: this.changePct(tonnageThis, tonnagePrev), monthly: this.buildMonthlySeries(now, monthlyRows), }, }; } private emptyDashboardSummary(): DashboardSummaryResponseDto { const now = new Date(); return { deliveredCount: 0, completionRate: 0, spendYtd: 0, spendCurrency: 'ETB', spendYtdChangePct: 0, freightVolume: { totalTonnes: 0, totalValue: 0, currency: 'ETB', ytdChangePct: 0, monthly: this.buildMonthlySeries(now, []), }, }; } /** First day of the month `n` months before `from`. */ private monthsAgo(from: Date, n: number): Date { return new Date(from.getFullYear(), from.getMonth() - n, 1); } /** Pick the currency with the largest total, preferring ETB on ties / when empty. */ private pickCurrencyTotal(totals: { currency: string; total: number }[]): { currency: string; total: number } { if (totals.length === 0) return { currency: 'ETB', total: 0 }; return totals.reduce((best, cur) => (cur.total > best.total ? cur : best)); } /** Percentage change vs a prior value, rounded; 0 when there is no prior base. */ private changePct(current: number, previous: number): number { if (previous <= 0) return 0; return Math.round(((current - previous) / previous) * 100); } /** Build a fixed 6-month tonnage series ending on `now`, zero-filling gaps. */ private buildMonthlySeries( now: Date, rows: { year: number; month: number; tonnes: number }[], ): { month: string; tonnes: number }[] { const labels = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; const byKey = new Map(rows.map((r) => [`${r.year}-${r.month}`, r.tonnes])); const series: { month: string; tonnes: number }[] = []; for (let i = 5; i >= 0; i--) { const d = new Date(now.getFullYear(), now.getMonth() - i, 1); const key = `${d.getFullYear()}-${d.getMonth() + 1}`; series.push({ month: labels[d.getMonth()], tonnes: Math.round(byKey.get(key) ?? 0) }); } return series; } async updateCompany(id: string, dto: UpdateCompanyDto): Promise { 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 { const { profile, company } = await this.getCompanyInfoByUserId(userId); const companyUpdates: Record = {}; const attrUpdates: Record = { ...(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 { await this.findCompanyById(id); await this.companiesRepo.softDelete(id); } async createProfile(dto: CreateExternalProfileDto): Promise { 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 { 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 { return this.profilesRepo.findByCompanyId(companyId); } async createFFClient(dto: CreateFFClientDto): Promise { 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 { return this.ffClientsRepo.findByForwarder(forwarderCompanyId); } async deleteFFClient(id: string): Promise { const client = await this.ffClientsRepo.findById(id); if (!client) throw new NotFoundException(`FFClient ${id} not found`); await this.ffClientsRepo.softDelete(id); } }