Merge pull request #178 from Tria-plc/freight/fix/fixes

Freight/fix/fixes
This commit is contained in:
Nathnael Wondisha
2026-06-16 16:19:14 +03:00
committed by GitHub
41 changed files with 1178 additions and 1032 deletions

View File

@@ -15,6 +15,7 @@ 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';
import { DashboardSummaryResponseDto } from './dto/dashboard-summary-response.dto';
interface CurrentIamUser {
id: string;
@@ -45,6 +46,12 @@ export class CompaniesController {
return new ProfileResponseDto(profile, company);
}
@Get('dashboard')
@ApiOperation({ summary: 'Get portal dashboard KPIs (delivered, spend, freight volume) for the current user' })
async getDashboard(@CurrentUser() user: CurrentIamUser): Promise<DashboardSummaryResponseDto> {
return this.companiesService.getDashboardSummary(user.id);
}
@Patch('profile')
@ApiOperation({ summary: 'Update profile (flattened settings page)' })
async updateProfile(

View File

@@ -6,14 +6,16 @@ import { CompaniesService } from './companies.service';
import { CompaniesRepository } from './companies.repository';
import { ExternalProfileRepository } from './external-profile.repository';
import { FFClientRepository } from './ff-client.repository';
import { CompanyDashboardRepository } from './company-dashboard.repository';
import { Company } from './entities/company.entity';
import { ExternalProfile } from './entities/external-profile.entity';
import { FFClient } from './entities/ff-client.entity';
import { Booking } from '../bookings/entities/booking.entity';
@Module({
imports: [TypeOrmModule.forFeature([Company, ExternalProfile, FFClient]), FilesModule],
imports: [TypeOrmModule.forFeature([Company, ExternalProfile, FFClient, Booking]), FilesModule],
controllers: [CompaniesController],
providers: [CompaniesService, CompaniesRepository, ExternalProfileRepository, FFClientRepository],
providers: [CompaniesService, CompaniesRepository, ExternalProfileRepository, FFClientRepository, CompanyDashboardRepository],
exports: [CompaniesService],
})
export class CompaniesModule {}

View File

@@ -2,6 +2,7 @@ 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';
@@ -9,6 +10,7 @@ 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';
@@ -27,6 +29,7 @@ export class CompaniesService {
private readonly companiesRepo: CompaniesRepository,
private readonly profilesRepo: ExternalProfileRepository,
private readonly ffClientsRepo: FFClientRepository,
private readonly dashboardRepo: CompanyDashboardRepository,
) {}
async createCompany(dto: CreateCompanyDto): Promise<Company> {
@@ -98,6 +101,122 @@ export class CompaniesService {
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);

View File

@@ -0,0 +1,126 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Booking } from '../bookings/entities/booking.entity';
/** Booking statuses that represent a delivered/finished shipment. */
const DELIVERED_STATUSES = ['DELIVERED', 'COMPLETED'] as const;
/**
* Statuses that represent real, committed freight (excludes drafts and dead
* bookings) — used for tonnage so cancelled/expired drafts don't inflate volume.
*/
const COMMITTED_STATUSES = [
'APPROVED',
'CONTRACT_READY',
'SIGNED_CUSTOMER',
'FULLY_EXECUTED',
'SELECTED_FOR_BATCH',
'PNR_GENERATED',
'PAYMENT_VERIFICATION_IN_PROGRESS',
'PAID',
'IN_TRANSIT',
'COMPLETED',
'DELIVERED',
'CONSOLIDATED',
] as const;
export interface CurrencyTotal {
currency: string;
total: number;
}
export interface MonthlyTonnage {
year: number;
month: number; // 1-12
tonnes: number;
}
/**
* Read-only aggregation queries against the bookings table, scoped to a
* company, that back the portal dashboard. Lives in the companies module so it
* can be exposed via `companies.controller` without a circular dependency on
* BookingsModule (which already imports CompaniesModule).
*/
@Injectable()
export class CompanyDashboardRepository {
constructor(
@InjectRepository(Booking)
private readonly bookings: Repository<Booking>,
) {}
/** Count of delivered/completed bookings for a company within [from, to). */
async countDelivered(companyId: string, from: Date, to: Date): Promise<number> {
return this.bookings
.createQueryBuilder('b')
.where('b.company_id = :companyId', { companyId })
.andWhere('b.deleted_at IS NULL')
.andWhere('b.status IN (:...statuses)', { statuses: [...DELIVERED_STATUSES] })
.andWhere('b.created_at >= :from AND b.created_at < :to', { from, to })
.getCount();
}
/** Count of committed (non-draft, non-dead) bookings for a company within [from, to). */
async countCommitted(companyId: string, from: Date, to: Date): Promise<number> {
return this.bookings
.createQueryBuilder('b')
.where('b.company_id = :companyId', { companyId })
.andWhere('b.deleted_at IS NULL')
.andWhere('b.status IN (:...statuses)', { statuses: [...COMMITTED_STATUSES] })
.andWhere('b.created_at >= :from AND b.created_at < :to', { from, to })
.getCount();
}
/** Sum of paid booking totals, grouped by currency, within [from, to). */
async sumPaidSpendByCurrency(companyId: string, from: Date, to: Date): Promise<CurrencyTotal[]> {
const rows = await this.bookings
.createQueryBuilder('b')
.select('b.payment_currency', 'currency')
.addSelect('COALESCE(SUM(b.total_amount), 0)', 'total')
.where('b.company_id = :companyId', { companyId })
.andWhere('b.deleted_at IS NULL')
.andWhere("b.payment_status = 'PAID'")
.andWhere('b.created_at >= :from AND b.created_at < :to', { from, to })
.groupBy('b.payment_currency')
.getRawMany<{ currency: string; total: string }>();
return rows.map((r) => ({ currency: r.currency ?? 'ETB', total: Number(r.total) }));
}
/** Total committed tonnage (cargo VGM) for a company within [from, to). */
async sumCommittedTonnage(companyId: string, from: Date, to: Date): Promise<number> {
const row = await this.bookings
.createQueryBuilder('b')
.select('COALESCE(SUM(b.cargo_total_weight_vgm), 0)', 'total')
.where('b.company_id = :companyId', { companyId })
.andWhere('b.deleted_at IS NULL')
.andWhere('b.status IN (:...statuses)', { statuses: [...COMMITTED_STATUSES] })
.andWhere('b.created_at >= :from AND b.created_at < :to', { from, to })
.getRawOne<{ total: string }>();
return Number(row?.total ?? 0);
}
/** Committed tonnage grouped by calendar month within [from, to). */
async monthlyCommittedTonnage(companyId: string, from: Date, to: Date): Promise<MonthlyTonnage[]> {
const rows = await this.bookings
.createQueryBuilder('b')
.select('EXTRACT(YEAR FROM b.created_at)', 'year')
.addSelect('EXTRACT(MONTH FROM b.created_at)', 'month')
.addSelect('COALESCE(SUM(b.cargo_total_weight_vgm), 0)', 'total')
.where('b.company_id = :companyId', { companyId })
.andWhere('b.deleted_at IS NULL')
.andWhere('b.status IN (:...statuses)', { statuses: [...COMMITTED_STATUSES] })
.andWhere('b.created_at >= :from AND b.created_at < :to', { from, to })
.groupBy('year')
.addGroupBy('month')
.getRawMany<{ year: string; month: string; total: string }>();
return rows.map((r) => ({
year: Number(r.year),
month: Number(r.month),
tonnes: Number(r.total),
}));
}
}

View File

@@ -0,0 +1,59 @@
import { ApiProperty } from '@nestjs/swagger';
export class FreightVolumePointDto {
@ApiProperty({ example: 'May', description: 'Short month label' })
month!: string;
@ApiProperty({ example: 940, description: 'Tonnage shipped in the month' })
tonnes!: number;
}
export class FreightVolumeDto {
@ApiProperty({ example: 4180, description: 'Total tonnage shipped year-to-date' })
totalTonnes!: number;
@ApiProperty({ example: 1240000, description: 'Total committed freight value year-to-date' })
totalValue!: number;
@ApiProperty({ example: 'ETB' })
currency!: string;
@ApiProperty({ example: 16, description: 'Tonnage change vs same period last year, in percent' })
ytdChangePct!: number;
@ApiProperty({ type: [FreightVolumePointDto], description: 'Monthly tonnage series (oldest first, last 6 months)' })
monthly!: FreightVolumePointDto[];
}
/**
* KPIs for the portal dashboard (MyPortalPage), aggregated from the current
* user's company bookings. All figures are scoped to that company.
*
* Note: every metric here derives from the bookings table — there is no
* separate "non-booking" data source for delivered/spend/volume. On-time
* delivery rate is replaced by completion rate: no ETA / promised-delivery
* column exists in the schema, so on-time cannot be computed, whereas
* completion rate (delivered ÷ committed) can.
*/
export class DashboardSummaryResponseDto {
@ApiProperty({ example: 12, description: 'Bookings delivered/completed year-to-date' })
deliveredCount!: number;
@ApiProperty({
example: 92,
description: 'Share of committed bookings that have been delivered/completed (YTD), in percent',
})
completionRate!: number;
@ApiProperty({ example: 1240000, description: 'Total paid spend year-to-date' })
spendYtd!: number;
@ApiProperty({ example: 'ETB' })
spendCurrency!: string;
@ApiProperty({ example: 16, description: 'Spend change vs same period last year, in percent' })
spendYtdChangePct!: number;
@ApiProperty({ type: FreightVolumeDto })
freightVolume!: FreightVolumeDto;
}