diff --git a/apps/edr-freight-api/src/modules/reports/report-queries.ts b/apps/edr-freight-api/src/modules/reports/report-queries.ts index e329c8df6..9e4a6f617 100644 --- a/apps/edr-freight-api/src/modules/reports/report-queries.ts +++ b/apps/edr-freight-api/src/modules/reports/report-queries.ts @@ -1,10 +1,10 @@ import { DataSource } from 'typeorm'; export interface ReportFilters { - /** ISO timestamp, inclusive lower bound. */ - dateFrom: string; - /** ISO timestamp, exclusive upper bound. */ - dateTo: string; + /** ISO timestamp, inclusive lower bound. null = no lower bound (all time). */ + dateFrom: string | null; + /** ISO timestamp, exclusive upper bound. null = no upper bound. */ + dateTo: string | null; granularity: 'day' | 'week' | 'month'; companyIds: string[] | null; routeIds: string[] | null; @@ -52,7 +52,8 @@ function bookingWhere(f: ReportFilters): { where: string; params: unknown[] } { where: ` b.deleted_at IS NULL AND ${NOT_UMBRELLA} - AND b.created_at >= $1::timestamptz AND b.created_at < $2::timestamptz + AND ($1::timestamptz IS NULL OR b.created_at >= $1) + AND ($2::timestamptz IS NULL OR b.created_at < $2) AND ($3::uuid[] IS NULL OR b.company_id = ANY($3)) AND ($4::uuid[] IS NULL OR b.cargo_type_id = ANY($4)) AND ($5::text[] IS NULL OR b.trade_direction = ANY($5)) @@ -182,8 +183,9 @@ const contractUtilization: ReportQuery = async (ds, f) => { AND b.status NOT IN (${DEAD_STATUSES})) booked ON true WHERE ct.deleted_at IS NULL AND ct.status NOT IN ('DRAFT') - AND ct.contract_valid_from < $2::timestamptz - AND (ct.contract_valid_until IS NULL OR ct.contract_valid_until >= $1::timestamptz) + AND ct.contract_valid_from < COALESCE($2::timestamptz, 'infinity') + AND (ct.contract_valid_until IS NULL + OR ct.contract_valid_until >= COALESCE($1::timestamptz, '-infinity')) AND ($3::uuid[] IS NULL OR ct.company_id = ANY($3)) AND ($4::text[] IS NULL OR ct.trade_direction = ANY($4)) AND ($5::text[] IS NULL OR ct.status = ANY($5)) @@ -227,8 +229,8 @@ const trainOnTime: ReportQuery = async (ds, f) => { JOIN freight.yards d ON d.id = ts.destination_station_id WHERE ts.deleted_at IS NULL AND ts.status IN ('DISPATCHED', 'ARRIVED') - AND ts.scheduled_departure_date >= $1::timestamptz - AND ts.scheduled_departure_date < $2::timestamptz + AND ($1::timestamptz IS NULL OR ts.scheduled_departure_date >= $1) + AND ($2::timestamptz IS NULL OR ts.scheduled_departure_date < $2) AND ($3::uuid[] IS NULL OR ts.route_id = ANY($3)) AND ($4::text[] IS NULL OR ts.direction = ANY($4)) AND ($5::uuid[] IS NULL OR ts.origin_station_id = ANY($5) OR ts.destination_station_id = ANY($5)) @@ -280,8 +282,8 @@ const scheduleFillRate: ReportQuery = async (ds, f) => { WHERE tw.train_set_id = ts.train_set_id AND tw.deleted_at IS NULL) w ON true WHERE ts.deleted_at IS NULL AND ts.status <> 'CANCELLED' - AND ts.scheduled_departure_date >= $1::timestamptz - AND ts.scheduled_departure_date < $2::timestamptz + AND ($1::timestamptz IS NULL OR ts.scheduled_departure_date >= $1) + AND ($2::timestamptz IS NULL OR ts.scheduled_departure_date < $2) AND ($3::uuid[] IS NULL OR ts.route_id = ANY($3)) AND ($4::text[] IS NULL OR ts.direction = ANY($4)) AND ($5::uuid[] IS NULL OR ts.origin_station_id = ANY($5) OR ts.destination_station_id = ANY($5)) @@ -319,8 +321,8 @@ const tripsPerRoute: ReportQuery = async (ds, f) => { WHERE tw.train_set_id = ts.train_set_id AND tw.deleted_at IS NULL) w ON true WHERE ts.deleted_at IS NULL AND ts.status IN ('DISPATCHED', 'ARRIVED') - AND ts.scheduled_departure_date >= $1::timestamptz - AND ts.scheduled_departure_date < $2::timestamptz + AND ($1::timestamptz IS NULL OR ts.scheduled_departure_date >= $1) + AND ($2::timestamptz IS NULL OR ts.scheduled_departure_date < $2) AND ($3::uuid[] IS NULL OR ts.route_id = ANY($3)) AND ($4::text[] IS NULL OR ts.direction = ANY($4)) AND ($5::uuid[] IS NULL OR ts.origin_station_id = ANY($5) OR ts.destination_station_id = ANY($5)) @@ -347,8 +349,8 @@ const invoicedVsCollected: ReportQuery = async (ds, f) => { FROM freight.invoices i WHERE i.deleted_at IS NULL AND i.status NOT IN ('DRAFT', 'CANCELLED') - AND COALESCE(i.issued_at, i.created_at) >= $1::timestamptz - AND COALESCE(i.issued_at, i.created_at) < $2::timestamptz + AND ($1::timestamptz IS NULL OR COALESCE(i.issued_at, i.created_at) >= $1) + AND ($2::timestamptz IS NULL OR COALESCE(i.issued_at, i.created_at) < $2) AND ($3::uuid[] IS NULL OR i.company_id = ANY($3)) AND ${refDirScope('i.source_id', '$4')} GROUP BY 1 ORDER BY 1`, @@ -371,26 +373,27 @@ const invoicedVsCollected: ReportQuery = async (ds, f) => { }; }; -// Aging is an as-of snapshot: dateTo is the as-of moment, dateFrom is ignored. +// Aging is an as-of snapshot: dateTo is the as-of moment (default now), +// dateFrom is ignored. const agingReceivables: ReportQuery = async (ds, f) => { const rows = await ds.query( `SELECT c.name AS customer, COUNT(*)::int AS invoices, ROUND(SUM(i.balance_amount))::float8 AS outstanding, - ROUND(COALESCE(SUM(i.balance_amount) FILTER (WHERE i.due_at >= $1::timestamptz), 0))::float8 AS current, - ROUND(COALESCE(SUM(i.balance_amount) FILTER (WHERE i.due_at < $1::timestamptz - AND i.due_at >= $1::timestamptz - interval '30 days'), 0))::float8 AS overdue_0_30, - ROUND(COALESCE(SUM(i.balance_amount) FILTER (WHERE i.due_at < $1::timestamptz - interval '30 days' - AND i.due_at >= $1::timestamptz - interval '60 days'), 0))::float8 AS overdue_31_60, - ROUND(COALESCE(SUM(i.balance_amount) FILTER (WHERE i.due_at < $1::timestamptz - interval '60 days' - AND i.due_at >= $1::timestamptz - interval '90 days'), 0))::float8 AS overdue_61_90, - ROUND(COALESCE(SUM(i.balance_amount) FILTER (WHERE i.due_at < $1::timestamptz - interval '90 days'), 0))::float8 AS overdue_90_plus + ROUND(COALESCE(SUM(i.balance_amount) FILTER (WHERE i.due_at >= COALESCE($1::timestamptz, now())), 0))::float8 AS current, + ROUND(COALESCE(SUM(i.balance_amount) FILTER (WHERE i.due_at < COALESCE($1::timestamptz, now()) + AND i.due_at >= COALESCE($1::timestamptz, now()) - interval '30 days'), 0))::float8 AS overdue_0_30, + ROUND(COALESCE(SUM(i.balance_amount) FILTER (WHERE i.due_at < COALESCE($1::timestamptz, now()) - interval '30 days' + AND i.due_at >= COALESCE($1::timestamptz, now()) - interval '60 days'), 0))::float8 AS overdue_31_60, + ROUND(COALESCE(SUM(i.balance_amount) FILTER (WHERE i.due_at < COALESCE($1::timestamptz, now()) - interval '60 days' + AND i.due_at >= COALESCE($1::timestamptz, now()) - interval '90 days'), 0))::float8 AS overdue_61_90, + ROUND(COALESCE(SUM(i.balance_amount) FILTER (WHERE i.due_at < COALESCE($1::timestamptz, now()) - interval '90 days'), 0))::float8 AS overdue_90_plus FROM freight.invoices i JOIN freight.companies c ON c.id = i.company_id WHERE i.deleted_at IS NULL AND i.status IN ('ISSUED', 'PENDING', 'PARTIALLY_PAID', 'OVERDUE') AND i.balance_amount > 0 - AND i.created_at < $1::timestamptz + AND ($1::timestamptz IS NULL OR i.created_at < $1) AND ($2::uuid[] IS NULL OR i.company_id = ANY($2)) AND ${refDirScope('i.source_id', '$3')} GROUP BY 1 ORDER BY outstanding DESC LIMIT 200`, @@ -416,7 +419,8 @@ const revenueByPaymentMethod: ReportQuery = async (ds, f) => { ROUND(SUM(p.amount))::float8 AS amount FROM freight.payments p WHERE p.status = 'success' - AND p.created_at >= $1::timestamptz AND p.created_at < $2::timestamptz + AND ($1::timestamptz IS NULL OR p.created_at >= $1) + AND ($2::timestamptz IS NULL OR p.created_at < $2) AND ${refDirScope('p.ref_id', '$3')} GROUP BY 1 ORDER BY amount DESC`, [f.dateFrom, f.dateTo, f.directions], @@ -436,7 +440,222 @@ const revenueByPaymentMethod: ReportQuery = async (ds, f) => { }; }; +// --------------------------------------------------------------------------- +// Record-level list exports. Same engine, raw rows instead of aggregates. +// ponytail: flat LIMIT 5000 per list — stream/paginate the export if a table +// ever outgrows that. +const LIST_LIMIT = 5000; + +const bookingsList: ReportQuery = async (ds, f) => { + const { where, params } = bookingWhere(f); + const rows = await ds.query( + `SELECT b.reference, + to_char(b.created_at, 'YYYY-MM-DD') AS created, + c.name AS customer, b.status, b.freight_type, + b.trade_direction AS direction, + o.label AS origin, d.label AS destination, + COALESCE(cty.cargo_type_name, b.cargo_free_text) AS cargo, + ROUND(${TONS})::float8 AS tons, + ROUND(${REVENUE})::float8 AS amount, + b.payment_status, b.scheduling_status + FROM freight.bookings b + JOIN freight.companies c ON c.id = b.company_id + JOIN freight.yards o ON o.id = b.origin_yard_id + JOIN freight.yards d ON d.id = b.destination_yard_id + LEFT JOIN freight.cargo_types cty ON cty.id = b.cargo_type_id + WHERE ${where} + ORDER BY b.created_at DESC LIMIT ${LIST_LIMIT}`, + params, + ); + return { + kpis: [ + { label: 'Bookings', value: rows.length }, + { label: 'Tonnage', value: sum(rows, 'tons'), unit: 't' }, + { label: 'Amount', value: sum(rows, 'amount'), unit: 'ETB' }, + ], + rows, + }; +}; + +const contractsList: ReportQuery = async (ds, f) => { + const rows = await ds.query( + `SELECT ct.reference, c.name AS customer, ct.contract_kind AS kind, + ct.status, ct.trade_direction AS direction, ct.freight_type, + to_char(ct.contract_valid_from, 'YYYY-MM-DD') AS valid_from, + to_char(ct.contract_valid_until, 'YYYY-MM-DD') AS valid_until, + to_char(ct.created_at, 'YYYY-MM-DD') AS created + FROM freight.contracts ct + LEFT JOIN freight.companies c ON c.id = ct.company_id + WHERE ct.deleted_at IS NULL + AND ($1::timestamptz IS NULL OR ct.created_at >= $1) + AND ($2::timestamptz IS NULL OR ct.created_at < $2) + AND ($3::uuid[] IS NULL OR ct.company_id = ANY($3)) + AND ($4::text[] IS NULL OR ct.trade_direction = ANY($4)) + AND ($5::text[] IS NULL OR ct.status = ANY($5)) + ORDER BY ct.created_at DESC LIMIT ${LIST_LIMIT}`, + [f.dateFrom, f.dateTo, f.companyIds, f.directions, f.statuses], + ); + const active = rows.filter((r: Record) => + ['CONTRACT_ACTIVE', 'ACTIVE_SHIPMENT_IN_PROGRESS'].includes(String(r.status)), + ).length; + return { + kpis: [ + { label: 'Contracts', value: rows.length }, + { label: 'Active', value: active }, + ], + rows, + }; +}; + +const schedulesList: ReportQuery = async (ds, f) => { + const rows = await ds.query( + `SELECT ts.train_number, ts.reference, ts.direction, ts.status, + o.label AS origin, d.label AS destination, + to_char(ts.scheduled_departure_date, 'YYYY-MM-DD HH24:MI') AS scheduled_departure, + to_char(ts.actual_departure_at, 'YYYY-MM-DD HH24:MI') AS actual_departure, + to_char(ts.scheduled_arrival_date, 'YYYY-MM-DD HH24:MI') AS scheduled_arrival, + to_char(ts.actual_arrival_at, 'YYYY-MM-DD HH24:MI') AS actual_arrival, + ts.max_wagons, tset.wagon_count + FROM freight.train_schedules ts + JOIN freight.yards o ON o.id = ts.origin_station_id + JOIN freight.yards d ON d.id = ts.destination_station_id + LEFT JOIN freight.train_sets tset ON tset.id = ts.train_set_id + WHERE ts.deleted_at IS NULL + AND ($1::timestamptz IS NULL OR ts.scheduled_departure_date >= $1) + AND ($2::timestamptz IS NULL OR ts.scheduled_departure_date < $2) + AND ($3::text[] IS NULL OR ts.direction = ANY($3)) + AND ($4::text[] IS NULL OR ts.status = ANY($4)) + AND ($5::uuid[] IS NULL OR ts.origin_station_id = ANY($5) OR ts.destination_station_id = ANY($5)) + ORDER BY ts.scheduled_departure_date DESC LIMIT ${LIST_LIMIT}`, + [f.dateFrom, f.dateTo, f.directions, f.statuses, f.yardIds], + ); + const count = (s: string) => + rows.filter((r: Record) => r.status === s).length; + return { + kpis: [ + { label: 'Schedules', value: rows.length }, + { label: 'Dispatched', value: count('DISPATCHED') }, + { label: 'Arrived', value: count('ARRIVED') }, + ], + rows, + }; +}; + +const fleetWagons: ReportQuery = async (ds, f) => { + const rows = await ds.query( + `SELECT w.wagon_number, wt.name AS type, + wt.capacity_tons::float8 AS capacity_tons, + w.status, y.label AS current_yard + FROM freight.wagons w + JOIN freight.wagon_types wt ON wt.id = w.wagon_type_id + LEFT JOIN freight.yards y ON y.id = w.current_yard_id + WHERE w.deleted_at IS NULL + AND ($1::text[] IS NULL OR w.status = ANY($1)) + AND ($2::uuid[] IS NULL OR w.current_yard_id = ANY($2)) + ORDER BY w.wagon_number LIMIT ${LIST_LIMIT}`, + [f.statuses, f.yardIds], + ); + const count = (s: string) => + rows.filter((r: Record) => r.status === s).length; + return { + kpis: [ + { label: 'Wagons', value: rows.length }, + { label: 'Available', value: count('AVAILABLE') }, + { label: 'Assigned', value: count('ASSIGNED') }, + { label: 'Maintenance', value: count('MAINTENANCE') }, + ], + rows, + }; +}; + +const fleetLocomotives: ReportQuery = async (ds, f) => { + const rows = await ds.query( + `SELECT l.code, l.name, l.locomotive_type, + l.max_pull_weight_tons::float8 AS max_pull_tons, + l.status, y.label AS current_yard + FROM freight.locomotives l + LEFT JOIN freight.yards y ON y.id = l.current_yard_id + WHERE l.deleted_at IS NULL + AND ($1::text[] IS NULL OR l.status = ANY($1)) + AND ($2::uuid[] IS NULL OR l.current_yard_id = ANY($2)) + ORDER BY l.code LIMIT ${LIST_LIMIT}`, + [f.statuses, f.yardIds], + ); + const available = rows.filter( + (r: Record) => r.status === 'AVAILABLE', + ).length; + return { + kpis: [ + { label: 'Locomotives', value: rows.length }, + { label: 'Available', value: available }, + ], + rows, + }; +}; + +const customersList: ReportQuery = async (ds, f) => { + const rows = await ds.query( + `SELECT c.name, c.type, c.kind, c.status, c.tin, + to_char(c.approved_at, 'YYYY-MM-DD') AS approved, + to_char(c.created_at, 'YYYY-MM-DD') AS created + FROM freight.companies c + WHERE c.deleted_at IS NULL + AND ($1::timestamptz IS NULL OR c.created_at >= $1) + AND ($2::timestamptz IS NULL OR c.created_at < $2) + AND ($3::text[] IS NULL OR c.status = ANY($3)) + ORDER BY c.created_at DESC LIMIT ${LIST_LIMIT}`, + [f.dateFrom, f.dateTo, f.statuses], + ); + const active = rows.filter( + (r: Record) => r.status === 'active', + ).length; + return { + kpis: [ + { label: 'Customers', value: rows.length }, + { label: 'Active', value: active }, + ], + rows, + }; +}; + +const paymentsList: ReportQuery = async (ds, f) => { + // No deleted_at on freight.payments; statuses are lowercase-hyphenated. + const rows = await ds.query( + `SELECT to_char(p.created_at, 'YYYY-MM-DD HH24:MI') AS created, + p.method::text AS method, p.status::text AS status, + p.currency::text AS currency, + ROUND(p.amount)::float8 AS amount, + p.transaction_id, p.merchant_order_id, + to_char(p.paid_at, 'YYYY-MM-DD') AS paid + FROM freight.payments p + WHERE ($1::timestamptz IS NULL OR p.created_at >= $1) + AND ($2::timestamptz IS NULL OR p.created_at < $2) + AND ($3::text[] IS NULL OR p.status::text = ANY($3)) + AND ${refDirScope('p.ref_id', '$4')} + ORDER BY p.created_at DESC LIMIT ${LIST_LIMIT}`, + [f.dateFrom, f.dateTo, f.statuses, f.directions], + ); + const success = rows.filter( + (r: Record) => r.status === 'success', + ); + return { + kpis: [ + { label: 'Payments', value: rows.length }, + { label: 'Successful', value: success.length }, + { label: 'Collected', value: sum(success, 'amount'), unit: 'ETB' }, + ], + rows, + }; +}; + export const REPORT_QUERIES: Record = { + 'bookings-list': bookingsList, + 'contracts-list': contractsList, + 'schedules-list': schedulesList, + 'fleet-wagons': fleetWagons, + 'fleet-locomotives': fleetLocomotives, + 'customers-list': customersList, + 'payments-list': paymentsList, 'bookings-trend': bookingsTrend, 'revenue-by-customer': revenueByCustomer, 'revenue-by-lane': revenueByLane, diff --git a/apps/edr-freight-api/src/modules/reports/reports.service.ts b/apps/edr-freight-api/src/modules/reports/reports.service.ts index 0e5c7d928..04e6e9a60 100644 --- a/apps/edr-freight-api/src/modules/reports/reports.service.ts +++ b/apps/edr-freight-api/src/modules/reports/reports.service.ts @@ -25,12 +25,13 @@ export class ReportsService { if (!(key in REPORT_QUERIES)) { throw new NotFoundException(`Unknown report: ${key}`); } - const to = dto.dateTo ? new Date(dto.dateTo) : new Date(); - const from = dto.dateFrom ? new Date(dto.dateFrom) : new Date(to.getTime() - 30 * DAY_MS); + // No default range: absent dates mean all time, so exports cover everything. + const to = dto.dateTo ? new Date(dto.dateTo) : null; + const from = dto.dateFrom ? new Date(dto.dateFrom) : null; const filters: ReportFilters = { - dateFrom: from.toISOString(), + dateFrom: from ? from.toISOString() : null, // dateTo is inclusive in the API; queries treat the bound as exclusive. - dateTo: new Date(to.getTime() + DAY_MS).toISOString(), + dateTo: to ? new Date(to.getTime() + DAY_MS).toISOString() : null, granularity: dto.granularity ?? 'day', companyIds: list(dto.companyIds), routeIds: list(dto.routeIds), diff --git a/apps/edr-freight-web/backoffice/src/pages/reports/ReportPage.tsx b/apps/edr-freight-web/backoffice/src/pages/reports/ReportPage.tsx index 0ac68325d..939a6423a 100644 --- a/apps/edr-freight-web/backoffice/src/pages/reports/ReportPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/reports/ReportPage.tsx @@ -305,7 +305,7 @@ export default function ReportPage() { value={toDate(params.get("dateFrom"))} maxDate={toDate(params.get("dateTo")) ?? undefined} onChange={(d) => setParam("dateFrom", toParam(d))} - placeholder="30 days ago" + placeholder="All time" /> setParam("dateTo", toParam(d))} - placeholder="Today" + placeholder="All time" /> {config.filters.includes("granularity") ? (