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/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 247a1dad5..7270c92b0 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -31,6 +31,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"; @@ -98,6 +99,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. */ @@ -244,12 +270,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", { @@ -259,6 +280,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). @@ -300,14 +372,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; @@ -318,7 +387,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); @@ -459,12 +535,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/booking-transition.paired-decision.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.paired-decision.spec.ts index 6496208e7..ada450c50 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.paired-decision.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.paired-decision.spec.ts @@ -63,7 +63,7 @@ describe('BookingTransitionService — paired staff decisions', () => { expect(result.partner.id).toBe('b-2'); }); - it('cancels both halves with the same reason', async () => { + it('cancels via cancel() once — its pair cascade settles the partner', async () => { const { service } = makeService(paired); const cancel = jest .spyOn(service, 'cancel') @@ -73,21 +73,23 @@ describe('BookingTransitionService — paired staff decisions', () => { reason: 'customer withdrew', }); - expect(cancel).toHaveBeenNthCalledWith(1, 'b-1', 'customer withdrew'); - expect(cancel).toHaveBeenNthCalledWith(2, 'b-2', 'customer withdrew'); + expect(cancel).toHaveBeenCalledTimes(1); + expect(cancel).toHaveBeenCalledWith('b-1', 'customer withdrew'); }); it('propagates a failure on the second half so neither is committed', async () => { const { service, dataSource } = makeService(paired); jest - .spyOn(service, 'cancel') + .spyOn(service, 'acceptIntake') .mockImplementationOnce(async (id) => ({ id }) as Booking) .mockImplementationOnce(async () => { throw new Error('partner is already in transit'); }); await expect( - service.applyPairedDecision('b-1', 'cancel', 'staff-1', { reason: 'x' }), + service.applyPairedDecision('b-1', 'accept', 'staff-1', { + validityDays: 30, + }), ).rejects.toThrow('partner is already in transit'); // Both halves ran inside one transaction, so the throw rolls the first back. diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts index 72261627e..399f14123 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts @@ -91,28 +91,10 @@ export class BookingTransitionService { /** Reject submit when the booking's 20ft containers can't be balanced onto wagons. */ private async assert20ftPairable(booking: Booking): Promise { - // Parity gate. 20ft ride two per wagon, so an odd total leaves one container - // that cannot be placed. Consolidation (pairing it with another customer's - // odd booking) is built end to end but switched off for now, so an odd total - // is rejected here rather than parked for a partner. - // containerSize is not always populated (some rows carry only the container - // type), so fall back to the type's sizeFt rather than silently skipping - // those lines and letting an odd booking through. - const ft20Quantity = (booking.bookingContainers ?? []) - .filter((bc) => - bc.containerSize - ? bc.containerSize.includes("20") - : Number(bc.containerType?.sizeFt) === 20, - ) - .reduce((sum, bc) => sum + Number(bc.quantity || 0), 0); - if (ft20Quantity % 2 === 1) { - throw new BadRequestException( - `20ft containers travel two per wagon, so they must be booked in even ` + - `numbers. This booking has ${ft20Quantity} — add one more or remove ` + - `one (book ${ft20Quantity + 1} or ${ft20Quantity - 1}).`, - ); - } - + // Odd 20ft totals are not rejected here: runConsolidationOnSubmit (called + // right after this gate) auto-pairs the odd leftover with another + // customer's odd booking or parks the booking as PENDING_CONSOLIDATION. + // Only the weight-pairing rule hard-blocks. const violations = await this.containerValidationService.validate20ftPairing(booking); if (violations.length) { @@ -456,11 +438,42 @@ export class BookingTransitionService { async cancelHold(bookingId: string, reason?: string): Promise { const booking = await this.bookingsService.findById(bookingId); assertBookingStatus(booking, ["SELECTED_FOR_BATCH"]); - if (booking.consolidationPartnerId) { - throw new BadRequestException( - "This booking shares a consolidated wagon with another booking — " + - "contact support to cancel it.", + // Consolidated pair: the shared wagon dies with this hold. An unpaid + // partner's hold is released with it (both cancel, no fee); a PAID partner + // keeps the whole wagon and this canceller owes the cancellation fee. + const partnerId = booking.consolidationPartnerId; + if (partnerId) { + const partner = await this.bookingsService.findById(partnerId); + const partnerPaid = + partner.paymentStatus === "PAID" || partner.status === "PAID"; + await this.bookingsRepository.clearConsolidationPair( + booking.id, + partnerId, ); + if (partnerPaid) { + this.events.emit("booking.consolidation.partnerLapsed", { + expiredBookingId: booking.id, + }); + } else if (!["CANCELLED", "EXPIRED"].includes(partner.status)) { + const partnerReason = "Cancelled with its consolidation partner"; + await this.bookingsRepository.createReviewNote( + partnerId, + partnerReason, + "REJECTION", + ); + if (partner.status === "SELECTED_FOR_BATCH") { + await this.bookingBatchService.cancelReservation(partnerId); + } else { + await this.invoiceService.expireOpenInvoices(partnerId); + await this.bookingsRepository.update(partnerId, { + status: "CANCELLED", + } as never); + } + this.notifier.cancelled( + await this.bookingsService.findById(partnerId), + partnerReason, + ); + } } await this.bookingsRepository.createReviewNote( bookingId, @@ -514,6 +527,17 @@ export class BookingTransitionService { ); } + // cancel() carries its own pair cascade (it settles the partner too), so + // running it twice would trip on the already-cancelled partner. + if (decision === "cancel") { + const own = await this.cancel( + bookingId, + options.reason ?? "Cancelled with its consolidation partner", + ); + const other = await this.bookingsService.findById(partnerId); + return { booking: own, partner: other }; + } + const runOne = async (id: string): Promise => { switch (decision) { case "accept": @@ -525,11 +549,6 @@ export class BookingTransitionService { ); } return this.acceptIntake(id, actorId, Number(options.validityDays)); - case "cancel": - return this.cancel( - id, - options.reason ?? "Cancelled with its consolidation partner", - ); case "operationAccept": return this.reviewOperationRequest(id, "ACCEPT", actorId, { note: options.note, @@ -566,8 +585,53 @@ export class BookingTransitionService { "PENDING_APPROVAL", "CONTRACT_READY", "OPERATION_REQUEST_PENDING", + // A booking parked waiting for a consolidation partner can be walked + // away from — nothing is reserved yet. + "PENDING_CONSOLIDATION", ]); + // Consolidated pair: a shared wagon never ships half-full, so cancelling + // one half settles the other too. Neither paid → both cancel, no fee. A + // PAID partner instead keeps the whole wagon and the unpaid canceller + // owes the cancellation fee (opened by the partnerLapsed listener). A + // PAID booking itself never comes through here (status gate above) — it + // cancels via wagon cancellation, where the fee machinery lives. + const partnerId = booking.consolidationPartnerId; + if (partnerId) { + const partner = await this.bookingsService.findById(partnerId); + const partnerPaid = + partner.paymentStatus === "PAID" || partner.status === "PAID"; + await this.bookingsRepository.clearConsolidationPair( + booking.id, + partnerId, + ); + if (partnerPaid) { + this.events.emit("booking.consolidation.partnerLapsed", { + expiredBookingId: booking.id, + }); + } else if (!["CANCELLED", "EXPIRED"].includes(partner.status)) { + const partnerReason = "Cancelled with its consolidation partner"; + await this.bookingsRepository.createReviewNote( + partnerId, + partnerReason, + "REJECTION", + ); + await this.invoiceService.expireOpenInvoices(partnerId); + if (partner.status === "SELECTED_FOR_BATCH") { + // Reserved hold: release the wagons through the batch engine. + await this.bookingBatchService.cancelReservation(partnerId); + } else { + await this.bookingsRepository.update(partnerId, { + status: "CANCELLED", + } as never); + } + this.notifier.cancelled( + await this.bookingsService.findById(partnerId), + partnerReason, + ); + } + } + await this.bookingsRepository.createReviewNote( bookingId, reason, diff --git a/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts index 0b4315d62..387a20824 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts @@ -7,6 +7,7 @@ import { Logger, NotFoundException, } from '@nestjs/common'; +import { OnEvent } from '@nestjs/event-emitter'; import { ExchangeService } from '@edr/api-common'; import { Freight, NotificationAudience, NotificationType } from '@edr/types'; import { DataSource, EntityManager, In, IsNull } from 'typeorm'; @@ -140,7 +141,29 @@ export class BookingWagonCancellationService { creditAmount: number; }> { const booking = await this.loadCancellableBooking(bookingId); - const cut = await this.resolveRequestedCut(booking, dto); + // Empty dto = the whole booking ("Cancel booking" button). + const cut = this.isEmptyCut(dto) + ? await this.resolveFullCut(booking) + : await this.resolveRequestedCut(booking, dto); + // Consolidated booking: preview the same rules the request enforces — a + // full cut breaks the pair (canceller fee = ceil of its fractional + // wagons); a partial cut must spare the shared wagon. + if (booking.consolidationPartnerId) { + const full = await this.resolveFullCut(booking); + if (cut.wagons >= full.wagons) { + const feeWagons = Math.ceil(cut.wagons); + const fee = await this.priceFee(booking, { ...cut, wagons: feeWagons }); + return { + wagons: cut.wagons, + weightTons: cut.weightTons, + feePerWagon: fee.perWagon, + feeAmount: fee.amount, + feeCurrency: fee.currency, + creditAmount: this.creditFor(booking, Number(booking.wagonsRequired ?? 0)), + }; + } + this.assertCutSparesSharedWagon(cut); + } const fee = await this.priceFee(booking, cut); return { wagons: cut.wagons, @@ -158,6 +181,24 @@ export class BookingWagonCancellationService { userId?: string, ): Promise { const booking = await this.loadCancellableBooking(bookingId); + // Consolidated booking: the shared wagon itself is untouchable — its other + // half belongs to the partner. The customer may still cancel + // - the WHOLE booking (breaks the pair: both cancel, ceil/floor fees), or + // - a PARTIAL cut of their own full wagons — an EVEN number of 20ft + // containers, so the odd one stays on the shared wagon and the pair + // survives untouched. + if (booking.consolidationPartnerId) { + if (this.isEmptyCut(dto)) { + return this.cancelConsolidatedPair(booking, dto.reason ?? null, userId); + } + const full = await this.resolveFullCut(booking); + const cut = await this.resolveRequestedCut(booking, dto); + if (cut.wagons >= full.wagons) { + return this.cancelConsolidatedPair(booking, dto.reason ?? null, userId); + } + this.assertCutSparesSharedWagon(cut); + // fall through: a pair-safe partial cut rides the normal partial flow. + } const open = await this.repo.findOpenForBooking(bookingId); if (open) { throw new ConflictException( @@ -165,7 +206,10 @@ export class BookingWagonCancellationService { ); } - const cut = await this.resolveRequestedCut(booking, dto); + // Empty dto = the whole booking ("Cancel booking" button). + const cut = this.isEmptyCut(dto) + ? await this.resolveFullCut(booking) + : await this.resolveRequestedCut(booking, dto); const fee = await this.priceFee(booking, cut); const feeAmount = fee.amount; const creditAmount = this.creditFor(booking, cut.wagons); @@ -280,6 +324,253 @@ export class BookingWagonCancellationService { return (await this.repo.update(row.id, { status: 'WITHDRAWN' }))!; } + // ── Consolidated-pair cancellation ────────────────────────────────────────── + + /** + * Cancel BOTH halves of a consolidated pair — a shared wagon never ships + * half-full, so a paired booking always cancels whole, together with its + * partner. + * + * Fee split (the canceller's leftover 20ft claims the shared wagon): + * canceller pays ceil(its wagons), the partner floor(its wagons) — e.g. + * 11 + 13 × 20ft = 12 wagons → canceller 7, partner 5, total 12. A PAID side + * keeps its full freight as a rebooking credit (rebooked by GL through the + * normal rebook endpoint once its fee settles); an UNPAID partner is + * cancelled with no fee and no credit. + */ + private async cancelConsolidatedPair( + booking: Booking, + reason: string | null, + userId?: string, + ): Promise { + const partnerId = booking.consolidationPartnerId!; + const partner = await this.bookingsRepository.findById(partnerId); + if (!partner) { + throw new NotFoundException(`Partner booking ${partnerId} not found.`); + } + const partnerPaid = + partner.paymentStatus === 'PAID' || partner.status === 'PAID'; + + // Break the link first — every write below treats each side singly. + await this.bookingsRepository.clearConsolidationPair(booking.id, partnerId); + + const row = await this.openConsolidationBreak( + booking, + 'ceil', + this.creditFor(booking, Number(booking.wagonsRequired ?? 0)), + reason ?? 'Consolidated pair cancelled', + userId, + ); + if (partnerPaid) { + await this.openConsolidationBreak( + partner, + 'floor', + this.creditFor(partner, Number(partner.wagonsRequired ?? 0)), + `Cancelled with its consolidation partner ${booking.reference}`, + userId, + ); + } else { + // Unpaid partner: no fee — just make sure no payable invoice stays open. + await this.billing + .expirePayable(Freight.InvoiceSource.Booking, partner.id, 'PREPAID') + .catch(() => undefined); + } + + for (const b of [booking, partner]) { + await this.dataSource.getRepository(Booking).update(b.id, { + status: 'CANCELLED', + trainScheduleId: null, + requestedTrainScheduleId: null, + }); + await this.detachFromSchedule(b); + } + this.notifyCustomer( + booking, + 'Consolidated booking cancelled', + `${booking.reference} shared a wagon with another booking, so both are cancelled. Your paid freight is kept as credit — pay the cancellation fee to rebook.`, + ); + this.notifyCustomer( + partner, + 'Consolidated booking cancelled', + partnerPaid + ? `${partner.reference} shared a wagon with a booking that was cancelled, so it is cancelled too. Your paid freight is kept as credit — pay the cancellation fee to rebook.` + : `${partner.reference} shared a wagon with a booking that was cancelled, so it is cancelled too. Nothing was paid — no fee applies.`, + ); + this.notifyStaff( + booking, + 'Consolidated pair cancelled', + `${booking.reference} + ${partner.reference}: shared-wagon pair cancelled; cancellation fee invoice(s) issued.`, + ); + return row; + } + + /** + * Open one side's ledger row for a consolidation break: a FULL cut whose fee + * is priced on the ceil/floor split of the cut's own FRACTIONAL wagons — + * never booking.wagonsRequired, which the contract flow persists already + * ceiled (3 × 20ft is stored as 2, not 1.5, and floor(2) would over-charge + * the partner). E.g. 1 + 3 × 20ft: canceller ceil(0.5) = 1 wagon, partner + * floor(1.5) = 1 wagon — 2 wagons total, matching the pair's real space. + * feeWagons 0 (the floor side of a lone 20ft) skips the fee entirely — the + * row goes straight to CREDIT_AVAILABLE. + */ + private async openConsolidationBreak( + booking: Booking, + mode: 'ceil' | 'floor', + creditAmount: number, + reason: string, + userId?: string, + ): Promise { + const open = await this.repo.findOpenForBooking(booking.id); + if (open) { + throw new ConflictException( + `Booking ${booking.reference} already has a cancellation awaiting its fee. Pay or withdraw it first.`, + ); + } + const cut = await this.resolveFullCut(booking); + const feeWagons = + mode === 'ceil' ? Math.ceil(cut.wagons) : Math.floor(cut.wagons); + // The pair is dead the moment it breaks — the wagons leave the schedule + // with the cancel itself, so T2 must not release them again. + const quantities = { ...cut.quantities, releasedAtRequest: true }; + + if (feeWagons <= 0) { + return this.repo.create({ + bookingId: booking.id, + wagonsCancelled: cut.wagons, + weightTons: cut.weightTons, + cancelledQuantities: quantities, + creditAmount, + feeAmount: 0, + feeCurrency: booking.paymentCurrency ?? 'ETB', + status: 'CREDIT_AVAILABLE', + feePaidAt: new Date(), + reason, + requestedByUserId: userId ?? null, + }); + } + + const fee = await this.priceFee(booking, { ...cut, wagons: feeWagons }); + const row = await this.repo.create({ + bookingId: booking.id, + wagonsCancelled: cut.wagons, + weightTons: cut.weightTons, + cancelledQuantities: quantities, + creditAmount, + feeRateId: fee.rates[0].id, + feeAmount: fee.amount, + feeCurrency: fee.currency, + status: 'FEE_PENDING', + reason, + requestedByUserId: userId ?? null, + }); + const invoice = await this.billing.generateInvoice({ + source: Freight.InvoiceSource.Booking, + sourceId: booking.id, + type: WAGON_CANCEL_FEE_INVOICE_TYPE, + companyId: booking.companyId, + companyProfileId: booking.companyProfileId, + currency: fee.currency, + lines: [ + { + chargeType: 'CANCELLATION_FEE', + description: `Consolidation cancellation fee — ${feeWagons} wagon(s) of booking ${booking.reference}`, + quantity: feeWagons, + unitRate: fee.perWagon, + amount: fee.amount, + currency: fee.currency, + metadata: { wagonCancellationId: row.id }, + }, + ], + totalAmount: fee.amount, + status: Freight.InvoiceStatus.Issued, + }); + return (await this.repo.update(row.id, { feeInvoiceId: invoice.id })) ?? row; + } + + /** No cut named at all — the "Cancel booking" button cancelling everything. */ + private isEmptyCut(dto: RequestWagonCancellationDto): boolean { + return ( + !dto.containers?.length && !dto.wagonAllocationIds?.length && !dto.wagons + ); + } + + /** + * A partial cut on a consolidated booking must leave the shared wagon whole: + * the odd 20ft riding it stays, so the cut's 20ft count must be EVEN (whole + * own wagons only). An odd cut — including picking the shared wagon itself in + * the Wagons tab (it contributes exactly one 20ft) — is rejected. + */ + private assertCutSparesSharedWagon(cut: RequestedCut): void { + const ft20Cut = Object.entries(cut.quantities.bySize ?? {}) + .filter(([size]) => sizeFtOf(size) === 20) + .reduce((sum, [, qty]) => sum + qty, 0); + if (ft20Cut % 2 === 1) { + throw new BadRequestException( + 'This booking shares a wagon with another booking — the shared wagon cannot be cancelled on its own. Cancel an even number of 20ft containers (your own whole wagons), or cancel the whole booking to end the consolidation.', + ); + } + } + + /** The whole booking as a cut — everything it still carries. */ + private async resolveFullCut(booking: Booking): Promise { + if (booking.freightType === 'CONTAINER') { + const lines = await this.dataSource.getRepository(BookingContainer).find({ + where: { bookingId: booking.id }, + }); + const bySize = new Map(); + for (const line of lines) { + const size = line.containerSize ?? ''; + bySize.set(size, (bySize.get(size) ?? 0) + Number(line.quantity ?? 0)); + } + const containers = [...bySize.entries()] + .filter(([, quantity]) => quantity > 0) + .map(([containerSize, quantity]) => ({ containerSize, quantity })); + return this.resolveRequestedCut(booking, { + containers, + } as RequestWagonCancellationDto); + } + return this.resolveRequestedCut(booking, { + wagons: Number(booking.wagonsRequired ?? 0), + } as RequestWagonCancellationDto); + } + + /** + * The batch engine expired an UNPAID booking whose consolidation partner had + * already PAID: the paid partner keeps the whole wagon at no extra cost; the + * lapsed side owes the cancellation fee on its own wagons — shared wagon + * included (ceil). Credit is 0 (nothing was paid); once the fee settles GL + * rebooks the customer through a normal new booking. + */ + @OnEvent('booking.consolidation.partnerLapsed') + async onConsolidationPartnerLapsed(payload: { + expiredBookingId: string; + }): Promise { + try { + const booking = await this.bookingsRepository.findById( + payload.expiredBookingId, + ); + if (!booking) return; + if (await this.repo.findOpenForBooking(booking.id)) return; // already charged + const row = await this.openConsolidationBreak( + booking, + 'ceil', + 0, + 'Expired while its consolidation partner had paid — cancellation fee applies', + ); + if (row.status !== 'FEE_PENDING') return; // nothing owed + this.notifyCustomer( + booking, + 'Cancellation fee due', + `${booking.reference} expired unpaid while sharing a wagon with a paid booking. A cancellation fee for ${Math.ceil(Number(row.wagonsCancelled))} wagon(s) has been invoiced — settle it before booking again.`, + ); + } catch (err) { + this.logger.error( + `Consolidation-lapse fee failed for booking ${payload.expiredBookingId}: ${err instanceof Error ? err.message : String(err)}`, + ); + } + } + // ── T2: fee settled ───────────────────────────────────────────────────────── /** @@ -454,6 +745,13 @@ export class BookingWagonCancellationService { `This credit cannot be rebooked (status is ${row.status}).`, ); } + // A consolidation-lapse row on an UNPAID booking carries no credit — the + // customer never paid freight, so there is nothing to redeem. Book fresh. + if (Number(row.creditAmount) <= 0) { + throw new BadRequestException( + 'This cancellation has no rebooking credit — the booking was never paid. Create a new booking instead.', + ); + } const source = await this.bookingsRepository.findById(row.bookingId); if (!source) throw new NotFoundException(`Booking ${row.bookingId} not found.`); if (!source.contractId) { diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts index efd6ed217..ff0888bef 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -596,6 +596,21 @@ export class BookingsRepository extends BaseRepository { } as never); } + /** + * Terminal un-pair: break the consolidation link only, touching neither + * status. Used when one half of a pair is cancelled/expired — the caller + * decides each side's fate ({@link unpairConsolidation} instead re-parks + * BOTH sides to PENDING_CONSOLIDATION, which is wrong for a dying booking). + */ + async clearConsolidationPair(bookingId: string, partnerId: string): Promise { + await this.repository.update(bookingId, { + consolidationPartnerId: null, + } as never); + await this.repository.update(partnerId, { + consolidationPartnerId: null, + } as never); + } + /** Un-pair a consolidation. */ async unpairConsolidation(bookingId: string, partnerId: string): Promise { await this.repository.update(bookingId, { diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index 0b5bf5a0a..6aa7eeb46 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -427,7 +427,8 @@ export class BookingsService { 'containerNumber', ci.container_number, 'sealNumber', ci.seal_number, 'positionOnWagon', ci.position_on_wagon, - 'grossWeightTons', ci.gross_weight_tons + 'grossWeightTons', ci.gross_weight_tons, + 'sizeFt', cit.size_ft ) ORDER BY ci.position_on_wagon, ci.container_number ) FILTER (WHERE ci.id IS NOT NULL), '[]' @@ -443,6 +444,7 @@ export class BookingsService { LEFT JOIN freight.yards sd ON sd.id = s.destination_station_id LEFT JOIN freight.wagon_allocation_container_items ci ON ci.wagon_booking_allocation_id = a.id AND ci.deleted_at IS NULL + LEFT JOIN freight.container_types cit ON cit.id = ci.container_type_id LEFT JOIN freight.wagon_allocation_bulk_loads bl ON bl.wagon_booking_allocation_id = a.id AND bl.deleted_at IS NULL WHERE a.booking_id = $1 AND a.deleted_at IS 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/contracts/contract-booking.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts index e093bd9bf..52cc2a51d 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts @@ -2458,22 +2458,12 @@ export class ContractBookingService { private async assert20ftPairableAtCreate( dto: CreateBookingUnderContractDto, ): Promise { - // Parity gate. 20ft containers ride two per wagon, so an odd total leaves - // one container that cannot be placed. Consolidation (pairing it with - // another customer's odd booking) is built end to end but switched off for - // now, so an odd total is rejected outright — server-side, because the - // frontend block alone is not a guarantee. - const ft20Quantity = (dto.containers ?? []) - .filter((line) => (line.containerSize ?? '').includes('20')) - .reduce((sum, line) => sum + Number(line.quantity || 0), 0); - if (ft20Quantity % 2 === 1) { - throw new BadRequestException( - `20ft containers travel two per wagon, so they must be booked in even ` + - `numbers. This booking has ${ft20Quantity} — add one more or remove ` + - `one (book ${ft20Quantity + 1} or ${ft20Quantity - 1}).`, - ); - } - + // Odd 20ft totals are no longer rejected here: the wagon consolidation gate + // that runs right after (consolidateDrawdown / needsConsolidationFromBooking, + // same machinery the plain booking flow already uses live) auto-pairs an odd + // total with another customer's odd booking or parks it as + // PENDING_CONSOLIDATION until one appears. This assert now only checks that + // any 20ft containers actually present can be weight-paired on a wagon. const twentyFtUnits = (dto.containers ?? []) .filter((line) => (line.containerSize ?? '').includes('20')) .flatMap((line, lineIdx) => 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/booking-batch.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts index ce0e77a3e..1c6b66ea8 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts @@ -1536,4 +1536,72 @@ describe('BookingBatchService — physical wagon-type gate', () => { // Those 16 are now held, so the next booking in the pass cannot re-take them. expect(stock.availableFor([NW5], WHOLE_LEG)).toBe(0); }); + + it('sizes a capped-bulk partial on ONE type at the cargo cap, not the 70T rating', async () => { + const svc = service(); + const inner = internals(svc); + (inner as { isSplitEligible: unknown }).isSplitEligible = () => true; + const dims = { lengthMeters: 14, tareWeightTons: 24, capacityTons: 70 }; + (inner as unknown as { loadWagonDims: unknown }).loadWagonDims = async () => ({ + container: dims, + bulk: dims, + byWagonTypeId: new Map([ + [NW5, dims], + [PW2, dims], + ]), + }); + const tryPartial = jest + .fn() + .mockResolvedValue({ wagons: 16, weightTons: 864, lengthMeters: 224 }); + (inner as { tryPartialOffer: unknown }).tryPartialOffer = tryPartial; + + const stock = mixedStock(); + const candidate = { + id: 'schedule-1', + budget: { + legOf: () => WHOLE_LEG, + remainingFor: () => ({ wagons: 20, weightTons: 99_999, lengthMeters: 99_999 }), + subtract: jest.fn(), + }, + armed: false, + stock, + }; + const booking = { + id: 'b2', + reference: 'BK-2', + originYardId: 'a', + destinationYardId: 'b', + freightType: 'BULK', + cargoTotalWeightVgm: 695, + cargoType: { + id: 'cargo-perishable', + wagonTypes: [ + { id: NW5, capacityTons: 70 }, + { id: PW2, capacityTons: 70 }, + ], + tonsPerWagonMap: { [NW5]: 30, [PW2]: 20 }, + }, + bookingContainers: [], + } as unknown as Booking; + + const offered = await inner.maybeOfferPartial( + booking, + false, + [candidate], + { wagons: 24, weightTons: 1400, lengthMeters: 336 }, + [NW5, PW2], + ); + + expect(offered).toBe(true); + // Room capped to the 16 NW5 that exist (biggest capped take), and the seat + // carries the 30T cargo cap — never the wagon's raw 70T rating. + expect(tryPartial.mock.calls[0][2]).toMatchObject({ wagons: 16 }); + expect(tryPartial.mock.calls[0][4]).toMatchObject({ + wagonTypeId: NW5, + perWagon: { capacityTons: 30 }, + }); + // Only the seated type is held; the PW2s stay free for bulk-only cargo. + expect(stock.availableFor([NW5], WHOLE_LEG)).toBe(0); + expect(stock.availableFor([PW2], WHOLE_LEG)).toBe(4); + }); }); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts index 0a7d87498..79a902a65 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts @@ -10,6 +10,7 @@ import { Optional, } from '@nestjs/common'; import { InjectDataSource } from '@nestjs/typeorm'; +import { EventEmitter2 } from '@nestjs/event-emitter'; import { SchedulerRegistry } from '@nestjs/schedule'; import { Between, @@ -92,6 +93,7 @@ import { BookingWindowGateway } from './booking-window.gateway'; import { MAX_TEU_SLOTS_PER_WAGON, containerWagonsForLines, + roundTons, } from './utils/wagon-plan.util'; import { Capacity, @@ -398,6 +400,8 @@ export class BookingBatchService implements OnModuleInit { @Optional() private readonly milestoneService?: ClearanceMilestoneService, @Optional() private readonly splitService?: BookingSplitService, + // Optional so hand-constructed spec instances keep compiling. + @Optional() private readonly eventEmitter?: EventEmitter2, @Optional() @Inject(forwardRef(() => RemainderPlacementService)) private readonly remainderPlacement?: RemainderPlacementService, @@ -1484,6 +1488,43 @@ export class BookingBatchService implements OnModuleInit { "Train is full — no export capacity left for this day", ); } + // Physical wagon gate — a pay window must never open for wagons that do + // not exist in a type this cargo can ride. PER_TON bulk is seated + // type-by-type at its per-wagon caps (the count allocation will really + // need); everything else checks the summed free stock of its types. + const stock = await this.stockLedgerFor( + schedule, + budget, + bookings.map((b) => b.id), + ); + const allowedWagonTypes = await this.loadAllowedWagonTypeIds(); + const primary = bookings[0]; + const wagonTypeIds = this.allowedWagonTypeIdsFor(primary, allowedWagonTypes); + const perItemBulk = + Number(primary.bulkTotalWeightTons ?? 0) > 0 && + Number(primary.cargoTotalWeightVgm ?? 0) > 0; + const useSmart = + bookings.length === 1 && + primary.freightType === "BULK" && + !perItemBulk && + wagonTypeIds.length > 0; + const smart = useSmart + ? this.smartBulkNeed( + primary, + wagonDims, + stock, + leg, + this.scarcityRankForPool([primary], allowedWagonTypes), + ) + : null; + const seated = useSmart + ? smart != null && budget.fits(smart.need, leg) + : this.hasWagonStock(stock, wagonTypeIds, need.wagons, leg); + if (!seated) { + throw new ConflictException( + "Train has no free wagons of a type this cargo can ride — payment was not opened", + ); + } for (const b of bookings) await this.reserve(b, scheduleId); }); @@ -2275,6 +2316,7 @@ export class BookingBatchService implements OnModuleInit { await this.recomputeBulkPriorities(pool, wagonDims); this.resortPoolByPriority(pool, await this.windowCycleIndexer(schedule)); const units = this.groupConsolidatedPool(pool); + const scarcityRank = this.scarcityRankForPool(pool, allowedWagonTypes); let armed = false; let preempted = false; let reservedThisPass = 0; @@ -2301,17 +2343,34 @@ export class BookingBatchService implements OnModuleInit { const leg = budget.legForYards(booking.originYardId, booking.destinationYardId); const wagonTypeIds = this.allowedWagonTypeIdsFor(booking, allowedWagonTypes); // Abstract room AND real wagons of a type this booking can ride — see - // fillRouteDayInternal for why both gates are needed. - const stocked = this.hasWagonStock(stock, wagonTypeIds, need.wagons, leg); + // fillRouteDayInternal for why both gates are needed. PER_TON bulk + // singles get the smart gate (exact per-type seating at the cargo's + // caps); a booking is only reserved — and only ever invoiced — when + // that seating is proven against the train's actual free wagons. + const perItemBulk = + Number(booking.bulkTotalWeightTons ?? 0) > 0 && + Number(booking.cargoTotalWeightVgm ?? 0) > 0; + const useSmart = + !isPair && + booking.freightType === "BULK" && + !perItemBulk && + wagonTypeIds.length > 0; + const smart = useSmart + ? this.smartBulkNeed(booking, wagonDims, stock, leg, scarcityRank) + : null; + const admitted = useSmart + ? smart != null && budget.fits(smart.need, leg) + : budget.fits(need, leg) && + this.hasWagonStock(stock, wagonTypeIds, need.wagons, leg); // Per-unit fit trace: which axis (wagons/weight/length/stock) admits or rejects. this.logger.debug( - `[fillSchedule ${scheduleId}] unit ${booking.reference}: need=${JSON.stringify(need)} ` + - `roomOnLeg=${JSON.stringify(budget.remainingFor(leg))} fits=${budget.fits(need, leg)} ` + - `stocked=${stocked}`, + `[fillSchedule ${scheduleId}] unit ${booking.reference}: need=${JSON.stringify( + smart?.need ?? need, + )} roomOnLeg=${JSON.stringify(budget.remainingFor(leg))} admitted=${admitted}`, ); - if (!budget.fits(need, leg) || !stocked) { + if (!admitted) { if (isGov) { const freed = await this.preemptForGovernment( scheduleId, @@ -2355,9 +2414,16 @@ export class BookingBatchService implements OnModuleInit { armed = true; commercialReserved += 1; } - budget.subtract(need, leg); + budget.subtract(smart?.need ?? need, leg); // Hold the physical wagons too — the next unit must not re-count them. - stock.consume(wagonTypeIds, need.wagons, leg); + // The smart gate holds the exact per-type counts it seated. + if (smart) { + for (const part of smart.perType) { + stock.consume([part.wagonTypeId], part.wagons, leg); + } + } else { + stock.consume(wagonTypeIds, need.wagons, leg); + } reservedThisPass += 1; } catch (err) { this.logger.error( @@ -2532,6 +2598,10 @@ export class BookingBatchService implements OnModuleInit { // Consolidated partners collapse into one atomic unit (both-or-neither); a // consolidated booking whose partner isn't ready this cycle is skipped. const units = this.groupConsolidatedPool(pool); + // Least-shareable-type-first seating for bulk (see smartBulkNeed): ranked + // once against the whole pool, so what containers will need is known + // before any bulk booking picks its wagons. + const scarcityRank = this.scarcityRankForPool(pool, allowedWagonTypes); // Batch fill trace: each train's caps + the day pool size at entry. this.logger.debug( @@ -2555,18 +2625,47 @@ export class BookingBatchService implements OnModuleInit { // Consolidated pairs share one wagon set; the primary's types stand for both. const wagonTypeIds = this.allowedWagonTypeIdsFor(booking, allowedWagonTypes); + // PER_TON bulk singles get the smart gate: seated type-by-type at the + // cargo's per-wagon caps, scarcest type first — the count the allocator + // will actually need, not a one-type estimate. Pairs, PER_ITEM and + // unconfigured cargo keep the generic gate (gov preemption and partial + // offers below also still size on the generic `need`). + const perItemBulk = + Number(booking.bulkTotalWeightTons ?? 0) > 0 && + Number(booking.cargoTotalWeightVgm ?? 0) > 0; + const useSmart = + !isPair && + booking.freightType === "BULK" && + !perItemBulk && + wagonTypeIds.length > 0; + let smart: { + need: Capacity; + perType: Array<{ wagonTypeId: string; wagons: number }>; + } | null = null; + // First train (earliest departure) whose corridor carries this booking's // leg, still fits it as-is AND physically holds enough wagons of a type the // booking can ride. Both gates matter: abstract room without the right // wagon type is space the allocator can never turn into a loaded consist. - let target = trains.find((t) => { + let target: (typeof trains)[number] | undefined; + for (const t of trains) { const leg = legOn(t); - return ( - leg != null && + if (leg == null) continue; + if (useSmart) { + const probe = this.smartBulkNeed(booking, wagonDims, t.stock, leg, scarcityRank); + if (probe != null && t.budget.fits(probe.need, leg)) { + smart = probe; + target = t; + break; + } + } else if ( t.budget.fits(need, leg) && this.hasWagonStock(t.stock, wagonTypeIds, need.wagons, leg) - ); - }); + ) { + target = t; + break; + } + } // Per-unit trace: chosen train + each train's remaining room on this leg. this.logger.debug( @@ -2645,10 +2744,18 @@ export class BookingBatchService implements OnModuleInit { target.armed = true; commercialReserved += 1; } - target.budget.subtract(need, legOn(target)!); + target.budget.subtract(smart?.need ?? need, legOn(target)!); // Hold the physical wagons too, so the next unit in this pass sees them - // gone — otherwise two bookings both "fit" the same 16 NW5. - target.stock.consume(wagonTypeIds, need.wagons, legOn(target)!); + // gone — otherwise two bookings both "fit" the same 16 NW5. The smart + // gate holds the EXACT per-type counts it seated (10 PW2 + 17 NW5), + // not a type-blind total drained deepest-first. + if (smart) { + for (const part of smart.perType) { + target.stock.consume([part.wagonTypeId], part.wagons, legOn(target)!); + } + } else { + target.stock.consume(wagonTypeIds, need.wagons, legOn(target)!); + } target.changed = true; reservedThisPass += 1; } catch (err) { @@ -2724,6 +2831,21 @@ export class BookingBatchService implements OnModuleInit { wagonTypeIds: string[] = [], ): Promise { if (!this.isSplitEligible(booking, isPair)) return false; + // PER_TON bulk partials are sized on ONE concrete wagon type at the + // cargo's per-wagon cap — sizing on the first type's raw 70T rating + // offered tonnage the wagons could never carry (Perishable caps at + // 20/30T), taking payment for cargo that stalls at allocation. + // ponytail: single-type bulk partials; a multi-type partial (PW2+NW5 + // mixed) is the upgrade path if offers come out too small. + const perItemBulk = + Number(booking.bulkTotalWeightTons ?? 0) > 0 && + Number(booking.cargoTotalWeightVgm ?? 0) > 0; + const cappedBulk = + !isPair && + booking.freightType === "BULK" && + !perItemBulk && + wagonTypeIds.length > 0; + const wagonDims = cappedBulk ? await this.loadWagonDims() : null; const target = candidates .map((c) => { const leg = c.budget.legOf(booking.originYardId, booking.destinationYardId); @@ -2734,12 +2856,39 @@ export class BookingBatchService implements OnModuleInit { // them NW5" into an offer for 16 — the customer pays for 16 and the // other 4 leave as the usual remainder booking, instead of paying for // 20 and stalling at allocation on wagon 17. + if (cappedBulk && wagonDims) { + const best = this.allowedDimsWithTypes(booking, wagonDims) + .filter((o): o is { wagonTypeId: string; dims: PerWagonDims } => + o.wagonTypeId != null, + ) + .map((o) => ({ + ...o, + free: c.stock?.availableFor([o.wagonTypeId], leg) ?? 0, + takePerWagon: bulkTonsPerWagon( + booking.cargoType, + o.wagonTypeId, + o.dims.capacityTons, + ), + })) + .filter((o) => o.free > 0 && o.takePerWagon > 0) + .sort((a, b) => b.takePerWagon - a.takePerWagon)[0]; + if (!best) return null; + return { + c, + leg, + room: { ...room, wagons: Math.min(room.wagons, best.free) }, + seat: { + wagonTypeId: best.wagonTypeId, + perWagon: { ...best.dims, capacityTons: best.takePerWagon }, + }, + }; + } const physical = wagonTypeIds.length ? c.stock?.availableFor(wagonTypeIds, leg) : undefined; const wagons = physical == null ? room.wagons : Math.min(room.wagons, physical); - return { c, leg, room: { ...room, wagons } }; + return { c, leg, room: { ...room, wagons }, seat: undefined }; }) .filter((x): x is NonNullable => x != null && x.room.wagons >= 1) .sort((a, b) => b.room.wagons - a.room.wagons)[0]; @@ -2749,10 +2898,15 @@ export class BookingBatchService implements OnModuleInit { target.c.id, target.room, need, + target.seat, ); if (!offered) return false; target.c.budget.subtract(offered, target.leg); - target.c.stock?.consume(wagonTypeIds, offered.wagons, target.leg); + target.c.stock?.consume( + target.seat ? [target.seat.wagonTypeId] : wagonTypeIds, + offered.wagons, + target.leg, + ); target.c.armed = true; return true; } @@ -2767,6 +2921,12 @@ export class BookingBatchService implements OnModuleInit { scheduleId: string, budget: Capacity, need: Capacity, + /** + * Capped-bulk seating (see maybeOfferPartial): the ONE wagon type this + * offer rides, with capacityTons already reduced to the cargo's per-wagon + * cap — so the offered tonnage is what those wagons can really carry. + */ + seat?: { wagonTypeId: string; perWagon: PerWagonDims }, ): Promise { if (!this.splitService) return null; // A consolidated booking is already half of a shared wagon — never split it. @@ -2784,8 +2944,14 @@ export class BookingBatchService implements OnModuleInit { // measured on the booking's REAL wagon type — the same one allocation // validates against. Bulk splits ride FULL wagons only: the offer never // part-loads its last wagon. - const perWagon = this.dimsFor(booking, wagonDims); - const partial = sizePartialOfferWagons(budget, need.wagons, perWagon, { + const perWagon = seat?.perWagon ?? this.dimsFor(booking, wagonDims); + // With a capped seat, the whole booking's wagon count follows the cap too + // (695T at 30T/wagon = 24, not 10 at the raw rating) — the offer must be a + // strict subset of THAT count. + const wholeWagons = seat + ? Math.max(1, Math.ceil(bookingCargoTons(booking) / perWagon.capacityTons)) + : need.wagons; + const partial = sizePartialOfferWagons(budget, wholeWagons, perWagon, { fullWagonsOnly: booking.freightType === "BULK", }); if (!partial) return null; @@ -2793,7 +2959,7 @@ export class BookingBatchService implements OnModuleInit { const sized = await this.splitService.sizeOffer( booking, partial.wagons, - need.wagons, + wholeWagons, perWagon.capacityTons, partial.maxCargoTons, ); @@ -2832,8 +2998,9 @@ export class BookingBatchService implements OnModuleInit { * Settle a schedule's reserved bookings. `expireUnpaidUnknownDeadline` decides * how to treat a reservation with no deadline (durable path: leave it; timeout * path: expire it). Consolidated pairs settle atomically: both allocate only - * when both paid; if either partner expires, both expire (a half-paid shared - * wagon must not ship). Returns whether anything changed. + * when both paid; when neither paid, both expire. A half-paid pair splits: + * the paid half keeps the whole wagon, the lapsed half expires and owes the + * cancellation fee (expire()'s pair cascade). Returns whether anything changed. */ private async settleReserved( scheduleId: string, @@ -2876,8 +3043,10 @@ export class BookingBatchService implements OnModuleInit { await this.allocate(scheduleId, partner, "paid"); anySettled = true; } else if (isExpired(booking) || isExpired(partner)) { + // One call is enough: expire()'s pair cascade settles both sides — + // both expire when neither paid; a paid half is rescued (keeps the + // whole wagon) while the lapsed half expires with its fee. await this.expire(booking); - await this.expire(partner); anySettled = true; } continue; @@ -3816,6 +3985,55 @@ export class BookingBatchService implements OnModuleInit { booking: Booking, reason: "payment" | "no-capacity" = "payment", ): Promise { + // Consolidated pair: break the link FIRST, then settle each side singly. + // - neither paid → both expire, no fee. + // - one side paid → the paid half keeps the whole wagon (rescued by the + // paid guard below at no extra cost); the lapsed half expires and owes + // the cancellation fee (the 'partnerLapsed' event opens the fee invoice + // in BookingWagonCancellationService). + // - both paid → nothing to expire; the paid guard rescues. + if (booking.consolidationPartnerId) { + const partnerId = booking.consolidationPartnerId; + const bookingRepo = this.dataSource.getRepository(Booking); + const partnerRow = await bookingRepo.findOne({ + where: { id: partnerId }, + relations: { company: true }, + }); + const freshSelf = await bookingRepo.findOne({ + where: { id: booking.id }, + }); + const paidOf = (b: Booking | null) => + b != null && (b.paymentStatus === "PAID" || b.status === "PAID"); + const selfPaid = paidOf(freshSelf); + const partnerPaid = paidOf(partnerRow); + + await this.bookingsRepository.clearConsolidationPair( + booking.id, + partnerId, + ); + booking.consolidationPartnerId = null; + if (partnerRow) partnerRow.consolidationPartnerId = null; + + if (selfPaid && !partnerPaid) { + // Wrong side called first: the lapsed partner is the one that expires + // (with its fee); this paid booking falls through to the rescue below. + if (partnerRow && !["EXPIRED", "CANCELLED"].includes(partnerRow.status)) { + this.eventEmitter?.emit("booking.consolidation.partnerLapsed", { + expiredBookingId: partnerRow.id, + }); + await this.expire(partnerRow, reason); + } + } else if (!selfPaid && partnerPaid) { + this.eventEmitter?.emit("booking.consolidation.partnerLapsed", { + expiredBookingId: booking.id, + }); + // fall through: this side expires below; the paid partner is untouched. + } else if (!selfPaid && !partnerPaid) { + if (partnerRow && !["EXPIRED", "CANCELLED"].includes(partnerRow.status)) { + await this.expire(partnerRow, reason); + } + } + } if (!booking.consolidationPartnerId) { const fresh = await this.dataSource .getRepository(Booking) @@ -4097,7 +4315,50 @@ export class BookingBatchService implements OnModuleInit { // and push once per schedule after the sweep (most unaccepted rows are // unpinned under day-level pooling, so this usually emits nothing). const touchedScheduleIds = new Set(); + const swept = new Set(); for (const booking of unaccepted) { + if (swept.has(booking.id)) continue; + swept.add(booking.id); + // Consolidated pair: the partner may sit outside this route-day's result + // set (different yards/day/status), so cascade explicitly — an unpaid + // partner expires with this booking; a PAID partner keeps the whole + // wagon and this booking owes the cancellation fee (partnerLapsed). + if (booking.consolidationPartnerId) { + const partner = await this.dataSource.getRepository(Booking).findOne({ + where: { id: booking.consolidationPartnerId }, + relations: { company: true }, + }); + await this.bookingsRepository.clearConsolidationPair( + booking.id, + booking.consolidationPartnerId, + ); + booking.consolidationPartnerId = null; + if (partner) { + const partnerPaid = + partner.paymentStatus === "PAID" || partner.status === "PAID"; + if (partnerPaid) { + this.eventEmitter?.emit("booking.consolidation.partnerLapsed", { + expiredBookingId: booking.id, + }); + } else if (!["EXPIRED", "CANCELLED"].includes(partner.status)) { + swept.add(partner.id); + partner.consolidationPartnerId = null; + if (partner.trainScheduleId) touchedScheduleIds.add(partner.trainScheduleId); + await this.bookingsRepository.update(partner.id, { + status: "EXPIRED", + schedulingStatus: "ELIGIBLE", + scheduledDate: null, + } as never); + await this.billing + .expirePayable(Freight.InvoiceSource.Booking, partner.id, "PREPAID") + .catch(() => undefined); + this.notifier.expired(partner); + this.logger.log( + `[BATCH] EXPIRED (unaccepted, with consolidation partner) ${partner.reference}:${partner.id} at doc-review end`, + ); + } + } + } if (booking.trainScheduleId) touchedScheduleIds.add(booking.trainScheduleId); await this.bookingsRepository.update(booking.id, { status: "EXPIRED", @@ -4798,12 +5059,34 @@ export class BookingBatchService implements OnModuleInit { this.loadAllowedWagonTypeIds(), ]); const anyType = [...stock.remainingByTypeId.keys()]; - for (const b of await this.committedBookings(schedule, excludeBookingIds)) { + const committed = await this.committedBookings(schedule, excludeBookingIds); + // Debit committed PER_TON bulk the way it was SEATED — per type at the + // cargo's caps, scarcest type first — not a one-type wagon count drained + // deepest-first (which mis-charged 695T Perishable as 24 NW5 when it holds + // 10 PW2 + 17 NW5, so later passes over-counted free PW2 and sold NW5 that + // were already spoken for). + const rank = this.scarcityRankForPool(committed, allowed); + for (const b of committed) { const typeIds = this.allowedWagonTypeIdsFor(b, allowed); + const leg = budget.legForYards(b.originYardId, b.destinationYardId); + const perItemBulk = + Number(b.bulkTotalWeightTons ?? 0) > 0 && + Number(b.cargoTotalWeightVgm ?? 0) > 0; + if (b.freightType === "BULK" && !perItemBulk && typeIds.length) { + const smart = this.smartBulkNeed(b, wagonDims, ledger, leg, rank); + if (smart) { + for (const part of smart.perType) { + ledger.consume([part.wagonTypeId], part.wagons, leg); + } + continue; + } + // Over-committed (stock cannot seat it any more) — drain what exists, + // same as before, so the shortage stays visible to the gates. + } ledger.consume( typeIds.length ? typeIds : anyType, this.wagonsFor(b, wagonDims), - budget.legForYards(b.originYardId, b.destinationYardId), + leg, ); } return ledger; @@ -4825,6 +5108,112 @@ export class BookingBatchService implements OnModuleInit { return stock.availableFor(wagonTypeIds, leg) >= wagonsNeeded; } + /** + * Scarcity rank over the day pool: how many distinct demand groups (bulk + * cargo types / container types among these bookings) may ride each wagon + * type. The batch seats least-shareable types first, so bulk with a + * bulk-only alternative (PW2) never eats the container-capable stock (NW5) + * that containers cannot substitute. + */ + private scarcityRankForPool( + pool: Booking[], + allowed: { + byCargoTypeId: Map; + byContainerTypeId: Map; + }, + ): Map { + const groups = new Map(); + for (const b of pool) { + if (b.freightType === "BULK") { + const cargoTypeId = b.cargoTypeId ?? b.cargoType?.id; + if (cargoTypeId) { + groups.set(`B:${cargoTypeId}`, allowed.byCargoTypeId.get(cargoTypeId) ?? []); + } + } else { + for (const line of b.bookingContainers ?? []) { + const containerTypeId = line.containerTypeId ?? line.containerType?.id; + if (containerTypeId) { + groups.set( + `C:${containerTypeId}`, + allowed.byContainerTypeId.get(containerTypeId) ?? [], + ); + } + } + } + } + const rank = new Map(); + for (const ids of groups.values()) { + for (const id of ids) rank.set(id, (rank.get(id) ?? 0) + 1); + } + return rank; + } + + /** + * Cap-aware, scarcity-ordered seating of a PER_TON bulk booking across the + * wagon types this train actually has free on its leg — the same policy the + * wagon planner applies at allocation time (least-shareable type first, each + * wagon filled to the cargo type's per-wagon cap, one booking per wagon). + * + * This is the payment gate's real fit check for bulk: the generic + * `hasWagonStock` sums free wagons across allowed types against a count + * sized on ONE type, so 695T Perishable read "24 wagons needed, 28 free" + * when seating it across 10 PW2 (20T) + NW5 (30T) really takes 27 wagons. + * Returns the exact per-type counts and the three-axis capacity they + * consume, or null when the free stock cannot seat the whole booking. + */ + private smartBulkNeed( + booking: Booking, + wagonDims: WagonDims, + stock: WagonStockLedger, + leg: CorridorLeg, + scarcityRank: Map, + ): { need: Capacity; perType: Array<{ wagonTypeId: string; wagons: number }> } | null { + const options = this.allowedDimsWithTypes(booking, wagonDims) + .filter((o): o is { wagonTypeId: string; dims: PerWagonDims } => o.wagonTypeId != null) + .map((o) => ({ + ...o, + free: stock.availableFor([o.wagonTypeId], leg), + takePerWagon: bulkTonsPerWagon( + booking.cargoType, + o.wagonTypeId, + o.dims.capacityTons, + ), + })) + .filter((o) => o.free > 0 && o.takePerWagon > 0) + .sort( + (a, b) => + (scarcityRank.get(a.wagonTypeId) ?? 1) - + (scarcityRank.get(b.wagonTypeId) ?? 1) || + b.takePerWagon - a.takePerWagon, + ); + + let remaining = bookingCargoTons(booking); + if (remaining <= 0) return null; + const perType: Array<{ wagonTypeId: string; wagons: number }> = []; + let weightTons = remaining; // gross: cargo plus each seated wagon's tare + let lengthMeters = 0; + let wagons = 0; + for (const option of options) { + if (remaining <= 1e-9) break; + const take = Math.min(option.free, Math.ceil(remaining / option.takePerWagon)); + if (take <= 0) continue; + remaining = roundTons(Math.max(0, remaining - take * option.takePerWagon)); + wagons += take; + weightTons += take * option.dims.tareWeightTons; + lengthMeters += take * option.dims.lengthMeters; + perType.push({ wagonTypeId: option.wagonTypeId, wagons: take }); + } + if (remaining > 1e-9) return null; + return { + need: { + wagons, + weightTons: roundTons(weightTons), + lengthMeters: roundTons(lengthMeters), + }, + perType, + }; + } + private allowedWagonTypeCache: { byCargoTypeId: Map; byContainerTypeId: Map; diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.smart-need.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.smart-need.spec.ts new file mode 100644 index 000000000..bf64f0413 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.smart-need.spec.ts @@ -0,0 +1,104 @@ +import { Booking } from '../bookings/entities/booking.entity'; +import { BookingBatchService } from './booking-batch.service'; +import { WagonStockLedger } from './wagon-stock-ledger.util'; + +/** + * smartBulkNeed math in isolation: the private helpers it touches + * (allowedDimsWithTypes) read only their arguments, so a bare prototype + * instance is enough — no Nest wiring. + */ +describe('BookingBatchService.smartBulkNeed', () => { + const service = Object.create(BookingBatchService.prototype) as BookingBatchService; + const call = ( + booking: Booking, + stock: WagonStockLedger, + rank: Map, + ) => + ( + service as unknown as { + smartBulkNeed: ( + b: Booking, + d: unknown, + s: WagonStockLedger, + l: { fromEdge: number; toEdge: number }, + r: Map, + ) => { need: { wagons: number }; perType: Array<{ wagonTypeId: string; wagons: number }> } | null; + } + ).smartBulkNeed(booking, wagonDims, stock, { fromEdge: 0, toEdge: 1 }, rank); + + const nw5 = { id: 'wt-nw5', capacityTons: 70 }; + const pw2 = { id: 'wt-pw2', capacityTons: 70 }; + const perishable = { + id: 'cargo-perishable', + wagonTypes: [nw5, pw2], + tonsPerWagonMap: { [nw5.id]: 30, [pw2.id]: 20 }, + }; + const wagonDims = { + container: { lengthMeters: 14, tareWeightTons: 24, capacityTons: 70 }, + bulk: { lengthMeters: 14, tareWeightTons: 24, capacityTons: 70 }, + byWagonTypeId: new Map([ + [nw5.id, { lengthMeters: 14, tareWeightTons: 24, capacityTons: 70 }], + [pw2.id, { lengthMeters: 14, tareWeightTons: 24, capacityTons: 70 }], + ]), + }; + const booking = (tons: number): Booking => + ({ + id: 'b1', + reference: 'b1', + freightType: 'BULK', + cargoTotalWeightVgm: tons, + cargoTypeId: perishable.id, + cargoType: perishable, + bookingContainers: [], + }) as unknown as Booking; + // Containers compete for NW5 → NW5 rank 2, PW2 rank 1. + const contested = new Map([ + [nw5.id, 2], + [pw2.id, 1], + ]); + + it('seats 695T as 10 PW2 (20T) + 17 NW5 (30T) = 27 wagons, PW2 first', () => { + const stock = new WagonStockLedger( + new Map([ + [nw5.id, 18], + [pw2.id, 10], + ]), + 1, + ); + const smart = call(booking(695), stock, contested); + expect(smart).not.toBeNull(); + expect(smart!.need.wagons).toBe(27); + expect(smart!.perType).toEqual([ + { wagonTypeId: pw2.id, wagons: 10 }, + { wagonTypeId: nw5.id, wagons: 17 }, + ]); + }); + + it('returns null when the free stock cannot seat the whole booking', () => { + const stock = new WagonStockLedger( + new Map([ + [nw5.id, 5], + [pw2.id, 10], + ]), + 1, + ); + // 10×20 + 5×30 = 350T < 695T. + expect(call(booking(695), stock, contested)).toBeNull(); + }); + + it('uncontested types fall back to biggest per-cargo take (fewest wagons)', () => { + const stock = new WagonStockLedger( + new Map([ + [nw5.id, 10], + [pw2.id, 10], + ]), + 1, + ); + const even = new Map([ + [nw5.id, 1], + [pw2.id, 1], + ]); + const smart = call(booking(60), stock, even); + expect(smart!.perType).toEqual([{ wagonTypeId: nw5.id, wagons: 2 }]); + }); +}); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts index 6f758f053..b36ed4639 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts @@ -6081,6 +6081,18 @@ export class TrainSchedulingService { const trainSetWagon = savedWagons[i]; if (!slot || !trainSetWagon) continue; + // Last line of defense behind validateWagonCargoExclusivity: a wagon + // with bulk on it carries that one load only — never a container and + // never a second bulk booking. + if ( + slot.allocations.length > 1 && + slot.allocations.some((a) => a.loadType === AllocationLoadType.Bulk) + ) { + throw new BadRequestException( + `Wagon #${slot.sequenceNo} mixes bulk with other cargo — a wagon carrying bulk takes that one load only`, + ); + } + for (const alloc of slot.allocations) { const savedAllocation = await manager.getRepository(WagonBookingAllocation).save( manager.getRepository(WagonBookingAllocation).create({ diff --git a/apps/edr-freight-api/src/modules/train-scheduling/utils/wagon-plan.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/utils/wagon-plan.util.spec.ts index 52176662a..bac2db62a 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/utils/wagon-plan.util.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/utils/wagon-plan.util.spec.ts @@ -14,6 +14,7 @@ import { sumWagonsRequired, validate20ftContainerRules, validateContainerPlacements, + validateWagonCargoExclusivity, } from './wagon-plan.util'; const nw5: WagonType = { @@ -222,6 +223,85 @@ describe('wagon-plan.util', () => { expect(plan[0]?.slotLoadType).toBe('BULK'); expect(buildBulkWagonPlan([bulkBooking], cw3)).toHaveLength(1); }); + + it('never pools two bulk bookings on one wagon', () => { + // 5T + 40T both fit a single 60T CW3 by tonnage — but a wagon with bulk + // takes that one load only, so each booking gets its own wagon. + const small = { + id: 'bulk-5', + reference: 'bulk-5', + freightType: 'BULK', + cargoTotalWeightVgm: 5, + bookingContainers: [], + } as unknown as Booking; + const other = { + id: 'bulk-40', + reference: 'bulk-40', + freightType: 'BULK', + cargoTotalWeightVgm: 40, + bookingContainers: [], + } as unknown as Booking; + const plan = buildBulkWagonPlan([small, other], cw3); + expect(plan).toHaveLength(2); + for (const slot of plan) { + expect(slot.allocations).toHaveLength(1); + } + expect(plan[0]?.allocations[0]?.bookingId).toBe('bulk-5'); + expect(plan[1]?.allocations[0]?.bookingId).toBe('bulk-40'); + expect(validateWagonCargoExclusivity(plan)).toEqual([]); + }); + + it('a multi-wagon bulk booking still spreads over its own wagons', () => { + const big = { + id: 'bulk-130', + reference: 'bulk-130', + freightType: 'BULK', + cargoTotalWeightVgm: 130, + bookingContainers: [], + } as unknown as Booking; + const plan = buildBulkWagonPlan([big], cw3); + expect(plan).toHaveLength(3); + expect(plan.map((s) => s.allocations[0]?.allocatedWeightTons)).toEqual([60, 60, 10]); + }); + + it('flags a wagon mixing bulk with anything else', () => { + const bulkAlloc = { + bookingId: 'b', + bookingReference: 'b', + allocatedWeightTons: 5, + loadType: AllocationLoadType.Bulk, + }; + const containerAlloc = { + bookingId: 'c', + bookingReference: 'c', + allocatedWeightTons: 25, + loadType: AllocationLoadType.Container, + }; + const slot = (allocations: (typeof bulkAlloc)[]) => ({ + sequenceNo: 1, + wagonTypeId: cw3.id, + wagonTypeCode: cw3.code, + capacityTons: 60, + lengthMeters: 14, + tareWeightTons: 24, + assignedWeightTons: 0, + allocations, + }); + // bulk + container on one wagon + expect(validateWagonCargoExclusivity([slot([bulkAlloc, containerAlloc])])) + .toHaveLength(1); + // bulk + bulk on one wagon + expect( + validateWagonCargoExclusivity([slot([bulkAlloc, { ...bulkAlloc, bookingId: 'b2' }])]), + ).toHaveLength(1); + // bulk alone, and containers sharing, are fine + expect(validateWagonCargoExclusivity([slot([bulkAlloc])])).toEqual([]); + expect( + validateWagonCargoExclusivity([ + slot([containerAlloc, { ...containerAlloc, bookingId: 'c2' }]), + ]), + ).toEqual([]); + }); }); describe('containerWagonsForLines — TEU-aware, ceil booking total once', () => { diff --git a/apps/edr-freight-api/src/modules/train-scheduling/utils/wagon-plan.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/utils/wagon-plan.util.ts index 01c497262..5447a0b6e 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/utils/wagon-plan.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/utils/wagon-plan.util.ts @@ -200,16 +200,14 @@ export function buildBulkWagonPlan( ); const cappedTonSlots = cappedTonSlotsByBooking.reduce((sum, n) => sum + n, 0); - const totalWeight = roundTons( - bookings.reduce( - (sum, b, i) => - itemSlotsByBooking[i] > 0 || cappedTonSlotsByBooking[i] > 0 - ? sum - : sum + Number(b.cargoTotalWeightVgm ?? 0), - 0, - ), - ); - const tonSlots = totalWeight > 0 ? Math.ceil(totalWeight / capacity) : 0; + // One bulk booking per wagon — bookings never pool tonnage on a shared + // wagon, so each uncapped booking sizes its own wagons (ceil per booking, + // not over the pooled total). + const tonSlots = bookings.reduce((sum, b, i) => { + if (itemSlotsByBooking[i] > 0 || cappedTonSlotsByBooking[i] > 0) return sum; + const weight = roundTons(Number(b.cargoTotalWeightVgm ?? 0)); + return weight > 0 ? sum + Math.ceil(weight / capacity) : sum; + }, 0); const slots = Math.max(1, tonSlots + itemSlots + cappedTonSlots); const basePlan: WagonPlanSlot[] = Array.from({ length: slots }, (_, index) => ({ @@ -374,13 +372,12 @@ function allocateBookingsToSlots( if (booking.remainingWeightTons <= 0) { bookingIndex += 1; - } else if (allocatedWeightTons >= takeCap) { - // The cap stopped this wagon short of its rating and the booking has - // more to load. The leftover room is NOT free: `buildBulkWagonPlan` - // already reserved a wagon for the rest, so backfilling another booking - // here would double-book the consist. Close the wagon. - break; } + // One bulk booking per wagon: a wagon carrying bulk takes nothing else — + // never a second booking's cargo. `buildBulkWagonPlan` sized the slots + // per booking, so leftover room on this wagon is not free capacity. + // Close the wagon after its single allocation. + break; } return { ...slot, assignedWeightTons, allocations }; @@ -504,6 +501,26 @@ export function sumWagonsRequired(booking: Booking, wagonPlan?: WagonPlanSlot[]) ); } +/** + * One wagon carries one kind of cargo: a slot with a BULK allocation holds + * nothing else — no container beside it and no second bulk booking. Container + * allocations may still share a wagon with each other (TEU rules apply). + */ +export function validateWagonCargoExclusivity(wagonPlan: WagonPlanSlot[]): string[] { + const violations: string[] = []; + for (const slot of wagonPlan) { + const hasBulk = slot.allocations.some( + (a) => a.loadType === AllocationLoadType.Bulk, + ); + if (hasBulk && slot.allocations.length > 1) { + violations.push( + `Wagon #${slot.sequenceNo} mixes bulk with other cargo — a wagon carrying bulk takes that one load only`, + ); + } + } + return violations; +} + export function validateBulkWagonSlotWeights(wagonPlan: WagonPlanSlot[]): string[] { const violations: string[] = []; for (const slot of wagonPlan.filter((s) => s.slotLoadType === 'BULK')) { @@ -547,6 +564,7 @@ export function validateTrainLimits( ); violations.push(...validateBulkWagonSlotWeights(wagonPlan)); + violations.push(...validateWagonCargoExclusivity(wagonPlan)); return violations; } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.spec.ts index 127167567..967215f57 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.spec.ts @@ -490,3 +490,112 @@ describe('planWagonsWithStock — consist split across yards', () => { expect(result.deferred.map((d) => d.reference)).toEqual(['BKG-G']); }); }); + +describe('planWagonsWithStock — scarcity-aware bulk (one booking per wagon, capped fill)', () => { + // The S-2026-00044 shape: Perishable rides NW5 (30T cap) or PW2 (20T cap); + // containers ride only NW5. NW5 is the shared, scarce type. + const nw5: WagonType = { + id: 'wt-nw5', + code: 'NW5', + name: 'Flat Wagon', + capacityTons: 70, + lengthMeters: 14, + supportedLoadTypes: ['CONTAINER'], + isActive: true, + supportsContainer: true, + } as WagonType; + const pw2: WagonType = { + id: 'wt-pw2', + code: 'PW2', + name: 'Flat Wagon', + capacityTons: 70, + lengthMeters: 14, + supportedLoadTypes: ['BULK'], + isActive: true, + supportsContainer: false, + } as WagonType; + const perishable = { + id: 'cargo-perishable', + cargoTypeName: 'Perishable', + wagonTypes: [nw5, pw2], + tonsPerWagonMap: { [nw5.id]: 30, [pw2.id]: 20 }, + }; + const bulkBooking = (id: string, tons: number): Booking => + ({ + id, + reference: id, + freightType: 'BULK', + cargoTotalWeightVgm: tons, + cargoTypeId: perishable.id, + cargoType: perishable, + bookingContainers: [], + }) as unknown as Booking; + const allowed = { + byContainerTypeId: new Map([['ct-1', [nw5]]]), + byCargoTypeId: new Map([[perishable.id, [nw5, pw2]]]), + }; + const stockOf = (nw5Count: number, pw2Count: number) => ({ + mode: 'YARD' as const, + remainingByTypeId: new Map([ + [nw5.id, nw5Count], + [pw2.id, pw2Count], + ]), + codesByTypeId: new Map([ + [nw5.id, nw5.code], + [pw2.id, pw2.code], + ]), + }); + + it('fills the bulk-only PW2s first when containers compete for NW5', () => { + // 695T Perishable + one 40ft container. Smart split: 10 PW2 × 20T = 200T, + // remainder 495T → 17 NW5 × 30T. The container still gets an NW5. + const container = containerBooking('BKG-C', 1, 1); + container.bookingContainers![0]!.containerType = { code: '40GP', sizeFt: 40 } as never; + const result = planWagonsWithStock({ + bookings: [bulkBooking('BKG-BULK', 695), container], + allowed, + stock: stockOf(18, 10), + }); + + expect(result.deferred).toEqual([]); + const bulkSlots = result.plan.filter((s) => s.slotLoadType === 'BULK'); + expect(bulkSlots.filter((s) => s.wagonTypeCode === 'PW2')).toHaveLength(10); + expect(bulkSlots.filter((s) => s.wagonTypeCode === 'NW5')).toHaveLength(17); + // Capped fill: no PW2 slot above 20T, no NW5 bulk slot above 30T. + for (const slot of bulkSlots) { + expect(slot.assignedWeightTons).toBeLessThanOrEqual( + slot.wagonTypeCode === 'PW2' ? 20 : 30, + ); + } + const containerSlots = result.plan.filter((s) => s.slotLoadType === 'CONTAINER'); + expect(containerSlots).toHaveLength(1); + expect(containerSlots[0]?.wagonTypeCode).toBe('NW5'); + }); + + it('prefers the bigger per-cargo take when nothing competes for the shared type', () => { + // Bulk alone (no containers in the run): NW5 30T beats PW2 20T — fewest + // wagons wins, PW2-first would waste consist length. + const result = planWagonsWithStock({ + bookings: [bulkBooking('BKG-BULK', 60)], + allowed, + stock: stockOf(10, 10), + }); + expect(result.deferred).toEqual([]); + expect(result.plan).toHaveLength(2); + expect(result.plan.every((s) => s.wagonTypeCode === 'NW5')).toBe(true); + }); + + it('never puts two bulk bookings on one wagon, even same cargo type', () => { + // 5T + 40T both fit one wagon's cap by tonnage — each still gets its own. + const result = planWagonsWithStock({ + bookings: [bulkBooking('BKG-A', 5), bulkBooking('BKG-B', 40)], + allowed, + stock: stockOf(10, 0), + }); + expect(result.deferred).toEqual([]); + expect(result.plan).toHaveLength(3); // 5T → 1 wagon; 40T @30 cap → 2 wagons + for (const slot of result.plan) { + expect(new Set(slot.allocations.map((a) => a.bookingId)).size).toBe(1); + } + }); +}); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.ts index ad117663e..d30094b30 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.ts @@ -5,6 +5,7 @@ import { WagonType } from '../wagon-types/entities/wagon-type.entity'; import { bookingCargoTons, bulkItemsFitFor, + bulkTonsPerWagon, bulkWagonsForAllowedTypes, } from './train-capacity.util'; import { @@ -187,8 +188,10 @@ const addAllocation = ( * containers/tonnage placed on wagons whose type is allowed for its container * or cargo type) or is deferred with the shortfall reason. Wagon purity rules: * a wagon carries one kind at a time — containers pack by TEU (one 40ft, or - * two 20ft, never mixed sizes), bulk fills by weight and never shares a wagon - * with a different cargo type. + * two 20ft, never mixed sizes); a bulk wagon carries ONE booking's cargo only, + * filled to the cargo type's per-wagon cap. Type choice is scarcity-aware: + * least-shareable wagon type first, so bulk with a PW2 alternative leaves the + * container-capable NW5s to the containers. */ export function planWagonsWithStock(params: { bookings: Booking[]; @@ -221,6 +224,38 @@ export function planWagonsWithStock(params: { const deferred: DeferredBookingRow[] = []; const configIssues = new Set(); + // Scarcity rank: how many distinct demand groups (container types / bulk + // cargo types) among THESE bookings can ride each wagon type. When a cargo + // can choose, it takes the least-shareable type first, keeping versatile + // types (e.g. container-capable NW5) free for the cargo that has no + // alternative. A type nobody else wants ranks 1; unranked types rank 1 too + // (nothing competes for them). + const demandGroups = new Map(); + for (const b of bookings) { + if (b.freightType === 'CONTAINER') { + for (const line of b.bookingContainers ?? []) { + const containerTypeId = line.containerTypeId ?? line.containerType?.id; + if (!containerTypeId) continue; + demandGroups.set( + `C:${containerTypeId}`, + allowed.byContainerTypeId.get(containerTypeId) ?? [], + ); + } + } else { + const cargoTypeId = b.cargoTypeId ?? b.cargoType?.id; + if (cargoTypeId) { + demandGroups.set(`B:${cargoTypeId}`, allowed.byCargoTypeId.get(cargoTypeId) ?? []); + } + } + } + const scarcityRank = new Map(); + for (const types of demandGroups.values()) { + for (const wt of types) { + scarcityRank.set(wt.id, (scarcityRank.get(wt.id) ?? 0) + 1); + } + } + const rankOf = (wt: WagonType): number => scarcityRank.get(wt.id) ?? 1; + const legFor = (booking: Booking): BookingLeg => { const leg = legs?.get(booking.id); if (!leg || leg.from < 0 || leg.to > edgeCount || leg.from >= leg.to) { @@ -277,18 +312,26 @@ export function planWagonsWithStock(params: { kind: SlotLoadType, cargoTypeId: string | null, leg: BookingLeg, + /** Bulk only: the booking's cargo type, for its per-wagon tonnage cap. */ + cargoType?: Booking['cargoType'], ): OpenSlot | PlacementProblem => { const inStock = candidates.filter((wt) => availableFor(wt.id, leg) > 0); if (!inStock.length) { return { kind: 'stock', message: noStockMessage(candidates, leg), candidates }; } - // Bulk favors the largest wagon (fewest wagons for the tonnage); containers + // Least-shareable type first (see scarcityRank) so cargo with alternatives + // never starves cargo without one. Bulk then favors the biggest per-wagon + // take for THIS cargo (its configured cap, not the raw rating); containers // favor the deepest stock so the consist drains evenly. Ties keep config order. + const bulkTakeOf = (wt: WagonType): number => + bulkTonsPerWagon(cargoType, wt.id, Number(wt.capacityTons)); const chosen = [...inStock].sort((a, b) => kind === 'BULK' - ? Number(b.capacityTons) - Number(a.capacityTons) || + ? rankOf(a) - rankOf(b) || + bulkTakeOf(b) - bulkTakeOf(a) || availableFor(b.id, leg) - availableFor(a.id, leg) - : availableFor(b.id, leg) - availableFor(a.id, leg), + : rankOf(a) - rankOf(b) || + availableFor(b.id, leg) - availableFor(a.id, leg), )[0]; const pool = poolOf(leg); const row = usedRow(rowKeyFor(chosen.id, pool)); @@ -298,7 +341,10 @@ export function planWagonsWithStock(params: { teuPerEdge: new Array(edgeCount).fill(0), kind, cargoTypeId, - freeCapacityTons: Number(chosen.capacityTons), + // A bulk wagon fills to the cargo type's configured per-wagon cap + // (Perishable: 20T on PW2, 30T on NW5), never the raw 70T rating. + freeCapacityTons: + kind === 'BULK' ? bulkTakeOf(chosen) : Number(chosen.capacityTons), legKey: legKeyOf(leg), covered: { ...leg }, pool, @@ -411,7 +457,6 @@ export function planWagonsWithStock(params: { message: `Cargo type "${booking.cargoType?.cargoTypeName ?? booking.cargoType?.code ?? 'unknown'}" has no wagon types configured — set them in its configuration before scheduling.`, }; } - const allowedIds = new Set(candidates.map((wt) => wt.id)); // Break-bulk (PER_ITEM): `cargoTotalWeightVgm` is the ITEM COUNT and the // real tonnage lives in `bulkTotalWeightTons` — bookingCargoTons resolves // it either way. Items are indivisible, so a wagon takes whole items only, @@ -423,68 +468,41 @@ export function planWagonsWithStock(params: { const perItemTons = perItem ? remainingWeight / quantity : 0; let remainingItems = perItem ? quantity : 0; - /** Whole items one wagon of this slot's type can still take. */ - const itemRoomOf = (open: OpenSlot): number => - Math.min( - open.freeItems ?? Number.MAX_SAFE_INTEGER, - perItemTons > 0 ? Math.floor(open.freeCapacityTons / perItemTons) : 0, - ); - /** Fresh wagon's whole-item budget: items-fit map floor'd by tonnage. */ + /** Fresh wagon's whole-item budget: items-fit map floor'd by (capped) tonnage. */ const itemBudgetOf = (open: OpenSlot): number => { const fit = bulkItemsFitFor(booking.cargoType, open.slot.wagonTypeId); const byTonnage = perItemTons > 0 - ? Math.max(1, Math.floor(Number(open.slot.capacityTons) / perItemTons)) + ? Math.max(1, Math.floor(open.freeCapacityTons / perItemTons)) : 1; return Math.min(fit ?? Number.MAX_SAFE_INTEGER, byTonnage); }; let placedAnywhere = false; - // Per-item: prefer the type carrying the most whole items per wagon. - // openSlot's own capacity sort is stable, so this order breaks its ties. + // Per-item: least-shareable type first (same scarcity rule as openSlot), + // then the type carrying the most whole items per wagon. const itemBudgetOfType = (wt: WagonType): number => Math.min( bulkItemsFitFor(booking.cargoType, wt.id) ?? Number.MAX_SAFE_INTEGER, perItemTons > 0 - ? Math.max(1, Math.floor(Number(wt.capacityTons) / perItemTons)) + ? Math.max( + 1, + Math.floor( + bulkTonsPerWagon(booking.cargoType, wt.id, Number(wt.capacityTons)) / + perItemTons, + ), + ) : 1, ); const orderedCandidates = perItem - ? [...candidates].sort((a, b) => itemBudgetOfType(b) - itemBudgetOfType(a)) + ? [...candidates].sort( + (a, b) => rankOf(a) - rankOf(b) || itemBudgetOfType(b) - itemBudgetOfType(a), + ) : candidates; - // Top off wagons already carrying THIS cargo type before opening new ones. - // ponytail: per-item cargo only shares wagons that were opened per-item - // (freeItems tracked); mixing itemized and loose loads of one cargo type - // on one wagon is not modeled — open a new wagon instead. - for (const open of openSlots) { - if (perItem ? remainingItems <= 0 : remainingWeight <= 0) break; - if (open.kind !== 'BULK') continue; - if (open.legKey !== legKey) continue; - if (open.cargoTypeId !== cargoTypeId) continue; - if (!allowedIds.has(open.slot.wagonTypeId)) continue; - if (open.freeCapacityTons <= 0) continue; - if (perItem !== (open.freeItems !== undefined)) continue; - const takeItems = perItem ? Math.min(itemRoomOf(open), remainingItems) : 0; - if (perItem && takeItems <= 0) continue; - const take = perItem - ? roundTons(takeItems * perItemTons) - : roundTons(Math.min(open.freeCapacityTons, remainingWeight)); - addAllocation( - open.slot, - booking.id, - booking.reference, - take, - AllocationLoadType.Bulk, - ); - open.freeCapacityTons = roundTons(open.freeCapacityTons - take); - if (perItem) { - open.freeItems = (open.freeItems ?? 0) - takeItems; - remainingItems -= takeItems; - } - remainingWeight = roundTons(remainingWeight - take); - placedAnywhere = true; - } + // One bulk booking per wagon: a wagon carrying bulk takes that one + // booking's cargo only — never topped up from another booking, even of + // the same cargo type. Every bulk booking therefore opens its own wagons. while ((perItem ? remainingItems > 0 : remainingWeight > 0) || !placedAnywhere) { // Per-item: openSlot's stock-depth tie-break would override the fit @@ -498,6 +516,7 @@ export function planWagonsWithStock(params: { 'BULK', cargoTypeId, leg, + booking.cargoType, ); if ('message' in openedSlot) return openedSlot; let take: number; 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..5fde44f2d 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 @@ -1746,6 +1834,7 @@ export const NOTIFICATION_PERMISSIONS: FreightPermissionSeed[] = [ export const ADVANCED_BACKOFFICE_PERMISSIONS: FreightPermissionSeed[] = [ ...REPORT_PERMISSIONS, + ...OVERVIEW_LAYOUT_PERMISSIONS, ...CUSTOMER_PERMISSIONS, ...SHIPPING_LINE_PERMISSIONS, ...CHAT_PERMISSIONS, @@ -1978,8 +2067,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 +2076,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 +2380,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 +2533,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. 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) => ( + )} + + + } /> - {(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 1a52d21c4..883c9e76e 100644 --- a/apps/edr-freight-web/backoffice/src/services/bookings.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/bookings.service.ts @@ -518,13 +518,22 @@ export const bookingsService = { /** Finance raises a new charge — 'draft' just saves it, 'send' also issues the invoice and notifies the customer. */ createAdditionalCharge: async ( id: string, - payload: { reason: string; amount: number; currency: string; action: "draft" | "send"; file?: File | null }, + payload: { + reason: string; + amount: number; + 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(); form.append("reason", payload.reason); 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`, form, { headers: { "Content-Type": "multipart/form-data" }, 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/ReadonlyBookingView.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx index f5906ceea..de1286ffd 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx @@ -1,5 +1,5 @@ -import { Button, Group, Modal, Stack, Tabs, Text } from "@mantine/core"; -import { useMutation } from "@tanstack/react-query"; +import { Button, Group, Modal, Skeleton, Stack, Tabs, Text } from "@mantine/core"; +import { useMutation, useQuery } from "@tanstack/react-query"; import { Clock, CreditCard, @@ -67,6 +67,8 @@ const CUSTOMER_CANCELLABLE_STATUSES = [ "CONTRACT_READY", "OPERATION_REQUEST_PENDING", "SELECTED_FOR_BATCH", + // Parked waiting for a consolidation partner — nothing reserved yet. + "PENDING_CONSOLIDATION", ]; const cancelErrorMessage = (error: unknown) => { @@ -136,6 +138,32 @@ export function ReadonlyBookingView({ const canCancel = booking.paymentStatus !== "PAID" && CUSTOMER_CANCELLABLE_STATUSES.includes(status); + // PAID booking (allocated or not): the same button cancels the WHOLE booking + // through wagon cancellation — a per-wagon fee is invoiced and the paid + // freight becomes a rebooking credit. Blocked once loading starts (server + // enforces; loading flips status past PAID/TRUCK_ASSIGNED). + const canCancelPaid = + booking.paymentStatus === "PAID" && + ["PAID", "TRUCK_ASSIGNED"].includes(status) && + Boolean(booking.contractId); + const [paidCancelOpen, setPaidCancelOpen] = useState(false); + const paidPreview = useQuery({ + queryKey: ["whole-cancel-preview", booking.id], + queryFn: () => bookingsService.previewWagonCancellation(booking.id, {}), + enabled: paidCancelOpen, + }); + const paidCancelMutation = useMutation({ + mutationFn: () => bookingsService.requestWagonCancellation(booking.id, {}), + onSuccess: () => { + setPaidCancelOpen(false); + toast.success( + "Cancellation requested — pay the cancellation fee to settle it. Your paid freight is kept as credit for rebooking.", + { duration: 8000 }, + ); + onBookingUpdated?.(); + }, + onError: (e) => toast.error(cancelErrorMessage(e)), + }); const pricing = booking.pricingBreakdown; // A general contract is paid once it's FULLY_EXECUTED (signed) — it never @@ -206,7 +234,8 @@ export function ReadonlyBookingView({ actions={ (canApproveDelivery || (payables.items.length > 0 && tab !== "payments") || - canCancel) && ( + canCancel || + canCancelPaid) && ( {canApproveDelivery && ( @@ -227,6 +256,14 @@ export function ReadonlyBookingView({ onClick={() => setCancelOpen(true)} /> )} + {canCancelPaid && ( + } + label="Cancel booking" + onClick={() => setPaidCancelOpen(true)} + /> + )} ) } @@ -414,6 +451,7 @@ export function ReadonlyBookingView({ booking.paymentStatus === "PAID" && Boolean(booking.contractId) } + consolidated={Boolean(booking.consolidationPartnerId)} onCancellationRequested={onBookingUpdated} /> @@ -509,6 +547,86 @@ export function ReadonlyBookingView({
+ setPaidCancelOpen(false)} + title={ + + Cancel this booking? + + } + centered + radius={16} + > + + + You're about to cancel the whole booking{" "} + + {booking.reference} + + . A cancellation fee applies per wagon; your paid freight is kept as + a credit you can rebook with once the fee is settled. + {booking.consolidationPartnerId + ? " This booking shares a wagon with another customer — both bookings will be cancelled, and the shared wagon's fee is charged to you, not to them." + : ""} + + {paidPreview.isLoading && } + {paidPreview.data && ( + + + Wagons cancelled: {paidPreview.data.wagons} + + + Cancellation fee:{" "} + + {Number(paidPreview.data.feeAmount).toLocaleString()}{" "} + {paidPreview.data.feeCurrency} + {" "} + ({Number(paidPreview.data.feePerWagon).toLocaleString()} per + wagon) + + + Rebooking credit:{" "} + + {Number(paidPreview.data.creditAmount).toLocaleString()}{" "} + {booking.paymentCurrency} + + + + )} + {paidPreview.isError && ( + + {cancelErrorMessage(paidPreview.error)} + + )} + + + + + + {viewer} ); 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. - - + {`${ft20Total} is an odd number of 20ft containers — this booking will be paired with another customer's odd booking to share a wagon, or held until one is available.`} + + ) : null} + + @@ -1687,18 +1684,16 @@ function CargoStep({ if (ft20 % 2 !== 1) return null; return ( } title={`Odd number of 20ft containers (${ft20})`} > - 20ft containers travel two per wagon, so they must be booked - in even numbers. Please add one more 20ft container or remove - one (e.g. book {ft20 + 1} or {ft20 - 1} instead of {ft20}) — - the booking cannot be submitted with an unpaired 20ft - container. + 20ft containers travel two per wagon. This booking will be + paired with another customer's odd booking to share a + wagon, or held until one is available. ); diff --git a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentRequestPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentRequestPage.tsx index b69c83b05..76f568f5d 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentRequestPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentRequestPage.tsx @@ -106,15 +106,14 @@ export default function NewShipmentRequestPage() { contract.cargoScope?.[0]; const isPerItem = bulkScope?.cargoType?.unitOfMeasure === "PER_ITEM"; - // 20ft containers ride two per wagon, so an odd total leaves one unpaired and - // the request cannot be planned. Consolidation (pairing the odd container with - // another customer's odd booking) is built but switched off for now, so an odd - // request is blocked here rather than dead-ending downstream. + // 20ft containers ride two per wagon. An odd total no longer blocks the + // request — the server auto-pairs it with another customer's odd booking, or + // parks it as PENDING_CONSOLIDATION until one shows up (same consolidation + // gate the direct-booking flow already uses). const ft20Requested = isContainer ? Number(qtyBySize["20ft"]) || 0 : 0; const hasOdd20ft = ft20Requested % 2 === 1; const handleSubmit = () => { - if (hasOdd20ft) return; const dto: Freight.CreateBookingRequestDto = { contractRouteId: route?.id, scheduledDate: hasCustoms ? undefined : scheduledDate || undefined, @@ -221,17 +220,16 @@ export default function NewShipmentRequestPage() { {hasOdd20ft ? ( } title={`Odd number of 20ft containers (${ft20Requested})`} > - 20ft containers travel two per wagon, so they must be requested - in even numbers. Please add one more 20ft container or remove - one (e.g. request {ft20Requested + 1} or {ft20Requested - 1}{" "} - instead of {ft20Requested}). + 20ft containers travel two per wagon. This request will be + paired with another customer's odd booking to share a + wagon, or held until one is available. ) : null} @@ -282,7 +280,6 @@ export default function NewShipmentRequestPage() { leftSection={} loading={submit.isPending} onClick={handleSubmit} - disabled={hasOdd20ft} > Submit shipment request diff --git a/apps/edr-freight-web/portal/src/services/bookings.service.ts b/apps/edr-freight-web/portal/src/services/bookings.service.ts index 6312c8738..29614419c 100644 --- a/apps/edr-freight-web/portal/src/services/bookings.service.ts +++ b/apps/edr-freight-web/portal/src/services/bookings.service.ts @@ -7,6 +7,19 @@ import { client } from "../utils/api"; const B = URL_CONSTANTS.BOOKINGS; +export interface EmptyContainerReturn { + id: string; + containerNumber: string; + containerSize: "20" | "40" | null; + returnDate: string; + facility: string | null; + yard: string | null; + zone: string | null; + condition: string | null; + status: string; + returnedBy: "EDR" | "CUSTOMER" | null; +} + export interface MileVehicleSummary { plate: string | null; code: string | null; @@ -204,6 +217,8 @@ export interface BookingWagonContainer { sealNumber: string | null; positionOnWagon: number | null; grossWeightTons: string | null; + /** Container size in feet (20/40) — identifies the shared consolidation wagon. */ + sizeFt: number | null; } /** One allocated wagon of a booking, as returned by GET /bookings/:id/wagons. */ @@ -383,6 +398,19 @@ export const bookingsService = { ); return data.data ?? data; }, + listEmptyContainerReturns: async (bookingId: string): Promise => { + const { data } = await client.get( + `/api/import-operations/bookings/${bookingId}/empty-container-returns`, + ); + return data.data ?? data; + }, + downloadEquipmentInterchangeDocument: async (returnId: string): Promise => { + const { data } = await client.get( + `/api/import-operations/empty-container-returns/${returnId}/document`, + { responseType: "blob" }, + ); + return data; + }, downloadBookingGrnDocument: async (bookingId: string): Promise => { const { data } = await client.get( `/api/warehouse-inventory/bookings/${bookingId}/grn-document`, @@ -597,6 +625,13 @@ export const bookingsService = { return data; }, + downloadCarriageAcceptanceSheet: async (id: string): Promise => { + const { data } = await client.get(B.CARRIAGE_ACCEPTANCE_SHEET(id), { + responseType: "blob", + }); + return data; + }, + checkPayment: async (orderId: string): Promise<{ status: string }> => { const { data } = await client.post(`/api/payments/bookings/check-payment/${orderId}`); return data.data ?? data; diff --git a/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.dto.ts b/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.dto.ts index 4b06e906e..763f4a4d2 100644 --- a/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.dto.ts +++ b/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.dto.ts @@ -8,6 +8,10 @@ export class LogExcessBaggageDto { @IsOptional() @IsString() bookingReference?: string; @ApiPropertyOptional({ example: 'agent-uuid', description: 'Injected from IAM token; optional override' }) @IsOptional() @IsString() agentId?: string; + @ApiPropertyOptional({ example: '+251911223344', description: 'Override the phone the payment link SMS should go to. Defaults to the booking contact phone.' }) + @IsOptional() @IsString() contactPhone?: string; + @ApiPropertyOptional({ example: 'passenger@example.com', description: 'Override the email the payment link should also be sent to. Defaults to the booking contact email.' }) + @IsOptional() @IsString() contactEmail?: string; @ApiProperty({ example: 7, description: 'Excess weight in kg above the free allowance' }) @IsInt() @IsPositive() excessWeightKg: number; @ApiPropertyOptional({ description: 'Collect cash now instead of sending a payment link' }) diff --git a/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.service.ts b/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.service.ts index 7ccc0af06..a8eee0028 100644 --- a/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.service.ts +++ b/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.service.ts @@ -115,8 +115,8 @@ export class ExcessBaggageService { const totalMinor = feePerKgMinor * dto.excessWeightKg; const expiresAt = new Date(Date.now() + CHARGE_TTL_MS); - const contactPhone = booking.contactPhone ?? booking.passenger?.user?.phone ?? null; - const contactEmail = booking.contactEmail ?? booking.passenger?.user?.email ?? null; + const contactPhone = dto.contactPhone?.trim() || (booking.contactPhone ?? booking.passenger?.user?.phone ?? null); + const contactEmail = dto.contactEmail?.trim() || (booking.contactEmail ?? booking.passenger?.user?.email ?? null); const status = dto.collectCash ? 'CASH_COLLECTED' : 'PENDING'; const paidAt = dto.collectCash ? new Date() : null; diff --git a/apps/edr-passenger-api/src/modules/payments/payments.controller.ts b/apps/edr-passenger-api/src/modules/payments/payments.controller.ts index 847ccf2c7..956de8e06 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.controller.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.controller.ts @@ -49,6 +49,8 @@ class CreateSupplementaryChargeDto { @ApiProperty({ example: 'EDR-20240001', description: 'Booking reference number' }) @IsString() bookingRef: string; @ApiProperty({ description: 'Amount owed in minor units (e.g. 5000 = 50 ETB)' }) @IsInt() @Min(1) amountMinor: number; @ApiProperty({ example: 'UNDERPAYMENT' }) @IsString() reason: string; + @ApiPropertyOptional({ description: 'Override the phone the payment link SMS should go to. Falls back to the booking contact phone.', example: '+251911223344' }) @IsOptional() @IsString() contactPhone?: string; + @ApiPropertyOptional({ description: 'Override the email the payment link should also be sent to. Falls back to the booking contact email.', example: 'passenger@example.com' }) @IsOptional() @IsString() contactEmail?: string; @ApiPropertyOptional() @IsOptional() @IsString() notes?: string; } diff --git a/apps/edr-passenger-api/src/modules/payments/supplementary-charges.service.ts b/apps/edr-passenger-api/src/modules/payments/supplementary-charges.service.ts index 714b02170..184ff69ad 100644 --- a/apps/edr-passenger-api/src/modules/payments/supplementary-charges.service.ts +++ b/apps/edr-passenger-api/src/modules/payments/supplementary-charges.service.ts @@ -52,6 +52,8 @@ export class SupplementaryChargesService { amountMinor: number; reason: string; notes?: string; + contactPhone?: string; + contactEmail?: string; createdBy: string; }) { const booking = await this.prisma.booking.findUnique({ @@ -76,8 +78,8 @@ export class SupplementaryChargesService { }, }); - const phone = booking.contactPhone ?? booking.passenger?.user?.phone ?? null; - const email = booking.contactEmail ?? booking.passenger?.user?.email ?? null; + const phone = dto.contactPhone?.trim() || (booking.contactPhone ?? booking.passenger?.user?.phone ?? null); + const email = dto.contactEmail?.trim() || (booking.contactEmail ?? booking.passenger?.user?.email ?? null); await this.sendLink(charge, booking.bookingRef, phone, email); await this.auditService.log({ 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/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)} + /> +