Merge pull request #1384 from Tria-plc/dev

dev
This commit is contained in:
marshal
2026-08-22 02:36:04 +03:00
committed by GitHub
98 changed files with 4703 additions and 1587 deletions

BIN
EDR-Freight-User-Guide.pdf Normal file

Binary file not shown.

View File

@@ -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<void> {
await queryRunner.query(`
ALTER TABLE "freight"."additional_charge"
ADD COLUMN IF NOT EXISTS "due_at" timestamptz
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE "freight"."additional_charge" DROP COLUMN IF EXISTS "due_at"
`);
}
}

View File

@@ -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<Invoice>,
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<Record<string, number>> {
const qb = this.dataSource
.getRepository(Invoice)

View File

@@ -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<string, string>) => {
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"]);
});
});

View File

@@ -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<string, string> = {
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";
}

View File

@@ -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;
}
}
}

View File

@@ -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.

View File

@@ -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<void> {
// 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<Booking> {
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<Booking> => {
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,

View File

@@ -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<BookingWagonCancellation> {
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<BookingWagonCancellation> {
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<BookingWagonCancellation> {
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<RequestedCut> {
if (booking.freightType === 'CONTAINER') {
const lines = await this.dataSource.getRepository(BookingContainer).find({
where: { bookingId: booking.id },
});
const bySize = new Map<string, number>();
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<void> {
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) {

View File

@@ -596,6 +596,21 @@ export class BookingsRepository extends BaseRepository<Booking> {
} 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<void> {
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<void> {
await this.repository.update(bookingId, {

View File

@@ -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

View File

@@ -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 {

View File

@@ -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;

View File

@@ -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<Company> {
* 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<Company> {
type,
kind,
status,
nationality,
createdFrom,
createdTo,
onboardingCompleted,
hasPendingChangeRequest,
sortBy = 'review',
@@ -122,6 +108,18 @@ export class CompaniesRepository extends BaseRepository<Company> {
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

View File

@@ -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
)`;

View File

@@ -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; " +

View File

@@ -2458,22 +2458,12 @@ export class ContractBookingService {
private async assert20ftPairableAtCreate(
dto: CreateBookingUnderContractDto,
): Promise<void> {
// 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) =>

View File

@@ -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.

View File

@@ -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}%`,

View File

@@ -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}%` });

View File

@@ -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<void> {
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);
}
}

View File

@@ -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],

View File

@@ -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<ImportCustomsFinalization>,
@InjectRepository(EmptyContainerReturn)
private readonly emptyReturns: Repository<EmptyContainerReturn>,
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<EmptyContainerReturn> {
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
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]) =>
`<tr><th>${esc(label)}</th><td>${esc(value)}</td></tr>`,
)
.join('');
return `<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>Equipment Interchange Receipt</title>
<style>
@page { size: A4; margin: 14mm; }
* { box-sizing: border-box; }
body { margin: 0; color: #0f172a; font-family: Arial, sans-serif; }
.top { display: flex; justify-content: space-between; align-items: flex-start; border-bottom: 3px solid #0f766e; padding-bottom: 12px; gap: 24px; }
.brand { font-size: 11px; color: #475569; text-transform: uppercase; letter-spacing: .08em; font-weight: 700; }
h1 { margin: 6px 0 0; font-size: 22px; line-height: 1.1; }
.meta { text-align: right; font-size: 11px; color: #475569; }
.meta strong { display: block; margin-top: 4px; color: #0f172a; font-size: 15px; }
${logoImageCss()}
table { width: 100%; border-collapse: collapse; margin-top: 20px; }
th, td { border: 1px solid #cbd5e1; padding: 8px 10px; font-size: 11.5px; text-align: left; vertical-align: top; }
th { width: 220px; background: #f8fafc; color: #475569; font-weight: 700; }
.notice { margin-top: 16px; border-left: 4px solid #0f766e; background: #f0fdfa; padding: 10px 12px; font-size: 10.5px; color: #134e4a; }
.signatures { display: grid; grid-template-columns: repeat(2, 1fr); gap: 24px; margin-top: 40px; }
.line { border-top: 1px solid #334155; padding-top: 8px; font-size: 10px; color: #475569; min-height: 40px; }
</style>
</head>
<body>
<div class="top">
<div>
${logoMarkup(opts.logoImageUrl)}
<div class="brand">Ethio-Djibouti Railway S.C.</div>
<h1>Equipment Interchange Receipt</h1>
</div>
<div class="meta">
Receipt No.
<strong>${esc(`EIR-${row.id.slice(0, 8).toUpperCase()}`)}</strong>
Generated: ${esc(new Date().toLocaleString('en-GB'))}
</div>
</div>
<table>
<tbody>
${rowsHtml}
</tbody>
</table>
<div class="notice">
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.
</div>
<div class="signatures">
<div class="line">Depot officer name / signature / date</div>
<div class="line">Customer or driver name / signature / date</div>
</div>
</body>
</html>`;
}
private async getOrCreateCustoms(bookingId: string) {
const existing = await this.customs.findOne({ where: { bookingId } });
if (existing) return existing;

View File

@@ -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()

View File

@@ -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 (OctDec
* and the 56 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<TargetDimension, string> = {
};
export const TARGET_PERIOD_LABELS: Record<TargetPeriodType, string> = {
day: 'Daily',
week: 'Weekly',
month: 'Monthly',
quarter: 'Quarterly',
half_year: 'Half-yearly',
nine_month: 'Nine-monthly',
ninety_day: '90-day',
year: 'Yearly',
};

View File

@@ -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],

View File

@@ -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');
// OctDec 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([]);
});
});

View File

@@ -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: JanSep, then a short OctDec.
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<string, Map<string, string>> = {
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<Exclude<TargetDimension, 'station'>, Set<string>> = {
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<OperationsTarget> {
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<OperationsTarget> {
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<TargetSlot> {
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<void> {
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<void> {
await this.findById(id);
await this.repository.softDelete(id);

View File

@@ -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:<key>: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;
}

View File

@@ -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' })

View File

@@ -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<ObjectLiteral>,
): SelectQueryBuilder<ObjectLiteral> {
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' },
];
},
};

View File

@@ -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')

View File

@@ -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')

View File

@@ -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<ObjectLiteral> {
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) },
];
},
};

View File

@@ -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<ObjectLiteral> {
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) },
];
},
};

View File

@@ -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<ObjectLiteral> {
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' },
];
},
};

View File

@@ -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<ObjectLiteral> {
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' },
];
},
};

View File

@@ -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\./);
}
});
});

View File

@@ -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<ObjectLiteral>,
ctx: ReportContext,
dateExpr: string,
): SelectQueryBuilder<ObjectLiteral> {
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<ObjectLiteral> {
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<ObjectLiteral> {
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<ObjectLiteral> {
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<ObjectLiteral> {
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) },
];
},
};

View File

@@ -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, unknown>): 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<ObjectLiteral> {
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 },
];

View File

@@ -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<ObjectLiteral> {
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 },
];
},
};

View File

@@ -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<ObjectLiteral> {
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' },
];
},
};

View File

@@ -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<ObjectLiteral> {
}
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) },
];
},
};

View File

@@ -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) {

View File

@@ -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<string, unknown>,
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<string, unknown>,

View File

@@ -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<ReportKey, ReportDefinition>(REPORTS.map((r) => [r.key, r]));
const BY_KEY = new Map<ReportKey, ReportDefinition>(
REPORTS.map((r) => [r.key, r]),
);
export function getReport(key: string): ReportDefinition | undefined {
return BY_KEY.get(key as ReportKey);

View File

@@ -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)');
});
});

View File

@@ -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 JanSep plus
* a short OctDec. 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<string, PeriodUnit> = {
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, unknown>): string {
return periodExprOn(REVENUE_DATE, params);
}
export function resolvePeriod(
params: Record<string, unknown>,
): (typeof PERIOD_UNITS)[keyof typeof PERIOD_UNITS] {
const key = String(params.period ?? '') as keyof typeof PERIOD_UNITS;
export function resolvePeriod(params: Record<string, unknown>): 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, unknown>): string =>
`to_char(${periodTruncExprOn(dateExpr, params)}, '${resolvePeriod(params).fmt}')`;
resolvePeriod(params).labelOn(periodTruncExprOn(dateExpr, params));
export const periodTruncExprOn = (dateExpr: string, params: Record<string, unknown>): 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, unknown>): string =>
@@ -298,9 +384,16 @@ export const periodTruncExpr = (params: Record<string, unknown>): string =>
export const periodOrdinalExpr = (params: Record<string, unknown>): 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, unknown>): 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

View File

@@ -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);
});
});

View File

@@ -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<boolean> {
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<typeof x> => 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<Capacity | null> {
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<void> {
// 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<string>();
const swept = new Set<string>();
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<string, string[]>;
byContainerTypeId: Map<string, string[]>;
},
): Map<string, number> {
const groups = new Map<string, string[]>();
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<string, number>();
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<string, number>,
): { 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<string, string[]>;
byContainerTypeId: Map<string, string[]>;

View File

@@ -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<string, number>,
) =>
(
service as unknown as {
smartBulkNeed: (
b: Booking,
d: unknown,
s: WagonStockLedger,
l: { fromEdge: number; toEdge: number },
r: Map<string, number>,
) => { 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 }]);
});
});

View File

@@ -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({

View File

@@ -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', () => {

View File

@@ -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;
}

View File

@@ -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);
}
});
});

View File

@@ -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<string>();
// 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<string, WagonType[]>();
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<string, number>();
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<number>(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;

View File

@@ -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;
}

View File

@@ -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(

View File

@@ -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<typeof FREIGHT_PERMS.overview.layout>[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")] },
];

View File

@@ -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<OverviewLayoutKey, string> = {
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.

View File

@@ -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}'`,

View File

@@ -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}` : ""}
</Text>
)}
{charge.dueAt && charge.status !== "PAID" && charge.status !== "CANCELLED" && (
<Text fz="11.5px" c="dimmed">
Due {formatDate(charge.dueAt)}
</Text>
)}
</Box>
</Group>
<Group gap={8} wrap="nowrap">
<Text fz="14px" fw={800} c="edr-text">
{charge.amount.toLocaleString(undefined, { minimumFractionDigits: 2 })}{" "}
{charge.currency}
</Text>
<Badge variant="light" color={meta.color} radius="sm">
{meta.label}
</Badge>
<Group gap={8} wrap="nowrap" align="flex-end" style={{ flexDirection: "column" }}>
<Group gap={8} wrap="nowrap">
<Text fz="14px" fw={800} c="edr-text">
{charge.amount.toLocaleString(undefined, { minimumFractionDigits: 2 })}{" "}
{charge.currency}
</Text>
<Badge variant="light" color={meta.color} radius="sm">
{meta.label}
</Badge>
</Group>
{charge.convertedAmount != null && (
<Text fz="11.5px" c="dimmed">
{charge.convertedAmount.toLocaleString(undefined, { minimumFractionDigits: 2 })}{" "}
{charge.convertedCurrency}
</Text>
)}
</Group>
</Group>
@@ -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<number | string>("");
const [currency, setCurrency] = useState("ETB");
const [file, setFile] = useState<File | null>(null);
const [dueDate, setDueDate] = useState<Date | null>(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}
/>
</Group>
<DateInput
label="Due date"
placeholder="Defaults to 14 days after sending"
value={dueDate}
onChange={(v) => setDueDate(v ? new Date(v) : null)}
minDate={new Date()}
clearable
/>
<FileButton onChange={setFile} accept="application/pdf,image/*">
{(props) => (
<Button

View File

@@ -873,15 +873,10 @@ export default function GlCreateBookingForm() {
// Only a customs (Path B) instance being COMPLETED by GL can use the shared
// wagon: it is GL, not the customer, who links the two bookings. Anything else
// keeps the historical hard block on odd 20ft.
//
// Switched OFF for now: consolidation is built end to end (toggle, parent
// picker, split entry, paired pricing, approval gate) but not in use, so an
// odd 20ft total is rejected outright instead of offering the shared wagon.
// Drop the `false &&` to bring the whole flow back.
const oddConsolidationAvailable =
false &&
Boolean(completeBookingId && isContainer && contract?.customsClearingEnabled);
// falls through to the server's automatic consolidation gate.
const oddConsolidationAvailable = Boolean(
completeBookingId && isContainer && contract?.customsClearingEnabled,
);
// Auto-on: entering an odd 20ft total opens the consolidation panel by itself,
// once. GL can still switch it off — then odd is blocked exactly as before.
@@ -970,11 +965,11 @@ export default function GlCreateBookingForm() {
!cargoDescriptionError
: !bulkErrors.quantity && !bulkErrors.hazardous && !bulkErrors.reefer;
// Consolidation (sharing the wagon with another customer's odd booking) is
// built but switched off for now, so an odd 20ft total always blocks — the
// shared wagon no longer resolves the unpaired container. Flip this back to
// `hasOdd20ft && !consolidationActive` to re-enable the shared-wagon path.
const oddBlocksSubmit = hasOdd20ft;
// COMPLETION never blocks on an odd 20ft total: a customs instance can share
// the wagon via the manual pair (consolidationActive), and anything else is
// auto-paired or parked as PENDING_CONSOLIDATION by the server's
// consolidation gate. Creating a booking from scratch keeps the block.
const oddBlocksSubmit = hasOdd20ft && !completeBookingId;
// Partner side: a linked partner must be picked, carry an odd 20ft count of
// its own (odd + odd = even fills the wagon) and have complete unit details.

View File

@@ -1,6 +1,3 @@
import type { AuthUser } from "@/auth/types";
import { getPositionKeys } from "@/lib/permissions";
/** One overview composition. Every backoffice user lands on exactly one of these. */
export type OverviewLayoutKey =
| "executive"
@@ -20,80 +17,35 @@ export const OVERVIEW_LAYOUT_LABEL: Record<OverviewLayoutKey, string> = {
};
/**
* Position/role key → layout, in match priority order: a user holding several
* of these keys gets the first match, so the specific operational view wins
* over the broad executive one. Roles are matched alongside positions because
* the IAM payload models the GL desks as positions (`ethiopian_gl`) on some
* accounts and as roles (`edr_gl_ethiopia`) on others — see `getPositionKeys`.
*
* The `edr_freight_app/…` keys are the org's real position keys (root desks and
* their sub-positions) as configured under Unit → Departments. They are typed
* by hand in the Add/Edit Department form, so a new sub-position appears here
* only once someone adds it — unmapped keys fall through to `executive`.
* Priority order: a caller who holds more than one of the six
* `edr_freight_app:overview:<key>:view` permissions gets the FIRST match
* here — the specific operational view wins over the broad executive one.
* Mirrors `OVERVIEW_LAYOUT_KEYS` in the API's freight-permissions.registry.ts
* bit for bit; keep the two in sync if this ever changes.
*/
const ROLE_LAYOUTS: Array<[key: string, layout: OverviewLayoutKey]> = [
// ── Clearance & logistics: both GL desks, root and sub-positions ──────────
["ethiopian_gl", "clearance"],
["edr_freight_app/gl_003", "clearance"], // Ethiopian GL Chief
["edr_freight_app/off_001", "clearance"], // Ethiopian GL Director
["edr_freight_app/off_0056", "clearance"], // Ethiopian GL Officer
["djibouti_gl", "clearance"],
["edr_freight_app/dj_gl_001", "clearance"], // Djibouti GL Director
["edr_freight_app/dj_gl_002", "clearance"], // Djibouti GL Chief
["edr_freight_app/dj_gl_003", "clearance"], // Djibouti GL Officer
["edr_gl_ethiopia", "clearance"], // legacy role form
["edr_gl_djibouti", "clearance"], // legacy role form
// ── Control centre ───────────────────────────────────────────────────────
["edr_freight_app/occ_001", "occ"], // OCC
["edr_freight_app/occ_005", "occ"], // OCC Director
["edr_line_staff", "occ"], // legacy role form
// ── Operations: operations desk, track & machinery, rolling stock ─────────
["edr_freight_app/opn", "operation"], // Operation
["edr_freight_app/opcf", "operation"], // Operation Chief
["edr_freight_app/opdr", "operation"], // Operation Director
["edr_freight_app/opco", "operation"], // Operation Officer
["edr_freight_app/opp_005", "operation"], // Operation Dispatcher
["edr_freight_app/opp_0067", "operation"], // Gelan Operation Director
["edr_freight_app/track_001", "operation"], // Track And Machinery
["edr_freight_app/ttk_001", "operation"], // Track Director
["edr_freight_app/tto_001", "operation"], // Track Operator
["edr_freight_app/rool_001", "operation"], // Rolling Stock
["edr_freight_app/rl_003", "operation"], // Rolling Stock Director
["edr_freight_app/rl_009", "operation"], // Rolling Stock Team Lead
["edr_freight_app/rl_0090", "operation"], // Rolling Stock Dispatcher
["operation", "operation"],
["operations_chief", "operation"],
["dispatcher", "operation"],
["truck_machinery_chief", "operation"],
["edr_operations_officer", "operation"], // legacy role form
// ── Marketing ────────────────────────────────────────────────────────────
["edr_freight_app/edr_test_org_0022", "marketer"], // Commercial Marketing
["edr_freight_app/edr_test_org_00567", "marketer"], // Marketing Director
["edr_freight_app/edr_test_org_0054", "marketer"], // Marketing Chief
["edr_freight_app/edr_test_org_0013", "marketer"], // Marketing Officer
["marketer", "marketer"],
["edr_marketing", "marketer"], // legacy role form
// ── Finance ──────────────────────────────────────────────────────────────
["edr_freight_app/finance", "finance"],
["edr_finance", "finance"], // legacy role form
// ── Executive: org-wide desks with no operational queue of their own ──────
["ceo", "executive"],
["director", "executive"],
["chief", "executive"],
["edr_ceo", "executive"], // legacy role form
["edr_director", "executive"], // legacy role form
["edr_org_manager", "executive"], // legacy role form
const LAYOUT_PRIORITY: OverviewLayoutKey[] = [
"clearance",
"occ",
"operation",
"marketer",
"finance",
"executive",
];
/** Unmapped keys (superadmin, IAM admins, Safety, new positions) keep the executive layout. */
/**
* Which layout to render, given the keys `GET /overview/layouts` said the
* caller may see — the endpoint already filtered those by permission, so
* this only breaks the tie when a caller holds more than one. Same shape as
* the Reports page trusting `GET /reports`'s catalog rather than re-deriving
* access from permission keys client-side.
*
* Empty/unmapped falls back to the executive layout — same default the old
* role/position-key table used for superadmin, IAM admins, and any position
* that hasn't been granted one of these permissions yet.
*/
export function resolveOverviewLayout(
user: AuthUser | null | undefined,
allowed: OverviewLayoutKey[] | undefined,
): OverviewLayoutKey {
const held = new Set(getPositionKeys(user));
return ROLE_LAYOUTS.find(([key]) => held.has(key))?.[1] ?? "executive";
const held = new Set(allowed ?? []);
return LAYOUT_PRIORITY.find((key) => held.has(key)) ?? "executive";
}

View File

@@ -250,7 +250,7 @@ function WagonCar({
<HoverCard.Target>
<Box
onClick={() => onSelectSlot(loaded[0] ?? wagon)}
style={{ width: 120, flexShrink: 0, cursor: "pointer" }}
style={{ width: 148, flexShrink: 0, cursor: "pointer" }}
>
<Box
onDragOver={(e) => {
@@ -270,7 +270,9 @@ function WagonCar({
}}
style={{
position: "relative",
height: 70,
// A leg-sharing wagon stacks its loads (bulk and container rows
// top/bottom) — give the stack real height so both stay legible.
height: shared ? 88 : 70,
borderRadius: 11,
background: isEmpty
? "var(--mantine-color-gray-0)"
@@ -393,7 +395,7 @@ function WagonCar({
);
const rowBlocks = wagonItems(slot).slice(0, 2);
const rowSelected = shared && slot.id === selectedWagonId;
const rowHeight = shared ? 13 : 26;
const rowHeight = shared ? 20 : 26;
return (
<Group
key={slot.id}

View File

@@ -235,6 +235,7 @@ export const QUERY_KEYS = {
OVERVIEW: {
ROOT: ["overview"] as const,
layouts: () => ["overview", "layouts"] as const,
dashboard: (range?: string) =>
["overview", "dashboard", range ?? "30d"] as const,
bookingsTab: (range?: string) =>

View File

@@ -184,6 +184,7 @@ export const URL_CONSTANTS = {
OVERVIEW: {
BASE: "/overview",
LAYOUTS: "/overview/layouts",
BOOKINGS: "/overview/bookings",
CONTRACTS: "/overview/contracts",
BILLING: "/overview/billing",

View File

@@ -11,6 +11,15 @@ export function useOverview(range: OverviewRange = "30d") {
});
}
/** Layouts the caller may render — server-filtered by permission, same shape as useReports' catalog. */
export function useOverviewLayouts() {
return useQuery({
queryKey: QUERY_KEYS.OVERVIEW.layouts(),
queryFn: () => overviewService.getLayouts(),
staleTime: 5 * 60 * 1000,
});
}
export function useOverviewBookingsTab(range: OverviewRange, enabled: boolean) {
return useQuery({
queryKey: QUERY_KEYS.OVERVIEW.bookingsTab(range),

View File

@@ -4,7 +4,6 @@ import {
Box,
Card,
Group,
SegmentedControl,
Stack,
Text,
Tooltip,
@@ -22,7 +21,7 @@ import {
ShieldOff,
Users,
} from "lucide-react";
import { useMemo, useState } from "react";
import { useMemo } from "react";
import { useNavigate } from "react-router-dom";
import {
@@ -31,49 +30,22 @@ import {
ManualRegistrationBadge,
ProfileChips,
formatDate,
humanize,
} from "@/components/customers";
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
import { api } from "@/services/api";
import type { Company, CompanyStatus } from "@/types/customer";
import type { Company, CompanyListFilter } from "@/types/customer";
import { isOnboardingDraft } from "@/types/customer";
import { DataTable, DataTableFooter, type ColumnDef } from "@edr/ui-common";
import { FilterBar, useFilters, type FilterDef } from "@/components/filters";
import {
FilterBar,
dateRangeParams,
isoToLocalDateStr,
useFilters,
type FilterDef,
} from "@/components/filters";
import { ExportButton } from "@/components/export/ExportButton";
/**
* The list's segmented views. "Pending approval" means submitted-and-awaiting-
* review, so it excludes drafts — a company row exists from the onboarding
* wizard's first click and would otherwise pad the review queue. Those drafts
* get their own view instead of disappearing, so staff can still chase them.
*/
type CustomerView =
| "all"
| "pending"
| "pendingChanges"
| "onboarding"
| "active";
/**
* "Pending changes" is deliberately not folded into "Pending approval". A
* customer who edits their profile after being approved stays `status = active`,
* so the pending filter can never match them — their resubmission would only
* ever be visible by opening their detail page. This view is that queue.
*/
const VIEW_FILTERS: Record<
CustomerView,
{
status?: CompanyStatus;
onboardingCompleted?: boolean;
hasPendingChangeRequest?: boolean;
}
> = {
all: {},
pending: { status: "pending", onboardingCompleted: true },
pendingChanges: { hasPendingChangeRequest: true },
onboarding: { onboardingCompleted: false },
active: { status: "active" },
};
const SORT_OPTIONS = [
// Queue ordering: awaiting first approval → pending profile changes → the
// rest, newest first within each group. The default, so whatever marketing
@@ -85,29 +57,110 @@ const SORT_OPTIONS = [
{ value: "name:DESC", label: "Name (ZA)" },
] as const;
/** No filter pills — search/sort/page are the only real filter dimensions;
* `view` below is a tab (mutually exclusive, navigational), not a filter. */
const NO_FILTER_DEFS: FilterDef[] = [];
/**
* Every state a customer can be in, as one single-select list.
*
* Three of these are not `companies.status` values at all, which is why each
* option maps its own params:
* - **Pending approval** is submitted-and-awaiting-review, so it excludes
* drafts — a company row exists from the onboarding wizard's first click and
* would otherwise pad the review queue.
* - **Onboarding** is that draft: still in the portal wizard, never submitted.
* - **Pending changes** is an already-approved (`active`) customer who edited
* their profile. `status` can never match them, so without this option their
* resubmission is only visible by opening their detail page.
*/
const STATUS_OPTIONS: {
value: string;
label: string;
params: Record<string, string>;
}[] = [
{ value: "pending", label: "Pending approval", params: { status: "pending", onboardingCompleted: "true" } },
{ value: "pendingChanges", label: "Pending changes", params: { hasPendingChangeRequest: "true" } },
{ value: "onboarding", label: "Onboarding", params: { onboardingCompleted: "false" } },
{ value: "active", label: "Active", params: { status: "active" } },
{ value: "suspended", label: "Suspended", params: { status: "suspended" } },
{ value: "blacklisted", label: "Blacklisted", params: { status: "blacklisted" } },
];
/**
* Filter pills. The review queues that used to sit beside them as segmented
* tabs are folded into the Status pill above — three of the five were never a
* plain `status` value, so as a separate tab strip they could contradict the
* status filter next to them. One list, mutually exclusive, no contradiction.
*/
const CUSTOMER_FILTER_DEFS: FilterDef[] = [
{
key: "status",
label: "Status",
type: "enum",
multiple: false,
options: STATUS_OPTIONS.map(({ value, label }) => ({ value, label })),
toParams: (v) =>
STATUS_OPTIONS.find((o) => o.value === v.v[0])?.params ?? {},
},
{
key: "type",
label: "Type",
type: "enum",
multiple: false,
options: (
["customer", "freight_forwarder", "dj_freight_forwarder", "transporter"] as const
).map((value) => ({ value, label: humanize(value) })),
},
{
key: "kind",
label: "Sector",
type: "enum",
multiple: false,
options: [
{ value: "commercial", label: "Commercial" },
{ value: "government", label: "Government" },
],
},
{
key: "nationality",
label: "Nationality",
type: "enum",
multiple: false,
options: [
{ value: "ethiopian", label: "Ethiopian" },
{ value: "foreign", label: "Foreign" },
],
},
{
key: "created",
label: "Registered",
type: "date",
secondary: true,
operators: ["between", "before", "after"],
toParams: dateRangeParams("createdFrom", "createdTo"),
},
];
export default function CustomersPage() {
const navigate = useNavigate();
const [view, setView] = useState<CustomerView>("all");
const controls = useFilters(NO_FILTER_DEFS, { defaultSort: "review:DESC", pageSize: 10 });
const controls = useFilters(CUSTOMER_FILTER_DEFS, {
defaultSort: "review:DESC",
pageSize: 10,
});
const filter = useMemo(() => {
const [sortBy, sortOrder] = controls.sort.split(":") as [
"review" | "name" | "createdAt" | "updatedAt",
"ASC" | "DESC",
];
return {
page: controls.page,
pageSize: controls.pageSize,
search: String(controls.params.search ?? ""),
sortBy,
sortOrder,
...VIEW_FILTERS[view],
};
}, [controls.page, controls.pageSize, controls.params.search, controls.sort, view]);
// `controls.params` is the whole query: page/pageSize/search, the split
// sortBy/sortOrder, and every pill's mapped params.
const filter = controls.params as unknown as CompanyListFilter;
/**
* The export's `daterange` filters are coerced from calendar days while the
* list takes ISO instants — hand the dialog the local day each bound falls on
* so the file covers the same range the screen shows.
*/
const exportParams = useMemo(() => {
const out: Record<string, unknown> = { ...controls.params };
for (const key of ["createdFrom", "createdTo"]) {
if (typeof out[key] === "string") out[key] = isoToLocalDateStr(out[key] as string);
}
return out;
}, [controls.params]);
const { data: stats } = useQuery(
api.customers.stats.queryOptions({ input: {} }),
@@ -293,33 +346,13 @@ export default function CustomersPage() {
<Stack gap={0}>
<Box px="md" pt="md" pb="sm" w="100%">
<FilterBar
defs={NO_FILTER_DEFS}
defs={CUSTOMER_FILTER_DEFS}
controls={controls}
searchPlaceholder="Search by company, TIN, email or profile reference…"
sortOptions={SORT_OPTIONS.map((o) => ({ ...o }))}
viewId="customers"
>
<SegmentedControl
size="sm"
radius="md"
value={view}
onChange={(v) => {
// `view` lives outside useFilters (it's a tab, not a
// filter pill), so switching it needs its own page reset —
// the same "stranded on page 5" hazard useFilters guards
// against for its own filters.
setView(v as CustomerView);
controls.setPage(1);
}}
data={[
{ label: "All", value: "all" },
{ label: "Pending approval", value: "pending" },
{ label: "Pending changes", value: "pendingChanges" },
{ label: "Onboarding", value: "onboarding" },
{ label: "Active", value: "active" },
]}
/>
<ExportButton datasetKey="customers" params={controls.params} />
<ExportButton datasetKey="customers" params={exportParams} />
</FilterBar>
</Box>
@@ -331,8 +364,8 @@ export default function CustomersPage() {
status={isLoading ? "loading" : isError ? "error" : "success"}
onRowClick={(row) => navigate(`/dashboard/customers/${row.id}`)}
emptyMessage={
controls.searchText
? "No companies match your search."
controls.activeCount > 0
? "No companies match these filters."
: "No companies yet."
}
error={

View File

@@ -3,7 +3,6 @@ import { AlertCircle } from "lucide-react";
import { Alert, Button, Skeleton, Stack } from "@mantine/core";
import { useQueryClient } from "@tanstack/react-query";
import { useAuth } from "@/auth/useAuth";
import { PageContainer } from "@/components/page";
import { ClearanceOverview } from "@/components/overview/layouts/ClearanceOverview";
import { ExecutiveOverview } from "@/components/overview/layouts/ExecutiveOverview";
@@ -20,7 +19,7 @@ import {
import { OverviewHero } from "@/components/overview/summary/OverviewHero";
import { OverviewHeroKpis } from "@/components/overview/summary/OverviewHeroKpis";
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
import { useOverview } from "@/hooks/useOverview";
import { useOverview, useOverviewLayouts } from "@/hooks/useOverview";
import type { OverviewRange } from "@/types/overview";
import "@/components/overview/summary/overview-summary.css";
@@ -57,13 +56,14 @@ function OverviewSkeleton() {
const OverviewPage = () => {
const [range, setRange] = useState<OverviewRange>("30d");
const queryClient = useQueryClient();
const { user } = useAuth();
const { data, isLoading, isError, error, refetch, isFetching } =
useOverview(range);
const { data: layouts, isLoading: layoutsLoading } = useOverviewLayouts();
// Hero, range control and headline KPIs are role-neutral; everything below
// them is chosen by role key.
const layoutKey = resolveOverviewLayout(user);
// them is chosen by which overview:<key>:view permissions the caller holds
// (GET /overview/layouts already filtered these server-side).
const layoutKey = resolveOverviewLayout(layouts?.map((l) => l.key));
const RoleLayout = layoutKey ? LAYOUTS[layoutKey] : null;
const accessDenied =
@@ -129,7 +129,7 @@ const OverviewPage = () => {
</Alert>
)}
{isLoading && !data ? (
{(isLoading || layoutsLoading) && !data ? (
<Stack mt="lg">
<OverviewSkeleton />
</Stack>

View File

@@ -224,6 +224,21 @@ const FleetResourcePage = () => {
secondary: true,
toParams: dateRangeParams("createdFrom", "createdTo"),
};
// Wagons-only: "last maintenance" is a derived value (latest status-log
// flip to MAINTENANCE), not a column other fleet resources have.
const dateDefs: FilterDef[] =
slug === "wagons"
? [
dateDef,
{
key: "lastMaintenance",
label: "Last maintenance",
type: "date",
secondary: true,
toParams: dateRangeParams("maintenanceFrom", "maintenanceTo"),
},
]
: [dateDef];
if (config?.listFilters?.length) {
return [
...config.listFilters.map((filter): FilterDef => ({
@@ -235,13 +250,13 @@ const FleetResourcePage = () => {
? (dynamicOptions[filter.dynamicOptions] ?? [])
: (filter.options ?? []),
})),
dateDef,
...dateDefs,
];
}
const fallback = FALLBACK_STATUS_OPTIONS[slug];
return fallback
? [{ key: "status", label: "Status", type: "enum", multiple: false, options: fallback }, dateDef]
: [dateDef];
? [{ key: "status", label: "Status", type: "enum", multiple: false, options: fallback }, ...dateDefs]
: dateDefs;
}, [config, dynamicOptions, slug]);
const controls = useFilters(filterDefs, { pageSize: 10 });

View File

@@ -1,29 +1,127 @@
import type { Freight } from "@edr/types";
import {
ActionIcon,
Badge,
Box,
Card,
Group,
SegmentedControl,
Stack,
Text,
TextInput,
} from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks";
import { Freight } from "@edr/types";
import { ActionIcon, Badge, Box, Card, Group, Stack, Text } from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import { Banknote, CircleDollarSign, Landmark, RefreshCw, Search, X } from "lucide-react";
import { useMemo, useState } from "react";
import { Banknote, CircleDollarSign, Landmark, RefreshCw } from "lucide-react";
import { useMemo } from "react";
import { useNavigate } from "react-router-dom";
import { InvoiceStatusBadge, formatDate, formatMoney, humanize } from "@/components/customers";
import {
FilterBar,
dateRangeParams,
isoToLocalDateStr,
useFilters,
type FilterDef,
} from "@/components/filters";
import { KpiStrip } from "@/components/page";
import CreditInvoiceActions from "@/components/shipping-lines/CreditInvoiceActions";
import { ExportButton } from "@/components/export/ExportButton";
import { useExchangeSettingsQuery } from "@/hooks/useExchangeSettings";
import { api } from "@/services/api";
import type { Invoice } from "@/types/invoice";
import { DataTable, DataTableFooter, usePagination, type ColumnDef } from "@edr/ui-common";
import type { Invoice, InvoiceListFilter } from "@/types/invoice";
import { DataTable, DataTableFooter, type ColumnDef } from "@edr/ui-common";
const STATUS_OPTIONS = Object.values(Freight.InvoiceStatus).map((value) => ({
value,
label: humanize(value),
}));
const SOURCE_OPTIONS = Object.values(Freight.InvoiceSource).map((value) => ({
value,
label: humanize(value),
}));
/** Mirrors `EimsInvoiceStatus` in the API — Finance's "what still needs filing" cut. */
const EIMS_STATUS_OPTIONS = [
"NOT_SUBMITTED",
"SUBMITTING",
"REGISTERED",
"FAILED",
"UNKNOWN",
"CANCELLED",
].map((value) => ({ value, label: humanize(value) }));
/**
* Every dimension the list narrows by. Keys are the URL keys; `toParams` maps
* them onto the API's `FilterInvoiceDto`. Secondary defs sit behind "More
* filters" until they hold a value, then pin themselves as a pill.
*/
const INVOICE_FILTER_DEFS: FilterDef[] = [
{ key: "statuses", label: "Status", type: "enum", options: STATUS_OPTIONS },
{ key: "sources", label: "Source", type: "enum", options: SOURCE_OPTIONS },
{
key: "currency",
label: "Currency",
type: "enum",
multiple: false,
options: [
{ value: "ETB", label: "ETB" },
{ value: "USD", label: "USD" },
],
},
{
// One pill for the two settlement cuts Finance actually chases. Both are
// computed from the balance and due date rather than read off `status` —
// nothing sweeps PENDING rows into OVERDUE, so the status under-reports.
key: "settlement",
label: "Settlement",
type: "enum",
multiple: false,
options: [
{ value: "outstanding", label: "Outstanding" },
{ value: "overdue", label: "Overdue" },
],
toParams: (v) =>
v.v[0] === "overdue" ? { overdue: "true" } : { hasBalance: "true" },
},
{
key: "issued",
label: "Issued",
type: "date",
operators: ["between", "before", "after"],
toParams: dateRangeParams("issuedFrom", "issuedTo"),
},
{
key: "due",
label: "Due",
type: "date",
secondary: true,
operators: ["between", "before", "after"],
toParams: dateRangeParams("dueFrom", "dueTo"),
},
{
key: "amount",
label: "Amount",
type: "number",
secondary: true,
operators: ["between", "is"],
// Amounts are compared in each invoice's OWN currency — pair this with the
// currency pill when the mix matters.
toParams: (v) =>
v.op === "between"
? { minAmount: v.v[0], maxAmount: v.v[1] }
: { minAmount: v.v[0], maxAmount: v.v[0] },
},
{
key: "eimsStatuses",
label: "EIMS",
type: "enum",
secondary: true,
options: EIMS_STATUS_OPTIONS,
},
];
const SORT_OPTIONS = [
{ value: "issuedAt:DESC", label: "Newest issued" },
{ value: "issuedAt:ASC", label: "Oldest issued" },
{ value: "dueAt:ASC", label: "Due soonest" },
{ value: "totalAmount:DESC", label: "Largest amount" },
{ value: "balanceAmount:DESC", label: "Largest balance" },
{ value: "invoiceNumber:ASC", label: "Invoice no. (AZ)" },
];
/** Date params the export's `daterange` coercion expects as calendar days. */
const EXPORT_DAY_KEYS = ["issuedFrom", "issuedTo", "dueFrom", "dueTo"];
/**
* Which record raised the invoice, not just which subsystem. The source label
@@ -65,20 +163,12 @@ function InvoiceSourceCell({ invoice }: { invoice: Invoice }) {
/** Invoices tab body of `FinanceHubPage` — page chrome lives in the parent. */
export default function InvoicesPanel() {
const navigate = useNavigate();
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [query, setQuery] = useState("");
const [debouncedQuery] = useDebouncedValue(query, 300);
const [statusFilter, setStatusFilter] = useState<"" | Freight.InvoiceStatus>("");
const controls = useFilters(INVOICE_FILTER_DEFS, {
defaultSort: "issuedAt:DESC",
pageSize: 10,
});
const filter = useMemo(
() => ({
page: pagination.pageIndex + 1,
pageSize: pagination.pageSize,
search: debouncedQuery,
status: statusFilter || undefined,
}),
[pagination.pageIndex, pagination.pageSize, debouncedQuery, statusFilter],
);
const filter = controls.params as unknown as InvoiceListFilter;
const { data, isLoading, isError, refetch, isFetching } = useQuery(
api.invoices.list.queryOptions({ input: { filter } }),
@@ -86,7 +176,6 @@ export default function InvoicesPanel() {
const rows = data?.items ?? [];
const total = data?.total ?? 0;
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
// Shipping-line credit invoices carry makerchecker actions (mark paid /
// cancel). One batched lookup fetches the visible rows' pending requests.
@@ -106,14 +195,28 @@ export default function InvoicesPanel() {
);
// Summary card: total collected (paidAmount) across every invoice matching
// the current search/status filters, not just the visible page.
// the current filters, not just the visible page. Same params minus
// pagination, so the card can never total a different set than the table.
const summaryFilter = useMemo(() => {
const { page: _page, pageSize: _pageSize, ...rest } = filter;
return rest;
}, [filter]);
const { data: summary, isLoading: summaryLoading } = useQuery(
api.invoices.collectedSummary.queryOptions({
input: {
filter: { search: debouncedQuery, status: statusFilter || undefined },
},
}),
api.invoices.collectedSummary.queryOptions({ input: { filter: summaryFilter } }),
);
/**
* The export's `daterange` filters are coerced from calendar days, while the
* list takes ISO instants — hand the dialog the local day each bound falls
* on so an exported file covers the same range the screen shows.
*/
const exportParams = useMemo(() => {
const out: Record<string, unknown> = { ...controls.params };
for (const key of EXPORT_DAY_KEYS) {
if (typeof out[key] === "string") out[key] = isoToLocalDateStr(out[key] as string);
}
return out;
}, [controls.params]);
const { data: exchangeSettings } = useExchangeSettingsQuery();
const etbCollected = summary?.ETB ?? 0;
const usdCollected = summary?.USD ?? 0;
@@ -236,45 +339,14 @@ export default function InvoicesPanel() {
<Card p={0}>
<Stack gap={0}>
<Box px="md" pt="md" pb="sm" w="100%">
<Group justify="space-between" gap="md" wrap="wrap">
<TextInput
placeholder="Search invoice, customer, booking ref, GRN or shipping line…"
leftSection={<Search size={18} />}
value={query}
onChange={(e) => setQuery(e.target.value)}
rightSection={
query ? (
<ActionIcon
size="sm"
color="gray"
radius="md"
variant="transparent"
onClick={() => setQuery("")}
>
<X size={16} />
</ActionIcon>
) : null
}
style={{ flex: 1, minWidth: "240px" }}
radius="lg"
/>
<ExportButton datasetKey="invoices" params={filter} size="sm" />
<SegmentedControl
size="sm"
radius="md"
value={statusFilter || "all"}
onChange={(v) => {
setStatusFilter(v === "all" ? "" : (v as Freight.InvoiceStatus));
setPagination((prev) => ({ ...prev, pageIndex: 0 }));
}}
data={[
{ label: "All", value: "all" },
{ label: "Pending", value: "PENDING" },
{ label: "Payment processing", value: "PAYMENT_PROCESSING" },
{ label: "Paid", value: "PAID" },
{ label: "Overdue", value: "OVERDUE" },
]}
/>
<FilterBar
defs={INVOICE_FILTER_DEFS}
controls={controls}
searchPlaceholder="Search invoice, customer, booking ref, GRN or shipping line…"
sortOptions={SORT_OPTIONS}
viewId="invoices"
>
<ExportButton datasetKey="invoices" params={exportParams} size="sm" />
<ActionIcon
variant="default"
size="lg"
@@ -285,7 +357,7 @@ export default function InvoicesPanel() {
>
<RefreshCw size={16} />
</ActionIcon>
</Group>
</FilterBar>
</Box>
<Box style={{ overflowX: "auto" }} w="100%">
@@ -296,7 +368,9 @@ export default function InvoicesPanel() {
status={isLoading ? "loading" : isError ? "error" : "success"}
onRowClick={(row) => navigate(`/dashboard/invoices/${row.id}`)}
emptyMessage={
debouncedQuery ? "No invoices match your search." : "No invoices yet."
controls.activeCount > 0
? "No invoices match these filters."
: "No invoices yet."
}
error={
isError
@@ -306,18 +380,7 @@ export default function InvoicesPanel() {
}
: undefined
}
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount,
totalCount: total,
}}
tableOptions={{
state: { pagination },
onPaginationChange: setPagination,
manualPagination: true,
pageCount,
}}
{...controls.tableProps(total)}
containerClassName="border-0 shadow-none bg-transparent"
footer={DataTableFooter}
/>

View File

@@ -151,6 +151,12 @@ const TRADE_DIRECTIONS = [
* a report matches a target by this exact key, so a value here that the API
* does not emit is a plan the report will never find. The API spec
* `operations-classification.spec.ts` guards the API side of the pair.
*
* Drift is no longer silent: `OperationsTargetsService.assertDimensionKey`
* rejects any key outside the API's own vocabulary, so a stale entry here
* surfaces as a 400 on save rather than a plan that quietly never joins.
* `UNCLASSIFIED` is left out deliberately — the API accepts it, but there is no
* sense in planning against cargo nobody has classified.
*/
export const OPERATIONS_CARGO_CATEGORIES = [
{ label: "Multimodal container import", value: "CONTAINER_IMPORT_MULTIMODAL" },
@@ -713,14 +719,21 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
],
},
{
// Mirrors TARGET_PERIOD_LABELS in the API's operations-target entity.
// Commit the number at whatever grain the business quotes it — the
// report re-gathers it into whichever grain the viewer asks for.
name: "periodType",
label: "Period",
type: "select",
required: true,
options: [
{ label: "Daily", value: "day" },
{ label: "Weekly", value: "week" },
{ label: "Monthly", value: "month" },
{ label: "Quarterly", value: "quarter" },
{ label: "Half-yearly", value: "half_year" },
{ label: "Nine-monthly", value: "nine_month" },
{ label: "90-day", value: "ninety_day" },
{ label: "Yearly", value: "year" },
],
},

View File

@@ -359,13 +359,7 @@ export default function TrainScheduleV2ListPage() {
id: "metrics",
header: "Load",
meta: { headerClassName, cellClassName },
cell: ({ row }) => (
<Group gap={6} wrap="nowrap">
<MetricChip value={row.original.bookingsCount} label="bkg" />
<WagonChips schedule={row.original} />
<MetricChip value={`${row.original.totalWeightTons}T`} label="" subtle />
</Group>
),
cell: ({ row }) => <MetricChip value={row.original.bookingsCount} label="bkg" />,
},
{
id: "actions",
@@ -884,14 +878,6 @@ export default function TrainScheduleV2ListPage() {
);
}
/**
* The row's wagon chips: used is slots carrying a booking allocation, the
* denominator is the schedule's capacity (API-computed: the larger of coupled
* consist and planned `maxWagons`, since wagons are coupled on demand). Both
* are consist-wide totals, so on a multi-leg schedule they do not describe any
* single leg — the bookable/planned counts were dropped for that reason; the
* detail page's wagon plan is the per-leg source of truth.
*/
/** Green tint for departures dedicated to a shipping line (overrides direction tint). */
const SHIPPING_LINE_ROW_STYLE = {
backgroundColor: "var(--mantine-color-edr-green-0)",
@@ -956,25 +942,6 @@ function ShippingLineBadge({ schedule }: { schedule: TrainScheduleListItem }) {
);
}
function WagonChips({ schedule }: { schedule: TrainScheduleListItem }) {
// Pre-deploy API rows carry only wagonCount; fall back so the chip still
// renders rather than reading 0 used on every train.
const total = schedule.wagonsTotal ?? schedule.wagonCount;
const used = schedule.wagonsUsed;
const reserved = schedule.wagonsReserved ?? 0;
if (used == null || schedule.wagonCount === 0) {
return <MetricChip value={total} label="wgn" subtle />;
}
return (
<>
<MetricChip value={`${used}/${total}`} label="wgn used" />
{reserved > used ? <MetricChip value={reserved} label="reserved" subtle /> : null}
</>
);
}
function MetricChip({
value,
label,
@@ -1071,11 +1038,7 @@ function ScheduleCard({
) : null}
<ShippingLineBadge schedule={schedule} />
</Group>
<Group gap={6} wrap="nowrap">
<MetricChip value={schedule.bookingsCount} label="bkg" />
<WagonChips schedule={schedule} />
<MetricChip value={`${schedule.totalWeightTons}T`} label="" subtle />
</Group>
<MetricChip value={schedule.bookingsCount} label="bkg" />
</Group>
<Group gap="xs" wrap="nowrap">

View File

@@ -1,9 +1,10 @@
import { useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Badge, Card, Center, Divider, Group, Loader, Select, SimpleGrid, Stack, Text, ThemeIcon } from '@mantine/core';
import { Button, Card, Center, Group, Loader, Popover, Select, SimpleGrid, Stack, Text, ThemeIcon } from '@mantine/core';
import { DatePickerInput } from '@mantine/dates';
import {
ClipboardList,
Filter,
PackageCheck,
PackageOpen,
PackagePlus,
@@ -17,7 +18,6 @@ import {
} from 'lucide-react';
import { PageContainer, PageHeader } from '@/components/page';
import { getDateRangePresets } from '@/components/common/dateRangePresets';
import {
AccrualDashboard,
CycleTimeCard,
@@ -44,44 +44,42 @@ interface Metric {
icon: React.ReactNode;
/** Route to navigate to when the card is clicked. */
to: string;
theme: string;
}
const ORANGE = 'rgb(241, 147, 23)';
const GREEN = '#084b21';
const METRICS: Metric[] = [
{ key: 'totalWarehouses', label: 'Total Warehouses', icon: <WarehouseIcon size={22} />, to: '/dashboard/warehouses', theme: ORANGE },
{ key: 'totalInventory', label: 'Total Inventory', icon: <Boxes size={22} />, to: '/dashboard/warehouse-inventory', theme: GREEN },
{ key: 'received', label: 'Received Today', icon: <PackagePlus size={22} />, to: '/dashboard/warehouse-inventory?status=RECEIVED', theme: ORANGE },
{ key: 'awaitingInspection', label: 'Awaiting Inspection', icon: <ClipboardList size={22} />, to: '/dashboard/warehouse-inventory?status=RECEIVED', theme: GREEN },
{ key: 'emptyContainers', label: 'Empty Containers', icon: <PackageOpen size={22} />, to: '/dashboard/containers', theme: ORANGE },
{ key: 'importTrains', label: 'Import Trains', icon: <Train size={22} />, to: '/dashboard/import-warehouse', theme: GREEN },
{ key: 'exportTrains', label: 'Export Trains', icon: <Train size={22} />, to: '/dashboard/export-warehouse', theme: ORANGE },
{ key: 'loaded', label: 'Loaded', icon: <Truck size={22} />, to: '/dashboard/loaded-inventory', theme: GREEN },
{ key: 'dispatched', label: 'Dispatched', icon: <Send size={22} />, to: '/dashboard/dispatch-queue', theme: ORANGE },
{ key: 'readyForPickup', label: 'Ready For Pickup', icon: <PackageSearch size={22} />, to: '/dashboard/warehouse-inventory?status=READY_FOR_PICKUP', theme: GREEN },
{ key: 'readyForLoading', label: 'Ready For Loading', icon: <PackageCheck size={22} />, to: '/dashboard/loading-queue', theme: ORANGE },
{ key: 'delivered', label: 'Delivered', icon: <CircleCheck size={22} />, to: '/dashboard/warehouse-inventory?status=DELIVERED', theme: GREEN },
{ key: 'totalWarehouses', label: 'Total Warehouses', icon: <WarehouseIcon size={18} />, to: '/dashboard/warehouses' },
{ key: 'totalInventory', label: 'Total Inventory', icon: <Boxes size={18} />, to: '/dashboard/warehouse-inventory' },
{ key: 'received', label: 'Received Today', icon: <PackagePlus size={18} />, to: '/dashboard/warehouse-inventory?status=RECEIVED' },
{ key: 'awaitingInspection', label: 'Awaiting Inspection', icon: <ClipboardList size={18} />, to: '/dashboard/warehouse-inventory?status=RECEIVED' },
{ key: 'emptyContainers', label: 'Empty Containers', icon: <PackageOpen size={18} />, to: '/dashboard/containers' },
{ key: 'importTrains', label: 'Import Trains', icon: <Train size={18} />, to: '/dashboard/import-warehouse' },
{ key: 'exportTrains', label: 'Export Trains', icon: <Train size={18} />, to: '/dashboard/export-warehouse' },
{ key: 'loaded', label: 'Loaded', icon: <Truck size={18} />, to: '/dashboard/loaded-inventory' },
{ key: 'dispatched', label: 'Dispatched', icon: <Send size={18} />, to: '/dashboard/dispatch-queue' },
{ key: 'readyForPickup', label: 'Ready For Pickup', icon: <PackageSearch size={18} />, to: '/dashboard/warehouse-inventory?status=READY_FOR_PICKUP' },
{ key: 'readyForLoading', label: 'Ready For Loading', icon: <PackageCheck size={18} />, to: '/dashboard/loading-queue' },
{ key: 'delivered', label: 'Delivered', icon: <CircleCheck size={18} />, to: '/dashboard/warehouse-inventory?status=DELIVERED' },
];
export default function WarehouseDashboardPage() {
const navigate = useNavigate();
// Both null → the API defaults `received` to "today", matching the page's original behaviour.
const [dateRange, setDateRange] = useState<[string | null, string | null]>([null, null]);
// null → the API defaults `received` to "today", matching the page's original behaviour.
const [receivedDate, setReceivedDate] = useState<string | null>(null);
const [warehouseId, setWarehouseId] = useState<string | null>(null);
const [dateFrom, dateTo] = dateRange;
const hasCustomRange = Boolean(dateFrom || dateTo);
const [filtersOpen, setFiltersOpen] = useState(false);
const hasCustomDate = Boolean(receivedDate);
const warehousesQuery = useWarehouses();
const warehouseOptions = useMemo(
() => (warehousesQuery.data ?? []).map((w) => ({ value: w.id, label: `${w.name} (${w.code})` })),
[warehousesQuery.data],
);
const activeFilterCount = (warehouseId ? 1 : 0) + (hasCustomDate ? 1 : 0);
const { data, isError, isLoading } = useWarehouseDashboard({
dateFrom: dateFrom ?? undefined,
dateTo: dateTo ?? undefined,
// Same date both ends → the one day the picker selected, inclusive.
dateFrom: receivedDate ?? undefined,
dateTo: receivedDate ?? undefined,
warehouseId: warehouseId ?? undefined,
});
@@ -89,57 +87,64 @@ export default function WarehouseDashboardPage() {
<PageContainer>
<PageHeader
title="Warehouse Dashboard"
subtitle="Live overview of warehouse capacity and inventory lifecycle."
subtitle="Freight import/export logistics operations overview"
action={
<Group gap="sm" wrap="wrap" justify="flex-end">
<Select
placeholder="All warehouses"
clearable
searchable
data={warehouseOptions}
value={warehouseId}
onChange={setWarehouseId}
w={220}
/>
<DatePickerInput
type="range"
placeholder="Received: today"
value={dateRange}
onChange={setDateRange}
presets={getDateRangePresets()}
value={receivedDate}
onChange={setReceivedDate}
clearable
w={230}
w={180}
/>
<Badge
color="edr-green"
variant="light"
size="lg"
leftSection={
<span
style={{
display: 'inline-block',
width: 8,
height: 8,
borderRadius: '50%',
background: 'var(--mantine-color-edr-green-6)',
}}
/>
}
>
Live · updates every 60s
</Badge>
<Popover opened={filtersOpen} onChange={setFiltersOpen} position="bottom-end" withArrow shadow="md">
<Popover.Target>
<Button
variant="default"
leftSection={<Filter size={16} />}
rightSection={activeFilterCount > 0 ? <Text size="xs" fw={700} c="edr-green">{activeFilterCount}</Text> : null}
onClick={() => setFiltersOpen((o) => !o)}
>
Filters
</Button>
</Popover.Target>
<Popover.Dropdown>
<Stack gap="sm" w={240}>
<Select
label="Warehouse"
placeholder="All warehouses"
clearable
searchable
data={warehouseOptions}
value={warehouseId}
onChange={setWarehouseId}
/>
{activeFilterCount > 0 && (
<Button
variant="subtle"
color="gray"
size="xs"
onClick={() => {
setWarehouseId(null);
setReceivedDate(null);
}}
>
Clear filters
</Button>
)}
</Stack>
</Popover.Dropdown>
</Popover>
</Group>
}
/>
{(warehouseId || hasCustomRange) && (
{(warehouseId || hasCustomDate) && (
<Text size="xs" c="dimmed" mt={-8}>
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.
</Text>
)}
@@ -152,41 +157,33 @@ export default function WarehouseDashboardPage() {
<Text c="red">Failed to load warehouse dashboard.</Text>
</Center>
) : (
<Stack gap="xl">
<Stack gap="lg">
{/* Needs attention — live ops counters (received today, pending
inspection, trucks on-site, items aging > 7 days). */}
<Stack gap="sm">
<SectionTitle>Needs attention</SectionTitle>
<WarehouseOpsKpiStrip />
</Stack>
<Divider />
<WarehouseOpsKpiStrip />
<SimpleGrid cols={{ base: 1, xs: 2, md: 4 }} spacing="md">
{METRICS.map((metric) => (
<Card
key={metric.key}
padding="lg"
padding="md"
withBorder
radius="md"
onClick={() => navigate(metric.to)}
className="cursor-pointer transition-[transform,border-color] duration-150 hover:-translate-y-0.5 hover:border-edr-primary!"
>
<Group justify="space-between" align="flex-start" wrap="nowrap">
<div>
<Text size="xs" c="edr-muted" tt="uppercase" fw={700} style={{ letterSpacing: 0.4 }}>
{metric.key === 'received' && hasCustomRange ? 'Received' : metric.label}
</Text>
<Text fw={800} fz={32} mt={8} c="edr-text" lh={1.1}>
{data ? data[metric.key] : 0}
</Text>
</div>
<ThemeIcon
variant="light"
size={46}
radius="md"
style={{ backgroundColor: `${metric.theme}1a`, color: metric.theme }}
>
<Group gap="sm" wrap="nowrap">
<ThemeIcon color="edr-green" variant="light" size={40} radius="md">
{metric.icon}
</ThemeIcon>
<Stack gap={0} style={{ minWidth: 0 }}>
<Text size="xs" c="edr-muted" fw={600}>
{metric.key === 'received' && hasCustomDate ? 'Received' : metric.label}
</Text>
<Text fw={700} fz={20} c="edr-text" lh={1.2}>
{data ? data[metric.key] : 0}
</Text>
</Stack>
</Group>
</Card>
))}

View File

@@ -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<Freight.AdditionalCharge[]> => {
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" },

View File

@@ -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<IOverviewLayoutOption[]> => {
const response = await client.get<IOverviewLayoutOption[]>(O.LAYOUTS);
return unwrap(response);
},
getDashboard: async (range?: OverviewRange): Promise<IOverviewDashboard> => {
const response = await client.get<IOverviewDashboard>(O.BASE, {
params: range ? { range } : undefined,

View File

@@ -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();

View File

@@ -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;
/**

View File

@@ -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). */

View File

@@ -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`,

View File

@@ -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) && (
<Group gap={8} wrap="nowrap">
{canApproveDelivery && (
<ApproveDeliveryButton bookingId={booking.id} />
@@ -227,6 +256,14 @@ export function ReadonlyBookingView({
onClick={() => setCancelOpen(true)}
/>
)}
{canCancelPaid && (
<HeaderButton
red
icon={<XCircle size={16} />}
label="Cancel booking"
onClick={() => setPaidCancelOpen(true)}
/>
)}
</Group>
)
}
@@ -414,6 +451,7 @@ export function ReadonlyBookingView({
booking.paymentStatus === "PAID" &&
Boolean(booking.contractId)
}
consolidated={Boolean(booking.consolidationPartnerId)}
onCancellationRequested={onBookingUpdated}
/>
</Tabs.Panel>
@@ -509,6 +547,86 @@ export function ReadonlyBookingView({
</Group>
</Stack>
</Modal>
<Modal
opened={paidCancelOpen}
onClose={() => setPaidCancelOpen(false)}
title={
<Text fw={800} fz={18} c="#10202F">
Cancel this booking?
</Text>
}
centered
radius={16}
>
<Stack gap="md">
<Text size="sm" c="#475569">
You&apos;re about to cancel the whole booking{" "}
<Text span fw={700} c="#10202F">
{booking.reference}
</Text>
. 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."
: ""}
</Text>
{paidPreview.isLoading && <Skeleton height={64} radius={10} />}
{paidPreview.data && (
<Stack
gap={4}
p={12}
style={{
borderRadius: 10,
backgroundColor: "#FFFBEB",
border: "1px solid #FDE68A",
}}
>
<Text fz={13} c="#92400E">
Wagons cancelled: <b>{paidPreview.data.wagons}</b>
</Text>
<Text fz={13} c="#92400E">
Cancellation fee:{" "}
<b>
{Number(paidPreview.data.feeAmount).toLocaleString()}{" "}
{paidPreview.data.feeCurrency}
</b>{" "}
({Number(paidPreview.data.feePerWagon).toLocaleString()} per
wagon)
</Text>
<Text fz={13} c="#92400E">
Rebooking credit:{" "}
<b>
{Number(paidPreview.data.creditAmount).toLocaleString()}{" "}
{booking.paymentCurrency}
</b>
</Text>
</Stack>
)}
{paidPreview.isError && (
<Text fz={13} c="#B3362C">
{cancelErrorMessage(paidPreview.error)}
</Text>
)}
<Group justify="flex-end" gap={8}>
<Button
variant="default"
radius={10}
onClick={() => setPaidCancelOpen(false)}
>
Keep booking
</Button>
<Button
color="red"
radius={10}
disabled={!paidPreview.data}
loading={paidCancelMutation.isPending}
onClick={() => paidCancelMutation.mutate()}
>
Cancel booking &amp; issue fee
</Button>
</Group>
</Stack>
</Modal>
{viewer}
</PageShell>
);

View File

@@ -59,8 +59,16 @@ function ChargeRow({ charge }: { charge: Freight.AdditionalCharge }) {
<Text fz="12px" c="#9AA8B5" mt={2}>
{charge.amount.toLocaleString(undefined, { minimumFractionDigits: 2 })}{" "}
{charge.currency}
{charge.convertedAmount != null
? ` (≈ ${charge.convertedAmount.toLocaleString(undefined, { minimumFractionDigits: 2 })} ${charge.convertedCurrency})`
: ""}
{charge.paymentReference ? ` · ref ${charge.paymentReference}` : ""}
</Text>
{charge.dueAt && charge.status === "SENT" && (
<Text fz="12px" c="#9AA8B5">
Due {new Date(charge.dueAt).toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" })}
</Text>
)}
</Box>
<Badge
radius="sm"

View File

@@ -28,6 +28,7 @@ import { ClearanceUploadedDocumentsPanel } from "@/components/contracts/Clearanc
import toast from "react-hot-toast";
import { bookingsService } from "@/services/bookings.service";
import type { EmptyContainerReturn } from "@/services/bookings.service";
import { saveBlob } from "@/utils/download";
import { IconSquare } from "./Documents";
import { CardTitle, SectionCard } from "./layout";
@@ -251,6 +252,24 @@ export function DocumentsTab({ booking }: { booking: Freight.IBooking }) {
queryFn: () => warehouseService.bookingHandovers(booking.id),
});
const { data: emptyReturns = [] } = useQuery({
queryKey: ["emptyContainerReturns", booking.id],
queryFn: () =>
bookingsService.listEmptyContainerReturns(booking.id).catch(() => []),
});
const [downloadingReturnId, setDownloadingReturnId] = useState<string | null>(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 }) {
</SectionCard>
)}
{/* ── 4b. Equipment interchange receipts (empty container returns) ─── */}
{emptyReturns.length > 0 && (
<SectionCard>
<CardTitle>Equipment interchange receipts</CardTitle>
<Text fz="12.5px" c="dimmed" mt={4} mb="sm">
Container number, size, return time, depot, and condition for each empty
container returned on this booking.
</Text>
<Stack gap={0}>
{emptyReturns.map((ret, i) => (
<Box
key={ret.id}
py={12}
style={{
borderBottom:
i === emptyReturns.length - 1 ? undefined : "1px solid #F2F5F8",
}}
>
<Group justify="space-between" align="flex-start" wrap="nowrap">
<Box miw={0} flex={1}>
<Text fz="13.5px" fw={700} c="#10202F">
{ret.containerNumber}
{ret.containerSize ? ` · ${ret.containerSize}ft` : ""}
</Text>
<Text fz="12px" c="#9AA8B5">
{ret.returnDate ? new Date(ret.returnDate).toLocaleString() : "—"}
{ret.facility ? ` · ${ret.facility}` : ""}
{ret.condition ? ` · ${ret.condition}` : ""}
</Text>
</Box>
<IconSquare
icon={<Download size={15} />}
onClick={
downloadingReturnId === ret.id ? undefined : () => void downloadEir(ret)
}
/>
</Group>
</Box>
))}
</Stack>
</SectionCard>
)}
{/* ── Warehouse documents (one-click bundle) ──────────────────────── */}
<SectionCard>
<CardTitle>Warehouse documents</CardTitle>
<Text fz="12.5px" c="dimmed" mt={4} mb="sm">
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.
</Text>
<Button
leftSection={<Download size={16} />}

View File

@@ -303,11 +303,14 @@ function WagonCard({
wagon,
selectable,
selected,
shared,
onToggle,
}: {
wagon: BookingWagonAllocation;
selectable?: boolean;
selected?: boolean;
/** Shared consolidation wagon — not selectable for cancellation. */
shared?: boolean;
onToggle?: () => void;
}) {
const allocated = Number(wagon.allocatedWeightTons || 0);
@@ -368,6 +371,14 @@ function WagonCard({
<StatusPill status={wagon.status} />
</Group>
{shared && (
<Text fz={11.5} c="#B45309" mb={6}>
Shared wagon the other half belongs to another customer&apos;s
booking, so it cannot be cancelled on its own. Cancel the whole
booking to release it.
</Text>
)}
<LoadBar allocated={allocated} capacity={capacity} />
<Group gap={16} mt="sm" mb={containers.length || wagon.loadType === "BULK" ? "sm" : 0}>
@@ -464,6 +475,7 @@ export function WagonsTab({
bookingId,
currency,
cancellable,
consolidated,
onCancellationRequested,
}: {
bookingId: string;
@@ -471,6 +483,8 @@ export function WagonsTab({
currency?: string;
/** PAID contract booking — specific wagons may be selected for cancellation. */
cancellable?: boolean;
/** Consolidated booking — its shared wagon (a lone 20ft) cannot be cancelled alone. */
consolidated?: boolean;
onCancellationRequested?: () => void;
}) {
const queryClient = useQueryClient();
@@ -711,19 +725,31 @@ export function WagonsTab({
<CancelledWagonsSection rows={ownCancellations} />
<SimpleGrid cols={{ base: 1, md: 2 }} spacing={24}>
{wagons.map((w) => (
<WagonCard
key={w.allocationId ?? w.sequenceNo}
wagon={w}
selectable={
canSelect &&
!!w.allocationId &&
(w.status === "PLANNED" || w.status === "RESERVED")
}
selected={!!w.allocationId && selected.has(w.allocationId)}
onToggle={() => w.allocationId && toggle(w.allocationId)}
/>
))}
{wagons.map((w) => {
// The shared consolidation wagon carries this booking's lone 20ft —
// its other half belongs to the partner booking, so it can never be
// cancelled on its own (the server rejects it too).
const isSharedWagon =
!!consolidated &&
w.loadType === "CONTAINER" &&
(w.containers ?? []).length === 1 &&
Number(w.containers?.[0]?.sizeFt) === 20;
return (
<WagonCard
key={w.allocationId ?? w.sequenceNo}
wagon={w}
shared={isSharedWagon}
selectable={
canSelect &&
!isSharedWagon &&
!!w.allocationId &&
(w.status === "PLANNED" || w.status === "RESERVED")
}
selected={!!w.allocationId && selected.has(w.allocationId)}
onToggle={() => w.allocationId && toggle(w.allocationId)}
/>
);
})}
</SimpleGrid>
<Modal

View File

@@ -29,7 +29,6 @@ import {
Textarea,
ThemeIcon,
Title,
Tooltip,
} from "@mantine/core";
import {
AlertCircle,
@@ -413,10 +412,10 @@ function NewShipmentBookingForm({
mode: "onChange",
});
// 20ft containers ride two per wagon, so an odd total leaves one unpaired and
// the booking can never be planned. The server's shipment validation reports
// it too, but only once the price modal opens — block it inline instead, the
// same way the direct-booking wizard does (new-booking-form `calcWagons`).
// 20ft containers ride two per wagon. An odd total no longer blocks the
// booking — 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 watchedContainers = form.watch("containers");
const ft20Total =
contract.freightType === "CONTAINER"
@@ -581,8 +580,6 @@ function NewShipmentBookingForm({
// run it for every freight type; container contracts additionally get
// overweight warnings + 20ft pairing hard-blocks surfaced in the modal.
const handleReview = form.handleSubmit((values) => {
// An unpaired 20ft can never be planned onto a wagon — don't even price it.
if (hasOdd20ft) return;
setPendingValues(values);
validateMutation.reset();
validateMutation.mutate(buildDto(values));
@@ -747,27 +744,27 @@ function NewShipmentBookingForm({
Fix the highlighted fields before reviewing the price.
</Alert>
) : null}
<Group justify="flex-end">
<Tooltip
label={`Book an even number of 20ft containers — ${ft20Total} is odd and would leave one unpaired.`}
withArrow
disabled={!hasOdd20ft}
{hasOdd20ft ? (
<Alert
color="yellow"
variant="light"
radius="md"
icon={<AlertCircle size={16} />}
mb="sm"
>
{/* Mantine tooltips get no pointer events from a disabled button,
so the wrapper carries the hover target. */}
<Box>
<Button
type="button"
color="edr-green"
radius="md"
leftSection={<Receipt size={16} />}
onClick={handleReview}
disabled={hasOdd20ft}
>
{isResubmit ? "Change booking" : "Review price & book"}
</Button>
</Box>
</Tooltip>
{`${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.`}
</Alert>
) : null}
<Group justify="flex-end">
<Button
type="button"
color="edr-green"
radius="md"
leftSection={<Receipt size={16} />}
onClick={handleReview}
>
{isResubmit ? "Change booking" : "Review price & book"}
</Button>
</Group>
</Box>
</Box>
@@ -1687,18 +1684,16 @@ function CargoStep({
if (ft20 % 2 !== 1) return null;
return (
<Alert
color="red"
color="yellow"
variant="light"
radius="md"
icon={<AlertCircle size={16} />}
title={`Odd number of 20ft containers (${ft20})`}
>
<Text fz={13}>
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&apos;s odd booking to share a
wagon, or held until one is available.
</Text>
</Alert>
);

View File

@@ -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 ? (
<Alert
color="red"
color="yellow"
variant="light"
radius="md"
icon={<AlertCircle size={16} />}
title={`Odd number of 20ft containers (${ft20Requested})`}
>
<Text fz={13}>
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&apos;s odd booking to share a
wagon, or held until one is available.
</Text>
</Alert>
) : null}
@@ -282,7 +280,6 @@ export default function NewShipmentRequestPage() {
leftSection={<Send size={16} />}
loading={submit.isPending}
onClick={handleSubmit}
disabled={hasOdd20ft}
>
Submit shipment request
</Button>

View File

@@ -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<EmptyContainerReturn[]> => {
const { data } = await client.get(
`/api/import-operations/bookings/${bookingId}/empty-container-returns`,
);
return data.data ?? data;
},
downloadEquipmentInterchangeDocument: async (returnId: string): Promise<Blob> => {
const { data } = await client.get(
`/api/import-operations/empty-container-returns/${returnId}/document`,
{ responseType: "blob" },
);
return data;
},
downloadBookingGrnDocument: async (bookingId: string): Promise<Blob> => {
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<Blob> => {
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;

View File

@@ -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' })

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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({

View File

@@ -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<string | null>(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<string | null>(null);
const [resendModal, setResendModal] = useState<any>(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() {
<h1 className="text-2xl font-bold text-foreground">Excess Lugagge</h1>
<p className="text-muted-foreground">Track and manage excess luggage charges at boarding</p>
</div>
<ActionButton icon={Plus} onClick={() => { setLogModal(true); setLogError(null); setLogForm({ bookingReference: '', excessWeightKg: '', collectCash: false }); }}>
<ActionButton icon={Plus} onClick={() => { setLogModal(true); setLogError(null); setLogForm({ bookingReference: '', excessWeightKg: '', collectCash: false, paymentPhone: '' }); }}>
Log Excess Luggage
</ActionButton>
</div>
@@ -256,6 +280,15 @@ export default function ExcessBaggagePage() {
onChange={(e) => setLogForm({ ...logForm, bookingReference: e.target.value })}
/>
</div>
<div>
<label className="label">Payment SMS Phone</label>
<input
className="input"
placeholder="e.g. +251911223344"
value={logForm.paymentPhone}
onChange={(e) => setLogForm({ ...logForm, paymentPhone: e.target.value })}
/>
</div>
<div>
<label className="label">Excess Weight (kg)</label>
<input
@@ -282,7 +315,7 @@ export default function ExcessBaggagePage() {
</label>
{!logForm.collectCash && (
<p className="text-xs text-muted-foreground">
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.
</p>
)}
</>
@@ -302,6 +335,7 @@ export default function ExcessBaggagePage() {
bookingReference: logForm.bookingReference.trim(),
excessWeightKg: parseInt(logForm.excessWeightKg),
collectCash: logForm.collectCash,
contactPhone: logForm.paymentPhone.trim() || undefined,
});
}}
>

View File

@@ -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<string | null>(null);
const [createSuccess, setCreateSuccess] = useState<string | null>(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) {
<label className="label">Booking Reference <span className="text-red-500">*</span></label>
<input className="input" placeholder="e.g. EDR-20240001" value={form.bookingRef} onChange={(e) => setForm({ ...form, bookingRef: e.target.value })} />
</div>
<div>
<label className="label">Payment SMS Phone</label>
<input className="input" placeholder="e.g. +251911223344" value={form.paymentPhone} onChange={(e) => setForm({ ...form, paymentPhone: e.target.value })} />
</div>
<div>
<label className="label">Amount Owed (ETB) <span className="text-red-500">*</span></label>
<input className="input" type="number" min="0.01" step="0.01" placeholder="e.g. 50.00" value={form.amountEtb} onChange={(e) => setForm({ ...form, amountEtb: e.target.value })} />
@@ -69,7 +104,7 @@ export default function SupplementaryChargesModal({ isOpen, onClose }: Props) {
</div>
<p className="text-xs text-muted-foreground">
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.
</p>
<div className="flex justify-end gap-2 pt-2 border-t border-muted">

View File

@@ -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();

View File

@@ -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<any>(null);
const [excessKg, setExcessKg] = useState('');
const [excessCollectCash, setExcessCollectCash] = useState(false);
const [excessPaymentPhone, setExcessPaymentPhone] = useState('');
const [excessError, setExcessError] = useState<string | null>(null);
const [excessResult, setExcessResult] = useState<any>(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
/>
</div>
<div>
<label className="label">Payment SMS Phone</label>
<input
className="input"
placeholder="e.g. +251911223344"
value={excessPaymentPhone}
onChange={(e) => setExcessPaymentPhone(e.target.value)}
/>
</div>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"

View File

@@ -200,8 +200,14 @@ export const paymentsApi = {
updateMethod: (id: string, data: any) => apiClient.patch(`/payments/methods/${id}`, data),
deleteMethod: (id: string) => apiClient.delete(`/payments/methods/${id}`),
supplementary: {
create: (data: { bookingRef: string; amountMinor: number; reason: string; notes?: string }) =>
apiClient.post<any>('/payments/supplementary', data),
create: (data: {
bookingRef: string;
amountMinor: number;
reason: string;
notes?: string;
contactPhone?: string;
contactEmail?: string;
}) => apiClient.post<any>('/payments/supplementary', data),
getAll: async (params?: any) => {
const cleanParams = Object.fromEntries(
Object.entries(params || {}).filter(([, v]) => v !== '' && v !== undefined && v !== null)

View File

@@ -909,6 +909,11 @@ export interface AdditionalCharge {
status: AdditionalChargeStatus;
amount: number;
currency: string;
/** Amount converted to the other of ETB/USD at the current exchange rate; null if the rate feed is down. */
convertedAmount: number | null;
convertedCurrency: string | null;
/** Optional payment due date finance sets on the charge; falls back to the invoice's own default term when unset. */
dueAt: string | null;
file: { id: string; name: string; url: string } | null;
invoiceId: string | null;
invoiceNumber: string | null;