mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 00:10:57 +00:00
A GENERAL + customs contract does not let the customer book directly: they
submit a shipment request, and initiateForShipmentRequest opens a BARE booking
from it — "the request itself carries the quantities; the instance carries
none". Between initiation and completeUnderContract the booking legitimately
holds no cargo, so the export reported 0 containers for a customer who had
declared, say, 2 x 20FT. 23 bookings on dev data are in that state.
Adds two columns and one filter reading booking_requests.requested_lines:
- "Requested cargo" — the declared lines as text ("2 x 20FT"), handling the
bulk shape too (tons / item count), not only containers.
- "Requested containers" — the declared box count, with a matching min/max
filter on the list and the export.
Deliberately a separate column rather than a fallback inside the real container
count: a declared 2 x 20FT is a request, not two boxes on a booking, and
merging them would overstate operational totals. The two compose instead —
Containers = 0 AND Requested containers >= 1 is exactly the set awaiting
completion after clearance.
requested_lines is free-form jsonb, so the container array is guarded by
jsonb_typeof before jsonb_array_elements; one malformed row would otherwise
500 the whole list.
147 lines
5.5 KiB
TypeScript
147 lines
5.5 KiB
TypeScript
import {
|
|
CARGO_TYPE_SUBTREE_SQL,
|
|
bookingContainerCountSql,
|
|
bookingContainerVgmSql,
|
|
bookingContentMatchSql,
|
|
bookingContentSql,
|
|
bookingHasContainerTypeSql,
|
|
bookingRequestedCargoSql,
|
|
bookingRequestedContainerCountSql,
|
|
} 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');
|
|
});
|
|
});
|
|
|
|
describe('requested (shipment-request) cargo', () => {
|
|
const cargo = bookingRequestedCargoSql('b');
|
|
const count = bookingRequestedContainerCountSql('b');
|
|
|
|
it('reads the request, never the booking or its container lines', () => {
|
|
for (const sql of [cargo, count]) {
|
|
expect(sql).toContain('freight.booking_requests br');
|
|
expect(sql).toContain('br.created_booking_id = b.id');
|
|
expect(sql).not.toContain('freight.booking_container');
|
|
}
|
|
});
|
|
|
|
// requested_lines is a free-form jsonb column; jsonb_array_elements throws on
|
|
// a non-array, which would 500 the whole list for one malformed row.
|
|
it('survives a requested_lines with no container array', () => {
|
|
for (const sql of [cargo, count]) {
|
|
expect(sql).toContain("jsonb_typeof(br.requested_lines->'containers') = 'array'");
|
|
expect(sql).toContain("ELSE '[]'::jsonb");
|
|
}
|
|
});
|
|
|
|
it('renders the bulk shape too, not only containers', () => {
|
|
expect(cargo).toContain("'bulk'->>'cargoWeightTons'");
|
|
expect(cargo).toContain("'bulk'->>'itemCount'");
|
|
});
|
|
|
|
it('counts 0 rather than NULL when no request exists', () => {
|
|
expect(count).toContain("COALESCE(SUM((l->>'quantity')::int), 0)");
|
|
});
|
|
|
|
it('ignores soft-deleted requests', () => {
|
|
expect(cargo).toContain('br.deleted_at IS NULL');
|
|
expect(count).toContain('br.deleted_at IS NULL');
|
|
});
|
|
});
|