mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-06 12:25:02 +00:00
feat(bookings): make customer self-haul assignment work end to end
The customer truck card on the booking's Logistics tab is now usable for every cargo type, with an Excel template and bulk upload that match what the API enforces. Excel template and bulk upload - The template is built per booking. Container bookings get the booking's own containers with their sizes on a reference sheet and sample rows paired 20ft+20ft; bulk (PER_TON) bookings get a Planned Tons column; counted cargo (PER_ITEM: machinery, RoRo vehicles) gets Planned Quantity. - Parsing validates the whole file before anything is posted: plate, driver, truck type, ISO container numbers, containers on the booking, duplicates across rows, containers already on a truck, and the capacity rule (one 40ft alone, or two 20ft). Errors quote the Excel row number. - The bulk DTO reuses AddCustomerTruckDto instead of a drifted copy that lacked plannedTons/plannedQuantity, so bulk cargo can be uploaded at all. - A partial failure is reported per row in the modal instead of closing it as if every truck had been created. Capacity rule in the form - The container picker shows sizes and stops offering a second container once a 40ft is picked, or a 40ft once a 20ft is picked. - PER_ITEM cargo commits by item count; tonnage becomes optional. Shared vocabulary - CUSTOMER_TRUCK_TYPES and ISO_CONTAINER_NUMBER move to @edr/types so the API validators, the dropdown and the template read one list. When assignment is open - The card always renders and states why assignment is closed (EDR haulage, unpaid, train not arrived, cargo already loaded) rather than vanishing. - Self-haul is blocked only once EDR has committed to the road leg: an approved last-mile request or an existing last-mile leg. A delivery address whose request is still awaiting confirmation, submitted or rejected no longer blocks the customer from bringing their own truck. Collection on exports has no approval step and still blocks as before. Both the multi-truck service and the legacy single-truck path read the same SQL fragment (edrHaulsThisBooking), and the portal applies the same rule with a notice that assigning a truck makes the pending request unapprovable. The last-mile side already refuses to approve a booking carrying a customer truck, so the two paths stay mutually exclusive. Verified: EXPLAIN on the new SQL against edr_dev, type-check clean for freight-api and portal, 12 util specs pass (5 new). Backoffice type-check fails only in pre-existing user-management files. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { usesEdrMileService } from './mile-haulage.util';
|
||||
import { edrHaulsThisBooking, usesEdrMileService } from './mile-haulage.util';
|
||||
|
||||
/**
|
||||
* The road legs are chosen on the contract and copied onto the booking. EDR
|
||||
@@ -26,27 +26,76 @@ describe('usesEdrMileService', () => {
|
||||
});
|
||||
|
||||
it('an export that chose collection uses EDR haulage', () => {
|
||||
expect(
|
||||
usesEdrMileService(booking({ tradeDirection: 'EXPORT', firstMile: 'Modjo' })),
|
||||
).toBe(true);
|
||||
expect(usesEdrMileService(booking({ tradeDirection: 'EXPORT', firstMile: 'Modjo' }))).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('ignores the delivery address on an export — delivery is the import leg', () => {
|
||||
expect(
|
||||
usesEdrMileService(booking({ tradeDirection: 'EXPORT', lastMile: 'Djibouti' })),
|
||||
).toBe(false);
|
||||
expect(usesEdrMileService(booking({ tradeDirection: 'EXPORT', lastMile: 'Djibouti' }))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('a domestic booking counts either leg', () => {
|
||||
expect(
|
||||
usesEdrMileService(booking({ tradeDirection: 'DOMESTIC', firstMile: 'Adama' })),
|
||||
).toBe(true);
|
||||
expect(
|
||||
usesEdrMileService(booking({ tradeDirection: 'DOMESTIC', lastMile: 'Dire Dawa' })),
|
||||
).toBe(true);
|
||||
expect(usesEdrMileService(booking({ tradeDirection: 'DOMESTIC', firstMile: 'Adama' }))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(usesEdrMileService(booking({ tradeDirection: 'DOMESTIC', lastMile: 'Dire Dawa' }))).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('treats a whitespace-only address as no choice', () => {
|
||||
expect(usesEdrMileService(booking({ lastMile: ' ' }))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Self-haul is closed only once EDR has committed to the leg. Delivery chosen
|
||||
* on the contract is a request the chief still has to approve; collection has
|
||||
* no approval step.
|
||||
*/
|
||||
describe('edrHaulsThisBooking', () => {
|
||||
const booking = (over: Partial<Parameters<typeof edrHaulsThisBooking>[0]> = {}) => ({
|
||||
tradeDirection: 'IMPORT',
|
||||
firstMile: null,
|
||||
lastMile: null,
|
||||
lastMileCommitted: false,
|
||||
...over,
|
||||
});
|
||||
|
||||
it('an import whose last-mile request is not yet approved may still self-haul', () => {
|
||||
expect(edrHaulsThisBooking(booking({ lastMile: 'Bole, Addis Ababa' }))).toBe(false);
|
||||
});
|
||||
|
||||
it('an import whose last-mile request was approved is hauled by EDR', () => {
|
||||
expect(
|
||||
edrHaulsThisBooking(booking({ lastMile: 'Bole, Addis Ababa', lastMileCommitted: true })),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('an import that chose no delivery self-hauls, whatever the leg tables say', () => {
|
||||
expect(edrHaulsThisBooking(booking({ lastMileCommitted: true }))).toBe(false);
|
||||
});
|
||||
|
||||
it('an export that chose collection is hauled by EDR — no approval step on that leg', () => {
|
||||
expect(edrHaulsThisBooking(booking({ tradeDirection: 'EXPORT', firstMile: 'Modjo' }))).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('a domestic booking is blocked by collection, or by an approved delivery', () => {
|
||||
expect(edrHaulsThisBooking(booking({ tradeDirection: 'DOMESTIC', firstMile: 'Adama' }))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
edrHaulsThisBooking(booking({ tradeDirection: 'DOMESTIC', lastMile: 'Dire Dawa' })),
|
||||
).toBe(false);
|
||||
expect(
|
||||
edrHaulsThisBooking(
|
||||
booking({ tradeDirection: 'DOMESTIC', lastMile: 'Dire Dawa', lastMileCommitted: true }),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -39,7 +39,60 @@ export const SELF_HAUL_CONFLICT_MESSAGE =
|
||||
'This booking is delivered by the customer’s own truck — an EDR mile leg cannot also be assigned.';
|
||||
|
||||
export const EDR_HAULAGE_CONFLICT_MESSAGE =
|
||||
'Customer truck assignment is only allowed when first/last mile delivery is not selected';
|
||||
'Customer truck assignment is only allowed when first/last mile delivery is not selected, or when the EDR last-mile request has not been approved';
|
||||
|
||||
/** The booking fields that decide whether the customer may still bring their own truck. */
|
||||
export interface MileCommitmentRow extends MileHaulageRow {
|
||||
/**
|
||||
* EDR has actually committed to the delivery leg: the booking's last-mile
|
||||
* request was approved, or a `freight.last_mile` leg row exists for it.
|
||||
* Selecting delivery on the contract is only a request — see
|
||||
* `edrHaulsThisBooking`.
|
||||
*/
|
||||
lastMileCommitted: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* SQL for `MileCommitmentRow.lastMileCommitted`, to be selected alongside the
|
||||
* booking row aliased `b`. Both services that gate self-haul read the same
|
||||
* fragment so the rule cannot drift between them.
|
||||
*/
|
||||
export const LAST_MILE_COMMITTED_SQL = `(
|
||||
EXISTS (SELECT 1
|
||||
FROM freight.last_mile lm
|
||||
WHERE lm.booking_id = b.id AND lm.deleted_at IS NULL)
|
||||
OR EXISTS (SELECT 1
|
||||
FROM freight.last_mile_requests lmr
|
||||
WHERE lmr.booking_id = b.id
|
||||
AND lmr.deleted_at IS NULL
|
||||
AND lmr.status = 'APPROVED')
|
||||
)`;
|
||||
|
||||
/**
|
||||
* Whether EDR is hauling this booking's road leg, such that the customer may
|
||||
* NOT assign their own truck. Stricter than `usesEdrMileService` on the
|
||||
* delivery side: choosing last-mile delivery on the contract opens a request
|
||||
* that the Truck & Machinery chief still has to approve, and until that
|
||||
* approval the customer is free to self-haul instead. Collection (the export
|
||||
* leg) has no approval step, so the contract choice alone decides it.
|
||||
*
|
||||
* `usesEdrMileService` keeps answering the other question — whether the booking
|
||||
* belongs in the EDR mile queues at all — and the queue side still refuses a
|
||||
* booking that already carries a customer truck, so the two paths remain
|
||||
* mutually exclusive whichever acts first.
|
||||
*/
|
||||
export function edrHaulsThisBooking(booking: MileCommitmentRow): boolean {
|
||||
const hasFirstMile = Boolean(booking.firstMile?.trim());
|
||||
const lastMileApproved = Boolean(booking.lastMile?.trim()) && booking.lastMileCommitted;
|
||||
switch (booking.tradeDirection) {
|
||||
case 'IMPORT':
|
||||
return lastMileApproved;
|
||||
case 'EXPORT':
|
||||
return hasFirstMile;
|
||||
default:
|
||||
return hasFirstMile || lastMileApproved;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The road legs are chosen on the contract. A booking whose contract bought
|
||||
|
||||
@@ -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";
|
||||
@@ -848,7 +849,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);
|
||||
|
||||
@@ -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';
|
||||
@@ -174,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');
|
||||
|
||||
@@ -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,
|
||||
@@ -36,6 +41,8 @@ interface BookingGuardRow {
|
||||
paymentStatus: string | null;
|
||||
status: string | null;
|
||||
trainScheduleStatus: string | null;
|
||||
/** See `MileCommitmentRow` — an approved EDR last-mile leg closes self-haul. */
|
||||
lastMileCommitted: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -547,7 +554,8 @@ export class CustomerTruckService {
|
||||
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"
|
||||
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],
|
||||
@@ -557,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') {
|
||||
@@ -631,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',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
|
||||
@@ -16,6 +16,17 @@ import {
|
||||
import { EmptyReturnRequestsService } from './empty-return-requests.service';
|
||||
import type { EmptyReturnRequestStatus } from './entities/empty-return-request.entity';
|
||||
|
||||
/**
|
||||
* Reading the queue is OR'd with the warehouse-inventory key the rest of the
|
||||
* Imports menu uses, so the staff who already run container returns can open
|
||||
* it while the dedicated key is still being handed out. Approving and
|
||||
* rejecting stay on the review key alone — that one is a commercial decision.
|
||||
*/
|
||||
const CAN_VIEW = [
|
||||
FREIGHT_PERMS.emptyReturnRequests.view,
|
||||
FREIGHT_PERMS.warehouseInventory.view,
|
||||
];
|
||||
|
||||
@ApiTags('empty-return-requests')
|
||||
@ApiBearerAuth()
|
||||
@Controller('empty-return-requests')
|
||||
@@ -23,7 +34,7 @@ export class EmptyReturnRequestsController {
|
||||
constructor(private readonly service: EmptyReturnRequestsService) {}
|
||||
|
||||
@Get()
|
||||
@BookingStaff(FREIGHT_PERMS.emptyReturnRequests.view)
|
||||
@BookingStaff(CAN_VIEW)
|
||||
@ApiOperation({ summary: 'Empty container return requests queue' })
|
||||
findAll(@Query('status') status?: string, @Query('bookingId') bookingId?: string) {
|
||||
return this.service.findAll({
|
||||
@@ -42,7 +53,7 @@ export class EmptyReturnRequestsController {
|
||||
}
|
||||
|
||||
@Get('eligibility/:bookingId')
|
||||
@MixedAudience(FREIGHT_PERMS.emptyReturnRequests.view)
|
||||
@MixedAudience(CAN_VIEW)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Whether a booking may request an empty return, its free containers, and the price per container',
|
||||
@@ -55,14 +66,14 @@ export class EmptyReturnRequestsController {
|
||||
}
|
||||
|
||||
@Get('by-booking/:bookingId')
|
||||
@MixedAudience(FREIGHT_PERMS.emptyReturnRequests.view)
|
||||
@MixedAudience(CAN_VIEW)
|
||||
@ApiOperation({ summary: "A booking's empty return requests, newest first" })
|
||||
findForBooking(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
|
||||
return this.service.findForBooking(bookingId);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@MixedAudience(FREIGHT_PERMS.emptyReturnRequests.view)
|
||||
@MixedAudience(CAN_VIEW)
|
||||
@ApiOperation({ summary: 'Get an empty return request by ID' })
|
||||
findOne(@Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: TCurrentUser) {
|
||||
return this.service.findById(id, this.portalUserId(user));
|
||||
|
||||
Reference in New Issue
Block a user