diff --git a/EDR-Freight-User-Guide.pdf b/EDR-Freight-User-Guide.pdf new file mode 100644 index 000000000..66512875f Binary files /dev/null and b/EDR-Freight-User-Guide.pdf differ diff --git a/apps/edr-freight-api/src/common/booking-guards.ts b/apps/edr-freight-api/src/common/booking-guards.ts index f9eab4d39..68bbdcba4 100644 --- a/apps/edr-freight-api/src/common/booking-guards.ts +++ b/apps/edr-freight-api/src/common/booking-guards.ts @@ -75,7 +75,7 @@ export const TrainSchedulingView = () => BookingStaff(FREIGHT_PERMS.trainScheduling.view); // Granular train-scheduling actions replace the retired coarse manage: -// create a schedule, update (assign/consist/loading/finalize/dispatch/arrive…), +// create a schedule, update (assign/consist/finalize/dispatch/arrive…), // cancel a schedule, reschedule (+ maintenance), and manage global rules. export const TrainSchedulingCreate = () => BookingStaff(FREIGHT_PERMS.trainScheduling.create); @@ -83,6 +83,18 @@ export const TrainSchedulingCreate = () => export const TrainSchedulingUpdate = () => BookingStaff(FREIGHT_PERMS.trainScheduling.update); +/** + * Confirm a booking's cargo loaded/unloaded at a yard — carved out of the + * coarse `update` so it can be granted independently of general schedule + * editing. Same two keys gate import, export, and intercity movements alike: + * the generic per-booking route and the intercity-specific one both use them. + */ +export const TrainSchedulingLoad = () => + BookingStaff(FREIGHT_PERMS.trainScheduling.load); + +export const TrainSchedulingUnload = () => + BookingStaff(FREIGHT_PERMS.trainScheduling.unload); + export const TrainSchedulingCancel = () => BookingStaff(FREIGHT_PERMS.trainScheduling.cancel); diff --git a/apps/edr-freight-api/src/migrations/3650000000000-AdditionalChargeDueAt.ts b/apps/edr-freight-api/src/migrations/3650000000000-AdditionalChargeDueAt.ts new file mode 100644 index 000000000..8dcda8181 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3650000000000-AdditionalChargeDueAt.ts @@ -0,0 +1,19 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** Optional payment due date finance can set on an additional charge. */ +export class AdditionalChargeDueAt3650000000000 implements MigrationInterface { + name = 'AdditionalChargeDueAt3650000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "freight"."additional_charge" + ADD COLUMN IF NOT EXISTS "due_at" timestamptz + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "freight"."additional_charge" DROP COLUMN IF EXISTS "due_at" + `); + } +} diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.ts b/apps/edr-freight-api/src/modules/billing/billing.service.ts index 19f7c0a6e..39c5a36ad 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -32,6 +32,7 @@ import { InvoiceDocumentService, pngDataUrl, } from "./documents/invoice-document.service"; +import { INVOICE_SORT_COLUMNS } from "./dto/filter-invoice.dto"; import { InvoiceLine } from "./entities/invoice-line.entity"; import { Invoice, InvoicePayment } from "./entities/invoice.entity"; import { InvoiceLineRepository } from "./invoice-line.repository"; @@ -99,6 +100,31 @@ export interface RecordPaymentInput { } /** Default invoice payment-term window, in days, used to compute `dueAt`. */ +/** + * Every dimension the backoffice invoice list narrows by. `findAllPaginated` + * and `collectedSummary` share it so the summary card can never total a + * different set of invoices than the table below it shows. + */ +export interface InvoiceListFilters { + companyId?: string; + status?: Freight.InvoiceStatus; + statuses?: Freight.InvoiceStatus[]; + sources?: string[]; + eimsStatuses?: string[]; + currency?: string; + search?: string; + issuedFrom?: string; + issuedTo?: string; + dueFrom?: string; + dueTo?: string; + minAmount?: number; + maxAmount?: number; + hasBalance?: boolean; + overdue?: boolean; + /** Per-user trade-direction scope, applied via the source booking. */ + tradeDirections?: string[]; +} + const DEFAULT_DUE_DAYS = 14; /** Statuses an invoice can still be settled (paid/refunded/cancelled) from. */ @@ -245,12 +271,7 @@ export class BillingService { /** Same list filters `findAllPaginated` and `collectedSummary` both narrow by. */ private applyInvoiceFilters( qb: SelectQueryBuilder, - filter: { - companyId?: string; - status?: Freight.InvoiceStatus; - search?: string; - tradeDirections?: string[]; - }, + filter: InvoiceListFilters, ) { if (filter.companyId) { qb.andWhere("invoice.companyId = :companyId", { @@ -260,6 +281,57 @@ export class BillingService { if (filter.status) { qb.andWhere("invoice.status = :status", { status: filter.status }); } + if (filter.statuses?.length) { + qb.andWhere("invoice.status IN (:...statuses)", { + statuses: filter.statuses, + }); + } + if (filter.sources?.length) { + qb.andWhere("invoice.source IN (:...sources)", { sources: filter.sources }); + } + if (filter.eimsStatuses?.length) { + qb.andWhere("invoice.eimsStatus IN (:...eimsStatuses)", { + eimsStatuses: filter.eimsStatuses, + }); + } + if (filter.currency) { + // Stored casing has drifted ("usd" rows exist) — compare normalised. + qb.andWhere("UPPER(invoice.currency) = :currency", { + currency: filter.currency.toUpperCase(), + }); + } + if (filter.issuedFrom) { + qb.andWhere("invoice.issuedAt >= :issuedFrom", { + issuedFrom: filter.issuedFrom, + }); + } + if (filter.issuedTo) { + qb.andWhere("invoice.issuedAt <= :issuedTo", { issuedTo: filter.issuedTo }); + } + if (filter.dueFrom) { + qb.andWhere("invoice.dueAt >= :dueFrom", { dueFrom: filter.dueFrom }); + } + if (filter.dueTo) { + qb.andWhere("invoice.dueAt <= :dueTo", { dueTo: filter.dueTo }); + } + if (filter.minAmount !== undefined) { + qb.andWhere("invoice.totalAmount >= :minAmount", { + minAmount: filter.minAmount, + }); + } + if (filter.maxAmount !== undefined) { + qb.andWhere("invoice.totalAmount <= :maxAmount", { + maxAmount: filter.maxAmount, + }); + } + if (filter.hasBalance) { + qb.andWhere("invoice.balanceAmount > 0"); + } + if (filter.overdue) { + // Computed, not `status = OVERDUE`: nothing sweeps PENDING rows into + // that status, so reading the column alone under-reports the arrears. + qb.andWhere("invoice.balanceAmount > 0 AND invoice.dueAt < now()"); + } if (filter.search) { // Searches what the row actually shows: its number, who it bills, and // the source record behind it (booking reference, GRN, shipping line). @@ -301,14 +373,11 @@ export class BillingService { } async findAllPaginated( - filter: { - companyId?: string; - status?: Freight.InvoiceStatus; - search?: string; + filter: InvoiceListFilters & { page?: number; pageSize?: number; - /** Per-user trade-direction scope, applied via the source booking. */ - tradeDirections?: string[]; + sortBy?: string; + sortOrder?: "ASC" | "DESC"; } = {}, ): Promise<{ items: InvoiceListRow[]; total: number }> { const page = filter.page && filter.page > 0 ? filter.page : 1; @@ -319,7 +388,14 @@ export class BillingService { .getRepository(Invoice) .createQueryBuilder("invoice") .leftJoinAndSelect("invoice.company", "company") - .orderBy("invoice.issuedAt", "DESC") + // sortBy is whitelisted through INVOICE_SORT_COLUMNS, never interpolated + // raw. The id tiebreaker keeps paging stable when the sort column ties + // (issuedAt is null on every DRAFT row). + .orderBy( + INVOICE_SORT_COLUMNS[filter.sortBy ?? ""] ?? "invoice.issuedAt", + filter.sortOrder ?? "DESC", + ) + .addOrderBy("invoice.id", "ASC") .skip((page - 1) * pageSize) .take(pageSize); @@ -460,12 +536,7 @@ export class BillingService { * visible page. */ async collectedSummary( - filter: { - companyId?: string; - status?: Freight.InvoiceStatus; - search?: string; - tradeDirections?: string[]; - } = {}, + filter: InvoiceListFilters = {}, ): Promise> { const qb = this.dataSource .getRepository(Invoice) diff --git a/apps/edr-freight-api/src/modules/billing/dto/filter-invoice.dto.spec.ts b/apps/edr-freight-api/src/modules/billing/dto/filter-invoice.dto.spec.ts new file mode 100644 index 000000000..55e6b19d2 --- /dev/null +++ b/apps/edr-freight-api/src/modules/billing/dto/filter-invoice.dto.spec.ts @@ -0,0 +1,53 @@ +import { plainToInstance } from "class-transformer"; +import { validateSync } from "class-validator"; + +import { FilterInvoiceDto } from "./filter-invoice.dto"; + +/** + * The list endpoint runs under `forbidNonWhitelisted`, so every param the + * backoffice filter bar sends has to survive transform + validation here or + * the whole request 400s. The CSV filters are the fragile part: they arrive as + * one string and must come out as a validated array. + */ +const parse = (query: Record) => { + const dto = plainToInstance(FilterInvoiceDto, query); + return { dto, errors: validateSync(dto).map((e) => e.property) }; +}; + +describe("FilterInvoiceDto", () => { + it("accepts the full filter-bar query and splits the CSV filters", () => { + const { dto, errors } = parse({ + page: "2", + pageSize: "10", + search: "INV-2026", + statuses: "PENDING,OVERDUE", + sources: "booking,warehouse", + eimsStatuses: "NOT_SUBMITTED", + currency: "etb", + issuedFrom: "2026-08-01T00:00:00.000Z", + issuedTo: "2026-08-20T20:59:59.999Z", + dueFrom: "2026-08-01T00:00:00.000Z", + dueTo: "2026-09-01T20:59:59.999Z", + minAmount: "100", + maxAmount: "5000", + hasBalance: "true", + overdue: "false", + sortBy: "balanceAmount", + sortOrder: "asc", + }); + + expect(errors).toEqual([]); + expect(dto.statuses).toEqual(["PENDING", "OVERDUE"]); + expect(dto.sources).toEqual(["booking", "warehouse"]); + expect(dto.currency).toBe("ETB"); + expect(dto.minAmount).toBe(100); + expect(dto.hasBalance).toBe(true); + expect(dto.overdue).toBe(false); + expect(dto.sortOrder).toBe("ASC"); + }); + + it("rejects a value outside the enum and an unsortable column", () => { + expect(parse({ statuses: "PENDING,NOT_A_STATUS" }).errors).toEqual(["statuses"]); + expect(parse({ sortBy: "eimsIrn" }).errors).toEqual(["sortBy"]); + }); +}); diff --git a/apps/edr-freight-api/src/modules/billing/dto/filter-invoice.dto.ts b/apps/edr-freight-api/src/modules/billing/dto/filter-invoice.dto.ts index 91327946c..aec6e4ac0 100644 --- a/apps/edr-freight-api/src/modules/billing/dto/filter-invoice.dto.ts +++ b/apps/edr-freight-api/src/modules/billing/dto/filter-invoice.dto.ts @@ -2,14 +2,43 @@ import { Freight } from "@edr/types"; import { ApiPropertyOptional } from "@nestjs/swagger"; import { Transform } from "class-transformer"; import { + IsArray, + IsBoolean, + IsDateString, IsIn, IsInt, + IsNumber, IsOptional, IsString, IsUUID, Min, } from "class-validator"; +import { EimsInvoiceStatus } from "../../eims/eims-registration.types"; + +/** Columns the invoice list may be ordered by -> their query-builder expression. */ +export const INVOICE_SORT_COLUMNS: Record = { + issuedAt: "invoice.issuedAt", + dueAt: "invoice.dueAt", + createdAt: "invoice.createdAt", + totalAmount: "invoice.totalAmount", + balanceAmount: "invoice.balanceAmount", + invoiceNumber: "invoice.invoiceNumber", +}; + +/** `?statuses=A,B` -> `["A","B"]`. A bare value stays a one-element list. */ +const csv = ({ value }: { value: unknown }) => + typeof value === "string" + ? value + .split(",") + .map((v) => v.trim()) + .filter(Boolean) + : value; + +const bool = ({ value }: { value: unknown }) => value === "true" || value === true; + +const num = ({ value }: { value: unknown }) => Number(value); + export class FilterInvoiceDto { @ApiPropertyOptional({ default: 1 }) @IsOptional() @@ -40,10 +69,97 @@ export class FilterInvoiceDto { @IsIn(Object.values(Freight.InvoiceStatus)) status?: Freight.InvoiceStatus; - /** Manual-payments worklist only: restrict to one currency. */ + /** + * Multi-select status (`?statuses=PENDING,OVERDUE`). ANDed with `status` + * when both are sent, so the single-status worklists keep their meaning. + */ + @ApiPropertyOptional({ isArray: true, enum: Freight.InvoiceStatus }) + @IsOptional() + @Transform(csv) + @IsArray() + @IsIn(Object.values(Freight.InvoiceStatus), { each: true }) + statuses?: Freight.InvoiceStatus[]; + + /** Originating subsystem (`booking`, `warehouse`, `shipping_line_credit`, …). */ + @ApiPropertyOptional({ isArray: true, enum: Freight.InvoiceSource }) + @IsOptional() + @Transform(csv) + @IsArray() + @IsIn(Object.values(Freight.InvoiceSource), { each: true }) + sources?: Freight.InvoiceSource[]; + + /** MoR filing state — Finance's "what still needs registering" cut. */ + @ApiPropertyOptional({ isArray: true, enum: EimsInvoiceStatus }) + @IsOptional() + @Transform(csv) + @IsArray() + @IsIn(Object.values(EimsInvoiceStatus), { each: true }) + eimsStatuses?: EimsInvoiceStatus[]; + + /** Manual-payments worklist and the invoice list: restrict to one currency. */ @ApiPropertyOptional({ enum: ["USD", "ETB"] }) @IsOptional() @Transform(({ value }: { value: unknown }) => String(value).toUpperCase()) @IsIn(["USD", "ETB"]) currency?: "USD" | "ETB"; + + @ApiPropertyOptional({ description: "Issued at or after this instant (ISO)." }) + @IsOptional() + @IsDateString() + issuedFrom?: string; + + @ApiPropertyOptional({ description: "Issued at or before this instant (ISO)." }) + @IsOptional() + @IsDateString() + issuedTo?: string; + + @ApiPropertyOptional({ description: "Due at or after this instant (ISO)." }) + @IsOptional() + @IsDateString() + dueFrom?: string; + + @ApiPropertyOptional({ description: "Due at or before this instant (ISO)." }) + @IsOptional() + @IsDateString() + dueTo?: string; + + /** Total amount bounds, in the invoice's own currency — pair with `currency`. */ + @ApiPropertyOptional() + @IsOptional() + @Transform(num) + @IsNumber() + minAmount?: number; + + @ApiPropertyOptional() + @IsOptional() + @Transform(num) + @IsNumber() + maxAmount?: number; + + @ApiPropertyOptional({ description: "Only invoices with an outstanding balance." }) + @IsOptional() + @Transform(bool) + @IsBoolean() + hasBalance?: boolean; + + /** + * Outstanding AND past its due date, computed rather than read off `status`: + * nothing sweeps PENDING rows into OVERDUE, so the status alone under-reports. + */ + @ApiPropertyOptional({ description: "Only invoices outstanding past their due date." }) + @IsOptional() + @Transform(bool) + @IsBoolean() + overdue?: boolean; + + @ApiPropertyOptional({ enum: Object.keys(INVOICE_SORT_COLUMNS), default: "issuedAt" }) + @IsOptional() + @IsIn(Object.keys(INVOICE_SORT_COLUMNS)) + sortBy?: string; + + @ApiPropertyOptional({ enum: ["ASC", "DESC"], default: "DESC" }) + @IsOptional() + @Transform(({ value }: { value: unknown }) => String(value).toUpperCase()) + @IsIn(["ASC", "DESC"]) + sortOrder?: "ASC" | "DESC"; } diff --git a/apps/edr-freight-api/src/modules/bookings/additional-charge.service.ts b/apps/edr-freight-api/src/modules/bookings/additional-charge.service.ts index bb5836d1a..012d5f8db 100644 --- a/apps/edr-freight-api/src/modules/bookings/additional-charge.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/additional-charge.service.ts @@ -1,6 +1,7 @@ import { ConflictException, Injectable, Logger, NotFoundException } from '@nestjs/common'; import { OnEvent } from '@nestjs/event-emitter'; import { DataSource, EntityManager } from 'typeorm'; +import { ExchangeService } from '@edr/api-common'; import { Freight, NotificationAudience, NotificationType } from '@edr/types'; import { BillingService, InvoiceEventPayload } from '../billing/billing.service'; @@ -35,6 +36,7 @@ export class AdditionalChargeService { private readonly repository: AdditionalChargeRepository, private readonly bookingsRepository: BookingsRepository, private readonly filesService: FilesService, + private readonly exchangeService: ExchangeService, private readonly billing: BillingService, private readonly bookingsService: BookingsService, private readonly notifications: NotificationsService, @@ -74,6 +76,7 @@ export class AdditionalChargeService { reason: dto.reason.trim(), amount: dto.amount.toFixed(2), currency: dto.currency.trim().toUpperCase(), + dueAt: dto.dueDate ? new Date(dto.dueDate) : null, status: 'DRAFT', createdByStaffId: staffId, }), @@ -132,6 +135,8 @@ export class AdditionalChargeService { companyId: booking.companyId, companyProfileId: booking.companyProfileId, currency: charge.currency, + // Unset falls through to BillingService's own DEFAULT_DUE_DAYS (14). + dueAt: charge.dueAt ?? undefined, lines: [ { chargeType: 'ADDITIONAL_CHARGE', @@ -254,9 +259,12 @@ export class AdditionalChargeService { ? await this.dataSource.getRepository(Invoice).find({ where: invoiceIds.map((id) => ({ id })) }) : []; const invoiceById = new Map(invoices.map((i) => [i.id, i])); + const converted = await Promise.all(rows.map((r) => this.convertAmount(r))); + const convertedById = new Map(rows.map((r, i) => [r.id, converted[i]])); return rows.map((r) => { const file = filesByCharge.get(r.id)?.[0]; + const fx = convertedById.get(r.id) ?? null; return { id: r.id, bookingId: r.bookingId, @@ -264,6 +272,9 @@ export class AdditionalChargeService { status: r.status, amount: Number(r.amount), currency: r.currency, + convertedAmount: fx?.amount ?? null, + convertedCurrency: fx?.currency ?? null, + dueAt: r.dueAt?.toISOString() ?? null, file: file ? { id: file.id, name: file.name, url: file.url } : null, invoiceId: r.invoiceId ?? null, invoiceNumber: r.invoiceId ? (invoiceById.get(r.invoiceId)?.invoiceNumber ?? null) : null, @@ -278,4 +289,26 @@ export class AdditionalChargeService { }; }); } + + /** + * Amount converted to the other of ETB/USD, via the existing shared + * `ExchangeService` (CBE rate, falls back to the stored `exchange_settings` + * rate) — same mechanism `booking-wagon-cancellation.service.ts` and + * warehouse fee pricing already use. Null on anything but ETB/USD, or if + * the rate feed is down — this is a display convenience, not the payable + * amount, so a failure here must never break the charge list. + */ + private async convertAmount( + charge: AdditionalCharge, + ): Promise<{ amount: number; currency: string } | null> { + if (charge.currency !== 'ETB' && charge.currency !== 'USD') return null; + const target = charge.currency === 'ETB' ? 'USD' : 'ETB'; + try { + const amount = await this.exchangeService.convert(Number(charge.amount), charge.currency, target); + return { amount: Math.round(amount * 100) / 100, currency: target }; + } catch (err) { + this.logger.warn(`Rate conversion failed for charge ${charge.id}: ${(err as Error).message}`); + return null; + } + } } diff --git a/apps/edr-freight-api/src/modules/bookings/dto/additional-charge.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/additional-charge.dto.ts index eb4d095bf..baf135876 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/additional-charge.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/additional-charge.dto.ts @@ -1,6 +1,14 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Type } from 'class-transformer'; -import { IsIn, IsNumber, IsOptional, IsPositive, IsString, Length } from 'class-validator'; +import { + IsDateString, + IsIn, + IsNumber, + IsOptional, + IsPositive, + IsString, + Length, +} from 'class-validator'; export class CreateAdditionalChargeDto { @ApiProperty({ example: 'Re-weighing fee at Mojo dry port' }) @@ -24,6 +32,12 @@ export class CreateAdditionalChargeDto { @IsOptional() @IsIn(['draft', 'send']) action?: 'draft' | 'send'; + + /** Payment due date; omit to fall back to the invoice's own default term (14 days) on send. */ + @ApiPropertyOptional({ example: '2026-09-01' }) + @IsOptional() + @IsDateString() + dueDate?: string; } export class CancelAdditionalChargeDto { diff --git a/apps/edr-freight-api/src/modules/bookings/entities/additional-charge.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/additional-charge.entity.ts index 11ea0bdb1..ba29f0118 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/additional-charge.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/additional-charge.entity.ts @@ -39,6 +39,10 @@ export class AdditionalCharge extends BaseEntity { @Column({ name: 'currency', type: 'varchar', length: 8 }) currency!: string; + /** Optional payment due date; unset falls back to the invoice's own default term on send. */ + @Column({ name: 'due_at', type: 'timestamptz', nullable: true }) + dueAt?: Date | null; + /** The supporting attachment (FileRecord), if any. */ @Column({ name: 'file_record_id', type: 'uuid', nullable: true }) fileRecordId?: string | null; diff --git a/apps/edr-freight-api/src/modules/companies/companies.repository.ts b/apps/edr-freight-api/src/modules/companies/companies.repository.ts index 092ebc2c4..17e3631e0 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.repository.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.repository.ts @@ -3,6 +3,10 @@ import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { BaseRepository } from '@edr/api-common'; import { Company } from './entities/company.entity'; +import { + companyDraftSql, + companyPendingChangeRequestSql, +} from './company-scope.sql'; import { ListCompaniesQueryDto } from './dto/list-companies-query.dto'; import { CompanyStatsResponseDto } from './dto/company-stats-response.dto'; @@ -15,31 +19,10 @@ export class CompaniesRepository extends BaseRepository { * placeholder name + TIN, so it must not be offered up for review. * Staff-created companies have no external profiles and are never drafts. */ - private static readonly DRAFT_SQL = `( - EXISTS ( - SELECT 1 FROM freight.external_profiles ep - WHERE ep.company_id = company.id - AND ep.deleted_at IS NULL - ) - AND NOT EXISTS ( - SELECT 1 FROM freight.external_profiles ep - WHERE ep.company_id = company.id - AND ep.deleted_at IS NULL - AND ep.onboarding_completed = true - ) - )`; + private static readonly DRAFT_SQL = companyDraftSql('company'); - /** - * A company waiting on a reviewer to decide an edit it submitted after being - * approved. These rows are `status = active`, so the pending-application filter - * can never surface them — the review queue needs its own predicate. - */ - private static readonly PENDING_CHANGE_REQUEST_SQL = `EXISTS ( - SELECT 1 FROM freight.company_change_request ccr - WHERE ccr.company_id = company.id - AND ccr.status = 'pending' - AND ccr.deleted_at IS NULL - )`; + private static readonly PENDING_CHANGE_REQUEST_SQL = + companyPendingChangeRequestSql('company'); /** * The `sortBy = 'review'` queue ordering: whatever marketing must act on @@ -96,6 +79,9 @@ export class CompaniesRepository extends BaseRepository { type, kind, status, + nationality, + createdFrom, + createdTo, onboardingCompleted, hasPendingChangeRequest, sortBy = 'review', @@ -122,6 +108,18 @@ export class CompaniesRepository extends BaseRepository { qb.andWhere('company.status = :status', { status }); } + if (nationality) { + qb.andWhere('company.nationality = :nationality', { nationality }); + } + + if (createdFrom) { + qb.andWhere('company.createdAt >= :createdFrom', { createdFrom }); + } + + if (createdTo) { + qb.andWhere('company.createdAt <= :createdTo', { createdTo }); + } + if (onboardingCompleted !== undefined) { qb.andWhere( onboardingCompleted diff --git a/apps/edr-freight-api/src/modules/companies/company-scope.sql.ts b/apps/edr-freight-api/src/modules/companies/company-scope.sql.ts new file mode 100644 index 000000000..df7a0bc7e --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/company-scope.sql.ts @@ -0,0 +1,40 @@ +/** + * Two predicates that define a customer's review state but are NOT columns on + * `companies`. Shared verbatim by the list repository and the export dataset — + * the backoffice offers both as one Status filter, so an export that computed + * "onboarding draft" differently from the list would quietly disagree with the + * screen it was launched from. + * + * Each takes the query's table alias because the two callers use different + * ones (`company` in the repository, `c` in the dataset). + */ + +/** + * Still in the portal onboarding wizard: has at least one external profile, + * none of them submitted. Such a row exists from the wizard's first click, so + * it must be excluded from the awaiting-approval queue. + */ +export const companyDraftSql = (alias: string): string => `( + EXISTS ( + SELECT 1 FROM freight.external_profiles ep + WHERE ep.company_id = ${alias}.id + AND ep.deleted_at IS NULL + ) + AND NOT EXISTS ( + SELECT 1 FROM freight.external_profiles ep + WHERE ep.company_id = ${alias}.id + AND ep.deleted_at IS NULL + AND ep.onboarding_completed = true + ) + )`; + +/** + * An already-approved customer who edited their profile: they stay + * `status = active`, so no status filter can ever surface them. + */ +export const companyPendingChangeRequestSql = (alias: string): string => `EXISTS ( + SELECT 1 FROM freight.company_change_request ccr + WHERE ccr.company_id = ${alias}.id + AND ccr.status = 'pending' + AND ccr.deleted_at IS NULL + )`; diff --git a/apps/edr-freight-api/src/modules/companies/dto/list-companies-query.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/list-companies-query.dto.ts index ffb600e36..18b816a2a 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/list-companies-query.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/list-companies-query.dto.ts @@ -1,7 +1,20 @@ import { ApiPropertyOptional } from "@nestjs/swagger"; -import { IsBoolean, IsIn, IsInt, IsOptional, IsString, Min } from "class-validator"; +import { + IsBoolean, + IsDateString, + IsIn, + IsInt, + IsOptional, + IsString, + Min, +} from "class-validator"; import { Transform } from "class-transformer"; -import { CompanyKind, CompanyStatus, CompanyType } from "../entities/company.entity"; +import { + CompanyKind, + CompanyNationality, + CompanyStatus, + CompanyType, +} from "../entities/company.entity"; export class ListCompaniesQueryDto { @ApiPropertyOptional({ default: 1 }) @@ -38,6 +51,21 @@ export class ListCompaniesQueryDto { @IsIn(Object.values(CompanyStatus)) status?: CompanyStatus; + @ApiPropertyOptional({ enum: CompanyNationality }) + @IsOptional() + @IsIn(Object.values(CompanyNationality)) + nationality?: CompanyNationality; + + @ApiPropertyOptional({ description: "Registered on or after this instant (ISO)." }) + @IsOptional() + @IsDateString() + createdFrom?: string; + + @ApiPropertyOptional({ description: "Registered on or before this instant (ISO)." }) + @IsOptional() + @IsDateString() + createdTo?: string; + @ApiPropertyOptional({ description: "Filter by onboarding submission. `true` = reviewable applications; " + diff --git a/apps/edr-freight-api/src/modules/exports/datasets/bookings.dataset.ts b/apps/edr-freight-api/src/modules/exports/datasets/bookings.dataset.ts index 1e2cc9098..c9f2a7d21 100644 --- a/apps/edr-freight-api/src/modules/exports/datasets/bookings.dataset.ts +++ b/apps/edr-freight-api/src/modules/exports/datasets/bookings.dataset.ts @@ -13,7 +13,7 @@ import { applyDirectionScope } from '../../user-trade-access/trade-scope.util'; import { ExportDataset } from '../export.types'; /** - * Domain semantics shared with `reports/definitions/bookings-list.report.ts`. + * Domain semantics that the retired `bookings-list` report used to share. * Kept identical on purpose — for PER_ITEM bulk bookings `cargo_total_weight_vgm` * holds an item COUNT, not tonnage, and `adjusted_total_amount` silently * overrides `total_amount`. Getting either wrong misreports money or weight. 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 index 7a01ae048..a840f54a0 100644 --- a/apps/edr-freight-api/src/modules/exports/datasets/customers.dataset.ts +++ b/apps/edr-freight-api/src/modules/exports/datasets/customers.dataset.ts @@ -1,5 +1,9 @@ import { FREIGHT_PERMS } from '../../../seed/freight-permissions.registry'; import { Company } from '../../companies/entities/company.entity'; +import { + companyDraftSql, + companyPendingChangeRequestSql, +} from '../../companies/company-scope.sql'; import { ExportDataset } from '../export.types'; /** @@ -114,6 +118,21 @@ export const customersDataset: ExportDataset = { { value: 'government', label: 'Government' }, ] }, { key: 'status', label: 'Status', type: 'text' }, + { key: 'nationality', label: 'Nationality', type: 'select', options: [ + { value: 'ethiopian', label: 'Ethiopian' }, + { value: 'foreign', label: 'Foreign' }, + ] }, + // The list's Status filter folds the review queues in, and sends these two + // alongside `status`. They are predicates, not columns — see + // `company-scope.sql.ts`, shared with the list so both agree exactly. + { key: 'onboardingCompleted', label: 'Onboarding submitted', type: 'select', options: [ + { value: 'true', label: 'Submitted' }, + { value: 'false', label: 'Still a draft' }, + ] }, + { key: 'hasPendingChangeRequest', label: 'Pending profile changes', type: 'select', options: [ + { value: 'true', label: 'Awaiting review' }, + { value: 'false', label: 'None open' }, + ] }, { key: 'search', label: 'Search name, TIN or email', type: 'text' }, ], @@ -127,6 +146,15 @@ export const customersDataset: ExportDataset = { 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.nationality) qb.andWhere('c.nationality = :nationality', { nationality: params.nationality }); + if (params.onboardingCompleted) { + const draft = companyDraftSql('c'); + qb.andWhere(params.onboardingCompleted === 'true' ? `NOT ${draft}` : draft); + } + if (params.hasPendingChangeRequest) { + const pending = companyPendingChangeRequestSql('c'); + qb.andWhere(params.hasPendingChangeRequest === 'true' ? pending : `NOT ${pending}`); + } if (params.search) { qb.andWhere('(c.name ILIKE :search OR c.tin ILIKE :search OR c.email ILIKE :search)', { search: `%${params.search as string}%`, 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 index 5eb0986b5..67f2207e9 100644 --- a/apps/edr-freight-api/src/modules/exports/datasets/invoices.dataset.ts +++ b/apps/edr-freight-api/src/modules/exports/datasets/invoices.dataset.ts @@ -97,14 +97,21 @@ export const invoicesDataset: ExportDataset = { filters: [ { key: 'issued', label: 'Issued', type: 'daterange' }, + { key: 'due', label: 'Due', type: 'daterange' }, { key: 'statuses', label: 'Status', type: 'multiselect' }, // The invoices list page sends a single `status`; accept both so its // on-screen filter actually carries into the export. { key: 'status', label: 'Status (single)', type: 'text' }, + { key: 'sources', label: 'Source', type: 'multiselect' }, + { key: 'eimsStatuses', label: 'EIMS status', type: 'multiselect' }, { key: 'currency', label: 'Currency', type: 'select', options: [ { value: 'ETB', label: 'ETB' }, { value: 'USD', label: 'USD' }, ] }, + { key: 'minAmount', label: 'Min total', type: 'text' }, + { key: 'maxAmount', label: 'Max total', type: 'text' }, + { key: 'hasBalance', label: 'Outstanding only', type: 'text' }, + { key: 'overdue', label: 'Overdue only', type: 'text' }, { key: 'companyId', label: 'Customer', type: 'text' }, { key: 'search', label: 'Search invoice no. or customer', type: 'text' }, ], @@ -116,10 +123,27 @@ export const invoicesDataset: ExportDataset = { 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 }); + if (params.dueFrom) qb.andWhere('i.due_at >= :dueFrom', { dueFrom: params.dueFrom }); + if (params.dueTo) qb.andWhere('i.due_at < :dueTo', { dueTo: params.dueTo }); const statuses = params.statuses as string[] | null; if (statuses?.length) qb.andWhere('i.status IN (:...statuses)', { statuses }); if (params.status) qb.andWhere('i.status = :status', { status: params.status }); - if (params.currency) qb.andWhere('i.currency = :currency', { currency: params.currency }); + const sources = params.sources as string[] | null; + if (sources?.length) qb.andWhere('i.source IN (:...sources)', { sources }); + const eimsStatuses = params.eimsStatuses as string[] | null; + if (eimsStatuses?.length) qb.andWhere('i.eims_status IN (:...eimsStatuses)', { eimsStatuses }); + // Casing has drifted in the data ("usd" rows exist) — normalise both sides, + // same as the list endpoint does. + if (params.currency) { + qb.andWhere('UPPER(i.currency) = :currency', { + currency: String(params.currency).toUpperCase(), + }); + } + if (params.minAmount) qb.andWhere('i.total_amount >= :minAmount', { minAmount: Number(params.minAmount) }); + if (params.maxAmount) qb.andWhere('i.total_amount <= :maxAmount', { maxAmount: Number(params.maxAmount) }); + if (params.hasBalance === 'true') qb.andWhere('i.balance_amount > 0'); + // Computed, not `status = OVERDUE` — nothing sweeps PENDING rows into it. + if (params.overdue === 'true') qb.andWhere('i.balance_amount > 0 AND i.due_at < now()'); 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}%` }); diff --git a/apps/edr-freight-api/src/modules/import-operations/import-operations.controller.ts b/apps/edr-freight-api/src/modules/import-operations/import-operations.controller.ts index c631bef92..ae80ea9c8 100644 --- a/apps/edr-freight-api/src/modules/import-operations/import-operations.controller.ts +++ b/apps/edr-freight-api/src/modules/import-operations/import-operations.controller.ts @@ -1,8 +1,13 @@ -import { Body, Controller, Get, Param, ParseUUIDPipe, Post, Query } from '@nestjs/common'; +import { Body, Controller, Get, NotFoundException, Param, ParseUUIDPipe, Post, Query, Res } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import type { Response } from 'express'; +import { CurrentUser } from '@edr/api-common'; +import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; -import { BookingStaff } from '../../common/booking-guards'; +import { BookingStaff, MixedAudience } from '../../common/booking-guards'; +import { hasFreightPermission } from '../../common/freight-permission.util'; import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; +import { BookingsService } from '../bookings/bookings.service'; import { AssignCustomsRiskDto, CreateDjiboutiIncidentDto, @@ -19,30 +24,38 @@ import { ImportOperationsService } from './import-operations.service'; @ApiBearerAuth() @Controller('import-operations') // Post-booking customs / import-operations actions are GL/Ops work, mirroring the -// contracts controller's GL operational endpoints (risk, duty, milestones). -@BookingStaff(FREIGHT_PERMS.bookings.operations) +// contracts controller's GL operational endpoints (risk, duty, milestones). No +// class-level guard: the equipment interchange receipt below is customer-reachable, +// every other route here stays staff-only via its own @BookingStaff. export class ImportOperationsController { - constructor(private readonly service: ImportOperationsService) {} + constructor( + private readonly service: ImportOperationsService, + private readonly bookingsService: BookingsService, + ) {} @Get('djibouti-incidents') + @BookingStaff(FREIGHT_PERMS.bookings.operations) @ApiOperation({ summary: 'Batch 8: list Djibouti import incidents' }) listIncidents(@Query('bookingId') bookingId?: string) { return this.service.listIncidents(bookingId); } @Post('djibouti-incidents') + @BookingStaff(FREIGHT_PERMS.bookings.operations) @ApiOperation({ summary: 'Batch 8: report a Djibouti import incident / exception' }) createIncident(@Body() dto: CreateDjiboutiIncidentDto) { return this.service.createIncident(dto); } @Get('customs/:bookingId') + @BookingStaff(FREIGHT_PERMS.bookings.operations) @ApiOperation({ summary: 'Batch 12: import customs finalization state' }) getCustoms(@Param('bookingId', ParseUUIDPipe) bookingId: string) { return this.service.getCustoms(bookingId); } @Post('customs/:bookingId/documents') + @BookingStaff(FREIGHT_PERMS.bookings.operations) @ApiOperation({ summary: 'Batch 12: upload IM4/IM5/T1/permit/payment-slip documents' }) uploadCustomsDocument( @Param('bookingId', ParseUUIDPipe) bookingId: string, @@ -52,6 +65,7 @@ export class ImportOperationsController { } @Post('customs/:bookingId/declaration') + @BookingStaff(FREIGHT_PERMS.bookings.operations) @ApiOperation({ summary: 'Batch 12: record declaration serial number' }) recordDeclaration( @Param('bookingId', ParseUUIDPipe) bookingId: string, @@ -61,6 +75,7 @@ export class ImportOperationsController { } @Post('customs/:bookingId/notify-duties-taxes') + @BookingStaff(FREIGHT_PERMS.bookings.operations) @ApiOperation({ summary: 'Batch 12: notify duties and taxes' }) notifyDutiesTaxes( @Param('bookingId', ParseUUIDPipe) bookingId: string, @@ -70,6 +85,7 @@ export class ImportOperationsController { } @Post('customs/:bookingId/duties-taxes-paid') + @BookingStaff(FREIGHT_PERMS.bookings.operations) @ApiOperation({ summary: 'Batch 12: mark duties and taxes paid' }) markDutiesTaxesPaid( @Param('bookingId', ParseUUIDPipe) bookingId: string, @@ -79,12 +95,14 @@ export class ImportOperationsController { } @Post('customs/:bookingId/risk') + @BookingStaff(FREIGHT_PERMS.bookings.operations) @ApiOperation({ summary: 'Batch 12: assign customs risk' }) assignRisk(@Param('bookingId', ParseUUIDPipe) bookingId: string, @Body() dto: AssignCustomsRiskDto) { return this.service.assignRisk(bookingId, dto); } @Post('customs/:bookingId/release-permitted') + @BookingStaff(FREIGHT_PERMS.bookings.operations) @ApiOperation({ summary: 'Batch 12: mark import release permitted' }) markReleasePermitted( @Param('bookingId', ParseUUIDPipe) bookingId: string, @@ -94,18 +112,21 @@ export class ImportOperationsController { } @Get('empty-container-returns') + @BookingStaff(FREIGHT_PERMS.bookings.operations) @ApiOperation({ summary: 'Batch 16: list empty container returns' }) listEmptyReturns() { return this.service.listEmptyReturns(); } @Post('empty-container-returns') + @BookingStaff(FREIGHT_PERMS.bookings.operations) @ApiOperation({ summary: 'Batch 16: create an empty container return record' }) createEmptyReturn(@Body() dto: CreateEmptyContainerReturnDto) { return this.service.createEmptyReturn(dto); } @Post('empty-container-returns/load-on-train') + @BookingStaff(FREIGHT_PERMS.bookings.operations) @ApiOperation({ summary: 'Load returned empties onto an export train (1×40ft or 2×20ft per wagon)', }) @@ -114,6 +135,7 @@ export class ImportOperationsController { } @Post('empty-container-returns/:id/status') + @BookingStaff(FREIGHT_PERMS.bookings.operations) @ApiOperation({ summary: 'Batch 16: advance empty container return workflow' }) updateEmptyReturnStatus( @Param('id', ParseUUIDPipe) id: string, @@ -121,4 +143,53 @@ export class ImportOperationsController { ) { return this.service.updateEmptyReturnStatus(id, dto); } + + @Get('bookings/:bookingId/empty-container-returns') + @MixedAudience(FREIGHT_PERMS.bookings.operations) + @ApiOperation({ summary: 'List empty container returns for a booking (customer portal)' }) + async listEmptyReturnsForBooking( + @Param('bookingId', ParseUUIDPipe) bookingId: string, + @CurrentUser() user: TCurrentUser, + ) { + await this.assertCanAccessBooking(user, bookingId); + return this.service.listEmptyReturnsForBooking(bookingId); + } + + @Get('empty-container-returns/:id/document') + @MixedAudience(FREIGHT_PERMS.bookings.operations) + @ApiOperation({ summary: 'Download the equipment interchange receipt PDF (customer portal)' }) + async equipmentInterchangeDocument( + @Param('id', ParseUUIDPipe) id: string, + @CurrentUser() user: TCurrentUser, + @Res() res: Response, + ) { + const row = await this.service.getEmptyReturnOrThrow(id); + // A standalone (no-booking) return has no owner to check against, so it + // stays staff-only. + if (!row.bookingId) { + await this.assertCanAccessBooking(user, null); + } else { + await this.assertCanAccessBooking(user, row.bookingId); + } + + const { filename, buffer } = await this.service.equipmentInterchangeDocument(row); + res.setHeader('Content-Type', 'application/pdf'); + res.setHeader('Content-Disposition', `inline; filename="${filename}"`); + res.setHeader('Content-Length', buffer.length); + return res.send(buffer); + } + + /** + * Staff pass on permission alone. A customer must own the booking; `null` + * (a standalone, booking-less return) has no owner for a customer to match, + * so it 404s them the same way a foreign booking would. + */ + private async assertCanAccessBooking(user: TCurrentUser, bookingId: string | null): Promise { + if (hasFreightPermission(user, FREIGHT_PERMS.bookings.operations)) return; + if (!bookingId) { + throw new NotFoundException('Not found'); + } + const booking = await this.bookingsService.findById(bookingId); + await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking); + } } diff --git a/apps/edr-freight-api/src/modules/import-operations/import-operations.module.ts b/apps/edr-freight-api/src/modules/import-operations/import-operations.module.ts index fb4c6e896..21e0dd9a0 100644 --- a/apps/edr-freight-api/src/modules/import-operations/import-operations.module.ts +++ b/apps/edr-freight-api/src/modules/import-operations/import-operations.module.ts @@ -1,6 +1,8 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { BookingsModule } from '../bookings/bookings.module'; +import { WarehousesModule } from '../warehouses/warehouses.module'; import { DjiboutiIncident } from './entities/djibouti-incident.entity'; import { EmptyContainerReturn } from './entities/empty-container-return.entity'; import { ImportCustomsFinalization } from './entities/import-customs-finalization.entity'; @@ -14,6 +16,11 @@ import { ImportOperationsService } from './import-operations.service'; ImportCustomsFinalization, EmptyContainerReturn, ]), + // WarehouseReleaseDocumentService (the shared PDF renderer) for the + // equipment interchange receipt; BookingsModule for the customer + // ownership check on that same route. + WarehousesModule, + BookingsModule, ], controllers: [ImportOperationsController], providers: [ImportOperationsService], diff --git a/apps/edr-freight-api/src/modules/import-operations/import-operations.service.ts b/apps/edr-freight-api/src/modules/import-operations/import-operations.service.ts index 28eb4e44f..ccec40139 100644 --- a/apps/edr-freight-api/src/modules/import-operations/import-operations.service.ts +++ b/apps/edr-freight-api/src/modules/import-operations/import-operations.service.ts @@ -2,6 +2,9 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/comm import { InjectRepository } from '@nestjs/typeorm'; import { In, Repository } from 'typeorm'; +import { LogoSettingsService } from '../logo-settings/logo-settings.service'; +import { logoImageCss, logoMarkup } from '../billing/documents/logo-markup.util'; +import { WarehouseReleaseDocumentService } from '../warehouses/warehouse-release-document.service'; import { CreateDjiboutiIncidentDto, CreateEmptyContainerReturnDto, @@ -39,6 +42,8 @@ export class ImportOperationsService { private readonly customs: Repository, @InjectRepository(EmptyContainerReturn) private readonly emptyReturns: Repository, + private readonly pdfDocuments: WarehouseReleaseDocumentService, + private readonly logoSettings: LogoSettingsService, ) {} listIncidents(bookingId?: string) { @@ -150,6 +155,10 @@ export class ImportOperationsService { return this.emptyReturns.find({ order: { createdAt: 'DESC' } as never }); } + listEmptyReturnsForBooking(bookingId: string) { + return this.emptyReturns.find({ where: { bookingId }, order: { createdAt: 'DESC' } as never }); + } + async createEmptyReturn(dto: CreateEmptyContainerReturnDto) { const returnDate = dto.returnDate ? new Date(dto.returnDate) : new Date(); return this.emptyReturns.save( @@ -248,6 +257,142 @@ export class ImportOperationsService { return this.emptyReturns.findOneOrFail({ where: { id } }); } + async getEmptyReturnOrThrow(id: string): Promise { + const row = await this.emptyReturns.findOne({ where: { id } }); + if (!row) { + throw new NotFoundException(`Empty container return ${id} not found`); + } + return row; + } + + /** + * Equipment Interchange Receipt — container number/size, exact return + * timestamp, depot, condition, and the carrier/booking reference that ties + * the box back to its bill of lading. Handed to the customer to download. + */ + async equipmentInterchangeDocument( + row: EmptyContainerReturn, + ): Promise<{ filename: string; buffer: Buffer }> { + const booking = row.bookingId + ? (( + await this.emptyReturns.manager.query( + `SELECT b.reference, c.name AS company_name + FROM freight.bookings b + LEFT JOIN freight.companies c ON c.id = b.company_id + WHERE b.id = $1`, + [row.bookingId], + ) + )[0] as { reference: string; company_name: string | null } | undefined) + : undefined; + + const html = this.buildEquipmentInterchangeHtml(row, booking, { + logoImageUrl: await this.logoSettings.getLogoImageUrl(), + }); + const buffer = await this.pdfDocuments.renderDocumentHtml(html, 'Equipment interchange receipt'); + return { + filename: `equipment-interchange-${row.containerNumber || row.id.slice(0, 8)}.pdf`, + buffer, + }; + } + + private buildEquipmentInterchangeHtml( + row: EmptyContainerReturn, + booking: { reference: string; company_name: string | null } | undefined, + opts: { logoImageUrl?: string | null }, + ): string { + const esc = (value: unknown) => + String(value ?? '-') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + const dateTime = (value: unknown) => + value ? new Date(value as string | Date).toLocaleString('en-GB', { dateStyle: 'medium', timeStyle: 'short' }) : '-'; + const carrier = + row.returnedBy === 'EDR' + ? 'EDR Last Mile' + : row.returnedBy === 'CUSTOMER' + ? 'Customer Self-Haul' + : '-'; + + const rows: Array<[string, string]> = [ + ['Container Number', row.containerNumber], + ['Container Size', row.containerSize ? `${row.containerSize}ft` : 'Not recorded'], + ['Date & Time of Return', dateTime(row.returnDate)], + ['Depot / Location', [row.facility, row.yard, row.zone].filter(Boolean).join(' — ') || '-'], + ['Condition Status', row.condition || 'Good — no exceptions noted'], + ['Carrier', carrier], + ['Booking / BOL Reference', booking?.reference || 'Standalone — no booking'], + ['Shipping Line / Customer', booking?.company_name || '-'], + ['Current Status', row.status.replace(/_/g, ' ')], + ['Handover Note', row.handoverNote || '-'], + ]; + + const rowsHtml = rows + .map( + ([label, value]) => + `${esc(label)}${esc(value)}`, + ) + .join(''); + + return ` + + + + Equipment Interchange Receipt + + + +
+
+ ${logoMarkup(opts.logoImageUrl)} +
Ethio-Djibouti Railway S.C.
+

