Merge pull request #1438 from Tria-plc/freight/nati-2

Freight/nati 2
This commit is contained in:
Nathnael Wondisha
2026-08-28 14:09:18 +03:00
committed by GitHub
26 changed files with 733 additions and 27 deletions

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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