mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-01 10:03:26 +00:00
Every revenue report excluded invoices whose booking carries contract_kind = 'GENERAL', on the premise that such a booking is an umbrella contract row paid once and drawn down by many orders, so counting it alongside those orders would double-count. That premise does not hold. On the dev database all 169 GENERAL bookings are real shipments with origin and destination yards, warehouse receipts and their own invoices; there is no umbrella invoice to double-count, and no invoice source of that kind exists at all. The predicate simply deleted 119 invoices and ETB 164.6M of billed revenue from every Finance report, which is why revenue-by-customer reported ETB 39.4M collected while /billing/invoices/summary reported ETB 140.1M - a gap of exactly ETB 100,741,976, the paid total of the 80 PAID invoices the predicate hid. Removing it from the shared revenue and invoice ledgers brings the reports back in line with billing (ETB 210.6M billed, ETB 140.1M paid, 163 invoices), and removing it from the overview repository restores the same bookings to the operational KPIs. Also fixes what that exposed in the reports that build their own query: - GATEWAY_PAID read the booking's whole gateway total onto every invoice sharing that booking. With 25 booking ids backing 58 invoices, the reconciliation report claimed ETB 113.2M of receipts against ETB 43.6M of settlement and showed ~ETB 89.4M of variance that does not exist. Receipts are now apportioned across an invoice's siblings by settled share, so the gateway column sums to the payments table and total variance is the real ETB 13.1M of manual settlements. - Aging Receivables joined companies with an INNER JOIN, dropping shipping-line-billed arrears, and summed both currencies under a hardcoded ETB label. Both payer joins are now LEFT and the report takes a currency filter. - Invoicing Pipeline summed ETB and USD invoices into one ETB total and applied no trade-direction scope, unlike every other Finance report. Both are now applied. Verified against the shared dev database: every new statement passes EXPLAIN, and the report totals reconcile with billing and with freight.payments. Type-check and the reports and overview suites pass; the five failures in billing.service.spec.ts are pre-existing on this branch and untouched by this change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1596 lines
54 KiB
TypeScript
1596 lines
54 KiB
TypeScript
import { Injectable } from "@nestjs/common";
|
||
import { InjectRepository } from "@nestjs/typeorm";
|
||
import { EUserStatus } from "@tria-plc/api-common/utils/enums/user.enum";
|
||
import { Employee } from "@tria-plc/iamapi-common";
|
||
import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity";
|
||
import { Freight } from "@edr/types";
|
||
import { Repository, ObjectLiteral } from "typeorm";
|
||
|
||
import { Booking } from "../bookings/entities/booking.entity";
|
||
import { Cargo } from "../cargoes/entities/cargoes.entity";
|
||
import { Container } from "../container-management/entities/container.entity";
|
||
import { Contract } from "../contracts/entities/contract.entity";
|
||
import { PaymentEntity } from "../payment/entities/payment.entity";
|
||
import { CargoType } from "../rule-engine/entities/cargo-type.entity";
|
||
import { ContainerType } from "../rule-engine/entities/container-type.entity";
|
||
import { Yard } from "../rule-engine/entities/yard.entity";
|
||
import { TrainSchedule } from "../train-schedules/entities/train-schedule.entity";
|
||
import { Train } from "../trains/entities/train.entity";
|
||
import { WagonType } from "../wagon-types/entities/wagon-type.entity";
|
||
import { Wagon } from "../wagons/entities/wagon.entity";
|
||
import {
|
||
OVERVIEW_CLOSED_STATUSES,
|
||
OVERVIEW_CONTRACT_CLOSED_STATUSES,
|
||
OVERVIEW_CONTRACT_IN_APPROVAL_STATUSES,
|
||
OVERVIEW_CONTRACT_IN_CLEARANCE_STATUSES,
|
||
OVERVIEW_CONTRACT_NEEDS_ACTION_STATUSES,
|
||
OVERVIEW_IN_APPROVAL_STATUSES,
|
||
OVERVIEW_NEEDS_ACTION_STATUSES,
|
||
OVERVIEW_URGENT_PRIORITY_THRESHOLD,
|
||
} from "./overview.constants";
|
||
import { Company } from "../companies/entities/company.entity";
|
||
import {
|
||
bookingRefScopeSql,
|
||
directionScopeSql,
|
||
} from "../user-trade-access/trade-scope.util";
|
||
|
||
export type OverviewBookingKpisRow = {
|
||
total: number;
|
||
totalActive: number;
|
||
needsAction: number;
|
||
urgent: number;
|
||
inApproval: number;
|
||
submittedToday: number;
|
||
};
|
||
|
||
export type OverviewRecentBookingRow = {
|
||
id: string;
|
||
reference: string;
|
||
customerLabel: string;
|
||
status: string;
|
||
priorityScore: number;
|
||
totalAmount: number | null;
|
||
paymentCurrency: string | null;
|
||
createdAt: Date;
|
||
};
|
||
|
||
/** One cell of a two-dimensional breakdown (bucket × stacked series). */
|
||
export type MatrixCell = { group: string; series: string; count: number };
|
||
|
||
export type TurnaroundRow = { trainSet: string; hours: number };
|
||
|
||
export type TrainLoadRow = {
|
||
scheduleId: string;
|
||
trainNumber: string;
|
||
date: string;
|
||
direction: string;
|
||
wagonsAllocated: number;
|
||
wagonsTotal: number;
|
||
tons: number;
|
||
};
|
||
|
||
export type OverviewContractKpisRow = {
|
||
total: number;
|
||
totalActive: number;
|
||
needsAction: number;
|
||
inApproval: number;
|
||
inClearance: number;
|
||
createdToday: number;
|
||
};
|
||
|
||
export type OverviewRecentContractRow = {
|
||
id: string;
|
||
reference: string;
|
||
customerLabel: string;
|
||
status: string;
|
||
contractKind: string;
|
||
freightType: string;
|
||
paymentCurrency: string | null;
|
||
validUntil: Date | null;
|
||
createdAt: Date;
|
||
};
|
||
|
||
@Injectable()
|
||
export class OverviewRepository {
|
||
constructor(
|
||
@InjectRepository(Booking)
|
||
private readonly bookingRepository: Repository<Booking>,
|
||
@InjectRepository(PaymentEntity)
|
||
private readonly paymentRepository: Repository<PaymentEntity>,
|
||
@InjectRepository(Company)
|
||
private readonly companyRepository: Repository<Company>,
|
||
@InjectRepository(Train)
|
||
private readonly trainRepository: Repository<Train>,
|
||
@InjectRepository(TrainSchedule)
|
||
private readonly trainScheduleRepository: Repository<TrainSchedule>,
|
||
@InjectRepository(Wagon)
|
||
private readonly wagonRepository: Repository<Wagon>,
|
||
@InjectRepository(Container)
|
||
private readonly containerRepository: Repository<Container>,
|
||
@InjectRepository(Cargo)
|
||
private readonly cargoRepository: Repository<Cargo>,
|
||
@InjectRepository(Contract)
|
||
private readonly contractRepository: Repository<Contract>,
|
||
@InjectRepository(Employee)
|
||
private readonly employeeRepository: Repository<Employee>,
|
||
@InjectRepository(User)
|
||
private readonly userRepository: Repository<User>,
|
||
) { }
|
||
|
||
async getBookingKpis(dirs?: string[]): Promise<OverviewBookingKpisRow> {
|
||
const scope = directionScopeSql("booking.trade_direction", dirs);
|
||
const row = await this.bookingRepository
|
||
.createQueryBuilder("booking")
|
||
.select("COUNT(*)::int", "total")
|
||
.addSelect(
|
||
`COUNT(*) FILTER (WHERE booking.status NOT IN (:...closedStatuses) AND booking.status != 'DRAFT')::int`,
|
||
"totalActive",
|
||
)
|
||
.addSelect(
|
||
`COUNT(*) FILTER (WHERE booking.status IN (:...needsActionStatuses))::int`,
|
||
"needsAction",
|
||
)
|
||
.addSelect(
|
||
`COUNT(*) FILTER (WHERE booking.priority_score >= :urgentThreshold)::int`,
|
||
"urgent",
|
||
)
|
||
.addSelect(
|
||
`COUNT(*) FILTER (WHERE booking.status IN (:...inApprovalStatuses))::int`,
|
||
"inApproval",
|
||
)
|
||
.addSelect(
|
||
`COUNT(*) FILTER (WHERE booking.created_at >= CURRENT_DATE AND booking.status != 'DRAFT')::int`,
|
||
"submittedToday",
|
||
)
|
||
.where("booking.deleted_at IS NULL")
|
||
.andWhere(scope.sql, scope.params)
|
||
.setParameters({
|
||
closedStatuses: [...OVERVIEW_CLOSED_STATUSES],
|
||
needsActionStatuses: [...OVERVIEW_NEEDS_ACTION_STATUSES],
|
||
inApprovalStatuses: [...OVERVIEW_IN_APPROVAL_STATUSES],
|
||
urgentThreshold: OVERVIEW_URGENT_PRIORITY_THRESHOLD,
|
||
})
|
||
.getRawOne<Record<string, string>>();
|
||
|
||
return {
|
||
total: Number(row?.total ?? 0),
|
||
totalActive: Number(row?.totalActive ?? 0),
|
||
needsAction: Number(row?.needsAction ?? 0),
|
||
urgent: Number(row?.urgent ?? 0),
|
||
inApproval: Number(row?.inApproval ?? 0),
|
||
submittedToday: Number(row?.submittedToday ?? 0),
|
||
};
|
||
}
|
||
|
||
async getOperationsKpis(): Promise<{
|
||
trainsActive: number;
|
||
wagonsAvailable: number;
|
||
wagonsTotal: number;
|
||
containersInTransit: number;
|
||
cargoesLoaded: number;
|
||
schedulesUpcoming: number;
|
||
dispatchedToday: number;
|
||
}> {
|
||
const [
|
||
trainsActive,
|
||
wagonsAvailable,
|
||
wagonsTotal,
|
||
containersInTransit,
|
||
cargoesLoaded,
|
||
schedulesUpcoming,
|
||
dispatchedToday,
|
||
] = await Promise.all([
|
||
this.trainRepository
|
||
.createQueryBuilder("train")
|
||
.where("train.deleted_at IS NULL")
|
||
.andWhere("train.status IN (:...statuses)", {
|
||
statuses: [
|
||
Freight.TrainStatus.InService,
|
||
Freight.TrainStatus.Scheduled,
|
||
],
|
||
})
|
||
.getCount(),
|
||
this.wagonRepository
|
||
.createQueryBuilder("wagon")
|
||
.where("wagon.deleted_at IS NULL")
|
||
.andWhere("wagon.status = :status", {
|
||
status: Freight.WagonStatus.Available,
|
||
})
|
||
.getCount(),
|
||
this.wagonRepository
|
||
.createQueryBuilder("wagon")
|
||
.where("wagon.deleted_at IS NULL")
|
||
.getCount(),
|
||
this.containerRepository
|
||
.createQueryBuilder("container")
|
||
.where("container.deleted_at IS NULL")
|
||
.andWhere("container.status = :status", { status: "IN_TRANSIT" })
|
||
.getCount(),
|
||
this.cargoRepository
|
||
.createQueryBuilder("cargo")
|
||
.where("cargo.deleted_at IS NULL")
|
||
.andWhere("cargo.status IN (:...statuses)", {
|
||
statuses: ["LOADED", "IN_TRANSIT"],
|
||
})
|
||
.getCount(),
|
||
this.trainScheduleRepository
|
||
.createQueryBuilder("schedule")
|
||
.where("schedule.deleted_at IS NULL")
|
||
.andWhere("schedule.status = :status", {
|
||
status: Freight.TrainScheduleStatus.Scheduled,
|
||
})
|
||
.andWhere("schedule.scheduled_departure_date >= CURRENT_DATE")
|
||
.getCount(),
|
||
this.trainScheduleRepository
|
||
.createQueryBuilder("schedule")
|
||
.where("schedule.deleted_at IS NULL")
|
||
.andWhere("schedule.status = :status", {
|
||
status: Freight.TrainScheduleStatus.Dispatched,
|
||
})
|
||
.andWhere("schedule.scheduled_departure_date::date = CURRENT_DATE")
|
||
.getCount(),
|
||
]);
|
||
|
||
return {
|
||
trainsActive,
|
||
wagonsAvailable,
|
||
wagonsTotal,
|
||
containersInTransit,
|
||
cargoesLoaded,
|
||
schedulesUpcoming,
|
||
dispatchedToday,
|
||
};
|
||
}
|
||
|
||
async getCustomerKpis(): Promise<{
|
||
totalCustomers: number;
|
||
newCustomersThisMonth: number;
|
||
}> {
|
||
const row = await this.companyRepository
|
||
.createQueryBuilder("customer")
|
||
.select("COUNT(*)::int", "totalCustomers")
|
||
.addSelect(
|
||
`COUNT(*) FILTER (WHERE customer.created_at >= date_trunc('month', CURRENT_DATE))::int`,
|
||
"newCustomersThisMonth",
|
||
)
|
||
.where("customer.deleted_at IS NULL")
|
||
.getRawOne<Record<string, string>>();
|
||
|
||
return {
|
||
totalCustomers: Number(row?.totalCustomers ?? 0),
|
||
newCustomersThisMonth: Number(row?.newCustomersThisMonth ?? 0),
|
||
};
|
||
}
|
||
|
||
async getBillingKpis(dirs?: string[]): Promise<{
|
||
revenueMtdEtb: number;
|
||
revenueMtdUsd: number;
|
||
pendingPayments: number;
|
||
successfulPaymentsMtd: number;
|
||
}> {
|
||
const scope = bookingRefScopeSql("payment.ref_id", dirs);
|
||
const revenueRow = await this.paymentRepository
|
||
.createQueryBuilder("payment")
|
||
.select(
|
||
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'ETB'), 0)`,
|
||
"revenueMtdEtb",
|
||
)
|
||
.addSelect(
|
||
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`,
|
||
"revenueMtdUsd",
|
||
)
|
||
.addSelect(`COUNT(*)::int`, "successfulPaymentsMtd")
|
||
.where("payment.status = :status", { status: "success" })
|
||
.andWhere(
|
||
`COALESCE(payment.paid_at, payment.created_at) >= date_trunc('month', CURRENT_DATE)`,
|
||
)
|
||
.andWhere(scope.sql, scope.params)
|
||
.getRawOne<Record<string, string>>();
|
||
|
||
const pendingPayments = await this.paymentRepository
|
||
.createQueryBuilder("payment")
|
||
.where("payment.status IN (:...statuses)", {
|
||
statuses: ["action-required", "processing"],
|
||
})
|
||
.andWhere(scope.sql, scope.params)
|
||
.getCount();
|
||
|
||
return {
|
||
revenueMtdEtb: Number(revenueRow?.revenueMtdEtb ?? 0),
|
||
revenueMtdUsd: Number(revenueRow?.revenueMtdUsd ?? 0),
|
||
pendingPayments,
|
||
successfulPaymentsMtd: Number(revenueRow?.successfulPaymentsMtd ?? 0),
|
||
};
|
||
}
|
||
|
||
async getStaffKpis(): Promise<{
|
||
activeEmployees: number;
|
||
activeUsers: number;
|
||
}> {
|
||
const [activeEmployees, activeUsers] = await Promise.all([
|
||
this.employeeRepository.count({
|
||
where: { isCurrent: true },
|
||
}),
|
||
this.userRepository.count({
|
||
where: {
|
||
isActive: true,
|
||
status: EUserStatus.ACCEPTED,
|
||
},
|
||
}),
|
||
]);
|
||
|
||
return { activeEmployees, activeUsers };
|
||
}
|
||
|
||
async getBookingTrend(
|
||
days: number,
|
||
dirs?: string[],
|
||
): Promise<{ date: string; count: number }[]> {
|
||
const scope = directionScopeSql("booking.trade_direction", dirs);
|
||
const rows = await this.bookingRepository
|
||
.createQueryBuilder("booking")
|
||
.select(`to_char(booking.created_at::date, 'YYYY-MM-DD')`, "date")
|
||
.addSelect("COUNT(*)::int", "count")
|
||
.where("booking.deleted_at IS NULL")
|
||
.andWhere(scope.sql, scope.params)
|
||
.andWhere(`booking.created_at >= CURRENT_DATE - :days::int + 1`, { days })
|
||
.groupBy("booking.created_at::date")
|
||
.orderBy("booking.created_at::date", "ASC")
|
||
.getRawMany<{ date: string; count: string }>();
|
||
|
||
return rows.map((row) => ({
|
||
date: row.date,
|
||
count: Number(row.count),
|
||
}));
|
||
}
|
||
|
||
async getStatusCounts(dirs?: string[]): Promise<Record<string, number>> {
|
||
const scope = directionScopeSql("booking.trade_direction", dirs);
|
||
const rows = await this.bookingRepository
|
||
.createQueryBuilder("booking")
|
||
.select("booking.status", "status")
|
||
.addSelect("COUNT(*)::int", "count")
|
||
.where("booking.deleted_at IS NULL")
|
||
.andWhere(scope.sql, scope.params)
|
||
.groupBy("booking.status")
|
||
.getRawMany<{ status: string; count: string }>();
|
||
|
||
return Object.fromEntries(
|
||
rows.map((row) => [row.status, Number(row.count)]),
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Daily successful-payment revenue for a `days`-wide window shifted back by
|
||
* `offsetDays` — `0` (default) is the current window ending today,
|
||
* `offsetDays: days` is the immediately preceding window (the ghost-line
|
||
* comparison series on the overview chart).
|
||
*/
|
||
async getPaymentTrend(
|
||
days: number,
|
||
dirs?: string[],
|
||
offsetDays = 0,
|
||
): Promise<{ date: string; amountEtb: number; amountUsd: number }[]> {
|
||
const scope = bookingRefScopeSql("payment.ref_id", dirs);
|
||
const rows = await this.paymentRepository
|
||
.createQueryBuilder("payment")
|
||
.select(
|
||
`to_char(COALESCE(payment.paid_at, payment.created_at)::date, 'YYYY-MM-DD')`,
|
||
"date",
|
||
)
|
||
.addSelect(
|
||
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'ETB'), 0)`,
|
||
"amountEtb",
|
||
)
|
||
.addSelect(
|
||
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`,
|
||
"amountUsd",
|
||
)
|
||
.where("payment.status = :status", { status: "success" })
|
||
.andWhere(
|
||
`COALESCE(payment.paid_at, payment.created_at) >= CURRENT_DATE - :offsetDays::int - :days::int + 1 AND COALESCE(payment.paid_at, payment.created_at) < CURRENT_DATE - :offsetDays::int + 1`,
|
||
{ days, offsetDays },
|
||
)
|
||
.andWhere(scope.sql, scope.params)
|
||
.groupBy(`COALESCE(payment.paid_at, payment.created_at)::date`)
|
||
.orderBy(`COALESCE(payment.paid_at, payment.created_at)::date`, "ASC")
|
||
.getRawMany<{ date: string; amountEtb: string; amountUsd: string }>();
|
||
|
||
return rows.map((row) => ({
|
||
date: row.date,
|
||
amountEtb: Number(row.amountEtb),
|
||
amountUsd: Number(row.amountUsd),
|
||
}));
|
||
}
|
||
|
||
async getRecentBookings(
|
||
limit: number,
|
||
dirs?: string[],
|
||
): Promise<OverviewRecentBookingRow[]> {
|
||
const scope = directionScopeSql("booking.trade_direction", dirs);
|
||
const rows = await this.bookingRepository
|
||
.createQueryBuilder("booking")
|
||
.leftJoin("booking.company", "company")
|
||
.select("booking.id", "id")
|
||
.addSelect("booking.reference", "reference")
|
||
.addSelect("COALESCE(company.name, '—')", "customerLabel")
|
||
.addSelect("booking.status", "status")
|
||
.addSelect("booking.priority_score", "priorityScore")
|
||
.addSelect("booking.total_amount", "totalAmount")
|
||
.addSelect("booking.payment_currency", "paymentCurrency")
|
||
.addSelect("booking.created_at", "createdAt")
|
||
.where("booking.deleted_at IS NULL")
|
||
.andWhere(scope.sql, scope.params)
|
||
.orderBy("booking.created_at", "DESC")
|
||
.limit(limit)
|
||
.getRawMany<{
|
||
id: string;
|
||
reference: string;
|
||
customerLabel: string;
|
||
status: string;
|
||
priorityScore: string;
|
||
totalAmount: string | null;
|
||
paymentCurrency: string | null;
|
||
createdAt: Date;
|
||
}>();
|
||
|
||
return rows.map((row) => ({
|
||
id: row.id,
|
||
reference: row.reference,
|
||
customerLabel: row.customerLabel,
|
||
status: row.status,
|
||
priorityScore: Number(row.priorityScore),
|
||
totalAmount: row.totalAmount != null ? Number(row.totalAmount) : null,
|
||
paymentCurrency: row.paymentCurrency,
|
||
createdAt: row.createdAt,
|
||
}));
|
||
}
|
||
|
||
async getBookingsByFreightType(
|
||
dirs?: string[],
|
||
): Promise<{ label: string; count: number }[]> {
|
||
const scope = directionScopeSql("booking.trade_direction", dirs);
|
||
const rows = await this.bookingRepository
|
||
.createQueryBuilder("booking")
|
||
.select("booking.freight_type", "label")
|
||
.addSelect("COUNT(*)::int", "count")
|
||
.where("booking.deleted_at IS NULL")
|
||
.andWhere("booking.status != 'DRAFT'")
|
||
.andWhere(scope.sql, scope.params)
|
||
.groupBy("booking.freight_type")
|
||
.orderBy("count", "DESC")
|
||
.getRawMany<{ label: string; count: string }>();
|
||
|
||
return rows.map((row) => ({
|
||
label: row.label,
|
||
count: Number(row.count),
|
||
}));
|
||
}
|
||
|
||
async getBookingsByCurrency(
|
||
dirs?: string[],
|
||
): Promise<{ label: string; count: number }[]> {
|
||
const scope = directionScopeSql("booking.trade_direction", dirs);
|
||
const rows = await this.bookingRepository
|
||
.createQueryBuilder("booking")
|
||
.select("booking.payment_currency", "label")
|
||
.addSelect("COUNT(*)::int", "count")
|
||
.where("booking.deleted_at IS NULL")
|
||
.andWhere("booking.status != 'DRAFT'")
|
||
.andWhere(scope.sql, scope.params)
|
||
.groupBy("booking.payment_currency")
|
||
.orderBy("count", "DESC")
|
||
.getRawMany<{ label: string; count: string }>();
|
||
|
||
return rows.map((row) => ({
|
||
label: row.label,
|
||
count: Number(row.count),
|
||
}));
|
||
}
|
||
|
||
async getPaymentsByStatus(
|
||
dirs?: string[],
|
||
): Promise<{ status: string; count: number }[]> {
|
||
const scope = bookingRefScopeSql("payment.ref_id", dirs);
|
||
const rows = await this.paymentRepository
|
||
.createQueryBuilder("payment")
|
||
.select("payment.status", "status")
|
||
.addSelect("COUNT(*)::int", "count")
|
||
.where(scope.sql, scope.params)
|
||
.groupBy("payment.status")
|
||
.orderBy("count", "DESC")
|
||
.getRawMany<{ status: string; count: string }>();
|
||
|
||
return rows.map((row) => ({
|
||
status: row.status,
|
||
count: Number(row.count),
|
||
}));
|
||
}
|
||
|
||
async getPaymentsByMethod(
|
||
dirs?: string[],
|
||
): Promise<
|
||
{ method: string; count: number; amountEtb: number; amountUsd: number }[]
|
||
> {
|
||
const scope = bookingRefScopeSql("payment.ref_id", dirs);
|
||
const rows = await this.paymentRepository
|
||
.createQueryBuilder("payment")
|
||
.select("payment.method", "method")
|
||
.addSelect("COUNT(*)::int", "count")
|
||
.addSelect(
|
||
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'ETB' AND payment.status = 'success'), 0)`,
|
||
"amountEtb",
|
||
)
|
||
.addSelect(
|
||
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD' AND payment.status = 'success'), 0)`,
|
||
"amountUsd",
|
||
)
|
||
.where(scope.sql, scope.params)
|
||
.groupBy("payment.method")
|
||
.orderBy("count", "DESC")
|
||
.getRawMany<{
|
||
method: string;
|
||
count: string;
|
||
amountEtb: string;
|
||
amountUsd: string;
|
||
}>();
|
||
|
||
return rows.map((row) => ({
|
||
method: row.method,
|
||
count: Number(row.count),
|
||
amountEtb: Number(row.amountEtb),
|
||
amountUsd: Number(row.amountUsd),
|
||
}));
|
||
}
|
||
|
||
async getRevenueByCurrency(
|
||
dirs?: string[],
|
||
): Promise<{ currency: string; amount: number }[]> {
|
||
const scope = bookingRefScopeSql("payment.ref_id", dirs);
|
||
const rows = await this.paymentRepository
|
||
.createQueryBuilder("payment")
|
||
.select("payment.currency", "currency")
|
||
.addSelect("COALESCE(SUM(payment.amount), 0)", "amount")
|
||
.where("payment.status = :status", { status: "success" })
|
||
.andWhere(
|
||
`COALESCE(payment.paid_at, payment.created_at) >= date_trunc('month', CURRENT_DATE)`,
|
||
)
|
||
.andWhere(scope.sql, scope.params)
|
||
.groupBy("payment.currency")
|
||
.getRawMany<{ currency: string; amount: string }>();
|
||
|
||
return rows.map((row) => ({
|
||
currency: row.currency,
|
||
amount: Number(row.amount),
|
||
}));
|
||
}
|
||
|
||
/**
|
||
* Bookings created, revenue and tonnage for one `days`-wide window, shifted
|
||
* back by `offsetDays`. Called twice by the service — `offsetDays: 0` for
|
||
* the current period, `offsetDays: days` for the immediately preceding
|
||
* one-of-the-same-length period — so the page can show a real vs-prior-period
|
||
* delta instead of a bare count.
|
||
*/
|
||
async getPeriodTotals(
|
||
days: number,
|
||
offsetDays: number,
|
||
dirs?: string[],
|
||
): Promise<{
|
||
bookingsCreated: number;
|
||
revenueEtb: number;
|
||
revenueUsd: number;
|
||
tons: number;
|
||
}> {
|
||
const bookingScope = directionScopeSql("booking.trade_direction", dirs);
|
||
const paymentScope = bookingRefScopeSql("payment.ref_id", dirs);
|
||
const cargoScope = directionScopeSql("booking.trade_direction", dirs);
|
||
const windowSql = (column: string) =>
|
||
`${column} >= CURRENT_DATE - :offsetDays::int - :days::int + 1 AND ${column} < CURRENT_DATE - :offsetDays::int + 1`;
|
||
|
||
const [bookingsCreated, revenueRow, tonsRow] = await Promise.all([
|
||
this.bookingRepository
|
||
.createQueryBuilder("booking")
|
||
.where("booking.deleted_at IS NULL")
|
||
.andWhere(bookingScope.sql, bookingScope.params)
|
||
.andWhere(windowSql("booking.created_at"), { days, offsetDays })
|
||
.getCount(),
|
||
this.paymentRepository
|
||
.createQueryBuilder("payment")
|
||
.select(
|
||
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'ETB'), 0)`,
|
||
"revenueEtb",
|
||
)
|
||
.addSelect(
|
||
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`,
|
||
"revenueUsd",
|
||
)
|
||
.where("payment.status = :status", { status: "success" })
|
||
.andWhere(
|
||
windowSql("COALESCE(payment.paid_at, payment.created_at)"),
|
||
{ days, offsetDays },
|
||
)
|
||
.andWhere(paymentScope.sql, paymentScope.params)
|
||
.getRawOne<{ revenueEtb: string; revenueUsd: string }>(),
|
||
this.cargoRepository
|
||
.createQueryBuilder("cargo")
|
||
.leftJoin(Booking, "booking", "booking.id = cargo.booking_id")
|
||
.select(`COALESCE(SUM(cargo.weight), 0) / 1000`, "tons")
|
||
.where("cargo.deleted_at IS NULL")
|
||
.andWhere(windowSql("cargo.created_at"), { days, offsetDays })
|
||
.andWhere(cargoScope.sql, cargoScope.params)
|
||
.getRawOne<{ tons: string }>(),
|
||
]);
|
||
|
||
return {
|
||
bookingsCreated,
|
||
revenueEtb: Number(revenueRow?.revenueEtb ?? 0),
|
||
revenueUsd: Number(revenueRow?.revenueUsd ?? 0),
|
||
tons: Number(tonsRow?.tons ?? 0),
|
||
};
|
||
}
|
||
|
||
/** Revenue for the selected range, split by booking trade direction. */
|
||
async getRevenueByDirection(
|
||
days: number,
|
||
dirs?: string[],
|
||
): Promise<{ label: string; amountEtb: number; amountUsd: number }[]> {
|
||
const scope = bookingRefScopeSql("payment.ref_id", dirs);
|
||
const rows = await this.paymentRepository
|
||
.createQueryBuilder("payment")
|
||
.leftJoin(Booking, "booking", "booking.id::text = payment.ref_id")
|
||
.select("booking.trade_direction", "label")
|
||
.addSelect(
|
||
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'ETB'), 0)`,
|
||
"amountEtb",
|
||
)
|
||
.addSelect(
|
||
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`,
|
||
"amountUsd",
|
||
)
|
||
.where("payment.status = :status", { status: "success" })
|
||
.andWhere(
|
||
`COALESCE(payment.paid_at, payment.created_at) >= CURRENT_DATE - :days::int + 1`,
|
||
{ days },
|
||
)
|
||
.andWhere(scope.sql, scope.params)
|
||
.andWhere("booking.trade_direction IS NOT NULL")
|
||
.groupBy("booking.trade_direction")
|
||
.getRawMany<{ label: string; amountEtb: string; amountUsd: string }>();
|
||
|
||
return rows.map((row) => ({
|
||
label: row.label,
|
||
amountEtb: Number(row.amountEtb),
|
||
amountUsd: Number(row.amountUsd),
|
||
}));
|
||
}
|
||
|
||
/** Revenue for the selected range, split by booking freight type. */
|
||
async getRevenueByFreightType(
|
||
days: number,
|
||
dirs?: string[],
|
||
): Promise<{ label: string; amountEtb: number; amountUsd: number }[]> {
|
||
const scope = bookingRefScopeSql("payment.ref_id", dirs);
|
||
const rows = await this.paymentRepository
|
||
.createQueryBuilder("payment")
|
||
.leftJoin(Booking, "booking", "booking.id::text = payment.ref_id")
|
||
.select("booking.freight_type", "label")
|
||
.addSelect(
|
||
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'ETB'), 0)`,
|
||
"amountEtb",
|
||
)
|
||
.addSelect(
|
||
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`,
|
||
"amountUsd",
|
||
)
|
||
.where("payment.status = :status", { status: "success" })
|
||
.andWhere(
|
||
`COALESCE(payment.paid_at, payment.created_at) >= CURRENT_DATE - :days::int + 1`,
|
||
{ days },
|
||
)
|
||
.andWhere(scope.sql, scope.params)
|
||
.andWhere("booking.freight_type IS NOT NULL")
|
||
.groupBy("booking.freight_type")
|
||
.getRawMany<{ label: string; amountEtb: string; amountUsd: string }>();
|
||
|
||
return rows.map((row) => ({
|
||
label: row.label,
|
||
amountEtb: Number(row.amountEtb),
|
||
amountUsd: Number(row.amountUsd),
|
||
}));
|
||
}
|
||
|
||
/** Daily cargo tonnage for the selected range — hero sparkline series. */
|
||
async getTonsTrend(
|
||
days: number,
|
||
dirs?: string[],
|
||
): Promise<{ date: string; tons: number }[]> {
|
||
const scope = directionScopeSql("booking.trade_direction", dirs);
|
||
const rows = await this.cargoRepository
|
||
.createQueryBuilder("cargo")
|
||
.leftJoin(Booking, "booking", "booking.id = cargo.booking_id")
|
||
.select(`to_char(cargo.created_at::date, 'YYYY-MM-DD')`, "date")
|
||
.addSelect(`COALESCE(SUM(cargo.weight), 0) / 1000`, "tons")
|
||
.where("cargo.deleted_at IS NULL")
|
||
.andWhere(scope.sql, scope.params)
|
||
.andWhere(`cargo.created_at >= CURRENT_DATE - :days::int + 1`, { days })
|
||
.groupBy("cargo.created_at::date")
|
||
.orderBy("cargo.created_at::date", "ASC")
|
||
.getRawMany<{ date: string; tons: string }>();
|
||
|
||
return rows.map((row) => ({ date: row.date, tons: Number(row.tons) }));
|
||
}
|
||
|
||
/**
|
||
* Revenue for the selected range as direction → freight-type flows — the
|
||
* Sankey on the overview. One row per (direction, freight type) pair.
|
||
*/
|
||
async getRevenueFlows(
|
||
days: number,
|
||
dirs?: string[],
|
||
): Promise<
|
||
{
|
||
direction: string;
|
||
freightType: string;
|
||
amountEtb: number;
|
||
amountUsd: number;
|
||
}[]
|
||
> {
|
||
const scope = bookingRefScopeSql("payment.ref_id", dirs);
|
||
const rows = await this.paymentRepository
|
||
.createQueryBuilder("payment")
|
||
.leftJoin(Booking, "booking", "booking.id::text = payment.ref_id")
|
||
.select("booking.trade_direction", "direction")
|
||
.addSelect("booking.freight_type", "freightType")
|
||
.addSelect(
|
||
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'ETB'), 0)`,
|
||
"amountEtb",
|
||
)
|
||
.addSelect(
|
||
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`,
|
||
"amountUsd",
|
||
)
|
||
.where("payment.status = :status", { status: "success" })
|
||
.andWhere(
|
||
`COALESCE(payment.paid_at, payment.created_at) >= CURRENT_DATE - :days::int + 1`,
|
||
{ days },
|
||
)
|
||
.andWhere(scope.sql, scope.params)
|
||
.andWhere("booking.trade_direction IS NOT NULL")
|
||
.andWhere("booking.freight_type IS NOT NULL")
|
||
.groupBy("booking.trade_direction")
|
||
.addGroupBy("booking.freight_type")
|
||
.getRawMany<{
|
||
direction: string;
|
||
freightType: string;
|
||
amountEtb: string;
|
||
amountUsd: string;
|
||
}>();
|
||
|
||
return rows.map((row) => ({
|
||
direction: row.direction,
|
||
freightType: row.freightType,
|
||
amountEtb: Number(row.amountEtb),
|
||
amountUsd: Number(row.amountUsd),
|
||
}));
|
||
}
|
||
|
||
/**
|
||
* Booking arrivals bucketed by ISO weekday (1 = Mon … 7 = Sun) and 3-hour
|
||
* block (0 = 00–03 … 7 = 21–24) — the demand-rhythm heatmap. Buckets use
|
||
* the database server's timezone, same as every ::date grouping here.
|
||
*/
|
||
async getBookingHeatmap(
|
||
days: number,
|
||
dirs?: string[],
|
||
): Promise<{ dow: number; block: number; count: number }[]> {
|
||
const scope = directionScopeSql("booking.trade_direction", dirs);
|
||
const rows = await this.bookingRepository
|
||
.createQueryBuilder("booking")
|
||
.select("EXTRACT(ISODOW FROM booking.created_at)::int", "dow")
|
||
.addSelect("FLOOR(EXTRACT(HOUR FROM booking.created_at) / 3)::int", "block")
|
||
.addSelect("COUNT(*)::int", "count")
|
||
.where("booking.deleted_at IS NULL")
|
||
.andWhere(scope.sql, scope.params)
|
||
.andWhere(`booking.created_at >= CURRENT_DATE - :days::int + 1`, { days })
|
||
.groupBy("EXTRACT(ISODOW FROM booking.created_at)::int")
|
||
.addGroupBy("FLOOR(EXTRACT(HOUR FROM booking.created_at) / 3)::int")
|
||
.getRawMany<{ dow: string; block: string; count: string }>();
|
||
|
||
return rows.map((row) => ({
|
||
dow: Number(row.dow),
|
||
block: Number(row.block),
|
||
count: Number(row.count),
|
||
}));
|
||
}
|
||
|
||
async getTrainStatusBreakdown(): Promise<
|
||
{ status: string; count: number }[]
|
||
> {
|
||
return this.statusBreakdown(this.trainRepository, "train");
|
||
}
|
||
|
||
async getWagonStatusBreakdown(): Promise<
|
||
{ status: string; count: number }[]
|
||
> {
|
||
return this.statusBreakdown(this.wagonRepository, "wagon");
|
||
}
|
||
|
||
async getContainerStatusBreakdown(): Promise<
|
||
{ status: string; count: number }[]
|
||
> {
|
||
return this.statusBreakdown(this.containerRepository, "container");
|
||
}
|
||
|
||
async getCargoStatusBreakdown(): Promise<
|
||
{ status: string; count: number }[]
|
||
> {
|
||
return this.statusBreakdown(this.cargoRepository, "cargo");
|
||
}
|
||
|
||
async getScheduleStatusBreakdown(): Promise<
|
||
{ status: string; count: number }[]
|
||
> {
|
||
return this.statusBreakdown(this.trainScheduleRepository, "schedule");
|
||
}
|
||
|
||
/** Scheduled departures per day over the range, split by trade direction. */
|
||
async getDepartureTrend(days: number): Promise<
|
||
{
|
||
date: string;
|
||
importCount: number;
|
||
exportCount: number;
|
||
domesticCount: number;
|
||
}[]
|
||
> {
|
||
const rows = await this.trainScheduleRepository
|
||
.createQueryBuilder("schedule")
|
||
.select(
|
||
`to_char(schedule.scheduled_departure_date::date, 'YYYY-MM-DD')`,
|
||
"date",
|
||
)
|
||
.addSelect(
|
||
`COUNT(*) FILTER (WHERE schedule.direction = 'IMPORT')::int`,
|
||
"importCount",
|
||
)
|
||
.addSelect(
|
||
`COUNT(*) FILTER (WHERE schedule.direction = 'EXPORT')::int`,
|
||
"exportCount",
|
||
)
|
||
.addSelect(
|
||
`COUNT(*) FILTER (WHERE schedule.direction NOT IN ('IMPORT', 'EXPORT') OR schedule.direction IS NULL)::int`,
|
||
"domesticCount",
|
||
)
|
||
.where("schedule.deleted_at IS NULL")
|
||
.andWhere("schedule.status != :draft", {
|
||
draft: Freight.TrainScheduleStatus.Draft,
|
||
})
|
||
.andWhere(
|
||
`schedule.scheduled_departure_date >= CURRENT_DATE - :days::int + 1`,
|
||
{ days },
|
||
)
|
||
.andWhere(
|
||
`schedule.scheduled_departure_date < CURRENT_DATE + :ahead::int`,
|
||
{ ahead: 8 },
|
||
)
|
||
.groupBy("schedule.scheduled_departure_date::date")
|
||
.orderBy("schedule.scheduled_departure_date::date", "ASC")
|
||
.getRawMany<{
|
||
date: string;
|
||
importCount: string;
|
||
exportCount: string;
|
||
domesticCount: string;
|
||
}>();
|
||
|
||
return rows.map((row) => ({
|
||
date: row.date,
|
||
importCount: Number(row.importCount),
|
||
exportCount: Number(row.exportCount),
|
||
domesticCount: Number(row.domesticCount),
|
||
}));
|
||
}
|
||
|
||
async getWagonsByType(): Promise<{ label: string; count: number }[]> {
|
||
const rows = await this.wagonRepository
|
||
.createQueryBuilder("wagon")
|
||
.leftJoin(WagonType, "wagon_type", "wagon_type.id = wagon.wagon_type_id")
|
||
.select(`COALESCE(wagon_type.name, 'Unknown')`, "label")
|
||
.addSelect("COUNT(*)::int", "count")
|
||
.where("wagon.deleted_at IS NULL")
|
||
.groupBy("wagon_type.name")
|
||
.orderBy("count", "DESC")
|
||
.getRawMany<{ label: string; count: string }>();
|
||
|
||
return rows.map((row) => ({ label: row.label, count: Number(row.count) }));
|
||
}
|
||
|
||
async getWagonsByYard(limit: number): Promise<
|
||
{ label: string; count: number }[]
|
||
> {
|
||
const rows = await this.wagonRepository
|
||
.createQueryBuilder("wagon")
|
||
.innerJoin(Yard, "yard", "yard.id = wagon.current_yard_id")
|
||
.select("yard.label", "label")
|
||
.addSelect("COUNT(*)::int", "count")
|
||
.where("wagon.deleted_at IS NULL")
|
||
.groupBy("yard.label")
|
||
.orderBy("count", "DESC")
|
||
.limit(limit)
|
||
.getRawMany<{ label: string; count: string }>();
|
||
|
||
return rows.map((row) => ({ label: row.label, count: Number(row.count) }));
|
||
}
|
||
|
||
async getContainersBySize(): Promise<{ label: string; count: number }[]> {
|
||
const rows = await this.containerRepository
|
||
.createQueryBuilder("container")
|
||
.leftJoin(
|
||
ContainerType,
|
||
"container_type",
|
||
"container_type.id = container.container_type_id",
|
||
)
|
||
.select(
|
||
`COALESCE(container_type.size_ft::text || ' ft', container_type.code, 'Unknown')`,
|
||
"label",
|
||
)
|
||
.addSelect("COUNT(*)::int", "count")
|
||
.where("container.deleted_at IS NULL")
|
||
.groupBy("container_type.size_ft")
|
||
.addGroupBy("container_type.code")
|
||
.orderBy("count", "DESC")
|
||
.getRawMany<{ label: string; count: string }>();
|
||
|
||
return rows.map((row) => ({ label: row.label, count: Number(row.count) }));
|
||
}
|
||
|
||
/** Total cargo weight (tons) grouped by cargo type, heaviest first. */
|
||
async getCargoTonnageByType(limit: number): Promise<
|
||
{ label: string; tons: number }[]
|
||
> {
|
||
const rows = await this.cargoRepository
|
||
.createQueryBuilder("cargo")
|
||
.leftJoin(CargoType, "cargo_type", "cargo_type.id = cargo.cargo_type_id")
|
||
.select(`COALESCE(cargo_type.cargo_type_name, 'Other')`, "label")
|
||
.addSelect(`ROUND(COALESCE(SUM(cargo.weight), 0) / 1000, 1)`, "tons")
|
||
.where("cargo.deleted_at IS NULL")
|
||
.groupBy("cargo_type.cargo_type_name")
|
||
.orderBy("tons", "DESC")
|
||
.limit(limit)
|
||
.getRawMany<{ label: string; tons: string }>();
|
||
|
||
return rows
|
||
.map((row) => ({ label: row.label, tons: Number(row.tons) }))
|
||
.filter((row) => row.tons > 0);
|
||
}
|
||
|
||
private async statusBreakdown(
|
||
repository: Repository<ObjectLiteral>,
|
||
alias: string,
|
||
): Promise<{ status: string; count: number }[]> {
|
||
const rows = await repository
|
||
.createQueryBuilder(alias)
|
||
.select(`${alias}.status`, "status")
|
||
.addSelect("COUNT(*)::int", "count")
|
||
.where(`${alias}.deleted_at IS NULL`)
|
||
.groupBy(`${alias}.status`)
|
||
.orderBy("count", "DESC")
|
||
.getRawMany<{ status: string; count: string }>();
|
||
|
||
return rows.map((row) => ({
|
||
status: row.status,
|
||
count: Number(row.count),
|
||
}));
|
||
}
|
||
|
||
async getCustomerGrowthTrend(
|
||
days: number,
|
||
): Promise<{ date: string; count: number }[]> {
|
||
const rows = await this.companyRepository
|
||
.createQueryBuilder("customer")
|
||
.select(`to_char(customer.created_at::date, 'YYYY-MM-DD')`, "date")
|
||
.addSelect("COUNT(*)::int", "count")
|
||
.where("customer.deleted_at IS NULL")
|
||
.andWhere(`customer.created_at >= CURRENT_DATE - :days::int + 1`, {
|
||
days,
|
||
})
|
||
.groupBy("customer.created_at::date")
|
||
.orderBy("customer.created_at::date", "ASC")
|
||
.getRawMany<{ date: string; count: string }>();
|
||
|
||
return rows.map((row) => ({
|
||
date: row.date,
|
||
count: Number(row.count),
|
||
}));
|
||
}
|
||
|
||
async getCustomersByType(): Promise<{ label: string; count: number }[]> {
|
||
const rows = await this.companyRepository
|
||
.createQueryBuilder("customer")
|
||
.select(
|
||
`COALESCE(NULLIF(customer.type, ''), 'Unknown')`,
|
||
"label",
|
||
)
|
||
.addSelect("COUNT(*)::int", "count")
|
||
.where("customer.deleted_at IS NULL")
|
||
.groupBy("customer.type")
|
||
.orderBy("count", "DESC")
|
||
.getRawMany<{ label: string; count: string }>();
|
||
|
||
return rows.map((row) => ({
|
||
label: row.label,
|
||
count: Number(row.count),
|
||
}));
|
||
}
|
||
|
||
async getTopCustomersByBookings(
|
||
limit: number,
|
||
dirs?: string[],
|
||
): Promise<{ label: string; count: number }[]> {
|
||
const scope = directionScopeSql("booking.trade_direction", dirs);
|
||
const rows = await this.bookingRepository
|
||
.createQueryBuilder("booking")
|
||
.leftJoin("booking.company", "company")
|
||
.select(`COALESCE(company.name, 'Unknown')`, "label")
|
||
.addSelect("COUNT(*)::int", "count")
|
||
.where("booking.deleted_at IS NULL")
|
||
.andWhere("booking.status != 'DRAFT'")
|
||
.andWhere(scope.sql, scope.params)
|
||
.groupBy("company.name")
|
||
.orderBy("count", "DESC")
|
||
.limit(limit)
|
||
.getRawMany<{ label: string; count: string }>();
|
||
|
||
return rows.map((row) => ({
|
||
label: row.label,
|
||
count: Number(row.count),
|
||
}));
|
||
}
|
||
|
||
async getUsersByStatus(): Promise<{ status: string; count: number }[]> {
|
||
const rows = await this.userRepository
|
||
.createQueryBuilder("user")
|
||
.select("user.status", "status")
|
||
.addSelect("COUNT(*)::int", "count")
|
||
.groupBy("user.status")
|
||
.orderBy("count", "DESC")
|
||
.getRawMany<{ status: string; count: string }>();
|
||
|
||
return rows.map((row) => ({
|
||
status: row.status,
|
||
count: Number(row.count),
|
||
}));
|
||
}
|
||
|
||
async getEmployeeGrowthTrend(
|
||
days: number,
|
||
): Promise<{ date: string; count: number }[]> {
|
||
const rows = await this.employeeRepository
|
||
.createQueryBuilder("employee")
|
||
.select(`to_char(employee.created_at::date, 'YYYY-MM-DD')`, "date")
|
||
.addSelect("COUNT(*)::int", "count")
|
||
.where("employee.is_current = true")
|
||
.andWhere(`employee.created_at >= CURRENT_DATE - :days::int + 1`, {
|
||
days,
|
||
})
|
||
.groupBy("employee.created_at::date")
|
||
.orderBy("employee.created_at::date", "ASC")
|
||
.getRawMany<{ date: string; count: string }>();
|
||
|
||
return rows.map((row) => ({
|
||
date: row.date,
|
||
count: Number(row.count),
|
||
}));
|
||
}
|
||
|
||
async getActiveUsersBreakdown(): Promise<{ label: string; count: number }[]> {
|
||
const [active, inactive] = await Promise.all([
|
||
this.userRepository.count({
|
||
where: { isActive: true, status: EUserStatus.ACCEPTED },
|
||
}),
|
||
this.userRepository
|
||
.createQueryBuilder("user")
|
||
.where("user.is_active = false OR user.status != :status", {
|
||
status: EUserStatus.ACCEPTED,
|
||
})
|
||
.getCount(),
|
||
]);
|
||
|
||
return [
|
||
{ label: "Active", count: active },
|
||
{ label: "Inactive", count: inactive },
|
||
];
|
||
}
|
||
|
||
// ── Contracts (overview Contract tab) ──────────────────────────────────────
|
||
|
||
async getContractKpis(dirs?: string[]): Promise<OverviewContractKpisRow> {
|
||
const scope = directionScopeSql("contract.trade_direction", dirs);
|
||
const row = await this.contractRepository
|
||
.createQueryBuilder("contract")
|
||
.select("COUNT(*)::int", "total")
|
||
.addSelect(
|
||
`COUNT(*) FILTER (WHERE contract.status NOT IN (:...closedStatuses) AND contract.status != 'DRAFT')::int`,
|
||
"totalActive",
|
||
)
|
||
.addSelect(
|
||
`COUNT(*) FILTER (WHERE contract.status IN (:...needsActionStatuses))::int`,
|
||
"needsAction",
|
||
)
|
||
.addSelect(
|
||
`COUNT(*) FILTER (WHERE contract.status IN (:...inApprovalStatuses))::int`,
|
||
"inApproval",
|
||
)
|
||
.addSelect(
|
||
`COUNT(*) FILTER (WHERE contract.status IN (:...inClearanceStatuses))::int`,
|
||
"inClearance",
|
||
)
|
||
.addSelect(
|
||
`COUNT(*) FILTER (WHERE contract.created_at >= CURRENT_DATE AND contract.status != 'DRAFT')::int`,
|
||
"createdToday",
|
||
)
|
||
.where("contract.deleted_at IS NULL")
|
||
.andWhere(scope.sql, scope.params)
|
||
.setParameters({
|
||
closedStatuses: [...OVERVIEW_CONTRACT_CLOSED_STATUSES],
|
||
needsActionStatuses: [...OVERVIEW_CONTRACT_NEEDS_ACTION_STATUSES],
|
||
inApprovalStatuses: [...OVERVIEW_CONTRACT_IN_APPROVAL_STATUSES],
|
||
inClearanceStatuses: [...OVERVIEW_CONTRACT_IN_CLEARANCE_STATUSES],
|
||
})
|
||
.getRawOne<Record<string, string>>();
|
||
|
||
return {
|
||
total: Number(row?.total ?? 0),
|
||
totalActive: Number(row?.totalActive ?? 0),
|
||
needsAction: Number(row?.needsAction ?? 0),
|
||
inApproval: Number(row?.inApproval ?? 0),
|
||
inClearance: Number(row?.inClearance ?? 0),
|
||
createdToday: Number(row?.createdToday ?? 0),
|
||
};
|
||
}
|
||
|
||
async getContractStatusCounts(
|
||
dirs?: string[],
|
||
): Promise<Record<string, number>> {
|
||
const scope = directionScopeSql("contract.trade_direction", dirs);
|
||
const rows = await this.contractRepository
|
||
.createQueryBuilder("contract")
|
||
.select("contract.status", "status")
|
||
.addSelect("COUNT(*)::int", "count")
|
||
.where("contract.deleted_at IS NULL")
|
||
.andWhere(scope.sql, scope.params)
|
||
.groupBy("contract.status")
|
||
.getRawMany<{ status: string; count: string }>();
|
||
|
||
return Object.fromEntries(rows.map((row) => [row.status, Number(row.count)]));
|
||
}
|
||
|
||
async getContractTrend(
|
||
days: number,
|
||
dirs?: string[],
|
||
): Promise<{ date: string; count: number }[]> {
|
||
const scope = directionScopeSql("contract.trade_direction", dirs);
|
||
const rows = await this.contractRepository
|
||
.createQueryBuilder("contract")
|
||
.select(`to_char(contract.created_at::date, 'YYYY-MM-DD')`, "date")
|
||
.addSelect("COUNT(*)::int", "count")
|
||
.where("contract.deleted_at IS NULL")
|
||
.andWhere(scope.sql, scope.params)
|
||
.andWhere(`contract.created_at >= CURRENT_DATE - :days::int + 1`, { days })
|
||
.groupBy("contract.created_at::date")
|
||
.orderBy("contract.created_at::date", "ASC")
|
||
.getRawMany<{ date: string; count: string }>();
|
||
|
||
return rows.map((row) => ({ date: row.date, count: Number(row.count) }));
|
||
}
|
||
|
||
async getContractsByKind(
|
||
dirs?: string[],
|
||
): Promise<{ label: string; count: number }[]> {
|
||
const scope = directionScopeSql("contract.trade_direction", dirs);
|
||
const rows = await this.contractRepository
|
||
.createQueryBuilder("contract")
|
||
.select("contract.contract_kind", "label")
|
||
.addSelect("COUNT(*)::int", "count")
|
||
.where("contract.deleted_at IS NULL")
|
||
.andWhere("contract.status != 'DRAFT'")
|
||
.andWhere(scope.sql, scope.params)
|
||
.groupBy("contract.contract_kind")
|
||
.orderBy("count", "DESC")
|
||
.getRawMany<{ label: string; count: string }>();
|
||
|
||
return rows.map((row) => ({ label: row.label, count: Number(row.count) }));
|
||
}
|
||
|
||
async getContractsByFreightType(
|
||
dirs?: string[],
|
||
): Promise<{ label: string; count: number }[]> {
|
||
const scope = directionScopeSql("contract.trade_direction", dirs);
|
||
const rows = await this.contractRepository
|
||
.createQueryBuilder("contract")
|
||
.select("contract.freight_type", "label")
|
||
.addSelect("COUNT(*)::int", "count")
|
||
.where("contract.deleted_at IS NULL")
|
||
.andWhere("contract.status != 'DRAFT'")
|
||
.andWhere(scope.sql, scope.params)
|
||
.groupBy("contract.freight_type")
|
||
.orderBy("count", "DESC")
|
||
.getRawMany<{ label: string; count: string }>();
|
||
|
||
return rows.map((row) => ({ label: row.label, count: Number(row.count) }));
|
||
}
|
||
|
||
async getRecentContracts(
|
||
limit: number,
|
||
dirs?: string[],
|
||
): Promise<OverviewRecentContractRow[]> {
|
||
const scope = directionScopeSql("contract.trade_direction", dirs);
|
||
const rows = await this.contractRepository
|
||
.createQueryBuilder("contract")
|
||
.leftJoin("contract.company", "company")
|
||
.select("contract.id", "id")
|
||
.addSelect("contract.reference", "reference")
|
||
.addSelect("COALESCE(company.name, '—')", "customerLabel")
|
||
.addSelect("contract.status", "status")
|
||
.addSelect("contract.contract_kind", "contractKind")
|
||
.addSelect("contract.freight_type", "freightType")
|
||
.addSelect("contract.payment_currency", "paymentCurrency")
|
||
.addSelect("contract.contract_valid_until", "validUntil")
|
||
.addSelect("contract.created_at", "createdAt")
|
||
.where("contract.deleted_at IS NULL")
|
||
.andWhere(scope.sql, scope.params)
|
||
.orderBy("contract.created_at", "DESC")
|
||
.limit(limit)
|
||
.getRawMany<{
|
||
id: string;
|
||
reference: string;
|
||
customerLabel: string;
|
||
status: string;
|
||
contractKind: string;
|
||
freightType: string;
|
||
paymentCurrency: string | null;
|
||
validUntil: Date | null;
|
||
createdAt: Date;
|
||
}>();
|
||
|
||
return rows.map((row) => ({
|
||
id: row.id,
|
||
reference: row.reference,
|
||
customerLabel: row.customerLabel,
|
||
status: row.status,
|
||
contractKind: row.contractKind,
|
||
freightType: row.freightType,
|
||
paymentCurrency: row.paymentCurrency,
|
||
validUntil: row.validUntil,
|
||
createdAt: row.createdAt,
|
||
}));
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Fleet / operations detail. These read tables that have no repository
|
||
// injected here (locomotives, train_set_wagons, document reviews, …), so they
|
||
// go through the shared entity manager with plain SQL instead of adding six
|
||
// more constructor arguments for one query each.
|
||
// ---------------------------------------------------------------------------
|
||
|
||
private get sql() {
|
||
return this.wagonRepository.manager;
|
||
}
|
||
|
||
private async matrix(
|
||
statement: string,
|
||
params: unknown[] = [],
|
||
): Promise<MatrixCell[]> {
|
||
const rows = await this.sql.query<
|
||
{ group: string; series: string; count: string }[]
|
||
>(statement, params);
|
||
return rows.map((row) => ({
|
||
group: row.group,
|
||
series: row.series,
|
||
count: Number(row.count),
|
||
}));
|
||
}
|
||
|
||
private async labelCounts(
|
||
statement: string,
|
||
params: unknown[] = [],
|
||
): Promise<{ label: string; count: number }[]> {
|
||
const rows = await this.sql.query<{ label: string; count: string }[]>(
|
||
statement,
|
||
params,
|
||
);
|
||
return rows.map((row) => ({ label: row.label, count: Number(row.count) }));
|
||
}
|
||
|
||
private async statusCounts(
|
||
statement: string,
|
||
params: unknown[] = [],
|
||
): Promise<{ status: string; count: number }[]> {
|
||
const rows = await this.sql.query<{ status: string; count: string }[]>(
|
||
statement,
|
||
params,
|
||
);
|
||
return rows.map((row) => ({ status: row.status, count: Number(row.count) }));
|
||
}
|
||
|
||
/** Wagon lifecycle state crossed with wagon type — "how many flat wagons are detained". */
|
||
getWagonStatusByType(): Promise<MatrixCell[]> {
|
||
return this.matrix(`
|
||
SELECT COALESCE(wt.name, 'Unknown') AS "group",
|
||
w.status AS series,
|
||
COUNT(*)::int AS count
|
||
FROM freight.wagons w
|
||
LEFT JOIN freight.wagon_types wt ON wt.id = w.wagon_type_id
|
||
WHERE w.deleted_at IS NULL
|
||
GROUP BY 1, 2
|
||
ORDER BY 1, 2
|
||
`);
|
||
}
|
||
|
||
/** Same lifecycle state, per yard the wagon currently sits in. */
|
||
getWagonStatusByYard(): Promise<MatrixCell[]> {
|
||
return this.matrix(`
|
||
SELECT y.label AS "group",
|
||
w.status AS series,
|
||
COUNT(*)::int AS count
|
||
FROM freight.wagons w
|
||
INNER JOIN freight.yards y ON y.id = w.current_yard_id
|
||
WHERE w.deleted_at IS NULL
|
||
GROUP BY 1, 2
|
||
ORDER BY 1, 2
|
||
`);
|
||
}
|
||
|
||
getLocomotiveStatusBreakdown(): Promise<{ status: string; count: number }[]> {
|
||
return this.statusCounts(`
|
||
SELECT status, COUNT(*)::int AS count
|
||
FROM freight.locomotives
|
||
WHERE deleted_at IS NULL
|
||
GROUP BY status
|
||
ORDER BY count DESC
|
||
`);
|
||
}
|
||
|
||
/** Locomotives per station, split by status — the OCC's first question. */
|
||
getLocomotivesByYard(): Promise<MatrixCell[]> {
|
||
return this.matrix(`
|
||
SELECT COALESCE(y.label, 'Unassigned') AS "group",
|
||
l.status AS series,
|
||
COUNT(*)::int AS count
|
||
FROM freight.locomotives l
|
||
LEFT JOIN freight.yards y ON y.id = l.current_yard_id
|
||
WHERE l.deleted_at IS NULL
|
||
GROUP BY 1, 2
|
||
ORDER BY 1, 2
|
||
`);
|
||
}
|
||
|
||
getLocomotivesByType(): Promise<{ label: string; count: number }[]> {
|
||
return this.labelCounts(`
|
||
SELECT COALESCE(locomotive_type, 'Unknown') AS label,
|
||
COUNT(*)::int AS count
|
||
FROM freight.locomotives
|
||
WHERE deleted_at IS NULL
|
||
GROUP BY 1
|
||
ORDER BY count DESC
|
||
`);
|
||
}
|
||
|
||
/**
|
||
* Departure-to-departure gap per train set over the range. Only consecutive
|
||
* *actual* departures count — a schedule that never left says nothing about
|
||
* how fast the set turned around.
|
||
*/
|
||
async getTrainTurnaround(
|
||
days: number,
|
||
limit: number,
|
||
): Promise<{ avgHours: number | null; rows: TurnaroundRow[] }> {
|
||
const rows = await this.sql.query<
|
||
{ trainSet: string; hours: string }[]
|
||
>(
|
||
`
|
||
WITH departures AS (
|
||
SELECT s.train_set_id,
|
||
s.actual_departure_at,
|
||
LAG(s.actual_departure_at) OVER (
|
||
PARTITION BY s.train_set_id ORDER BY s.actual_departure_at
|
||
) AS previous_departure
|
||
FROM freight.train_schedules s
|
||
WHERE s.deleted_at IS NULL
|
||
AND s.actual_departure_at IS NOT NULL
|
||
AND s.actual_departure_at >= NOW() - make_interval(days => $1::int)
|
||
)
|
||
SELECT COALESCE(t.train_number, t.code, 'Train set') AS "trainSet",
|
||
ROUND(
|
||
AVG(
|
||
EXTRACT(EPOCH FROM (d.actual_departure_at - d.previous_departure)) / 3600
|
||
)::numeric,
|
||
1
|
||
) AS hours
|
||
FROM departures d
|
||
LEFT JOIN freight.train_sets ts ON ts.id = d.train_set_id
|
||
LEFT JOIN freight.trains t ON t.id = ts.train_id
|
||
WHERE d.previous_departure IS NOT NULL
|
||
GROUP BY 1
|
||
ORDER BY hours ASC
|
||
LIMIT $2::int
|
||
`,
|
||
[days, limit],
|
||
);
|
||
|
||
const mapped = rows.map((row) => ({
|
||
trainSet: row.trainSet,
|
||
hours: Number(row.hours),
|
||
}));
|
||
const avgHours = mapped.length
|
||
? Number(
|
||
(
|
||
mapped.reduce((sum, row) => sum + row.hours, 0) / mapped.length
|
||
).toFixed(1),
|
||
)
|
||
: null;
|
||
|
||
return { avgHours, rows: mapped };
|
||
}
|
||
|
||
/**
|
||
* Bookings per port yard. Import cargo enters at its origin yard, export
|
||
* cargo leaves from its destination yard — anything else is counted at origin.
|
||
*/
|
||
getBookingsByPort(days: number): Promise<{ label: string; count: number }[]> {
|
||
return this.labelCounts(
|
||
`
|
||
SELECT y.label AS label, COUNT(*)::int AS count
|
||
FROM freight.bookings b
|
||
INNER JOIN freight.yards y
|
||
ON y.id = CASE WHEN b.trade_direction = 'EXPORT'
|
||
THEN b.destination_yard_id ELSE b.origin_yard_id END
|
||
WHERE b.deleted_at IS NULL
|
||
AND b.created_at >= NOW() - make_interval(days => $1::int)
|
||
GROUP BY 1
|
||
ORDER BY count DESC
|
||
`,
|
||
[days],
|
||
);
|
||
}
|
||
|
||
getBookingStatusByPort(days: number): Promise<MatrixCell[]> {
|
||
return this.matrix(
|
||
`
|
||
SELECT y.label AS "group", b.status AS series, COUNT(*)::int AS count
|
||
FROM freight.bookings b
|
||
INNER JOIN freight.yards y
|
||
ON y.id = CASE WHEN b.trade_direction = 'EXPORT'
|
||
THEN b.destination_yard_id ELSE b.origin_yard_id END
|
||
WHERE b.deleted_at IS NULL
|
||
AND b.created_at >= NOW() - make_interval(days => $1::int)
|
||
GROUP BY 1, 2
|
||
ORDER BY 1, 2
|
||
`,
|
||
[days],
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Wagon fill and tonnage per scheduled train. Slots come from the train set's
|
||
* wagon list, the load from confirmed booking allocations against those slots.
|
||
*/
|
||
async getTrainLoads(limit: number): Promise<TrainLoadRow[]> {
|
||
const rows = await this.sql.query<
|
||
{
|
||
scheduleId: string;
|
||
trainNumber: string;
|
||
date: string;
|
||
direction: string;
|
||
wagonsTotal: string;
|
||
wagonsAllocated: string;
|
||
tons: string;
|
||
}[]
|
||
>(
|
||
`
|
||
SELECT s.id AS "scheduleId",
|
||
COALESCE(s.train_number, s.reference, '—') AS "trainNumber",
|
||
to_char(s.scheduled_departure_date, 'YYYY-MM-DD') AS date,
|
||
COALESCE(s.direction, 'DOMESTIC') AS direction,
|
||
COALESCE(slots.total, 0)::int AS "wagonsTotal",
|
||
COALESCE(load.wagons, 0)::int AS "wagonsAllocated",
|
||
COALESCE(load.tons, 0)::float AS tons
|
||
FROM freight.train_schedules s
|
||
LEFT JOIN LATERAL (
|
||
SELECT COUNT(*)::int AS total
|
||
FROM freight.train_set_wagons tsw
|
||
WHERE tsw.train_set_id = s.train_set_id
|
||
AND tsw.deleted_at IS NULL
|
||
) slots ON TRUE
|
||
LEFT JOIN LATERAL (
|
||
SELECT COUNT(DISTINCT wba.train_set_wagon_id)::int AS wagons,
|
||
COALESCE(SUM(wba.allocated_weight_tons), 0) AS tons
|
||
FROM freight.wagon_booking_allocations wba
|
||
INNER JOIN freight.train_set_wagons tsw ON tsw.id = wba.train_set_wagon_id
|
||
WHERE tsw.train_set_id = s.train_set_id
|
||
AND wba.deleted_at IS NULL
|
||
AND tsw.deleted_at IS NULL
|
||
) load ON TRUE
|
||
WHERE s.deleted_at IS NULL
|
||
AND s.status <> 'DRAFT'
|
||
ORDER BY s.scheduled_departure_date DESC
|
||
LIMIT $1::int
|
||
`,
|
||
[limit],
|
||
);
|
||
|
||
return rows.map((row) => ({
|
||
scheduleId: row.scheduleId,
|
||
trainNumber: row.trainNumber,
|
||
date: row.date,
|
||
direction: row.direction,
|
||
wagonsTotal: Number(row.wagonsTotal),
|
||
wagonsAllocated: Number(row.wagonsAllocated),
|
||
tons: Number(row.tons),
|
||
}));
|
||
}
|
||
|
||
getBookingDocumentsByStatus(): Promise<{ status: string; count: number }[]> {
|
||
return this.statusCounts(`
|
||
SELECT status, COUNT(*)::int AS count
|
||
FROM freight.booking_document_review
|
||
WHERE deleted_at IS NULL
|
||
GROUP BY status
|
||
ORDER BY count DESC
|
||
`);
|
||
}
|
||
|
||
getContractDocumentsByStatus(): Promise<{ status: string; count: number }[]> {
|
||
return this.statusCounts(`
|
||
SELECT status, COUNT(*)::int AS count
|
||
FROM freight.contract_document_review
|
||
WHERE deleted_at IS NULL
|
||
GROUP BY status
|
||
ORDER BY count DESC
|
||
`);
|
||
}
|
||
|
||
getInvoicesByStatus(): Promise<{ status: string; count: number }[]> {
|
||
return this.statusCounts(`
|
||
SELECT status, COUNT(*)::int AS count
|
||
FROM freight.invoices
|
||
WHERE deleted_at IS NULL
|
||
GROUP BY status
|
||
ORDER BY count DESC
|
||
`);
|
||
}
|
||
|
||
getInvoicesByType(): Promise<{ label: string; count: number }[]> {
|
||
return this.labelCounts(`
|
||
SELECT COALESCE(type, 'Other') AS label, COUNT(*)::int AS count
|
||
FROM freight.invoices
|
||
WHERE deleted_at IS NULL
|
||
GROUP BY 1
|
||
ORDER BY count DESC
|
||
`);
|
||
}
|
||
|
||
/** Handover papers per mile, split into signed and awaiting signature. */
|
||
getHandoversByMile(): Promise<MatrixCell[]> {
|
||
return this.matrix(`
|
||
SELECT COALESCE(mile_type, 'Unknown') AS "group",
|
||
CASE WHEN signed_at IS NULL THEN 'PENDING' ELSE 'SIGNED' END AS series,
|
||
COUNT(*)::int AS count
|
||
FROM freight.booking_handovers
|
||
WHERE deleted_at IS NULL
|
||
GROUP BY 1, 2
|
||
ORDER BY 1, 2
|
||
`);
|
||
}
|
||
|
||
/** Company profiles per type × status — active, pending and suspended per trade role. */
|
||
getProfilesByTypeStatus(): Promise<MatrixCell[]> {
|
||
return this.matrix(`
|
||
SELECT type AS "group", status AS series, COUNT(*)::int AS count
|
||
FROM freight.company_profiles
|
||
WHERE deleted_at IS NULL
|
||
GROUP BY 1, 2
|
||
ORDER BY 1, 2
|
||
`);
|
||
}
|
||
}
|