Files
edr-platform/apps/edr-freight-api/src/modules/overview/overview.repository.ts
2026-08-13 18:13:42 +00:00

1610 lines
54 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 = 0003 … 7 = 2124) — 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
`);
}
}