From 29913259f60da2b3fe615303ea49655da65f7517 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Thu, 13 Aug 2026 08:23:19 +0000 Subject: [PATCH] feat(freight-api): add 9 commercial/finance reports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit customer-status (company-profile roles, not Company — importer/ exporter/forwarder lives there), contract-lifecycle, customs-documents (clearance milestones), invoicing-pipeline, first-last-mile-bookings (one resolver, UNION ALL over first_mile/last_mile — verified the raw-string .from() subquery against the live query builder, not just hand-written SQL, after the join-alias bug earlier this branch), invoices-by-status, payments-by-status, revenue-summary, cargo-summary. payments carries no deleted_at column despite extending BaseEntity — caught by column-checking against the live DB before shipping, dropped the soft-delete filter for that one query. Completes the ITLMS dashboard spec's 20-resolver dedup list (19 built, freight-weight-variance dropped — no charged-vs-actual weight distinction in the schema). --- .../definitions/cargo-summary.report.ts | 58 ++++++++++++ .../definitions/contract-lifecycle.report.ts | 83 +++++++++++++++++ .../definitions/customer-status.report.ts | 67 ++++++++++++++ .../definitions/customs-documents.report.ts | 66 ++++++++++++++ .../first-last-mile-bookings.report.ts | 90 +++++++++++++++++++ .../definitions/invoices-by-status.report.ts | 72 +++++++++++++++ .../definitions/invoicing-pipeline.report.ts | 59 ++++++++++++ .../definitions/payments-by-status.report.ts | 73 +++++++++++++++ .../definitions/revenue-summary.report.ts | 61 +++++++++++++ .../src/modules/reports/report.registry.ts | 18 ++++ .../src/seed/freight-permissions.registry.ts | 9 ++ 11 files changed, 656 insertions(+) create mode 100644 apps/edr-freight-api/src/modules/reports/definitions/cargo-summary.report.ts create mode 100644 apps/edr-freight-api/src/modules/reports/definitions/contract-lifecycle.report.ts create mode 100644 apps/edr-freight-api/src/modules/reports/definitions/customer-status.report.ts create mode 100644 apps/edr-freight-api/src/modules/reports/definitions/customs-documents.report.ts create mode 100644 apps/edr-freight-api/src/modules/reports/definitions/first-last-mile-bookings.report.ts create mode 100644 apps/edr-freight-api/src/modules/reports/definitions/invoices-by-status.report.ts create mode 100644 apps/edr-freight-api/src/modules/reports/definitions/invoicing-pipeline.report.ts create mode 100644 apps/edr-freight-api/src/modules/reports/definitions/payments-by-status.report.ts create mode 100644 apps/edr-freight-api/src/modules/reports/definitions/revenue-summary.report.ts diff --git a/apps/edr-freight-api/src/modules/reports/definitions/cargo-summary.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/cargo-summary.report.ts new file mode 100644 index 000000000..ee3063ee1 --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/definitions/cargo-summary.report.ts @@ -0,0 +1,58 @@ +import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; + +import { Booking } from '../../bookings/entities/booking.entity'; +import { ReportContext, ReportDefinition } from '../report.types'; + +const TONS = 'COALESCE(b.bulk_total_weight_tons, b.cargo_total_weight_vgm)'; +const NOT_UMBRELLA = "(b.contract_kind IS NULL OR b.contract_kind <> 'GENERAL')"; +const DEAD_STATUSES = ['DRAFT', 'CANCELLED', 'REJECTED', 'EXPIRED']; + +function baseQuery(ctx: ReportContext): SelectQueryBuilder { + const { params, directions } = ctx; + const qb = ctx.ds + .createQueryBuilder() + .from(Booking, 'b') + .where(`b.deleted_at IS NULL AND ${NOT_UMBRELLA}`) + .andWhere('b.status NOT IN (:...deadStatuses)', { deadStatuses: DEAD_STATUSES }); + + if (params.dateFrom) qb.andWhere('b.created_at >= :dateFrom', { dateFrom: params.dateFrom }); + if (params.dateTo) qb.andWhere('b.created_at < :dateTo', { dateTo: params.dateTo }); + if (directions !== null) { + qb.andWhere(directions.length ? 'b.trade_direction IN (:...directions)' : '1 = 0', { directions }); + } + return qb; +} + +export const cargoSummaryReport: ReportDefinition = { + key: 'cargo-summary', + title: 'Cargo Summary', + description: 'Cargo tonnage by direction and cargo type', + group: 'Operations', + filters: [{ key: 'date', label: 'Created', type: 'daterange' }], + columns: [ + { key: 'direction', label: 'Direction', type: 'string', sortable: true }, + { key: 'freightType', label: 'Cargo type', type: 'string', sortable: true }, + { key: 'bookings', label: 'Bookings', type: 'number', sortable: true }, + { key: 'tons', label: 'Tonnage', type: 'tons', sortable: true }, + ], + defaultSort: { key: 'tons', dir: 'DESC' }, + query(ctx) { + return baseQuery(ctx) + .select('b.trade_direction', 'direction') + .addSelect('b.freight_type', 'freightType') + .addSelect('COUNT(*)::int', 'bookings') + .addSelect(`ROUND(COALESCE(SUM(${TONS}), 0))::float8`, 'tons') + .groupBy('b.trade_direction') + .addGroupBy('b.freight_type'); + }, + async summary(ctx) { + const row = await baseQuery(ctx) + .select(`ROUND(COALESCE(SUM(${TONS}), 0))::float8`, 'tons') + .addSelect('COUNT(*)::int', 'bookings') + .getRawOne(); + return [ + { label: 'Bookings', value: Number(row?.bookings ?? 0) }, + { label: 'Total tonnage', value: Number(row?.tons ?? 0), unit: 't' }, + ]; + }, +}; diff --git a/apps/edr-freight-api/src/modules/reports/definitions/contract-lifecycle.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/contract-lifecycle.report.ts new file mode 100644 index 000000000..513abeda4 --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/definitions/contract-lifecycle.report.ts @@ -0,0 +1,83 @@ +import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; + +import { Contract, CONTRACT_KINDS, CONTRACT_STATUSES } from '../../contracts/entities/contract.entity'; +import { Company } from '../../companies/entities/company.entity'; +import { ReportContext, ReportDefinition } from '../report.types'; + +function baseQuery(ctx: ReportContext): SelectQueryBuilder { + const { params, directions } = ctx; + const qb = ctx.ds + .createQueryBuilder() + .from(Contract, 'ct') + .leftJoin(Company, 'c', 'c.id = ct.company_id') + .where('ct.deleted_at IS NULL'); + + if (params.dateFrom) qb.andWhere('ct.contract_valid_from >= :dateFrom', { dateFrom: params.dateFrom }); + if (params.dateTo) qb.andWhere('ct.contract_valid_from < :dateTo', { dateTo: params.dateTo }); + if (params.kind) qb.andWhere('ct.contract_kind = :kind', { kind: params.kind }); + if (params.direction) qb.andWhere('ct.trade_direction = :direction', { direction: params.direction }); + const statuses = params.statuses as string[] | null; + if (statuses) qb.andWhere('ct.status IN (:...statuses)', { statuses }); + if (directions !== null) { + qb.andWhere(directions.length ? 'ct.trade_direction IN (:...directions)' : '1 = 0', { directions }); + } + return qb; +} + +export const contractLifecycleReport: ReportDefinition = { + key: 'contract-lifecycle', + title: 'Contracts', + description: 'Signed, active and cancelled contracts', + group: 'Commercial', + filters: [ + { key: 'date', label: 'Valid from', type: 'daterange' }, + { key: 'kind', label: 'Kind', type: 'select', options: CONTRACT_KINDS.map((v) => ({ value: v, label: v })) }, + { + key: 'direction', + label: 'Direction', + type: 'select', + options: [ + { value: 'IMPORT', label: 'Import' }, + { value: 'EXPORT', label: 'Export' }, + { value: 'DOMESTIC', label: 'Domestic' }, + ], + }, + { key: 'statuses', label: 'Status', type: 'multiselect', options: CONTRACT_STATUSES.map((v) => ({ value: v, label: v.replace(/_/g, ' ') })) }, + ], + columns: [ + { key: 'reference', label: 'Reference', type: 'string', sortable: true, sortExpr: 'ct.reference' }, + { key: 'customer', label: 'Customer', type: 'string', sortable: true, sortExpr: 'c.name' }, + { key: 'kind', label: 'Kind', type: 'string' }, + { key: 'direction', label: 'Direction', type: 'string' }, + { key: 'freightType', label: 'Freight type', type: 'string' }, + { key: 'status', label: 'Status', type: 'string', sortable: true, sortExpr: 'ct.status' }, + { key: 'validFrom', label: 'Valid from', type: 'date', sortable: true, sortExpr: 'ct.contract_valid_from' }, + { key: 'validUntil', label: 'Valid until', type: 'date' }, + { key: 'signedAt', label: 'Signed', type: 'date' }, + ], + defaultSort: { key: 'validFrom', dir: 'DESC' }, + query(ctx) { + return baseQuery(ctx) + .select('ct.reference', 'reference') + .addSelect("COALESCE(c.name, ct.government_institution, 'Unknown')", 'customer') + .addSelect('ct.contract_kind', 'kind') + .addSelect('ct.trade_direction', 'direction') + .addSelect('ct.freight_type', 'freightType') + .addSelect('ct.status', 'status') + .addSelect(`to_char(ct.contract_valid_from, 'YYYY-MM-DD')`, 'validFrom') + .addSelect(`to_char(ct.contract_valid_until, 'YYYY-MM-DD')`, 'validUntil') + .addSelect(`to_char(ct.fully_executed_at, 'YYYY-MM-DD')`, 'signedAt'); + }, + async summary(ctx) { + const row = await baseQuery(ctx) + .select('COUNT(*)::int', 'total') + .addSelect('COUNT(*) FILTER (WHERE ct.fully_executed_at IS NOT NULL)::int', 'signed') + .addSelect("COUNT(*) FILTER (WHERE ct.status = 'CANCELLED')::int", 'cancelled') + .getRawOne(); + return [ + { label: 'Contracts', value: Number(row?.total ?? 0) }, + { label: 'Signed', value: Number(row?.signed ?? 0) }, + { label: 'Cancelled', value: Number(row?.cancelled ?? 0) }, + ]; + }, +}; diff --git a/apps/edr-freight-api/src/modules/reports/definitions/customer-status.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/customer-status.report.ts new file mode 100644 index 000000000..60b6ff31a --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/definitions/customer-status.report.ts @@ -0,0 +1,67 @@ +import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; + +import { CompanyProfile, ProfileStatus, ProfileType } from '../../companies/entities/company-profile.entity'; +import { Company } from '../../companies/entities/company.entity'; +import { ReportContext, ReportDefinition } from '../report.types'; + +// "Type (Importer, Exporter, Freight Forwarding)" and "Active/Suspended" are +// CompanyProfile fields, not Company's — a company can hold several profiles +// (e.g. importer AND exporter), each independently approved/suspended. +const TYPE_OPTIONS = Object.values(ProfileType).map((v) => ({ value: v, label: v.replace(/_/g, ' ') })); +const STATUS_OPTIONS = Object.values(ProfileStatus).map((v) => ({ value: v, label: v })); + +function baseQuery(ctx: ReportContext): SelectQueryBuilder { + const { params } = ctx; + const qb = ctx.ds + .createQueryBuilder() + .from(CompanyProfile, 'cp') + .innerJoin(Company, 'c', 'c.id = cp.company_id') + .where('cp.deleted_at IS NULL'); + + if (params.type) qb.andWhere('cp.type = :type', { type: params.type }); + const statuses = params.statuses as string[] | null; + if (statuses) qb.andWhere('cp.status IN (:...statuses)', { statuses }); + return qb; +} + +export const customerStatusReport: ReportDefinition = { + key: 'customer-status', + title: 'Customer Profiles', + description: 'Company profiles by role type and approval status', + group: 'Commercial', + filters: [ + { key: 'type', label: 'Type', type: 'select', options: TYPE_OPTIONS }, + { key: 'statuses', label: 'Status', type: 'multiselect', options: STATUS_OPTIONS }, + ], + columns: [ + { key: 'company', label: 'Company', type: 'string', sortable: true, sortExpr: 'c.name' }, + { key: 'type', label: 'Type', type: 'string', sortable: true, sortExpr: 'cp.type' }, + { key: 'status', label: 'Status', type: 'string', sortable: true, sortExpr: 'cp.status' }, + { key: 'reference', label: 'Reference', type: 'string' }, + { key: 'note', label: 'Note', type: 'string' }, + { key: 'reviewedAt', label: 'Reviewed', type: 'date', sortable: true, sortExpr: 'cp.reviewed_at' }, + ], + defaultSort: { key: 'reviewedAt', dir: 'DESC' }, + query(ctx) { + return baseQuery(ctx) + .select('c.name', 'company') + .addSelect('cp.type', 'type') + .addSelect('cp.status', 'status') + .addSelect("COALESCE(cp.reference, '')", 'reference') + .addSelect("COALESCE(cp.review_note, '')", 'note') + .addSelect(`to_char(cp.reviewed_at, 'YYYY-MM-DD')`, 'reviewedAt'); + }, + async summary(ctx) { + const row = await baseQuery(ctx) + .select('COUNT(*)::int', 'total') + .addSelect('COUNT(*) FILTER (WHERE cp.status = :active)::int', 'active') + .addSelect('COUNT(*) FILTER (WHERE cp.status = :suspended)::int', 'suspended') + .setParameters({ active: ProfileStatus.Active, suspended: ProfileStatus.Suspended }) + .getRawOne(); + return [ + { label: 'Profiles', value: Number(row?.total ?? 0) }, + { label: 'Active', value: Number(row?.active ?? 0) }, + { label: 'Suspended', value: Number(row?.suspended ?? 0) }, + ]; + }, +}; diff --git a/apps/edr-freight-api/src/modules/reports/definitions/customs-documents.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/customs-documents.report.ts new file mode 100644 index 000000000..a0bcb32fc --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/definitions/customs-documents.report.ts @@ -0,0 +1,66 @@ +import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; + +import { + ClearanceMilestone, + MILESTONE_OWNER_REGIONS, + MILESTONE_STATUSES, +} from '../../contracts/entities/clearance-milestone.entity'; +import { ReportContext, ReportDefinition } from '../report.types'; + +function baseQuery(ctx: ReportContext): SelectQueryBuilder { + const { params } = ctx; + const qb = ctx.ds.createQueryBuilder().from(ClearanceMilestone, 'cm').where('cm.deleted_at IS NULL'); + + if (params.dateFrom) qb.andWhere('cm.created_at >= :dateFrom', { dateFrom: params.dateFrom }); + if (params.dateTo) qb.andWhere('cm.created_at < :dateTo', { dateTo: params.dateTo }); + if (params.ownerRegion) qb.andWhere('cm.owner_region = :ownerRegion', { ownerRegion: params.ownerRegion }); + const statuses = params.statuses as string[] | null; + if (statuses) qb.andWhere('cm.status IN (:...statuses)', { statuses }); + return qb; +} + +export const customsDocumentsReport: ReportDefinition = { + key: 'customs-documents', + title: 'Customs Clearance Milestones', + description: 'Clearance milestone volume by label, owner and status', + group: 'Operations', + filters: [ + { key: 'date', label: 'Created', type: 'daterange' }, + { + key: 'ownerRegion', + label: 'Owner', + type: 'select', + options: MILESTONE_OWNER_REGIONS.map((v) => ({ value: v, label: v })), + }, + { key: 'statuses', label: 'Status', type: 'multiselect', options: MILESTONE_STATUSES.map((v) => ({ value: v, label: v })) }, + ], + columns: [ + { key: 'milestone', label: 'Milestone', type: 'string', sortable: true }, + { key: 'ownerRegion', label: 'Owner', type: 'string', sortable: true }, + { key: 'status', label: 'Status', type: 'string', sortable: true }, + { key: 'count', label: 'Count', type: 'number', sortable: true }, + ], + defaultSort: { key: 'count', dir: 'DESC' }, + query(ctx) { + return baseQuery(ctx) + .select('cm.milestone_label', 'milestone') + .addSelect("COALESCE(cm.owner_region, 'Unassigned')", 'ownerRegion') + .addSelect('cm.status', 'status') + .addSelect('COUNT(*)::int', 'count') + .groupBy('cm.milestone_label') + .addGroupBy('cm.owner_region') + .addGroupBy('cm.status'); + }, + async summary(ctx) { + const row = await baseQuery(ctx) + .select('COUNT(*)::int', 'total') + .addSelect("COUNT(*) FILTER (WHERE cm.status = 'COMPLETED')::int", 'completed') + .addSelect("COUNT(*) FILTER (WHERE cm.status = 'PENDING')::int", 'pending') + .getRawOne(); + return [ + { label: 'Milestones', value: Number(row?.total ?? 0) }, + { label: 'Completed', value: Number(row?.completed ?? 0) }, + { label: 'Pending', value: Number(row?.pending ?? 0) }, + ]; + }, +}; diff --git a/apps/edr-freight-api/src/modules/reports/definitions/first-last-mile-bookings.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/first-last-mile-bookings.report.ts new file mode 100644 index 000000000..d3abe10dc --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/definitions/first-last-mile-bookings.report.ts @@ -0,0 +1,90 @@ +import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; + +import { Booking } from '../../bookings/entities/booking.entity'; +import { Company } from '../../companies/entities/company.entity'; +import { Vehicle } from '../../vehicles/entities/vehicle.entity'; +import { ReportContext, ReportDefinition } from '../report.types'; + +// FirstMile and LastMile are separate tables with an identical shape (status, +// booking, optional vehicle). One resolver, unioned, with a `leg` column — +// beats shipping two near-duplicate reports for the two halves of the trip. +const LEG_UNION = `( + SELECT 'FIRST' AS leg, fm.id AS id, fm.booking_id AS booking_id, fm.status AS status, + fm.vehicle_id AS vehicle_id, fm.created_at AS created_at + FROM freight.first_mile fm WHERE fm.deleted_at IS NULL + UNION ALL + SELECT 'LAST' AS leg, lm.id AS id, lm.booking_id AS booking_id, lm.status AS status, + lm.vehicle_id AS vehicle_id, lm.created_at AS created_at + FROM freight.last_mile lm WHERE lm.deleted_at IS NULL +)`; + +const STATUS_OPTIONS = [ + { value: 'PAYMENT_PENDING', label: 'Payment pending' }, + { value: 'READY_TO_TRANSIT', label: 'Ready to transit' }, + { value: 'IN_TRANSIT', label: 'In transit' }, + { value: 'RECEIVED_TO_PORT', label: 'Received to port' }, + { value: 'DELIVERED', label: 'Delivered' }, +]; + +function baseQuery(ctx: ReportContext): SelectQueryBuilder { + const { params } = ctx; + const qb = ctx.ds + .createQueryBuilder() + .from(LEG_UNION, 'fl') + .innerJoin(Booking, 'b', 'b.id = fl.booking_id') + .leftJoin(Company, 'c', 'c.id = b.company_id') + .leftJoin(Vehicle, 'v', 'v.id = fl.vehicle_id') + .where('1 = 1'); + + if (params.leg) qb.andWhere('fl.leg = :leg', { leg: params.leg }); + if (params.dateFrom) qb.andWhere('fl.created_at >= :dateFrom', { dateFrom: params.dateFrom }); + if (params.dateTo) qb.andWhere('fl.created_at < :dateTo', { dateTo: params.dateTo }); + const statuses = params.statuses as string[] | null; + if (statuses) qb.andWhere('fl.status IN (:...statuses)', { statuses }); + return qb; +} + +export const firstLastMileBookingsReport: ReportDefinition = { + key: 'first-last-mile-bookings', + title: 'First/Last Mile Trucking', + description: 'First- and last-mile bookings by status and truck assignment', + group: 'Operations', + filters: [ + { key: 'date', label: 'Created', type: 'daterange' }, + { key: 'leg', label: 'Leg', type: 'select', options: [{ value: 'FIRST', label: 'First mile' }, { value: 'LAST', label: 'Last mile' }] }, + { key: 'statuses', label: 'Status', type: 'multiselect', options: STATUS_OPTIONS }, + ], + columns: [ + { key: 'leg', label: 'Leg', type: 'string', sortable: true }, + { key: 'booking', label: 'Booking', type: 'string', sortable: true, sortExpr: 'b.reference' }, + { key: 'customer', label: 'Customer', type: 'string', sortable: true, sortExpr: 'c.name' }, + { key: 'status', label: 'Status', type: 'string', sortable: true, sortExpr: 'fl.status' }, + { key: 'truck', label: 'Truck', type: 'string' }, + { key: 'assigned', label: 'Assigned', type: 'string', sortable: true }, + { key: 'createdAt', label: 'Created', type: 'date', sortable: true, sortExpr: 'fl.created_at' }, + ], + defaultSort: { key: 'createdAt', dir: 'DESC' }, + query(ctx) { + return baseQuery(ctx) + .select('fl.leg', 'leg') + .addSelect('b.reference', 'booking') + .addSelect("COALESCE(c.name, 'Unknown')", 'customer') + .addSelect('fl.status', 'status') + .addSelect("COALESCE(v.plate_number, '—')", 'truck') + .addSelect("CASE WHEN fl.vehicle_id IS NOT NULL THEN 'Assigned' ELSE 'Unassigned' END", 'assigned') + .addSelect(`to_char(fl.created_at, 'YYYY-MM-DD')`, 'createdAt'); + }, + async summary(ctx) { + const row = await baseQuery(ctx) + .select('COUNT(*)::int', 'total') + .addSelect('COUNT(*) FILTER (WHERE fl.vehicle_id IS NOT NULL)::int', 'assigned') + .getRawOne(); + const total = Number(row?.total ?? 0); + const assigned = Number(row?.assigned ?? 0); + return [ + { label: 'Trips', value: total }, + { label: 'Assigned', value: assigned }, + { label: 'Unassigned', value: total - assigned }, + ]; + }, +}; diff --git a/apps/edr-freight-api/src/modules/reports/definitions/invoices-by-status.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/invoices-by-status.report.ts new file mode 100644 index 000000000..81b183f90 --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/definitions/invoices-by-status.report.ts @@ -0,0 +1,72 @@ +import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; + +import { Freight } from '@edr/types'; +import { Invoice } from '../../billing/entities/invoice.entity'; +import { Company } from '../../companies/entities/company.entity'; +import { CompanyProfile } from '../../companies/entities/company-profile.entity'; +import { ReportContext, ReportDefinition } from '../report.types'; + +const STATUS_OPTIONS = Object.values(Freight.InvoiceStatus).map((v) => ({ value: v, label: v })); + +function baseQuery(ctx: ReportContext): SelectQueryBuilder { + const { params } = ctx; + const qb = ctx.ds + .createQueryBuilder() + .from(Invoice, 'i') + .innerJoin(Company, 'c', 'c.id = i.company_id') + .leftJoin(CompanyProfile, 'cp', 'cp.id = i.company_profile_id') + .where('i.deleted_at IS NULL'); + + if (params.dateFrom) qb.andWhere('i.issued_at >= :dateFrom', { dateFrom: params.dateFrom }); + if (params.dateTo) qb.andWhere('i.issued_at < :dateTo', { dateTo: params.dateTo }); + const statuses = params.statuses as string[] | null; + if (statuses) qb.andWhere('i.status IN (:...statuses)', { statuses }); + return qb; +} + +export const invoicesByStatusReport: ReportDefinition = { + key: 'invoices-by-status', + title: 'Invoices', + description: 'Every invoice with customer, profile type and settlement status', + group: 'Finance', + filters: [ + { key: 'date', label: 'Issued', type: 'daterange' }, + { key: 'statuses', label: 'Status', type: 'multiselect', options: STATUS_OPTIONS }, + ], + columns: [ + { key: 'invoiceNumber', label: 'Invoice No.', type: 'string', sortable: true, sortExpr: 'i.invoice_number' }, + { key: 'customer', label: 'Customer', type: 'string', sortable: true, sortExpr: 'c.name' }, + { key: 'profileType', label: 'Profile', type: 'string' }, + { key: 'status', label: 'Status', type: 'string', sortable: true, sortExpr: 'i.status' }, + { key: 'totalAmount', label: 'Total', type: 'money', sortable: true }, + { key: 'paidAmount', label: 'Paid', type: 'money' }, + { key: 'balanceAmount', label: 'Balance', type: 'money', sortable: true }, + { key: 'issuedAt', label: 'Issued', type: 'date', sortable: true, sortExpr: 'i.issued_at' }, + { key: 'dueAt', label: 'Due', type: 'date' }, + ], + defaultSort: { key: 'issuedAt', dir: 'DESC' }, + query(ctx) { + return baseQuery(ctx) + .select('i.invoice_number', 'invoiceNumber') + .addSelect('c.name', 'customer') + .addSelect("COALESCE(cp.type, 'Unknown')", 'profileType') + .addSelect('i.status', 'status') + .addSelect('ROUND(i.total_amount)::float8', 'totalAmount') + .addSelect('ROUND(i.paid_amount)::float8', 'paidAmount') + .addSelect('ROUND(i.balance_amount)::float8', 'balanceAmount') + .addSelect(`to_char(i.issued_at, 'YYYY-MM-DD')`, 'issuedAt') + .addSelect(`to_char(i.due_at, 'YYYY-MM-DD')`, 'dueAt'); + }, + async summary(ctx) { + const row = await baseQuery(ctx) + .select('COUNT(*)::int', 'invoices') + .addSelect('ROUND(COALESCE(SUM(i.total_amount), 0))::float8', 'total') + .addSelect('ROUND(COALESCE(SUM(i.balance_amount), 0))::float8', 'balance') + .getRawOne(); + return [ + { label: 'Invoices', value: Number(row?.invoices ?? 0) }, + { label: 'Total value', value: Number(row?.total ?? 0), unit: 'ETB' }, + { label: 'Outstanding', value: Number(row?.balance ?? 0), unit: 'ETB' }, + ]; + }, +}; diff --git a/apps/edr-freight-api/src/modules/reports/definitions/invoicing-pipeline.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/invoicing-pipeline.report.ts new file mode 100644 index 000000000..8907f4f5a --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/definitions/invoicing-pipeline.report.ts @@ -0,0 +1,59 @@ +import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; + +import { Freight } from '@edr/types'; +import { Invoice } from '../../billing/entities/invoice.entity'; +import { ReportContext, ReportDefinition } from '../report.types'; + +const STATUS_OPTIONS = Object.values(Freight.InvoiceStatus).map((v) => ({ value: v, label: v })); + +function baseQuery(ctx: ReportContext): SelectQueryBuilder { + const { params } = ctx; + const qb = ctx.ds.createQueryBuilder().from(Invoice, 'i').where('i.deleted_at IS NULL'); + + if (params.dateFrom) qb.andWhere('i.created_at >= :dateFrom', { dateFrom: params.dateFrom }); + if (params.dateTo) qb.andWhere('i.created_at < :dateTo', { dateTo: params.dateTo }); + const statuses = params.statuses as string[] | null; + if (statuses) qb.andWhere('i.status IN (:...statuses)', { statuses }); + return qb; +} + +export const invoicingPipelineReport: ReportDefinition = { + key: 'invoicing-pipeline', + title: 'Invoicing Pipeline', + description: 'Invoice volume and value by type and status', + group: 'Finance', + filters: [ + { key: 'date', label: 'Created', type: 'daterange' }, + { key: 'statuses', label: 'Status', type: 'multiselect', options: STATUS_OPTIONS }, + ], + columns: [ + { key: 'type', label: 'Type', type: 'string', sortable: true }, + { key: 'status', label: 'Status', type: 'string', sortable: true }, + { key: 'invoices', label: 'Invoices', type: 'number', sortable: true }, + { key: 'totalAmount', label: 'Total', type: 'money', sortable: true }, + { key: 'balance', label: 'Outstanding', type: 'money', sortable: true }, + ], + defaultSort: { key: 'invoices', dir: 'DESC' }, + query(ctx) { + return baseQuery(ctx) + .select('i.type', 'type') + .addSelect('i.status', 'status') + .addSelect('COUNT(*)::int', 'invoices') + .addSelect('ROUND(COALESCE(SUM(i.total_amount), 0))::float8', 'totalAmount') + .addSelect('ROUND(COALESCE(SUM(i.balance_amount), 0))::float8', 'balance') + .groupBy('i.type') + .addGroupBy('i.status'); + }, + async summary(ctx) { + const row = await baseQuery(ctx) + .select('COUNT(*)::int', 'invoices') + .addSelect('ROUND(COALESCE(SUM(i.total_amount), 0))::float8', 'totalAmount') + .addSelect('ROUND(COALESCE(SUM(i.balance_amount), 0))::float8', 'balance') + .getRawOne(); + return [ + { label: 'Invoices', value: Number(row?.invoices ?? 0) }, + { label: 'Total value', value: Number(row?.totalAmount ?? 0), unit: 'ETB' }, + { label: 'Outstanding', value: Number(row?.balance ?? 0), unit: 'ETB' }, + ]; + }, +}; diff --git a/apps/edr-freight-api/src/modules/reports/definitions/payments-by-status.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/payments-by-status.report.ts new file mode 100644 index 000000000..15e99a4b1 --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/definitions/payments-by-status.report.ts @@ -0,0 +1,73 @@ +import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; + +import { PaymentEntity } from '../../payment/entities/payment.entity'; +import { ReportContext, ReportDefinition } from '../report.types'; + +// No direct company link on payments (refId points at whatever the intent was +// for — booking, demurrage, ...); breakdown stops at status/method/currency. +const STATUS_OPTIONS = [ + { value: 'action-required', label: 'Action required' }, + { value: 'processing', label: 'Processing' }, + { value: 'success', label: 'Success' }, + { value: 'failed', label: 'Failed' }, + { value: 'canceled', label: 'Canceled' }, + { value: 'refunded', label: 'Refunded' }, +]; +const METHOD_OPTIONS = ['telebirr', 'cbe-birr', 'ebirr', 'waafi', 'card', 'dmoney', 'cac-bank', 'cbe-bill'].map( + (v) => ({ value: v, label: v }), +); + +function baseQuery(ctx: ReportContext): SelectQueryBuilder { + const { params } = ctx; + // payments carries no deleted_at column (unlike the rest of the schema) — + // confirmed against the live DB, not assumed from BaseEntity. + const qb = ctx.ds.createQueryBuilder().from(PaymentEntity, 'p').where('1 = 1'); + + if (params.dateFrom) qb.andWhere('p.created_at >= :dateFrom', { dateFrom: params.dateFrom }); + if (params.dateTo) qb.andWhere('p.created_at < :dateTo', { dateTo: params.dateTo }); + if (params.method) qb.andWhere('p.method = :method', { method: params.method }); + const statuses = params.statuses as string[] | null; + if (statuses) qb.andWhere('p.status IN (:...statuses)', { statuses }); + return qb; +} + +export const paymentsByStatusReport: ReportDefinition = { + key: 'payments-by-status', + title: 'Payments by Status', + description: 'Payment volume and value by status, method and currency', + group: 'Finance', + filters: [ + { key: 'date', label: 'Created', type: 'daterange' }, + { key: 'method', label: 'Method', type: 'select', options: METHOD_OPTIONS }, + { key: 'statuses', label: 'Status', type: 'multiselect', options: STATUS_OPTIONS }, + ], + columns: [ + { key: 'status', label: 'Status', type: 'string', sortable: true }, + { key: 'method', label: 'Method', type: 'string', sortable: true }, + { key: 'currency', label: 'Currency', type: 'string' }, + { key: 'payments', label: 'Payments', type: 'number', sortable: true }, + { key: 'amount', label: 'Amount', type: 'money', sortable: true }, + ], + defaultSort: { key: 'amount', dir: 'DESC' }, + query(ctx) { + return baseQuery(ctx) + .select('p.status', 'status') + .addSelect('p.method', 'method') + .addSelect('p.currency', 'currency') + .addSelect('COUNT(*)::int', 'payments') + .addSelect('ROUND(COALESCE(SUM(p.amount), 0))::float8', 'amount') + .groupBy('p.status') + .addGroupBy('p.method') + .addGroupBy('p.currency'); + }, + async summary(ctx) { + const row = await baseQuery(ctx) + .select('COUNT(*)::int', 'payments') + .addSelect("ROUND(COALESCE(SUM(p.amount) FILTER (WHERE p.status = 'success'), 0))::float8", 'paid') + .getRawOne(); + return [ + { label: 'Payments', value: Number(row?.payments ?? 0) }, + { label: 'Total paid', value: Number(row?.paid ?? 0), unit: 'ETB' }, + ]; + }, +}; diff --git a/apps/edr-freight-api/src/modules/reports/definitions/revenue-summary.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/revenue-summary.report.ts new file mode 100644 index 000000000..684d4e2e5 --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/definitions/revenue-summary.report.ts @@ -0,0 +1,61 @@ +import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; + +import { Booking } from '../../bookings/entities/booking.entity'; +import { ReportContext, ReportDefinition } from '../report.types'; + +const REVENUE = 'COALESCE(b.adjusted_total_amount, b.total_amount)'; +const NOT_UMBRELLA = "(b.contract_kind IS NULL OR b.contract_kind <> 'GENERAL')"; +const DEAD_STATUSES = ['DRAFT', 'CANCELLED', 'REJECTED', 'EXPIRED']; + +function baseQuery(ctx: ReportContext): SelectQueryBuilder { + const { params, directions } = ctx; + const qb = ctx.ds + .createQueryBuilder() + .from(Booking, 'b') + .where(`b.deleted_at IS NULL AND ${NOT_UMBRELLA}`) + .andWhere('b.status NOT IN (:...deadStatuses)', { deadStatuses: DEAD_STATUSES }); + + if (params.dateFrom) qb.andWhere('b.created_at >= :dateFrom', { dateFrom: params.dateFrom }); + if (params.dateTo) qb.andWhere('b.created_at < :dateTo', { dateTo: params.dateTo }); + if (directions !== null) { + qb.andWhere(directions.length ? 'b.trade_direction IN (:...directions)' : '1 = 0', { directions }); + } + return qb; +} + +export const revenueSummaryReport: ReportDefinition = { + key: 'revenue-summary', + title: 'Revenue Summary', + description: 'Booking revenue by direction, cargo type and currency', + group: 'Finance', + filters: [{ key: 'date', label: 'Created', type: 'daterange' }], + columns: [ + { key: 'direction', label: 'Direction', type: 'string', sortable: true }, + { key: 'freightType', label: 'Cargo type', type: 'string', sortable: true }, + { key: 'currency', label: 'Currency', type: 'string' }, + { key: 'bookings', label: 'Bookings', type: 'number', sortable: true }, + { key: 'revenue', label: 'Revenue', type: 'money', sortable: true }, + ], + defaultSort: { key: 'revenue', dir: 'DESC' }, + query(ctx) { + return baseQuery(ctx) + .select('b.trade_direction', 'direction') + .addSelect('b.freight_type', 'freightType') + .addSelect('b.payment_currency', 'currency') + .addSelect('COUNT(*)::int', 'bookings') + .addSelect(`ROUND(COALESCE(SUM(${REVENUE}), 0))::float8`, 'revenue') + .groupBy('b.trade_direction') + .addGroupBy('b.freight_type') + .addGroupBy('b.payment_currency'); + }, + async summary(ctx) { + const row = await baseQuery(ctx) + .select(`ROUND(COALESCE(SUM(${REVENUE}), 0))::float8`, 'revenue') + .addSelect('COUNT(*)::int', 'bookings') + .getRawOne(); + return [ + { label: 'Bookings', value: Number(row?.bookings ?? 0) }, + { label: 'Total revenue', value: Number(row?.revenue ?? 0), unit: 'ETB' }, + ]; + }, +}; diff --git a/apps/edr-freight-api/src/modules/reports/report.registry.ts b/apps/edr-freight-api/src/modules/reports/report.registry.ts index 0d9b1d3a2..004b61e5b 100644 --- a/apps/edr-freight-api/src/modules/reports/report.registry.ts +++ b/apps/edr-freight-api/src/modules/reports/report.registry.ts @@ -13,6 +13,15 @@ import { trainTurnaroundReport } from './definitions/train-turnaround.report'; import { wagonTeuUtilizationReport } from './definitions/wagon-teu-utilization.report'; import { loadedCapacityReport } from './definitions/loaded-capacity.report'; import { globalLogisticsWagonsReport } from './definitions/global-logistics-wagons.report'; +import { customerStatusReport } from './definitions/customer-status.report'; +import { contractLifecycleReport } from './definitions/contract-lifecycle.report'; +import { customsDocumentsReport } from './definitions/customs-documents.report'; +import { invoicingPipelineReport } from './definitions/invoicing-pipeline.report'; +import { firstLastMileBookingsReport } from './definitions/first-last-mile-bookings.report'; +import { invoicesByStatusReport } from './definitions/invoices-by-status.report'; +import { paymentsByStatusReport } from './definitions/payments-by-status.report'; +import { revenueSummaryReport } from './definitions/revenue-summary.report'; +import { cargoSummaryReport } from './definitions/cargo-summary.report'; import { ReportDefinition } from './report.types'; /** @@ -35,6 +44,15 @@ export const REPORTS: ReportDefinition[] = [ wagonTeuUtilizationReport, loadedCapacityReport, globalLogisticsWagonsReport, + customerStatusReport, + contractLifecycleReport, + customsDocumentsReport, + invoicingPipelineReport, + firstLastMileBookingsReport, + invoicesByStatusReport, + paymentsByStatusReport, + revenueSummaryReport, + cargoSummaryReport, ]; const BY_KEY = new Map(REPORTS.map((r) => [r.key, r])); diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index 9cddfde87..e98127042 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -69,6 +69,15 @@ export const REPORT_KEYS = [ "wagon-teu-utilization", "loaded-capacity", "global-logistics-wagons", + "customer-status", + "contract-lifecycle", + "customs-documents", + "invoicing-pipeline", + "first-last-mile-bookings", + "invoices-by-status", + "payments-by-status", + "revenue-summary", + "cargo-summary", ] as const; export type ReportKey = (typeof REPORT_KEYS)[number];