Merge pull request #1280 from Tria-plc/freight/nati-2

Freight/nati 2
This commit is contained in:
Nathnael Wondisha
2026-08-13 22:16:04 +03:00
committed by GitHub
25 changed files with 1837 additions and 121 deletions

View File

@@ -116,6 +116,28 @@ export class OverviewTonnagePointDto {
@ApiProperty() tons!: number;
}
/** One bucket × series cell of a two-dimensional breakdown. */
export class OverviewMatrixCellDto {
@ApiProperty({ example: 'Flat wagon' }) group!: string;
@ApiProperty({ example: 'AVAILABLE' }) series!: string;
@ApiProperty() count!: number;
}
export class OverviewTrainLoadDto {
@ApiProperty() scheduleId!: string;
@ApiProperty({ example: '8001' }) trainNumber!: string;
@ApiProperty({ example: '2026-08-13' }) date!: string;
@ApiProperty({ example: 'EXPORT' }) direction!: string;
@ApiProperty() wagonsAllocated!: number;
@ApiProperty() wagonsTotal!: number;
@ApiProperty() tons!: number;
}
export class OverviewTurnaroundDto {
@ApiProperty({ example: '8001' }) trainSet!: string;
@ApiProperty({ example: 26.5 }) hours!: number;
}
export class OverviewOperationsTabDto {
@ApiProperty({ type: OverviewOperationsKpisDto })
kpis!: OverviewOperationsKpisDto;
@@ -150,6 +172,64 @@ export class OverviewOperationsTabDto {
@ApiProperty({ type: [OverviewStatusCountDto] })
cargoStatusBreakdown!: OverviewStatusCountDto[];
@ApiProperty({ type: [OverviewLabelCountDto] })
bookingsByPort!: OverviewLabelCountDto[];
@ApiProperty({ type: [OverviewMatrixCellDto] })
bookingStatusByPort!: OverviewMatrixCellDto[];
@ApiProperty({ type: [OverviewTrainLoadDto] })
trainLoads!: OverviewTrainLoadDto[];
@ApiProperty()
generatedAt!: string;
}
export class OverviewFleetTabDto {
@ApiProperty({ type: [OverviewStatusCountDto] })
wagonStatusBreakdown!: OverviewStatusCountDto[];
@ApiProperty({ type: [OverviewMatrixCellDto] })
wagonStatusByType!: OverviewMatrixCellDto[];
@ApiProperty({ type: [OverviewMatrixCellDto] })
wagonStatusByYard!: OverviewMatrixCellDto[];
@ApiProperty({ type: [OverviewStatusCountDto] })
locomotiveStatusBreakdown!: OverviewStatusCountDto[];
@ApiProperty({ type: [OverviewMatrixCellDto] })
locomotivesByYard!: OverviewMatrixCellDto[];
@ApiProperty({ type: [OverviewLabelCountDto] })
locomotivesByType!: OverviewLabelCountDto[];
@ApiProperty({ nullable: true, example: 26.5 })
avgTurnaroundHours!: number | null;
@ApiProperty({ type: [OverviewTurnaroundDto] })
turnaroundByTrain!: OverviewTurnaroundDto[];
@ApiProperty()
generatedAt!: string;
}
export class OverviewClearanceTabDto {
@ApiProperty({ type: [OverviewStatusCountDto] })
bookingDocumentsByStatus!: OverviewStatusCountDto[];
@ApiProperty({ type: [OverviewStatusCountDto] })
contractDocumentsByStatus!: OverviewStatusCountDto[];
@ApiProperty({ type: [OverviewStatusCountDto] })
invoicesByStatus!: OverviewStatusCountDto[];
@ApiProperty({ type: [OverviewLabelCountDto] })
invoicesByType!: OverviewLabelCountDto[];
@ApiProperty({ type: [OverviewMatrixCellDto] })
handoversByMile!: OverviewMatrixCellDto[];
@ApiProperty()
generatedAt!: string;
}
@@ -167,6 +247,9 @@ export class OverviewCustomersTabDto {
@ApiProperty({ type: [OverviewLabelCountDto] })
topCustomersByBookings!: OverviewLabelCountDto[];
@ApiProperty({ type: [OverviewMatrixCellDto] })
profilesByTypeStatus!: OverviewMatrixCellDto[];
@ApiProperty()
generatedAt!: string;
}

View File

@@ -15,8 +15,10 @@ import { OverviewResponseDto } from './dto/overview-response.dto';
import {
OverviewBillingTabDto,
OverviewBookingsTabDto,
OverviewClearanceTabDto,
OverviewContractsTabDto,
OverviewCustomersTabDto,
OverviewFleetTabDto,
OverviewOperationsTabDto,
OverviewStaffTabDto,
} from './dto/overview-tab-response.dto';
@@ -106,6 +108,22 @@ export class OverviewController {
return this.overviewService.getOperationsTab(query.range ?? '30d');
}
@Get('fleet')
@BookingStaff(FREIGHT_PERMS.overview.view)
@ApiOperation({ summary: 'Wagon and locomotive fleet detail, plus turnaround' })
@ApiOkResponse({ type: OverviewFleetTabDto })
getFleetTab(@Query() query: OverviewQueryDto): Promise<OverviewFleetTabDto> {
return this.overviewService.getFleetTab(query.range ?? '30d');
}
@Get('clearance')
@BookingStaff(FREIGHT_PERMS.overview.view)
@ApiOperation({ summary: 'Document review and invoice queues' })
@ApiOkResponse({ type: OverviewClearanceTabDto })
getClearanceTab(): Promise<OverviewClearanceTabDto> {
return this.overviewService.getClearanceTab();
}
@Get('customers')
@BookingStaff(FREIGHT_PERMS.overview.view)
@ApiOperation({ summary: 'Customers tab metrics and charts' })

View File

@@ -58,6 +58,21 @@ export type OverviewRecentBookingRow = {
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;
@@ -1261,4 +1276,334 @@ export class OverviewRepository {
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
`);
}
}

View File

@@ -9,8 +9,10 @@ import type { OverviewResponseDto } from './dto/overview-response.dto';
import type {
OverviewBillingTabDto,
OverviewBookingsTabDto,
OverviewClearanceTabDto,
OverviewContractsTabDto,
OverviewCustomersTabDto,
OverviewFleetTabDto,
OverviewOperationsTabDto,
OverviewStaffTabDto,
} from './dto/overview-tab-response.dto';
@@ -237,6 +239,9 @@ export class OverviewService {
wagonStatusBreakdown,
containerStatusBreakdown,
cargoStatusBreakdown,
bookingsByPort,
bookingStatusByPort,
trainLoads,
] = await Promise.all([
this.overviewRepository.getOperationsKpis(),
this.overviewRepository.getDepartureTrend(days),
@@ -249,6 +254,9 @@ export class OverviewService {
this.overviewRepository.getWagonStatusBreakdown(),
this.overviewRepository.getContainerStatusBreakdown(),
this.overviewRepository.getCargoStatusBreakdown(),
this.overviewRepository.getBookingsByPort(days),
this.overviewRepository.getBookingStatusByPort(days),
this.overviewRepository.getTrainLoads(8),
]);
return {
@@ -263,6 +271,76 @@ export class OverviewService {
wagonStatusBreakdown,
containerStatusBreakdown,
cargoStatusBreakdown,
bookingsByPort,
bookingStatusByPort,
trainLoads,
generatedAt: new Date().toISOString(),
};
}
/**
* Rolling stock in depth: wagons and locomotives crossed with type and yard,
* plus how fast each train set turns around. Feeds the operations and control
* centre dashboards.
*/
async getFleetTab(
range: OverviewRangeQuery = '30d',
): Promise<OverviewFleetTabDto> {
const days = OVERVIEW_RANGE_DAYS[range];
const [
wagonStatusBreakdown,
wagonStatusByType,
wagonStatusByYard,
locomotiveStatusBreakdown,
locomotivesByYard,
locomotivesByType,
turnaround,
] = await Promise.all([
this.overviewRepository.getWagonStatusBreakdown(),
this.overviewRepository.getWagonStatusByType(),
this.overviewRepository.getWagonStatusByYard(),
this.overviewRepository.getLocomotiveStatusBreakdown(),
this.overviewRepository.getLocomotivesByYard(),
this.overviewRepository.getLocomotivesByType(),
this.overviewRepository.getTrainTurnaround(days, 8),
]);
return {
wagonStatusBreakdown,
wagonStatusByType,
wagonStatusByYard,
locomotiveStatusBreakdown,
locomotivesByYard,
locomotivesByType,
avgTurnaroundHours: turnaround.avgHours,
turnaroundByTrain: turnaround.rows,
generatedAt: new Date().toISOString(),
};
}
/** Document and invoice queues — the GL desks' and marketing's work in progress. */
async getClearanceTab(): Promise<OverviewClearanceTabDto> {
const [
bookingDocumentsByStatus,
contractDocumentsByStatus,
invoicesByStatus,
invoicesByType,
handoversByMile,
] = await Promise.all([
this.overviewRepository.getBookingDocumentsByStatus(),
this.overviewRepository.getContractDocumentsByStatus(),
this.overviewRepository.getInvoicesByStatus(),
this.overviewRepository.getInvoicesByType(),
this.overviewRepository.getHandoversByMile(),
]);
return {
bookingDocumentsByStatus,
contractDocumentsByStatus,
invoicesByStatus,
invoicesByType,
handoversByMile,
generatedAt: new Date().toISOString(),
};
}
@@ -273,19 +351,26 @@ export class OverviewService {
): Promise<OverviewCustomersTabDto> {
const days = OVERVIEW_RANGE_DAYS[range];
const [kpis, customerGrowthTrend, customersByType, topCustomersByBookings] =
await Promise.all([
this.overviewRepository.getCustomerKpis(),
this.overviewRepository.getCustomerGrowthTrend(days),
this.overviewRepository.getCustomersByType(),
this.overviewRepository.getTopCustomersByBookings(8, dirs),
]);
const [
kpis,
customerGrowthTrend,
customersByType,
topCustomersByBookings,
profilesByTypeStatus,
] = await Promise.all([
this.overviewRepository.getCustomerKpis(),
this.overviewRepository.getCustomerGrowthTrend(days),
this.overviewRepository.getCustomersByType(),
this.overviewRepository.getTopCustomersByBookings(8, dirs),
this.overviewRepository.getProfilesByTypeStatus(),
]);
return {
kpis,
customerGrowthTrend,
customersByType,
topCustomersByBookings,
profilesByTypeStatus,
generatedAt: new Date().toISOString(),
};
}