merge conflict resolved

This commit is contained in:
marshal
2026-09-02 22:35:18 +00:00
376 changed files with 26459 additions and 2649 deletions

View File

@@ -0,0 +1,146 @@
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');
});
});

View File

@@ -0,0 +1,148 @@
/**
* 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)`;
}
/**
* Cargo the customer declared on the SHIPMENT REQUEST behind a booking, which
* is not the same fact as cargo on the booking itself.
*
* On a GENERAL + customs contract the customer cannot book directly: they
* submit a request (day + quantities), and `initiateForShipmentRequest` opens a
* BARE instance from it — "the request itself carries the quantities; the
* instance carries none". So between initiation and `completeUnderContract` the
* booking legitimately holds no cargo while the customer's declared quantities
* sit on `booking_requests.requested_lines`.
*
* Kept in its own column rather than folded into the real container count: a
* declared 2 × 20FT is a request, not two boxes on a booking, and merging the
* two would overstate operational totals.
*/
const REQUESTED_CONTAINER_LINES = `jsonb_array_elements(
CASE WHEN jsonb_typeof(br.requested_lines->'containers') = 'array'
THEN br.requested_lines->'containers'
ELSE '[]'::jsonb END)`;
/** Human-readable declared cargo: "2 × 20FT", "12 t", "40 items". */
export function bookingRequestedCargoSql(alias = 'b'): string {
return `(SELECT COALESCE(
(SELECT string_agg((l->>'quantity') || ' × ' || upper(l->>'containerSize'), ', '
ORDER BY l->>'containerSize')
FROM ${REQUESTED_CONTAINER_LINES} AS l),
NULLIF(br.requested_lines->'bulk'->>'cargoWeightTons', '') || ' t',
NULLIF(br.requested_lines->'bulk'->>'itemCount', '') || ' items')
FROM freight.booking_requests br
WHERE br.created_booking_id = ${alias}.id
AND br.deleted_at IS NULL
ORDER BY br.created_at DESC
LIMIT 1)`;
}
/**
* Boxes declared on the shipment request. Pairs with the real container count:
* `Containers = 0` AND `Requested containers >= 1` is exactly the set awaiting
* completion.
*/
export function bookingRequestedContainerCountSql(alias = 'b'): string {
return `(SELECT COALESCE(SUM((l->>'quantity')::int), 0)
FROM freight.booking_requests br
CROSS JOIN LATERAL ${REQUESTED_CONTAINER_LINES} AS l
WHERE br.created_booking_id = ${alias}.id
AND br.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

@@ -2024,9 +2024,12 @@ export class BookingWagonCancellationService {
// the same cargo); number/seal/VGM come from the override when given.
units: sized.map((u, i) => ({
containerNumber: replacement?.[i]?.containerNumber ?? u.containerNumber,
// A credit snapshot taken before seals were mandatory can carry
// none; the booking service normalizes the blank back to null
// rather than blocking the rebook of already-paid cargo.
sealNumber: replacement
? (replacement[i]?.sealNumber ?? undefined)
: (u.sealNumber ?? undefined),
? (replacement[i]?.sealNumber ?? '')
: (u.sealNumber ?? ''),
vgmTons: replacement?.[i]?.vgmTons ?? u.vgmTons,
isHazardous: u.isHazardous,
isReefer: u.isReefer,

View File

@@ -86,6 +86,7 @@ import {
import { ContractViewDto } from "./dto/contract-view.dto";
import { CustomerTruckAssignmentDto } from "./dto/customer-truck-assignment.dto";
import { AddCustomerTruckDto } from "./dto/add-customer-truck.dto";
import { BulkCustomerTrucksDto } from "./dto/bulk-customer-truck.dto";
import { DepartCustomerTruckDto } from "./dto/depart-customer-truck.dto";
import { LoadCustomerTruckDto } from "./dto/load-customer-truck.dto";
import { CustomerTruckService } from "./customer-truck.service";
@@ -858,7 +859,7 @@ export class BookingsController {
})
async bulkAddCustomerTrucks(
@Param("id", ParseUUIDPipe) id: string,
@Body() payload: { trucks: AddCustomerTruckDto[] },
@Body() payload: BulkCustomerTrucksDto,
@CurrentUser() user: TCurrentUser,
) {
const booking = await this.bookingsService.findById(id);

View File

@@ -21,6 +21,13 @@ 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,
bookingRequestedContainerCountSql,
} from './booking-content.sql';
import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity';
import {
BookingDocumentReview,
@@ -65,7 +72,17 @@ 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;
/** Bounds on containers declared on the shipment request behind the booking. */
requestedContainersMin?: number;
requestedContainersMax?: number;
freightType?: string;
bookingType?: string;
tradeDirection?: string;
@@ -1176,11 +1193,55 @@ 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,
});
}
}
// Declared on the shipment request, not on the booking. Pairs with the
// count above: containers 0..0 AND requested >= 1 is the set awaiting
// completion after clearance.
if (options.requestedContainersMin != null) {
qb.andWhere(`${bookingRequestedContainerCountSql('booking')} >= :requestedContainersMin`, {
requestedContainersMin: options.requestedContainersMin,
});
}
if (options.requestedContainersMax != null) {
qb.andWhere(`${bookingRequestedContainerCountSql('booking')} <= :requestedContainersMax`, {
requestedContainersMax: options.requestedContainersMax,
});
}
if (omit !== 'freightType' && options.freightType) {
qb.andWhere('booking.freight_type = :freightType', {
freightType: options.freightType,

View File

@@ -29,6 +29,11 @@ import { DataSource, In } from 'typeorm';
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
import { assertExportReceivedWithGrn, DIRECT_TO_TRAIN } from '../../common/export-received-gate';
import {
EDR_HAULAGE_CONFLICT_MESSAGE,
LAST_MILE_COMMITTED_SQL,
edrHaulsThisBooking,
} from '../../common/mile-haulage.util';
import { Yard } from '../rule-engine/entities/yard.entity';
import { ServiceType } from '../rule-engine/entities/service-type.entity';
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
@@ -112,8 +117,13 @@ interface CarriageAcceptanceWagonRow {
departureAt: Date | null;
marshalledAt: string | null;
arrivalAt: string | null;
/** Per-row stations: the slot's own board/alight yard, else the schedule's endpoints. */
departureStation: string | null;
arrivalStation: string | null;
containerNumbers: string | null;
sealNumbers: string | null;
/** Allocation status — LOADED/DEPARTED means EDR has the cargo. */
status: string | null;
}
/** A received-but-not-yet-marshalled export line, standing in for a wagon row. */
@@ -169,18 +179,23 @@ export class BookingsService {
dto: CustomerTruckAssignmentDto,
): Promise<Booking> {
const booking = await this.findById(bookingId);
const hasFirstMile = Boolean(booking.firstMilePickupAddress?.trim());
const hasLastMile = Boolean(booking.lastMileDeliveryAddress?.trim());
const usesMileService =
booking.tradeDirection === 'IMPORT'
? hasLastMile
: booking.tradeDirection === 'EXPORT'
? hasFirstMile
: hasFirstMile || hasLastMile;
if (usesMileService) {
throw new BadRequestException(
'Customer truck assignment is only allowed when first/last mile delivery is not selected',
);
// Same rule as CustomerTruckService.assertSelfHaulPaid: an EDR delivery leg
// closes self-haul only once it has been approved.
const [commitment]: Array<{ lastMileCommitted: boolean }> = await this.dataSource.query(
`SELECT ${LAST_MILE_COMMITTED_SQL} AS "lastMileCommitted"
FROM freight.bookings b
WHERE b.id = $1`,
[bookingId],
);
if (
edrHaulsThisBooking({
tradeDirection: booking.tradeDirection ?? null,
firstMile: booking.firstMilePickupAddress ?? null,
lastMile: booking.lastMileDeliveryAddress ?? null,
lastMileCommitted: Boolean(commitment?.lastMileCommitted),
})
) {
throw new BadRequestException(EDR_HAULAGE_CONFLICT_MESSAGE);
}
if (booking.customerTruckAssignedAt) {
throw new ConflictException('Customer truck assignment is already submitted and locked');
@@ -263,9 +278,13 @@ export class BookingsService {
/**
* Carriage acceptance sheet — one per booking, listing every wagon the booking
* occupies. Handed to the customer when EDR accepts the cargo (export) and when
* the wagons are allocated before marshalling (import), so it is only available
* once the booking has wagon allocations.
* occupies. A booking is routinely loaded in parts (some containers go, the
* rest wait for the next train), so each row carries a Status of Loaded or
* Not loaded and the totals count only the loaded ones: the customer sees the
* whole plan on one page without the sheet overstating what EDR has taken.
*
* Handed to the customer when EDR accepts the cargo (export) and when the
* wagons are allocated before marshalling (import).
*/
async carriageAcceptanceSheet(bookingId: string): Promise<{ filename: string; buffer: Buffer }> {
const booking = await this.findById(bookingId);
@@ -285,6 +304,9 @@ export class BookingsService {
s.scheduled_departure_date AS "departureAt",
so.label AS "marshalledAt",
sd.label AS "arrivalAt",
COALESCE(by_.label, so.label) AS "departureStation",
COALESCE(ay.label, sd.label) AS "arrivalStation",
a.status AS "status",
string_agg(DISTINCT ci.container_number, ', ') AS "containerNumbers",
string_agg(DISTINCT ci.seal_number, ', ') AS "sealNumbers"
FROM freight.wagon_booking_allocations a
@@ -296,13 +318,31 @@ export class BookingsService {
ON s.train_set_id = tsw.train_set_id AND s.deleted_at IS NULL
LEFT JOIN freight.yards so ON so.id = s.origin_station_id
LEFT JOIN freight.yards sd ON sd.id = s.destination_station_id
LEFT JOIN freight.yards by_ ON by_.id = tsw.board_yard_id
LEFT JOIN freight.yards ay ON ay.id = tsw.alight_yard_id
LEFT JOIN freight.wagon_allocation_container_items ci
ON ci.wagon_booking_allocation_id = a.id AND ci.deleted_at IS NULL
AND (
$2 <> 'EXPORT' OR $3 <> 'CONTAINER' OR EXISTS (
SELECT 1
FROM freight.booking_container_units received_unit
JOIN freight.booking_container received_line
ON received_line.id = received_unit.booking_container_id
AND received_line.deleted_at IS NULL
WHERE received_line.booking_id = a.booking_id
AND received_unit.container_number = ci.container_number
AND received_unit.received_to_port = true
AND NULLIF(TRIM(received_unit.grn_number), '') IS NOT NULL
AND received_unit.deleted_at IS NULL
)
)
WHERE a.booking_id = $1 AND a.deleted_at IS NULL
GROUP BY tsw.id, a.id, wt.code, wt.name, w.wagon_number, wt.tare_weight_tons,
s.train_number, s.scheduled_departure_date, so.label, sd.label
GROUP BY tsw.id, a.id, a.status, wt.code, wt.name, w.wagon_number, wt.tare_weight_tons,
s.train_number, s.scheduled_departure_date, so.label, sd.label,
by_.label, ay.label
HAVING $2 <> 'EXPORT' OR $3 <> 'CONTAINER' OR COUNT(ci.id) > 0
ORDER BY tsw.sequence_no`,
[bookingId],
[bookingId, booking.tradeDirection, booking.freightType],
);
// Export acceptance happens at the warehouse gate, not at marshalling: EDR
// takes custody of the cargo when it receives it, and the customer is handed
@@ -337,17 +377,17 @@ export class BookingsService {
)
: booking.tradeDirection === 'EXPORT'
? await this.dataSource.query(
`SELECT inv.weight AS "allocatedWeightTons",
c.container_number AS "containerNumbers"
FROM freight.warehouse_inventory inv
LEFT JOIN freight.containers c
ON c.id = inv.container_id AND c.deleted_at IS NULL
WHERE inv.booking_id = $1 AND inv.deleted_at IS NULL
AND COALESCE(
NULLIF(TRIM(inv.grn_number), ''),
substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)')
) IS NOT NULL
ORDER BY inv.created_at`,
`SELECT unit.vgm_tons AS "allocatedWeightTons",
unit.container_number AS "containerNumbers",
unit.seal_number AS "sealNumbers"
FROM freight.booking_container_units unit
JOIN freight.booking_container line
ON line.id = unit.booking_container_id AND line.deleted_at IS NULL
WHERE line.booking_id = $1
AND unit.deleted_at IS NULL
AND unit.received_to_port = true
AND NULLIF(TRIM(unit.grn_number), '') IS NOT NULL
ORDER BY unit.received_at, unit.container_number`,
[bookingId],
)
: [];
@@ -381,8 +421,12 @@ export class BookingsService {
departureAt: null,
marshalledAt: null,
arrivalAt: null,
departureStation: null,
arrivalStation: null,
containerNumbers: row.containerNumbers,
sealNumbers: row.sealNumbers ?? null,
// A received line has no allocation; it is cargo EDR already holds.
status: null,
}));
}
@@ -497,7 +541,17 @@ export class BookingsService {
const header = wagons[0];
const sheetDate = header.departureAt ? new Date(header.departureAt) : new Date();
const totals = wagons.reduce(
// Loaded = EDR has the cargo. A booking is routinely loaded in parts, so the
// totals count only those: the sheet shows the whole plan, but must never
// total up cargo still sitting in the yard. A received-line sheet
// (pendingWagons) has no allocation status, and every line on it is cargo
// already accepted, so it counts in full.
const isLoaded = (w: CarriageAcceptanceWagonRow) =>
pendingWagons || w.status === 'LOADED' || w.status === 'DEPARTED';
const loadedWagons = wagons.filter(isLoaded);
const notLoadedCount = wagons.length - loadedWagons.length;
const totals = loadedWagons.reduce(
(acc, w) => ({
tare: acc.tare + (Number(w.tareWeightTons) || 0),
capacity: acc.capacity + (Number(w.loadCapacityTons) || 0),
@@ -507,7 +561,7 @@ export class BookingsService {
{ tare: 0, capacity: 0, load: 0, length: 0 },
);
// A wagon carrying no weight and no container is running empty under this booking.
const fullWagons = wagons.filter(
const fullWagons = loadedWagons.filter(
(w) => (Number(w.allocatedWeightTons) || 0) > 0 || Boolean(w.containerNumbers),
).length;
@@ -520,11 +574,14 @@ export class BookingsService {
<td class="num">${num(w.tareWeightTons, 2)}</td>
<td class="num">${num(w.equatedLength)}</td>
<td class="num">${num(w.loadCapacityTons)}</td>
<td>${esc(arrivalStation)}</td>
<td>${esc(w.arrivalStation ?? arrivalStation)}</td>
<td>${esc(cargoName)}</td>
<td>${esc(departureStation)}</td>
<td>${esc(w.departureStation ?? departureStation)}</td>
<td>${esc(w.containerNumbers)}</td>
<td>${esc(w.sealNumbers)}</td>
<td class="${isLoaded(w) ? 'loaded' : 'pending'}">${
pendingWagons ? 'Accepted' : isLoaded(w) ? 'Loaded' : 'Not loaded'
}</td>
<td class="num">${money(prices[i])}</td>
</tr>`,
)
@@ -535,23 +592,37 @@ export class BookingsService {
// figure from the printed sheet.
const totalsRow = `<tr class="totals">
<td>TOT</td>
<td>${wagons.length} ${pendingWagons ? 'received lines' : 'wagons'}</td>
<td>${
pendingWagons
? 'pending marshalling'
: `full ${fullWagons} / empty ${wagons.length - fullWagons}`
}</td>
<td>${loadedWagons.length} ${pendingWagons ? 'received lines' : 'wagons loaded'}</td>
<td></td>
<td class="num">${num(totals.tare, 2)}</td>
<td class="num">${num(totals.length)}</td>
<td class="num">${num(totals.capacity)}</td>
<td></td>
<td>Gross ${num(totals.tare + totals.load)} T</td>
<td></td>
<td></td>
<td></td>
<td></td>
<td>${notLoadedCount > 0 ? `loaded only (${notLoadedCount} not loaded)` : ''}</td>
<td class="num">${money(totalAmount)}</td>
</tr>`;
// The signed footer of the paper sheet. Rendered as .tile so the
// Chromium-less fallback (buildTabularFallbackPdf parses .tile, not
// arbitrary divs) still prints every figure.
const footer = `
<div class="summary footer-summary">
<div class="tile"><span>In Total Wagon No.</span><strong>${loadedWagons.length}</strong></div>
<div class="tile"><span>Tare Weight (T)</span><strong>${num(totals.tare, 2)}</strong></div>
<div class="tile"><span>Load Capacity (T)</span><strong>${num(totals.capacity)}</strong></div>
<div class="tile"><span>Gross Weight (T)</span><strong>${num(totals.tare + totals.load)}</strong></div>
<div class="tile"><span>Equated Length</span><strong>${num(totals.length)}</strong></div>
<div class="tile"><span>Full Wagon</span><strong>${pendingWagons ? '-' : fullWagons}</strong></div>
<div class="tile"><span>Empty Wagon</span><strong>${
pendingWagons ? '-' : loadedWagons.length - fullWagons
}</strong></div>
<div class="tile"><span>Total Amount (${esc(currency)})</span><strong>${money(totalAmount)}</strong></div>
</div>`;
return `<!doctype html>
<html>
<head>
@@ -568,6 +639,8 @@ export class BookingsService {
.meta { text-align: right; font-size: 11px; color: #475569; min-width: 210px; }
.meta strong { display: block; margin-top: 4px; color: #0f172a; font-size: 15px; }
.summary { display: grid; grid-template-columns: repeat(6, 1fr); gap: 8px; margin: 14px 0; }
.footer-summary { grid-template-columns: repeat(8, 1fr); margin: 10px 0 0; }
.footer-summary .tile { background: #f8fafc; }
.tile { border: 1px solid #cbd5e1; padding: 8px; min-height: 50px; }
.tile span { display: block; color: #64748b; font-size: 9px; text-transform: uppercase; letter-spacing: .05em; margin-bottom: 4px; }
.tile strong { font-size: 11px; }
@@ -575,6 +648,8 @@ export class BookingsService {
th { background: #f8fafc; color: #475569; text-align: left; }
th, td { border: 1px solid #cbd5e1; padding: 5px 6px; font-size: 9.5px; vertical-align: top; }
.num { text-align: right; }
.loaded { color: #0f766e; font-weight: 700; }
.pending { color: #b45309; font-weight: 700; }
tr.totals td { background: #f8fafc; font-weight: 700; }
.notice { margin-top: 10px; border-left: 4px solid #0f766e; background: #f0fdfa; padding: 8px 10px; font-size: 10px; color: #134e4a; }
.signatures { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; margin-top: 34px; }
@@ -618,6 +693,7 @@ export class BookingsService {
<th>Departure Station</th>
<th>Container No.</th>
<th>Seal No.</th>
<th>Status</th>
<th class="num">Price (${esc(currency)})</th>
</tr>
</thead>
@@ -626,6 +702,7 @@ export class BookingsService {
${totalsRow}
</tbody>
</table>
${footer}
<div class="notice">
${
@@ -1845,6 +1922,12 @@ export class BookingsService {
contractType: filter.contractType,
serviceTypeId: filter.serviceTypeId,
cargoTypeId: filter.cargoTypeId,
cargoText: filter.cargoText,
containerTypeId: filter.containerTypeId,
containersMin: filter.containersMin,
containersMax: filter.containersMax,
requestedContainersMin: filter.requestedContainersMin,
requestedContainersMax: filter.requestedContainersMax,
freightType: filter.freightType,
bookingType: filter.bookingType,
tradeDirection: filter.tradeDirection,
@@ -2072,6 +2155,12 @@ export class BookingsService {
contractType: filter.contractType,
serviceTypeId: filter.serviceTypeId,
cargoTypeId: filter.cargoTypeId,
cargoText: filter.cargoText,
containerTypeId: filter.containerTypeId,
containersMin: filter.containersMin,
containersMax: filter.containersMax,
requestedContainersMin: filter.requestedContainersMin,
requestedContainersMax: filter.requestedContainersMax,
freightType: filter.freightType,
bookingType: filter.bookingType,
tradeDirection: filter.tradeDirection,

View File

@@ -24,3 +24,84 @@ describe('carriage acceptance sheet — price split', () => {
expect(shares).toEqual([33.33, 33.33, 33.34]);
});
});
// The HTML builder only reaches `this` for two prototype helpers (escapeHtml,
// splitAmountAcrossWagons), so the prototype itself serves as `this`.
const buildSheet = (wagons: unknown[], booking: Record<string, unknown> = {}): string =>
(
BookingsService.prototype as unknown as {
buildCarriageAcceptanceSheetHtml(
b: unknown,
w: unknown[],
o: { pendingWagons: boolean },
): string;
}
).buildCarriageAcceptanceSheetHtml.call(
BookingsService.prototype,
{
reference: 'BK-1',
tradeDirection: 'EXPORT',
totalAmount: 100,
paymentCurrency: 'ETB',
originYard: { label: 'Booking Origin' },
destinationYard: { label: 'Booking Destination' },
...booking,
},
wagons,
{ pendingWagons: false },
);
const wagon = (over: Record<string, unknown> = {}) => ({
sequenceNo: 1,
wagonType: 'FLAT',
wagonNumber: 'W-001',
tareWeightTons: '20',
equatedLength: '14',
loadCapacityTons: '60',
allocatedWeightTons: '40',
trainNumber: '8302',
departureAt: null,
marshalledAt: 'DCT/SGTD',
arrivalAt: 'GMP',
departureStation: null,
arrivalStation: null,
containerNumbers: 'CN-1',
sealNumbers: 'SL-1',
status: 'LOADED',
...over,
});
describe('carriage acceptance sheet — rows and footer', () => {
it('prints each row its own Departure/Arrival Station, falling back to the booking yards', () => {
const html = buildSheet([
wagon({ departureStation: 'Dire Dawa Port', arrivalStation: 'Adama' }),
wagon({ sequenceNo: 2, wagonNumber: 'W-002' }),
]);
expect(html).toContain('<td>Dire Dawa Port</td>');
expect(html).toContain('<td>Adama</td>');
expect(html).toContain('<td>Booking Origin</td>');
expect(html).toContain('<td>Booking Destination</td>');
});
it('totals the footer over loaded wagons only', () => {
const html = buildSheet([
wagon(),
wagon({ sequenceNo: 2, wagonNumber: 'W-002', status: 'ALLOCATED' }),
wagon({
sequenceNo: 3,
wagonNumber: 'W-003',
allocatedWeightTons: '0',
containerNumbers: null,
}),
]);
// 2 loaded of 3: tare 40, capacity 120, equated length 28, gross 40 + 40 load.
expect(html).toContain('<span>In Total Wagon No.</span><strong>2</strong>');
expect(html).toContain('<span>Tare Weight (T)</span><strong>40.00</strong>');
expect(html).toContain('<span>Load Capacity (T)</span><strong>120.000</strong>');
expect(html).toContain('<span>Gross Weight (T)</span><strong>80.000</strong>');
expect(html).toContain('<span>Equated Length</span><strong>28.000</strong>');
expect(html).toContain('<span>Full Wagon</span><strong>1</strong>');
expect(html).toContain('<span>Empty Wagon</span><strong>1</strong>');
expect(html).toContain('<span>Total Amount (ETB)</span><strong>100.00</strong>');
});
});

View File

@@ -9,12 +9,17 @@ import { DataSource, EntityManager, IsNull } from 'typeorm';
import { NotificationAudience, NotificationType } from '@edr/types';
import { AddCustomerTruckDto } from './dto/add-customer-truck.dto';
import type {
BulkTruckUploadError,
BulkTruckUploadResult,
} from './dto/bulk-customer-truck.dto';
import { DepartCustomerTruckDto } from './dto/depart-customer-truck.dto';
import { CustomerTruckAssignment } from './entities/customer-truck-assignment.entity';
import { CustomerTruckContainer } from './entities/customer-truck-container.entity';
import {
EDR_HAULAGE_CONFLICT_MESSAGE,
usesEdrMileService,
LAST_MILE_COMMITTED_SQL,
edrHaulsThisBooking,
} from '../../common/mile-haulage.util';
import {
assertBulkTonnageRemains,
@@ -35,6 +40,9 @@ interface BookingGuardRow {
lastMile: string | null;
paymentStatus: string | null;
status: string | null;
trainScheduleStatus: string | null;
/** See `MileCommitmentRow` — an approved EDR last-mile leg closes self-haul. */
lastMileCommitted: boolean;
}
/**
@@ -294,19 +302,16 @@ export class CustomerTruckService {
}
const requested = (dto.containerNumbers ?? []).map((n) => n.trim().toUpperCase());
if (booking.freightType === 'CONTAINER' && !requested.length) {
throw new BadRequestException('Select the containers loaded on this truck');
}
if (requested.length) {
const bookingNumbers = await this.bookingContainerNumbers(bookingId);
for (const n of requested) {
if (!bookingNumbers.includes(n)) {
throw new BadRequestException(`Container ${n} is not one of this booking's containers`);
}
}
const elsewhere = await this.assignedContainerNumbersExcept(bookingId, assignmentId);
for (const n of requested) {
if (elsewhere.includes(n)) {
throw new ConflictException(`Container ${n} is already loaded onto another truck`);
}
}
assertTruckLoad({
containers: requested,
bookingContainers: await this.bookingContainerNumbers(bookingId),
sizes: await bookingContainerSizes(this.dataSource, bookingId, requested),
assignedElsewhere: await this.assignedContainerNumbersExcept(bookingId, assignmentId),
});
}
await this.dataSource.transaction(async (manager) => {
@@ -542,9 +547,17 @@ export class CustomerTruckService {
first_mile_pickup_address AS "firstMile",
last_mile_delivery_address AS "lastMile",
payment_status AS "paymentStatus",
status
FROM freight.bookings
WHERE id = $1 AND deleted_at IS NULL`,
b.status,
(SELECT ts.status
FROM freight.train_schedule_bookings tsb
JOIN freight.train_schedules ts
ON ts.id = tsb.train_schedule_id AND ts.deleted_at IS NULL
WHERE tsb.booking_id = b.id AND tsb.deleted_at IS NULL
ORDER BY ts.updated_at DESC
LIMIT 1) AS "trainScheduleStatus",
${LAST_MILE_COMMITTED_SQL} AS "lastMileCommitted"
FROM freight.bookings b
WHERE b.id = $1 AND b.deleted_at IS NULL`,
[bookingId],
);
if (!row) throw new NotFoundException(`Booking ${bookingId} not found`);
@@ -552,10 +565,12 @@ export class CustomerTruckService {
}
private assertSelfHaulPaid(booking: BookingGuardRow): void {
// Shared with the EDR side (LastMileService.assertNoCustomerTruck) so the two
// halves of this rule cannot drift apart — they did, and a booking ended up
// with a customer truck and an EDR leg at once.
if (usesEdrMileService(booking)) {
// Mirrors the EDR side (LastMileService.assertEdrHaulsThisBooking) so the
// two halves of this rule cannot drift apart — they did, and a booking ended
// up with a customer truck and an EDR leg at once. A last-mile leg only
// blocks self-haul once it is approved; until then the customer may still
// bring their own truck, and doing so makes the pending request unapprovable.
if (edrHaulsThisBooking(booking)) {
throw new BadRequestException(EDR_HAULAGE_CONFLICT_MESSAGE);
}
if (booking.paymentStatus !== 'PAID') {
@@ -575,7 +590,7 @@ export class CustomerTruckService {
private assertAssignmentWindow(booking: BookingGuardRow): void {
const status = booking.status ?? '';
if (booking.tradeDirection === 'IMPORT') {
if (status !== 'ARRIVED') {
if (status !== 'ARRIVED' && booking.trainScheduleStatus !== 'ARRIVED') {
throw new BadRequestException(
'Import pickup trucks can only be assigned after the train has arrived',
);
@@ -626,26 +641,29 @@ export class CustomerTruckService {
/** Contract container sizes (e.g. "20ft" / "40ft") for the given container numbers. */
/**
* Add trucks one at a time, keeping the good ones. Partial success is the
* right shape here: one mistyped plate in a twenty-row spreadsheet should not
* discard the other nineteen trucks. Every row still goes through `addTruck`,
* so no guard is skipped.
*/
async addBulkTrucks(
bookingId: string,
dtos: AddCustomerTruckDto[],
): Promise<{
success: number;
failed: number;
errors: Array<{ row: number; truck: string; reason: string }>;
}> {
const errors: Array<{ row: number; truck: string; reason: string }> = [];
): Promise<BulkTruckUploadResult> {
const errors: BulkTruckUploadError[] = [];
let successCount = 0;
for (let i = 0; i < dtos.length; i++) {
try {
await this.addTruck(bookingId, dtos[i]);
successCount++;
} catch (err: any) {
} catch (err) {
errors.push({
row: i + 2, // Row 1 is header
index: i,
row: i + 2, // Row 1 is the header
truck: dtos[i].truckPlateNumber,
reason: err.message || 'Unknown error',
reason: err instanceof Error ? err.message : 'Unknown error',
});
}
}

View File

@@ -12,7 +12,7 @@ import {
Min,
} from 'class-validator';
import { CUSTOMER_TRUCK_TYPES } from './customer-truck-assignment.dto';
import { CUSTOMER_TRUCK_TYPES, ISO_CONTAINER_NUMBER } from '@edr/types';
/**
* Add one external customer truck to a booking.
@@ -41,7 +41,7 @@ export class AddCustomerTruckDto {
@IsArray()
@ArrayMaxSize(2)
@ArrayUnique()
@Matches(/^[A-Z]{4}\d{7}$/, {
@Matches(ISO_CONTAINER_NUMBER, {
each: true,
message: 'each container number must match ISO container format, e.g. ABCD1234567',
})

View File

@@ -1,48 +1,41 @@
import { IsString, IsNotEmpty, IsIn, IsArray, ArrayMaxSize, ArrayUnique, Matches, IsOptional } from 'class-validator';
import { CUSTOMER_TRUCK_TYPES } from './customer-truck-assignment.dto';
import { ArrayMaxSize, ArrayMinSize, IsArray, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';
export class BulkCustomerTruckRow {
@IsString()
@IsNotEmpty()
truckPlateNumber!: string;
@IsString()
@IsNotEmpty()
driverName!: string;
@IsString()
@IsNotEmpty()
@IsIn(CUSTOMER_TRUCK_TYPES)
truckType!: string;
@IsOptional()
@IsArray()
@ArrayMaxSize(2)
@ArrayUnique()
@Matches(/^[A-Z]{4}\d{7}$/, {
each: true,
message: 'each container must be ISO format (e.g. ABCD1234567)',
})
containerNumbers?: (string | null)[];
}
import { AddCustomerTruckDto } from './add-customer-truck.dto';
/**
* Bulk self-haul truck assignment, parsed from the customer's Excel upload in
* the browser and posted as JSON (the house pattern — the API never receives an
* .xlsx for import).
*
* Rows reuse `AddCustomerTruckDto` verbatim rather than redeclaring the fields:
* the earlier copy drifted, missing `plannedTons` / `plannedQuantity`, so bulk
* cargo could not be uploaded at all.
*/
export class BulkCustomerTrucksDto {
@IsArray()
@ArrayMinSize(1)
@ArrayMaxSize(100)
trucks!: BulkCustomerTruckRow[];
@ValidateNested({ each: true })
@Type(() => AddCustomerTruckDto)
trucks!: AddCustomerTruckDto[];
}
export interface BulkTruckUploadError {
/**
* Position in the submitted array. The client knows which spreadsheet line it
* read each entry from, so it maps this back to the row number the customer
* actually sees.
*/
index: number;
/** 1-based row assuming a single header line — a fallback for non-Excel callers. */
row: number;
truck: string;
reason: string;
}
export interface BulkTruckUploadResult {
success: number;
failed: number;
errors: Array<{
row: number;
truck: string;
reason: string;
}>;
created: Array<{
truckPlateNumber: string;
driverName: string;
containers: number;
}>;
errors: BulkTruckUploadError[];
}

View File

@@ -1,12 +1,12 @@
import { IsIn, IsNotEmpty, IsString, Matches, MaxLength } from 'class-validator';
import { CUSTOMER_TRUCK_TYPES, ISO_CONTAINER_NUMBER } from '@edr/types';
export const CUSTOMER_TRUCK_TYPES = [
'Flatbed',
'Container Chassis',
'Lowboy',
'Box Truck',
'Tipper',
] as const;
/**
* Re-exported for the DTOs that already import it from here. The list itself
* lives in `@edr/types` so the portal's dropdown and its Excel template read the
* same values this validator enforces.
*/
export { CUSTOMER_TRUCK_TYPES };
export class CustomerTruckAssignmentDto {
@IsString()
@@ -27,7 +27,7 @@ export class CustomerTruckAssignmentDto {
@IsString()
@IsNotEmpty()
@MaxLength(16)
@Matches(/^[A-Z]{4}\d{7}$/, {
@Matches(ISO_CONTAINER_NUMBER, {
message: 'containerNumberToLoad must match ISO container format, e.g. ABCD1234567',
})
containerNumberToLoad!: string;

View File

@@ -8,6 +8,7 @@ import {
Matches,
Min,
} from 'class-validator';
import { ISO_CONTAINER_NUMBER } from '@edr/types';
/**
* Register an import self-haul truck leaving the port: the containers it actually
@@ -20,7 +21,7 @@ export class DepartCustomerTruckDto {
@IsArray()
@ArrayMaxSize(2)
@ArrayUnique()
@Matches(/^[A-Z]{4}\d{7}$/, {
@Matches(ISO_CONTAINER_NUMBER, {
each: true,
message: 'each container number must match ISO container format, e.g. ABCD1234567',
})

View File

@@ -62,11 +62,60 @@ 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({
description:
'Minimum containers declared on the shipment request behind the booking',
})
@IsOptional()
@Transform(({ value }) => (value === '' || value == null ? undefined : Number(value)))
requestedContainersMin?: number;
@ApiPropertyOptional({ description: 'Maximum requested containers — see requestedContainersMin' })
@IsOptional()
@Transform(({ value }) => (value === '' || value == null ? undefined : Number(value)))
requestedContainersMax?: number;
@ApiPropertyOptional({ enum: FREIGHT_TYPES })
@IsOptional()
@IsIn([...FREIGHT_TYPES])

View File

@@ -1,4 +1,5 @@
import { ArrayMaxSize, ArrayMinSize, ArrayUnique, IsArray, Matches } from 'class-validator';
import { ISO_CONTAINER_NUMBER } from '@edr/types';
/** Containers loaded onto a truck at Truck_dispatch (after arrival, before it leaves). */
export class LoadCustomerTruckDto {
@@ -7,7 +8,7 @@ export class LoadCustomerTruckDto {
// A truck carries at most 2 containers (two 20ft, or one 40ft).
@ArrayMaxSize(2)
@ArrayUnique()
@Matches(/^[A-Z]{4}\d{7}$/, {
@Matches(ISO_CONTAINER_NUMBER, {
each: true,
message: 'each container number must match ISO container format, e.g. ABCD1234567',
})