mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
314 lines
13 KiB
TypeScript
314 lines
13 KiB
TypeScript
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<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 };
|
|
}
|
|
|
|
/**
|
|
* 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<DashboardSummaryResponseDto> {
|
|
// 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<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);
|
|
}
|
|
}
|