diff --git a/apps/edr-freight-api/src/modules/exports/datasets/contracts.dataset.ts b/apps/edr-freight-api/src/modules/exports/datasets/contracts.dataset.ts new file mode 100644 index 000000000..854604c45 --- /dev/null +++ b/apps/edr-freight-api/src/modules/exports/datasets/contracts.dataset.ts @@ -0,0 +1,134 @@ +import { FREIGHT_PERMS } from '../../../seed/freight-permissions.registry'; +import { Company } from '../../companies/entities/company.entity'; +import { CompanyProfile } from '../../companies/entities/company-profile.entity'; +import { Contract } from '../../contracts/entities/contract.entity'; +import { ServiceType } from '../../rule-engine/entities/service-type.entity'; +import { applyDirectionScope } from '../../user-trade-access/trade-scope.util'; +import { ExportDataset } from '../export.types'; + +export const contractsDataset: ExportDataset = { + key: 'contracts', + title: 'Contracts', + description: 'Contracts with customer, terms, routes, approval and clearance detail', + group: 'Commercial', + permission: FREIGHT_PERMS.contracts.view, + base: { entity: Contract, alias: 'ct' }, + + joins: [ + { alias: 'c', entity: Company, on: 'c.id = ct.company_id' }, + { alias: 'cp', entity: CompanyProfile, on: 'cp.id = ct.company_profile_id' }, + { alias: 'st', entity: ServiceType, on: 'st.id = ct.service_type_id' }, + { alias: 'ren', entity: Contract, on: 'ren.id = ct.renewal_of_id' }, + ], + // `search` matches the customer name. + alwaysJoin: ['c'], + + groups: [ + { id: 'contract', label: 'Contract' }, + { id: 'customer', label: 'Customer' }, + { id: 'terms', label: 'Terms' }, + { id: 'routes', label: 'Routes & cargo' }, + { id: 'approval', label: 'Approval' }, + { id: 'clearance', label: 'Clearance' }, + ], + + fields: [ + { key: 'reference', label: 'Reference', type: 'string', group: 'contract', default: true, select: 'ct.reference', sortExpr: 'ct.reference' }, + { key: 'status', label: 'Status', type: 'string', group: 'contract', default: true, select: 'ct.status', sortExpr: 'ct.status' }, + { key: 'contractKind', label: 'Kind', type: 'string', group: 'contract', default: true, select: 'ct.contract_kind' }, + { key: 'contractType', label: 'Type', type: 'string', group: 'contract', select: 'ct.contract_type' }, + { key: 'createdAt', label: 'Created', type: 'date', group: 'contract', default: true, select: `to_char(ct.created_at, 'YYYY-MM-DD')`, sortExpr: 'ct.created_at' }, + { key: 'submittedAt', label: 'Submitted', type: 'datetime', group: 'contract', select: `to_char(ct.submitted_at, 'YYYY-MM-DD HH24:MI')` }, + { key: 'versionNumber', label: 'Version', type: 'number', group: 'contract', select: 'ct.version_number' }, + { key: 'renewalOf', label: 'Renewal of', type: 'string', group: 'contract', requires: ['ren'], select: 'ren.reference' }, + { key: 'statusBeforeSuspension', label: 'Status before suspension', type: 'string', group: 'contract', select: 'ct.status_before_suspension' }, + + { key: 'customer', label: 'Customer', type: 'string', group: 'customer', default: true, requires: ['c'], select: 'c.name', sortExpr: 'c.name' }, + { key: 'customerTin', label: 'Customer TIN', type: 'string', group: 'customer', requires: ['c'], select: 'c.tin' }, + { key: 'customerPhone', label: 'Customer phone', type: 'string', group: 'customer', requires: ['c'], select: 'c.phone' }, + { key: 'customerEmail', label: 'Customer email', type: 'string', group: 'customer', requires: ['c'], select: 'c.email' }, + { key: 'customerType', label: 'Customer type', type: 'string', group: 'customer', requires: ['c'], select: 'c.type' }, + { key: 'customerProfileRef', label: 'Profile reference', type: 'string', group: 'customer', requires: ['cp'], select: 'cp.reference' }, + { key: 'isGovernment', label: 'Government', type: 'boolean', group: 'customer', select: 'ct.is_government' }, + { key: 'governmentInstitution', label: 'Government institution', type: 'string', group: 'customer', select: 'ct.government_institution' }, + + { key: 'tradeDirection', label: 'Direction', type: 'string', group: 'terms', default: true, select: 'ct.trade_direction', sortExpr: 'ct.trade_direction' }, + { key: 'freightType', label: 'Freight type', type: 'string', group: 'terms', default: true, select: 'ct.freight_type' }, + { key: 'serviceType', label: 'Service type', type: 'string', group: 'terms', requires: ['st'], select: 'st.service_name' }, + { key: 'paymentCurrency', label: 'Currency', type: 'string', group: 'terms', select: 'ct.payment_currency' }, + { key: 'validFrom', label: 'Valid from', type: 'date', group: 'terms', default: true, select: `to_char(ct.contract_valid_from, 'YYYY-MM-DD')`, sortExpr: 'ct.contract_valid_from' }, + { key: 'validUntil', label: 'Valid until', type: 'date', group: 'terms', default: true, select: `to_char(ct.contract_valid_until, 'YYYY-MM-DD')` }, + { key: 'validityDays', label: 'Validity (days)', type: 'number', group: 'terms', select: 'ct.contract_validity_days' }, + { key: 'expiresAt', label: 'Expires', type: 'date', group: 'terms', select: `to_char(ct.expires_at, 'YYYY-MM-DD')` }, + { key: 'estimatedShipmentDate', label: 'Est. shipment date', type: 'date', group: 'terms', select: `to_char(ct.estimated_shipment_date, 'YYYY-MM-DD')` }, + { key: 'equipmentReturn', label: 'Equipment return', type: 'string', group: 'terms', select: 'ct.equipment_return' }, + { key: 'pricingDisplayMode', label: 'Pricing display mode', type: 'string', group: 'terms', select: 'ct.pricing_display_mode' }, + + { + key: 'routes', label: 'Routes', type: 'string', group: 'routes', + select: `(SELECT string_agg(o.label || ' -> ' || d.label, ' | ' ORDER BY cr.sort_order) + FROM freight.contract_routes cr + JOIN freight.yards o ON o.id = cr.origin_yard_id + JOIN freight.yards d ON d.id = cr.destination_yard_id + WHERE cr.contract_id = ct.id AND cr.deleted_at IS NULL)`, + }, + { + key: 'routeCount', label: 'Route count', type: 'number', group: 'routes', + select: `(SELECT COUNT(*)::int FROM freight.contract_routes cr + WHERE cr.contract_id = ct.id AND cr.deleted_at IS NULL)`, + }, + { + key: 'bookingCount', label: 'Bookings', type: 'number', group: 'routes', + select: `(SELECT COUNT(*)::int FROM freight.bookings b + WHERE b.contract_id = ct.id AND b.deleted_at IS NULL)`, + }, + { key: 'isHazardous', label: 'Hazardous', type: 'boolean', group: 'routes', select: 'ct.is_hazardous' }, + { key: 'hazardClass', label: 'Hazard class', type: 'string', group: 'routes', select: 'ct.hazard_class' }, + { key: 'unNumber', label: 'UN number', type: 'string', group: 'routes', select: 'ct.un_number' }, + { key: 'isReefer', label: 'Reefer', type: 'boolean', group: 'routes', select: 'ct.is_reefer' }, + + { key: 'approvedAt', label: 'Approved at', type: 'datetime', group: 'approval', select: `to_char(ct.approved_by_staff_at, 'YYYY-MM-DD HH24:MI')` }, + { key: 'signedByDirectorAt', label: 'Director signed', type: 'datetime', group: 'approval', select: `to_char(ct.signed_by_director_at, 'YYYY-MM-DD HH24:MI')` }, + { key: 'signedByCeoAt', label: 'CEO signed', type: 'datetime', group: 'approval', select: `to_char(ct.signed_by_ceo_at, 'YYYY-MM-DD HH24:MI')` }, + { key: 'customerSignedAt', label: 'Customer signed', type: 'datetime', group: 'approval', select: `to_char(ct.customer_signed_at, 'YYYY-MM-DD HH24:MI')` }, + { key: 'fullyExecutedAt', label: 'Fully executed', type: 'datetime', group: 'approval', default: true, select: `to_char(ct.fully_executed_at, 'YYYY-MM-DD HH24:MI')` }, + { key: 'lockedAt', label: 'Locked at', type: 'datetime', group: 'approval', select: `to_char(ct.locked_at, 'YYYY-MM-DD HH24:MI')` }, + { key: 'contractGeneratedAt', label: 'Document generated', type: 'datetime', group: 'approval', select: `to_char(ct.contract_generated_at, 'YYYY-MM-DD HH24:MI')` }, + + { key: 'clearanceStatus', label: 'Clearance status', type: 'string', group: 'clearance', select: 'ct.clearance_status' }, + { key: 'clearanceCycleNumber', label: 'Clearance cycle', type: 'number', group: 'clearance', select: 'ct.clearance_cycle_number' }, + { key: 'customsClearingEnabled', label: 'Customs clearing', type: 'boolean', group: 'clearance', select: 'ct.customs_clearing_enabled' }, + { key: 'customsClearingAgent', label: 'Clearing agent', type: 'string', group: 'clearance', select: 'ct.customs_clearing_agent' }, + { key: 'firstMileAddress', label: 'Pickup address', type: 'string', group: 'clearance', select: 'ct.first_mile_pickup_address' }, + { key: 'lastMileAddress', label: 'Delivery address', type: 'string', group: 'clearance', select: 'ct.last_mile_delivery_address' }, + ], + + filters: [ + { key: 'created', label: 'Created', type: 'daterange' }, + { key: 'statuses', label: 'Status', type: 'multiselect' }, + { key: 'contractKind', label: 'Kind', type: 'text' }, + { key: 'tradeDirection', label: 'Direction', type: 'select', options: ['IMPORT', 'EXPORT', 'DOMESTIC'].map((v) => ({ value: v, label: v })) }, + { key: 'freightType', label: 'Freight type', type: 'select', options: ['CONTAINER', 'BULK'].map((v) => ({ value: v, label: v })) }, + { key: 'companyId', label: 'Customer', type: 'text' }, + { key: 'search', label: 'Search reference or customer', type: 'text' }, + ], + + defaultSort: { key: 'createdAt', dir: 'DESC' }, + + scope(ctx, qb) { + const { params, directions } = ctx; + qb.andWhere('ct.deleted_at IS NULL'); + if (params.createdFrom) qb.andWhere('ct.created_at >= :createdFrom', { createdFrom: params.createdFrom }); + if (params.createdTo) qb.andWhere('ct.created_at < :createdTo', { createdTo: params.createdTo }); + const statuses = params.statuses as string[] | null; + if (statuses?.length) qb.andWhere('ct.status IN (:...statuses)', { statuses }); + if (params.contractKind) qb.andWhere('ct.contract_kind = :contractKind', { contractKind: params.contractKind }); + if (params.tradeDirection) qb.andWhere('ct.trade_direction = :tradeDirection', { tradeDirection: params.tradeDirection }); + if (params.freightType) qb.andWhere('ct.freight_type = :freightType', { freightType: params.freightType }); + if (params.companyId) qb.andWhere('ct.company_id = :companyId', { companyId: params.companyId }); + if (params.search) { + qb.andWhere('(ct.reference ILIKE :search OR c.name ILIKE :search)', { search: `%${params.search as string}%` }); + } + applyDirectionScope(qb, 'ct.trade_direction', directions); + }, +}; diff --git a/apps/edr-freight-api/src/modules/exports/datasets/customers.dataset.ts b/apps/edr-freight-api/src/modules/exports/datasets/customers.dataset.ts new file mode 100644 index 000000000..7a01ae048 --- /dev/null +++ b/apps/edr-freight-api/src/modules/exports/datasets/customers.dataset.ts @@ -0,0 +1,137 @@ +import { FREIGHT_PERMS } from '../../../seed/freight-permissions.registry'; +import { Company } from '../../companies/entities/company.entity'; +import { ExportDataset } from '../export.types'; + +/** + * ONE ROW PER COMPANY. A company has many `company_profiles`, so every + * profile-derived field aggregates in a subquery rather than joining — a join + * would multiply rows and make the file disagree with the count endpoint. + * If per-profile rows are ever wanted, that is a separate `company-profiles` + * dataset, not a flag on this one. + */ +export const customersDataset: ExportDataset = { + key: 'customers', + title: 'Customers', + description: 'Companies with registration, contact, address and activity detail', + group: 'Commercial', + permission: FREIGHT_PERMS.customers.view, + base: { entity: Company, alias: 'c' }, + joins: [], + + groups: [ + { id: 'identity', label: 'Identity' }, + { id: 'registration', label: 'Registration' }, + { id: 'contact', label: 'Contact' }, + { id: 'address', label: 'Address' }, + { id: 'profiles', label: 'Profiles' }, + { id: 'activity', label: 'Activity' }, + ], + + fields: [ + { key: 'name', label: 'Company', type: 'string', group: 'identity', default: true, select: 'c.name', sortExpr: 'c.name' }, + { key: 'type', label: 'Type', type: 'string', group: 'identity', default: true, select: 'c.type' }, + { key: 'kind', label: 'Kind', type: 'string', group: 'identity', default: true, select: 'c.kind' }, + { key: 'status', label: 'Status', type: 'string', group: 'identity', default: true, select: 'c.status', sortExpr: 'c.status' }, + { key: 'statusDescription', label: 'Status note', type: 'string', group: 'identity', select: 'c.status_description' }, + { key: 'nationality', label: 'Nationality', type: 'string', group: 'identity', select: 'c.nationality' }, + + // date_registered / renewal_date / renewed_* are varchar in the schema, + // not dates — exported verbatim rather than pushed through to_char. + { key: 'tin', label: 'TIN', type: 'string', group: 'registration', default: true, select: 'c.tin' }, + { key: 'vatNumber', label: 'VAT number', type: 'string', group: 'registration', select: 'c.vat_number' }, + { key: 'fanNumber', label: 'FAN number', type: 'string', group: 'registration', select: 'c.fan_number' }, + { key: 'licenceNumber', label: 'Licence number', type: 'string', group: 'registration', select: 'c.licence_number' }, + { key: 'dateRegistered', label: 'Date registered', type: 'string', group: 'registration', select: 'c.date_registered' }, + { key: 'renewalDate', label: 'Renewal date', type: 'string', group: 'registration', select: 'c.renewal_date' }, + { key: 'renewedFrom', label: 'Renewed from', type: 'string', group: 'registration', select: 'c.renewed_from' }, + { key: 'renewedTo', label: 'Renewed to', type: 'string', group: 'registration', select: 'c.renewed_to' }, + { key: 'approvedAt', label: 'Approved at', type: 'datetime', group: 'registration', select: `to_char(c.approved_at, 'YYYY-MM-DD HH24:MI')` }, + + { key: 'phone', label: 'Phone', type: 'string', group: 'contact', default: true, select: 'c.phone' }, + { key: 'email', label: 'Email', type: 'string', group: 'contact', default: true, select: 'c.email' }, + { key: 'etradePhone', label: 'eTrade phone', type: 'string', group: 'contact', select: 'c.etrade_phone' }, + { key: 'website', label: 'Website', type: 'string', group: 'contact', select: 'c.website' }, + { key: 'contactPersonName', label: 'Contact person', type: 'string', group: 'contact', select: 'c.contact_person_name' }, + { key: 'contactPersonPhone', label: 'Contact phone', type: 'string', group: 'contact', select: 'c.contact_person_phone' }, + + { key: 'country', label: 'Country', type: 'string', group: 'address', select: 'c.country' }, + { key: 'region', label: 'Region', type: 'string', group: 'address', select: 'c.region' }, + { key: 'zone', label: 'Zone', type: 'string', group: 'address', select: 'c.zone' }, + { key: 'woreda', label: 'Woreda', type: 'string', group: 'address', select: 'c.woreda' }, + { key: 'kebele', label: 'Kebele', type: 'string', group: 'address', select: 'c.kebele' }, + { key: 'houseNo', label: 'House no.', type: 'string', group: 'address', select: 'c.house_no' }, + { key: 'address', label: 'Address', type: 'string', group: 'address', select: 'c.address' }, + + { + key: 'profileCount', label: 'Profile count', type: 'number', group: 'profiles', default: true, + select: `(SELECT COUNT(*)::int FROM freight.company_profiles cp + WHERE cp.company_id = c.id AND cp.deleted_at IS NULL)`, + }, + { + key: 'profileTypes', label: 'Profile types', type: 'string', group: 'profiles', + select: `(SELECT string_agg(DISTINCT cp.type, ' | ') FROM freight.company_profiles cp + WHERE cp.company_id = c.id AND cp.deleted_at IS NULL)`, + }, + { + key: 'profileReferences', label: 'Profile references', type: 'string', group: 'profiles', + select: `(SELECT string_agg(cp.reference, ' | ' ORDER BY cp.reference) FROM freight.company_profiles cp + WHERE cp.company_id = c.id AND cp.deleted_at IS NULL)`, + }, + { + key: 'profileStatuses', label: 'Profile statuses', type: 'string', group: 'profiles', + select: `(SELECT string_agg(DISTINCT cp.status, ' | ') FROM freight.company_profiles cp + WHERE cp.company_id = c.id AND cp.deleted_at IS NULL)`, + }, + + { key: 'createdAt', label: 'Registered on', type: 'date', group: 'activity', default: true, select: `to_char(c.created_at, 'YYYY-MM-DD')`, sortExpr: 'c.created_at' }, + { + key: 'bookingCount', label: 'Bookings', type: 'number', group: 'activity', + select: `(SELECT COUNT(*)::int FROM freight.bookings b + WHERE b.company_id = c.id AND b.deleted_at IS NULL)`, + }, + { + key: 'contractCount', label: 'Contracts', type: 'number', group: 'activity', + select: `(SELECT COUNT(*)::int FROM freight.contracts ct + WHERE ct.company_id = c.id AND ct.deleted_at IS NULL)`, + }, + { + key: 'invoicedTotal', label: 'Invoiced total', type: 'money', group: 'activity', + select: `(SELECT ROUND(COALESCE(SUM(i.total_amount), 0), 2)::float8 FROM freight.invoices i + WHERE i.company_id = c.id AND i.deleted_at IS NULL)`, + }, + { + key: 'outstandingBalance', label: 'Outstanding balance', type: 'money', group: 'activity', + select: `(SELECT ROUND(COALESCE(SUM(i.balance_amount), 0), 2)::float8 FROM freight.invoices i + WHERE i.company_id = c.id AND i.deleted_at IS NULL)`, + }, + ], + + filters: [ + { key: 'created', label: 'Registered', type: 'daterange' }, + { key: 'type', label: 'Type', type: 'text' }, + { key: 'kind', label: 'Kind', type: 'select', options: [ + { value: 'commercial', label: 'Commercial' }, + { value: 'government', label: 'Government' }, + ] }, + { key: 'status', label: 'Status', type: 'text' }, + { key: 'search', label: 'Search name, TIN or email', type: 'text' }, + ], + + defaultSort: { key: 'name', dir: 'ASC' }, + + scope(ctx, qb) { + const { params } = ctx; + qb.andWhere('c.deleted_at IS NULL'); + if (params.createdFrom) qb.andWhere('c.created_at >= :createdFrom', { createdFrom: params.createdFrom }); + if (params.createdTo) qb.andWhere('c.created_at < :createdTo', { createdTo: params.createdTo }); + if (params.type) qb.andWhere('c.type = :type', { type: params.type }); + if (params.kind) qb.andWhere('c.kind = :kind', { kind: params.kind }); + if (params.status) qb.andWhere('c.status = :status', { status: params.status }); + if (params.search) { + qb.andWhere('(c.name ILIKE :search OR c.tin ILIKE :search OR c.email ILIKE :search)', { + search: `%${params.search as string}%`, + }); + } + // Companies carry no trade direction — nothing to scope. Intentional. + }, +}; diff --git a/apps/edr-freight-api/src/modules/exports/datasets/invoices.dataset.ts b/apps/edr-freight-api/src/modules/exports/datasets/invoices.dataset.ts new file mode 100644 index 000000000..c98ca684f --- /dev/null +++ b/apps/edr-freight-api/src/modules/exports/datasets/invoices.dataset.ts @@ -0,0 +1,126 @@ +import { FREIGHT_PERMS } from '../../../seed/freight-permissions.registry'; +import { Invoice } from '../../billing/entities/invoice.entity'; +import { Company } from '../../companies/entities/company.entity'; +import { CompanyProfile } from '../../companies/entities/company-profile.entity'; +import { ShippingLineCompany } from '../../shipping-lines/entities/shipping-line-company.entity'; +import { applyBookingRefDirectionScope } from '../../user-trade-access/trade-scope.util'; +import { ExportDataset } from '../export.types'; + +/** + * Sensitive EIMS internals are deliberately absent: `eims_signed_qr` (a + * signature blob) and `eims_last_error` (a raw error dump). The + * human-meaningful status/IRN/document-number fields are kept. + */ +export const invoicesDataset: ExportDataset = { + key: 'invoices', + title: 'Invoices', + description: 'Invoices with customer, amounts, payment status and EIMS state', + group: 'Finance', + permission: FREIGHT_PERMS.invoices.view, + base: { entity: Invoice, alias: 'i' }, + + joins: [ + { alias: 'c', entity: Company, on: 'c.id = i.company_id' }, + { alias: 'cp', entity: CompanyProfile, on: 'cp.id = i.company_profile_id' }, + // No relation object on the entity for this FK — the service hydrates it + // with a second query. In a dataset it is just a join by column. + { alias: 'slc', entity: ShippingLineCompany, on: 'slc.id = i.shipping_line_company_id' }, + { alias: 'rel', entity: Invoice, on: 'rel.id = i.related_invoice_id' }, + ], + alwaysJoin: ['c'], + + groups: [ + { id: 'invoice', label: 'Invoice' }, + { id: 'customer', label: 'Customer' }, + { id: 'amounts', label: 'Amounts' }, + { id: 'payment', label: 'Payment' }, + { id: 'lines', label: 'Lines' }, + { id: 'eims', label: 'EIMS' }, + ], + + fields: [ + { key: 'invoiceNumber', label: 'Invoice no.', type: 'string', group: 'invoice', default: true, select: 'i.invoice_number', sortExpr: 'i.invoice_number' }, + { key: 'status', label: 'Status', type: 'string', group: 'invoice', default: true, select: 'i.status', sortExpr: 'i.status' }, + { key: 'type', label: 'Type', type: 'string', group: 'invoice', select: 'i.type' }, + { key: 'source', label: 'Source', type: 'string', group: 'invoice', default: true, select: 'i.source' }, + { key: 'sourceId', label: 'Source reference', type: 'string', group: 'invoice', select: 'i.source_id' }, + { key: 'issuedAt', label: 'Issued', type: 'date', group: 'invoice', default: true, select: `to_char(i.issued_at, 'YYYY-MM-DD')`, sortExpr: 'i.issued_at' }, + { key: 'dueAt', label: 'Due', type: 'date', group: 'invoice', default: true, select: `to_char(i.due_at, 'YYYY-MM-DD')`, sortExpr: 'i.due_at' }, + { key: 'createdAt', label: 'Created', type: 'date', group: 'invoice', select: `to_char(i.created_at, 'YYYY-MM-DD')`, sortExpr: 'i.created_at' }, + { key: 'relatedInvoice', label: 'Related invoice', type: 'string', group: 'invoice', requires: ['rel'], select: 'rel.invoice_number' }, + + { key: 'customer', label: 'Customer', type: 'string', group: 'customer', default: true, requires: ['c'], select: 'c.name', sortExpr: 'c.name' }, + { key: 'customerTin', label: 'Customer TIN', type: 'string', group: 'customer', requires: ['c'], select: 'c.tin' }, + { key: 'customerVat', label: 'Customer VAT no.', type: 'string', group: 'customer', requires: ['c'], select: 'c.vat_number' }, + { key: 'customerPhone', label: 'Customer phone', type: 'string', group: 'customer', requires: ['c'], select: 'c.phone' }, + { key: 'customerEmail', label: 'Customer email', type: 'string', group: 'customer', requires: ['c'], select: 'c.email' }, + { key: 'customerAddress', label: 'Customer address', type: 'string', group: 'customer', requires: ['c'], select: 'c.address' }, + { key: 'customerProfileRef', label: 'Profile reference', type: 'string', group: 'customer', requires: ['cp'], select: 'cp.reference' }, + { key: 'shippingLineCompany', label: 'Shipping line company', type: 'string', group: 'customer', requires: ['slc'], select: 'slc.name' }, + + { key: 'subtotalAmount', label: 'Subtotal', type: 'money', group: 'amounts', select: 'i.subtotal_amount::float8' }, + { key: 'taxAmount', label: 'Tax', type: 'money', group: 'amounts', select: 'i.tax_amount::float8' }, + { key: 'totalAmount', label: 'Total', type: 'money', group: 'amounts', default: true, select: 'i.total_amount::float8', sortExpr: 'i.total_amount' }, + { key: 'paidAmount', label: 'Paid', type: 'money', group: 'amounts', default: true, select: 'i.paid_amount::float8' }, + { key: 'balanceAmount', label: 'Balance', type: 'money', group: 'amounts', default: true, select: 'i.balance_amount::float8', sortExpr: 'i.balance_amount' }, + { key: 'currency', label: 'Currency', type: 'string', group: 'amounts', default: true, select: 'i.currency' }, + + { key: 'paidAt', label: 'Paid at', type: 'datetime', group: 'payment', select: `to_char(i.paid_at, 'YYYY-MM-DD HH24:MI')` }, + { + key: 'daysOverdue', label: 'Days overdue', type: 'number', group: 'payment', + select: `CASE WHEN i.balance_amount > 0 AND i.due_at < now() + THEN EXTRACT(DAY FROM now() - i.due_at)::int ELSE 0 END`, + }, + + { + key: 'lineCount', label: 'Line count', type: 'number', group: 'lines', + select: `(SELECT COUNT(*)::int FROM freight.invoice_lines il + WHERE il.invoice_id = i.id AND il.deleted_at IS NULL)`, + }, + { + key: 'lineCharges', label: 'Charges', type: 'string', group: 'lines', + select: `(SELECT string_agg(il.charge_type || ': ' || ROUND(il.amount, 2), ' | ' ORDER BY il.charge_type) + FROM freight.invoice_lines il + WHERE il.invoice_id = i.id AND il.deleted_at IS NULL)`, + }, + + { key: 'eimsStatus', label: 'EIMS status', type: 'string', group: 'eims', select: 'i.eims_status' }, + { key: 'eimsIrn', label: 'EIMS IRN', type: 'string', group: 'eims', select: 'i.eims_irn' }, + { key: 'eimsDocumentNumber', label: 'EIMS document no.', type: 'string', group: 'eims', select: 'i.eims_document_number' }, + { key: 'eimsDocumentType', label: 'EIMS document type', type: 'string', group: 'eims', select: 'i.eims_document_type' }, + { key: 'eimsSubmittedAt', label: 'EIMS submitted', type: 'datetime', group: 'eims', select: `to_char(i.eims_submitted_at, 'YYYY-MM-DD HH24:MI')` }, + // eims_ack_date is varchar in the schema, not a timestamp. + { key: 'eimsAckDate', label: 'EIMS acknowledged', type: 'string', group: 'eims', select: 'i.eims_ack_date' }, + { key: 'eimsCancelledAt', label: 'EIMS cancelled', type: 'datetime', group: 'eims', select: `to_char(i.eims_cancelled_at, 'YYYY-MM-DD HH24:MI')` }, + { key: 'eimsCancellationReasonCode', label: 'EIMS cancellation reason', type: 'string', group: 'eims', select: 'i.eims_cancellation_reason_code' }, + ], + + filters: [ + { key: 'issued', label: 'Issued', type: 'daterange' }, + { key: 'statuses', label: 'Status', type: 'multiselect' }, + { key: 'currency', label: 'Currency', type: 'select', options: [ + { value: 'ETB', label: 'ETB' }, + { value: 'USD', label: 'USD' }, + ] }, + { key: 'companyId', label: 'Customer', type: 'text' }, + { key: 'search', label: 'Search invoice no. or customer', type: 'text' }, + ], + + defaultSort: { key: 'issuedAt', dir: 'DESC' }, + + scope(ctx, qb) { + const { params, directions } = ctx; + qb.andWhere('i.deleted_at IS NULL'); + if (params.issuedFrom) qb.andWhere('i.issued_at >= :issuedFrom', { issuedFrom: params.issuedFrom }); + if (params.issuedTo) qb.andWhere('i.issued_at < :issuedTo', { issuedTo: params.issuedTo }); + const statuses = params.statuses as string[] | null; + if (statuses?.length) qb.andWhere('i.status IN (:...statuses)', { statuses }); + if (params.currency) qb.andWhere('i.currency = :currency', { currency: params.currency }); + if (params.companyId) qb.andWhere('i.company_id = :companyId', { companyId: params.companyId }); + if (params.search) { + qb.andWhere('(i.invoice_number ILIKE :search OR c.name ILIKE :search)', { search: `%${params.search as string}%` }); + } + // ACL: invoices.source_id is a varchar pointer at the originating booking. + applyBookingRefDirectionScope(qb, 'i.source_id', directions); + }, +}; diff --git a/apps/edr-freight-api/src/modules/exports/datasets/locomotives.dataset.ts b/apps/edr-freight-api/src/modules/exports/datasets/locomotives.dataset.ts new file mode 100644 index 000000000..5ec734489 --- /dev/null +++ b/apps/edr-freight-api/src/modules/exports/datasets/locomotives.dataset.ts @@ -0,0 +1,79 @@ +import { FREIGHT_PERMS } from '../../../seed/freight-permissions.registry'; +import { Locomotive } from '../../locomotives/entities/locomotive.entity'; +import { Yard } from '../../rule-engine/entities/yard.entity'; +import { ExportDataset } from '../export.types'; + +const STATUS_OPTIONS = ['AVAILABLE', 'IN_USE', 'MAINTENANCE', 'OUT_OF_SERVICE'].map((v) => ({ + value: v, + label: v.replace(/_/g, ' '), +})); + +export const locomotivesDataset: ExportDataset = { + key: 'locomotives', + title: 'Locomotives', + description: 'Locomotive fleet with capacity, traction specs and current location', + group: 'Fleet', + permission: FREIGHT_PERMS.locomotives.view, + base: { entity: Locomotive, alias: 'l' }, + + joins: [{ alias: 'y', entity: Yard, on: 'y.id = l.current_yard_id' }], + + groups: [ + { id: 'identity', label: 'Locomotive' }, + { id: 'capacity', label: 'Capacity & traction' }, + { id: 'location', label: 'Status & location' }, + { id: 'assignment', label: 'Assignment' }, + ], + + fields: [ + { key: 'code', label: 'Code', type: 'string', group: 'identity', default: true, select: 'l.code', sortExpr: 'l.code' }, + { key: 'name', label: 'Name', type: 'string', group: 'identity', default: true, select: 'l.name' }, + { key: 'locomotiveType', label: 'Type', type: 'string', group: 'identity', default: true, select: 'l.locomotive_type' }, + { key: 'createdAt', label: 'Added', type: 'date', group: 'identity', select: `to_char(l.created_at, 'YYYY-MM-DD')`, sortExpr: 'l.created_at' }, + + { key: 'maxPullWeightTons', label: 'Max pull weight (t)', type: 'tons', group: 'capacity', default: true, select: 'l.max_pull_weight_tons::float8', sortExpr: 'l.max_pull_weight_tons' }, + { key: 'maxTrainLengthMeters', label: 'Max train length (m)', type: 'number', group: 'capacity', default: true, select: 'l.max_train_length_meters::float8' }, + { key: 'overageToleranceTons', label: 'Overage tolerance (t)', type: 'tons', group: 'capacity', select: 'l.overage_tolerance_tons::float8' }, + { key: 'overageToleranceMeters', label: 'Overage tolerance (m)', type: 'number', group: 'capacity', select: 'l.overage_tolerance_meters::float8' }, + { key: 'powerKw', label: 'Power (kW)', type: 'number', group: 'capacity', select: 'l.power_kw::float8' }, + { key: 'tractionForceKn', label: 'Traction force (kN)', type: 'number', group: 'capacity', select: 'l.traction_force_kn::float8' }, + { key: 'maxSpeedKmh', label: 'Max speed (km/h)', type: 'number', group: 'capacity', select: 'l.max_speed_kmh::float8' }, + + { key: 'status', label: 'Status', type: 'string', group: 'location', default: true, select: 'l.status', sortExpr: 'l.status' }, + { key: 'availableFrom', label: 'Available from', type: 'date', group: 'location', select: `to_char(l.available_from, 'YYYY-MM-DD')` }, + { key: 'currentYard', label: 'Current yard', type: 'string', group: 'location', default: true, requires: ['y'], select: 'y.label' }, + { key: 'currentYardCode', label: 'Current yard code', type: 'string', group: 'location', requires: ['y'], select: 'y.code' }, + { key: 'currentYardCountry', label: 'Current yard country', type: 'string', group: 'location', requires: ['y'], select: 'y.country' }, + + { + // One-to-many -> aggregate in a subquery, never a join. + key: 'assignedTrains', label: 'Assigned trains', type: 'string', group: 'assignment', + select: `(SELECT string_agg(DISTINCT tr.code, ' | ') + FROM freight.train_set_locomotives tsl + JOIN freight.train_sets ts ON ts.id = tsl.train_set_id AND ts.deleted_at IS NULL + JOIN freight.trains tr ON tr.id = ts.train_id AND tr.deleted_at IS NULL + WHERE tsl.locomotive_id = l.id AND tsl.deleted_at IS NULL)`, + }, + ], + + filters: [ + { key: 'status', label: 'Status', type: 'select', options: STATUS_OPTIONS }, + { key: 'locomotiveType', label: 'Type', type: 'text' }, + { key: 'currentYardId', label: 'Current yard', type: 'text' }, + { key: 'search', label: 'Search code or name', type: 'text' }, + ], + + defaultSort: { key: 'code', dir: 'ASC' }, + + scope(ctx, qb) { + const { params } = ctx; + qb.andWhere('l.deleted_at IS NULL'); + if (params.status) qb.andWhere('l.status = :status', { status: params.status }); + if (params.locomotiveType) qb.andWhere('l.locomotive_type = :locomotiveType', { locomotiveType: params.locomotiveType }); + if (params.currentYardId) qb.andWhere('l.current_yard_id = :currentYardId', { currentYardId: params.currentYardId }); + if (params.search) { + qb.andWhere('(l.code ILIKE :search OR l.name ILIKE :search)', { search: `%${params.search as string}%` }); + } + // Locomotives carry no trade direction — nothing to scope. Intentional. + }, +}; diff --git a/apps/edr-freight-api/src/modules/exports/datasets/payments.dataset.ts b/apps/edr-freight-api/src/modules/exports/datasets/payments.dataset.ts new file mode 100644 index 000000000..59739823c --- /dev/null +++ b/apps/edr-freight-api/src/modules/exports/datasets/payments.dataset.ts @@ -0,0 +1,113 @@ +import { FREIGHT_PERMS } from '../../../seed/freight-permissions.registry'; +import { Booking } from '../../bookings/entities/booking.entity'; +import { Company } from '../../companies/entities/company.entity'; +import { PaymentEntity } from '../../payment/entities/payment.entity'; +import { applyBookingRefDirectionScope } from '../../user-trade-access/trade-scope.util'; +import { ExportDataset } from '../export.types'; + +/** + * `freight.payments` breaks two conventions this codebase otherwise holds to, + * both verified against the live schema: + * + * 1. It does NOT extend @edr/api-common's BaseEntity — there is no + * `updated_at` and no `deleted_at`. A soft-delete guard here is a 42703, + * which is why `scope()` below applies the ACL only. + * 2. The failure columns are `failer_code` / `failer_message`, not `failure_*`. + * + * Raw gateway payloads (`raw_initiation`, `client_action`) are deliberately + * not exposed as fields. + */ +export const paymentsDataset: ExportDataset = { + key: 'payments', + title: 'Payments', + description: 'Payment transactions with method, status, and the booking and customer they belong to', + group: 'Finance', + permission: FREIGHT_PERMS.payments.view, + base: { entity: PaymentEntity, alias: 'p' }, + + joins: [ + // ref_id is a varchar pointer at the booking, so the cast is required. + { alias: 'bk', entity: Booking, on: 'bk.id::text = p.ref_id' }, + { alias: 'c', entity: Company, on: 'c.id = bk.company_id', requires: ['bk'] }, + ], + + groups: [ + { id: 'payment', label: 'Payment' }, + { id: 'amounts', label: 'Amounts' }, + { id: 'gateway', label: 'Gateway' }, + { id: 'booking', label: 'Booking' }, + { id: 'customer', label: 'Customer' }, + ], + + fields: [ + { key: 'merchantOrderId', label: 'Order ID', type: 'string', group: 'payment', default: true, select: 'p.merchant_order_id', sortExpr: 'p.merchant_order_id' }, + { key: 'status', label: 'Status', type: 'string', group: 'payment', default: true, select: 'p.status', sortExpr: 'p.status' }, + { key: 'method', label: 'Method', type: 'string', group: 'payment', default: true, select: 'p.method', sortExpr: 'p.method' }, + { key: 'type', label: 'Type', type: 'string', group: 'payment', select: 'p.type' }, + { key: 'referenceType', label: 'Reference type', type: 'string', group: 'payment', select: 'p.reference_type' }, + { key: 'createdAt', label: 'Created', type: 'datetime', group: 'payment', default: true, select: `to_char(p.created_at, 'YYYY-MM-DD HH24:MI')`, sortExpr: 'p.created_at' }, + { key: 'paidAt', label: 'Paid at', type: 'datetime', group: 'payment', default: true, select: `to_char(p.paid_at, 'YYYY-MM-DD HH24:MI')`, sortExpr: 'p.paid_at' }, + { key: 'refundedAt', label: 'Refunded at', type: 'datetime', group: 'payment', select: `to_char(p.refunded_at, 'YYYY-MM-DD HH24:MI')` }, + { key: 'expiresAt', label: 'Expires at', type: 'datetime', group: 'payment', select: `to_char(p.expires_at, 'YYYY-MM-DD HH24:MI')` }, + + { key: 'amount', label: 'Amount', type: 'money', group: 'amounts', default: true, select: 'p.amount::float8', sortExpr: 'p.amount' }, + { key: 'currency', label: 'Currency', type: 'string', group: 'amounts', default: true, select: 'p.currency' }, + { + // payment_refunds stores MINOR units (amount_minor), unlike payments.amount + // which is major. Divide, or a 50.00 refund exports as 5000. + key: 'refundedTotal', label: 'Refunded total', type: 'money', group: 'amounts', + select: `(SELECT ROUND(COALESCE(SUM(pr.amount_minor), 0) / 100.0, 2)::float8 + FROM freight.payment_refunds pr WHERE pr.payment_id = p.id)`, + }, + + { key: 'transactionId', label: 'Transaction ID', type: 'string', group: 'gateway', select: 'p.transaction_id' }, + { key: 'failerCode', label: 'Failure code', type: 'string', group: 'gateway', select: 'p.failer_code' }, + { key: 'failureMessage', label: 'Failure message', type: 'string', group: 'gateway', select: 'p.failer_message' }, + { key: 'reason', label: 'Reason', type: 'string', group: 'gateway', select: 'p.reason' }, + + { key: 'bookingReference', label: 'Booking', type: 'string', group: 'booking', default: true, requires: ['bk'], select: 'bk.reference' }, + { key: 'bookingStatus', label: 'Booking status', type: 'string', group: 'booking', requires: ['bk'], select: 'bk.status' }, + { key: 'bookingPaymentStatus', label: 'Booking payment status', type: 'string', group: 'booking', requires: ['bk'], select: 'bk.payment_status' }, + { key: 'bookingTradeDirection', label: 'Direction', type: 'string', group: 'booking', requires: ['bk'], select: 'bk.trade_direction' }, + { key: 'bookingFreightType', label: 'Freight type', type: 'string', group: 'booking', requires: ['bk'], select: 'bk.freight_type' }, + { key: 'bookingPnrCode', label: 'PNR code', type: 'string', group: 'booking', requires: ['bk'], select: 'bk.pnr_code' }, + + { key: 'customer', label: 'Customer', type: 'string', group: 'customer', default: true, requires: ['c'], select: 'c.name' }, + { key: 'customerTin', label: 'Customer TIN', type: 'string', group: 'customer', requires: ['c'], select: 'c.tin' }, + { key: 'customerPhone', label: 'Customer phone', type: 'string', group: 'customer', requires: ['c'], select: 'c.phone' }, + { key: 'customerEmail', label: 'Customer email', type: 'string', group: 'customer', requires: ['c'], select: 'c.email' }, + ], + + filters: [ + { key: 'created', label: 'Created', type: 'daterange' }, + { key: 'status', label: 'Status', type: 'select', options: [ + 'action-required', 'processing', 'success', 'failed', 'canceled', 'refunded', + ].map((v) => ({ value: v, label: v })) }, + { key: 'method', label: 'Method', type: 'select', options: [ + 'telebirr', 'cbe-birr', 'ebirr', 'waafi', 'card', 'dmoney', 'cac-bank', 'cbe-bill', + ].map((v) => ({ value: v, label: v })) }, + { key: 'currency', label: 'Currency', type: 'select', options: [ + { value: 'ETB', label: 'ETB' }, + { value: 'USD', label: 'USD' }, + ] }, + { key: 'search', label: 'Search order or transaction ID', type: 'text' }, + ], + + defaultSort: { key: 'createdAt', dir: 'DESC' }, + + scope(ctx, qb) { + const { params, directions } = ctx; + // No `p.deleted_at IS NULL` — this table has no soft-delete column. + if (params.createdFrom) qb.andWhere('p.created_at >= :createdFrom', { createdFrom: params.createdFrom }); + if (params.createdTo) qb.andWhere('p.created_at < :createdTo', { createdTo: params.createdTo }); + if (params.status) qb.andWhere('p.status = :status', { status: params.status }); + if (params.method) qb.andWhere('p.method = :method', { method: params.method }); + if (params.currency) qb.andWhere('p.currency = :currency', { currency: params.currency }); + if (params.search) { + qb.andWhere('(p.merchant_order_id ILIKE :search OR p.transaction_id ILIKE :search)', { + search: `%${params.search as string}%`, + }); + } + applyBookingRefDirectionScope(qb, 'p.ref_id', directions); + }, +}; diff --git a/apps/edr-freight-api/src/modules/exports/datasets/train-schedules.dataset.ts b/apps/edr-freight-api/src/modules/exports/datasets/train-schedules.dataset.ts new file mode 100644 index 000000000..5402e7917 --- /dev/null +++ b/apps/edr-freight-api/src/modules/exports/datasets/train-schedules.dataset.ts @@ -0,0 +1,127 @@ +import { FREIGHT_PERMS } from '../../../seed/freight-permissions.registry'; +import { Route } from '../../routes/entities/route.entity'; +import { Yard } from '../../rule-engine/entities/yard.entity'; +import { ShippingLineCompany } from '../../shipping-lines/entities/shipping-line-company.entity'; +import { TrainSchedule } from '../../train-schedules/entities/train-schedule.entity'; +import { applyDirectionScope } from '../../user-trade-access/trade-scope.util'; +import { ExportDataset } from '../export.types'; + +/** + * `freightType` is NOT a column on train_schedules — it is derived from the + * bookings aboard (the list service does this with an EXISTS subquery). Exposed + * here as an aggregate over those bookings, never as `sch.freight_type`. + */ +export const trainSchedulesDataset: ExportDataset = { + key: 'train-schedules', + title: 'Train schedules', + description: 'Scheduled trains with route, timings, booking window and load', + group: 'Operations', + permission: FREIGHT_PERMS.trainScheduling.view, + base: { entity: TrainSchedule, alias: 'sch' }, + + joins: [ + { alias: 'rt', entity: Route, on: 'rt.id = sch.route_id' }, + { alias: 'os', entity: Yard, on: 'os.id = sch.origin_station_id' }, + { alias: 'ds', entity: Yard, on: 'ds.id = sch.destination_station_id' }, + { alias: 'slc', entity: ShippingLineCompany, on: 'slc.id = sch.shipping_line_company_id' }, + ], + + groups: [ + { id: 'schedule', label: 'Schedule' }, + { id: 'route', label: 'Route' }, + { id: 'timings', label: 'Timings' }, + { id: 'window', label: 'Booking window' }, + { id: 'load', label: 'Load' }, + ], + + fields: [ + { key: 'reference', label: 'Reference', type: 'string', group: 'schedule', default: true, select: 'sch.reference', sortExpr: 'sch.reference' }, + { key: 'status', label: 'Status', type: 'string', group: 'schedule', default: true, select: 'sch.status', sortExpr: 'sch.status' }, + { key: 'trainNumber', label: 'Train number', type: 'string', group: 'schedule', default: true, select: 'sch.train_number' }, + { key: 'voyageNumber', label: 'Voyage number', type: 'string', group: 'schedule', select: 'sch.voyage_number' }, + { key: 'direction', label: 'Direction', type: 'string', group: 'schedule', default: true, select: 'sch.direction' }, + { key: 'shippingLineCompany', label: 'Shipping line company', type: 'string', group: 'schedule', requires: ['slc'], select: 'slc.name' }, + { key: 'bookingCycleNo', label: 'Booking cycle', type: 'number', group: 'schedule', select: 'sch.booking_cycle_no' }, + { key: 'reverseWagonOrder', label: 'Reverse wagon order', type: 'boolean', group: 'schedule', select: 'sch.reverse_wagon_order' }, + { key: 'createdAt', label: 'Created', type: 'date', group: 'schedule', select: `to_char(sch.created_at, 'YYYY-MM-DD')`, sortExpr: 'sch.created_at' }, + + { key: 'originStation', label: 'Origin', type: 'string', group: 'route', default: true, requires: ['os'], select: 'os.label' }, + { key: 'originStationCode', label: 'Origin code', type: 'string', group: 'route', requires: ['os'], select: 'os.code' }, + { key: 'destinationStation', label: 'Destination', type: 'string', group: 'route', default: true, requires: ['ds'], select: 'ds.label' }, + { key: 'destinationStationCode', label: 'Destination code', type: 'string', group: 'route', requires: ['ds'], select: 'ds.code' }, + { key: 'routeStatus', label: 'Route status', type: 'string', group: 'route', requires: ['rt'], select: 'rt.status' }, + + { key: 'scheduledDeparture', label: 'Scheduled departure', type: 'datetime', group: 'timings', default: true, select: `to_char(sch.scheduled_departure_date, 'YYYY-MM-DD HH24:MI')`, sortExpr: 'sch.scheduled_departure_date' }, + { key: 'scheduledArrival', label: 'Scheduled arrival', type: 'datetime', group: 'timings', default: true, select: `to_char(sch.scheduled_arrival_date, 'YYYY-MM-DD HH24:MI')` }, + { key: 'actualDeparture', label: 'Actual departure', type: 'datetime', group: 'timings', select: `to_char(sch.actual_departure_at, 'YYYY-MM-DD HH24:MI')` }, + { key: 'actualArrival', label: 'Actual arrival', type: 'datetime', group: 'timings', select: `to_char(sch.actual_arrival_at, 'YYYY-MM-DD HH24:MI')` }, + { + key: 'departureDelayHours', label: 'Departure delay (h)', type: 'number', group: 'timings', + select: `ROUND(EXTRACT(EPOCH FROM (sch.actual_departure_at - sch.scheduled_departure_date)) / 3600.0, 2)::float8`, + }, + { + key: 'transitHours', label: 'Transit time (h)', type: 'number', group: 'timings', + select: `ROUND(EXTRACT(EPOCH FROM (sch.actual_arrival_at - sch.actual_departure_at)) / 3600.0, 2)::float8`, + }, + + { key: 'bookingWindowStatus', label: 'Window status', type: 'string', group: 'window', select: 'sch.booking_window_status' }, + { key: 'windowPhase', label: 'Window phase', type: 'string', group: 'window', select: 'sch.window_phase' }, + { key: 'windowOpensAt', label: 'Window opens', type: 'datetime', group: 'window', select: `to_char(sch.window_opens_at, 'YYYY-MM-DD HH24:MI')` }, + { key: 'windowClosesAt', label: 'Window closes', type: 'datetime', group: 'window', select: `to_char(sch.window_closes_at, 'YYYY-MM-DD HH24:MI')` }, + { key: 'docReviewEndsAt', label: 'Doc review ends', type: 'datetime', group: 'window', select: `to_char(sch.doc_review_ends_at, 'YYYY-MM-DD HH24:MI')` }, + { key: 'paymentPhaseEndsAt', label: 'Payment phase ends', type: 'datetime', group: 'window', select: `to_char(sch.payment_phase_ends_at, 'YYYY-MM-DD HH24:MI')` }, + { key: 'windowRuleCustom', label: 'Custom window rules', type: 'boolean', group: 'window', select: 'sch.window_rule_custom' }, + + { key: 'maxWagons', label: 'Max wagons', type: 'number', group: 'load', select: 'sch.max_wagons' }, + { + key: 'bookingCount', label: 'Bookings', type: 'number', group: 'load', default: true, + select: `(SELECT COUNT(*)::int FROM freight.bookings b + WHERE b.train_schedule_id = sch.id AND b.deleted_at IS NULL)`, + }, + { + // Derived, not a column — see the file header. + key: 'freightTypes', label: 'Freight types', type: 'string', group: 'load', default: true, + select: `(SELECT string_agg(DISTINCT b.freight_type, ' | ') FROM freight.bookings b + WHERE b.train_schedule_id = sch.id AND b.deleted_at IS NULL)`, + }, + { + key: 'totalWeightTons', label: 'Total weight (t)', type: 'tons', group: 'load', default: true, + select: `(SELECT ROUND(COALESCE(SUM(COALESCE(b.bulk_total_weight_tons, b.cargo_total_weight_vgm)), 0))::float8 + FROM freight.bookings b + WHERE b.train_schedule_id = sch.id AND b.deleted_at IS NULL)`, + }, + { + key: 'assignedWagonCount', label: 'Wagons assigned', type: 'number', group: 'load', + select: `(SELECT COUNT(*)::int FROM freight.wagons w + WHERE w.current_train_schedule_id = sch.id AND w.deleted_at IS NULL)`, + }, + ], + + filters: [ + { key: 'departure', label: 'Departure', type: 'daterange' }, + { key: 'status', label: 'Status', type: 'text' }, + { key: 'direction', label: 'Direction', type: 'select', options: ['IMPORT', 'EXPORT', 'DOMESTIC'].map((v) => ({ value: v, label: v })) }, + { key: 'originStationId', label: 'Origin', type: 'text' }, + { key: 'destinationStationId', label: 'Destination', type: 'text' }, + { key: 'search', label: 'Search reference or train number', type: 'text' }, + ], + + defaultSort: { key: 'scheduledDeparture', dir: 'DESC' }, + + scope(ctx, qb) { + const { params, directions } = ctx; + qb.andWhere('sch.deleted_at IS NULL'); + if (params.departureFrom) qb.andWhere('sch.scheduled_departure_date >= :departureFrom', { departureFrom: params.departureFrom }); + if (params.departureTo) qb.andWhere('sch.scheduled_departure_date < :departureTo', { departureTo: params.departureTo }); + if (params.status) qb.andWhere('sch.status = :status', { status: params.status }); + if (params.direction) qb.andWhere('sch.direction = :direction', { direction: params.direction }); + if (params.originStationId) qb.andWhere('sch.origin_station_id = :originStationId', { originStationId: params.originStationId }); + if (params.destinationStationId) qb.andWhere('sch.destination_station_id = :destinationStationId', { destinationStationId: params.destinationStationId }); + if (params.search) { + qb.andWhere('(sch.reference ILIKE :search OR sch.train_number ILIKE :search)', { search: `%${params.search as string}%` }); + } + // Schedules carry their own `direction` column, so scope on that directly + // rather than through the bookings aboard. + applyDirectionScope(qb, 'sch.direction', directions); + }, +}; diff --git a/apps/edr-freight-api/src/modules/exports/datasets/trains.dataset.ts b/apps/edr-freight-api/src/modules/exports/datasets/trains.dataset.ts new file mode 100644 index 000000000..4122a245a --- /dev/null +++ b/apps/edr-freight-api/src/modules/exports/datasets/trains.dataset.ts @@ -0,0 +1,90 @@ +import { FREIGHT_PERMS } from '../../../seed/freight-permissions.registry'; +import { Route } from '../../routes/entities/route.entity'; +import { Yard } from '../../rule-engine/entities/yard.entity'; +import { Train } from '../../trains/entities/train.entity'; +import { ExportDataset } from '../export.types'; + +/** + * The list endpoint (`trains.service.ts findAll`) loads NO relations, so the + * UI shows raw FK uuids where names belong. This dataset resolves them, which + * makes it the clearest "the export shows more than the screen" case in the set. + */ +export const trainsDataset: ExportDataset = { + key: 'trains', + title: 'Trains', + description: 'Trains with route, stations, capacity and current composition', + group: 'Fleet', + permission: FREIGHT_PERMS.trains.view, + base: { entity: Train, alias: 't' }, + + joins: [ + { alias: 'y', entity: Yard, on: 'y.id = t.current_yard_id' }, + { alias: 'rt', entity: Route, on: 'rt.id = t.route_id' }, + { alias: 'os', entity: Yard, on: 'os.id = t.origin_station_id' }, + { alias: 'ds', entity: Yard, on: 'ds.id = t.destination_station_id' }, + ], + + groups: [ + { id: 'train', label: 'Train' }, + { id: 'route', label: 'Route & stations' }, + { id: 'capacity', label: 'Capacity' }, + { id: 'composition', label: 'Composition' }, + ], + + fields: [ + { key: 'code', label: 'Code', type: 'string', group: 'train', default: true, select: 't.code', sortExpr: 't.code' }, + { key: 'trainNumber', label: 'Train number', type: 'string', group: 'train', default: true, select: 't.train_number' }, + { key: 'trainName', label: 'Train name', type: 'string', group: 'train', default: true, select: 't.train_name' }, + { key: 'status', label: 'Status', type: 'string', group: 'train', default: true, select: 't.status', sortExpr: 't.status' }, + { key: 'importTrainNumber', label: 'Import run', type: 'string', group: 'train', select: 't.import_train_number' }, + { key: 'exportTrainNumber', label: 'Export run', type: 'string', group: 'train', select: 't.export_train_number' }, + { key: 'locomotiveNumber', label: 'Locomotive number', type: 'string', group: 'train', select: 't.locomotive_number' }, + { key: 'notes', label: 'Notes', type: 'string', group: 'train', select: 't.notes' }, + { key: 'remarks', label: 'Remarks', type: 'string', group: 'train', select: 't.remarks' }, + { key: 'createdAt', label: 'Added', type: 'date', group: 'train', select: `to_char(t.created_at, 'YYYY-MM-DD')`, sortExpr: 't.created_at' }, + + { key: 'currentYard', label: 'Current yard', type: 'string', group: 'route', default: true, requires: ['y'], select: 'y.label' }, + { key: 'currentYardCode', label: 'Current yard code', type: 'string', group: 'route', requires: ['y'], select: 'y.code' }, + { key: 'routeDirection', label: 'Route direction', type: 'string', group: 'route', requires: ['rt'], select: 'rt.direction' }, + { key: 'routeStatus', label: 'Route status', type: 'string', group: 'route', requires: ['rt'], select: 'rt.status' }, + { key: 'originStation', label: 'Origin station', type: 'string', group: 'route', requires: ['os'], select: 'os.label' }, + { key: 'destinationStation', label: 'Destination station', type: 'string', group: 'route', requires: ['ds'], select: 'ds.label' }, + { key: 'departureTime', label: 'Departure time', type: 'string', group: 'route', select: 't.departure_time::text' }, + { key: 'arrivalTime', label: 'Arrival time', type: 'string', group: 'route', select: 't.arrival_time::text' }, + + { key: 'capacityTons', label: 'Capacity (t)', type: 'tons', group: 'capacity', default: true, select: 't.capacity_tons::float8', sortExpr: 't.capacity_tons' }, + + { + key: 'wagonCount', label: 'Wagons attached', type: 'number', group: 'composition', default: true, + select: `(SELECT COUNT(*)::int FROM freight.wagons w + WHERE w.train_id = t.id AND w.deleted_at IS NULL)`, + }, + { + key: 'wagonNumbers', label: 'Wagon numbers', type: 'string', group: 'composition', + select: `(SELECT string_agg(w.wagon_number, ' | ' ORDER BY w.sequence_number NULLS LAST, w.wagon_number) + FROM freight.wagons w + WHERE w.train_id = t.id AND w.deleted_at IS NULL)`, + }, + ], + + filters: [ + { key: 'status', label: 'Status', type: 'text' }, + { key: 'currentYardId', label: 'Current yard', type: 'text' }, + { key: 'search', label: 'Search code, number or name', type: 'text' }, + ], + + defaultSort: { key: 'code', dir: 'ASC' }, + + scope(ctx, qb) { + const { params } = ctx; + qb.andWhere('t.deleted_at IS NULL'); + if (params.status) qb.andWhere('t.status = :status', { status: params.status }); + if (params.currentYardId) qb.andWhere('t.current_yard_id = :currentYardId', { currentYardId: params.currentYardId }); + if (params.search) { + qb.andWhere('(t.code ILIKE :search OR t.train_number ILIKE :search OR t.train_name ILIKE :search)', { + search: `%${params.search as string}%`, + }); + } + // Trains carry no trade direction — nothing to scope. Intentional. + }, +}; diff --git a/apps/edr-freight-api/src/modules/exports/datasets/wagons.dataset.ts b/apps/edr-freight-api/src/modules/exports/datasets/wagons.dataset.ts new file mode 100644 index 000000000..cce85bb88 --- /dev/null +++ b/apps/edr-freight-api/src/modules/exports/datasets/wagons.dataset.ts @@ -0,0 +1,110 @@ +import { FREIGHT_PERMS } from '../../../seed/freight-permissions.registry'; +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 { ExportDataset } from '../export.types'; + +/** + * Two traps this dataset works around: + * + * 1. Tare weight, payload and length live on WAGON_TYPE, not wagon — which is + * why they carry `requires: ['wt']` and their sortExpr points at `wt`. + * 2. `lastMaintenanceAt` / `lastAvailableAt` come from a grouped query over + * wagon_status_logs in the service (`attachStatusDates`). Ported here as + * correlated subqueries so they compose with everything else and cost + * nothing when unticked. Note the log columns are `from_status`/`to_status`, + * not `status`. + */ +export const wagonsDataset: ExportDataset = { + key: 'wagons', + title: 'Wagons', + description: 'Wagon fleet with type specs, location, train assignment and status history', + group: 'Fleet', + permission: FREIGHT_PERMS.wagons.view, + base: { entity: Wagon, alias: 'w' }, + + joins: [ + { alias: 'wt', entity: WagonType, on: 'wt.id = w.wagon_type_id' }, + { alias: 'y', entity: Yard, on: 'y.id = w.current_yard_id' }, + { alias: 't', entity: Train, on: 't.id = w.train_id' }, + { alias: 'sch', entity: TrainSchedule, on: 'sch.id = w.current_train_schedule_id' }, + ], + + groups: [ + { id: 'wagon', label: 'Wagon' }, + { id: 'type', label: 'Type & specs' }, + { id: 'location', label: 'Location' }, + { id: 'assignment', label: 'Assignment' }, + { id: 'history', label: 'Status history' }, + ], + + fields: [ + { key: 'wagonNumber', label: 'Wagon number', type: 'string', group: 'wagon', default: true, select: 'w.wagon_number', sortExpr: 'w.wagon_number' }, + { key: 'status', label: 'Status', type: 'string', group: 'wagon', default: true, select: 'w.status', sortExpr: 'w.status' }, + { key: 'sequenceNumber', label: 'Sequence no.', type: 'number', group: 'wagon', select: 'w.sequence_number' }, + { key: 'notes', label: 'Notes', type: 'string', group: 'wagon', select: 'w.notes' }, + { key: 'createdAt', label: 'Added', type: 'date', group: 'wagon', select: `to_char(w.created_at, 'YYYY-MM-DD')`, sortExpr: 'w.created_at' }, + + { key: 'wagonType', label: 'Type', type: 'string', group: 'type', default: true, requires: ['wt'], select: 'wt.name', sortExpr: 'wt.name' }, + { key: 'wagonTypeCode', label: 'Type code', type: 'string', group: 'type', requires: ['wt'], select: 'wt.code' }, + { key: 'capacityTons', label: 'Capacity (t)', type: 'tons', group: 'type', default: true, requires: ['wt'], select: 'wt.capacity_tons::float8', sortExpr: 'wt.capacity_tons' }, + { key: 'tareWeightTons', label: 'Tare weight (t)', type: 'tons', group: 'type', requires: ['wt'], select: 'wt.tare_weight_tons::float8' }, + { key: 'lengthMeters', label: 'Length (m)', type: 'number', group: 'type', requires: ['wt'], select: 'wt.length_meters::float8' }, + { key: 'equatedLengthM', label: 'Equated length (m)', type: 'number', group: 'type', requires: ['wt'], select: 'wt.equated_length_m::float8' }, + { key: 'maxContainerGrossT', label: 'Max container gross (t)', type: 'tons', group: 'type', requires: ['wt'], select: 'wt.max_container_gross_t::float8' }, + { key: 'supportsContainer', label: 'Supports container', type: 'boolean', group: 'type', requires: ['wt'], select: 'wt.supports_container' }, + { key: 'supportedLoadTypes', label: 'Supported load types', type: 'string', group: 'type', requires: ['wt'], select: 'wt.supported_load_types::text' }, + + { key: 'currentYard', label: 'Current yard', type: 'string', group: 'location', default: true, requires: ['y'], select: 'y.label' }, + { key: 'currentYardCode', label: 'Current yard code', type: 'string', group: 'location', requires: ['y'], select: 'y.code' }, + { key: 'currentYardCountry', label: 'Current yard country', type: 'string', group: 'location', requires: ['y'], select: 'y.country' }, + + { key: 'trainCode', label: 'Train', type: 'string', group: 'assignment', default: true, requires: ['t'], select: 't.code' }, + { key: 'trainNumber', label: 'Train number', type: 'string', group: 'assignment', requires: ['t'], select: 't.train_number' }, + { key: 'exportTrainNumber', label: 'Export run', type: 'string', group: 'assignment', select: 'w.export_train_number' }, + { key: 'importTrainNumber', label: 'Import run', type: 'string', group: 'assignment', select: 'w.import_train_number' }, + { key: 'scheduleReference', label: 'Current schedule', type: 'string', group: 'assignment', requires: ['sch'], select: 'sch.reference' }, + { key: 'scheduleDeparture', label: 'Schedule departure', type: 'date', group: 'assignment', requires: ['sch'], select: `to_char(sch.scheduled_departure_date, 'YYYY-MM-DD')` }, + + { + key: 'lastMaintenanceAt', label: 'Last maintenance', type: 'datetime', group: 'history', + select: `(SELECT to_char(MAX(l.created_at), 'YYYY-MM-DD HH24:MI') + FROM freight.wagon_status_logs l + WHERE l.wagon_id = w.id AND l.to_status = 'MAINTENANCE' AND l.deleted_at IS NULL)`, + }, + { + key: 'lastAvailableAt', label: 'Last available', type: 'datetime', group: 'history', + select: `(SELECT to_char(MAX(l.created_at), 'YYYY-MM-DD HH24:MI') + FROM freight.wagon_status_logs l + WHERE l.wagon_id = w.id AND l.to_status = 'AVAILABLE' AND l.deleted_at IS NULL)`, + }, + { + key: 'statusChangeCount', label: 'Status changes', type: 'number', group: 'history', + select: `(SELECT COUNT(*)::int FROM freight.wagon_status_logs l + WHERE l.wagon_id = w.id AND l.deleted_at IS NULL)`, + }, + ], + + filters: [ + { key: 'status', label: 'Status', type: 'text' }, + { key: 'wagonTypeId', label: 'Wagon type', type: 'text' }, + { key: 'currentYardId', label: 'Current yard', type: 'text' }, + { key: 'trainId', label: 'Train', type: 'text' }, + { key: 'search', label: 'Search wagon number', type: 'text' }, + ], + + defaultSort: { key: 'wagonNumber', dir: 'ASC' }, + + scope(ctx, qb) { + const { params } = ctx; + qb.andWhere('w.deleted_at IS NULL'); + if (params.status) qb.andWhere('w.status = :status', { status: params.status }); + if (params.wagonTypeId) qb.andWhere('w.wagon_type_id = :wagonTypeId', { wagonTypeId: params.wagonTypeId }); + if (params.currentYardId) qb.andWhere('w.current_yard_id = :currentYardId', { currentYardId: params.currentYardId }); + if (params.trainId) qb.andWhere('w.train_id = :trainId', { trainId: params.trainId }); + if (params.search) qb.andWhere('w.wagon_number ILIKE :search', { search: `%${params.search as string}%` }); + // Wagons carry no trade direction — nothing to scope. Intentional. + }, +}; diff --git a/apps/edr-freight-api/src/modules/exports/export.registry.ts b/apps/edr-freight-api/src/modules/exports/export.registry.ts index dc7a3c968..1b7f24c13 100644 --- a/apps/edr-freight-api/src/modules/exports/export.registry.ts +++ b/apps/edr-freight-api/src/modules/exports/export.registry.ts @@ -1,4 +1,12 @@ import { bookingsDataset } from './datasets/bookings.dataset'; +import { contractsDataset } from './datasets/contracts.dataset'; +import { customersDataset } from './datasets/customers.dataset'; +import { invoicesDataset } from './datasets/invoices.dataset'; +import { locomotivesDataset } from './datasets/locomotives.dataset'; +import { paymentsDataset } from './datasets/payments.dataset'; +import { trainSchedulesDataset } from './datasets/train-schedules.dataset'; +import { trainsDataset } from './datasets/trains.dataset'; +import { wagonsDataset } from './datasets/wagons.dataset'; import { ExportDataset } from './export.types'; /** @@ -8,7 +16,17 @@ import { ExportDataset } from './export.types'; * no route, no permission seed — the dialog is driven entirely by the catalog * this registry serves, and a dataset reuses its module's existing `view` key. */ -export const DATASETS: ExportDataset[] = [bookingsDataset]; +export const DATASETS: ExportDataset[] = [ + bookingsDataset, + contractsDataset, + customersDataset, + invoicesDataset, + paymentsDataset, + trainSchedulesDataset, + locomotivesDataset, + trainsDataset, + wagonsDataset, +]; const BY_KEY = new Map(DATASETS.map((d) => [d.key, d]));