mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 01:20:55 +00:00
1610 lines
54 KiB
TypeScript
1610 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";
|
||
|
||
/** Bookings carry a contract_kind column; GENERAL = umbrella contract row, not a shipment. */
|
||
const EXCLUDE_GENERAL_CONTRACT_BOOKINGS =
|
||
"(booking.contract_kind IS NULL OR booking.contract_kind <> 'GENERAL')";
|
||
|
||
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(EXCLUDE_GENERAL_CONTRACT_BOOKINGS)
|
||
.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(EXCLUDE_GENERAL_CONTRACT_BOOKINGS)
|
||
.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(EXCLUDE_GENERAL_CONTRACT_BOOKINGS)
|
||
.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(EXCLUDE_GENERAL_CONTRACT_BOOKINGS)
|
||
.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(EXCLUDE_GENERAL_CONTRACT_BOOKINGS)
|
||
.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(EXCLUDE_GENERAL_CONTRACT_BOOKINGS)
|
||
.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(EXCLUDE_GENERAL_CONTRACT_BOOKINGS)
|
||
.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(EXCLUDE_GENERAL_CONTRACT_BOOKINGS)
|
||
.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.contract_kind IS NULL OR b.contract_kind <> 'GENERAL')
|
||
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.contract_kind IS NULL OR b.contract_kind <> 'GENERAL')
|
||
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
|
||
`);
|
||
}
|
||
}
|