mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 20:40:55 +00:00
@@ -116,6 +116,8 @@ export interface InvoiceListFilters {
|
||||
status?: Freight.InvoiceStatus;
|
||||
statuses?: Freight.InvoiceStatus[];
|
||||
sources?: string[];
|
||||
/** What the invoice bills for (`PREPAID`, `DEMURRAGE`, …) — free-form per source. */
|
||||
types?: string[];
|
||||
eimsStatuses?: string[];
|
||||
/** Settled payment method, normalised UPPER_SNAKE — see `invoicePaymentMethodExpr`. */
|
||||
paymentMethods?: string[];
|
||||
@@ -306,6 +308,9 @@ export class BillingService {
|
||||
sources: filter.sources,
|
||||
});
|
||||
}
|
||||
if (filter.types?.length) {
|
||||
qb.andWhere("invoice.type IN (:...types)", { types: filter.types });
|
||||
}
|
||||
if (filter.eimsStatuses?.length) {
|
||||
qb.andWhere("invoice.eimsStatus IN (:...eimsStatuses)", {
|
||||
eimsStatuses: filter.eimsStatuses,
|
||||
|
||||
@@ -22,6 +22,7 @@ describe("FilterInvoiceDto", () => {
|
||||
search: "INV-2026",
|
||||
statuses: "PENDING,OVERDUE",
|
||||
sources: "booking,warehouse",
|
||||
types: "PREPAID,WAGON_CANCEL_FEE",
|
||||
eimsStatuses: "NOT_SUBMITTED",
|
||||
currency: "etb",
|
||||
issuedFrom: "2026-08-01T00:00:00.000Z",
|
||||
@@ -39,6 +40,7 @@ describe("FilterInvoiceDto", () => {
|
||||
expect(errors).toEqual([]);
|
||||
expect(dto.statuses).toEqual(["PENDING", "OVERDUE"]);
|
||||
expect(dto.sources).toEqual(["booking", "warehouse"]);
|
||||
expect(dto.types).toEqual(["PREPAID", "WAGON_CANCEL_FEE"]);
|
||||
expect(dto.currency).toBe("ETB");
|
||||
expect(dto.minAmount).toBe(100);
|
||||
expect(dto.hasBalance).toBe(true);
|
||||
|
||||
@@ -89,6 +89,18 @@ export class FilterInvoiceDto {
|
||||
@IsIn(Object.values(Freight.InvoiceSource), { each: true })
|
||||
sources?: Freight.InvoiceSource[];
|
||||
|
||||
/**
|
||||
* What the invoice bills for (`?types=PREPAID,WAGON_CANCEL_FEE`). Free-form
|
||||
* like `paymentMethods`: every billing source mints its own `type` string, so
|
||||
* an `IsIn` here would silently drop a real value.
|
||||
*/
|
||||
@ApiPropertyOptional({ isArray: true, example: ["PREPAID"] })
|
||||
@IsOptional()
|
||||
@Transform(csv)
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
types?: string[];
|
||||
|
||||
/** MoR filing state — Finance's "what still needs registering" cut. */
|
||||
@ApiPropertyOptional({ isArray: true, enum: EimsInvoiceStatus })
|
||||
@IsOptional()
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import {
|
||||
CARGO_TYPE_SUBTREE_SQL,
|
||||
bookingContainerCountSql,
|
||||
bookingContainerVgmSql,
|
||||
bookingContentMatchSql,
|
||||
bookingContentSql,
|
||||
bookingHasContainerTypeSql,
|
||||
} from './booking-content.sql';
|
||||
|
||||
describe('bookingContentSql', () => {
|
||||
const sql = bookingContentSql('b');
|
||||
|
||||
it('prefers the container lines, since container bookings carry no description', () => {
|
||||
expect(sql.indexOf('freight.booking_container')).toBeLessThan(
|
||||
sql.indexOf('freight.cargo_types'),
|
||||
);
|
||||
expect(sql).toContain('freight.container_types');
|
||||
expect(sql).toContain('bc.deleted_at IS NULL');
|
||||
});
|
||||
|
||||
it('falls back to commodity, then to the free-text description', () => {
|
||||
expect(sql.indexOf('cgt.cargo_type_name')).toBeLessThan(
|
||||
sql.indexOf('b.cargo_free_text'),
|
||||
);
|
||||
});
|
||||
|
||||
// An empty string is not a missing value to COALESCE — without NULLIF a blank
|
||||
// description would win over the commodity behind it.
|
||||
it('treats an empty string as absent at every level', () => {
|
||||
expect(sql.match(/NULLIF/g)).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('rewrites every reference when embedded under another alias', () => {
|
||||
expect(bookingContentSql('bk')).not.toMatch(/\bb\.(cargo|id)/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CARGO_TYPE_SUBTREE_SQL', () => {
|
||||
// The filter offers groups, not just leaves, so picking "Bulk" has to reach
|
||||
// commodities at any depth beneath it — two levels today, more tomorrow.
|
||||
it('walks the tree recursively rather than one level of children', () => {
|
||||
expect(CARGO_TYPE_SUBTREE_SQL).toContain('WITH RECURSIVE');
|
||||
expect(CARGO_TYPE_SUBTREE_SQL).toContain('c.parent_group_id = sub.id');
|
||||
});
|
||||
|
||||
it('includes the picked node itself, so a leaf still matches exactly', () => {
|
||||
expect(CARGO_TYPE_SUBTREE_SQL).toContain('WHERE id = :cargoTypeId');
|
||||
});
|
||||
});
|
||||
|
||||
describe('bookingContentMatchSql', () => {
|
||||
const sql = bookingContentMatchSql('b');
|
||||
|
||||
it('searches all three places content can live', () => {
|
||||
expect(sql).toContain('b.cargo_free_text ILIKE :cargoText');
|
||||
expect(sql).toContain('cgt.cargo_type_name ILIKE :cargoText');
|
||||
expect(sql).toContain('cnt.code ILIKE :cargoText');
|
||||
});
|
||||
|
||||
// Anything but OR would make the text box match nothing for whole freight
|
||||
// types — a container booking has no commodity, a bulk one has no container.
|
||||
it('ORs them, and stays one parenthesised term for andWhere', () => {
|
||||
expect(sql).not.toContain(' AND :cargoText');
|
||||
expect(sql.startsWith('(')).toBe(true);
|
||||
expect(sql.trimEnd().endsWith(')')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('bookingContainerCountSql', () => {
|
||||
// booking_container is one row per LINE carrying a quantity, so counting rows
|
||||
// would report a 54-container booking as 1.
|
||||
it('sums the line quantities rather than counting lines', () => {
|
||||
expect(bookingContainerCountSql('b')).toContain('SUM(bc.quantity)');
|
||||
expect(bookingContainerCountSql('b')).not.toContain('COUNT(');
|
||||
});
|
||||
|
||||
it('counts every type by default and one type when scoped', () => {
|
||||
expect(bookingContainerCountSql('b')).not.toContain('container_type_id');
|
||||
expect(bookingContainerCountSql('b', true)).toContain(
|
||||
'bc.container_type_id = :containerTypeId',
|
||||
);
|
||||
});
|
||||
|
||||
it('is 0, never NULL, so a bound comparison still decides', () => {
|
||||
expect(bookingContainerCountSql('b')).toContain('COALESCE(SUM(bc.quantity), 0)');
|
||||
});
|
||||
|
||||
it('ignores soft-deleted lines', () => {
|
||||
expect(bookingContainerCountSql('b')).toContain('bc.deleted_at IS NULL');
|
||||
expect(bookingHasContainerTypeSql('b')).toContain('bc.deleted_at IS NULL');
|
||||
});
|
||||
|
||||
it('rewrites the booking reference under another alias', () => {
|
||||
expect(bookingContainerCountSql('bk')).toContain('bc.booking_id = bk.id');
|
||||
expect(bookingHasContainerTypeSql('bk')).toContain('bc.booking_id = bk.id');
|
||||
});
|
||||
});
|
||||
|
||||
describe('bookingContainerVgmSql', () => {
|
||||
// The whole point: b.cargo_total_weight_vgm is 0 for portal container
|
||||
// bookings, so the weight has to come off the lines.
|
||||
it('reads the lines, never the booking-level column', () => {
|
||||
const sql = bookingContainerVgmSql('b');
|
||||
expect(sql).toContain('SUM(bc.total_vgm_tons)');
|
||||
expect(sql).not.toContain('cargo_total_weight_vgm');
|
||||
expect(sql).toContain('bc.deleted_at IS NULL');
|
||||
});
|
||||
});
|
||||
100
apps/edr-freight-api/src/modules/bookings/booking-content.sql.ts
Normal file
100
apps/edr-freight-api/src/modules/bookings/booking-content.sql.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* What the customer said is IN the booking, per freight type — the list
|
||||
* filter, the summary and the export all read this one expression so the
|
||||
* column, the pill and the sheet can never disagree.
|
||||
*
|
||||
* BULK the commodity picked from the cargo tree (`cargo_types`), falling
|
||||
* back to the free-text description for a bare group or a legacy row
|
||||
* that has no commodity.
|
||||
* CONTAINER the wizard asks for no description at all — VGM and contents are
|
||||
* captured later in operations — so the closest thing to the
|
||||
* customer's own words is the container lines they entered:
|
||||
* "2 × 40FT, 1 × 20FT".
|
||||
*
|
||||
* Containers are checked FIRST: a container booking has no `cargo_type_id`
|
||||
* (the API rejects one), so the order only matters for a mixed legacy row,
|
||||
* where the physical lines are the better answer.
|
||||
*/
|
||||
export function bookingContentSql(alias = 'b'): string {
|
||||
return `COALESCE(
|
||||
NULLIF((SELECT string_agg(bc.quantity || ' × ' || COALESCE(cnt.label, cnt.code), ', '
|
||||
ORDER BY cnt.size_ft DESC NULLS LAST, cnt.code)
|
||||
FROM freight.booking_container bc
|
||||
JOIN freight.container_types cnt ON cnt.id = bc.container_type_id
|
||||
WHERE bc.booking_id = ${alias}.id AND bc.deleted_at IS NULL), ''),
|
||||
NULLIF((SELECT cgt.cargo_type_name FROM freight.cargo_types cgt
|
||||
WHERE cgt.id = ${alias}.cargo_type_id), ''),
|
||||
NULLIF(${alias}.cargo_free_text, ''))`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cargo types at or under `:cargoTypeId`, so picking a GROUP in the filter
|
||||
* matches every commodity beneath it — the same group→commodity drill-down the
|
||||
* booking wizard offers, read back. Recursive because `cargo_types` is an
|
||||
* arbitrary-depth tree (Bulk → Steel Billet → S1 → …), not two levels.
|
||||
*/
|
||||
export const CARGO_TYPE_SUBTREE_SQL = `(
|
||||
WITH RECURSIVE sub AS (
|
||||
SELECT id FROM freight.cargo_types WHERE id = :cargoTypeId
|
||||
UNION ALL
|
||||
SELECT c.id FROM freight.cargo_types c JOIN sub ON c.parent_group_id = sub.id
|
||||
)
|
||||
SELECT id FROM sub)`;
|
||||
|
||||
/**
|
||||
* Contains-match over every part of the content a customer can type or pick:
|
||||
* their own description, the commodity's name, and the container types on the
|
||||
* booking. Bind `:cargoText` already wrapped in `%`.
|
||||
*/
|
||||
export function bookingContentMatchSql(alias = 'b'): string {
|
||||
return `(${alias}.cargo_free_text ILIKE :cargoText
|
||||
OR EXISTS (SELECT 1 FROM freight.cargo_types cgt
|
||||
WHERE cgt.id = ${alias}.cargo_type_id
|
||||
AND cgt.cargo_type_name ILIKE :cargoText)
|
||||
OR EXISTS (SELECT 1 FROM freight.booking_container bc
|
||||
JOIN freight.container_types cnt ON cnt.id = bc.container_type_id
|
||||
WHERE bc.booking_id = ${alias}.id AND bc.deleted_at IS NULL
|
||||
AND (cnt.label ILIKE :cargoText OR cnt.code ILIKE :cargoText)))`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Containers on a booking, as a count of physical boxes — `booking_container`
|
||||
* is one row PER LINE with a `quantity`, not one row per box, so this sums the
|
||||
* quantity rather than counting rows.
|
||||
*
|
||||
* `scopedToType` narrows the sum to `:containerTypeId`, which is what makes one
|
||||
* number filter answer both "10 containers in total" and "10 forty-footers":
|
||||
* the count filter reads the container-type filter when one is set, and counts
|
||||
* every type when it is not.
|
||||
*/
|
||||
export function bookingContainerCountSql(alias = 'b', scopedToType = false): string {
|
||||
return `(SELECT COALESCE(SUM(bc.quantity), 0)
|
||||
FROM freight.booking_container bc
|
||||
WHERE bc.booking_id = ${alias}.id
|
||||
AND bc.deleted_at IS NULL${
|
||||
scopedToType ? '\n AND bc.container_type_id = :containerTypeId' : ''
|
||||
})`;
|
||||
}
|
||||
|
||||
/** Bookings carrying at least one line of `:containerTypeId`. */
|
||||
export function bookingHasContainerTypeSql(alias = 'b'): string {
|
||||
return `EXISTS (SELECT 1 FROM freight.booking_container bc
|
||||
WHERE bc.booking_id = ${alias}.id
|
||||
AND bc.deleted_at IS NULL
|
||||
AND bc.container_type_id = :containerTypeId)`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Container VGM on a booking, in tons — the sum of the per-line totals.
|
||||
*
|
||||
* NOT `bookings.cargo_total_weight_vgm`: the portal wizard leaves that at 0 for
|
||||
* container freight (VGM is captured per container, later, in operations), so
|
||||
* reading the booking-level column showed every portal container booking as
|
||||
* weighing nothing. Same reason `bookingTonsSql` falls through to these lines.
|
||||
*/
|
||||
export function bookingContainerVgmSql(alias = 'b'): string {
|
||||
return `(SELECT COALESCE(SUM(bc.total_vgm_tons), 0)
|
||||
FROM freight.booking_container bc
|
||||
WHERE bc.booking_id = ${alias}.id
|
||||
AND bc.deleted_at IS NULL)`;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { bookingTonsSql } from './booking-tons.sql';
|
||||
|
||||
describe('bookingTonsSql', () => {
|
||||
const sql = bookingTonsSql('b');
|
||||
|
||||
// The regression this exists for: a plain COALESCE stops at the portal's
|
||||
// literal 0 for container bookings and reports them as weighing nothing.
|
||||
it('treats a stored 0 as "no figure" on both booking-level columns', () => {
|
||||
expect(sql).toContain('NULLIF(b.bulk_total_weight_tons, 0)');
|
||||
expect(sql).toContain('NULLIF(b.cargo_total_weight_vgm, 0)');
|
||||
});
|
||||
|
||||
it('falls back to the per-line container VGM, excluding soft-deleted lines', () => {
|
||||
expect(sql).toContain('SUM(bc.total_vgm_tons)');
|
||||
expect(sql).toContain('freight.booking_container bc');
|
||||
expect(sql).toContain('bc.booking_id = b.id');
|
||||
expect(sql).toContain('bc.deleted_at IS NULL');
|
||||
});
|
||||
|
||||
it('never returns NULL, so callers may SUM it directly', () => {
|
||||
expect(sql.trimEnd().endsWith('0)')).toBe(true);
|
||||
});
|
||||
|
||||
it('rewrites every reference when embedded under another alias', () => {
|
||||
const aliased = bookingTonsSql('bk');
|
||||
expect(aliased).not.toMatch(/\bb\./);
|
||||
expect(aliased).toContain('bk.cargo_total_weight_vgm');
|
||||
expect(aliased).toContain('bc.booking_id = bk.id');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* SQL mirror of `bookingCargoTons()` (train-scheduling/train-capacity.util.ts).
|
||||
*
|
||||
* Three storage conventions share `bookings.cargo_total_weight_vgm`:
|
||||
* - BULK PER_TON — the column holds tons.
|
||||
* - BULK PER_ITEM — the column holds an ITEM COUNT; the tons are in
|
||||
* `bulk_total_weight_tons`.
|
||||
* - CONTAINER — the portal wizard captures VGM per line, not per booking,
|
||||
* and sends 0 (portal NewBookingPage: "containers carry NO weight at the
|
||||
* wizard"). The tons live in `booking_container.total_vgm_tons`. The
|
||||
* backoffice wizard does store a booking-level total, so both shapes exist
|
||||
* in the same table.
|
||||
*
|
||||
* Hence NULLIF on both columns: a plain
|
||||
* `COALESCE(bulk_total_weight_tons, cargo_total_weight_vgm)` stops at the
|
||||
* portal's 0 — COALESCE falls through on NULL, never on 0 — and every
|
||||
* portal-created container booking reads as 0 tons in exports and reports.
|
||||
*/
|
||||
export function bookingTonsSql(alias = 'b'): string {
|
||||
return `COALESCE(
|
||||
NULLIF(${alias}.bulk_total_weight_tons, 0),
|
||||
NULLIF(${alias}.cargo_total_weight_vgm, 0),
|
||||
(SELECT SUM(bc.total_vgm_tons) FROM freight.booking_container bc
|
||||
WHERE bc.booking_id = ${alias}.id AND bc.deleted_at IS NULL),
|
||||
0)`;
|
||||
}
|
||||
@@ -21,6 +21,12 @@ import { ShippingLineCompany } from '../shipping-lines/entities/shipping-line-co
|
||||
import { ContractRateSnapshot } from '../contracts/entities/contract-rate-snapshot.entity';
|
||||
import { ContractRoute } from '../contracts/entities/contract-route.entity';
|
||||
import { applyDirectionScope } from '../user-trade-access/trade-scope.util';
|
||||
import {
|
||||
CARGO_TYPE_SUBTREE_SQL,
|
||||
bookingContainerCountSql,
|
||||
bookingContentMatchSql,
|
||||
bookingHasContainerTypeSql,
|
||||
} from './booking-content.sql';
|
||||
import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity';
|
||||
import {
|
||||
BookingDocumentReview,
|
||||
@@ -65,7 +71,14 @@ export interface BookingListFilterOptions {
|
||||
contractId?: string;
|
||||
contractType?: string;
|
||||
serviceTypeId?: string;
|
||||
/** Cargo type OR cargo group — a group matches every commodity beneath it. */
|
||||
cargoTypeId?: string;
|
||||
/** Contains-search over content: description, commodity name, container types. */
|
||||
cargoText?: string;
|
||||
/** Bookings carrying this container type; also scopes the container count. */
|
||||
containerTypeId?: string;
|
||||
containersMin?: number;
|
||||
containersMax?: number;
|
||||
freightType?: string;
|
||||
bookingType?: string;
|
||||
tradeDirection?: string;
|
||||
@@ -1176,11 +1189,42 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
serviceTypeId: options.serviceTypeId,
|
||||
});
|
||||
}
|
||||
// A group is selectable in the filter, not just a leaf commodity, so this
|
||||
// matches the whole subtree — picking "Bulk" must return every commodity
|
||||
// under it, the same drill-down the booking wizard offers, read back.
|
||||
if (options.cargoTypeId) {
|
||||
qb.andWhere('booking.cargo_type_id = :cargoTypeId', {
|
||||
qb.andWhere(`booking.cargo_type_id IN ${CARGO_TYPE_SUBTREE_SQL}`, {
|
||||
cargoTypeId: options.cargoTypeId,
|
||||
});
|
||||
}
|
||||
if (options.cargoText) {
|
||||
qb.andWhere(bookingContentMatchSql('booking'), {
|
||||
cargoText: `%${options.cargoText}%`,
|
||||
});
|
||||
}
|
||||
if (options.containerTypeId) {
|
||||
qb.andWhere(bookingHasContainerTypeSql('booking'), {
|
||||
containerTypeId: options.containerTypeId,
|
||||
});
|
||||
}
|
||||
// One count filter, two questions: with a container type picked it counts
|
||||
// that type, without one it counts every box on the booking.
|
||||
if (options.containersMin != null || options.containersMax != null) {
|
||||
const count = bookingContainerCountSql(
|
||||
'booking',
|
||||
Boolean(options.containerTypeId),
|
||||
);
|
||||
if (options.containersMin != null) {
|
||||
qb.andWhere(`${count} >= :containersMin`, {
|
||||
containersMin: options.containersMin,
|
||||
});
|
||||
}
|
||||
if (options.containersMax != null) {
|
||||
qb.andWhere(`${count} <= :containersMax`, {
|
||||
containersMax: options.containersMax,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (omit !== 'freightType' && options.freightType) {
|
||||
qb.andWhere('booking.freight_type = :freightType', {
|
||||
freightType: options.freightType,
|
||||
|
||||
@@ -1845,6 +1845,10 @@ export class BookingsService {
|
||||
contractType: filter.contractType,
|
||||
serviceTypeId: filter.serviceTypeId,
|
||||
cargoTypeId: filter.cargoTypeId,
|
||||
cargoText: filter.cargoText,
|
||||
containerTypeId: filter.containerTypeId,
|
||||
containersMin: filter.containersMin,
|
||||
containersMax: filter.containersMax,
|
||||
freightType: filter.freightType,
|
||||
bookingType: filter.bookingType,
|
||||
tradeDirection: filter.tradeDirection,
|
||||
@@ -2072,6 +2076,10 @@ export class BookingsService {
|
||||
contractType: filter.contractType,
|
||||
serviceTypeId: filter.serviceTypeId,
|
||||
cargoTypeId: filter.cargoTypeId,
|
||||
cargoText: filter.cargoText,
|
||||
containerTypeId: filter.containerTypeId,
|
||||
containersMin: filter.containersMin,
|
||||
containersMax: filter.containersMax,
|
||||
freightType: filter.freightType,
|
||||
bookingType: filter.bookingType,
|
||||
tradeDirection: filter.tradeDirection,
|
||||
|
||||
@@ -62,11 +62,47 @@ export class FilterBookingDto {
|
||||
@IsUUID()
|
||||
serviceTypeId?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@ApiPropertyOptional({
|
||||
format: 'uuid',
|
||||
description:
|
||||
'Cargo type OR cargo group — a group matches every commodity beneath it',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
cargoTypeId?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'Contains-search over booking content: cargo description, commodity name, container types',
|
||||
})
|
||||
@IsOptional()
|
||||
@Transform(({ value }) =>
|
||||
typeof value === 'string' && value.trim() ? value.trim() : undefined,
|
||||
)
|
||||
cargoText?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
format: 'uuid',
|
||||
description:
|
||||
'Bookings carrying this container type. Also scopes containersMin/Max to it.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
containerTypeId?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'Minimum container count — of containerTypeId when set, else of all types',
|
||||
})
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => (value === '' || value == null ? undefined : Number(value)))
|
||||
containersMin?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Maximum container count — see containersMin' })
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => (value === '' || value == null ? undefined : Number(value)))
|
||||
containersMax?: number;
|
||||
|
||||
@ApiPropertyOptional({ enum: FREIGHT_TYPES })
|
||||
@IsOptional()
|
||||
@IsIn([...FREIGHT_TYPES])
|
||||
|
||||
@@ -1,4 +1,15 @@
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
import { FREIGHT_PERMS } from '../../../seed/freight-permissions.registry';
|
||||
import {
|
||||
CARGO_TYPE_SUBTREE_SQL,
|
||||
bookingContainerCountSql,
|
||||
bookingContentMatchSql,
|
||||
bookingContainerVgmSql,
|
||||
bookingContentSql,
|
||||
bookingHasContainerTypeSql,
|
||||
} from '../../bookings/booking-content.sql';
|
||||
import { bookingTonsSql } from '../../bookings/booking-tons.sql';
|
||||
import { Booking } from '../../bookings/entities/booking.entity';
|
||||
import { Company } from '../../companies/entities/company.entity';
|
||||
import { CompanyProfile } from '../../companies/entities/company-profile.entity';
|
||||
@@ -10,23 +21,103 @@ import { Yard } from '../../rule-engine/entities/yard.entity';
|
||||
import { ShippingLineCompany } from '../../shipping-lines/entities/shipping-line-company.entity';
|
||||
import { Train } from '../../trains/entities/train.entity';
|
||||
import { applyDirectionScope } from '../../user-trade-access/trade-scope.util';
|
||||
import { ExportDataset } from '../export.types';
|
||||
import { ExportFilterOption } from '../export-filter.util';
|
||||
import { ExportDataset, ExportField } from '../export.types';
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Tonnage is `bookingTonsSql` — the one resolver for the three ways a booking
|
||||
* stores its weight. `adjusted_total_amount` silently overrides `total_amount`.
|
||||
* Getting either wrong misreports money or weight.
|
||||
*/
|
||||
const TONS = 'COALESCE(b.bulk_total_weight_tons, b.cargo_total_weight_vgm)';
|
||||
const TONS = bookingTonsSql('b');
|
||||
const REVENUE = 'COALESCE(b.adjusted_total_amount, b.total_amount)';
|
||||
|
||||
/** What the customer described as the booking's contents — see the helper. */
|
||||
const CONTENT = bookingContentSql('b');
|
||||
const CONTAINER_COUNT = bookingContainerCountSql('b');
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
'DRAFT', 'SUBMITTED', 'UNDER_REVIEW', 'APPROVED', 'REJECTED',
|
||||
'CANCELLED', 'EXPIRED', 'SCHEDULED', 'LOADED', 'IN_TRANSIT',
|
||||
'ARRIVED', 'DELIVERED', 'COMPLETED',
|
||||
].map((v) => ({ value: v, label: v.replace(/_/g, ' ') }));
|
||||
|
||||
/**
|
||||
* Cargo tree flattened for a single select: groups and every commodity beneath
|
||||
* them, each labelled by its full path ("Bulk → Wheat") the way the booking
|
||||
* wizard shows a deep leaf. Picking a group row filters its whole subtree.
|
||||
*
|
||||
* Recursive because `cargo_types` is arbitrary-depth, not two levels.
|
||||
*/
|
||||
async function cargoTypeOptions(ds: DataSource): Promise<ExportFilterOption[]> {
|
||||
return ds.query(`
|
||||
WITH RECURSIVE t AS (
|
||||
SELECT id, display_order, 0 AS depth,
|
||||
ARRAY[display_order]::int[] AS ord,
|
||||
ARRAY[cargo_type_name]::text[] AS path
|
||||
FROM freight.cargo_types
|
||||
WHERE parent_group_id IS NULL AND deleted_at IS NULL AND is_active
|
||||
UNION ALL
|
||||
SELECT c.id, c.display_order, t.depth + 1,
|
||||
t.ord || c.display_order,
|
||||
t.path || c.cargo_type_name
|
||||
FROM freight.cargo_types c
|
||||
JOIN t ON c.parent_group_id = t.id
|
||||
WHERE c.deleted_at IS NULL AND c.is_active
|
||||
)
|
||||
SELECT id AS value, array_to_string(path, ' → ') AS label
|
||||
FROM t ORDER BY ord, path
|
||||
`) as Promise<ExportFilterOption[]>;
|
||||
}
|
||||
|
||||
/** Container types are 2 rows that change about never. */
|
||||
async function containerTypeOptions(ds: DataSource): Promise<ExportFilterOption[]> {
|
||||
return ds.query(`
|
||||
SELECT id AS value, COALESCE(label, code) AS label
|
||||
FROM freight.container_types
|
||||
WHERE deleted_at IS NULL AND is_active
|
||||
ORDER BY display_order, code
|
||||
`) as Promise<ExportFilterOption[]>;
|
||||
}
|
||||
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
/**
|
||||
* One column per container type ("20FT", "40FT", …), each the box count of
|
||||
* that type on the booking. Resolved from `container_types` rather than
|
||||
* hardcoded, so adding a 45ft adds its column without a deploy of this file.
|
||||
*
|
||||
* The type id is INTERPOLATED, not bound — `ExportField.select` is a raw SQL
|
||||
* string with no parameter bag — so ids that are not uuids are dropped rather
|
||||
* than spliced. They come from our own table; the guard is for the day someone
|
||||
* changes that column's type.
|
||||
*/
|
||||
async function containerTypeFields(ds: DataSource): Promise<ExportField[]> {
|
||||
const rows: Array<{ id: string; code: string; label: string | null }> = await ds.query(`
|
||||
SELECT id, code, label
|
||||
FROM freight.container_types
|
||||
WHERE deleted_at IS NULL AND is_active
|
||||
ORDER BY display_order, code
|
||||
`);
|
||||
return rows
|
||||
.filter((r) => UUID_RE.test(r.id))
|
||||
.map((r) => {
|
||||
const name = r.label || r.code;
|
||||
return {
|
||||
key: `containers${r.code.replace(/[^A-Za-z0-9]/g, '')}`,
|
||||
label: `${name} containers`,
|
||||
type: 'number' as const,
|
||||
group: 'cargo',
|
||||
select: `(SELECT COALESCE(SUM(bc.quantity), 0)
|
||||
FROM freight.booking_container bc
|
||||
WHERE bc.booking_id = b.id
|
||||
AND bc.deleted_at IS NULL
|
||||
AND bc.container_type_id = '${r.id}')::int`,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export const bookingsDataset: ExportDataset = {
|
||||
key: 'bookings',
|
||||
title: 'Bookings',
|
||||
@@ -112,10 +203,18 @@ export const bookingsDataset: ExportDataset = {
|
||||
{ key: 'serviceType', label: 'Service type', type: 'string', group: 'route', requires: ['st'], select: 'st.service_name' },
|
||||
|
||||
// ---- Cargo -----------------------------------------------------------
|
||||
{ key: 'cargo', label: 'Cargo', type: 'string', group: 'cargo', default: true, requires: ['cty'], select: 'COALESCE(cty.cargo_type_name, b.cargo_free_text)' },
|
||||
// What the customer said is in the booking. `cargo` below is the narrower
|
||||
// commodity-only view, kept for saved presets that already tick it.
|
||||
{ key: 'content', label: 'Content', type: 'string', group: 'cargo', default: true, select: CONTENT, sortExpr: CONTENT },
|
||||
{ key: 'cargo', label: 'Cargo (commodity)', type: 'string', group: 'cargo', requires: ['cty'], select: 'COALESCE(cty.cargo_type_name, b.cargo_free_text)' },
|
||||
{ key: 'cargoDescription', label: 'Cargo description', type: 'string', group: 'cargo', select: 'b.cargo_free_text' },
|
||||
// Boxes, not lines: booking_container is one row per LINE with a quantity.
|
||||
{ key: 'containerCount', label: 'Containers', type: 'number', group: 'cargo', default: true, select: `${CONTAINER_COUNT}::int`, sortExpr: CONTAINER_COUNT },
|
||||
{ key: 'freightType', label: 'Freight type', type: 'string', group: 'cargo', default: true, select: 'b.freight_type' },
|
||||
{ key: 'tons', label: 'Tonnage', type: 'tons', group: 'cargo', default: true, select: `ROUND(${TONS})::float8`, sortExpr: TONS },
|
||||
{ key: 'containerWeightVgm', label: 'Container VGM', type: 'number', group: 'cargo', select: 'b.cargo_total_weight_vgm' },
|
||||
// The per-line sum, NOT b.cargo_total_weight_vgm — the portal leaves that
|
||||
// column at 0 for container freight, so it read 0 for every such booking.
|
||||
{ key: 'containerWeightVgm', label: 'Container VGM (t)', type: 'tons', group: 'cargo', select: `${bookingContainerVgmSql('b')}::float8`, sortExpr: bookingContainerVgmSql('b') },
|
||||
{ key: 'bulkWeightTons', label: 'Bulk weight (t)', type: 'tons', group: 'cargo', select: 'b.bulk_total_weight_tons' },
|
||||
{ key: 'isHazardous', label: 'Hazardous', type: 'boolean', group: 'cargo', select: 'b.is_hazardous' },
|
||||
{ key: 'isReefer', label: 'Reefer', type: 'boolean', group: 'cargo', select: 'b.is_reefer' },
|
||||
@@ -172,6 +271,8 @@ export const bookingsDataset: ExportDataset = {
|
||||
{ key: 'doubleHandling', label: 'Double handling', type: 'boolean', group: 'clearance', select: 'b.double_handling' },
|
||||
],
|
||||
|
||||
dynamicFields: containerTypeFields,
|
||||
|
||||
filters: [
|
||||
{ key: 'created', label: 'Created', type: 'daterange' },
|
||||
{ key: 'statuses', label: 'Status', type: 'multiselect', options: STATUS_OPTIONS },
|
||||
@@ -190,6 +291,11 @@ export const bookingsDataset: ExportDataset = {
|
||||
{ value: 'PAID', label: 'Paid' },
|
||||
{ value: 'FAILED', label: 'Failed' },
|
||||
] },
|
||||
{ key: 'cargoTypeId', label: 'Content (cargo type)', type: 'select', optionsQuery: cargoTypeOptions },
|
||||
{ key: 'cargoText', label: 'Content contains', type: 'text' },
|
||||
{ key: 'containerTypeId', label: 'Container type', type: 'select', optionsQuery: containerTypeOptions },
|
||||
{ key: 'containersMin', label: 'Containers (min)', type: 'text' },
|
||||
{ key: 'containersMax', label: 'Containers (max)', type: 'text' },
|
||||
{ key: 'companyId', label: 'Customer', type: 'text' },
|
||||
{ key: 'search', label: 'Search reference or customer', type: 'text' },
|
||||
],
|
||||
@@ -210,6 +316,19 @@ export const bookingsDataset: ExportDataset = {
|
||||
|
||||
if (params.tradeDirection) qb.andWhere('b.trade_direction = :tradeDirection', { tradeDirection: params.tradeDirection });
|
||||
if (params.freightType) qb.andWhere('b.freight_type = :freightType', { freightType: params.freightType });
|
||||
// Group or leaf — a group matches its whole subtree (see CARGO_TYPE_SUBTREE_SQL).
|
||||
if (params.cargoTypeId) qb.andWhere(`b.cargo_type_id IN ${CARGO_TYPE_SUBTREE_SQL}`, { cargoTypeId: params.cargoTypeId });
|
||||
if (params.cargoText) qb.andWhere(bookingContentMatchSql('b'), { cargoText: `%${params.cargoText as string}%` });
|
||||
if (params.containerTypeId) qb.andWhere(bookingHasContainerTypeSql('b'), { containerTypeId: params.containerTypeId });
|
||||
// With a container type picked the count is of THAT type, else of every box.
|
||||
const containerCount = bookingContainerCountSql('b', Boolean(params.containerTypeId));
|
||||
// coerceFilterParams yields null (not undefined) for an unset filter, and
|
||||
// Number(null) is 0 — which would silently apply ">= 0" to every export.
|
||||
const num = (v: unknown) => (v == null || v === '' ? NaN : Number(v));
|
||||
const min = num(params.containersMin);
|
||||
const max = num(params.containersMax);
|
||||
if (Number.isFinite(min)) qb.andWhere(`${containerCount} >= :containersMin`, { containersMin: min });
|
||||
if (Number.isFinite(max)) qb.andWhere(`${containerCount} <= :containersMax`, { containersMax: max });
|
||||
if (params.paymentStatus) qb.andWhere('b.payment_status = :paymentStatus', { paymentStatus: params.paymentStatus });
|
||||
if (params.companyId) qb.andWhere('b.company_id = :companyId', { companyId: params.companyId });
|
||||
if (params.search) {
|
||||
|
||||
@@ -123,6 +123,7 @@ export const invoicesDataset: ExportDataset = {
|
||||
// on-screen filter actually carries into the export.
|
||||
{ key: 'status', label: 'Status (single)', type: 'text' },
|
||||
{ key: 'sources', label: 'Source', type: 'multiselect' },
|
||||
{ key: 'types', label: 'Type', type: 'multiselect' },
|
||||
{ key: 'eimsStatuses', label: 'EIMS status', type: 'multiselect' },
|
||||
{ key: 'paymentMethods', label: 'Payment method', type: 'multiselect' },
|
||||
{ key: 'currency', label: 'Currency', type: 'select', options: [
|
||||
@@ -151,6 +152,8 @@ export const invoicesDataset: ExportDataset = {
|
||||
if (params.status) qb.andWhere('i.status = :status', { status: params.status });
|
||||
const sources = params.sources as string[] | null;
|
||||
if (sources?.length) qb.andWhere('i.source IN (:...sources)', { sources });
|
||||
const types = params.types as string[] | null;
|
||||
if (types?.length) qb.andWhere('i.type IN (:...types)', { types });
|
||||
const eimsStatuses = params.eimsStatuses as string[] | null;
|
||||
if (eimsStatuses?.length) qb.andWhere('i.eims_status IN (:...eimsStatuses)', { eimsStatuses });
|
||||
const paymentMethods = params.paymentMethods as string[] | null;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { FREIGHT_PERMS } from '../../../seed/freight-permissions.registry';
|
||||
import { bookingTonsSql } from '../../bookings/booking-tons.sql';
|
||||
import { Route } from '../../routes/entities/route.entity';
|
||||
import { Yard } from '../../rule-engine/entities/yard.entity';
|
||||
import { ShippingLineCompany } from '../../shipping-lines/entities/shipping-line-company.entity';
|
||||
@@ -86,7 +87,7 @@ export const trainSchedulesDataset: ExportDataset = {
|
||||
},
|
||||
{
|
||||
key: 'totalWeightTons', label: 'Total weight (t)', type: 'tons', group: 'load', default: true,
|
||||
select: `(SELECT ROUND(COALESCE(SUM(COALESCE(b.bulk_total_weight_tons, b.cargo_total_weight_vgm)), 0))::float8
|
||||
select: `(SELECT ROUND(COALESCE(SUM(${bookingTonsSql('b')}), 0))::float8
|
||||
FROM freight.bookings b
|
||||
WHERE b.train_schedule_id = sch.id AND b.deleted_at IS NULL)`,
|
||||
},
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
import type { ExportField } from './export.types';
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
export type ExportFilterType = 'daterange' | 'date' | 'select' | 'multiselect' | 'text';
|
||||
@@ -64,6 +66,26 @@ export function coerceFilterParams(
|
||||
*/
|
||||
const optionsCache = new Map<string, ExportFilterOption[]>();
|
||||
|
||||
/** Process-lifetime cache for `dynamicFields`, keyed by dataset. */
|
||||
const fieldsCache = new Map<string, ExportField[]>();
|
||||
|
||||
/**
|
||||
* A dataset's full field list: its static fields plus whatever `dynamicFields`
|
||||
* resolves from the DB. Every read of `dataset.fields` goes through this, so
|
||||
* the catalog and the download agree on which keys exist.
|
||||
*/
|
||||
export async function resolveDatasetFields(
|
||||
dataset: { key: string; fields: ExportField[]; dynamicFields?: (ds: DataSource) => Promise<ExportField[]> },
|
||||
ds: DataSource,
|
||||
): Promise<ExportField[]> {
|
||||
if (!dataset.dynamicFields) return dataset.fields;
|
||||
const cached = fieldsCache.get(dataset.key);
|
||||
if (cached) return cached;
|
||||
const resolved = [...dataset.fields, ...(await dataset.dynamicFields(ds))];
|
||||
fieldsCache.set(dataset.key, resolved);
|
||||
return resolved;
|
||||
}
|
||||
|
||||
export async function resolveFilterOptions(
|
||||
filters: ExportFilterDef[],
|
||||
ds: DataSource,
|
||||
|
||||
@@ -103,6 +103,15 @@ export interface ExportDataset {
|
||||
alwaysJoin?: string[];
|
||||
groups: ExportGroup[];
|
||||
fields: ExportField[];
|
||||
/**
|
||||
* Extra fields resolved from reference data and appended to `fields` — one
|
||||
* column per row of some small, rarely-changing table (a column per container
|
||||
* type, say). Cached for the process, like `ExportFilterDef.optionsQuery`.
|
||||
*
|
||||
* The SQL these build is interpolated, not bound, so a resolver MUST validate
|
||||
* anything it splices in; see `bookingsDataset` for the uuid guard.
|
||||
*/
|
||||
dynamicFields?: (ds: DataSource) => Promise<ExportField[]>;
|
||||
filters: ExportFilterDef[];
|
||||
/** Must name a field whose `sortExpr` references only the base alias. */
|
||||
defaultSort?: { key: string; dir: 'ASC' | 'DESC' };
|
||||
|
||||
@@ -9,7 +9,7 @@ import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/curre
|
||||
|
||||
import { assertFreightPermission, hasFreightPermission } from '../../common/freight-permission.util';
|
||||
import { UserTradeAccessService } from '../user-trade-access/user-trade-access.service';
|
||||
import { resolveFilterOptions } from './export-filter.util';
|
||||
import { resolveDatasetFields, resolveFilterOptions } from './export-filter.util';
|
||||
import {
|
||||
EXPORT_MIME,
|
||||
formatRowCap,
|
||||
@@ -31,13 +31,16 @@ const CAPS = { csv: CSV_ROW_CAP, xlsx: XLSX_ROW_CAP, pdf: PDF_ROW_CAP };
|
||||
* Metadata only. `select` / `requires` / `sortExpr` are raw SQL and a map of
|
||||
* the schema — they never leave the server.
|
||||
*/
|
||||
const toCatalogEntry = (dataset: ExportDataset): ExportCatalogEntry => ({
|
||||
const toCatalogEntry = (
|
||||
dataset: ExportDataset,
|
||||
fields: ExportField[],
|
||||
): ExportCatalogEntry => ({
|
||||
key: dataset.key,
|
||||
title: dataset.title,
|
||||
description: dataset.description,
|
||||
group: dataset.group,
|
||||
groups: dataset.groups,
|
||||
fields: dataset.fields.map(({ key, label, type, group, default: isDefault }) => ({
|
||||
fields: fields.map(({ key, label, type, group, default: isDefault }) => ({
|
||||
key,
|
||||
label,
|
||||
type,
|
||||
@@ -72,7 +75,7 @@ export class ExportsController {
|
||||
const allowed = DATASETS.filter((d) => hasFreightPermission(user, d.permission));
|
||||
return Promise.all(
|
||||
allowed.map(async (d) => ({
|
||||
...toCatalogEntry(d),
|
||||
...toCatalogEntry(d, await resolveDatasetFields(d, this.dataSource)),
|
||||
filters: await resolveFilterOptions(d.filters, this.dataSource),
|
||||
})),
|
||||
);
|
||||
@@ -102,7 +105,10 @@ export class ExportsController {
|
||||
const dataset = this.resolve(key, user);
|
||||
const directions = await this.userTradeAccessService.resolveAllowedDirections(user);
|
||||
const format = resolveExportFormat(query.format);
|
||||
const fields = this.resolveFields(dataset, query.fields);
|
||||
const fields = ExportsController.pickFields(
|
||||
await resolveDatasetFields(dataset, this.dataSource),
|
||||
query.fields,
|
||||
);
|
||||
|
||||
const rows = await this.runner.run(dataset, fields, query, directions, {
|
||||
cap: formatRowCap(format),
|
||||
@@ -134,15 +140,15 @@ export class ExportsController {
|
||||
* DEFAULT set, not everything — a booking export has ~70 fields and dumping
|
||||
* all of them on an unparameterised call is nobody's intent.
|
||||
*/
|
||||
private resolveFields(dataset: ExportDataset, raw: string | undefined): ExportField[] {
|
||||
private static pickFields(all: ExportField[], raw: string | undefined): ExportField[] {
|
||||
if (raw?.trim()) {
|
||||
const picked = pickByKey(dataset.fields, raw);
|
||||
const picked = pickByKey(all, raw);
|
||||
// pickByKey falls back to everything when nothing matched; for a dataset
|
||||
// the safer read of "all keys unknown" is still the default set.
|
||||
if (picked.length !== dataset.fields.length) return picked;
|
||||
if (picked.length !== all.length) return picked;
|
||||
}
|
||||
const defaults = dataset.fields.filter((f) => f.default);
|
||||
return defaults.length ? defaults : dataset.fields;
|
||||
const defaults = all.filter((f) => f.default);
|
||||
return defaults.length ? defaults : all;
|
||||
}
|
||||
|
||||
private resolve(key: string, user: TCurrentUser): ExportDataset {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
|
||||
|
||||
import { BookingStatus } from '@edr/types';
|
||||
import { bookingTonsSql } from '../../bookings/booking-tons.sql';
|
||||
import { Booking } from '../../bookings/entities/booking.entity';
|
||||
import { Yard } from '../../rule-engine/entities/yard.entity';
|
||||
import { CargoType } from '../../rule-engine/entities/cargo-type.entity';
|
||||
@@ -9,7 +10,7 @@ import { ReportContext, ReportDefinition } from '../report.types';
|
||||
// One resolver behind "Booking per status, per port/train/date/cargo/contract
|
||||
// type" — the same breakdown Operation, Marketing, Global Logistics and the
|
||||
// Operation Report each ask for verbatim. Embed once, reuse everywhere.
|
||||
const TONS = 'COALESCE(b.bulk_total_weight_tons, b.cargo_total_weight_vgm)';
|
||||
const TONS = bookingTonsSql('b');
|
||||
const REVENUE = 'COALESCE(b.adjusted_total_amount, b.total_amount)';
|
||||
|
||||
const STATUS_OPTIONS = [...new Set(Object.values(BookingStatus))].map((v) => ({
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
|
||||
|
||||
import { bookingTonsSql } from '../../bookings/booking-tons.sql';
|
||||
import { Booking } from '../../bookings/entities/booking.entity';
|
||||
import { ReportContext, ReportDefinition } from '../report.types';
|
||||
|
||||
const TONS = 'COALESCE(b.bulk_total_weight_tons, b.cargo_total_weight_vgm)';
|
||||
const TONS = bookingTonsSql('b');
|
||||
const NOT_UMBRELLA = "(b.contract_kind IS NULL OR b.contract_kind <> 'GENERAL')";
|
||||
const DEAD_STATUSES = ['DRAFT', 'CANCELLED', 'REJECTED', 'EXPIRED'];
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
|
||||
|
||||
import { bookingTonsSql } from '../../bookings/booking-tons.sql';
|
||||
import { Company } from '../../companies/entities/company.entity';
|
||||
import { Contract } from '../../contracts/entities/contract.entity';
|
||||
import { ReportContext, ReportDefinition } from '../report.types';
|
||||
|
||||
const TONS = 'COALESCE(b.bulk_total_weight_tons, b.cargo_total_weight_vgm)';
|
||||
const TONS = bookingTonsSql('b');
|
||||
const DEAD_STATUSES = ['DRAFT', 'CANCELLED', 'REJECTED', 'EXPIRED'];
|
||||
|
||||
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
import { bookingTonsSql } from '../bookings/booking-tons.sql';
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
import { RouteMilestone } from '../routes/entities/route-milestone.entity';
|
||||
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
||||
@@ -58,7 +59,7 @@ export class IntercityService {
|
||||
b.reference AS "reference",
|
||||
b.status AS "status",
|
||||
b.freight_type AS "freightType",
|
||||
b.cargo_total_weight_vgm AS "weightTons",
|
||||
${bookingTonsSql('b')} AS "weightTons",
|
||||
b.loaded_at AS "loadedAt",
|
||||
b.arrived_at AS "arrivedAt",
|
||||
company.name AS "customer",
|
||||
|
||||
@@ -1,12 +1,32 @@
|
||||
import { Banknote, Receipt } from "lucide-react";
|
||||
import { Divider, Group, Paper, Stack, Text } from "@mantine/core";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { api } from "@/services/api";
|
||||
import type { BookingDetail } from "@/types/booking";
|
||||
|
||||
import { SectionCard } from "./detail/SectionCard";
|
||||
import { detailStyles } from "./detail/booking-detail.styles";
|
||||
|
||||
export function BookingPricingSummary({ booking }: { booking: BookingDetail }) {
|
||||
// The booking's own freight invoice: source `booking`, sourceId = booking id
|
||||
// (which `search` matches). Newest first — a re-issue supersedes the old one.
|
||||
const invoiceQuery = useQuery(
|
||||
api.invoices.list.queryOptions({
|
||||
input: {
|
||||
filter: {
|
||||
page: 1,
|
||||
pageSize: 1,
|
||||
sources: "booking",
|
||||
search: booking.id,
|
||||
sortBy: "createdAt",
|
||||
sortOrder: "DESC",
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
const invoiceNumber = invoiceQuery.data?.items[0]?.invoiceNumber ?? null;
|
||||
|
||||
const computed = Number(booking.totalAmount);
|
||||
// The booking price is computed from the contract and is NOT staff-editable.
|
||||
// A historical `adjustedTotalAmount` (from before adjustments were removed)
|
||||
@@ -49,6 +69,7 @@ export function BookingPricingSummary({ booking }: { booking: BookingDetail }) {
|
||||
</Paper>
|
||||
|
||||
<Row label="Payment status" value={booking.paymentStatus} />
|
||||
{invoiceNumber && <Row label="Invoice number" value={invoiceNumber} mono />}
|
||||
{booking.pnrCode && <Row label="PNR code" value={booking.pnrCode} mono />}
|
||||
|
||||
{lineItems.length > 0 && (
|
||||
|
||||
@@ -140,6 +140,25 @@ export default function BookingRequestsPage() {
|
||||
[refData],
|
||||
);
|
||||
|
||||
// Content options mirror the booking wizard's cargo picker: the group itself
|
||||
// — which the server expands to every commodity beneath it — then each
|
||||
// commodity, labelled by its full path so a generically-named leaf still
|
||||
// reads unambiguously. A group with no descendants is emitted by the
|
||||
// reference-data tree as its own single child; drop that duplicate.
|
||||
const cargoTypeOptions = useMemo(
|
||||
() =>
|
||||
(refData?.cargo_type ?? []).flatMap((group) => [
|
||||
{ value: group.id, label: group.name },
|
||||
...(group.children ?? [])
|
||||
.filter((child) => child.id !== group.id)
|
||||
.map((child) => ({
|
||||
value: child.id,
|
||||
label: `${group.name} → ${child.name}`,
|
||||
})),
|
||||
]),
|
||||
[refData],
|
||||
);
|
||||
|
||||
// Deep links land here pre-filtered (?statuses=A,B&tradeDirection=IMPORT) —
|
||||
// the header's document-review alarm opens exactly the undecided requests
|
||||
// it is counting down for. No sync effect needed any more: controls.values
|
||||
@@ -147,6 +166,15 @@ export default function BookingRequestsPage() {
|
||||
// already mounted just works, and every filter — direction included —
|
||||
// auto-pins its own pill the moment it has a value (FilterBar's `secondary`
|
||||
// split), so a deep link can never land behind "More filters" unseen.
|
||||
// Container types, flattened out of the reference data's size groups.
|
||||
const containerTypeOptions = useMemo(
|
||||
() =>
|
||||
(refData?.containers ?? []).flatMap((group) =>
|
||||
group.types.map((t) => ({ value: t.id, label: t.name || t.code })),
|
||||
),
|
||||
[refData],
|
||||
);
|
||||
|
||||
const bookingFilterDefs: FilterDef[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
@@ -183,6 +211,46 @@ export default function BookingRequestsPage() {
|
||||
multiple: false,
|
||||
options: FREIGHT_TYPE_OPTIONS,
|
||||
},
|
||||
{
|
||||
// Cargo group or commodity. The group row matches its whole subtree
|
||||
// server-side, so "Bulk" returns every bulk commodity under it.
|
||||
key: "cargoTypeId",
|
||||
label: "Content",
|
||||
type: "enum",
|
||||
multiple: false,
|
||||
options: cargoTypeOptions,
|
||||
},
|
||||
{
|
||||
// Containers carry no customer-written description, so this is also how
|
||||
// they are reached: it matches container types ("40FT") as well as the
|
||||
// commodity name and the bulk cargo description.
|
||||
key: "cargoText",
|
||||
label: "Content contains",
|
||||
type: "text",
|
||||
secondary: true,
|
||||
placeholder: "Commodity, description or container type",
|
||||
},
|
||||
{
|
||||
key: "containerTypeId",
|
||||
label: "Container type",
|
||||
type: "enum",
|
||||
multiple: false,
|
||||
options: containerTypeOptions,
|
||||
secondary: true,
|
||||
},
|
||||
{
|
||||
// Counts boxes. Scoped to the container-type filter when one is set, so
|
||||
// this one control answers "10 containers" and "10 forty-footers" both.
|
||||
key: "containers",
|
||||
label: "Containers",
|
||||
type: "number",
|
||||
secondary: true,
|
||||
operators: ["is", "between"],
|
||||
toParams: (v) =>
|
||||
v.op === "between"
|
||||
? { containersMin: v.v[0], containersMax: v.v[1] }
|
||||
: { containersMin: v.v[0], containersMax: v.v[0] },
|
||||
},
|
||||
{
|
||||
key: "serviceTypeId",
|
||||
label: "Service",
|
||||
@@ -244,7 +312,13 @@ export default function BookingRequestsPage() {
|
||||
toParams: dateRangeParams("scheduledFrom", "scheduledTo"),
|
||||
},
|
||||
],
|
||||
[filterOptions, yardOptions, serviceTypeOptions],
|
||||
[
|
||||
filterOptions,
|
||||
yardOptions,
|
||||
serviceTypeOptions,
|
||||
cargoTypeOptions,
|
||||
containerTypeOptions,
|
||||
],
|
||||
);
|
||||
|
||||
const controls = useFilters(bookingFilterDefs, {
|
||||
|
||||
@@ -19,8 +19,10 @@ import { ExportButton } from "@/components/export/ExportButton";
|
||||
import { useExchangeSettingsQuery } from "@/hooks/useExchangeSettings";
|
||||
import { api } from "@/services/api";
|
||||
import {
|
||||
INVOICE_TYPE_OPTIONS,
|
||||
PAYMENT_METHOD_OPTIONS,
|
||||
invoicePaymentMethod,
|
||||
invoiceTypeLabel,
|
||||
paymentMethodLabel,
|
||||
type Invoice,
|
||||
type InvoiceListFilter,
|
||||
@@ -55,6 +57,7 @@ const EIMS_STATUS_OPTIONS = [
|
||||
const INVOICE_FILTER_DEFS: FilterDef[] = [
|
||||
{ key: "statuses", label: "Status", type: "enum", options: STATUS_OPTIONS },
|
||||
{ key: "sources", label: "Source", type: "enum", options: SOURCE_OPTIONS },
|
||||
{ key: "types", label: "Type", type: "enum", options: INVOICE_TYPE_OPTIONS },
|
||||
{
|
||||
key: "currency",
|
||||
label: "Currency",
|
||||
@@ -261,6 +264,15 @@ export default function InvoicesPanel() {
|
||||
size: 220,
|
||||
cell: ({ row }) => <InvoiceSourceCell invoice={row.original} />,
|
||||
},
|
||||
{
|
||||
id: "type",
|
||||
header: "Type",
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm" c="edr-text" truncate maw={180}>
|
||||
{invoiceTypeLabel(row.original.type)}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
header: "Status",
|
||||
@@ -389,7 +401,7 @@ export default function InvoicesPanel() {
|
||||
</Box>
|
||||
|
||||
<Box style={{ overflowX: "auto" }} w="100%">
|
||||
<Box miw={1040}>
|
||||
<Box miw={1200}>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={rows}
|
||||
|
||||
@@ -44,7 +44,9 @@ import { useManualPaymentSettingsQuery } from "@/hooks/useManualPaymentSettings"
|
||||
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||
import { api } from "@/services/api";
|
||||
import {
|
||||
INVOICE_TYPE_OPTIONS,
|
||||
PAYMENT_METHOD_OPTIONS,
|
||||
invoiceTypeLabel,
|
||||
type InvoiceListFilter,
|
||||
type OfflineUsdInvoice,
|
||||
} from "@/types/invoice";
|
||||
@@ -109,6 +111,13 @@ const MANUAL_PAYMENT_FILTER_DEFS: FilterDef[] = [
|
||||
secondary: true,
|
||||
options: SOURCE_OPTIONS,
|
||||
},
|
||||
{
|
||||
key: "types",
|
||||
label: "Type",
|
||||
type: "enum",
|
||||
secondary: true,
|
||||
options: INVOICE_TYPE_OPTIONS,
|
||||
},
|
||||
{
|
||||
key: "paymentMethods",
|
||||
label: "Payment method",
|
||||
@@ -424,6 +433,15 @@ export default function UsdPaymentsPanel({
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "type",
|
||||
header: "Type",
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm" c="edr-text" truncate maw={180}>
|
||||
{invoiceTypeLabel(row.original.type)}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
header: "Status",
|
||||
@@ -523,7 +541,7 @@ export default function UsdPaymentsPanel({
|
||||
</Box>
|
||||
|
||||
<Box style={{ overflowX: "auto" }} w="100%">
|
||||
<Box miw={1160}>
|
||||
<Box miw={1320}>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={rows}
|
||||
|
||||
@@ -73,6 +73,15 @@ export interface BookingListFilter {
|
||||
freightType?: string;
|
||||
/** Service type (rule-engine service_types.id). */
|
||||
serviceTypeId?: string;
|
||||
/** Cargo type OR cargo group — a group matches every commodity beneath it. */
|
||||
cargoTypeId?: string;
|
||||
/** Contains-search over content: cargo description, commodity, container types. */
|
||||
cargoText?: string;
|
||||
/** Bookings carrying this container type; also scopes containersMin/Max to it. */
|
||||
containerTypeId?: string;
|
||||
/** Container count bounds — of containerTypeId when set, else of all types. */
|
||||
containersMin?: string;
|
||||
containersMax?: string;
|
||||
/** ONE_TIME | GENERAL_CONTRACT — the booking-kind tab filter. */
|
||||
bookingType?: string;
|
||||
/** 'true' → customs bookings, 'false' → self-clearance (non-customs). */
|
||||
@@ -199,6 +208,11 @@ export const bookingsService = {
|
||||
if (filter.companyId) params.companyId = filter.companyId;
|
||||
if (filter.freightType) params.freightType = filter.freightType;
|
||||
if (filter.serviceTypeId) params.serviceTypeId = filter.serviceTypeId;
|
||||
if (filter.cargoTypeId) params.cargoTypeId = filter.cargoTypeId;
|
||||
if (filter.cargoText) params.cargoText = filter.cargoText;
|
||||
if (filter.containerTypeId) params.containerTypeId = filter.containerTypeId;
|
||||
if (filter.containersMin) params.containersMin = filter.containersMin;
|
||||
if (filter.containersMax) params.containersMax = filter.containersMax;
|
||||
if (filter.bookingType) params.bookingType = filter.bookingType;
|
||||
if (filter.tradeDirection) params.tradeDirection = filter.tradeDirection;
|
||||
if (filter.paymentCurrency)
|
||||
@@ -238,6 +252,11 @@ export const bookingsService = {
|
||||
if (filter.contractId) params.contractId = filter.contractId;
|
||||
if (filter.freightType) params.freightType = filter.freightType;
|
||||
if (filter.serviceTypeId) params.serviceTypeId = filter.serviceTypeId;
|
||||
if (filter.cargoTypeId) params.cargoTypeId = filter.cargoTypeId;
|
||||
if (filter.cargoText) params.cargoText = filter.cargoText;
|
||||
if (filter.containerTypeId) params.containerTypeId = filter.containerTypeId;
|
||||
if (filter.containersMin) params.containersMin = filter.containersMin;
|
||||
if (filter.containersMax) params.containersMax = filter.containersMax;
|
||||
if (filter.bookingType) params.bookingType = filter.bookingType;
|
||||
if (filter.tradeDirection) params.tradeDirection = filter.tradeDirection;
|
||||
if (filter.paymentCurrency)
|
||||
|
||||
@@ -70,6 +70,8 @@ export interface InvoiceListFilter {
|
||||
statuses?: string;
|
||||
/** CSV of `Freight.InvoiceSource` values. */
|
||||
sources?: string;
|
||||
/** CSV of invoice types (see `INVOICE_TYPE_OPTIONS`). */
|
||||
types?: string;
|
||||
/** CSV of EIMS filing states. */
|
||||
eimsStatuses?: string;
|
||||
/** CSV of normalised UPPER_SNAKE payment methods (see `PAYMENT_METHOD_OPTIONS`). */
|
||||
@@ -171,3 +173,28 @@ export function invoicePaymentMethod(invoice: Invoice): string | null {
|
||||
/** Human label for a method value; unknown values are shown as-is. */
|
||||
export const paymentMethodLabel = (method: string): string =>
|
||||
PAYMENT_METHOD_LABELS.get(method) ?? method;
|
||||
|
||||
/**
|
||||
* What an invoice bills for. Every billing source mints its own `type` string,
|
||||
* so this list is the known vocabulary, not a closed enum — render an unknown
|
||||
* value rather than treating it as invalid.
|
||||
*/
|
||||
export const INVOICE_TYPE_OPTIONS: { value: string; label: string }[] = [
|
||||
{ value: "PREPAID", label: "Prepaid freight" },
|
||||
{ value: "WAGON_CANCEL_FEE", label: "Wagon cancellation fee" },
|
||||
{ value: "GL_FINAL", label: "General contract final" },
|
||||
{ value: "ADDITIONAL_CHARGE", label: "Additional charge" },
|
||||
{ value: "PORT_CHARGES", label: "Port charges" },
|
||||
{ value: "MISCELLANEOUS", label: "Miscellaneous" },
|
||||
{ value: "DELIVERY_FEE", label: "Delivery fee" },
|
||||
{ value: "LAST_MILE_ADVANCE", label: "Last-mile advance" },
|
||||
{ value: "SHIPPING_LINE_CREDIT", label: "Shipping line credit" },
|
||||
{ value: "STORAGE_FEE", label: "Storage fee" },
|
||||
{ value: "DEMURRAGE", label: "Demurrage" },
|
||||
{ value: "MIXED_WAREHOUSE_FEES", label: "Mixed warehouse fees" },
|
||||
];
|
||||
|
||||
/** Label for an invoice `type`, falling back to the humanised raw value. */
|
||||
export const invoiceTypeLabel = (type: string): string =>
|
||||
INVOICE_TYPE_OPTIONS.find((o) => o.value === type)?.label ??
|
||||
type.replace(/_/g, " ");
|
||||
|
||||
Reference in New Issue
Block a user