Equipment Interchange Receipt

+
+
+ Receipt No. + ${esc(`EIR-${row.id.slice(0, 8).toUpperCase()}`)} + Generated: ${esc(new Date().toLocaleString('en-GB'))} +
+
+ + + + ${rowsHtml} + +
+ +
+ This receipt confirms the physical interchange of the equipment described above at the + depot/location and time stated. Both parties should verify the container number, size, + and condition recorded here before signing. +
+ +
+
Depot officer name / signature / date
+
Customer or driver name / signature / date
+
+ +`; + } + private async getOrCreateCustoms(bookingId: string) { const existing = await this.customs.findOne({ where: { bookingId } }); if (existing) return existing; diff --git a/apps/edr-freight-api/src/modules/operations-reporting/dto/create-operations-target.dto.ts b/apps/edr-freight-api/src/modules/operations-reporting/dto/create-operations-target.dto.ts index 2d8f5beb0..07c380cda 100644 --- a/apps/edr-freight-api/src/modules/operations-reporting/dto/create-operations-target.dto.ts +++ b/apps/edr-freight-api/src/modules/operations-reporting/dto/create-operations-target.dto.ts @@ -58,13 +58,18 @@ export class CreateOperationsTargetDto { @ApiPropertyOptional({ description: - 'Station targets only: which cargo category this station plan covers. Leave blank for the other dimensions.', + 'Station targets only: which cargo category this station plan covers. Ignored for the ' + + 'other dimensions, whose key already carries the category.', example: 'CONTAINER_IMPORT_MULTIMODAL', }) @IsOptional() + // `'' ?? null` is `''`, and an empty string matches neither the unique + // index's `COALESCE(cargo_category, '')` nor the report's join — it reads as + // a category that does not exist. Blank means absent. + @Transform(({ value }) => (value === '' ? null : value)) @IsString() @MaxLength(60) - cargoCategory?: string; + cargoCategory?: string | null; @ApiPropertyOptional() @IsOptional() diff --git a/apps/edr-freight-api/src/modules/operations-reporting/entities/operations-target.entity.ts b/apps/edr-freight-api/src/modules/operations-reporting/entities/operations-target.entity.ts index aec11ae24..1bcbe908b 100644 --- a/apps/edr-freight-api/src/modules/operations-reporting/entities/operations-target.entity.ts +++ b/apps/edr-freight-api/src/modules/operations-reporting/entities/operations-target.entity.ts @@ -1,8 +1,26 @@ import { BaseEntity } from '@edr/api-common'; import { Column, Entity, Index } from 'typeorm'; -/** Planning buckets the reports offer. Mirrors the reports' period filter. */ -export const TARGET_PERIOD_TYPES = ['week', 'month', 'quarter', 'year'] as const; +/** + * Planning buckets the reports offer. Mirrors the reports' period filter + * (`PERIOD_UNITS` in `reports/revenue-classification.ts`) — a planner must be + * able to commit a number at whatever grain the business quotes it, and the + * report then re-gathers it into whatever grain the viewer asks for. + * + * All eight anchor to the calendar year. `nine_month` and `ninety_day` are the + * two that do not divide it evenly: their last block of a year is short (Oct–Dec + * and the 5–6 days after day 360). That is inherent to the unit, not a bug. + */ +export const TARGET_PERIOD_TYPES = [ + 'day', + 'week', + 'month', + 'quarter', + 'half_year', + 'nine_month', + 'ninety_day', + 'year', +] as const; export type TargetPeriodType = (typeof TARGET_PERIOD_TYPES)[number]; /** What is being planned. */ @@ -31,9 +49,13 @@ export const TARGET_DIMENSION_LABELS: Record = { }; export const TARGET_PERIOD_LABELS: Record = { + day: 'Daily', week: 'Weekly', month: 'Monthly', quarter: 'Quarterly', + half_year: 'Half-yearly', + nine_month: 'Nine-monthly', + ninety_day: '90-day', year: 'Yearly', }; diff --git a/apps/edr-freight-api/src/modules/operations-reporting/operations-reporting.module.ts b/apps/edr-freight-api/src/modules/operations-reporting/operations-reporting.module.ts index 180c12271..e2da0eb30 100644 --- a/apps/edr-freight-api/src/modules/operations-reporting/operations-reporting.module.ts +++ b/apps/edr-freight-api/src/modules/operations-reporting/operations-reporting.module.ts @@ -1,4 +1,4 @@ -import { Global, Module } from '@nestjs/common'; +import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { OperationsStandard } from './entities/operations-standard.entity'; @@ -13,10 +13,11 @@ import { OperationsTargetsService } from './operations-targets.service'; * standards (one settings row) and the planned targets the reports compare * actuals against. * - * Global because the reports module reads the standards row on every run and - * has no other reason to import this. + * Not global, and deliberately so: nothing outside this module injects either + * service. The reports read both tables in raw SQL — `STANDARDS_JOIN` and + * `plannedRowsSql` in `reports/operations-classification.ts` — so the exports + * below are for future callers, not current ones. */ -@Global() @Module({ imports: [TypeOrmModule.forFeature([OperationsStandard, OperationsTarget])], controllers: [OperationsStandardsController, OperationsTargetsController], diff --git a/apps/edr-freight-api/src/modules/operations-reporting/operations-targets.service.spec.ts b/apps/edr-freight-api/src/modules/operations-reporting/operations-targets.service.spec.ts new file mode 100644 index 000000000..69ad38db6 --- /dev/null +++ b/apps/edr-freight-api/src/modules/operations-reporting/operations-targets.service.spec.ts @@ -0,0 +1,144 @@ +import { + TARGET_PERIOD_LABELS, + TARGET_PERIOD_TYPES, + TargetPeriodType, +} from './entities/operations-target.entity'; +import { normalisePeriodStart } from './operations-targets.service'; + +/** + * `normalisePeriodStart` decides which slot a target occupies — the unique + * index is keyed on its output — and it is one half of a pair. The other half + * is `PERIOD_UNITS[...].truncOn` in `reports/revenue-classification.ts`, which + * buckets the actuals. A target that snaps to a boundary the report does not + * bucket on is a plan measured against a period that does not exist, and + * nothing downstream would say so. + * + * Everything here is UTC on purpose: the column is a bare `date`, and the same + * arithmetic in local time shifts a 1st-of-month target into the previous month + * for anyone east of Greenwich. + */ +describe('normalisePeriodStart', () => { + it('leaves a daily target on its own day', () => { + expect(normalisePeriodStart('day', '2026-08-21')).toBe('2026-08-21'); + }); + + it('snaps a week to its Monday', () => { + // 2026-08-21 is a Friday. + expect(normalisePeriodStart('week', '2026-08-21')).toBe('2026-08-17'); + // A Sunday belongs to the week that started six days earlier, not the next. + expect(normalisePeriodStart('week', '2026-08-23')).toBe('2026-08-17'); + expect(normalisePeriodStart('week', '2026-08-17')).toBe('2026-08-17'); + }); + + it('snaps a month to the 1st', () => { + expect(normalisePeriodStart('month', '2026-08-21')).toBe('2026-08-01'); + expect(normalisePeriodStart('month', '2026-08-01')).toBe('2026-08-01'); + }); + + it('snaps a quarter to Jan/Apr/Jul/Oct', () => { + expect(normalisePeriodStart('quarter', '2026-02-14')).toBe('2026-01-01'); + expect(normalisePeriodStart('quarter', '2026-05-01')).toBe('2026-04-01'); + expect(normalisePeriodStart('quarter', '2026-08-21')).toBe('2026-07-01'); + expect(normalisePeriodStart('quarter', '2026-12-31')).toBe('2026-10-01'); + }); + + it('snaps a half-year to Jan/Jul', () => { + expect(normalisePeriodStart('half_year', '2026-01-01')).toBe('2026-01-01'); + expect(normalisePeriodStart('half_year', '2026-06-30')).toBe('2026-01-01'); + expect(normalisePeriodStart('half_year', '2026-07-01')).toBe('2026-07-01'); + expect(normalisePeriodStart('half_year', '2026-12-31')).toBe('2026-07-01'); + }); + + it('snaps a nine-month to Jan/Oct, leaving a short final block', () => { + expect(normalisePeriodStart('nine_month', '2026-01-01')).toBe('2026-01-01'); + expect(normalisePeriodStart('nine_month', '2026-09-30')).toBe('2026-01-01'); + // Oct–Dec is three months, not nine. The block is short by design: nine + // does not divide twelve, and drifting out of the calendar year is worse. + expect(normalisePeriodStart('nine_month', '2026-10-01')).toBe('2026-10-01'); + expect(normalisePeriodStart('nine_month', '2026-12-31')).toBe('2026-10-01'); + }); + + it('snaps a 90-day block to day 1/91/181/271 of its year', () => { + expect(normalisePeriodStart('ninety_day', '2026-01-01')).toBe('2026-01-01'); + expect(normalisePeriodStart('ninety_day', '2026-03-31')).toBe('2026-01-01'); // day 90 + expect(normalisePeriodStart('ninety_day', '2026-04-01')).toBe('2026-04-01'); // day 91 + expect(normalisePeriodStart('ninety_day', '2026-06-29')).toBe('2026-04-01'); // day 180 + expect(normalisePeriodStart('ninety_day', '2026-06-30')).toBe('2026-06-30'); // day 181 + expect(normalisePeriodStart('ninety_day', '2026-07-01')).toBe('2026-06-30'); + expect(normalisePeriodStart('ninety_day', '2026-09-27')).toBe('2026-06-30'); // day 270 + expect(normalisePeriodStart('ninety_day', '2026-09-28')).toBe('2026-09-28'); // day 271 + }); + + it('widens the fourth 90-day block instead of opening a stub fifth', () => { + // Day 361 onwards would be its own block under an uncapped floor division — + // a five-day bucket at the end of every year. The cap keeps it in block 4, + // which must therefore match what late September resolves to. + const blockFour = normalisePeriodStart('ninety_day', '2026-09-28'); + expect(normalisePeriodStart('ninety_day', '2026-12-27')).toBe(blockFour); + expect(normalisePeriodStart('ninety_day', '2026-12-31')).toBe(blockFour); + }); + + it('handles a leap year, where day 366 still lands in the fourth block', () => { + // 2028 is a leap year: Dec 31 is day 366. + expect(normalisePeriodStart('ninety_day', '2028-12-31')).toBe( + normalisePeriodStart('ninety_day', '2028-09-27'), + ); + }); + + it('snaps a year to Jan 1', () => { + expect(normalisePeriodStart('year', '2026-08-21')).toBe('2026-01-01'); + expect(normalisePeriodStart('year', '2026-01-01')).toBe('2026-01-01'); + expect(normalisePeriodStart('year', '2026-12-31')).toBe('2026-01-01'); + }); + + it('ignores any time component rather than letting it shift the day', () => { + expect(normalisePeriodStart('day', '2026-08-21T23:59:59.999Z')).toBe('2026-08-21'); + expect(normalisePeriodStart('month', '2026-08-01T22:00:00+03:00')).toBe('2026-08-01'); + }); + + it('is idempotent for every period type', () => { + // A normalised start must survive a second pass untouched, because `update` + // re-normalises whatever is already stored. + for (const periodType of TARGET_PERIOD_TYPES) { + for (const date of ['2026-01-01', '2026-05-17', '2026-08-21', '2026-12-31']) { + const once = normalisePeriodStart(periodType, date); + expect(normalisePeriodStart(periodType, once)).toBe(once); + } + } + }); + + it('never moves a date forward, only back to its block start', () => { + for (const periodType of TARGET_PERIOD_TYPES) { + for (const date of ['2026-02-28', '2026-06-15', '2026-10-02', '2026-12-31']) { + expect(normalisePeriodStart(periodType, date) <= date).toBe(true); + } + } + }); +}); + +describe('target period vocabulary', () => { + it('labels every period type, so the admin grid shows no raw key', () => { + for (const periodType of TARGET_PERIOD_TYPES) { + expect(TARGET_PERIOD_LABELS[periodType]).toBeTruthy(); + } + expect(Object.keys(TARGET_PERIOD_LABELS).sort()).toEqual([...TARGET_PERIOD_TYPES].sort()); + }); + + it('keeps every period type inside the column width', () => { + // `period_type` is varchar(10); `nine_month` and `ninety_day` are exactly 10. + for (const periodType of TARGET_PERIOD_TYPES) { + expect(periodType.length).toBeLessThanOrEqual(10); + } + }); + + it('has a normalisation branch for every declared period type', () => { + // A type added to the union without a `case` would silently fall through + // and store an un-snapped date. Every type must move Dec 31 to a block + // start except `day`, which legitimately keeps it. + const unhandled = TARGET_PERIOD_TYPES.filter( + (t: TargetPeriodType) => + t !== 'day' && normalisePeriodStart(t, '2026-12-31') === '2026-12-31', + ); + expect(unhandled).toEqual([]); + }); +}); diff --git a/apps/edr-freight-api/src/modules/operations-reporting/operations-targets.service.ts b/apps/edr-freight-api/src/modules/operations-reporting/operations-targets.service.ts index c33054aa5..fbcbc6d7b 100644 --- a/apps/edr-freight-api/src/modules/operations-reporting/operations-targets.service.ts +++ b/apps/edr-freight-api/src/modules/operations-reporting/operations-targets.service.ts @@ -1,5 +1,10 @@ import { PaginatedResponse } from '@edr/types'; -import { ConflictException, Injectable, NotFoundException } from '@nestjs/common'; +import { + BadRequestException, + ConflictException, + Injectable, + NotFoundException, +} from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Brackets, IsNull, Repository } from 'typeorm'; @@ -12,6 +17,8 @@ import { TARGET_DIMENSION_LABELS, TARGET_METRIC_LABELS, TARGET_PERIOD_LABELS, + TargetDimension, + TargetMetric, TargetPeriodType, } from './entities/operations-target.entity'; import { @@ -19,10 +26,19 @@ import { CONTAINER_CLASSES, } from '../reports/operations-classification'; +const MS_PER_DAY = 86_400_000; + /** - * Normalises any date inside a bucket to the bucket's first day, matching - * Postgres `date_trunc` — which is what the reports group by. Week starts - * Monday, the same as `date_trunc('week', …)` and ISO week numbering. + * Normalises any date inside a bucket to the bucket's first day, matching the + * bucket expression the reports group by (`PERIOD_UNITS` in + * `reports/revenue-classification.ts`). Week starts Monday, the same as + * `date_trunc('week', …)` and ISO week numbering. + * + * The four units Postgres has no `date_trunc` for are anchored to the calendar + * year, exactly as their SQL twins are: half-years at Jan/Jul, nine-months at + * Jan/Oct, ninety-days at day 1/91/181/271. **This function and + * `PERIOD_UNITS[...].truncOn` must agree** — a target whose `period_start` is + * not a real block start plans against a bucket boundary that does not exist. * * Done in UTC throughout: the stored column is a bare `date`, and running the * arithmetic in local time would shift a 1st-of-month target into the previous @@ -31,6 +47,8 @@ import { export function normalisePeriodStart(periodType: TargetPeriodType, value: string): string { const d = new Date(`${value.slice(0, 10)}T00:00:00Z`); switch (periodType) { + case 'day': + break; case 'week': { // getUTCDay(): 0 = Sunday. Monday-based offset puts Sunday six days in. const offset = (d.getUTCDay() + 6) % 7; @@ -43,6 +61,22 @@ export function normalisePeriodStart(periodType: TargetPeriodType, value: string case 'quarter': d.setUTCMonth(Math.floor(d.getUTCMonth() / 3) * 3, 1); break; + case 'half_year': + d.setUTCMonth(Math.floor(d.getUTCMonth() / 6) * 6, 1); + break; + case 'nine_month': + // Two blocks a year, not 1.33: Jan–Sep, then a short Oct–Dec. + d.setUTCMonth(Math.floor(d.getUTCMonth() / 9) * 9, 1); + break; + case 'ninety_day': { + // Day-of-year, zero-based, so this matches SQL's 1-based `(doy - 1) / 90`. + // Capped at block 3 for the same reason the SQL caps it: uncapped, the + // last days of December become a 5-day stub block of their own. + const yearStart = Date.UTC(d.getUTCFullYear(), 0, 1); + const dayIndex = Math.floor((d.getTime() - yearStart) / MS_PER_DAY); + d.setTime(yearStart + Math.min(Math.floor(dayIndex / 90), 3) * 90 * MS_PER_DAY); + break; + } case 'year': d.setUTCMonth(0, 1); break; @@ -76,6 +110,27 @@ const LABELS_BY_DIMENSION: Record> = { const CARGO_CATEGORY_LABELS = LABELS_BY_DIMENSION.cargo_category; +/** + * The keys a target may be stored against, per dimension. A report matches a + * target by this exact string, so a key outside the set here is a plan no + * report can ever find — and nothing downstream would ever say so. `station` is + * absent on purpose: yard codes are admin-managed rows, resolved live. + * + * `UNCLASSIFIED` is accepted for `cargo_category` even though the admin form + * does not offer it, because `CARGO_CATEGORY_EXPR` does emit it — rejecting a + * key the reports can match would be stricter than the reports themselves. + */ +const KEYS_BY_DIMENSION: Record, Set> = { + cargo_category: new Set(CARGO_CATEGORIES.map((o) => o.value)), + container_class: new Set(CONTAINER_CLASSES.map((o) => o.value)), +}; + +/** The columns that decide which report row a target lines up with. */ +type TargetSlot = Pick< + OperationsTarget, + 'periodType' | 'periodStart' | 'metric' | 'dimension' | 'dimensionKey' | 'cargoCategory' +>; + @Injectable() export class OperationsTargetsService { constructor( @@ -152,35 +207,113 @@ export class OperationsTargetsService { } async create(dto: CreateOperationsTargetDto): Promise { - const periodStart = normalisePeriodStart(dto.periodType, dto.periodStart); - const cargoCategory = dto.cargoCategory ?? null; - await this.assertSlotFree({ ...dto, periodStart, cargoCategory }); - return this.repository.save(this.repository.create({ ...dto, periodStart, cargoCategory })); + const slot = await this.resolveSlot(dto); + await this.assertSlotFree(slot); + return this.repository.save(this.repository.create({ ...dto, ...slot })); } async update(id: string, dto: UpdateOperationsTargetDto): Promise { const current = await this.findById(id); - const periodType = dto.periodType ?? current.periodType; - const periodStart = normalisePeriodStart(periodType, dto.periodStart ?? current.periodStart); - const next = { - periodType, - periodStart, + const slot = await this.resolveSlot({ + periodType: dto.periodType ?? current.periodType, + periodStart: dto.periodStart ?? current.periodStart, metric: dto.metric ?? current.metric, dimension: dto.dimension ?? current.dimension, dimensionKey: dto.dimensionKey ?? current.dimensionKey, + // An absent key means "unchanged" only while the dimension still wants a + // category at all — `resolveSlot` drops it when the dimension no longer + // does, which is the whole point of routing both paths through it. cargoCategory: - dto.cargoCategory !== undefined ? (dto.cargoCategory ?? null) : current.cargoCategory ?? null, - }; - await this.assertSlotFree(next, id); + dto.cargoCategory !== undefined ? dto.cargoCategory : current.cargoCategory, + }); + await this.assertSlotFree(slot, id); await this.repository.update(id, { - ...next, + ...slot, ...(dto.plannedValue != null ? { plannedValue: dto.plannedValue } : {}), ...(dto.note !== undefined ? { note: dto.note } : {}), }); return this.findById(id); } + /** + * Everything that decides which report row a target lines up with, resolved + * in one place so `create` and `update` cannot drift apart. + * + * `cargoCategory` is **derived from the dimension, never carried over**. A + * station's plan is per station AND per cargo type; the other two dimensions + * already carry the category in `dimensionKey`. A stale category left on a + * row whose dimension has moved on is not cosmetic — it survives the + * `COALESCE(cargo_category, '')` unique index alongside the legitimate + * null-category row, `plannedRowsSql` groups by it, and the two plan rows + * then both join the same operated row: the category lists twice, each time + * carrying the full operated tonnage, while the summary tiles stay correct. + */ + private async resolveSlot(input: { + periodType: TargetPeriodType; + periodStart: string; + metric: TargetMetric; + dimension: TargetDimension; + dimensionKey: string; + cargoCategory?: string | null; + }): Promise { + const periodStart = normalisePeriodStart(input.periodType, input.periodStart); + await this.assertDimensionKey(input.dimension, input.dimensionKey); + + const base = { + periodType: input.periodType, + periodStart, + metric: input.metric, + dimension: input.dimension, + dimensionKey: input.dimensionKey, + }; + + if (input.dimension !== 'station') { + return { ...base, cargoCategory: null }; + } + + const cargoCategory = input.cargoCategory || null; + if (!cargoCategory) { + throw new BadRequestException( + 'A station target needs a cargo category — the plan is per station and per cargo type. ' + + 'Without one the report has nothing to match it against.', + ); + } + if (!KEYS_BY_DIMENSION.cargo_category.has(cargoCategory)) { + throw new BadRequestException( + `"${cargoCategory}" is not a cargo category the reports produce. ` + + `Expected one of: ${[...KEYS_BY_DIMENSION.cargo_category].join(', ')}`, + ); + } + return { ...base, cargoCategory }; + } + + /** + * A `dimensionKey` the reports never emit is a plan that silently never + * joins — the row lists fine and its label falls back to the raw key, so + * nothing downstream ever reports the mistake. Cheaper to reject on write. + */ + private async assertDimensionKey(dimension: TargetDimension, key: string): Promise { + if (dimension === 'station') { + const yards = await this.yardLabels(); + if (!yards.has(key)) { + throw new BadRequestException( + `"${key}" is not a known station code. A station target is keyed on ` + + '`yards.code`, which is what the reports match against.', + ); + } + return; + } + + const allowed = KEYS_BY_DIMENSION[dimension]; + if (!allowed.has(key)) { + throw new BadRequestException( + `"${key}" is not a ${TARGET_DIMENSION_LABELS[dimension].toLowerCase()} the reports ` + + `produce. Expected one of: ${[...allowed].join(', ')}`, + ); + } + } + async remove(id: string): Promise { await this.findById(id); await this.repository.softDelete(id); diff --git a/apps/edr-freight-api/src/modules/overview/dto/overview-layout.dto.ts b/apps/edr-freight-api/src/modules/overview/dto/overview-layout.dto.ts new file mode 100644 index 000000000..8a125bf81 --- /dev/null +++ b/apps/edr-freight-api/src/modules/overview/dto/overview-layout.dto.ts @@ -0,0 +1,19 @@ +import { ApiProperty } from '@nestjs/swagger'; + +import type { OverviewLayoutKey } from '../../../seed/freight-permissions.registry'; + +/** + * One entry per `GET /overview/layouts` item: a layout the caller holds the + * `edr_freight_app:overview::view` permission for. Mirrors the reports + * module's catalog entry (`ReportCatalogEntry`) — same "server filters by + * permission, frontend just renders what comes back" shape. + */ +export class OverviewLayoutDto { + @ApiProperty({ + enum: ['clearance', 'occ', 'operation', 'marketer', 'finance', 'executive'], + }) + key!: OverviewLayoutKey; + + @ApiProperty() + label!: string; +} diff --git a/apps/edr-freight-api/src/modules/overview/overview.controller.ts b/apps/edr-freight-api/src/modules/overview/overview.controller.ts index fcec82d4d..37d0b77d2 100644 --- a/apps/edr-freight-api/src/modules/overview/overview.controller.ts +++ b/apps/edr-freight-api/src/modules/overview/overview.controller.ts @@ -9,7 +9,13 @@ import { CurrentUser } from '@edr/api-common'; import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; import { BookingStaff } from '../../common/booking-guards'; -import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; +import { hasFreightPermission } from '../../common/freight-permission.util'; +import { + FREIGHT_PERMS, + OVERVIEW_LAYOUT_KEYS, + OVERVIEW_LAYOUT_LABELS, +} from '../../seed/freight-permissions.registry'; +import { OverviewLayoutDto } from './dto/overview-layout.dto'; import { OverviewQueryDto } from './dto/overview-query.dto'; import { OverviewResponseDto } from './dto/overview-response.dto'; import { @@ -34,6 +40,22 @@ export class OverviewController { private readonly userTradeAccessService: UserTradeAccessService, ) {} + /** + * Layouts the caller has permission to render, in priority order — exactly + * the same "server filters by permission, frontend just renders what comes + * back" shape as GET /reports. A caller lands on exactly one layout, so the + * frontend picks the first entry here rather than rendering the whole list. + */ + @Get('layouts') + @BookingStaff(FREIGHT_PERMS.overview.view) + @ApiOperation({ summary: 'Overview dashboard layouts the caller has permission to render' }) + @ApiOkResponse({ type: OverviewLayoutDto, isArray: true }) + getLayouts(@CurrentUser() user: TCurrentUser): OverviewLayoutDto[] { + return OVERVIEW_LAYOUT_KEYS.filter((key) => + hasFreightPermission(user, FREIGHT_PERMS.overview.layout(key)), + ).map((key) => ({ key, label: OVERVIEW_LAYOUT_LABELS[key] })); + } + @Get() @BookingStaff(FREIGHT_PERMS.overview.view) @ApiOperation({ summary: 'Aggregated dashboard summary for backoffice overview' }) diff --git a/apps/edr-freight-api/src/modules/reports/definitions/bookings-list.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/bookings-list.report.ts deleted file mode 100644 index ca476cea3..000000000 --- a/apps/edr-freight-api/src/modules/reports/definitions/bookings-list.report.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; - -import { Booking } from '../../bookings/entities/booking.entity'; -import { CargoType } from '../../rule-engine/entities/cargo-type.entity'; -import { Yard } from '../../rule-engine/entities/yard.entity'; -import { Company } from '../../companies/entities/company.entity'; -import { ReportContext, ReportDefinition } from '../report.types'; - -// For PER_ITEM bulk bookings cargo_total_weight_vgm holds an item COUNT, and -// the real tonnage lives in bulk_total_weight_tons — hence the COALESCE order -// (same guard as the retired report-queries.ts). -const TONS = 'COALESCE(b.bulk_total_weight_tons, b.cargo_total_weight_vgm)'; -// adjusted_total_amount silently overrides total_amount when set. -const REVENUE = 'COALESCE(b.adjusted_total_amount, b.total_amount)'; -// GENERAL contract_kind rows are umbrella contracts, not shipments; counting -// them double-counts every child booking. -const NOT_UMBRELLA = "(b.contract_kind IS NULL OR b.contract_kind <> 'GENERAL')"; -const DEAD_STATUSES = ['DRAFT', 'CANCELLED', 'REJECTED', 'EXPIRED']; - -function applyFilters( - ctx: ReportContext, - qb: SelectQueryBuilder, -): SelectQueryBuilder { - const { params, directions } = ctx; - qb.where(`b.deleted_at IS NULL AND ${NOT_UMBRELLA}`); - 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 (params.direction) qb.andWhere('b.trade_direction = :direction', { direction: params.direction }); - if (params.freightType) qb.andWhere('b.freight_type = :freightType', { freightType: params.freightType }); - const statuses = params.statuses as string[] | null; - if (statuses) { - qb.andWhere('b.status IN (:...statuses)', { statuses }); - } else { - qb.andWhere('b.status NOT IN (:...deadStatuses)', { deadStatuses: DEAD_STATUSES }); - } - if (params.search) { - qb.andWhere('(b.reference ILIKE :search OR c.name ILIKE :search)', { - search: `%${params.search}%`, - }); - } - if (directions !== null) { - qb.andWhere(directions.length ? 'b.trade_direction IN (:...directions)' : '1 = 0', { - directions, - }); - } - return qb; -} - -export const bookingsListReport: ReportDefinition = { - key: 'bookings-list', - title: 'Bookings', - description: 'Every booking with customer, route, cargo and revenue', - group: 'Commercial', - filters: [ - { key: 'date', label: 'Created', type: 'daterange' }, - { - key: 'direction', - label: 'Direction', - type: 'select', - options: [ - { value: 'IMPORT', label: 'Import' }, - { value: 'EXPORT', label: 'Export' }, - { value: 'DOMESTIC', label: 'Domestic' }, - ], - }, - { - key: 'freightType', - label: 'Freight type', - type: 'select', - options: [ - { value: 'CONTAINER', label: 'Container' }, - { value: 'BULK', label: 'Bulk' }, - ], - }, - { key: 'statuses', label: 'Status', type: 'multiselect' }, - { key: 'search', label: 'Search reference or customer', type: 'text' }, - ], - columns: [ - { key: 'reference', label: 'Reference', type: 'string', sortable: true, sortExpr: 'b.reference' }, - { key: 'created', label: 'Created', type: 'date', sortable: true, sortExpr: 'b.created_at' }, - { key: 'customer', label: 'Customer', type: 'string', sortable: true, sortExpr: 'c.name' }, - { key: 'status', label: 'Status', type: 'string', sortable: true, sortExpr: 'b.status' }, - { key: 'direction', label: 'Direction', type: 'string' }, - { key: 'origin', label: 'Origin', type: 'string' }, - { key: 'destination', label: 'Destination', type: 'string' }, - { key: 'cargo', label: 'Cargo', type: 'string' }, - { key: 'tons', label: 'Tonnage', type: 'tons', sortable: true }, - { key: 'amount', label: 'Amount', type: 'money', sortable: true }, - ], - defaultSort: { key: 'created', dir: 'DESC' }, - query(ctx) { - const qb = ctx.ds - .createQueryBuilder() - .select('b.reference', 'reference') - .addSelect(`to_char(b.created_at, 'YYYY-MM-DD')`, 'created') - .addSelect('c.name', 'customer') - .addSelect('b.status', 'status') - .addSelect('b.trade_direction', 'direction') - .addSelect('o.label', 'origin') - .addSelect('d.label', 'destination') - .addSelect('COALESCE(cty.cargo_type_name, b.cargo_free_text)', 'cargo') - .addSelect(`ROUND(${TONS})::float8`, 'tons') - .addSelect(`ROUND(${REVENUE})::float8`, 'amount') - .from(Booking, 'b') - .innerJoin(Company, 'c', 'c.id = b.company_id') - .innerJoin(Yard, 'o', 'o.id = b.origin_yard_id') - .innerJoin(Yard, 'd', 'd.id = b.destination_yard_id') - .leftJoin(CargoType, 'cty', 'cty.id = b.cargo_type_id'); - return applyFilters(ctx, qb); - }, - async summary(ctx) { - const qb = applyFilters( - ctx, - ctx.ds - .createQueryBuilder() - .select('COUNT(*)::int', 'bookings') - .addSelect(`ROUND(COALESCE(SUM(${TONS}), 0))::float8`, 'tons') - .addSelect(`ROUND(COALESCE(SUM(${REVENUE}), 0))::float8`, 'revenue') - .from(Booking, 'b') - .innerJoin(Company, 'c', 'c.id = b.company_id'), - ); - const row = await qb.getRawOne(); - return [ - { label: 'Bookings', value: Number(row?.bookings ?? 0) }, - { label: 'Tonnage', value: Number(row?.tons ?? 0), unit: 't' }, - { label: 'Revenue', value: Number(row?.revenue ?? 0), unit: 'ETB' }, - ]; - }, -}; diff --git a/apps/edr-freight-api/src/modules/reports/definitions/cargo-volume-by-station.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/cargo-volume-by-station.report.ts index dd111d22a..c63a28f05 100644 --- a/apps/edr-freight-api/src/modules/reports/definitions/cargo-volume-by-station.report.ts +++ b/apps/edr-freight-api/src/modules/reports/definitions/cargo-volume-by-station.report.ts @@ -13,6 +13,7 @@ import { TEU_EXPR, allocationLedgerQb, applyCategoryFilter, + attainmentCtx, PLAN_GRANULARITY_NOTE, implementRateExpr, plannedRowsParams, @@ -86,6 +87,7 @@ export const cargoVolumeByStationReport: ReportDefinition = { { key: 'category', label: 'Cargo type', type: 'string', sortable: true }, { key: 'operated', label: 'Operated', type: 'tons', sortable: true }, { key: 'plan', label: 'Plan', type: 'tons' }, + { key: 'planRequired', label: 'Required', type: 'tons' }, { key: 'implementRate', label: 'Implement rate', type: 'percent' }, { key: 'teu', label: 'TEU', type: 'number' }, { key: 'wagons', label: 'Wagons', type: 'number' }, @@ -118,6 +120,18 @@ export const cargoVolumeByStationReport: ReportDefinition = { .addGroupBy(originationExpr(params, 'code')) .addGroupBy(CARGO_CATEGORY_EXPR); + // Attainment for the cascade, keyed the way a station plan is: per station + // AND per cargo type. Unfiltered by date, so a mid-year view still knows + // what the station has already hauled against its target. + const attained = baseQuery(attainmentCtx(ctx)) + .select(periodTruncExprOn(OPS_DATE, params), 'bucket') + .addSelect(stationCode, 'act_key') + .addSelect(CARGO_CATEGORY_EXPR, 'act_category') + .addSelect(`${ACTUAL_TONS_EXPR}`, 'actual') + .groupBy(periodTruncExprOn(OPS_DATE, params)) + .addGroupBy(stationCode) + .addGroupBy(CARGO_CATEGORY_EXPR); + // A station plan is keyed on station AND cargo type, so the join needs // both. Full outer, so a station-and-cargo line that was planned and never // ran still reports its miss — the OCC report is full of those. @@ -134,9 +148,15 @@ export const cargoVolumeByStationReport: ReportDefinition = { COALESCE(o.teu, 0) AS teu, COALESCE(o.wagons, 0) AS wagons, COALESCE(o.trains, 0) AS trains, - p.plan_value AS plan + p.plan_value AS plan, + p.plan_required AS plan_required FROM (${operated.getQuery()}) o - FULL OUTER JOIN (${plannedRowsSql('VOLUME_TONS', 'station', params)}) p + FULL OUTER JOIN (${plannedRowsSql( + 'VOLUME_TONS', + 'station', + params, + attained.getQuery(), + )}) p ON p.period = o.period AND p.plan_key = o.station_code AND p.plan_category = o.category_key`; @@ -144,7 +164,11 @@ export const cargoVolumeByStationReport: ReportDefinition = { return ctx.ds .createQueryBuilder() .from(`(${combined})`, 'r') - .setParameters({ ...operated.getParameters(), ...plannedRowsParams(params) }) + .setParameters({ + ...operated.getParameters(), + ...attained.getParameters(), + ...plannedRowsParams(params), + }) .select('r.period', 'period') .addSelect('r.station', 'station') .addSelect('r.origination', 'origination') @@ -152,6 +176,7 @@ export const cargoVolumeByStationReport: ReportDefinition = { .addSelect('r.category_key', 'categoryKey') .addSelect('r.operated::float8', 'operated') .addSelect('r.plan::float8', 'plan') + .addSelect('r.plan_required::float8', 'planRequired') .addSelect(implementRateExpr('r.operated', 'r.plan'), 'implementRate') .addSelect('r.teu::int', 'teu') .addSelect('r.wagons::int', 'wagons') diff --git a/apps/edr-freight-api/src/modules/reports/definitions/cargo-volume-performance.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/cargo-volume-performance.report.ts index 68c255c11..2bb02b859 100644 --- a/apps/edr-freight-api/src/modules/reports/definitions/cargo-volume-performance.report.ts +++ b/apps/edr-freight-api/src/modules/reports/definitions/cargo-volume-performance.report.ts @@ -13,6 +13,7 @@ import { TEU_EXPR, allocationLedgerQb, applyCategoryFilter, + attainmentCtx, PLAN_GRANULARITY_NOTE, implementRateExpr, plannedRowsParams, @@ -42,6 +43,7 @@ export const cargoVolumePerformanceReport: ReportDefinition = { { key: 'category', label: 'Cargo category', type: 'string', sortable: true }, { key: 'operated', label: 'Operated', type: 'tons', sortable: true }, { key: 'plan', label: 'Plan', type: 'tons' }, + { key: 'planRequired', label: 'Required', type: 'tons' }, { key: 'implementRate', label: 'Implement rate', type: 'percent' }, { key: 'chargedTons', label: 'Charged volume', type: 'tons', sortable: true }, { key: 'teu', label: 'TEU', type: 'number', sortable: true }, @@ -63,6 +65,17 @@ export const cargoVolumePerformanceReport: ReportDefinition = { .groupBy(bucket) .addGroupBy(CARGO_CATEGORY_EXPR); + // What the cascade measures attainment from: the same tonnage, over the + // target's whole period rather than the user's date window. Bucketed on the + // block start, not the label, so it joins the plan on a real timestamp. + const attained = baseQuery(attainmentCtx(ctx)) + .select(periodTruncExprOn(OPS_DATE, ctx.params), 'bucket') + .addSelect(CARGO_CATEGORY_EXPR, 'act_key') + .addSelect('NULL::varchar', 'act_category') + .addSelect(`${ACTUAL_TONS_EXPR}`, 'actual') + .groupBy(periodTruncExprOn(OPS_DATE, ctx.params)) + .addGroupBy(CARGO_CATEGORY_EXPR); + // Full outer join so a planned cargo category that moved nothing still // reports its miss instead of disappearing from the table. const combined = ` @@ -73,20 +86,31 @@ export const cargoVolumePerformanceReport: ReportDefinition = { COALESCE(o.teu, 0) AS teu, COALESCE(o.wagons, 0) AS wagons, COALESCE(o.trains, 0) AS trains, - p.plan_value AS plan + p.plan_value AS plan, + p.plan_required AS plan_required FROM (${operated.getQuery()}) o - FULL OUTER JOIN (${plannedRowsSql('VOLUME_TONS', 'cargo_category', ctx.params)}) p + FULL OUTER JOIN (${plannedRowsSql( + 'VOLUME_TONS', + 'cargo_category', + ctx.params, + attained.getQuery(), + )}) p ON p.period = o.period AND p.plan_key = o.category_key`; return ctx.ds .createQueryBuilder() .from(`(${combined})`, 'r') - .setParameters({ ...operated.getParameters(), ...plannedRowsParams(ctx.params) }) + .setParameters({ + ...operated.getParameters(), + ...attained.getParameters(), + ...plannedRowsParams(ctx.params), + }) .select('r.period', 'period') .addSelect(CATEGORY_LABEL_OF('r.category_key'), 'category') .addSelect('r.category_key', 'categoryKey') .addSelect('r.operated::float8', 'operated') .addSelect('r.plan::float8', 'plan') + .addSelect('r.plan_required::float8', 'planRequired') .addSelect(implementRateExpr('r.operated', 'r.plan'), 'implementRate') .addSelect('r.charged_tons::float8', 'chargedTons') .addSelect('r.teu::int', 'teu') 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 deleted file mode 100644 index 513abeda4..000000000 --- a/apps/edr-freight-api/src/modules/reports/definitions/contract-lifecycle.report.ts +++ /dev/null @@ -1,83 +0,0 @@ -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 deleted file mode 100644 index 60b6ff31a..000000000 --- a/apps/edr-freight-api/src/modules/reports/definitions/customer-status.report.ts +++ /dev/null @@ -1,67 +0,0 @@ -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/invoices-by-status.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/invoices-by-status.report.ts deleted file mode 100644 index 81b183f90..000000000 --- a/apps/edr-freight-api/src/modules/reports/definitions/invoices-by-status.report.ts +++ /dev/null @@ -1,72 +0,0 @@ -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/payments-by-status.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/payments-by-status.report.ts deleted file mode 100644 index 15e99a4b1..000000000 --- a/apps/edr-freight-api/src/modules/reports/definitions/payments-by-status.report.ts +++ /dev/null @@ -1,73 +0,0 @@ -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/receivables-payables.report.spec.ts b/apps/edr-freight-api/src/modules/reports/definitions/receivables-payables.report.spec.ts new file mode 100644 index 000000000..76edd2238 --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/definitions/receivables-payables.report.spec.ts @@ -0,0 +1,71 @@ +import { WAGON_CANCELLATION_STATUSES } from '../../bookings/entities/booking-wagon-cancellation.entity'; +import { ShippingLineCreditStatus } from '../../shipping-lines/entities/shipping-line-credit.entity'; +import { + CREDIT_LIABILITY_STATUS, + INVOICE_SIDE_EXPR, + LEDGER_SIDES, + UNINVOICED_CREDIT_STATUS, + receivablesPayablesReport, +} from './receivables-payables.report'; + +/** + * The report's whole point is the sign of the money: a cancellation FEE is + * owed TO EDR, and the cancelled freight is owed BACK to the customer as + * bookable credit. These tests pin the two down at the string level — the SQL + * itself is validated against the database, not here. + */ +describe('receivables-payables report', () => { + it('treats exactly one wagon-cancellation status as a liability', () => { + expect(WAGON_CANCELLATION_STATUSES).toContain(CREDIT_LIABILITY_STATUS); + // Every other status owes nothing: nothing cut yet (FEE_PENDING), redeemed + // (REBOOKED), or voided (WITHDRAWN / EXPIRED). If a new status appears, + // this fails until someone decides which side of the ledger it lands on. + expect(WAGON_CANCELLATION_STATUSES.filter((s) => s !== CREDIT_LIABILITY_STATUS).sort()).toEqual( + ['EXPIRED', 'FEE_PENDING', 'REBOOKED', 'WITHDRAWN'], + ); + }); + + it('counts only the shipping-line credit status that has no invoice behind it', () => { + expect(UNINVOICED_CREDIT_STATUS).toBe(ShippingLineCreditStatus.Unbilled); + // BILLED is debt too, but it is counted through its invoice on the invoice + // branch — taking it here as well would double it. + expect(UNINVOICED_CREDIT_STATUS).not.toBe(ShippingLineCreditStatus.Billed); + }); + + it('never classifies the cancellation fee as a payable', () => { + // The fee invoice rides the booking's invoice list; while it is open it is + // an ordinary receivable balance, and it must not reach a PAYABLE arm. + expect(INVOICE_SIDE_EXPR).not.toContain('WAGON_CANCEL_FEE'); + expect(INVOICE_SIDE_EXPR).not.toContain('CANCELLATION_FEE'); + }); + + it('does not double-count a booking already carried by the cancellation ledger', () => { + expect(INVOICE_SIDE_EXPR).toContain('NOT EXISTS'); + expect(INVOICE_SIDE_EXPR).toContain('booking_wagon_cancellations'); + }); + + it('emits exactly the side keys the filter offers', () => { + const declared = LEDGER_SIDES.map((s) => s.value).sort(); + expect(declared).toEqual([ + 'PAYABLE_PREPAID', + 'PAYABLE_WAGON_CREDIT', + 'RECEIVABLE_OPEN', + 'RECEIVABLE_SL_INVOICED', + 'RECEIVABLE_SL_UNBILLED', + ]); + // The summary KPIs split on these prefixes; a key matching neither would + // silently vanish from both totals. + for (const key of declared) { + expect(key.startsWith('RECEIVABLE') || key.startsWith('PAYABLE')).toBe(true); + } + }); + + it('sorts on the union wrapper, never on a branch-local alias', () => { + // The runner appends ORDER BY outside the union subquery, where `i.*`, + // `b.*` and `bwc.*` do not exist. + for (const col of receivablesPayablesReport.columns) { + if (!col.sortExpr) continue; + expect(col.sortExpr).toMatch(/^r\./); + } + }); +}); diff --git a/apps/edr-freight-api/src/modules/reports/definitions/receivables-payables.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/receivables-payables.report.ts index 018a68faf..0e07710bb 100644 --- a/apps/edr-freight-api/src/modules/reports/definitions/receivables-payables.report.ts +++ b/apps/edr-freight-api/src/modules/reports/definitions/receivables-payables.report.ts @@ -1,5 +1,12 @@ import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; +import { Booking } from '../../bookings/entities/booking.entity'; +import { BookingWagonCancellation } from '../../bookings/entities/booking-wagon-cancellation.entity'; +import { Company } from '../../companies/entities/company.entity'; +import { Yard } from '../../rule-engine/entities/yard.entity'; +import { ShippingLineCompany } from '../../shipping-lines/entities/shipping-line-company.entity'; +import { ShippingLineCredit } from '../../shipping-lines/entities/shipping-line-credit.entity'; +import { directionScopeSql } from '../../user-trade-access/trade-scope.util'; import { ReportContext, ReportDefinition, ReportFilterOption } from '../report.types'; import { PAYER_EXPR, @@ -10,47 +17,287 @@ import { } from '../revenue-classification'; export const LEDGER_SIDES: ReportFilterOption[] = [ - { value: 'RECEIVABLE_CREDIT', label: 'Receivable — credit service (shipping line)' }, - { value: 'RECEIVABLE_OPEN', label: 'Receivable — open balance' }, - { value: 'PAYABLE_CANCELLATION', label: 'Payable — cancellation fee' }, - { value: 'PAYABLE_UNDELIVERED', label: 'Payable — paid but not delivered' }, - { value: 'SETTLED', label: 'Settled' }, + { + value: 'RECEIVABLE_SL_UNBILLED', + label: 'Receivable — shipping-line service, not yet invoiced', + }, + { + value: 'RECEIVABLE_SL_INVOICED', + label: 'Receivable — shipping-line invoice open', + }, + { value: 'RECEIVABLE_OPEN', label: 'Receivable — open invoice balance' }, + { + value: 'PAYABLE_WAGON_CREDIT', + label: 'Payable — unapplied wagon-cancellation credit', + }, + { value: 'PAYABLE_PREPAID', label: 'Payable — paid but not delivered' }, ]; /** - * Which side of the ledger an invoice sits on. + * Which side of the ledger a row sits on, and why the report is a union of + * three fact tables rather than a CASE over `invoices`. * - * Receivable = EDR delivered and is owed money — the shipping-line credit - * arrangement, plus any invoice still carrying a balance. - * Payable = the customer paid for something EDR did not deliver, so the money - * is a refund liability rather than revenue: cancellation fees, and prepaid - * invoices whose booking died. + * RECEIVABLE — money EDR is owed. The shipping-line arrangement is service + * first, pay later, and it produces debt in two shapes: a `shipping_line_credits` + * row with NO invoice while it is UNBILLED (a shipping-line booking raises no + * invoice at all), and an open batch invoice once finance bills it. Counting + * only the second understates the debt by everything not yet batched. Ordinary + * open invoice balances are the third shape — including the wagon-cancellation + * FEE, which is money the customer owes EDR, never a liability. + * + * PAYABLE — the customer paid and did not get the service. Wagon cancellation + * never refunds cash: the cancelled freight becomes a rebooking credit that is + * redeemed by creating another booking (see BookingWagonCancellationService). + * So the liability is exactly the cancellations sitting in CREDIT_AVAILABLE — + * fee settled, wagons freed, credit not yet applied — valued at `credit_amount`, + * and it disappears the moment the row turns REBOOKED. The source invoice is + * useless for this: a whole-booking cut leaves it PAID at its full amount + * forever, which is neither the right number nor the right lifetime. + * + * Fully settled invoices are not rows here. A zero-exposure invoice is neither + * a receivable nor a payable; Invoicing Pipeline is the report that lists them. */ -const SIDE_EXPR = `CASE - WHEN i.source = 'shipping_line_credit' OR i.type = 'SHIPPING_LINE_CREDIT' - THEN 'RECEIVABLE_CREDIT' - WHEN i.type = 'WAGON_CANCEL_FEE' THEN 'PAYABLE_CANCELLATION' - WHEN i.paid_amount > 0 AND b.status IN ('CANCELLED', 'REJECTED', 'EXPIRED') - THEN 'PAYABLE_UNDELIVERED' - WHEN i.balance_amount > 0 THEN 'RECEIVABLE_OPEN' - ELSE 'SETTLED' -END`; - const LABELS = new Map(LEDGER_SIDES.map((s) => [s.value, s.label])); -const SIDE_LABEL_EXPR = `CASE ${SIDE_EXPR} - ${[...LABELS].map(([value, label]) => `WHEN '${value}' THEN '${label.replace(/'/g, "''")}'`).join('\n ')} + +/** Labels a side key that is already a column — the union is classified inside, labelled outside. */ +const SIDE_LABEL_OF = (keyExpr: string): string => + `CASE ${keyExpr}\n ${[...LABELS] + .map(([value, label]) => `WHEN '${value}' THEN '${label.replace(/'/g, "''")}'`) + .join('\n ')}\nEND`; + +/** + * Statuses that cannot become cash. EXPIRED closed its own pay window and + * REFUNDED already gave the money back, so neither is owed in either + * direction. Filtered here rather than in the shared DEAD_INVOICE_STATUSES — + * that constant feeds every revenue report and those invoices did earn revenue. + */ +const UNCOLLECTABLE_INVOICE_STATUSES = "('EXPIRED', 'REFUNDED')"; + +/** + * A booking whose money is accounted for by the cancellation ledger instead. + * Without this, a whole-booking wagon cancellation would be counted twice: once + * as its own CREDIT_AVAILABLE credit, and again as the source booking's paid + * invoice sitting against a CANCELLED booking — and the second copy would never + * clear, because rebooking updates the ledger row, not the old invoice. + */ +const HAS_CANCELLATION_LEDGER = `EXISTS ( + SELECT 1 FROM freight.booking_wagon_cancellations bwc0 + WHERE bwc0.booking_id = b.id + AND bwc0.deleted_at IS NULL + AND bwc0.status <> 'WITHDRAWN' +)`; + +/** Customer paid, booking died, and no cancellation credit represents it. */ +const PREPAID_DEAD = `i.paid_amount > 0 + AND b.status IN ('CANCELLED', 'REJECTED', 'EXPIRED') + AND NOT ${HAS_CANCELLATION_LEDGER}`; + +export const INVOICE_SIDE_EXPR = `CASE + WHEN i.source = 'shipping_line_credit' OR i.type = 'SHIPPING_LINE_CREDIT' + THEN 'RECEIVABLE_SL_INVOICED' + WHEN ${PREPAID_DEAD} THEN 'PAYABLE_PREPAID' + ELSE 'RECEIVABLE_OPEN' END`; -/** Money at stake on this row: what is owed, or what may have to be given back. */ -const EXPOSURE = `CASE - WHEN ${SIDE_EXPR} LIKE 'PAYABLE%' THEN i.paid_amount - ELSE i.balance_amount -END`; +/** + * The union's column contract, in positional order. + * + * UNION matches by POSITION, and TypeORM does not preserve `addSelect` order — + * it hoists a branch's repeated expressions to the front, which silently + * rearranged one branch into `gross, exposure, side_key, …` and failed with + * "UNION types text and numeric cannot be matched". Every branch is therefore + * re-projected through this list by name before it is unioned. + */ +const UNION_COLUMNS = [ + 'side_key', + 'txn_date', + 'doc_ref', + 'booking_ref', + 'booking_status', + 'payer', + 'gross', + 'settled', + 'exposure', +] as const; +/** + * The one wagon-cancellation status that is a live liability: the fee is + * settled and the booking cut, but the credit has not been turned into a + * booking yet. FEE_PENDING has cut nothing, REBOOKED has been redeemed, and + * WITHDRAWN/EXPIRED owe nothing. + */ +export const CREDIT_LIABILITY_STATUS = 'CREDIT_AVAILABLE'; + +/** + * Shipping-line credit status that is debt with no invoice behind it. BILLED + * credits are counted through their invoice on branch A, which is what keeps + * the two shipping-line sides disjoint. + */ +export const UNINVOICED_CREDIT_STATUS = 'UNBILLED'; + +/** Applies the filters branches B and C share with {@link invoiceLedgerQb}. */ +function applySharedFilters( + qb: SelectQueryBuilder, + ctx: ReportContext, + dateExpr: string, +): SelectQueryBuilder { + const { params, directions } = ctx; + + if (params.dateFrom) qb.andWhere(`${dateExpr} >= :dateFrom`, { dateFrom: params.dateFrom }); + if (params.dateTo) qb.andWhere(`${dateExpr} < :dateTo`, { dateTo: params.dateTo }); + if (params.origin) qb.andWhere('oy.code = :origin', { origin: params.origin }); + if (params.destination) { + qb.andWhere('dy.code = :destination', { destination: params.destination }); + } + if (params.customer) { + qb.andWhere( + '(co.name ILIKE :customer OR slc.name ILIKE :customer OR b.reference ILIKE :customer)', + { customer: `%${params.customer as string}%` }, + ); + } + + // An umbrella general contract is paid once and drawn down by many orders — + // same exclusion invoiceLedgerQb applies on branch A. + qb.andWhere("(b.id IS NULL OR b.contract_kind IS NULL OR b.contract_kind <> 'GENERAL')"); + + // Both branches reach their booking directly, so the direction scope is the + // plain column form, not the source_id-pointer form invoices need. A row + // whose booking is gone carries no direction to scope by and stays visible — + // the same rule applyBookingRefDirectionScope applies on branch A. + const scope = directionScopeSql('b.trade_direction', directions); + qb.andWhere(`(b.id IS NULL OR ${scope.sql})`, scope.params); + + return qb; +} + +/** Branch A — invoices carrying a balance, plus prepayments against dead bookings. */ +function invoiceBranch(ctx: ReportContext): SelectQueryBuilder { + return invoiceLedgerQb(ctx) + .andWhere(`i.status NOT IN ${UNCOLLECTABLE_INVOICE_STATUSES}`) + .andWhere(`(i.balance_amount > 0 OR (${PREPAID_DEAD}))`) + .select(INVOICE_SIDE_EXPR, 'side_key') + .addSelect(REVENUE_DATE, 'txn_date') + .addSelect('i.invoice_number', 'doc_ref') + .addSelect("COALESCE(b.reference, '—')", 'booking_ref') + .addSelect("COALESCE(b.status, '—')", 'booking_status') + .addSelect(PAYER_EXPR, 'payer') + .addSelect('i.total_amount', 'gross') + .addSelect('i.paid_amount', 'settled') + .addSelect( + `CASE WHEN ${PREPAID_DEAD} THEN i.paid_amount ELSE i.balance_amount END`, + 'exposure', + ); +} + +/** + * Branch B — shipping-line services used but never invoiced. + * + * The credit row IS the debt while it is UNBILLED; BILLED rows are the ones + * behind an invoice and are already counted by branch A, so taking only + * UNBILLED here is what keeps the two shipping-line sides disjoint. + */ +function unbilledCreditBranch(ctx: ReportContext): SelectQueryBuilder { + const qb = ctx.ds + .createQueryBuilder() + .from(ShippingLineCredit, 'slc_c') + .leftJoin(Booking, 'b', 'b.id = slc_c.booking_id AND b.deleted_at IS NULL') + .leftJoin(Yard, 'oy', 'oy.id = b.origin_yard_id') + .leftJoin(Yard, 'dy', 'dy.id = b.destination_yard_id') + .leftJoin(Company, 'co', 'co.id = b.company_id') + .leftJoin(ShippingLineCompany, 'slc', 'slc.id = slc_c.shipping_line_company_id') + .where('slc_c.deleted_at IS NULL') + .andWhere('slc_c.status = :uninvoicedCreditStatus', { + uninvoicedCreditStatus: UNINVOICED_CREDIT_STATUS, + }) + .andWhere('slc_c.currency = :currency', { + currency: currencyOf(ctx.params), + }); + + // Priced when the service was used; that is the date the debt was incurred. + applySharedFilters(qb, ctx, 'slc_c.created_at'); + + return qb + .select("'RECEIVABLE_SL_UNBILLED'", 'side_key') + .addSelect('slc_c.created_at', 'txn_date') + .addSelect("'—'", 'doc_ref') + .addSelect("COALESCE(b.reference, '—')", 'booking_ref') + .addSelect("COALESCE(b.status, '—')", 'booking_status') + .addSelect("COALESCE(slc.name, 'Unknown')", 'payer') + .addSelect('slc_c.amount', 'gross') + .addSelect('0::numeric', 'settled') + .addSelect('slc_c.amount', 'exposure'); +} + +/** + * Branch C — cancelled wagons whose credit has not been rebooked. + * + * `credit_amount` is priced in the BOOKING's payment currency, not + * `fee_currency` — that one prices the cancellation fee, which is a separate + * (and opposite-signed) piece of money. + */ +function wagonCreditBranch(ctx: ReportContext): SelectQueryBuilder { + const qb = ctx.ds + .createQueryBuilder() + .from(BookingWagonCancellation, 'bwc') + .innerJoin(Booking, 'b', 'b.id = bwc.booking_id AND b.deleted_at IS NULL') + .leftJoin(Yard, 'oy', 'oy.id = b.origin_yard_id') + .leftJoin(Yard, 'dy', 'dy.id = b.destination_yard_id') + .leftJoin(Company, 'co', 'co.id = b.company_id') + .leftJoin(ShippingLineCompany, 'slc', 'slc.id = b.shipping_line_company_id') + .where('bwc.deleted_at IS NULL') + .andWhere('bwc.status = :creditLiabilityStatus', { + creditLiabilityStatus: CREDIT_LIABILITY_STATUS, + }) + .andWhere("COALESCE(b.payment_currency, 'ETB') = :currency", { + currency: currencyOf(ctx.params), + }); + + // The credit exists from the moment the fee settled and the booking was cut. + applySharedFilters(qb, ctx, 'COALESCE(bwc.fee_paid_at, bwc.created_at)'); + + return ( + qb + .select("'PAYABLE_WAGON_CREDIT'", 'side_key') + .addSelect('COALESCE(bwc.fee_paid_at, bwc.created_at)', 'txn_date') + // numeric(6,2) renders as "2.00"; a wagon count reads as "2" (and "2.5" + // survives, because a half wagon is a real bulk quantity here). + .addSelect( + `rtrim(rtrim(bwc.wagons_cancelled::text, '0'), '.') || ' wagon(s) cancelled'`, + 'doc_ref', + ) + .addSelect("COALESCE(b.reference, '—')", 'booking_ref') + .addSelect("COALESCE(b.status, '—')", 'booking_status') + .addSelect(PAYER_EXPR, 'payer') + // The freight was paid in full on the original booking, so the whole + // credit is money already in hand and owed back as bookable value. + .addSelect('bwc.credit_amount', 'gross') + .addSelect('bwc.credit_amount', 'settled') + .addSelect('bwc.credit_amount', 'exposure') + ); +} + +/** + * The three branches as one relation, wrapped so the runner can sort, page and + * COUNT(*) it like any other report query. + * + * Parameters are merged from every branch: `getQuery()` leaves `:name` + * placeholders in place, and only the outer builder's parameter bag is read + * when the SQL is finally bound. + */ function baseQuery(ctx: ReportContext): SelectQueryBuilder { - const qb = invoiceLedgerQb(ctx); + const branches = [invoiceBranch(ctx), unbilledCreditBranch(ctx), wagonCreditBranch(ctx)]; + const combined = branches + .map((b, idx) => `SELECT ${UNION_COLUMNS.join(', ')} FROM (${b.getQuery()}) branch_${idx}`) + .join('\n UNION ALL\n '); + + const qb = ctx.ds + .createQueryBuilder() + .from(`(${combined})`, 'r') + .setParameters(Object.assign({}, ...branches.map((b) => b.getParameters()))); + const sides = ctx.params.sides as string[] | null; - if (sides?.length) qb.andWhere(`${SIDE_EXPR} IN (:...sides)`, { sides }); + if (sides?.length) qb.andWhere('r.side_key IN (:...sides)', { sides }); + return qb; } @@ -58,57 +305,113 @@ export const receivablesPayablesReport: ReportDefinition = { key: 'receivables-payables', title: 'Receivables and Payables', description: - 'Splits customer money two ways: receivable, where EDR delivered and is owed — ' + - 'including shipping-line credit services — and payable, where the customer paid but ' + - 'the service was not delivered, such as cancellation fees and prepayments against ' + - 'dead bookings. Payable amounts are a refund liability, not revenue.', + 'Splits open customer money two ways: receivable, where EDR delivered and is owed — ' + + 'shipping-line credit services whether invoiced yet or not, plus any invoice still ' + + 'carrying a balance — and payable, where the customer paid and the service was not ' + + 'delivered. The payable is dominated by wagon cancellations whose credit has not been ' + + 'rebooked; that credit is redeemed by creating another booking, never refunded in cash.', group: 'Finance', filters: [ ...REVENUE_FILTERS.filter((f) => f.key !== 'categories' && f.key !== 'methods'), - { key: 'sides', label: 'Ledger side', type: 'multiselect', options: LEDGER_SIDES }, + { + key: 'sides', + label: 'Ledger side', + type: 'multiselect', + options: LEDGER_SIDES, + }, ], columns: [ - { key: 'side', label: 'Ledger side', type: 'string', sortable: true, sortExpr: SIDE_EXPR }, - { key: 'issuedAt', label: 'Issued', type: 'date', sortable: true, sortExpr: REVENUE_DATE }, - { key: 'invoiceNumber', label: 'Invoice No.', type: 'string', sortable: true, sortExpr: 'i.invoice_number' }, + { + key: 'side', + label: 'Ledger side', + type: 'string', + sortable: true, + sortExpr: 'r.side_key', + }, + { + key: 'issuedAt', + label: 'Date', + type: 'date', + sortable: true, + sortExpr: 'r.txn_date', + }, + { + key: 'invoiceNumber', + label: 'Invoice / ref', + type: 'string', + sortable: true, + sortExpr: 'r.doc_ref', + }, { key: 'bookingRef', label: 'Booking', type: 'string' }, { key: 'bookingStatus', label: 'Booking status', type: 'string' }, - { key: 'customer', label: 'Payer', type: 'string', sortable: true, sortExpr: PAYER_EXPR }, - { key: 'invoiced', label: 'Invoiced', type: 'money', sortable: true, sortExpr: 'i.total_amount' }, - { key: 'paid', label: 'Paid', type: 'money', sortable: true, sortExpr: 'i.paid_amount' }, - { key: 'exposure', label: 'Owed / refundable', type: 'money', sortable: true, sortExpr: EXPOSURE }, + { + key: 'customer', + label: 'Payer', + type: 'string', + sortable: true, + sortExpr: 'r.payer', + }, + { + key: 'invoiced', + label: 'Amount', + type: 'money', + sortable: true, + sortExpr: 'r.gross', + }, + { + key: 'paid', + label: 'Paid', + type: 'money', + sortable: true, + sortExpr: 'r.settled', + }, + { + key: 'exposure', + label: 'Owed / refundable', + type: 'money', + sortable: true, + sortExpr: 'r.exposure', + }, ], defaultSort: { key: 'exposure', dir: 'DESC' }, chart: { type: 'bar', x: 'side', y: ['exposure'] }, query(ctx) { return baseQuery(ctx) - .select(SIDE_LABEL_EXPR, 'side') - .addSelect(`to_char(${REVENUE_DATE}, 'YYYY-MM-DD')`, 'issuedAt') - .addSelect('i.invoice_number', 'invoiceNumber') - .addSelect("COALESCE(b.reference, '—')", 'bookingRef') - .addSelect("COALESCE(b.status, '—')", 'bookingStatus') - .addSelect(PAYER_EXPR, 'customer') - .addSelect('ROUND(i.total_amount, 2)::float8', 'invoiced') - .addSelect('ROUND(i.paid_amount, 2)::float8', 'paid') - .addSelect(`ROUND(${EXPOSURE}, 2)::float8`, 'exposure'); + .select(SIDE_LABEL_OF('r.side_key'), 'side') + .addSelect("to_char(r.txn_date, 'YYYY-MM-DD')", 'issuedAt') + .addSelect('r.doc_ref', 'invoiceNumber') + .addSelect('r.booking_ref', 'bookingRef') + .addSelect('r.booking_status', 'bookingStatus') + .addSelect('r.payer', 'customer') + .addSelect('ROUND(r.gross, 2)::float8', 'invoiced') + .addSelect('ROUND(r.settled, 2)::float8', 'paid') + .addSelect('ROUND(r.exposure, 2)::float8', 'exposure'); }, async summary(ctx) { const row = await baseQuery(ctx) .select( - `ROUND(COALESCE(SUM(${EXPOSURE}) FILTER (WHERE ${SIDE_EXPR} LIKE 'RECEIVABLE%'), 0))::float8`, + "ROUND(COALESCE(SUM(r.exposure) FILTER (WHERE r.side_key LIKE 'RECEIVABLE%'), 0))::float8", 'receivable', ) .addSelect( - `ROUND(COALESCE(SUM(${EXPOSURE}) FILTER (WHERE ${SIDE_EXPR} LIKE 'PAYABLE%'), 0))::float8`, + "ROUND(COALESCE(SUM(r.exposure) FILTER (WHERE r.side_key LIKE 'PAYABLE%'), 0))::float8", 'payable', ) - .addSelect('COUNT(*)::int', 'invoices') - .getRawOne<{ receivable: number; payable: number; invoices: number }>(); + .addSelect('COUNT(*)::int', 'items') + .getRawOne<{ receivable: number; payable: number; items: number }>(); + + const receivable = Number(row?.receivable ?? 0); + const payable = Number(row?.payable ?? 0); const currency = currencyOf(ctx.params); return [ - { label: 'Receivable', value: Number(row?.receivable ?? 0), unit: currency }, - { label: 'Payable', value: Number(row?.payable ?? 0), unit: currency }, - { label: 'Invoices', value: Number(row?.invoices ?? 0) }, + { label: 'Receivable', value: receivable, unit: currency }, + { label: 'Payable', value: payable, unit: currency }, + { + label: 'Net position', + value: Math.round(receivable - payable), + unit: currency, + }, + { label: 'Open items', value: Number(row?.items ?? 0) }, ]; }, }; diff --git a/apps/edr-freight-api/src/modules/reports/definitions/revenue-by-category.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/revenue-by-category.report.ts index 0a46f15ec..151cd4cc3 100644 --- a/apps/edr-freight-api/src/modules/reports/definitions/revenue-by-category.report.ts +++ b/apps/edr-freight-api/src/modules/reports/definitions/revenue-by-category.report.ts @@ -3,9 +3,10 @@ import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; import { ReportContext, ReportDefinition } from '../report.types'; import { AVG_PER_UNIT_EXPR, - CATEGORY_LABEL_EXPR, + CATEGORY_LABEL_OF, CONTAINERS_EXPR, PERIOD_FILTER, + REVENUE_CATEGORIES, REVENUE_CATEGORY_EXPR, REVENUE_FILTERS, REVENUE_SUM, @@ -21,19 +22,35 @@ import { const REVENUE = 'SUM(il.amount)'; /** - * Previous period's revenue for the same category. + * Previous period's revenue for the same category, over the zero-filled grid. * - * Postgres evaluates window functions after GROUP BY, so `lag(SUM(...))` is - * legal alongside the SUM — no self-join, no CTE. Both the PARTITION BY and the - * ORDER BY must repeat their grouping expressions verbatim: ordering by the - * inner `date_trunc` when the group key is the `to_char` wrapper fails, and - * ordinal shorthand (`ORDER BY 1`) is read as a constant inside a window - * clause, silently producing an unordered partition. + * The window runs in the OUTER query, not alongside the aggregate. `lag()` only + * ever sees the rows its own query level produces, so computing it inside the + * aggregate would skip straight over a category's silent periods — a category + * billed in January and March would read March's prior as January and report + * flat growth, hiding the month it earned nothing. Against the grid, February + * exists at zero and both comparisons are real. */ -const priorRevenue = (period: string): string => - `lag(${REVENUE}) OVER (PARTITION BY ${REVENUE_CATEGORY_EXPR} ORDER BY ${period})`; +const PRIOR_REVENUE = 'lag(r.revenue) OVER (PARTITION BY r.category_key ORDER BY r.period)'; -const growthPct = (period: string): string => growthPctExpr(REVENUE, priorRevenue(period)); +/** + * Every category the grid must carry, narrowed to the caller's selection. + * + * This is where the `categories` filter is enforced for the table — the grid + * lists only what the caller asked for, and the join back to the aggregate + * drops the rest. See {@link revenueByCategoryReport.query} for why the filter + * cannot also be left on the aggregate. + * + * Intersected in JS against the constant list rather than interpolating the + * request's own values: the grid spells its categories into the SQL text, and a + * user-supplied string must never land there. An unrecognised value simply + * drops out — the ledger would match nothing on it anyway. + */ +const gridCategoryKeys = (params: Record): string[] => { + const selected = params.categories as string[] | null; + const all = REVENUE_CATEGORIES.map((c) => c.value); + return selected?.length ? all.filter((key) => selected.includes(key)) : all; +}; function baseQuery(ctx: ReportContext): SelectQueryBuilder { return revenueLedgerQb(ctx); @@ -44,7 +61,9 @@ export const revenueByCategoryReport: ReportDefinition = { title: 'Revenue by Category', description: 'Billed revenue in the twelve rail revenue categories, per period, with volume and ' + - 'period-over-period growth. Growth compares against the previous period inside the ' + + 'period-over-period growth. Every category is listed in every period that has revenue, ' + + 'at zero when it was not billed, so a category going quiet reads as a drop rather than ' + + 'a missing row. Growth compares against the previous period inside the ' + 'selected date range, so the earliest period always reads zero. ' + 'Multimodal means a named sea carrier is on the booking.', group: 'Finance', @@ -81,21 +100,89 @@ export const revenueByCategoryReport: ReportDefinition = { }, query(ctx) { const period = periodExpr(ctx.params); - return baseQuery(ctx) + + /* + * One row per period/category that actually has lines. Revenue stays + * unrounded here so the growth window below divides the same numbers the + * old single-level query did; the display rounding happens in the wrapper. + * + * The category filter is deliberately dropped from this aggregate and + * applied by the grid instead. The period axis is built from whatever + * periods this aggregate produces, so filtering here would make the axis + * depend on the selection — pick a category that was never billed and + * there would be no periods left to hang its zero rows on, which is + * exactly the empty table the grid exists to prevent. Unselected + * categories still cost nothing: the grid never lists them, so the join + * drops them. + */ + const agg = revenueLedgerQb({ ...ctx, params: { ...ctx.params, categories: null } }) .select(period, 'period') - .addSelect(CATEGORY_LABEL_EXPR, 'category') - .addSelect(REVENUE_CATEGORY_EXPR, 'categoryKey') - .addSelect(`ROUND(${REVENUE})::float8`, 'revenue') - .addSelect(`ROUND(COALESCE(${priorRevenue(period)}, 0))::float8`, 'priorRevenue') - .addSelect(`COALESCE(${growthPct(period)}, 0)`, 'growthPct') + .addSelect(REVENUE_CATEGORY_EXPR, 'category_key') + .addSelect(REVENUE, 'revenue') .addSelect(`ROUND(COALESCE(${TONS_EXPR}, 0), 1)::float8`, 'tons') .addSelect(`ROUND(COALESCE(${TEU_EXPR}, 0))::int`, 'teu') .addSelect(`ROUND(COALESCE(${CONTAINERS_EXPR}, 0))::int`, 'containers') - .addSelect(`COALESCE(${AVG_PER_UNIT_EXPR}, 0)`, 'avgPerUnit') + .addSelect(`COALESCE(${AVG_PER_UNIT_EXPR}, 0)`, 'avg_per_unit') .addSelect(UNIT_LABEL_EXPR, 'unit') .addSelect('COUNT(*)::int', 'lines') .groupBy(period) .addGroupBy(REVENUE_CATEGORY_EXPR); + + const categoryKeys = gridCategoryKeys(ctx.params) + .map((key) => `'${key}'`) + .join(', '); + + /* + * The grid: every period that has revenue at all, crossed with every + * category the filter allows, then LEFT JOINed back to the aggregate so an + * unbilled category lands at zero instead of vanishing. + * + * Periods come from the data, NOT from generate_series over the date + * filter. A default twelve-month range over a database with one billed + * month would otherwise publish eleven months of pure zeros, and a daily + * granularity would multiply that by thirty. A period that saw no revenue + * in ANY category is still absent; a category that saw none in a live + * period is not — and because the aggregate above ignores the category + * filter, "live" means live for the business, not live for the selection. + * + * `unnest(ARRAY[...])` rather than `VALUES` because an empty array is legal + * and yields no rows — `VALUES` with nothing in it is a syntax error, and a + * filter naming only unrecognised categories produces exactly that list. + */ + const grid = ` + WITH agg AS (${agg.getQuery()}) + SELECT g.period, + g.category_key, + COALESCE(a.revenue, 0) AS revenue, + COALESCE(a.tons, 0) AS tons, + COALESCE(a.teu, 0) AS teu, + COALESCE(a.containers, 0) AS containers, + COALESCE(a.avg_per_unit, 0) AS avg_per_unit, + COALESCE(a.unit, '') AS unit, + COALESCE(a.lines, 0) AS lines + FROM ( + SELECT p.period, c.category_key + FROM (SELECT DISTINCT period FROM agg) p + CROSS JOIN unnest(ARRAY[${categoryKeys}]::text[]) AS c(category_key) + ) g + LEFT JOIN agg a ON a.period = g.period AND a.category_key = g.category_key`; + + return ctx.ds + .createQueryBuilder() + .from(`(${grid})`, 'r') + .setParameters(agg.getParameters()) + .select('r.period', 'period') + .addSelect(CATEGORY_LABEL_OF('r.category_key'), 'category') + .addSelect('r.category_key', 'categoryKey') + .addSelect('ROUND(r.revenue)::float8', 'revenue') + .addSelect(`ROUND(COALESCE(${PRIOR_REVENUE}, 0))::float8`, 'priorRevenue') + .addSelect(`COALESCE(${growthPctExpr('r.revenue', PRIOR_REVENUE)}, 0)`, 'growthPct') + .addSelect('r.tons::float8', 'tons') + .addSelect('r.teu::int', 'teu') + .addSelect('r.containers::int', 'containers') + .addSelect('r.avg_per_unit::float8', 'avgPerUnit') + .addSelect('r.unit', 'unit') + .addSelect('r.lines::int', 'lines'); }, async summary(ctx) { const row = await baseQuery(ctx) @@ -113,7 +200,10 @@ export const revenueByCategoryReport: ReportDefinition = { const currency = currencyOf(ctx.params); return [ { label: 'Total revenue', value: Number(row?.revenue ?? 0), unit: currency }, - { label: 'Categories', value: Number(row?.categories ?? 0) }, + // "with revenue" is not decoration: the table now lists every category in + // every live period, so a bare "Categories: 6" next to fourteen rows + // would read as a contradiction rather than as the count of live ones. + { label: 'Categories with revenue', value: Number(row?.categories ?? 0) }, // Always shown, even at zero: an audit report must never quietly drop money. { label: 'Unclassified', value: Number(row?.unclassified ?? 0), unit: currency }, ]; diff --git a/apps/edr-freight-api/src/modules/reports/definitions/revenue-by-customer.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/revenue-by-customer.report.ts index 31b9951b8..4312bbef6 100644 --- a/apps/edr-freight-api/src/modules/reports/definitions/revenue-by-customer.report.ts +++ b/apps/edr-freight-api/src/modules/reports/definitions/revenue-by-customer.report.ts @@ -1,91 +1,101 @@ import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; -import { Booking } from '../../bookings/entities/booking.entity'; -import { Company } from '../../companies/entities/company.entity'; -import { ReportContext, ReportDefinition } from '../report.types'; +import { ReportContext, ReportColumn, ReportDefinition } from '../report.types'; +import { + PAID_SHARE, + PAYER_EXPR, + PAYMENT_CLASSES, + PAYMENT_CLASS_EXPR, + REVENUE_FILTERS, + REVENUE_SUM, + currencyOf, + revenueLedgerQb, +} from '../revenue-classification'; -const TONS = 'COALESCE(b.bulk_total_weight_tons, b.cargo_total_weight_vgm)'; -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']; +/** + * One column per payment class, pivoted with FILTER. The class values are the + * compile-time constants in PAYMENT_CLASSES, never user input, so they are + * safe to interpolate. + */ +const CLASS_COLUMNS = PAYMENT_CLASSES.map((c) => ({ + value: c.value, + key: c.value.toLowerCase().replace(/_(.)/g, (_, ch: string) => ch.toUpperCase()), + label: c.label, +})); + +const classMoneyColumns: ReportColumn[] = CLASS_COLUMNS.map((c) => ({ + key: c.key, + label: c.label, + type: 'money', + sortable: true, +})); function baseQuery(ctx: ReportContext): SelectQueryBuilder { - const { params, directions } = ctx; - const qb = ctx.ds - .createQueryBuilder() - .from(Booking, 'b') - .innerJoin(Company, 'c', 'c.id = b.company_id') - .where(`b.deleted_at IS NULL AND ${NOT_UMBRELLA}`); - - 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 (params.direction) qb.andWhere('b.trade_direction = :direction', { direction: params.direction }); - if (params.freightType) qb.andWhere('b.freight_type = :freightType', { freightType: params.freightType }); - const statuses = params.statuses as string[] | null; - if (statuses) { - qb.andWhere('b.status IN (:...statuses)', { statuses }); - } else { - qb.andWhere('b.status NOT IN (:...deadStatuses)', { deadStatuses: DEAD_STATUSES }); - } - if (directions !== null) { - qb.andWhere(directions.length ? 'b.trade_direction IN (:...directions)' : '1 = 0', { - directions, - }); - } - return qb; + return revenueLedgerQb(ctx); } export const revenueByCustomerReport: ReportDefinition = { key: 'revenue-by-customer', title: 'Revenue by Customer', - description: 'Ranked customers by booking revenue', - group: 'Commercial', - filters: [ - { key: 'date', label: 'Created', type: 'daterange' }, - { - key: 'direction', - label: 'Direction', - type: 'select', - options: [ - { value: 'IMPORT', label: 'Import' }, - { value: 'EXPORT', label: 'Export' }, - { value: 'DOMESTIC', label: 'Domestic' }, - ], - }, - { - key: 'freightType', - label: 'Freight type', - type: 'select', - options: [ - { value: 'CONTAINER', label: 'Container' }, - { value: 'BULK', label: 'Bulk' }, - ], - }, - { key: 'statuses', label: 'Status', type: 'multiselect' }, - ], + description: + 'Every paying customer on one row: total billed revenue, what they have settled, ' + + 'what is still open, and a column per charge type — rail transport, customs ' + + 'clearance, first/last mile, overweight, cancellation, demurrage, storage, loading ' + + 'and unloading, and additional charges. Built on invoice lines, so the charge-type ' + + 'split is the billed one; a booking total is a lump sum and cannot be split. The ' + + 'payer is the company or, for shipping-line credit invoices, the shipping line. ' + + 'There is no dedicated loading/unloading charge type in the system — handling, ' + + 'double-handling and lashing stand in for it.', + group: 'Finance', + filters: REVENUE_FILTERS, columns: [ - { key: 'customer', label: 'Customer', type: 'string', sortable: true, sortExpr: 'c.name' }, - { key: 'bookings', label: 'Bookings', type: 'number', sortable: true }, - { key: 'tons', label: 'Tonnage', type: 'tons', sortable: true }, - { key: 'revenue', label: 'Revenue', type: 'money', sortable: true }, + { + key: 'customer', + label: 'Customer', + type: 'string', + sortable: true, + sortExpr: PAYER_EXPR, + }, + { key: 'revenue', label: 'Total revenue', type: 'money', sortable: true }, + { key: 'paid', label: 'Paid', type: 'money', sortable: true }, + { key: 'outstanding', label: 'Outstanding', type: 'money', sortable: true }, + ...classMoneyColumns, + { key: 'invoices', label: 'Invoices', type: 'number', sortable: true }, ], defaultSort: { key: 'revenue', dir: 'DESC' }, + chart: { type: 'bar', x: 'customer', y: ['revenue'] }, + drill: { to: 'revenue-transactions', carry: { customer: 'customer' } }, query(ctx) { - return baseQuery(ctx) - .select('c.name', 'customer') - .addSelect('COUNT(*)::int', 'bookings') - .addSelect(`ROUND(COALESCE(SUM(${TONS}), 0))::float8`, 'tons') - .addSelect(`ROUND(COALESCE(SUM(${REVENUE}), 0))::float8`, 'revenue') - .groupBy('c.name'); + const qb = baseQuery(ctx) + .select(PAYER_EXPR, 'customer') + .addSelect(REVENUE_SUM, 'revenue') + .addSelect(`ROUND(COALESCE(SUM(${PAID_SHARE}), 0))::float8`, 'paid') + .addSelect(`ROUND(COALESCE(SUM(il.amount - (${PAID_SHARE})), 0))::float8`, 'outstanding') + .addSelect('COUNT(DISTINCT i.id)::int', 'invoices') + .groupBy(PAYER_EXPR); + + for (const c of CLASS_COLUMNS) { + qb.addSelect( + `ROUND(COALESCE(SUM(il.amount) FILTER (WHERE ${PAYMENT_CLASS_EXPR} = '${c.value}'), 0))::float8`, + c.key, + ); + } + return qb; }, async summary(ctx) { const row = await baseQuery(ctx) - .select('COUNT(DISTINCT c.name)::int', 'customers') - .addSelect(`ROUND(COALESCE(SUM(${REVENUE}), 0))::float8`, 'revenue') - .getRawOne(); + .select(`COUNT(DISTINCT ${PAYER_EXPR})::int`, 'customers') + .addSelect(REVENUE_SUM, 'revenue') + .addSelect(`ROUND(COALESCE(SUM(${PAID_SHARE}), 0))::float8`, 'paid') + .getRawOne<{ customers: number; revenue: number; paid: number }>(); + const revenue = Number(row?.revenue ?? 0); + const paid = Number(row?.paid ?? 0); + const unit = currencyOf(ctx.params); return [ { label: 'Customers', value: Number(row?.customers ?? 0) }, - { label: 'Revenue', value: Number(row?.revenue ?? 0), unit: 'ETB' }, + { label: 'Total revenue', value: revenue, unit }, + { label: 'Paid', value: paid, unit }, + { label: 'Outstanding', value: Math.round(revenue - paid), unit }, ]; }, }; 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 deleted file mode 100644 index 512e05be8..000000000 --- a/apps/edr-freight-api/src/modules/reports/definitions/revenue-summary.report.ts +++ /dev/null @@ -1,62 +0,0 @@ -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' }, - chart: { type: 'bar', x: 'direction', y: ['revenue'] }, - 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/definitions/teu-performance.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/teu-performance.report.ts index 38a8ef04f..adc108a02 100644 --- a/apps/edr-freight-api/src/modules/reports/definitions/teu-performance.report.ts +++ b/apps/edr-freight-api/src/modules/reports/definitions/teu-performance.report.ts @@ -1,6 +1,6 @@ -import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; +import { ObjectLiteral, SelectQueryBuilder } from "typeorm"; -import { ReportContext, ReportDefinition } from '../report.types'; +import { ReportContext, ReportDefinition } from "../report.types"; import { CONTAINER_CLASSES, CONTAINER_CLASS_EXPR, @@ -10,12 +10,13 @@ import { OPERATIONS_FILTERS, TEU_EXPR, allocationLedgerQb, + attainmentCtx, PLAN_GRANULARITY_NOTE, implementRateExpr, plannedRowsParams, plannedRowsSql, -} from '../operations-classification'; -import { PERIOD_FILTER, periodExprOn, periodTruncExprOn } from '../revenue-classification'; +} from "../operations-classification"; +import { PERIOD_FILTER, periodExprOn, periodTruncExprOn } from "../revenue-classification"; const CONTAINERS_20 = `COALESCE(SUM(( SELECT COUNT(*) FROM freight.wagon_allocation_container_items ci @@ -39,44 +40,53 @@ function baseQuery(ctx: ReportContext): SelectQueryBuilder { } export const teuPerformanceReport: ReportDefinition = { - key: 'teu-performance', - title: 'TEU Performance', + key: "teu-performance", + title: "TEU Performance", description: - 'Twenty-foot equivalent units moved per container class against plan. Every 40ft box ' + - 'counts as two TEU, so ten 40ft and thirty 20ft is 50 TEU. Counted from the ' + - 'marshalling record — the containers actually allocated to wagons — not from the ' + - 'billing lines. Plan comes from Operational targets.' + + "Twenty-foot equivalent units moved per container class against plan. Every 40ft box " + + "counts as two TEU, so ten 40ft and thirty 20ft is 50 TEU. Counted from the " + + "marshalling record — the containers actually allocated to wagons — not from the " + + "billing lines. Plan comes from Operational targets." + PLAN_GRANULARITY_NOTE, - group: 'Operations', + group: "Operations", filters: [ PERIOD_FILTER, ...OPERATIONS_FILTERS, - { key: 'classes', label: 'Container class', type: 'multiselect', options: CONTAINER_CLASSES }, + { key: "classes", label: "Container class", type: "multiselect", options: CONTAINER_CLASSES }, ], columns: [ - { key: 'period', label: 'Period', type: 'string', sortable: true }, - { key: 'containerClass', label: 'Container type', type: 'string', sortable: true }, - { key: 'containers20', label: '20ft', type: 'number', sortable: true }, - { key: 'containers40', label: '40ft', type: 'number', sortable: true }, - { key: 'containers', label: 'Containers', type: 'number', sortable: true }, - { key: 'operated', label: 'Operated (TEU)', type: 'number', sortable: true }, - { key: 'plan', label: 'Plan', type: 'number' }, - { key: 'implementRate', label: 'Implement rate', type: 'percent' }, + { key: "period", label: "Period", type: "string", sortable: true }, + { key: "containerClass", label: "Container type", type: "string", sortable: true }, + { key: "containers20", label: "20ft", type: "number", sortable: true }, + { key: "containers40", label: "40ft", type: "number", sortable: true }, + { key: "operated", label: "Operated (TEU)", type: "number", sortable: true }, + { key: "plan", label: "Plan", type: "number" }, + { key: "planRequired", label: "Required", type: "number" }, + { key: "implementRate", label: "Implement rate", type: "percent" }, ], - defaultSort: { key: 'operated', dir: 'DESC' }, - chart: { type: 'bar', x: 'containerClass', y: ['operated'] }, + defaultSort: { key: "operated", dir: "DESC" }, + chart: { type: "bar", x: "containerClass", y: ["operated"] }, query(ctx) { const bucket = periodTruncExprOn(OPS_DATE, ctx.params); const operated = baseQuery(ctx) - .select(periodExprOn(OPS_DATE, ctx.params), 'period') - .addSelect(CONTAINER_CLASS_EXPR, 'class_key') - .addSelect(CONTAINERS_20, 'containers20') - .addSelect(CONTAINERS_40, 'containers40') - .addSelect(CONTAINERS_EXPR, 'containers') - .addSelect(TEU_EXPR, 'operated') + .select(periodExprOn(OPS_DATE, ctx.params), "period") + .addSelect(CONTAINER_CLASS_EXPR, "class_key") + .addSelect(CONTAINERS_20, "containers20") + .addSelect(CONTAINERS_40, "containers40") + .addSelect(TEU_EXPR, "operated") .groupBy(bucket) .addGroupBy(CONTAINER_CLASS_EXPR); + // Attainment for the cascade: TEU across the target's whole period, so a + // mid-year view does not read as "nothing shipped yet". + const attained = baseQuery(attainmentCtx(ctx)) + .select(periodTruncExprOn(OPS_DATE, ctx.params), "bucket") + .addSelect(CONTAINER_CLASS_EXPR, "act_key") + .addSelect("NULL::varchar", "act_category") + .addSelect(TEU_EXPR, "actual") + .groupBy(periodTruncExprOn(OPS_DATE, ctx.params)) + .addGroupBy(CONTAINER_CLASS_EXPR); + // Full outer join so a planned container class that never moved still // reports, at zero rather than vanishing. const combined = ` @@ -84,38 +94,47 @@ export const teuPerformanceReport: ReportDefinition = { COALESCE(o.class_key, p.plan_key) AS class_key, COALESCE(o.containers20, 0) AS containers20, COALESCE(o.containers40, 0) AS containers40, - COALESCE(o.containers, 0) AS containers, COALESCE(o.operated, 0) AS operated, - p.plan_value AS plan + p.plan_value AS plan, + p.plan_required AS plan_required FROM (${operated.getQuery()}) o - FULL OUTER JOIN (${plannedRowsSql('TEU', 'container_class', ctx.params)}) p + FULL OUTER JOIN (${plannedRowsSql( + "TEU", + "container_class", + ctx.params, + attained.getQuery(), + )}) p ON p.period = o.period AND p.plan_key = o.class_key`; return ctx.ds .createQueryBuilder() - .from(`(${combined})`, 'r') - .setParameters({ ...operated.getParameters(), ...plannedRowsParams(ctx.params) }) - .select('r.period', 'period') - .addSelect(CONTAINER_CLASS_LABEL_OF('r.class_key'), 'containerClass') - .addSelect('r.class_key', 'containerClassKey') - .addSelect('r.containers20::int', 'containers20') - .addSelect('r.containers40::int', 'containers40') - .addSelect('r.containers::int', 'containers') - .addSelect('r.operated::int', 'operated') - .addSelect('r.plan::float8', 'plan') - .addSelect(implementRateExpr('r.operated', 'r.plan'), 'implementRate'); + .from(`(${combined})`, "r") + .setParameters({ + ...operated.getParameters(), + ...attained.getParameters(), + ...plannedRowsParams(ctx.params), + }) + .select("r.period", "period") + .addSelect(CONTAINER_CLASS_LABEL_OF("r.class_key"), "containerClass") + .addSelect("r.class_key", "containerClassKey") + .addSelect("r.containers20::int", "containers20") + .addSelect("r.containers40::int", "containers40") + .addSelect("r.operated::int", "operated") + .addSelect("r.plan::float8", "plan") + .addSelect("r.plan_required::float8", "planRequired") + .addSelect(implementRateExpr("r.operated", "r.plan"), "implementRate"); }, async summary(ctx) { const row = await baseQuery(ctx) - .select(TEU_EXPR, 'teu') - .addSelect(CONTAINERS_EXPR, 'containers') - .addSelect('COUNT(DISTINCT ts.id)::int', 'trains') + .select(TEU_EXPR, "teu") + .addSelect(CONTAINERS_EXPR, "containers") + .addSelect("COUNT(DISTINCT ts.id)::int", "trains") .getRawOne<{ teu: number; containers: number; trains: number }>(); return [ - { label: 'TEU', value: Number(row?.teu ?? 0) }, - { label: 'Containers', value: Number(row?.containers ?? 0) }, - { label: 'Trains', value: Number(row?.trains ?? 0) }, + { label: "TEU", value: Number(row?.teu ?? 0) }, + { label: "Containers", value: Number(row?.containers ?? 0) }, + { label: "Trains", value: Number(row?.trains ?? 0) }, ]; }, }; diff --git a/apps/edr-freight-api/src/modules/reports/definitions/trainset-performance.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/trainset-performance.report.ts index 8e1c52434..385b69dc2 100644 --- a/apps/edr-freight-api/src/modules/reports/definitions/trainset-performance.report.ts +++ b/apps/edr-freight-api/src/modules/reports/definitions/trainset-performance.report.ts @@ -13,6 +13,7 @@ import { TRAINSETS_EXPR, allocationLedgerQb, applyCategoryFilter, + attainmentCtx, PLAN_GRANULARITY_NOTE, implementRateExpr, plannedRowsParams, @@ -45,6 +46,7 @@ export const trainsetPerformanceReport: ReportDefinition = { { key: 'wagons', label: 'Wagons', type: 'number', sortable: true }, { key: 'operated', label: 'Operated (trainsets)', type: 'number', sortable: true }, { key: 'plan', label: 'Plan', type: 'number' }, + { key: 'planRequired', label: 'Required', type: 'number' }, { key: 'implementRate', label: 'Implement rate', type: 'percent' }, ], defaultSort: { key: 'operated', dir: 'DESC' }, @@ -60,6 +62,16 @@ export const trainsetPerformanceReport: ReportDefinition = { .groupBy(bucket) .addGroupBy(CARGO_CATEGORY_EXPR); + // Attainment for the cascade: the same trainset measure across the target's + // whole period, not just the window the viewer is looking at. + const attained = baseQuery(attainmentCtx(ctx)) + .select(periodTruncExprOn(OPS_DATE, ctx.params), 'bucket') + .addSelect(CARGO_CATEGORY_EXPR, 'act_key') + .addSelect('NULL::varchar', 'act_category') + .addSelect(TRAINSETS_EXPR, 'actual') + .groupBy(periodTruncExprOn(OPS_DATE, ctx.params)) + .addGroupBy(CARGO_CATEGORY_EXPR); + // FULL OUTER JOIN so a category that was planned but never ran still shows, // at zero — TypeORM's builder has no full-outer join, hence the raw text. const combined = ` @@ -68,15 +80,25 @@ export const trainsetPerformanceReport: ReportDefinition = { COALESCE(o.trains, 0) AS trains, COALESCE(o.wagons, 0) AS wagons, COALESCE(o.operated, 0) AS operated, - p.plan_value AS plan + p.plan_value AS plan, + p.plan_required AS plan_required FROM (${operated.getQuery()}) o - FULL OUTER JOIN (${plannedRowsSql('TRAINSET', 'cargo_category', ctx.params)}) p + FULL OUTER JOIN (${plannedRowsSql( + 'TRAINSET', + 'cargo_category', + ctx.params, + attained.getQuery(), + )}) p ON p.period = o.period AND p.plan_key = o.category_key`; return ctx.ds .createQueryBuilder() .from(`(${combined})`, 'r') - .setParameters({ ...operated.getParameters(), ...plannedRowsParams(ctx.params) }) + .setParameters({ + ...operated.getParameters(), + ...attained.getParameters(), + ...plannedRowsParams(ctx.params), + }) .select('r.period', 'period') .addSelect(CATEGORY_LABEL_OF('r.category_key'), 'category') .addSelect('r.category_key', 'categoryKey') @@ -84,6 +106,7 @@ export const trainsetPerformanceReport: ReportDefinition = { .addSelect('r.wagons::int', 'wagons') .addSelect('r.operated::float8', 'operated') .addSelect('r.plan::float8', 'plan') + .addSelect('r.plan_required::float8', 'planRequired') .addSelect(implementRateExpr('r.operated', 'r.plan'), 'implementRate'); }, async summary(ctx) { diff --git a/apps/edr-freight-api/src/modules/reports/operations-classification.ts b/apps/edr-freight-api/src/modules/reports/operations-classification.ts index ed39536b7..1c8f8504c 100644 --- a/apps/edr-freight-api/src/modules/reports/operations-classification.ts +++ b/apps/edr-freight-api/src/modules/reports/operations-classification.ts @@ -503,21 +503,68 @@ export function applyCategoryFilter( } /** - * The planned rows for a metric, as a derived table. + * Appended to every plan-versus-actual report's description, because neither + * the re-bucketing nor the catch-up rule is guessable from the table. + */ +export const PLAN_GRANULARITY_NOTE = + ' A plan is spread evenly across its own period and re-gathered into whichever bucket ' + + 'the report shows, so a monthly target fills a quarter or a year exactly, and a daily ' + + 'or weekly view gets its share of it. A week that straddles two months draws on both. ' + + 'Plan is the committed figure and never moves. Required is the same target treated as a ' + + 'quota: whatever is still outstanding, spread across the time still left, so a period ' + + 'that fell behind raises what the periods after it must carry. A target already met in ' + + 'full requires nothing further.'; + +/** + * The user's date filter as open-ended bounds, so the clipping arithmetic below + * never has to branch on null. + */ +const PLAN_FROM = "COALESCE(CAST(:planFrom AS timestamptz), '-infinity'::timestamptz)"; +const PLAN_TO = "COALESCE(CAST(:planTo AS timestamptz), 'infinity'::timestamptz)"; + +/** + * How long one target's period runs. A target's span is exact — 90 days is 90 + * days — and need not line up with the ragged year-end display blocks the + * `nine_month` and `ninety_day` granularities produce. The spread below is + * proportional, so partial overlap resolves correctly either way. + */ +const TARGET_SPAN = `CASE ot.period_type + WHEN 'day' THEN INTERVAL '1 day' + WHEN 'week' THEN INTERVAL '7 days' + WHEN 'month' THEN INTERVAL '1 month' + WHEN 'quarter' THEN INTERVAL '3 months' + WHEN 'half_year' THEN INTERVAL '6 months' + WHEN 'nine_month' THEN INTERVAL '9 months' + WHEN 'ninety_day' THEN INTERVAL '90 days' + WHEN 'year' THEN INTERVAL '1 year' + ELSE INTERVAL '1 day' +END`; + +/** + * The planned rows for a metric, as a derived table: one row per bucket per + * planned key, carrying both a committed and a required figure. * - * A target is a rate over its own period, not a lump at its start: the plan is - * spread evenly across the days it covers, then re-gathered into the report's - * buckets. One rule covers every direction — three monthly targets add up to a + * **Plan** — a target is a rate over its own period, not a lump at its start. + * The committed value is spread evenly across the days it covers and + * re-gathered into the report's buckets, so three monthly targets add up to a * quarter exactly, a daily view gets a thirty-first of the month, and a week - * straddling a month boundary draws proportionally on both months. + * straddling a month boundary draws proportionally on both. The even spread is + * an assumption, and the only one available: a monthly figure carries no + * information about which days inside it were busier. This number never moves — + * Implement Rate is measured against it, so a month that missed keeps reading + * as a month that missed. * - * The even spread is an assumption, and the only one available: a monthly - * figure carries no information about which days inside it were busier. + * **Required** — the same target read as a quota. At each bucket, whatever is + * still outstanding (committed minus everything delivered in earlier buckets) + * is spread across the time still left in the period. A year 20% met at the + * halfway mark asks the remaining months for the other 80%. Over-delivery + * clamps to zero rather than going negative: a met quota requires nothing more. * - * The share is clipped to the user's date filter as well as to the bucket, so - * the plan always covers exactly the span the operated figure beside it covers. - * Without that, filtering to July and viewing by year would put a whole year's - * plan next to one month's work. + * `actualsSql` must produce `(bucket, act_key, act_category, actual)` and must + * be built **without the user's date bounds** — see {@link attainmentCtx}. + * Attainment is a fact about the target's whole period; measuring it through + * the report's date filter would read a mid-year view as "nothing delivered + * yet" and demand the entire year's work from one month. * * The reports FULL OUTER JOIN this to their operated aggregate so a category * that was planned but never ran still appears, at zero. The OCC monthly report @@ -528,62 +575,96 @@ export function applyCategoryFilter( * Period bounds ride on `:planFrom` / `:planTo`, which the caller must bind * with {@link plannedRowsParams} — they come from the user's date filter. */ -/** - * Appended to every plan-versus-actual report's description, because the - * re-bucketing rule is not guessable from the table. - */ -export const PLAN_GRANULARITY_NOTE = - ' A plan is spread evenly across its own period and re-gathered into whichever bucket ' + - 'the report shows, so a monthly target fills a quarter or a year exactly, and a daily ' + - 'or weekly view gets its share of it. A week that straddles two months draws on both.'; - -/** - * The user's date filter as open-ended bounds, so the clipping arithmetic below - * never has to branch on null. - */ -const PLAN_FROM = "COALESCE(CAST(:planFrom AS timestamptz), '-infinity'::timestamptz)"; -const PLAN_TO = "COALESCE(CAST(:planTo AS timestamptz), 'infinity'::timestamptz)"; - export const plannedRowsSql = ( metric: string, dimension: string, params: Record, + actualsSql: string, ): string => { const unit = resolvePeriod(params); + // Reused verbatim in the GROUP BY, per the trap documented on `periodExpr`. + const bucketOf = unit.truncOn('d.day'); return ` - SELECT to_char(g.bucket, '${unit.fmt}') AS period, - ot.dimension_key AS plan_key, - ot.cargo_category AS plan_category, - SUM(ot.planned_value * ( - GREATEST(0, EXTRACT(EPOCH FROM ( - LEAST(g.bucket + INTERVAL '${unit.step}', t.ends, ${PLAN_TO}) - - GREATEST(g.bucket, ot.period_start::timestamptz, ${PLAN_FROM})))) - / NULLIF(EXTRACT(EPOCH FROM (t.ends - ot.period_start)), 0) - )) AS plan_value - FROM freight.operations_targets ot - CROSS JOIN LATERAL ( - SELECT ot.period_start + CASE ot.period_type - WHEN 'week' THEN INTERVAL '7 days' - WHEN 'month' THEN INTERVAL '1 month' - WHEN 'quarter' THEN INTERVAL '3 months' - WHEN 'year' THEN INTERVAL '1 year' - ELSE INTERVAL '1 day' - END AS ends - ) t - CROSS JOIN LATERAL generate_series( - date_trunc('${unit.trunc}', ot.period_start::timestamptz), - date_trunc('${unit.trunc}', t.ends - INTERVAL '1 microsecond'), - INTERVAL '${unit.step}' - ) AS g(bucket) - WHERE ot.deleted_at IS NULL - AND ot.metric = '${metric}' - AND ot.dimension = '${dimension}' - AND g.bucket + INTERVAL '${unit.step}' > ${PLAN_FROM} - AND g.bucket < ${PLAN_TO} - GROUP BY 1, 2, 3 - HAVING SUM(ot.planned_value) > 0`; + WITH tgt AS ( + SELECT ot.id, + ot.dimension_key, + ot.cargo_category, + ot.planned_value, + ot.period_start::timestamptz AS starts, + ot.period_start::timestamptz + ${TARGET_SPAN} AS ends + FROM freight.operations_targets ot + WHERE ot.deleted_at IS NULL + AND ot.metric = '${metric}' + AND ot.dimension = '${dimension}' + AND ot.planned_value > 0 + ), + -- One row per target per bucket. Generated a day at a time rather than a + -- bucket at a time: the ragged units restart their blocks each January, so + -- stepping by the unit's own width walks off the anchor in the second year. + -- Day grain also makes a bucket that only partly overlaps the target fall out + -- for free, at the same sub-day precision the clipping used before. + spread AS ( + SELECT t.id, + t.dimension_key, + t.cargo_category, + t.planned_value, + EXTRACT(EPOCH FROM (t.ends - t.starts)) AS secs_total, + ${bucketOf} AS bucket, + SUM(GREATEST(0, EXTRACT(EPOCH FROM ( + LEAST(d.day + INTERVAL '1 day', t.ends) + - GREATEST(d.day, t.starts))))) AS secs_full, + SUM(GREATEST(0, EXTRACT(EPOCH FROM ( + LEAST(d.day + INTERVAL '1 day', t.ends, ${PLAN_TO}) + - GREATEST(d.day, t.starts, ${PLAN_FROM}))))) AS secs_in + FROM tgt t + CROSS JOIN LATERAL generate_series( + date_trunc('day', t.starts), + t.ends - INTERVAL '1 microsecond', + INTERVAL '1 day' + ) AS d(day) + GROUP BY t.id, t.dimension_key, t.cargo_category, t.planned_value, + t.starts, t.ends, ${bucketOf} + ), + -- secs_before and actual_before are strictly-preceding running sums, so a + -- bucket's requirement is decided by what happened before it, never by its + -- own result. The frame is spelled out rather than defaulted: the default + -- RANGE frame would fold peer rows into the current one. + cascaded AS ( + SELECT s.*, + COALESCE(SUM(s.secs_full) OVER prior, 0) AS secs_before, + COALESCE(SUM(a.actual) OVER prior, 0) AS actual_before + FROM spread s + LEFT JOIN (${actualsSql}) a + ON a.bucket = s.bucket + AND a.act_key = s.dimension_key + AND a.act_category IS NOT DISTINCT FROM s.cargo_category + WINDOW prior AS ( + PARTITION BY s.id ORDER BY s.bucket + ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING + ) + ) + SELECT ${unit.labelOn('c.bucket')} AS period, + c.dimension_key AS plan_key, + c.cargo_category AS plan_category, + SUM(c.planned_value * c.secs_in / NULLIF(c.secs_total, 0)) AS plan_value, + SUM(GREATEST(0, c.planned_value - c.actual_before) + * c.secs_in / NULLIF(c.secs_total - c.secs_before, 0)) AS plan_required + FROM cascaded c + WHERE c.secs_in > 0 + GROUP BY 1, 2, 3`; }; +/** + * The report's own ledger with the user's date bounds removed, for the + * attainment series {@link plannedRowsSql} cascades from. Every other filter + * stays applied, so the catch-up figure is measured on the same population as + * the `operated` column it sits beside. + */ +export const attainmentCtx = (ctx: ReportContext): ReportContext => ({ + ...ctx, + params: { ...ctx.params, dateFrom: null, dateTo: null }, +}); + /** The bindings {@link plannedRowsSql} expects. */ export const plannedRowsParams = ( params: Record, 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 fedc8a6dd..fc404738d 100644 --- a/apps/edr-freight-api/src/modules/reports/report.registry.ts +++ b/apps/edr-freight-api/src/modules/reports/report.registry.ts @@ -1,45 +1,39 @@ -import { ReportKey } from '../../seed/freight-permissions.registry'; -import { bookingsListReport } from './definitions/bookings-list.report'; -import { revenueByCustomerReport } from './definitions/revenue-by-customer.report'; -import { agingReceivablesReport } from './definitions/aging-receivables.report'; -import { contractUtilizationReport } from './definitions/contract-utilization.report'; -import { wagonFleetStatusReport } from './definitions/wagon-fleet-status.report'; -import { wagonStatusDurationReport } from './definitions/wagon-status-duration.report'; -import { wagonRequestsReport } from './definitions/wagon-requests.report'; -import { locomotiveFleetStatusReport } from './definitions/locomotive-fleet-status.report'; -import { bookingStatusBreakdownReport } from './definitions/booking-status-breakdown.report'; -import { trainScheduleStatusReport } from './definitions/train-schedule-status.report'; -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 { revenueByCategoryReport } from './definitions/revenue-by-category.report'; -import { revenueTransactionsReport } from './definitions/revenue-transactions.report'; -import { revenueByPeriodReport } from './definitions/revenue-by-period.report'; -import { revenueByRouteReport } from './definitions/revenue-by-route.report'; -import { revenueTopCustomersReport } from './definitions/revenue-top-customers.report'; -import { paymentClassificationReport } from './definitions/payment-classification.report'; -import { revenueReconciliationReport } from './definitions/revenue-reconciliation.report'; -import { receivablesPayablesReport } from './definitions/receivables-payables.report'; -import { revenueAnomaliesReport } from './definitions/revenue-anomalies.report'; -import { stationStayingTimeReport } from './definitions/station-staying-time.report'; -import { turnaroundCycleReport } from './definitions/turnaround-cycle.report'; -import { trainDelaysReport } from './definitions/train-delays.report'; -import { trainsetPerformanceReport } from './definitions/trainset-performance.report'; -import { teuPerformanceReport } from './definitions/teu-performance.report'; -import { cargoVolumePerformanceReport } from './definitions/cargo-volume-performance.report'; -import { chargedVsActualVolumeReport } from './definitions/charged-vs-actual-volume.report'; -import { cargoVolumeByStationReport } from './definitions/cargo-volume-by-station.report'; -import { ReportDefinition } from './report.types'; +import { ReportKey } from "../../seed/freight-permissions.registry"; +import { revenueByCustomerReport } from "./definitions/revenue-by-customer.report"; +import { agingReceivablesReport } from "./definitions/aging-receivables.report"; +import { contractUtilizationReport } from "./definitions/contract-utilization.report"; +import { wagonFleetStatusReport } from "./definitions/wagon-fleet-status.report"; +import { wagonStatusDurationReport } from "./definitions/wagon-status-duration.report"; +import { wagonRequestsReport } from "./definitions/wagon-requests.report"; +import { locomotiveFleetStatusReport } from "./definitions/locomotive-fleet-status.report"; +import { bookingStatusBreakdownReport } from "./definitions/booking-status-breakdown.report"; +import { trainScheduleStatusReport } from "./definitions/train-schedule-status.report"; +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 { customsDocumentsReport } from "./definitions/customs-documents.report"; +import { invoicingPipelineReport } from "./definitions/invoicing-pipeline.report"; +import { firstLastMileBookingsReport } from "./definitions/first-last-mile-bookings.report"; +import { cargoSummaryReport } from "./definitions/cargo-summary.report"; +import { revenueByCategoryReport } from "./definitions/revenue-by-category.report"; +import { revenueTransactionsReport } from "./definitions/revenue-transactions.report"; +import { revenueByPeriodReport } from "./definitions/revenue-by-period.report"; +import { revenueByRouteReport } from "./definitions/revenue-by-route.report"; +import { revenueTopCustomersReport } from "./definitions/revenue-top-customers.report"; +import { paymentClassificationReport } from "./definitions/payment-classification.report"; +import { revenueReconciliationReport } from "./definitions/revenue-reconciliation.report"; +import { receivablesPayablesReport } from "./definitions/receivables-payables.report"; +import { revenueAnomaliesReport } from "./definitions/revenue-anomalies.report"; +import { stationStayingTimeReport } from "./definitions/station-staying-time.report"; +import { turnaroundCycleReport } from "./definitions/turnaround-cycle.report"; +import { trainDelaysReport } from "./definitions/train-delays.report"; +import { trainsetPerformanceReport } from "./definitions/trainset-performance.report"; +import { teuPerformanceReport } from "./definitions/teu-performance.report"; +import { cargoVolumePerformanceReport } from "./definitions/cargo-volume-performance.report"; +import { chargedVsActualVolumeReport } from "./definitions/charged-vs-actual-volume.report"; +import { cargoVolumeByStationReport } from "./definitions/cargo-volume-by-station.report"; +import { ReportDefinition } from "./report.types"; /** * Every report the platform knows about. Adding one = a new file under @@ -47,7 +41,6 @@ import { ReportDefinition } from './report.types'; * an entry here. Nothing else — no frontend edit, no route, no sidebar edit. */ export const REPORTS: ReportDefinition[] = [ - bookingsListReport, revenueByCustomerReport, agingReceivablesReport, contractUtilizationReport, @@ -61,14 +54,9 @@ export const REPORTS: ReportDefinition[] = [ wagonTeuUtilizationReport, loadedCapacityReport, globalLogisticsWagonsReport, - customerStatusReport, - contractLifecycleReport, customsDocumentsReport, invoicingPipelineReport, firstLastMileBookingsReport, - invoicesByStatusReport, - paymentsByStatusReport, - revenueSummaryReport, cargoSummaryReport, revenueByCategoryReport, revenueTransactionsReport, @@ -89,7 +77,9 @@ export const REPORTS: ReportDefinition[] = [ cargoVolumeByStationReport, ]; -const BY_KEY = new Map(REPORTS.map((r) => [r.key, r])); +const BY_KEY = new Map( + REPORTS.map((r) => [r.key, r]), +); export function getReport(key: string): ReportDefinition | undefined { return BY_KEY.get(key as ReportKey); diff --git a/apps/edr-freight-api/src/modules/reports/revenue-classification.spec.ts b/apps/edr-freight-api/src/modules/reports/revenue-classification.spec.ts index 42475d591..abc63fc04 100644 --- a/apps/edr-freight-api/src/modules/reports/revenue-classification.spec.ts +++ b/apps/edr-freight-api/src/modules/reports/revenue-classification.spec.ts @@ -86,17 +86,54 @@ describe('revenue classification', () => { expect(periodExpr({ period: 'quarter' })).toContain("date_trunc('quarter'"); expect(periodExpr({ period: 'year' })).toContain("date_trunc('year'"); // Anything unrecognised — including an injection attempt — becomes 'month'. - expect(periodExpr({ period: "day'); DROP TABLE freight.invoices; --" })).toContain( - "date_trunc('month'", - ); + const injection = "day'); DROP TABLE freight.invoices; --"; + expect(periodExpr({ period: injection })).toContain("date_trunc('month'"); + expect(periodExpr({ period: injection })).not.toContain('DROP TABLE'); expect(periodExpr({})).toContain("date_trunc('month'"); }); it('offers exactly the period units the expression understands', () => { const offered = (PERIOD_FILTER.options ?? []).map((o) => o.value); - expect(offered.length).toBe(5); - for (const unit of offered) { - expect(periodExpr({ period: unit })).toContain(`date_trunc('${unit}'`); + expect(offered).toEqual([ + 'day', + 'week', + 'month', + 'quarter', + 'half_year', + 'nine_month', + 'ninety_day', + 'year', + ]); + // Every offered unit resolves to its own expression rather than silently + // falling through to the month default — which is what a missing entry or a + // typo'd key would look like. + const expressions = offered.map((unit) => periodExpr({ period: unit })); + expect(new Set(expressions).size).toBe(offered.length); + }); + + /** + * Half-year, nine-month and ninety-day have no `date_trunc` unit, so they are + * offset arithmetic anchored to January 1st. These pin the anchor: they are + * the SQL half of a pair whose other half is `normalisePeriodStart` in + * `operations-targets.service.ts`, and a target that snaps to a boundary the + * report does not bucket on plans against a period that does not exist. + */ + it('anchors the irregular units to the start of the calendar year', () => { + for (const unit of ['half_year', 'nine_month', 'ninety_day']) { + const expr = periodExpr({ period: unit }); + expect(expr).toContain("date_trunc('year'"); + expect(expr).not.toContain(`date_trunc('${unit}'`); } + + // Six- and nine-month blocks count whole months from January. + expect(periodExpr({ period: 'half_year' })).toContain("INTERVAL '6 months'"); + expect(periodExpr({ period: 'nine_month' })).toContain("INTERVAL '9 months'"); + + // 90-day blocks count days, and cap at the fourth so the last days of + // December widen block four instead of forming a 5-day stub of their own. + const ninety = periodExpr({ period: 'ninety_day' }); + expect(ninety).toContain("INTERVAL '90 days'"); + expect(ninety).toContain('LEAST('); + expect(ninety).toContain('/ 90, 3)'); }); }); diff --git a/apps/edr-freight-api/src/modules/reports/revenue-classification.ts b/apps/edr-freight-api/src/modules/reports/revenue-classification.ts index 526e35add..e3ba0a65d 100644 --- a/apps/edr-freight-api/src/modules/reports/revenue-classification.ts +++ b/apps/edr-freight-api/src/modules/reports/revenue-classification.ts @@ -139,8 +139,15 @@ const labelCase = (expr: string, options: ReportFilterOption[]): string => .map((o) => `WHEN '${o.value}' THEN '${o.label.replace(/'/g, "''")}'`) .join('\n ')}\nEND`; +/** + * The same labelling applied to a key that is already a column — for reports + * that classify in a subquery and label in the wrapper. + */ +export const CATEGORY_LABEL_OF = (keyExpr: string): string => + labelCase(keyExpr, REVENUE_CATEGORIES); + /** The category as a business label rather than its key, for display columns. */ -export const CATEGORY_LABEL_EXPR = labelCase(REVENUE_CATEGORY_EXPR, REVENUE_CATEGORIES); +export const CATEGORY_LABEL_EXPR = CATEGORY_LABEL_OF(REVENUE_CATEGORY_EXPR); /** * Period-over-period change, as a percentage. @@ -223,22 +230,103 @@ END`; // --------------------------------------------------------------------------- /** - * Frozen whitelist. The runner coerces a `select` filter to a trimmed string - * or null; that string is used only as an object key here, so the user's value - * never reaches SQL — one of five compile-time constants does. + * A granularity, as SQL builders rather than fragments to interpolate. * - * Every format is zero-padded, so lexicographic order equals chronological - * order. The growth window depends on that. + * Five of the eight are plain `date_trunc` units. The other three — half-year, + * nine-month, ninety-day — have no `date_trunc` equivalent in Postgres, so they + * are offset arithmetic from the start of the calendar year. Builders let both + * kinds live behind one interface. */ -const PERIOD_UNITS = { - day: { trunc: 'day', fmt: 'YYYY-MM-DD', label: 'Daily', step: '1 day' }, - week: { trunc: 'week', fmt: 'IYYY-"W"IW', label: 'Weekly', step: '1 week' }, - month: { trunc: 'month', fmt: 'YYYY-MM', label: 'Monthly', step: '1 month' }, +interface PeriodUnit { + label: string; + /** Interval one whole block wide. Only exact for the six regular units. */ + step: string; + /** Timestamp expression → the start of the block that timestamp falls in. */ + truncOn: (dateExpr: string) => string; + /** Block-start expression → its display label. */ + labelOn: (truncExpr: string) => string; + /** + * Block-start expression → the start of the NEXT block. Not always + * `+ step`: a ragged unit's final block of the year is shorter than its own + * step, so stepping past it overshoots into the wrong block. + */ + nextStartOn: (truncExpr: string) => string; +} + +const regular = (trunc: string, fmt: string, label: string, step: string): PeriodUnit => ({ + label, + step, + truncOn: (dateExpr) => `date_trunc('${trunc}', ${dateExpr})`, + labelOn: (truncExpr) => `to_char(${truncExpr}, '${fmt}')`, + nextStartOn: (truncExpr) => `(${truncExpr} + INTERVAL '${step}')`, +}); + +/** + * Blocks of `months` months counted from January, so they reset every calendar + * year. Six divides twelve and nine does not: a nine-month year is Jan–Sep plus + * a short Oct–Dec. That ragged tail is inherent to the unit — the alternative + * is blocks that drift out of the calendar, which is not what "calendar + * anchored" means. + */ +const monthBlocks = (months: number, marker: string, label: string): PeriodUnit => ({ + label, + step: `${months} months`, + truncOn: (dateExpr) => + `(date_trunc('year', ${dateExpr})` + + ` + (((EXTRACT(MONTH FROM ${dateExpr})::int - 1) / ${months}) * INTERVAL '${months} months'))`, + labelOn: (truncExpr) => + `(to_char(${truncExpr}, 'YYYY') || '-${marker}' ||` + + ` ((EXTRACT(MONTH FROM ${truncExpr})::int - 1) / ${months} + 1)::text)`, + nextStartOn: (truncExpr) => + `LEAST(${truncExpr} + INTERVAL '${months} months',` + + ` date_trunc('year', ${truncExpr}) + INTERVAL '1 year')`, +}); + +/** + * Frozen whitelist. The runner coerces a `select` filter to a trimmed string or + * null; that string is used only as an object key here, so the user's value + * never reaches SQL — one of eight compile-time constants does. + * + * Every label is zero-padded or single-digit-bounded, so lexicographic order + * equals chronological order. The growth windows depend on that. + */ +const PERIOD_UNITS: Record = { + day: regular('day', 'YYYY-MM-DD', 'Daily', '1 day'), + week: regular('week', 'IYYY-"W"IW', 'Weekly', '1 week'), + month: regular('month', 'YYYY-MM', 'Monthly', '1 month'), // `quarter` is a valid date_trunc unit but NOT a valid interval unit — // INTERVAL '1 quarter' is a syntax error, so the step is spelled in months. - quarter: { trunc: 'quarter', fmt: 'YYYY-"Q"Q', label: 'Quarterly', step: '3 months' }, - year: { trunc: 'year', fmt: 'YYYY', label: 'Yearly', step: '1 year' }, -} as const; + quarter: regular('quarter', 'YYYY-"Q"Q', 'Quarterly', '3 months'), + half_year: monthBlocks(6, 'H', 'Half-yearly'), + nine_month: monthBlocks(9, 'N', 'Nine-monthly'), + /** + * Four 90-day blocks from January 1st: days 1, 91, 181, 271. + * + * The block index is capped at 3 on purpose. Uncapped, `(doy - 1) / 90` puts + * December 27th onwards in a fifth block — a 5-day stub bucket at the end of + * every year, which is noise rather than a period. Capping instead lets the + * fourth block absorb the remainder and run 95 or 96 days. + * + * The label carries the zero-padded start day-of-year, which keeps it sorting + * chronologically and — unlike an ordinal — says out loud that the blocks are + * day-counted rather than month-aligned. + */ + ninety_day: { + label: '90-day', + step: '90 days', + truncOn: (dateExpr) => + `(date_trunc('year', ${dateExpr})` + + ` + (LEAST((EXTRACT(DOY FROM ${dateExpr})::int - 1) / 90, 3) * INTERVAL '90 days'))`, + labelOn: (truncExpr) => + `(to_char(${truncExpr}, 'YYYY') || '-D' || lpad(EXTRACT(DOY FROM ${truncExpr})::int::text, 3, '0'))`, + // The fourth block ends with the year, not 90 days after it started. + nextStartOn: (truncExpr) => + `(CASE WHEN EXTRACT(DOY FROM ${truncExpr})::int >= 271` + + ` THEN date_trunc('year', ${truncExpr}) + INTERVAL '1 year'` + + ` ELSE ${truncExpr} + INTERVAL '90 days' END)`, + }, + year: regular('year', 'YYYY', 'Yearly', '1 year'), +}; export const PERIOD_FILTER: ReportFilterDef = { key: 'period', @@ -267,10 +355,8 @@ export function periodExpr(params: Record): string { return periodExprOn(REVENUE_DATE, params); } -export function resolvePeriod( - params: Record, -): (typeof PERIOD_UNITS)[keyof typeof PERIOD_UNITS] { - const key = String(params.period ?? '') as keyof typeof PERIOD_UNITS; +export function resolvePeriod(params: Record): PeriodUnit { + const key = String(params.period ?? ''); return PERIOD_UNITS[key] ?? PERIOD_UNITS.month; } @@ -280,10 +366,10 @@ export function resolvePeriod( * these units so a month means the same thing on both sides of the product. */ export const periodExprOn = (dateExpr: string, params: Record): string => - `to_char(${periodTruncExprOn(dateExpr, params)}, '${resolvePeriod(params).fmt}')`; + resolvePeriod(params).labelOn(periodTruncExprOn(dateExpr, params)); export const periodTruncExprOn = (dateExpr: string, params: Record): string => - `date_trunc('${resolvePeriod(params).trunc}', ${dateExpr})`; + resolvePeriod(params).truncOn(dateExpr); /** The period's start timestamp — what to GROUP BY when a report needs it numerically. */ export const periodTruncExpr = (params: Record): string => @@ -298,9 +384,16 @@ export const periodTruncExpr = (params: Record): string => export const periodOrdinalExpr = (params: Record): string => `EXTRACT(EPOCH FROM ${periodTruncExpr(params)})`; -/** Same scale, one period later — where a one-step-ahead projection lands. */ +/** + * Same scale, one period later — where a one-step-ahead projection lands. + * + * Asks the unit rather than adding its step, because the two differ for the + * ragged units: a nine-month year's second block is three months long, and a + * 90-day year's fourth is 95, so `+ step` would land past the next block start + * and evaluate the regression at the wrong x. + */ export const nextPeriodOrdinalExpr = (params: Record): string => - `EXTRACT(EPOCH FROM ${periodTruncExpr(params)} + INTERVAL '${resolvePeriod(params).step}')`; + `EXTRACT(EPOCH FROM ${resolvePeriod(params).nextStartOn(periodTruncExpr(params))})`; // --------------------------------------------------------------------------- // Volume — measured at line grain, never joined from the booking diff --git a/apps/edr-freight-api/src/modules/train-scheduling/controllers/train-scheduling.controller.ts b/apps/edr-freight-api/src/modules/train-scheduling/controllers/train-scheduling.controller.ts index a86bc7651..e9a747784 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/controllers/train-scheduling.controller.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/controllers/train-scheduling.controller.ts @@ -17,7 +17,9 @@ import { TrainSchedulingCancel, TrainSchedulingCreate, TrainSchedulingEditTrainNumber, + TrainSchedulingLoad, TrainSchedulingReschedule, + TrainSchedulingUnload, TrainSchedulingRulesManage, TrainSchedulingUpdate, TrainSchedulingView, @@ -612,7 +614,7 @@ export class TrainSchedulingController { } @Post("schedules/:id/bookings/:bookingId/load") - @TrainSchedulingUpdate() + @TrainSchedulingLoad() @ApiOperation({ summary: "Confirm a booking's cargo loaded at its origin yard (any direction; train must be at that yard)", @@ -625,7 +627,7 @@ export class TrainSchedulingController { } @Post("schedules/:id/bookings/:bookingId/unload") - @TrainSchedulingUpdate() + @TrainSchedulingUnload() @ApiOperation({ summary: "Confirm a booking's cargo unloaded at its destination yard — per-booking arrival, may precede the train's final arrival", @@ -638,7 +640,7 @@ export class TrainSchedulingController { } @Post("schedules/:id/intercity/:bookingId/load") - @TrainSchedulingUpdate() + @TrainSchedulingLoad() @ApiOperation({ summary: "Confirm intercity cargo loaded (train must be at the booking's origin yard)", }) @@ -650,7 +652,7 @@ export class TrainSchedulingController { } @Post("schedules/:id/intercity/:bookingId/unload") - @TrainSchedulingUpdate() + @TrainSchedulingUnload() @ApiOperation({ summary: "Confirm intercity cargo unloaded at the booking's destination yard (completes the booking)", diff --git a/apps/edr-freight-api/src/modules/wagons/dto/list-wagons-query.dto.ts b/apps/edr-freight-api/src/modules/wagons/dto/list-wagons-query.dto.ts index 4f48c2a8b..1e2db1631 100644 --- a/apps/edr-freight-api/src/modules/wagons/dto/list-wagons-query.dto.ts +++ b/apps/edr-freight-api/src/modules/wagons/dto/list-wagons-query.dto.ts @@ -91,4 +91,18 @@ export class ListWagonsQueryDto { @IsOptional() @IsDateString() createdTo?: string; + + @ApiPropertyOptional({ + description: 'Last maintenance flip on or after this day (YYYY-MM-DD)', + }) + @IsOptional() + @IsDateString() + maintenanceFrom?: string; + + @ApiPropertyOptional({ + description: 'Last maintenance flip on or before this day (YYYY-MM-DD)', + }) + @IsOptional() + @IsDateString() + maintenanceTo?: string; } diff --git a/apps/edr-freight-api/src/modules/wagons/wagons.service.ts b/apps/edr-freight-api/src/modules/wagons/wagons.service.ts index 2347a446c..1ae1093f2 100644 --- a/apps/edr-freight-api/src/modules/wagons/wagons.service.ts +++ b/apps/edr-freight-api/src/modules/wagons/wagons.service.ts @@ -90,6 +90,27 @@ export class WagonsService { }); } + // Last-maintenance range, both ends inclusive. There's no column to + // compare directly — "last maintenance" is the latest status-log flip to + // MAINTENANCE (see attachStatusDates below), so this mirrors that same + // MAX(...) FILTER(...) as a correlated subquery against the same table. + if (query.maintenanceFrom) { + qb.andWhere( + `(SELECT MAX(l.created_at) FROM freight.wagon_status_logs l + WHERE l.wagon_id = w.id AND l.to_status = '${WagonStatus.Maintenance}') + >= CAST(:maintenanceFrom AS date)`, + { maintenanceFrom: query.maintenanceFrom }, + ); + } + if (query.maintenanceTo) { + qb.andWhere( + `(SELECT MAX(l.created_at) FROM freight.wagon_status_logs l + WHERE l.wagon_id = w.id AND l.to_status = '${WagonStatus.Maintenance}') + < CAST(:maintenanceTo AS date) + INTERVAL '1 day'`, + { maintenanceTo: query.maintenanceTo }, + ); + } + // Search matches the wagon number or either run number. if (search) { qb.andWhere( diff --git a/apps/edr-freight-api/src/seed/edr-freight.seed.ts b/apps/edr-freight-api/src/seed/edr-freight.seed.ts index e4ab41954..e611011f8 100644 --- a/apps/edr-freight-api/src/seed/edr-freight.seed.ts +++ b/apps/edr-freight-api/src/seed/edr-freight.seed.ts @@ -2,10 +2,15 @@ import { BOOKING_RULE_ENGINE_PERMISSIONS, BOOKING_RULE_ENGINE_PERMISSION_KEYS, deriveReadPermissions, + FREIGHT_PERMS, POSITION_PERMISSION_PRESETS, ROLE_PERMISSION_PRESETS, } from './freight-permissions.registry'; +/** Shorthand for the one overview-layout permission a role/position preset gets. */ +const overviewLayout = (key: Parameters[0]): string => + FREIGHT_PERMS.overview.layout(key); + export type FreightSeedRole = { key: string; name: { en: string }; @@ -248,48 +253,55 @@ export const EDR_FREIGHT_ROLES: FreightSeedRole[] = [ { key: "edr_line_staff", name: { en: "EDR Line Staff" }, - permissionKeys: [...ROLE_PERMISSION_PRESETS.lineStaff], + // OCC: the legacy role form of the control-centre desk (no position preset + // grants this layout — see EDR_FREIGHT_POSITIONS). + permissionKeys: [...ROLE_PERMISSION_PRESETS.lineStaff, overviewLayout("occ")], }, { key: "edr_operations_officer", name: { en: "EDR Operations Officer" }, - permissionKeys: [...ROLE_PERMISSION_PRESETS.operationsOfficer], + permissionKeys: [ + ...ROLE_PERMISSION_PRESETS.operationsOfficer, + overviewLayout("operation"), + ], }, { key: "edr_director", name: { en: "EDR Director" }, - permissionKeys: [...ROLE_PERMISSION_PRESETS.director], + permissionKeys: [...ROLE_PERMISSION_PRESETS.director, overviewLayout("executive")], }, { key: "edr_ceo", name: { en: "EDR CEO" }, - permissionKeys: [...ROLE_PERMISSION_PRESETS.ceo], + permissionKeys: [...ROLE_PERMISSION_PRESETS.ceo, overviewLayout("executive")], }, { key: "edr_finance", name: { en: "EDR Finance" }, - permissionKeys: [...ROLE_PERMISSION_PRESETS.finance], + // No position preset grants this layout — Finance only exists as a Role. + permissionKeys: [...ROLE_PERMISSION_PRESETS.finance, overviewLayout("finance")], }, { key: "edr_marketing", name: { en: "EDR Marketing" }, - permissionKeys: [...ROLE_PERMISSION_PRESETS.marketing], + permissionKeys: [...ROLE_PERMISSION_PRESETS.marketing, overviewLayout("marketer")], }, { key: "edr_gl_ethiopia", name: { en: "EDR Global Logistics — Ethiopia" }, - permissionKeys: [...ROLE_PERMISSION_PRESETS.glEthiopia], + permissionKeys: [...ROLE_PERMISSION_PRESETS.glEthiopia, overviewLayout("clearance")], }, { key: "edr_gl_djibouti", name: { en: "EDR Global Logistics — Djibouti" }, - permissionKeys: [...ROLE_PERMISSION_PRESETS.glDjibouti], + permissionKeys: [...ROLE_PERMISSION_PRESETS.glDjibouti, overviewLayout("clearance")], }, { key: "edr_org_manager", name: { en: "EDR Org Manager" }, permissionKeys: [ ...BOOKING_RULE_ENGINE_PERMISSION_KEYS, + overviewLayout("executive"), ...EMPLOYEE_REGISTRATION_PERMISSIONS.map((p) => p.key), ...ROLE_ASSIGNMENT_PERMISSIONS.map((p) => p.key), ...HIERARCHY_UNIT_PERMISSIONS.map((p) => p.key), @@ -326,15 +338,17 @@ export const EDR_FREIGHT_ROLES: FreightSeedRole[] = [ * PositionPermission rows (NOT Role/RolePermission). Users get their access by * being assigned to a Position via EmployeePosition. */ +// No position preset grants the "occ" or "finance" overview layouts today — +// see the comments on edr_line_staff / edr_finance above. export const EDR_FREIGHT_POSITIONS: FreightSeedPosition[] = [ - { key: "chief", name: { en: "Chief" }, rank: 1, permissionKeys: [...POSITION_PERMISSION_PRESETS.chief] }, - { key: "director", name: { en: "Director" }, rank: 2, permissionKeys: [...POSITION_PERMISSION_PRESETS.director] }, - { key: "ceo", name: { en: "CEO" }, rank: 1, permissionKeys: [...POSITION_PERMISSION_PRESETS.ceo] }, - { key: "ethiopian_gl", name: { en: "Ethiopian GL" }, rank: 3, permissionKeys: [...POSITION_PERMISSION_PRESETS.ethiopianGl] }, - { key: "djibouti_gl", name: { en: "Djibouti GL" }, rank: 3, permissionKeys: [...POSITION_PERMISSION_PRESETS.djiboutiGl] }, - { key: "marketer", name: { en: "Marketer" }, rank: 4, permissionKeys: [...POSITION_PERMISSION_PRESETS.marketer] }, - { key: "operation", name: { en: "Operation" }, rank: 4, permissionKeys: [...POSITION_PERMISSION_PRESETS.operation] }, - { key: "operations_chief", name: { en: "Operations Chief" }, rank: 2, permissionKeys: [...POSITION_PERMISSION_PRESETS.operationsChief] }, - { key: "dispatcher", name: { en: "Dispatcher" }, rank: 4, permissionKeys: [...POSITION_PERMISSION_PRESETS.dispatcher] }, - { key: "truck_machinery_chief", name: { en: "Truck & Machinery Chief" }, rank: 2, permissionKeys: [...POSITION_PERMISSION_PRESETS.truckMachineryChief] }, + { key: "chief", name: { en: "Chief" }, rank: 1, permissionKeys: [...POSITION_PERMISSION_PRESETS.chief, overviewLayout("executive")] }, + { key: "director", name: { en: "Director" }, rank: 2, permissionKeys: [...POSITION_PERMISSION_PRESETS.director, overviewLayout("executive")] }, + { key: "ceo", name: { en: "CEO" }, rank: 1, permissionKeys: [...POSITION_PERMISSION_PRESETS.ceo, overviewLayout("executive")] }, + { key: "ethiopian_gl", name: { en: "Ethiopian GL" }, rank: 3, permissionKeys: [...POSITION_PERMISSION_PRESETS.ethiopianGl, overviewLayout("clearance")] }, + { key: "djibouti_gl", name: { en: "Djibouti GL" }, rank: 3, permissionKeys: [...POSITION_PERMISSION_PRESETS.djiboutiGl, overviewLayout("clearance")] }, + { key: "marketer", name: { en: "Marketer" }, rank: 4, permissionKeys: [...POSITION_PERMISSION_PRESETS.marketer, overviewLayout("marketer")] }, + { key: "operation", name: { en: "Operation" }, rank: 4, permissionKeys: [...POSITION_PERMISSION_PRESETS.operation, overviewLayout("operation")] }, + { key: "operations_chief", name: { en: "Operations Chief" }, rank: 2, permissionKeys: [...POSITION_PERMISSION_PRESETS.operationsChief, overviewLayout("operation")] }, + { key: "dispatcher", name: { en: "Dispatcher" }, rank: 4, permissionKeys: [...POSITION_PERMISSION_PRESETS.dispatcher, overviewLayout("operation")] }, + { key: "truck_machinery_chief", name: { en: "Truck & Machinery Chief" }, rank: 2, permissionKeys: [...POSITION_PERMISSION_PRESETS.truckMachineryChief, overviewLayout("operation")] }, ]; 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 1a6170336..8204ee2d3 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -49,13 +49,14 @@ const perm = (id: string, key: string, en: string): FreightPermissionSeed => ({ }); /** - * One entry per report definition (see modules/reports/definitions). Each - * gets its own permission, gated behind the `reports:view` master key that - * opens the Reports section itself. - * Keep new keys at the END: reportPermId derives ids from list index, so a - * mid-list insert would shift ids already seeded for later keys. + * Every report key ever seeded, in seed order. + * + * NEVER reorder or delete an entry: reportPermId derives a permission's uuid + * from its index here, so a shift would re-map ids already granted to roles. + * Retiring a report means adding it to RETIRED_REPORT_KEYS, not removing it. + * New keys go at the END. */ -export const REPORT_KEYS = [ +const SEEDED_REPORT_KEYS = [ "bookings-list", "revenue-by-customer", "aging-receivables", @@ -98,21 +99,100 @@ export const REPORT_KEYS = [ "cargo-volume-by-station", ] as const; -export type ReportKey = (typeof REPORT_KEYS)[number]; +/** + * Reports whose definition was deleted (see modules/reports/definitions) — a + * flat list the Exports module and its backoffice table already serve, or a + * narrower view of a report that supersedes it. Their permissions stay seeded + * so no live report's uuid moves; nothing resolves them to a definition. + */ +const RETIRED_REPORT_KEYS = [ + "bookings-list", + "customer-status", + "contract-lifecycle", + "invoices-by-status", + "payments-by-status", + "revenue-summary", +] as const; -export const reportPermissionKey = (key: ReportKey): string => +export type ReportKey = Exclude< + (typeof SEEDED_REPORT_KEYS)[number], + (typeof RETIRED_REPORT_KEYS)[number] +>; + +/** One entry per live report definition — what the catalog and presets use. */ +export const REPORT_KEYS: readonly ReportKey[] = SEEDED_REPORT_KEYS.filter( + (k): k is ReportKey => + !(RETIRED_REPORT_KEYS as readonly string[]).includes(k), +); + +export const reportPermissionKey = (key: string): string => `edr_freight_app:reports:${key.replace(/-/g, "_")}:view`; const reportPermId = (index: number): string => `a4f00002-0001-4000-8000-${(index + 1).toString(16).padStart(12, "0")}`; const titleCase = (slug: string): string => - slug.split("-").map((w) => w[0].toUpperCase() + w.slice(1)).join(" "); + slug + .split("-") + .map((w) => w[0].toUpperCase() + w.slice(1)) + .join(" "); -export const REPORT_PERMISSIONS: FreightPermissionSeed[] = REPORT_KEYS.map( - (key, index) => - perm(reportPermId(index), reportPermissionKey(key), `Report: ${titleCase(key)}`), -); +// Seeded from SEEDED_REPORT_KEYS, not REPORT_KEYS: a retired report keeps its +// index and its permission row, which is what stops the live ids from moving. +export const REPORT_PERMISSIONS: FreightPermissionSeed[] = + SEEDED_REPORT_KEYS.map((key, index) => + perm( + reportPermId(index), + reportPermissionKey(key), + `Report: ${titleCase(key)}`, + ), + ); + +/** + * Overview dashboard layouts (see the backoffice's role-dashboards.config.ts, + * where `LAYOUTS` renders one composition per key). Unlike reports, a caller + * lands on exactly ONE layout, so `OVERVIEW_LAYOUT_KEYS` is also the priority + * order: whoever resolves the permission set picks the FIRST key here the + * caller holds — the specific operational view wins over the broad executive + * one, same rule the old role/position-key table encoded. + * + * NEVER reorder — GET /overview/layouts and the frontend both walk this array + * to break ties, so reordering silently changes who gets which dashboard. + */ +export const OVERVIEW_LAYOUT_KEYS = [ + "clearance", + "occ", + "operation", + "marketer", + "finance", + "executive", +] as const; + +export type OverviewLayoutKey = (typeof OVERVIEW_LAYOUT_KEYS)[number]; + +export const OVERVIEW_LAYOUT_LABELS: Record = { + clearance: "Clearance & logistics dashboard", + occ: "Control centre dashboard", + operation: "Operations dashboard", + marketer: "Marketing dashboard", + finance: "Finance dashboard", + executive: "Executive dashboard", +}; + +export const overviewLayoutPermissionKey = (key: string): string => + `edr_freight_app:overview:${key}:view`; + +const overviewLayoutPermId = (index: number): string => + `a4f00003-0001-4000-8000-${(index + 1).toString(16).padStart(12, "0")}`; + +export const OVERVIEW_LAYOUT_PERMISSIONS: FreightPermissionSeed[] = + OVERVIEW_LAYOUT_KEYS.map((key, index) => + perm( + overviewLayoutPermId(index), + overviewLayoutPermissionKey(key), + `Overview layout: ${OVERVIEW_LAYOUT_LABELS[key]}`, + ), + ); export const BOOKING_PERMISSIONS: FreightPermissionSeed[] = [ perm( @@ -464,12 +544,12 @@ export const RULE_ENGINE_PERMISSIONS: FreightPermissionSeed[] = ), ...(approveId ? [ - perm( - approveId, - `edr_freight_app:rule_engine:${resource}:approve`, - `Approve ${slug} changes`, - ), - ] + perm( + approveId, + `edr_freight_app:rule_engine:${resource}:approve`, + `Approve ${slug} changes`, + ), + ] : []), ]; }); @@ -572,8 +652,16 @@ export const SHIPPING_LINE_PERMISSIONS: FreightPermissionSeed[] = [ // Internal chat (Matrix/Element) — sidebar visibility + manual reconcile trigger. export const CHAT_PERMISSIONS: FreightPermissionSeed[] = [ - perm('c9a00001-0001-4000-8000-000000000001', 'edr_freight_app:chat:view', 'Open internal chat'), - perm('c9a00001-0001-4000-8000-000000000002', 'edr_freight_app:chat:sync', 'Re-run chat room/membership sync'), + perm( + "c9a00001-0001-4000-8000-000000000001", + "edr_freight_app:chat:view", + "Open internal chat", + ), + perm( + "c9a00001-0001-4000-8000-000000000002", + "edr_freight_app:chat:sync", + "Re-run chat room/membership sync", + ), ]; // D. Finance — payments + invoices @@ -1381,6 +1469,20 @@ export const SCHEDULING_EXTRA_PERMISSIONS: FreightPermissionSeed[] = [ "edr_freight_app:train_scheduling:rules_manage", "Manage global scheduling rules", ), + // Carved out of the coarse `update` — confirming a booking's cargo loaded/ + // unloaded at a yard, across import, export, and intercity movements alike + // (the same schedules/:id/bookings/:bookingId/{load,unload} + intercity + // routes serve all three directions). + perm( + "a2a00001-0001-4000-8000-000000000006", + "edr_freight_app:train_scheduling:load", + "Confirm cargo loaded (import, export, intercity)", + ), + perm( + "a2a00001-0001-4000-8000-000000000007", + "edr_freight_app:train_scheduling:unload", + "Confirm cargo unloaded (import, export, intercity)", + ), ]; // L. Administration & settings (split from the coarse admin umbrella) @@ -1746,6 +1848,7 @@ export const NOTIFICATION_PERMISSIONS: FreightPermissionSeed[] = [ export const ADVANCED_BACKOFFICE_PERMISSIONS: FreightPermissionSeed[] = [ ...REPORT_PERMISSIONS, + ...OVERVIEW_LAYOUT_PERMISSIONS, ...CUSTOMER_PERMISSIONS, ...SHIPPING_LINE_PERMISSIONS, ...CHAT_PERMISSIONS, @@ -1907,6 +2010,15 @@ export const FREIGHT_PERMS = { cancel: "edr_freight_app:train_scheduling:cancel", reschedule: "edr_freight_app:train_scheduling:reschedule", rulesManage: "edr_freight_app:train_scheduling:rules_manage", + /** + * Confirm a booking's cargo loaded/unloaded at a yard — carved out of the + * coarse `update` so load/unload can be granted independently of general + * schedule editing. Covers import, export, and intercity alike: the + * generic per-booking route and the intercity-specific one both gate on + * these same two keys. + */ + load: "edr_freight_app:train_scheduling:load", + unload: "edr_freight_app:train_scheduling:unload", dispatch: "edr_freight_app:train_scheduling:dispatch", markPaid: "edr_freight_app:train_scheduling:mark_paid", expireBooking: "edr_freight_app:train_scheduling:expire_booking", @@ -1978,8 +2090,7 @@ export const FREIGHT_PERMS = { // finance-level REQUEST grants (per action) and decision grants that apply // to ANY pending request — including the holder's own. /** Request recording an offline payment against a credit invoice. */ - invoiceMarkPaid: - "edr_freight_app:shipping_line_credits:invoice_mark_paid", + invoiceMarkPaid: "edr_freight_app:shipping_line_credits:invoice_mark_paid", /** Request voiding a credit invoice (credits return to unbilled). */ invoiceCancel: "edr_freight_app:shipping_line_credits:invoice_cancel", /** Approve any pending invoice request (mark-paid or cancel). */ @@ -1988,8 +2099,8 @@ export const FREIGHT_PERMS = { invoiceReject: "edr_freight_app:shipping_line_credits:invoice_reject", }, chat: { - view: 'edr_freight_app:chat:view', - sync: 'edr_freight_app:chat:sync', + view: "edr_freight_app:chat:view", + sync: "edr_freight_app:chat:sync", }, payments: { view: "edr_freight_app:payments:view", @@ -2292,6 +2403,7 @@ export const FREIGHT_PERMS = { }, overview: { view: "edr_freight_app:overview:view", + layout: (key: OverviewLayoutKey): string => overviewLayoutPermissionKey(key), }, reports: { view: "edr_freight_app:reports:view", @@ -2444,7 +2556,8 @@ const FLEET_GRANULAR_KEYS: string[] = [ FREIGHT_PERMS.consignments.create, ]; -const allReportKeys = (): string[] => REPORT_KEYS.map((k) => reportPermissionKey(k)); +const allReportKeys = (): string[] => + REPORT_KEYS.map((k) => reportPermissionKey(k)); // Everyone who works the booking desk also opens the overview dashboard and // the canned reports — granted alongside bookings:view in every preset below. @@ -2512,6 +2625,8 @@ export const ROLE_PERMISSION_PRESETS = { FREIGHT_PERMS.trainScheduling.view, FREIGHT_PERMS.trainScheduling.create, FREIGHT_PERMS.trainScheduling.update, + FREIGHT_PERMS.trainScheduling.load, + FREIGHT_PERMS.trainScheduling.unload, FREIGHT_PERMS.trainScheduling.cancel, FREIGHT_PERMS.trainScheduling.reschedule, FREIGHT_PERMS.trainScheduling.rulesManage, @@ -2732,6 +2847,8 @@ export const POSITION_PERMISSION_PRESETS = { FREIGHT_PERMS.trainScheduling.view, FREIGHT_PERMS.trainScheduling.create, FREIGHT_PERMS.trainScheduling.update, + FREIGHT_PERMS.trainScheduling.load, + FREIGHT_PERMS.trainScheduling.unload, FREIGHT_PERMS.trainScheduling.cancel, FREIGHT_PERMS.trainScheduling.reschedule, FREIGHT_PERMS.trainScheduling.rulesManage, diff --git a/apps/edr-freight-api/src/seed/freight-positions.seeder.ts b/apps/edr-freight-api/src/seed/freight-positions.seeder.ts index 81df21a17..9357398c7 100644 --- a/apps/edr-freight-api/src/seed/freight-positions.seeder.ts +++ b/apps/edr-freight-api/src/seed/freight-positions.seeder.ts @@ -229,7 +229,18 @@ export class FreightPositionsSeeder { return; } - await positionPermissionRepository.insert(rowsToInsert); + // orIgnore, not a bare insert: the read above and this write are not + // atomic across processes — two API replicas booting together (or a + // restart racing a running boot) both see the grant missing and both + // insert it, and the loser died on UQ_87ee8f7eef7366389a02ff69f04 with + // the whole seed transaction. ON CONFLICT DO NOTHING makes the grant + // idempotent no matter who else is inserting it. + await positionPermissionRepository + .createQueryBuilder() + .insert() + .values(rowsToInsert) + .orIgnore() + .execute(); this.logger.log( `Granted ${rowsToInsert.length} permissions to position '${seed.key}'`, diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/AdditionalPaymentsTab.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/AdditionalPaymentsTab.tsx index 38fda81e1..e6eb81a02 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/AdditionalPaymentsTab.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/AdditionalPaymentsTab.tsx @@ -16,6 +16,7 @@ import { Textarea, Tooltip, } from "@mantine/core"; +import { DateInput } from "@mantine/dates"; import { Ban, Download, @@ -32,7 +33,7 @@ import { isViewable } from "@edr/ui-common"; import { bookingsService } from "@/services/bookings.service"; import { downloadBookingFile, fetchViewableFile } from "@/services/files.service"; -import { formatDateTime } from "@/lib/format"; +import { formatDate, formatDateTime } from "@/lib/format"; import { extractErrorMessage } from "@/utils/errorExtractor"; const CURRENCIES = ["ETB", "USD"]; @@ -75,6 +76,7 @@ export function AdditionalPaymentsTab({ bookingId, onViewFile }: AdditionalPayme currency: string; action: "draft" | "send"; file?: File | null; + dueDate?: string | null; }) => bookingsService.createAdditionalCharge(bookingId, p), onSuccess: (next, p) => { toast.success(p.action === "send" ? "Charge sent to the customer" : "Draft saved"); @@ -203,16 +205,29 @@ function ChargeCard({ {charge.cancelReason ? ` — ${charge.cancelReason}` : ""} )} + {charge.dueAt && charge.status !== "PAID" && charge.status !== "CANCELLED" && ( + + Due {formatDate(charge.dueAt)} + + )} - - - {charge.amount.toLocaleString(undefined, { minimumFractionDigits: 2 })}{" "} - {charge.currency} - - - {meta.label} - + + + + {charge.amount.toLocaleString(undefined, { minimumFractionDigits: 2 })}{" "} + {charge.currency} + + + {meta.label} + + + {charge.convertedAmount != null && ( + + ≈ {charge.convertedAmount.toLocaleString(undefined, { minimumFractionDigits: 2 })}{" "} + {charge.convertedCurrency} + + )} @@ -297,12 +312,14 @@ function AddChargeModal({ currency: string; action: "draft" | "send"; file?: File | null; + dueDate?: string | null; }) => void; }) { const [reason, setReason] = useState(""); const [amount, setAmount] = useState(""); const [currency, setCurrency] = useState("ETB"); const [file, setFile] = useState(null); + const [dueDate, setDueDate] = useState(null); const valid = reason.trim().length > 0 && Number(amount) > 0; @@ -311,11 +328,23 @@ function AddChargeModal({ setAmount(""); setCurrency("ETB"); setFile(null); + setDueDate(null); }; const submit = (action: "draft" | "send") => { if (!valid) return; - onSubmit({ reason: reason.trim(), amount: Number(amount), currency, action, file }); + onSubmit({ + reason: reason.trim(), + amount: Number(amount), + currency, + action, + file, + // Local calendar date, not a UTC-shifted ISO timestamp — toISOString() can + // roll the date back a day for evening local time in a positive-offset zone. + dueDate: dueDate + ? `${dueDate.getFullYear()}-${String(dueDate.getMonth() + 1).padStart(2, "0")}-${String(dueDate.getDate()).padStart(2, "0")}` + : null, + }); }; return ( @@ -355,6 +384,14 @@ function AddChargeModal({ w={100} /> + setDueDate(v ? new Date(v) : null)} + minDate={new Date()} + clearable + /> {(props) => ( + + + void; }) { const { toast } = useToast(); + const { user } = useAuth(); + const canUnload = hasFreightPermission(user, FREIGHT_PERMS.warehouseInventory.unload); const { data: trains = [], isLoading } = useQuery( api.warehouses.importArriveQueue.queryOptions({ enabled }), ); @@ -2489,23 +2497,25 @@ export function ImportArriveQueueTab({ > Open - + + + diff --git a/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts b/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts index 7f6f4993c..3f1cce1b5 100644 --- a/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts @@ -235,6 +235,7 @@ export const QUERY_KEYS = { OVERVIEW: { ROOT: ["overview"] as const, + layouts: () => ["overview", "layouts"] as const, dashboard: (range?: string) => ["overview", "dashboard", range ?? "30d"] as const, bookingsTab: (range?: string) => diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index 68fefa3c9..bb3d88d2a 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -184,6 +184,7 @@ export const URL_CONSTANTS = { OVERVIEW: { BASE: "/overview", + LAYOUTS: "/overview/layouts", BOOKINGS: "/overview/bookings", CONTRACTS: "/overview/contracts", BILLING: "/overview/billing", diff --git a/apps/edr-freight-web/backoffice/src/hooks/useOverview.ts b/apps/edr-freight-web/backoffice/src/hooks/useOverview.ts index 7225ee5bc..42abe59e1 100644 --- a/apps/edr-freight-web/backoffice/src/hooks/useOverview.ts +++ b/apps/edr-freight-web/backoffice/src/hooks/useOverview.ts @@ -11,6 +11,15 @@ export function useOverview(range: OverviewRange = "30d") { }); } +/** Layouts the caller may render — server-filtered by permission, same shape as useReports' catalog. */ +export function useOverviewLayouts() { + return useQuery({ + queryKey: QUERY_KEYS.OVERVIEW.layouts(), + queryFn: () => overviewService.getLayouts(), + staleTime: 5 * 60 * 1000, + }); +} + export function useOverviewBookingsTab(range: OverviewRange, enabled: boolean) { return useQuery({ queryKey: QUERY_KEYS.OVERVIEW.bookingsTab(range), diff --git a/apps/edr-freight-web/backoffice/src/lib/permissions.ts b/apps/edr-freight-web/backoffice/src/lib/permissions.ts index 177976b28..d8ef47f35 100644 --- a/apps/edr-freight-web/backoffice/src/lib/permissions.ts +++ b/apps/edr-freight-web/backoffice/src/lib/permissions.ts @@ -104,6 +104,9 @@ export const FREIGHT_PERMS = { view: "edr_freight_app:train_scheduling:view", create: "edr_freight_app:train_scheduling:create", update: "edr_freight_app:train_scheduling:update", + /** Confirm cargo loaded/unloaded at a yard — import, export, and intercity alike. */ + load: "edr_freight_app:train_scheduling:load", + unload: "edr_freight_app:train_scheduling:unload", cancel: "edr_freight_app:train_scheduling:cancel", reschedule: "edr_freight_app:train_scheduling:reschedule", rulesManage: "edr_freight_app:train_scheduling:rules_manage", diff --git a/apps/edr-freight-web/backoffice/src/pages/customers/CustomersPage.tsx b/apps/edr-freight-web/backoffice/src/pages/customers/CustomersPage.tsx index 5b8155c63..72eb38fe6 100644 --- a/apps/edr-freight-web/backoffice/src/pages/customers/CustomersPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/customers/CustomersPage.tsx @@ -4,7 +4,6 @@ import { Box, Card, Group, - SegmentedControl, Stack, Text, Tooltip, @@ -22,7 +21,7 @@ import { ShieldOff, Users, } from "lucide-react"; -import { useMemo, useState } from "react"; +import { useMemo } from "react"; import { useNavigate } from "react-router-dom"; import { @@ -31,49 +30,22 @@ import { ManualRegistrationBadge, ProfileChips, formatDate, + humanize, } from "@/components/customers"; import { KpiStrip, PageContainer, PageHeader } from "@/components/page"; import { api } from "@/services/api"; -import type { Company, CompanyStatus } from "@/types/customer"; +import type { Company, CompanyListFilter } from "@/types/customer"; import { isOnboardingDraft } from "@/types/customer"; import { DataTable, DataTableFooter, type ColumnDef } from "@edr/ui-common"; -import { FilterBar, useFilters, type FilterDef } from "@/components/filters"; +import { + FilterBar, + dateRangeParams, + isoToLocalDateStr, + useFilters, + type FilterDef, +} from "@/components/filters"; import { ExportButton } from "@/components/export/ExportButton"; -/** - * The list's segmented views. "Pending approval" means submitted-and-awaiting- - * review, so it excludes drafts — a company row exists from the onboarding - * wizard's first click and would otherwise pad the review queue. Those drafts - * get their own view instead of disappearing, so staff can still chase them. - */ -type CustomerView = - | "all" - | "pending" - | "pendingChanges" - | "onboarding" - | "active"; - -/** - * "Pending changes" is deliberately not folded into "Pending approval". A - * customer who edits their profile after being approved stays `status = active`, - * so the pending filter can never match them — their resubmission would only - * ever be visible by opening their detail page. This view is that queue. - */ -const VIEW_FILTERS: Record< - CustomerView, - { - status?: CompanyStatus; - onboardingCompleted?: boolean; - hasPendingChangeRequest?: boolean; - } -> = { - all: {}, - pending: { status: "pending", onboardingCompleted: true }, - pendingChanges: { hasPendingChangeRequest: true }, - onboarding: { onboardingCompleted: false }, - active: { status: "active" }, -}; - const SORT_OPTIONS = [ // Queue ordering: awaiting first approval → pending profile changes → the // rest, newest first within each group. The default, so whatever marketing @@ -85,29 +57,110 @@ const SORT_OPTIONS = [ { value: "name:DESC", label: "Name (Z–A)" }, ] as const; -/** No filter pills — search/sort/page are the only real filter dimensions; - * `view` below is a tab (mutually exclusive, navigational), not a filter. */ -const NO_FILTER_DEFS: FilterDef[] = []; +/** + * Every state a customer can be in, as one single-select list. + * + * Three of these are not `companies.status` values at all, which is why each + * option maps its own params: + * - **Pending approval** is submitted-and-awaiting-review, so it excludes + * drafts — a company row exists from the onboarding wizard's first click and + * would otherwise pad the review queue. + * - **Onboarding** is that draft: still in the portal wizard, never submitted. + * - **Pending changes** is an already-approved (`active`) customer who edited + * their profile. `status` can never match them, so without this option their + * resubmission is only visible by opening their detail page. + */ +const STATUS_OPTIONS: { + value: string; + label: string; + params: Record; +}[] = [ + { value: "pending", label: "Pending approval", params: { status: "pending", onboardingCompleted: "true" } }, + { value: "pendingChanges", label: "Pending changes", params: { hasPendingChangeRequest: "true" } }, + { value: "onboarding", label: "Onboarding", params: { onboardingCompleted: "false" } }, + { value: "active", label: "Active", params: { status: "active" } }, + { value: "suspended", label: "Suspended", params: { status: "suspended" } }, + { value: "blacklisted", label: "Blacklisted", params: { status: "blacklisted" } }, +]; + +/** + * Filter pills. The review queues that used to sit beside them as segmented + * tabs are folded into the Status pill above — three of the five were never a + * plain `status` value, so as a separate tab strip they could contradict the + * status filter next to them. One list, mutually exclusive, no contradiction. + */ +const CUSTOMER_FILTER_DEFS: FilterDef[] = [ + { + key: "status", + label: "Status", + type: "enum", + multiple: false, + options: STATUS_OPTIONS.map(({ value, label }) => ({ value, label })), + toParams: (v) => + STATUS_OPTIONS.find((o) => o.value === v.v[0])?.params ?? {}, + }, + { + key: "type", + label: "Type", + type: "enum", + multiple: false, + options: ( + ["customer", "freight_forwarder", "dj_freight_forwarder", "transporter"] as const + ).map((value) => ({ value, label: humanize(value) })), + }, + { + key: "kind", + label: "Sector", + type: "enum", + multiple: false, + options: [ + { value: "commercial", label: "Commercial" }, + { value: "government", label: "Government" }, + ], + }, + { + key: "nationality", + label: "Nationality", + type: "enum", + multiple: false, + options: [ + { value: "ethiopian", label: "Ethiopian" }, + { value: "foreign", label: "Foreign" }, + ], + }, + { + key: "created", + label: "Registered", + type: "date", + secondary: true, + operators: ["between", "before", "after"], + toParams: dateRangeParams("createdFrom", "createdTo"), + }, +]; export default function CustomersPage() { const navigate = useNavigate(); - const [view, setView] = useState("all"); - const controls = useFilters(NO_FILTER_DEFS, { defaultSort: "review:DESC", pageSize: 10 }); + const controls = useFilters(CUSTOMER_FILTER_DEFS, { + defaultSort: "review:DESC", + pageSize: 10, + }); - const filter = useMemo(() => { - const [sortBy, sortOrder] = controls.sort.split(":") as [ - "review" | "name" | "createdAt" | "updatedAt", - "ASC" | "DESC", - ]; - return { - page: controls.page, - pageSize: controls.pageSize, - search: String(controls.params.search ?? ""), - sortBy, - sortOrder, - ...VIEW_FILTERS[view], - }; - }, [controls.page, controls.pageSize, controls.params.search, controls.sort, view]); + // `controls.params` is the whole query: page/pageSize/search, the split + // sortBy/sortOrder, and every pill's mapped params. + const filter = controls.params as unknown as CompanyListFilter; + + /** + * The export's `daterange` filters are coerced from calendar days while the + * list takes ISO instants — hand the dialog the local day each bound falls on + * so the file covers the same range the screen shows. + */ + const exportParams = useMemo(() => { + const out: Record = { ...controls.params }; + for (const key of ["createdFrom", "createdTo"]) { + if (typeof out[key] === "string") out[key] = isoToLocalDateStr(out[key] as string); + } + return out; + }, [controls.params]); const { data: stats } = useQuery( api.customers.stats.queryOptions({ input: {} }), @@ -293,33 +346,13 @@ export default function CustomersPage() { ({ ...o }))} viewId="customers" > - { - // `view` lives outside useFilters (it's a tab, not a - // filter pill), so switching it needs its own page reset — - // the same "stranded on page 5" hazard useFilters guards - // against for its own filters. - setView(v as CustomerView); - controls.setPage(1); - }} - data={[ - { label: "All", value: "all" }, - { label: "Pending approval", value: "pending" }, - { label: "Pending changes", value: "pendingChanges" }, - { label: "Onboarding", value: "onboarding" }, - { label: "Active", value: "active" }, - ]} - /> - + @@ -331,8 +364,8 @@ export default function CustomersPage() { status={isLoading ? "loading" : isError ? "error" : "success"} onRowClick={(row) => navigate(`/dashboard/customers/${row.id}`)} emptyMessage={ - controls.searchText - ? "No companies match your search." + controls.activeCount > 0 + ? "No companies match these filters." : "No companies yet." } error={ diff --git a/apps/edr-freight-web/backoffice/src/pages/dashboard/OverviewPage.tsx b/apps/edr-freight-web/backoffice/src/pages/dashboard/OverviewPage.tsx index eadd2f1c6..4c316477f 100644 --- a/apps/edr-freight-web/backoffice/src/pages/dashboard/OverviewPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/dashboard/OverviewPage.tsx @@ -3,7 +3,6 @@ import { AlertCircle } from "lucide-react"; import { Alert, Button, Skeleton, Stack } from "@mantine/core"; import { useQueryClient } from "@tanstack/react-query"; -import { useAuth } from "@/auth/useAuth"; import { PageContainer } from "@/components/page"; import { ClearanceOverview } from "@/components/overview/layouts/ClearanceOverview"; import { ExecutiveOverview } from "@/components/overview/layouts/ExecutiveOverview"; @@ -20,7 +19,7 @@ import { import { OverviewHero } from "@/components/overview/summary/OverviewHero"; import { OverviewHeroKpis } from "@/components/overview/summary/OverviewHeroKpis"; import { QUERY_KEYS } from "@/constants/QUERY_KEYS"; -import { useOverview } from "@/hooks/useOverview"; +import { useOverview, useOverviewLayouts } from "@/hooks/useOverview"; import type { OverviewRange } from "@/types/overview"; import "@/components/overview/summary/overview-summary.css"; @@ -57,13 +56,14 @@ function OverviewSkeleton() { const OverviewPage = () => { const [range, setRange] = useState("30d"); const queryClient = useQueryClient(); - const { user } = useAuth(); const { data, isLoading, isError, error, refetch, isFetching } = useOverview(range); + const { data: layouts, isLoading: layoutsLoading } = useOverviewLayouts(); // Hero, range control and headline KPIs are role-neutral; everything below - // them is chosen by role key. - const layoutKey = resolveOverviewLayout(user); + // them is chosen by which overview::view permissions the caller holds + // (GET /overview/layouts already filtered these server-side). + const layoutKey = resolveOverviewLayout(layouts?.map((l) => l.key)); const RoleLayout = layoutKey ? LAYOUTS[layoutKey] : null; const accessDenied = @@ -129,7 +129,7 @@ const OverviewPage = () => { )} - {isLoading && !data ? ( + {(isLoading || layoutsLoading) && !data ? ( diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx index 50f3fe26c..746d0118a 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx @@ -224,6 +224,21 @@ const FleetResourcePage = () => { secondary: true, toParams: dateRangeParams("createdFrom", "createdTo"), }; + // Wagons-only: "last maintenance" is a derived value (latest status-log + // flip to MAINTENANCE), not a column other fleet resources have. + const dateDefs: FilterDef[] = + slug === "wagons" + ? [ + dateDef, + { + key: "lastMaintenance", + label: "Last maintenance", + type: "date", + secondary: true, + toParams: dateRangeParams("maintenanceFrom", "maintenanceTo"), + }, + ] + : [dateDef]; if (config?.listFilters?.length) { return [ ...config.listFilters.map((filter): FilterDef => ({ @@ -235,13 +250,13 @@ const FleetResourcePage = () => { ? (dynamicOptions[filter.dynamicOptions] ?? []) : (filter.options ?? []), })), - dateDef, + ...dateDefs, ]; } const fallback = FALLBACK_STATUS_OPTIONS[slug]; return fallback - ? [{ key: "status", label: "Status", type: "enum", multiple: false, options: fallback }, dateDef] - : [dateDef]; + ? [{ key: "status", label: "Status", type: "enum", multiple: false, options: fallback }, ...dateDefs] + : dateDefs; }, [config, dynamicOptions, slug]); const controls = useFilters(filterDefs, { pageSize: 10 }); diff --git a/apps/edr-freight-web/backoffice/src/pages/invoices/InvoicesPage.tsx b/apps/edr-freight-web/backoffice/src/pages/invoices/InvoicesPage.tsx index 68cb7514d..8026f692b 100644 --- a/apps/edr-freight-web/backoffice/src/pages/invoices/InvoicesPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/invoices/InvoicesPage.tsx @@ -1,29 +1,127 @@ -import type { Freight } from "@edr/types"; -import { - ActionIcon, - Badge, - Box, - Card, - Group, - SegmentedControl, - Stack, - Text, - TextInput, -} from "@mantine/core"; -import { useDebouncedValue } from "@mantine/hooks"; +import { Freight } from "@edr/types"; +import { ActionIcon, Badge, Box, Card, Group, Stack, Text } from "@mantine/core"; import { useQuery } from "@tanstack/react-query"; -import { Banknote, CircleDollarSign, Landmark, RefreshCw, Search, X } from "lucide-react"; -import { useMemo, useState } from "react"; +import { Banknote, CircleDollarSign, Landmark, RefreshCw } from "lucide-react"; +import { useMemo } from "react"; import { useNavigate } from "react-router-dom"; import { InvoiceStatusBadge, formatDate, formatMoney, humanize } from "@/components/customers"; +import { + FilterBar, + dateRangeParams, + isoToLocalDateStr, + useFilters, + type FilterDef, +} from "@/components/filters"; import { KpiStrip } from "@/components/page"; import CreditInvoiceActions from "@/components/shipping-lines/CreditInvoiceActions"; import { ExportButton } from "@/components/export/ExportButton"; import { useExchangeSettingsQuery } from "@/hooks/useExchangeSettings"; import { api } from "@/services/api"; -import type { Invoice } from "@/types/invoice"; -import { DataTable, DataTableFooter, usePagination, type ColumnDef } from "@edr/ui-common"; +import type { Invoice, InvoiceListFilter } from "@/types/invoice"; +import { DataTable, DataTableFooter, type ColumnDef } from "@edr/ui-common"; + +const STATUS_OPTIONS = Object.values(Freight.InvoiceStatus).map((value) => ({ + value, + label: humanize(value), +})); + +const SOURCE_OPTIONS = Object.values(Freight.InvoiceSource).map((value) => ({ + value, + label: humanize(value), +})); + +/** Mirrors `EimsInvoiceStatus` in the API — Finance's "what still needs filing" cut. */ +const EIMS_STATUS_OPTIONS = [ + "NOT_SUBMITTED", + "SUBMITTING", + "REGISTERED", + "FAILED", + "UNKNOWN", + "CANCELLED", +].map((value) => ({ value, label: humanize(value) })); + +/** + * Every dimension the list narrows by. Keys are the URL keys; `toParams` maps + * them onto the API's `FilterInvoiceDto`. Secondary defs sit behind "More + * filters" until they hold a value, then pin themselves as a pill. + */ +const INVOICE_FILTER_DEFS: FilterDef[] = [ + { key: "statuses", label: "Status", type: "enum", options: STATUS_OPTIONS }, + { key: "sources", label: "Source", type: "enum", options: SOURCE_OPTIONS }, + { + key: "currency", + label: "Currency", + type: "enum", + multiple: false, + options: [ + { value: "ETB", label: "ETB" }, + { value: "USD", label: "USD" }, + ], + }, + { + // One pill for the two settlement cuts Finance actually chases. Both are + // computed from the balance and due date rather than read off `status` — + // nothing sweeps PENDING rows into OVERDUE, so the status under-reports. + key: "settlement", + label: "Settlement", + type: "enum", + multiple: false, + options: [ + { value: "outstanding", label: "Outstanding" }, + { value: "overdue", label: "Overdue" }, + ], + toParams: (v) => + v.v[0] === "overdue" ? { overdue: "true" } : { hasBalance: "true" }, + }, + { + key: "issued", + label: "Issued", + type: "date", + operators: ["between", "before", "after"], + toParams: dateRangeParams("issuedFrom", "issuedTo"), + }, + { + key: "due", + label: "Due", + type: "date", + secondary: true, + operators: ["between", "before", "after"], + toParams: dateRangeParams("dueFrom", "dueTo"), + }, + { + key: "amount", + label: "Amount", + type: "number", + secondary: true, + operators: ["between", "is"], + // Amounts are compared in each invoice's OWN currency — pair this with the + // currency pill when the mix matters. + toParams: (v) => + v.op === "between" + ? { minAmount: v.v[0], maxAmount: v.v[1] } + : { minAmount: v.v[0], maxAmount: v.v[0] }, + }, + { + key: "eimsStatuses", + label: "EIMS", + type: "enum", + secondary: true, + options: EIMS_STATUS_OPTIONS, + }, +]; + +const SORT_OPTIONS = [ + { value: "issuedAt:DESC", label: "Newest issued" }, + { value: "issuedAt:ASC", label: "Oldest issued" }, + { value: "dueAt:ASC", label: "Due soonest" }, + { value: "totalAmount:DESC", label: "Largest amount" }, + { value: "balanceAmount:DESC", label: "Largest balance" }, + { value: "invoiceNumber:ASC", label: "Invoice no. (A–Z)" }, +]; + +/** Date params the export's `daterange` coercion expects as calendar days. */ +const EXPORT_DAY_KEYS = ["issuedFrom", "issuedTo", "dueFrom", "dueTo"]; /** * Which record raised the invoice, not just which subsystem. The source label @@ -65,20 +163,12 @@ function InvoiceSourceCell({ invoice }: { invoice: Invoice }) { /** Invoices tab body of `FinanceHubPage` — page chrome lives in the parent. */ export default function InvoicesPanel() { const navigate = useNavigate(); - const { pagination, setPagination } = usePagination({ pageSize: 10 }); - const [query, setQuery] = useState(""); - const [debouncedQuery] = useDebouncedValue(query, 300); - const [statusFilter, setStatusFilter] = useState<"" | Freight.InvoiceStatus>(""); + const controls = useFilters(INVOICE_FILTER_DEFS, { + defaultSort: "issuedAt:DESC", + pageSize: 10, + }); - const filter = useMemo( - () => ({ - page: pagination.pageIndex + 1, - pageSize: pagination.pageSize, - search: debouncedQuery, - status: statusFilter || undefined, - }), - [pagination.pageIndex, pagination.pageSize, debouncedQuery, statusFilter], - ); + const filter = controls.params as unknown as InvoiceListFilter; const { data, isLoading, isError, refetch, isFetching } = useQuery( api.invoices.list.queryOptions({ input: { filter } }), @@ -86,7 +176,6 @@ export default function InvoicesPanel() { const rows = data?.items ?? []; const total = data?.total ?? 0; - const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize)); // Shipping-line credit invoices carry maker–checker actions (mark paid / // cancel). One batched lookup fetches the visible rows' pending requests. @@ -106,14 +195,28 @@ export default function InvoicesPanel() { ); // Summary card: total collected (paidAmount) across every invoice matching - // the current search/status filters, not just the visible page. + // the current filters, not just the visible page. Same params minus + // pagination, so the card can never total a different set than the table. + const summaryFilter = useMemo(() => { + const { page: _page, pageSize: _pageSize, ...rest } = filter; + return rest; + }, [filter]); const { data: summary, isLoading: summaryLoading } = useQuery( - api.invoices.collectedSummary.queryOptions({ - input: { - filter: { search: debouncedQuery, status: statusFilter || undefined }, - }, - }), + api.invoices.collectedSummary.queryOptions({ input: { filter: summaryFilter } }), ); + + /** + * The export's `daterange` filters are coerced from calendar days, while the + * list takes ISO instants — hand the dialog the local day each bound falls + * on so an exported file covers the same range the screen shows. + */ + const exportParams = useMemo(() => { + const out: Record = { ...controls.params }; + for (const key of EXPORT_DAY_KEYS) { + if (typeof out[key] === "string") out[key] = isoToLocalDateStr(out[key] as string); + } + return out; + }, [controls.params]); const { data: exchangeSettings } = useExchangeSettingsQuery(); const etbCollected = summary?.ETB ?? 0; const usdCollected = summary?.USD ?? 0; @@ -236,45 +339,14 @@ export default function InvoicesPanel() { - - } - value={query} - onChange={(e) => setQuery(e.target.value)} - rightSection={ - query ? ( - setQuery("")} - > - - - ) : null - } - style={{ flex: 1, minWidth: "240px" }} - radius="lg" - /> - - { - setStatusFilter(v === "all" ? "" : (v as Freight.InvoiceStatus)); - setPagination((prev) => ({ ...prev, pageIndex: 0 })); - }} - data={[ - { label: "All", value: "all" }, - { label: "Pending", value: "PENDING" }, - { label: "Payment processing", value: "PAYMENT_PROCESSING" }, - { label: "Paid", value: "PAID" }, - { label: "Overdue", value: "OVERDUE" }, - ]} - /> + + - + @@ -296,7 +368,9 @@ export default function InvoicesPanel() { status={isLoading ? "loading" : isError ? "error" : "success"} onRowClick={(row) => navigate(`/dashboard/invoices/${row.id}`)} emptyMessage={ - debouncedQuery ? "No invoices match your search." : "No invoices yet." + controls.activeCount > 0 + ? "No invoices match these filters." + : "No invoices yet." } error={ isError @@ -306,18 +380,7 @@ export default function InvoicesPanel() { } : undefined } - pagination={{ - pageIndex: pagination.pageIndex, - pageSize: pagination.pageSize, - pageCount, - totalCount: total, - }} - tableOptions={{ - state: { pagination }, - onPaginationChange: setPagination, - manualPagination: true, - pageCount, - }} + {...controls.tableProps(total)} containerClassName="border-0 shadow-none bg-transparent" footer={DataTableFooter} /> diff --git a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts index 5fd619bae..273c535e8 100644 --- a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts +++ b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts @@ -151,6 +151,12 @@ const TRADE_DIRECTIONS = [ * a report matches a target by this exact key, so a value here that the API * does not emit is a plan the report will never find. The API spec * `operations-classification.spec.ts` guards the API side of the pair. + * + * Drift is no longer silent: `OperationsTargetsService.assertDimensionKey` + * rejects any key outside the API's own vocabulary, so a stale entry here + * surfaces as a 400 on save rather than a plan that quietly never joins. + * `UNCLASSIFIED` is left out deliberately — the API accepts it, but there is no + * sense in planning against cargo nobody has classified. */ export const OPERATIONS_CARGO_CATEGORIES = [ { label: "Multimodal container import", value: "CONTAINER_IMPORT_MULTIMODAL" }, @@ -713,14 +719,21 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [ ], }, { + // Mirrors TARGET_PERIOD_LABELS in the API's operations-target entity. + // Commit the number at whatever grain the business quotes it — the + // report re-gathers it into whichever grain the viewer asks for. name: "periodType", label: "Period", type: "select", required: true, options: [ + { label: "Daily", value: "day" }, { label: "Weekly", value: "week" }, { label: "Monthly", value: "month" }, { label: "Quarterly", value: "quarter" }, + { label: "Half-yearly", value: "half_year" }, + { label: "Nine-monthly", value: "nine_month" }, + { label: "90-day", value: "ninety_day" }, { label: "Yearly", value: "year" }, ], }, diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/IntercityPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/IntercityPage.tsx index cfda31629..f756af834 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/IntercityPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/IntercityPage.tsx @@ -17,6 +17,8 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { AlertTriangle, PackageCheck, PackageOpen, TrainFront, Warehouse } from "lucide-react"; import { PageContainer, PageHeader } from "@/components/page"; +import { useAuth } from "@/auth/useAuth"; +import { FREIGHT_PERMS, hasPermission as hasFreightPermission } from "@/lib/permissions"; import ListControls from "@/components/common/ListControls"; // Generic list footer — already shared by the fleet and train-scheduling lists // despite the ruleEngine path. @@ -93,6 +95,9 @@ const apiErrorMessage = (error: unknown) => { function Rows({ rows }: { rows: IntercityRideAlongRow[] }) { const { toast } = useToast(); + const { user } = useAuth(); + const canLoad = hasFreightPermission(user, FREIGHT_PERMS.trainScheduling.load); + const canUnload = hasFreightPermission(user, FREIGHT_PERMS.trainScheduling.unload); const queryClient = useQueryClient(); const refresh = () => queryClient.invalidateQueries({ @@ -201,31 +206,37 @@ function Rows({ rows }: { rows: IntercityRideAlongRow[] }) { {/* Work the cargo right here while the train is at the yard. */} {r.trainScheduleId && atOrigin(r) && isWaiting(r) && r.status === "PAID" && ( - + + + )} {r.trainScheduleId && atDestination(r) && isRiding(r) && ( - + + + )} diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseDashboardPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseDashboardPage.tsx index c63852caa..9c1b18578 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseDashboardPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseDashboardPage.tsx @@ -1,9 +1,10 @@ import { useMemo, useState } from 'react'; import { useNavigate } from 'react-router-dom'; -import { Badge, Card, Center, Divider, Group, Loader, Select, SimpleGrid, Stack, Text, ThemeIcon } from '@mantine/core'; +import { Button, Card, Center, Group, Loader, Popover, Select, SimpleGrid, Stack, Text, ThemeIcon } from '@mantine/core'; import { DatePickerInput } from '@mantine/dates'; import { ClipboardList, + Filter, PackageCheck, PackageOpen, PackagePlus, @@ -17,7 +18,6 @@ import { } from 'lucide-react'; import { PageContainer, PageHeader } from '@/components/page'; -import { getDateRangePresets } from '@/components/common/dateRangePresets'; import { AccrualDashboard, CycleTimeCard, @@ -44,44 +44,42 @@ interface Metric { icon: React.ReactNode; /** Route to navigate to when the card is clicked. */ to: string; - theme: string; } -const ORANGE = 'rgb(241, 147, 23)'; -const GREEN = '#084b21'; - const METRICS: Metric[] = [ - { key: 'totalWarehouses', label: 'Total Warehouses', icon: , to: '/dashboard/warehouses', theme: ORANGE }, - { key: 'totalInventory', label: 'Total Inventory', icon: , to: '/dashboard/warehouse-inventory', theme: GREEN }, - { key: 'received', label: 'Received Today', icon: , to: '/dashboard/warehouse-inventory?status=RECEIVED', theme: ORANGE }, - { key: 'awaitingInspection', label: 'Awaiting Inspection', icon: , to: '/dashboard/warehouse-inventory?status=RECEIVED', theme: GREEN }, - { key: 'emptyContainers', label: 'Empty Containers', icon: , to: '/dashboard/containers', theme: ORANGE }, - { key: 'importTrains', label: 'Import Trains', icon: , to: '/dashboard/import-warehouse', theme: GREEN }, - { key: 'exportTrains', label: 'Export Trains', icon: , to: '/dashboard/export-warehouse', theme: ORANGE }, - { key: 'loaded', label: 'Loaded', icon: , to: '/dashboard/loaded-inventory', theme: GREEN }, - { key: 'dispatched', label: 'Dispatched', icon: , to: '/dashboard/dispatch-queue', theme: ORANGE }, - { key: 'readyForPickup', label: 'Ready For Pickup', icon: , to: '/dashboard/warehouse-inventory?status=READY_FOR_PICKUP', theme: GREEN }, - { key: 'readyForLoading', label: 'Ready For Loading', icon: , to: '/dashboard/loading-queue', theme: ORANGE }, - { key: 'delivered', label: 'Delivered', icon: , to: '/dashboard/warehouse-inventory?status=DELIVERED', theme: GREEN }, + { key: 'totalWarehouses', label: 'Total Warehouses', icon: , to: '/dashboard/warehouses' }, + { key: 'totalInventory', label: 'Total Inventory', icon: , to: '/dashboard/warehouse-inventory' }, + { key: 'received', label: 'Received Today', icon: , to: '/dashboard/warehouse-inventory?status=RECEIVED' }, + { key: 'awaitingInspection', label: 'Awaiting Inspection', icon: , to: '/dashboard/warehouse-inventory?status=RECEIVED' }, + { key: 'emptyContainers', label: 'Empty Containers', icon: , to: '/dashboard/containers' }, + { key: 'importTrains', label: 'Import Trains', icon: , to: '/dashboard/import-warehouse' }, + { key: 'exportTrains', label: 'Export Trains', icon: , to: '/dashboard/export-warehouse' }, + { key: 'loaded', label: 'Loaded', icon: , to: '/dashboard/loaded-inventory' }, + { key: 'dispatched', label: 'Dispatched', icon: , to: '/dashboard/dispatch-queue' }, + { key: 'readyForPickup', label: 'Ready For Pickup', icon: , to: '/dashboard/warehouse-inventory?status=READY_FOR_PICKUP' }, + { key: 'readyForLoading', label: 'Ready For Loading', icon: , to: '/dashboard/loading-queue' }, + { key: 'delivered', label: 'Delivered', icon: , to: '/dashboard/warehouse-inventory?status=DELIVERED' }, ]; export default function WarehouseDashboardPage() { const navigate = useNavigate(); - // Both null → the API defaults `received` to "today", matching the page's original behaviour. - const [dateRange, setDateRange] = useState<[string | null, string | null]>([null, null]); + // null → the API defaults `received` to "today", matching the page's original behaviour. + const [receivedDate, setReceivedDate] = useState(null); const [warehouseId, setWarehouseId] = useState(null); - const [dateFrom, dateTo] = dateRange; - const hasCustomRange = Boolean(dateFrom || dateTo); + const [filtersOpen, setFiltersOpen] = useState(false); + const hasCustomDate = Boolean(receivedDate); const warehousesQuery = useWarehouses(); const warehouseOptions = useMemo( () => (warehousesQuery.data ?? []).map((w) => ({ value: w.id, label: `${w.name} (${w.code})` })), [warehousesQuery.data], ); + const activeFilterCount = (warehouseId ? 1 : 0) + (hasCustomDate ? 1 : 0); const { data, isError, isLoading } = useWarehouseDashboard({ - dateFrom: dateFrom ?? undefined, - dateTo: dateTo ?? undefined, + // Same date both ends → the one day the picker selected, inclusive. + dateFrom: receivedDate ?? undefined, + dateTo: receivedDate ?? undefined, warehouseId: warehouseId ?? undefined, }); @@ -89,57 +87,64 @@ export default function WarehouseDashboardPage() { - + {activeFilterCount > 0 && ( + + )} + + + } /> - {(warehouseId || hasCustomRange) && ( + {(warehouseId || hasCustomDate) && ( Scoped to{' '} {warehouseId ? warehouseOptions.find((o) => o.value === warehouseId)?.label ?? 'selected warehouse' : 'all warehouses'} - {hasCustomRange - ? ` · Received counts ${dateFrom ?? '…'} to ${dateTo ?? '…'}` - : ' · Received counts: today'} - . Status-backlog and fleet counters are always current regardless of the date range. + {hasCustomDate ? ` · Received counts for ${receivedDate}` : ' · Received counts: today'} + . Status-backlog and fleet counters are always current regardless of the date filter. )} @@ -152,41 +157,33 @@ export default function WarehouseDashboardPage() { Failed to load warehouse dashboard. ) : ( - + {/* Needs attention — live ops counters (received today, pending inspection, trucks on-site, items aging > 7 days). */} - - Needs attention - - - - + {METRICS.map((metric) => ( navigate(metric.to)} className="cursor-pointer transition-[transform,border-color] duration-150 hover:-translate-y-0.5 hover:border-edr-primary!" > - -
- - {metric.key === 'received' && hasCustomRange ? 'Received' : metric.label} - - - {data ? data[metric.key] : 0} - -
- + + {metric.icon} + + + {metric.key === 'received' && hasCustomDate ? 'Received' : metric.label} + + + {data ? data[metric.key] : 0} + +
))} diff --git a/apps/edr-freight-web/backoffice/src/services/bookings.service.ts b/apps/edr-freight-web/backoffice/src/services/bookings.service.ts index 49e5dccfa..4fbb36b19 100644 --- a/apps/edr-freight-web/backoffice/src/services/bookings.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/bookings.service.ts @@ -583,6 +583,8 @@ export const bookingsService = { currency: string; action: "draft" | "send"; file?: File | null; + /** ISO date (YYYY-MM-DD); omit to fall back to the invoice's default 14-day term. */ + dueDate?: string | null; }, ): Promise => { const form = new FormData(); @@ -590,6 +592,7 @@ export const bookingsService = { form.append("amount", String(payload.amount)); form.append("currency", payload.currency); form.append("action", payload.action); + if (payload.dueDate) form.append("dueDate", payload.dueDate); if (payload.file) form.append("file", payload.file); const response = await client.post( `/bookings/${id}/additional-charges`, diff --git a/apps/edr-freight-web/backoffice/src/services/overview.service.ts b/apps/edr-freight-web/backoffice/src/services/overview.service.ts index 9229b0b4f..471b3538d 100644 --- a/apps/edr-freight-web/backoffice/src/services/overview.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/overview.service.ts @@ -1,6 +1,7 @@ import { api as client } from "../auth/http"; import { unwrap } from "@/utils/endpoint"; import { URL_CONSTANTS } from "@/constants/URLS"; +import type { OverviewLayoutKey } from "@/components/overview/role-dashboards.config"; import type { IOverviewBillingTab, IOverviewBookingsTab, @@ -16,7 +17,19 @@ import type { const O = URL_CONSTANTS.OVERVIEW; +/** Mirrors the API's OverviewLayoutDto — one entry per GET /overview/layouts item. */ +export interface IOverviewLayoutOption { + key: OverviewLayoutKey; + label: string; +} + export const overviewService = { + /** Layouts the caller has permission to render, in server priority order. */ + getLayouts: async (): Promise => { + const response = await client.get(O.LAYOUTS); + return unwrap(response); + }, + getDashboard: async (range?: OverviewRange): Promise => { const response = await client.get(O.BASE, { params: range ? { range } : undefined, diff --git a/apps/edr-freight-web/backoffice/src/services/wagon.service.ts b/apps/edr-freight-web/backoffice/src/services/wagon.service.ts index f2c4ece8e..e484632e6 100644 --- a/apps/edr-freight-web/backoffice/src/services/wagon.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/wagon.service.ts @@ -51,6 +51,10 @@ export interface WagonListFilters { /** Registration day range (YYYY-MM-DD), both ends inclusive. */ createdFrom?: string; createdTo?: string; + /** Last-maintenance day range (YYYY-MM-DD), both ends inclusive — matches + * the latest status-log flip to MAINTENANCE, not a stored column. */ + maintenanceFrom?: string; + maintenanceTo?: string; /** Only read by `getPaged`. */ page?: number; pageSize?: number; @@ -67,6 +71,8 @@ const wagonListQuery = (filters: WagonListFilters): string => { if (filters.trainNumber) params.set('trainNumber', filters.trainNumber); if (filters.createdFrom) params.set('createdFrom', filters.createdFrom); if (filters.createdTo) params.set('createdTo', filters.createdTo); + if (filters.maintenanceFrom) params.set('maintenanceFrom', filters.maintenanceFrom); + if (filters.maintenanceTo) params.set('maintenanceTo', filters.maintenanceTo); if (filters.page) params.set('page', String(filters.page)); if (filters.pageSize) params.set('pageSize', String(filters.pageSize)); const qs = params.toString(); diff --git a/apps/edr-freight-web/backoffice/src/types/customer.ts b/apps/edr-freight-web/backoffice/src/types/customer.ts index 8c95e7342..3c2427a1b 100644 --- a/apps/edr-freight-web/backoffice/src/types/customer.ts +++ b/apps/edr-freight-web/backoffice/src/types/customer.ts @@ -313,6 +313,10 @@ export interface CompanyListFilter { type?: CompanyType; kind?: CompanyKind; status?: CompanyStatus; + nationality?: CompanyNationality; + /** ISO instants — inclusive bounds on the registration date. */ + createdFrom?: string; + createdTo?: string; /** `true` = submitted applications only; `false` = drafts only; omit for both. */ onboardingCompleted?: boolean; /** diff --git a/apps/edr-freight-web/backoffice/src/types/invoice.ts b/apps/edr-freight-web/backoffice/src/types/invoice.ts index 3f2f661e1..9bd0ff502 100644 --- a/apps/edr-freight-web/backoffice/src/types/invoice.ts +++ b/apps/edr-freight-web/backoffice/src/types/invoice.ts @@ -24,15 +24,39 @@ export interface Invoice extends Freight.IInvoice { sourceRef?: InvoiceSourceRef | null; } -/** Query parameters for the invoice list. */ +/** + * Query parameters for the invoice list. Every key maps 1:1 onto + * `FilterInvoiceDto` on the API — the list endpoint runs with + * `forbidNonWhitelisted`, so a param that isn't declared there is a 400, not a + * silently ignored extra. + */ export interface InvoiceListFilter { page: number; pageSize: number; companyId?: string; + /** Single status — kept for the worklists that pin one. */ status?: Freight.InvoiceStatus; + /** CSV multi-select status, as the filter bar sends it. */ + statuses?: string; + /** CSV of `Freight.InvoiceSource` values. */ + sources?: string; + /** CSV of EIMS filing states. */ + eimsStatuses?: string; search?: string; - /** Manual-payments worklist only. */ currency?: "USD" | "ETB"; + /** ISO instants — inclusive bounds on `issuedAt` / `dueAt`. */ + issuedFrom?: string; + issuedTo?: string; + dueFrom?: string; + dueTo?: string; + minAmount?: number; + maxAmount?: number; + /** Outstanding balance only. */ + hasBalance?: boolean; + /** Outstanding AND past due — computed, not read off `status`. */ + overdue?: boolean; + sortBy?: string; + sortOrder?: "ASC" | "DESC"; } /** Standard paginated list envelope (matches the customers/bookings service shape). */ diff --git a/apps/edr-freight-web/portal/src/constants/URLS.ts b/apps/edr-freight-web/portal/src/constants/URLS.ts index cefef5ddc..1b0a32c28 100644 --- a/apps/edr-freight-web/portal/src/constants/URLS.ts +++ b/apps/edr-freight-web/portal/src/constants/URLS.ts @@ -128,6 +128,8 @@ export const URL_CONSTANTS = { CONTRACT_DOCUMENT: (id: string) => `/api/bookings/${id}/contract/document`, CONTRACT_SIGN: (id: string) => `/api/bookings/${id}/contract/sign`, CONTRACT_DOWNLOAD: (id: string) => `/api/bookings/${id}/contract`, + CARRIAGE_ACCEPTANCE_SHEET: (id: string) => + `/api/bookings/${id}/carriage-acceptance-sheet`, CANCEL: (id: string | number) => `/api/bookings/${id}/cancel`, CONFIRM: (id: string | number) => `/api/bookings/${id}/confirm`, CUSTOMER_TRUCKS: (id: string) => `/api/bookings/${id}/customer-trucks`, diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/AdditionalChargesPanel.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/AdditionalChargesPanel.tsx index 399285ca0..d49a8883d 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/AdditionalChargesPanel.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/AdditionalChargesPanel.tsx @@ -59,8 +59,16 @@ function ChargeRow({ charge }: { charge: Freight.AdditionalCharge }) { {charge.amount.toLocaleString(undefined, { minimumFractionDigits: 2 })}{" "} {charge.currency} + {charge.convertedAmount != null + ? ` (≈ ${charge.convertedAmount.toLocaleString(undefined, { minimumFractionDigits: 2 })} ${charge.convertedCurrency})` + : ""} {charge.paymentReference ? ` · ref ${charge.paymentReference}` : ""} + {charge.dueAt && charge.status === "SENT" && ( + + Due {new Date(charge.dueAt).toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" })} + + )} warehouseService.bookingHandovers(booking.id), }); + const { data: emptyReturns = [] } = useQuery({ + queryKey: ["emptyContainerReturns", booking.id], + queryFn: () => + bookingsService.listEmptyContainerReturns(booking.id).catch(() => []), + }); + const [downloadingReturnId, setDownloadingReturnId] = useState(null); + const downloadEir = async (ret: EmptyContainerReturn) => { + setDownloadingReturnId(ret.id); + try { + const blob = await bookingsService.downloadEquipmentInterchangeDocument(ret.id); + saveBlob(blob, `equipment-interchange-${ret.containerNumber}.pdf`); + } catch { + toast.error("Could not download the interchange receipt."); + } finally { + setDownloadingReturnId(null); + } + }; + const customerDocs = useMemo( () => (clearance?.documents ?? []).filter((d) => d.uploadedBy === "customer"), [clearance], @@ -303,6 +322,14 @@ export function DocumentsTab({ booking }: { booking: Freight.IBooking }) { fn: () => bookingsService.downloadBookingHandoverDocument(booking.id), }, ]; + // Carriage acceptance sheet only exists for export bookings — 404s + // (skipped below) for import/domestic, so this is safe unconditionally. + if (booking.tradeDirection === "EXPORT") { + jobs.push({ + name: `carriage-acceptance-${ref}.pdf`, + fn: () => bookingsService.downloadCarriageAcceptanceSheet(booking.id), + }); + } let saved = 0; for (const job of jobs) { try { @@ -584,12 +611,56 @@ export function DocumentsTab({ booking }: { booking: Freight.IBooking }) { )} + {/* ── 4b. Equipment interchange receipts (empty container returns) ─── */} + {emptyReturns.length > 0 && ( + + Equipment interchange receipts + + Container number, size, return time, depot, and condition for each empty + container returned on this booking. + + + {emptyReturns.map((ret, i) => ( + + + + + {ret.containerNumber} + {ret.containerSize ? ` · ${ret.containerSize}ft` : ""} + + + {ret.returnDate ? new Date(ret.returnDate).toLocaleString() : "—"} + {ret.facility ? ` · ${ret.facility}` : ""} + {ret.condition ? ` · ${ret.condition}` : ""} + + + } + onClick={ + downloadingReturnId === ret.id ? undefined : () => void downloadEir(ret) + } + /> + + + ))} + + + )} + {/* ── Warehouse documents (one-click bundle) ──────────────────────── */} Warehouse documents - Goods Received Note, gate clearance / release order and handover — download all - available documents for this booking in one click. + Goods Received Note, gate clearance / release order, handover, and — for export + bookings — the carriage acceptance sheet: download all available documents for + this booking in one click. - {/* Coach Types Tab */} @@ -591,105 +573,6 @@ function CoachesPageContent() { )} - {/* Utilization Tab */} - {activeTab === 'utilization' && (() => { - const rows = Array.isArray(utilizationData) ? utilizationData : (utilizationData as any)?.data || []; - - const UTIL_COLS = [ - { key: 'number', label: 'Coach' }, - { key: 'coachType', label: 'Type' }, - { key: 'totalSeats', label: 'Total Seats' }, - { key: 'availableSeats', label: 'Available' }, - { key: 'bookedSeats', label: 'Booked' }, - { key: 'blockedSeats', label: 'Blocked' }, - { key: 'maintenanceSeats', label: 'Maintenance' }, - { key: 'utilizationRate', label: 'Utilization %' }, - { key: 'totalAssignments', label: 'Assignments' }, - { key: 'totalBookings', label: 'Total Bookings' }, - ]; - - const doExport = () => { - if (!rows.length) { alert('No data to export'); return; } - const headers = UTIL_COLS.map(c => c.label); - const exportRows = rows.map((r: any) => UTIL_COLS.map(({ key }) => String(r[key] ?? ''))); - const dateStr = new Date().toISOString().split('T')[0]; - if (exportUtilFormat === 'pdf') { - const w = window.open('', '_blank')!; - w.document.write(`Coach Utilization Report`); - w.document.write(`

Coach Utilization Report — ${dateStr}

${headers.map(h => ``).join('')}`); - exportRows.forEach((r: string[]) => { w.document.write(`${r.map((v: string) => ``).join('')}`); }); - w.document.write('
${h}
${v}
'); - w.document.close(); w.print(); - } else if (exportUtilFormat === 'excel') { - const tsv = [headers.join('\t'), ...exportRows.map((r: string[]) => r.join('\t'))].join('\n'); - const blob = new Blob([tsv], { type: 'application/vnd.ms-excel' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); a.href = url; a.download = `coach-utilization-${dateStr}.xls`; a.click(); URL.revokeObjectURL(url); - } else { - const csv = [headers.map(h => `"${h}"`).join(','), ...exportRows.map((r: string[]) => r.map((v: string) => `"${v.replace(/"/g, '""')}"`).join(','))].join('\n'); - const blob = new Blob([csv], { type: 'text/csv' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); a.href = url; a.download = `coach-utilization-${dateStr}.csv`; a.click(); URL.revokeObjectURL(url); - } - setExportUtilModalOpen(false); - }; - - return ( -
-
- setExportUtilModalOpen(true)}>Export -
- {r.number} }, - { key: 'coachType', label: 'Type', render: (r: any) => {r.coachType || 'N/A'} }, - { key: 'totalSeats', label: 'Total Seats', render: (r: any) => {r.totalSeats} }, - { key: 'availableSeats', label: 'Available', render: (r: any) => {r.availableSeats} }, - { key: 'bookedSeats', label: 'Booked', render: (r: any) => {r.bookedSeats} }, - { key: 'blockedSeats', label: 'Blocked', render: (r: any) => {r.blockedSeats} }, - { key: 'maintenanceSeats', label: 'Maintenance', render: (r: any) => {r.maintenanceSeats} }, - { - key: 'utilizationRate', label: 'Utilization', - render: (r: any) => ( -
-
-
-
- {r.utilizationRate}% -
- ), - }, - { key: 'totalAssignments', label: 'Assignments', render: (r: any) => {r.totalAssignments} }, - { key: 'totalBookings', label: 'Total Bookings', render: (r: any) => {r.totalBookings} }, - ]} - data={rows} - actions={[]} - loading={utilizationLoading} - emptyMessage="No coach utilization data available" - /> - - setExportUtilModalOpen(false)} title="Export Utilization Report" size="sm"> -
-
-

Export Format

-
- {(['csv', 'excel', 'pdf'] as const).map(fmt => ( - - ))} -
-
-
- setExportUtilModalOpen(false)}>Cancel - Export -
-
-
-
- ); - })()}
{/* Delete Confirmation */} diff --git a/apps/edr-passenger-web/backoffice/src/app/excess-baggage/page.tsx b/apps/edr-passenger-web/backoffice/src/app/excess-baggage/page.tsx index f571499a4..6e3c98340 100644 --- a/apps/edr-passenger-web/backoffice/src/app/excess-baggage/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/excess-baggage/page.tsx @@ -1,13 +1,13 @@ 'use client'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { Plus, RefreshCw, Send, Trash2 } from 'lucide-react'; import DataTable from '@/components/ui/DataTable'; import Badge from '@/components/ui/Badge'; import ActionButton from '@/components/ui/ActionButton'; import Modal from '@/components/ui/Modal'; -import { excessBaggageApi, apiClient } from '@/lib/api'; +import { excessBaggageApi, apiClient, bookingsApi } from '@/lib/api'; import { formatDateTime, formatCurrency } from '@/lib/utils'; import { useAuthStore } from '@/lib/auth-store'; @@ -28,7 +28,7 @@ export default function ExcessBaggagePage() { const [waiveReason, setWaiveReason] = useState(''); const [waiveError, setWaiveError] = useState(null); const [logModal, setLogModal] = useState(false); - const [logForm, setLogForm] = useState({ bookingReference: '', excessWeightKg: '', collectCash: false }); + const [logForm, setLogForm] = useState({ bookingReference: '', excessWeightKg: '', collectCash: false, paymentPhone: '' }); const [logError, setLogError] = useState(null); const [resendModal, setResendModal] = useState(null); const [resendSuccess, setResendSuccess] = useState(false); @@ -54,12 +54,36 @@ export default function ExcessBaggagePage() { }), }); + useEffect(() => { + if (!logModal) return; + const bookingRef = logForm.bookingReference.trim(); + if (!bookingRef) { + setLogForm((prev) => ({ ...prev, paymentPhone: '' })); + return; + } + + const timeout = setTimeout(async () => { + try { + const response = await bookingsApi.getAll({ search: bookingRef, page: 1, pageSize: 5 }); + const items = response?.items ?? []; + const match = items.find((booking: any) => booking.bookingRef?.toLowerCase() === bookingRef.toLowerCase()) ?? items[0]; + if (!match) return; + const nextPhone = match.contactPhone ?? match.passenger?.user?.phone ?? ''; + setLogForm((prev) => ({ ...prev, paymentPhone: prev.paymentPhone || nextPhone })); + } catch { + // Ignore lookup failures: the agent can still override the number manually. + } + }, 250); + + return () => clearTimeout(timeout); + }, [logForm.bookingReference, logModal]); + const logMutation = useMutation({ mutationFn: (data: any) => excessBaggageApi.logCharge(data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['excess-baggage'] }); setLogModal(false); - setLogForm({ bookingReference: '', excessWeightKg: '', collectCash: false }); + setLogForm({ bookingReference: '', excessWeightKg: '', collectCash: false, paymentPhone: '' }); setLogError(null); }, onError: (e: any) => setLogError(e?.response?.data?.message || e?.message || 'Failed to log charge'), @@ -178,7 +202,7 @@ export default function ExcessBaggagePage() {

Excess Lugagge

Track and manage excess luggage charges at boarding

- { setLogModal(true); setLogError(null); setLogForm({ bookingReference: '', excessWeightKg: '', collectCash: false }); }}> + { setLogModal(true); setLogError(null); setLogForm({ bookingReference: '', excessWeightKg: '', collectCash: false, paymentPhone: '' }); }}> Log Excess Luggage @@ -256,6 +280,15 @@ export default function ExcessBaggagePage() { onChange={(e) => setLogForm({ ...logForm, bookingReference: e.target.value })} /> +
+ + setLogForm({ ...logForm, paymentPhone: e.target.value })} + /> +
{!logForm.collectCash && (

- A payment link will be sent to the passenger's email and phone on file. + The payment link will be sent to the phone above and the booking's saved email when present.

)} @@ -302,6 +335,7 @@ export default function ExcessBaggagePage() { bookingReference: logForm.bookingReference.trim(), excessWeightKg: parseInt(logForm.excessWeightKg), collectCash: logForm.collectCash, + contactPhone: logForm.paymentPhone.trim() || undefined, }); }} > diff --git a/apps/edr-passenger-web/backoffice/src/app/payments/SupplementaryChargesModal.tsx b/apps/edr-passenger-web/backoffice/src/app/payments/SupplementaryChargesModal.tsx index 45941cea4..f62f7c402 100644 --- a/apps/edr-passenger-web/backoffice/src/app/payments/SupplementaryChargesModal.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/payments/SupplementaryChargesModal.tsx @@ -1,9 +1,10 @@ 'use client'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { PlusCircle } from 'lucide-react'; import Modal from '@/components/ui/Modal'; import ActionButton from '@/components/ui/ActionButton'; +import { bookingsApi } from '@/lib/api'; import { useCreateSupplementaryCharge } from './useSupplementaryCharges'; const REASONS = ['UNDERPAYMENT', 'FARE_CORRECTION', 'CURRENCY_ADJUSTMENT', 'OTHER']; @@ -14,13 +15,37 @@ interface Props { } export default function SupplementaryChargesModal({ isOpen, onClose }: Props) { - const [form, setForm] = useState({ bookingRef: '', amountEtb: '', reason: 'UNDERPAYMENT', notes: '' }); + const [form, setForm] = useState({ bookingRef: '', amountEtb: '', reason: 'UNDERPAYMENT', notes: '', paymentPhone: '' }); const [formError, setFormError] = useState(null); const [createSuccess, setCreateSuccess] = useState(null); + useEffect(() => { + if (!isOpen) return; + const bookingRef = form.bookingRef.trim(); + if (!bookingRef) { + setForm((prev) => ({ ...prev, paymentPhone: '' })); + return; + } + + const timeout = setTimeout(async () => { + try { + const response = await bookingsApi.getAll({ search: bookingRef, page: 1, pageSize: 5 }); + const items = response?.items ?? []; + const match = items.find((booking: any) => booking.bookingRef?.toLowerCase() === bookingRef.toLowerCase()) ?? items[0]; + if (!match) return; + const nextPhone = match.contactPhone ?? match.passenger?.user?.phone ?? ''; + setForm((prev) => ({ ...prev, paymentPhone: prev.paymentPhone || nextPhone })); + } catch { + // Ignore lookup failures here; the staff member can still type a phone override manually. + } + }, 300); + + return () => clearTimeout(timeout); + }, [form.bookingRef, isOpen]); + const createMutation = useCreateSupplementaryCharge(() => { setCreateSuccess('Charge created and payment link sent.'); - setForm({ bookingRef: '', amountEtb: '', reason: 'UNDERPAYMENT', notes: '' }); + setForm({ bookingRef: '', amountEtb: '', reason: 'UNDERPAYMENT', notes: '', paymentPhone: '' }); setFormError(null); setTimeout(() => { setCreateSuccess(null); onClose(); }, 2000); }); @@ -31,7 +56,13 @@ export default function SupplementaryChargesModal({ isOpen, onClose }: Props) { if (!form.bookingRef.trim()) return setFormError('Booking reference is required'); if (!form.amountEtb || isNaN(amountMinor) || amountMinor <= 0) return setFormError('Enter a valid amount'); try { - await createMutation.mutateAsync({ bookingRef: form.bookingRef.trim(), amountMinor, reason: form.reason, notes: form.notes || undefined }); + await createMutation.mutateAsync({ + bookingRef: form.bookingRef.trim(), + amountMinor, + reason: form.reason, + notes: form.notes || undefined, + contactPhone: form.paymentPhone.trim() || undefined, + }); } catch (e: any) { setFormError(e?.response?.data?.message ?? e?.message ?? 'Failed to create charge'); } @@ -52,6 +83,10 @@ export default function SupplementaryChargesModal({ isOpen, onClose }: Props) { setForm({ ...form, bookingRef: e.target.value })} />
+
+ + setForm({ ...form, paymentPhone: e.target.value })} /> +
setForm({ ...form, amountEtb: e.target.value })} /> @@ -69,7 +104,7 @@ export default function SupplementaryChargesModal({ isOpen, onClose }: Props) {

- A payment link will be sent to the passenger's registered phone/email. The link expires in 72 hours. + The payment link sends to the phone above, falling back to the booking's saved contact details if left empty. The link expires in 72 hours.

diff --git a/apps/edr-passenger-web/backoffice/src/app/payments/useSupplementaryCharges.ts b/apps/edr-passenger-web/backoffice/src/app/payments/useSupplementaryCharges.ts index 32bfb4998..18d41fd3c 100644 --- a/apps/edr-passenger-web/backoffice/src/app/payments/useSupplementaryCharges.ts +++ b/apps/edr-passenger-web/backoffice/src/app/payments/useSupplementaryCharges.ts @@ -13,8 +13,14 @@ export function useSupplementaryCharges(filters: { bookingRef?: string; status?: export function useCreateSupplementaryCharge(onSuccess: () => void) { const qc = useQueryClient(); return useMutation({ - mutationFn: (data: { bookingRef: string; amountMinor: number; reason: string; notes?: string }) => - paymentsApi.supplementary.create(data), + mutationFn: (data: { + bookingRef: string; + amountMinor: number; + reason: string; + notes?: string; + contactPhone?: string; + contactEmail?: string; + }) => paymentsApi.supplementary.create(data), onSuccess: () => { qc.invalidateQueries({ queryKey: ['supplementary-charges'] }); onSuccess(); diff --git a/apps/edr-passenger-web/backoffice/src/app/reports/coach-utilization/page.tsx b/apps/edr-passenger-web/backoffice/src/app/reports/coach-utilization/page.tsx new file mode 100644 index 000000000..6a3837c68 --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/app/reports/coach-utilization/page.tsx @@ -0,0 +1,362 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { Activity, BarChart3, Download, Search } from "lucide-react"; +import { apiClient } from "@/lib/api-client"; +import ActionButton from "@/components/ui/ActionButton"; +import Pagination from "@/components/ui/Pagination"; +import { usePagination } from "@/lib/use-pagination"; + +interface ScheduleOption { + id: string; + label: string; +} + +interface CoachUtilizationRow { + id: string; + number: string; + coachType: string | null; + status: string | null; + totalSeats: number; + availableSeats: number; + bookedSeats: number; + blockedSeats: number; + maintenanceSeats: number; + utilizationRate: number; + totalAssignments: number; + totalBookings: number; +} + +export default function CoachUtilizationReportPage() { + const [scheduleId, setScheduleId] = useState(""); + const [search, setSearch] = useState(""); + + const { data: schedulesRaw, isLoading: loadingSchedules } = useQuery({ + queryKey: ["report-schedules-all"], + queryFn: () => apiClient.get("/reports/schedules?all=true"), + }); + + const { data, isLoading, isError } = useQuery({ + queryKey: ["coach-utilization-report", scheduleId], + queryFn: () => apiClient.get(`/fleet/coaches/utilization?scheduleId=${scheduleId}`), + enabled: !!scheduleId, + }); + + const schedules = schedulesRaw ?? []; + const rows = data ?? []; + + const filteredRows = useMemo(() => { + const q = search.trim().toLowerCase(); + if (!q) return rows; + return rows.filter((row) => { + const coachType = row.coachType ?? ""; + const status = row.status ?? ""; + return ( + row.number.toLowerCase().includes(q) || + coachType.toLowerCase().includes(q) || + status.toLowerCase().includes(q) + ); + }); + }, [rows, search]); + + const { paged, page, totalPages, setPage, reset } = usePagination(filteredRows, 25); + + const summary = useMemo(() => { + if (!rows.length) return null; + + const totals = rows.reduce( + (acc, row) => { + acc.totalSeats += row.totalSeats; + acc.availableSeats += row.availableSeats; + acc.bookedSeats += row.bookedSeats; + acc.blockedSeats += row.blockedSeats; + acc.maintenanceSeats += row.maintenanceSeats; + acc.totalBookings += row.totalBookings; + acc.totalAssignments += row.totalAssignments; + return acc; + }, + { + totalSeats: 0, + availableSeats: 0, + bookedSeats: 0, + blockedSeats: 0, + maintenanceSeats: 0, + totalBookings: 0, + totalAssignments: 0, + }, + ); + + const avgUtilization = rows.length + ? rows.reduce((sum, row) => sum + row.utilizationRate, 0) / rows.length + : 0; + + return { + totalCoaches: rows.length, + totalSeats: totals.totalSeats, + availableSeats: totals.availableSeats, + bookedSeats: totals.bookedSeats, + blockedSeats: totals.blockedSeats, + maintenanceSeats: totals.maintenanceSeats, + avgUtilization, + totalAssignments: totals.totalAssignments, + totalBookings: totals.totalBookings, + }; + }, [rows]); + + const doExport = () => { + if (!filteredRows.length) return; + + const headers = [ + "Coach", + "Type", + "Status", + "Total Seats", + "Available", + "Booked", + "Blocked", + "Maintenance", + "Utilization %", + "Assignments", + "Total Bookings", + ]; + + const rowsCsv = filteredRows.map((row) => [ + row.number, + row.coachType ?? "—", + row.status ?? "—", + String(row.totalSeats), + String(row.availableSeats), + String(row.bookedSeats), + String(row.blockedSeats), + String(row.maintenanceSeats), + `${row.utilizationRate}%`, + String(row.totalAssignments), + String(row.totalBookings), + ]); + + const csv = [ + headers.map((header) => `"${header}"`).join(","), + ...rowsCsv.map((row) => row.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(",")), + ].join("\n"); + + const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `coach-utilization-${scheduleId || "fleet"}-${new Date().toISOString().split("T")[0]}.csv`; + a.click(); + URL.revokeObjectURL(url); + }; + + return ( +
+
+

Coach Utilization Report

+

+ Occupancy, availability, and booking load by coach for a selected schedule. +

+
+ +
+
+
+ + +
+
+ {isLoading &&

Loading…

} + {isError &&

Failed to load coach utilization.

} +
+ + {!scheduleId && ( +
+ +

Choose a schedule to review coach occupancy

+

The report drills into availability, bookings, and maintenance status for each assigned coach.

+
+ )} + + {data && summary && ( + <> +
+
+

Coaches

+

{summary.totalCoaches}

+

Assigned coaches

+
+ +
+
+
+

Total Seats

+

{summary.totalSeats}

+

Across all coaches

+
+ +
+
+ +
+
+
+

Booked

+

{summary.bookedSeats}

+

Occupied seats

+
+ +
+
+ +
+
+
+

Available

+

{summary.availableSeats}

+

Open seats

+
+ +
+
+ +
+
+
+

Blocked

+

{summary.blockedSeats}

+

Unavailable seats

+
+ +
+
+ +
+
+
+

Avg Utilization

+

+ {summary.avgUtilization.toFixed(1)}% +

+

Across coach set

+
+ +
+
+
+ +
+
+

+ Coach Details +

+
+
+ + { + setSearch(event.target.value); + reset(); + }} + /> +
+ + Export CSV + +
+
+ +
+ + + + {[ + "Coach", + "Type", + "Status", + "Total Seats", + "Available", + "Booked", + "Blocked", + "Maintenance", + "Utilization", + "Assignments", + "Bookings", + ].map((header) => ( + + ))} + + + + {paged.map((row) => ( + + + + + + + + + + + + + + ))} + + {paged.length === 0 && ( + + + + )} + +
+ {header} +
{row.number}{row.coachType ?? "—"}{row.status ?? "—"}{row.totalSeats}{row.availableSeats}{row.bookedSeats}{row.blockedSeats}{row.maintenanceSeats} +
+
+
+
+ {row.utilizationRate.toFixed(1)}% +
+
{row.totalAssignments}{row.totalBookings}
+ No coach utilization rows found +
+
+ +
+ + )} + + {!data && !isLoading && scheduleId && ( +
+ No utilization data found for this schedule. +
+ )} +
+ ); +} diff --git a/apps/edr-passenger-web/backoffice/src/app/tickets/page.tsx b/apps/edr-passenger-web/backoffice/src/app/tickets/page.tsx index 8731a1eb4..74f1ec9b8 100644 --- a/apps/edr-passenger-web/backoffice/src/app/tickets/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/tickets/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { LogIn, ListCollapse, Trash2, Printer, Package } from 'lucide-react'; import { Download } from 'lucide-react'; @@ -39,6 +39,7 @@ export default function TicketsPage() { const [excessTicket, setExcessTicket] = useState(null); const [excessKg, setExcessKg] = useState(''); const [excessCollectCash, setExcessCollectCash] = useState(false); + const [excessPaymentPhone, setExcessPaymentPhone] = useState(''); const [excessError, setExcessError] = useState(null); const [excessResult, setExcessResult] = useState(null); @@ -163,10 +164,35 @@ export default function TicketsPage() { onError: (e: any) => setExcessError(e?.response?.data?.message || e?.message || 'Failed to log charge'), }); + useEffect(() => { + if (!excessModalOpen || !excessTicket) return; + const bookingRef = excessTicket?.booking?.bookingRef ?? ''; + if (!bookingRef) { + setExcessPaymentPhone(''); + return; + } + + const timeout = setTimeout(async () => { + try { + const response = await bookingsApi.getAll({ search: bookingRef, page: 1, pageSize: 5 }); + const items = response?.items ?? []; + const match = items.find((booking: any) => booking.bookingRef?.toLowerCase() === bookingRef.toLowerCase()) ?? items[0]; + if (!match) return; + const nextPhone = match.contactPhone ?? match.passenger?.user?.phone ?? ''; + setExcessPaymentPhone((prev) => prev || nextPhone); + } catch { + // Ignore lookup failures here; the staff member can still type a phone override manually. + } + }, 250); + + return () => clearTimeout(timeout); + }, [excessModalOpen, excessTicket]); + const openExcessModal = (ticket: any) => { setExcessTicket(ticket); setExcessKg(''); setExcessCollectCash(false); + setExcessPaymentPhone(''); setExcessError(null); setExcessResult(null); setExcessModalOpen(true); @@ -179,6 +205,7 @@ export default function TicketsPage() { bookingId: excessTicket.booking?.id ?? excessTicket.bookingId, excessWeightKg: parseInt(excessKg), collectCash: excessCollectCash, + contactPhone: excessPaymentPhone.trim() || undefined, }); }; @@ -979,6 +1006,15 @@ export default function TicketsPage() { required />
+
+ + setExcessPaymentPhone(e.target.value)} + /> +