Merge pull request #1473 from Tria-plc/Warehousechanges

Customer Truck Assignmnet
This commit is contained in:
Hagernesh Tadesse
2026-09-02 10:24:26 +03:00
committed by GitHub
18 changed files with 868 additions and 160 deletions

View File

@@ -45,6 +45,16 @@ describe('assertTruckLoad', () => {
).toThrow(BadRequestException);
});
it('allows two containers only when both are explicitly 20ft', () => {
expect(() =>
assertTruckLoad({
containers: ['ABCD1234567', 'ABCD7654321'],
bookingContainers: booking,
sizes: ['20ft', '45ft'],
}),
).toThrow(BadRequestException);
});
it('rejects more than two containers', () => {
expect(() =>
assertTruckLoad({

View File

@@ -54,10 +54,11 @@ export function assertTruckLoad({
}
}
// A 40ft fills the bed, so it travels alone.
if (containers.length > 1 && sizes.some((size) => size.includes('40'))) {
// A truck may pair containers only when BOTH are explicitly 20ft. A 40ft
// (and any legacy/unknown larger size) fills the bed and travels alone.
if (containers.length > 1 && sizes.some((size) => !size.includes('20'))) {
throw new BadRequestException(
'A 40ft container fills the truck — assign only 1 container to this truck',
'Truck capacity is either 1 x 40ft container or up to 2 x 20ft containers',
);
}
}

View File

@@ -26,6 +26,20 @@ const withEnv = (vars: Record<string, string | undefined>, fn: () => void) => {
};
describe("eims.config — private key / certificate resolution", () => {
it("requires EIMS_API_KEY when EIMS is enabled without exposing a value", () => {
withEnv(
{
...REQUIRED,
EIMS_API_KEY: undefined,
EIMS_PRIVATE_KEY: "private-key-present",
EIMS_CERTIFICATE: "certificate-present",
},
() => {
expect(() => eimsConfigFactory()).toThrow(/env vars are missing: EIMS_API_KEY/);
},
);
});
it("unescapes a literal \\n when the PEM was pasted without real newlines", () => {
withEnv(
{ ...REQUIRED, EIMS_PRIVATE_KEY: "line1\\nline2", EIMS_CERTIFICATE_PATH: "/dev/null" },

View File

@@ -312,12 +312,27 @@ export class BookingsService {
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, 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
@@ -352,17 +367,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],
)
: [];

View File

@@ -35,6 +35,7 @@ interface BookingGuardRow {
lastMile: string | null;
paymentStatus: string | null;
status: string | null;
trainScheduleStatus: string | null;
}
/**
@@ -294,19 +295,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 +540,16 @@ 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"
FROM freight.bookings b
WHERE b.id = $1 AND b.deleted_at IS NULL`,
[bookingId],
);
if (!row) throw new NotFoundException(`Booking ${bookingId} not found`);
@@ -575,7 +580,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',
);

View File

@@ -1,4 +1,5 @@
import { HttpService } from "@nestjs/axios";
import { Logger } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { AxiosError, AxiosHeaders } from "axios";
import { of, throwError } from "rxjs";
@@ -46,24 +47,48 @@ const sentConfig = (post: jest.Mock, call = 0) => post.mock.calls[call][2];
const sentBody = (post: jest.Mock, call = 0) => post.mock.calls[call][1];
describe("EimsClientService transport", () => {
it("bearer-authenticates every protected call", async () => {
const post = ok();
await build(post).postBearer("/v1/cancel", { Irn: "irn-1" });
const protectedHeaders = {
"Content-Type": "application/json",
Authorization: `Bearer ${TOKEN}`,
apikey: API_KEY,
};
expect(sentConfig(post).headers).toEqual({
"Content-Type": "application/json",
Authorization: `Bearer ${TOKEN}`,
it.each([
["verify", "/v1/verify", { irn: "irn-1" }],
["sales receipt", "/v1/receipt/sales", { receipt: "sales" }],
["withholding receipt", "/v1/receipt/withholding", { receipt: "withholding" }],
["cancel", "/v1/cancel", { Irn: "irn-1" }],
["bulk cancel", "/v1/bulkCancel", [{ Irn: "irn-1" }]],
])("authenticates the raw %s endpoint without changing its body", async (_name, path, body) => {
const post = ok();
await build(post).postBearer(path, body);
expect(sentConfig(post).headers).toEqual(protectedHeaders);
expect(sentBody(post)).toBe(body);
});
it.each([
["invoice", { DocumentDetails: { Type: "INV" } }],
["credit memo", { DocumentDetails: { Type: "CRE" } }],
["debit memo", { DocumentDetails: { Type: "DEB" } }],
])("authenticates and signs a %s registration", async (_name, request) => {
const post = ok({ statusCode: 200, body: { irn: "irn-1" } });
await build(post).postSigned("/v1/register", request);
expect(sentConfig(post).headers).toEqual(protectedHeaders);
expect(JSON.parse(sentBody(post) as string)).toEqual({
request,
signature: "SIGNATURE",
certificate: "CERTIFICATE",
});
});
it("sends the same headers on a signed call", async () => {
const post = ok({ statusCode: 200, body: { irn: "irn-1" } });
await build(post).postSigned("/v1/register", { Invoice: 1 });
it("authenticates bulk registration through the same signed path", async () => {
const post = ok({ conversationId: "conversation-1", status: 202 });
const request = [{ DocumentDetails: { Type: "INV" } }];
await build(post).postSigned("/v1/bulkRegister", request);
expect(sentConfig(post).headers).toEqual({
"Content-Type": "application/json",
Authorization: `Bearer ${TOKEN}`,
});
expect(sentConfig(post).headers).toEqual(protectedHeaders);
});
it("wraps a signed call in the {request,signature,certificate} envelope", async () => {
@@ -85,26 +110,24 @@ describe("EimsClientService transport", () => {
expect(sentBody(post)).toEqual({ Irn: "irn-1" });
});
it("re-signs and re-authenticates through the one 401 retry", async () => {
it("re-authenticates a raw verify call through the one 401 retry without changing its body", async () => {
const post = jest
.fn()
.mockReturnValueOnce(throwError(() => axiosErr(401, { message: "expired" })))
.mockReturnValueOnce(of({ data: { statusCode: 200, body: { Irn: "irn-1" } } }));
await build(post).postSigned("/v1/verify", { irn: "irn-1" });
await build(post).postBearer("/v1/verify", { irn: "irn-1" });
expect(post).toHaveBeenCalledTimes(2);
expect(sentConfig(post, 1).headers.Authorization).toBe(`Bearer ${TOKEN}`);
expect(JSON.parse(sentBody(post, 1) as string)).toMatchObject({
request: { irn: "irn-1" },
signature: "SIGNATURE",
});
expect(sentBody(post, 1)).toEqual({ irn: "irn-1" });
});
it("never leaks the api key, bearer token or client secret into a thrown failure", async () => {
const logError = jest.spyOn(Logger.prototype, "error").mockImplementation(() => undefined);
const post = jest.fn().mockReturnValue(
throwError(() =>
// The live shape of an unsigned /v1/verify rejection, as observed on 2026-09-01.
// A gateway rejection may echo request data; redaction must remove it before logging.
axiosErr(400, {
message: "GATEWAY ERROR",
code: "4001",
@@ -120,7 +143,7 @@ describe("EimsClientService transport", () => {
);
const error: Error = await build(post)
.postSigned("/v1/verify", { irn: "irn-1" })
.postBearer("/v1/verify", { irn: "irn-1" })
.then(() => {
throw new Error("expected the call to reject");
})
@@ -134,7 +157,12 @@ describe("EimsClientService transport", () => {
expect(serialized).not.toContain(API_KEY);
expect(serialized).not.toContain(CLIENT_SECRET);
expect(serialized).not.toContain(TOKEN);
const serializedLogs = JSON.stringify(logError.mock.calls);
expect(serializedLogs).not.toContain(API_KEY);
expect(serializedLogs).not.toContain(CLIENT_SECRET);
expect(serializedLogs).not.toContain(TOKEN);
// The gateway's own reporting still survives redaction.
expect(error.message).toContain("4001");
logError.mockRestore();
});
});

View File

@@ -8,7 +8,7 @@ import { EimsSignerService, toSignedBody } from "./eims-signer.service";
import { toEimsApiException } from "./eims.errors";
/**
* Foundation for EIMS's bearer-authenticated endpoints (`/v1/register`, `/v1/verify`, …).
* Foundation for EIMS's authenticated endpoints (`/v1/register`, `/v1/verify`, …).
*
* Login is not routed through here: `/auth/login` carries no bearer token and lives in
* `EimsAuthService`.
@@ -29,7 +29,8 @@ export class EimsClientService {
}
/**
* Sign `request`, POST it to `path` with a valid bearer token, and return the parsed response.
* Sign `request`, POST it to `path` with the shared protected-endpoint headers, and return the
* parsed response.
* A 401 invalidates the cached token and retries exactly once.
*/
async postSigned<TRequest, TResponse>(path: string, request: TRequest): Promise<TResponse> {
@@ -37,17 +38,8 @@ export class EimsClientService {
}
/**
* POST `request` verbatim — bearer-authenticated but **not** wrapped in a signed envelope.
*
* The supplied collection sends raw bodies for `/v1/verify`, `/v1/cancel` and the receipt
* endpoints. That turned out to be wrong for `/v1/verify`, which the live gateway rejects with
* `GATEWAY ERROR code=4001` (`request`/`signature`/`certificate` must not be null) until the
* envelope is added — so verify now uses `postSigned`.
*
* The remaining callers (cancel, bulk cancel, receipts) still send raw bodies and have **not**
* been exercised against the live gateway. Each is a candidate for the same rejection; none can
* be probed safely, because unlike verify they all mutate state at MoR. Expect to convert them
* the same way the first time one is filed for real.
* POST `request` verbatim with the shared protected-endpoint headers, but **not** wrapped in a
* signed envelope. This is the wire contract for verify, cancel and receipt calls.
*/
async postBearer<TRequest, TResponse>(path: string, request: TRequest): Promise<TResponse> {
return this.send<TRequest, TResponse>(path, request, false, false);
@@ -66,7 +58,11 @@ export class EimsClientService {
try {
const res = await firstValueFrom(
this.http.post<TResponse>(`${cfg.baseUrl}${path}`, body, {
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
apikey: cfg.apiKey,
},
timeout: cfg.httpTimeoutMs,
}),
);

View File

@@ -759,39 +759,37 @@ describe("EimsInvoiceRegistrationService staff alerting", () => {
});
describe("EimsInvoiceRegistrationService.verifyInvoiceWithEims", () => {
it("verifies the stored IRN over the signed transport", async () => {
it("verifies the stored IRN as an unchanged raw body", async () => {
const db = new FakeDb([invoiceRow({ eimsIrn: IRN })]);
const postBearer = jest.fn();
const postSigned = jest.fn().mockResolvedValue(verifyResponse());
const postBearer = jest.fn().mockResolvedValue(verifyResponse());
const postSigned = jest.fn();
const result = await build(db, postSigned, config(), postBearer).verifyInvoiceWithEims(
INVOICE_ID,
);
// Lowercase `irn`, signed envelope — the live gateway rejects the unsigned body with
// `code=4001` naming request/signature/certificate as null.
expect(postSigned).toHaveBeenCalledWith("/v1/verify", { irn: IRN });
expect(postBearer).not.toHaveBeenCalled();
expect(postBearer).toHaveBeenCalledWith("/v1/verify", { irn: IRN });
expect(postSigned).not.toHaveBeenCalled();
expect(result.body).toMatchObject({ Irn: IRN });
});
it("rejects a 200 that carries no Irn", async () => {
const db = new FakeDb([invoiceRow({ eimsIrn: IRN })]);
const postSigned = jest.fn().mockResolvedValue({ statusCode: 200, body: { Irn: " " } });
const postBearer = jest.fn().mockResolvedValue({ statusCode: 200, body: { Irn: " " } });
await expect(
build(db, postSigned, config(), jest.fn()).verifyInvoiceWithEims(INVOICE_ID),
build(db, jest.fn(), config(), postBearer).verifyInvoiceWithEims(INVOICE_ID),
).rejects.toThrow(/returned no Irn/);
});
it("refuses to verify an invoice with no IRN", async () => {
const db = new FakeDb([invoiceRow({ eimsStatus: EimsInvoiceStatus.Unknown })]);
const postSigned = jest.fn();
const postBearer = jest.fn();
await expect(
build(db, postSigned, config(), jest.fn()).verifyInvoiceWithEims(INVOICE_ID),
build(db, jest.fn(), config(), postBearer).verifyInvoiceWithEims(INVOICE_ID),
).rejects.toThrow(/no EIMS IRN to verify/);
expect(postSigned).not.toHaveBeenCalled();
expect(postBearer).not.toHaveBeenCalled();
});
it("leaves a filed invoice and the IRN chain untouched when the gateway rejects the verify", async () => {
@@ -803,12 +801,12 @@ describe("EimsInvoiceRegistrationService.verifyInvoiceWithEims", () => {
// The live failure this guards: `GATEWAY ERROR code=4001`, a transport fault on a document
// that is already registered. Verification is a read — a failed read must never downgrade the
// registration or move the counter.
const postSigned = jest
const postBearer = jest
.fn()
.mockRejectedValue(new EimsApiException("SCHEMA_VALIDATION", "GATEWAY ERROR code=4001", 400));
await expect(
build(db, postSigned, config(), jest.fn()).verifyInvoiceWithEims(INVOICE_ID),
build(db, jest.fn(), config(), postBearer).verifyInvoiceWithEims(INVOICE_ID),
).rejects.toThrow(/4001/);
expect(db.invoices.get(INVOICE_ID)).toEqual(before);
@@ -835,15 +833,15 @@ describe("EimsInvoiceRegistrationService.resolveEimsRegistration", () => {
it("records a confirmed IRN, resumes the chain and clears the block", async () => {
const db = blocked();
const postSigned = jest.fn().mockResolvedValue(verifyResponse());
const postBearer = jest.fn().mockResolvedValue(verifyResponse());
const view = await build(db, postSigned, config(), jest.fn()).resolveEimsRegistration(
const view = await build(db, jest.fn(), config(), postBearer).resolveEimsRegistration(
INVOICE_ID,
{ irn: IRN },
);
// The IRN is confirmed at the gateway before it is ever written.
expect(postSigned).toHaveBeenCalledWith("/v1/verify", { irn: IRN });
expect(postBearer).toHaveBeenCalledWith("/v1/verify", { irn: IRN });
expect(view).toMatchObject({ eimsStatus: EimsInvoiceStatus.Registered, eimsIrn: IRN });
expect(db.state).toMatchObject({
previousIrn: IRN,
@@ -854,12 +852,12 @@ describe("EimsInvoiceRegistrationService.resolveEimsRegistration", () => {
it("refuses an IRN the gateway answers with a different one, leaving the block intact", async () => {
const db = blocked();
const postSigned = jest
const postBearer = jest
.fn()
.mockResolvedValue(verifyResponse({ Irn: "0000000000000000000000000000000000000000" }));
await expect(
build(db, postSigned, config(), jest.fn()).resolveEimsRegistration(INVOICE_ID, { irn: IRN }),
build(db, jest.fn(), config(), postBearer).resolveEimsRegistration(INVOICE_ID, { irn: IRN }),
).rejects.toThrow(/answered the lookup for IRN/);
expect(db.invoices.get(INVOICE_ID)).toMatchObject({
@@ -875,14 +873,14 @@ describe("EimsInvoiceRegistrationService.resolveEimsRegistration", () => {
it("refuses an IRN whose document number is not this invoice, leaving the block intact", async () => {
const db = blocked();
const postSigned = jest.fn().mockResolvedValue(
const postBearer = jest.fn().mockResolvedValue(
verifyResponse({
DocumentDetails: { Type: "INV", DocumentNumber: "99999" },
}),
);
await expect(
build(db, postSigned, config(), jest.fn()).resolveEimsRegistration(INVOICE_ID, { irn: IRN }),
build(db, jest.fn(), config(), postBearer).resolveEimsRegistration(INVOICE_ID, { irn: IRN }),
).rejects.toThrow(/not 5/);
expect(db.invoices.get(INVOICE_ID)).toMatchObject({
@@ -898,10 +896,10 @@ describe("EimsInvoiceRegistrationService.resolveEimsRegistration", () => {
it("refuses an IRN the gateway does not acknowledge at all", async () => {
const db = blocked();
const postSigned = jest.fn().mockResolvedValue({ statusCode: 200, body: {} });
const postBearer = jest.fn().mockResolvedValue({ statusCode: 200, body: {} });
await expect(
build(db, postSigned, config(), jest.fn()).resolveEimsRegistration(INVOICE_ID, { irn: IRN }),
build(db, jest.fn(), config(), postBearer).resolveEimsRegistration(INVOICE_ID, { irn: IRN }),
).rejects.toThrow(/returned no Irn/);
expect(db.state!.blockedReason).toBe("never acknowledged");
});

View File

@@ -214,13 +214,11 @@ export class EimsInvoiceRegistrationService {
* compared — the supplied collection's own fixture uses different example values on each side,
* so equality there would assert a property of the mock rather than of the gateway.
*
* Signed, via `postSigned`. The supplied collection shows a raw `{"irn":"…"}` body, but the live
* gateway rejects that with `GATEWAY ERROR code=4001` naming `request`, `signature` and
* `certificate` as null — verified against `core.mor.gov.et` on 2026-09-01. The signed envelope
* clears that validation. The collection's unsigned example is wrong for this endpoint.
* Raw, via `postBearer`: verification accepts exactly `{"irn":"…"}` and relies on the shared
* transport for the bearer token and API-key header. It must not be signed or wrapped.
*/
private async queryVerify(irn: string): Promise<EimsVerifyResponse> {
const response = await this.client.postSigned<EimsVerifyRequest, EimsVerifyResponse>(
const response = await this.client.postBearer<EimsVerifyRequest, EimsVerifyResponse>(
"/v1/verify",
{ irn },
);

View File

@@ -191,6 +191,7 @@ describe("EimsReceiptService.registerSalesReceipt", () => {
it("marks the receipt FAILED on a deterministic rejection and rethrows", async () => {
const db = new FakeDb([invoiceRow()]);
const invoiceBefore = { ...db.invoices.get(INVOICE_ID)! };
const postBearer = jest
.fn()
.mockRejectedValue(new EimsApiException("RULE_VALIDATION", "EIMS receipt failed (406)", 406));
@@ -200,6 +201,7 @@ describe("EimsReceiptService.registerSalesReceipt", () => {
).rejects.toBeInstanceOf(EimsApiException);
const [receipt] = [...db.receipts.values()];
expect(receipt.status).toBe(EimsReceiptStatus.Failed);
expect(db.invoices.get(INVOICE_ID)).toEqual(invoiceBefore);
});
it("marks the receipt UNKNOWN on an ambiguous failure (never auto-retried)", async () => {

View File

@@ -1,6 +1,19 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { ArrayNotEmpty, IsArray, IsBoolean, IsIn, IsNumber, IsOptional, IsString, IsUUID, Min } from 'class-validator';
import {
ArrayMaxSize,
ArrayNotEmpty,
ArrayUnique,
IsArray,
IsBoolean,
IsIn,
IsNumber,
IsOptional,
IsString,
IsUUID,
Matches,
Min,
} from 'class-validator';
import { ValidateNested } from 'class-validator';
export class TruckEntranceDto {
@@ -187,6 +200,22 @@ export class BulkReceiveDto {
@IsUUID('all', { each: true })
bookingIds!: string[];
/**
* The physical containers delivered by this truck. Container exports are
* received one truck at a time: either one 40ft box or up to two 20ft boxes.
*/
@ApiPropertyOptional({ type: [String] })
@IsOptional()
@IsArray()
@ArrayNotEmpty()
@ArrayMaxSize(2)
@ArrayUnique()
@Matches(/^[A-Z]{4}\d{7}$/, {
each: true,
message: 'each container number must match ISO container format, e.g. ABCD1234567',
})
containerNumbers?: string[];
@ApiPropertyOptional({ type: TruckEntranceDto })
@IsOptional()
@ValidateNested()

View File

@@ -17,6 +17,7 @@ import {
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
import { generateGrnNumber } from '../../common/grn.util';
import { assertTruckLoad } from '../../common/truck-load.util';
import { SCHEDULE_BOOKINGS_CTE } from '../../common/schedule-bookings.sql';
import { Booking } from '../bookings/entities/booking.entity';
import type { StationWorkLog } from '../train-schedules/entities/train-schedule.entity';
@@ -275,12 +276,30 @@ export interface EligibleBookingRow {
customerTruckType: string | null;
customerTruckContainerNumber: string | null;
customerTruckAssignedAt: string | null;
containerUnits: Array<{
containerNumber: string;
containerSize: string | null;
weightTons: number;
received: boolean;
grnNumber: string | null;
}>;
receivedContainerCount: number;
remainingContainerCount: number;
}
export interface BulkReceiveResult {
receivedCount: number;
skippedCount: number;
results: { bookingId: string; status: string; inventoryId?: string; grnNumber?: string; reason?: string }[];
results: {
bookingId: string;
status: string;
inventoryId?: string;
inventoryIds?: string[];
grnNumber?: string;
receivedContainers?: number;
remainingContainers?: number;
reason?: string;
}[];
}
@@ -1406,6 +1425,9 @@ export class WarehouseInventoryService {
${companyNotifyPhoneExpr('company')} AS "customerPhone",
COALESCE(bcu.unit_numbers, bc.container_numbers) AS "containerNumber",
bcu.seal_numbers AS "sealNumbers",
COALESCE(bcu.container_units, '[]'::json) AS "containerUnits",
COALESCE(bcu.received_count, 0)::int AS "receivedContainerCount",
COALESCE(bcu.remaining_count, 0)::int AS "remainingContainerCount",
bc.container_quantity AS "containerQuantity",
bc.container_packaging_type AS "containerPackagingType",
-- service_types.includes_last_mile/first_mile are NOT read here: every
@@ -1457,7 +1479,6 @@ export class WarehouseInventoryService {
LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id
LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id
LEFT JOIN freight.cargo_types ct ON ct.id = b.cargo_type_id
LEFT JOIN freight.warehouse_inventory inv ON inv.booking_id = b.id AND inv.deleted_at IS NULL
LEFT JOIN LATERAL (
SELECT string_agg(NULLIF(booking_container.container_number, ''), ', ' ORDER BY booking_container.container_number) AS container_numbers,
SUM(booking_container.quantity)::int AS container_quantity,
@@ -1476,7 +1497,18 @@ export class WarehouseInventoryService {
) bc ON true
LEFT JOIN LATERAL (
SELECT string_agg(NULLIF(unit.container_number, ''), ', ' ORDER BY unit.container_number) AS unit_numbers,
string_agg(DISTINCT NULLIF(unit.seal_number, ''), ', ') AS seal_numbers
string_agg(DISTINCT NULLIF(unit.seal_number, ''), ', ') AS seal_numbers,
COUNT(*) FILTER (WHERE unit.received_to_port)::int AS received_count,
COUNT(*) FILTER (WHERE NOT unit.received_to_port)::int AS remaining_count,
json_agg(
json_build_object(
'containerNumber', unit.container_number,
'containerSize', line.container_size,
'weightTons', unit.vgm_tons,
'received', unit.received_to_port,
'grnNumber', unit.grn_number
) ORDER BY unit.container_number
) AS container_units
FROM freight.booking_container_units unit
JOIN freight.booking_container line
ON line.id = unit.booking_container_id AND line.deleted_at IS NULL
@@ -1493,7 +1525,14 @@ export class WarehouseInventoryService {
LEFT JOIN freight.drivers driver ON driver.id = v.assigned_driver_id
WHERE b.deleted_at IS NULL
AND b.payment_status = 'PAID'
AND inv.id IS NULL
AND (
(b.freight_type = 'CONTAINER' AND COALESCE(bcu.remaining_count, 0) > 0)
OR
(b.freight_type <> 'CONTAINER' AND NOT EXISTS (
SELECT 1 FROM freight.warehouse_inventory inv
WHERE inv.booking_id = b.id AND inv.deleted_at IS NULL
))
)
-- Direct truck-to-train cargo never comes to the warehouse, so never
-- offer it for receipt.
AND COALESCE(b.export_handover_mode, 'WAREHOUSE') <> 'DIRECT_TO_TRAIN'
@@ -1650,9 +1689,6 @@ export class WarehouseInventoryService {
}
}
const existing = await manager.getRepository(WarehouseInventory).findOne({ where: { bookingId } });
if (existing) { skip('Already received'); continue; }
const containerQuantity = Number(booking.containerQuantity ?? 0);
if (booking.freightType === 'CONTAINER' && containerQuantity <= 0) {
skip('Container booking has no container quantity');
@@ -1660,62 +1696,256 @@ export class WarehouseInventoryService {
}
const now = new Date();
const grnNumber = this.generateGrnNumber(dto.direction, bookingId, now, booking.customer);
const truckEntrance = dto.truckEntrance
? this.mergeSystemTruckEntrance(dto.truckEntrance, booking)
: undefined;
// Multi-truck self-haul is selected explicitly at the gate. The booking
// source contains comma-joined legacy summary fields, which must never
// replace the one physical truck the receiver selected.
if (truckEntrance && !booking.hasFirstMile && dto.truckEntrance) {
truckEntrance.truckPlateNumber = dto.truckEntrance.truckPlateNumber;
truckEntrance.driverName = dto.truckEntrance.driverName;
truckEntrance.driverPhone = dto.truckEntrance.driverPhone;
truckEntrance.truckType = dto.truckEntrance.truckType;
}
if (dto.direction === 'EXPORT') {
this.assertTruckEntrance(truckEntrance);
}
type ReceiveContainerUnit = {
containerNumber: string;
containerSize: string | null;
weightTons: string | number;
sealNumber: string | null;
bookingContainerId: string;
containerTypeId: string | null;
received: boolean;
};
let selectedUnits: ReceiveContainerUnit[] = [];
let grnNumber = this.generateGrnNumber(dto.direction, bookingId, now, booking.customer);
if (booking.freightType === 'CONTAINER') {
if (dto.bookingIds.length !== 1) {
throw new BadRequestException(
'Receive one container booking per arriving truck so its containers and documents stay separate',
);
}
const selectedNumbers = (dto.containerNumbers ?? []).map((n) => n.trim().toUpperCase());
if (!selectedNumbers.length) {
throw new BadRequestException('Select the containers arriving on this truck');
}
const allUnits: ReceiveContainerUnit[] = await manager.query(
`SELECT UPPER(bcu.container_number) AS "containerNumber",
bc.container_size AS "containerSize",
bcu.vgm_tons AS "weightTons",
bcu.seal_number AS "sealNumber",
bc.id AS "bookingContainerId",
bc.container_type_id AS "containerTypeId",
bcu.received_to_port AS received
FROM freight.booking_container_units bcu
JOIN freight.booking_container bc
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
WHERE bc.booking_id = $1 AND bcu.deleted_at IS NULL
FOR UPDATE OF bcu`,
[bookingId],
);
assertTruckLoad({
containers: selectedNumbers,
bookingContainers: allUnits.map((unit) => unit.containerNumber),
sizes: allUnits
.filter((unit) => selectedNumbers.includes(unit.containerNumber))
.map((unit) => unit.containerSize ?? ''),
});
selectedUnits = allUnits.filter((unit) => selectedNumbers.includes(unit.containerNumber));
if (selectedUnits.some((unit) => unit.received)) {
const repeated = selectedUnits.filter((unit) => unit.received).map((unit) => unit.containerNumber);
throw new BadRequestException(`Container(s) already received: ${repeated.join(', ')}`);
}
// If this is a customer-assigned truck, it may only deliver the boxes
// assigned to that plate. Manual/unassigned arrivals retain the same
// physical capacity validation but have no assignment list to check.
if (truckEntrance?.truckPlateNumber) {
const assigned: Array<{ containerNumber: string }> = await manager.query(
`SELECT UPPER(ctc.container_number) AS "containerNumber"
FROM freight.customer_truck_assignments cta
JOIN freight.customer_truck_containers ctc
ON ctc.assignment_id = cta.id AND ctc.deleted_at IS NULL
WHERE cta.booking_id = $1
AND UPPER(cta.plate_number) = UPPER($2)
AND cta.deleted_at IS NULL`,
[bookingId, truckEntrance.truckPlateNumber],
);
if (
assigned.length > 0 &&
selectedNumbers.some(
(number) => !assigned.some((container) => container.containerNumber === number),
)
) {
throw new BadRequestException(
`Selected containers are not assigned to truck ${truckEntrance.truckPlateNumber}`,
);
}
}
const [{ batches }]: Array<{ batches: string }> = await manager.query(
`SELECT COUNT(DISTINCT inv.grn_number) AS batches
FROM freight.warehouse_inventory inv
WHERE inv.booking_id = $1
AND inv.grn_number IS NOT NULL
AND inv.deleted_at IS NULL`,
[bookingId],
);
grnNumber = `${grnNumber}-${String(Number(batches ?? 0) + 1).padStart(2, '0')}`;
if (truckEntrance) {
truckEntrance.assignedEquipmentNumber = selectedNumbers.join(', ');
truckEntrance.unitCount = selectedNumbers.length;
truckEntrance.netWeightKg = selectedUnits.reduce(
(total, unit) => total + Number(unit.weightTons || 0),
0,
);
}
} else {
const existing = await manager
.getRepository(WarehouseInventory)
.findOne({ where: { bookingId } });
if (existing) {
skip('Already received');
continue;
}
}
const receivedBefore =
booking.freightType === 'CONTAINER'
? Number(
(
await manager.query(
`SELECT COUNT(*) AS count
FROM freight.booking_container_units bcu
JOIN freight.booking_container bc
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
WHERE bc.booking_id = $1
AND bcu.received_to_port = true
AND bcu.deleted_at IS NULL`,
[bookingId],
)
)[0]?.count ?? 0,
)
: 0;
const receivedAfter = receivedBefore + selectedUnits.length;
const remainingAfter = Math.max(0, containerQuantity - receivedAfter);
const receiveNote = this.buildReceiveNote({
grnNumber,
direction: dto.direction,
notes: `Bulk received (${dto.direction})`,
notes:
booking.freightType === 'CONTAINER'
? `${selectedUnits.length} container(s) arrived: ${selectedUnits
.map((unit) => unit.containerNumber)
.join(', ')}. ${remainingAfter} container(s) left.`
: `Bulk received (${dto.direction})`,
truckEntrance,
});
// Validate capacity before saving
const weight = Number(booking.weight) || 0;
const containerCount = booking.freightType === 'CONTAINER' ? containerQuantity : 0;
const weight =
booking.freightType === 'CONTAINER'
? selectedUnits.reduce((total, unit) => total + Number(unit.weightTons || 0), 0)
: Number(booking.weight) || 0;
const containerCount = booking.freightType === 'CONTAINER' ? selectedUnits.length : 0;
this.assertCapacity('Warehouse', warehouse, weight, 0, containerCount);
this.assertCapacity('Yard', yard, weight, 0, containerCount);
this.assertCapacity('Zone', zone, weight, 0, containerCount);
const saved = await manager.getRepository(WarehouseInventory).save(
manager.getRepository(WarehouseInventory).create({
warehouseId: dto.warehouseId,
yardId: dto.yardId,
zoneId: dto.zoneId,
bookingId,
quantity: booking.freightType === 'CONTAINER' ? containerQuantity : 1,
weight,
grnNumber,
status: 'RECEIVED',
arrivedAt: now,
notes: receiveNote,
}),
);
const inventoryIds: string[] = [];
if (booking.freightType === 'CONTAINER') {
const containers = manager.getRepository(Container);
for (const unit of selectedUnits) {
let container = await containers.findOne({
where: { containerNumber: unit.containerNumber },
withDeleted: true,
});
if (!container && !unit.containerTypeId) {
throw new BadRequestException(
`Container ${unit.containerNumber} has no container type and cannot be received`,
);
}
if (!container) {
container = await containers.save(
containers.create({
containerNumber: unit.containerNumber,
containerTypeId: unit.containerTypeId as string,
bookingContainerId: unit.bookingContainerId,
bookingId,
sealNumber: unit.sealNumber,
tareWeight: 0,
maxGrossWeight: Number(unit.weightTons || 0),
status: 'LOADED',
wagonId: null,
position: null,
wagonBookingAllocationId: null,
}),
);
} else {
await containers.update(container.id, {
bookingId,
bookingContainerId: unit.bookingContainerId,
sealNumber: unit.sealNumber,
status: 'LOADED',
deletedAt: null,
});
}
const saved = await manager.getRepository(WarehouseInventory).save(
manager.getRepository(WarehouseInventory).create({
warehouseId: dto.warehouseId,
yardId: dto.yardId,
zoneId: dto.zoneId,
bookingId,
containerId: container.id,
quantity: 1,
weight: Number(unit.weightTons || 0),
grnNumber,
status: 'RECEIVED',
arrivedAt: now,
notes: receiveNote,
}),
);
inventoryIds.push(saved.id);
}
await manager.query(
`UPDATE freight.booking_container_units bcu
SET received_to_port = true,
received_at = COALESCE(bcu.received_at, NOW()),
grn_number = $3,
updated_at = NOW()
FROM freight.booking_container bc
WHERE bc.id = bcu.booking_container_id
AND bc.booking_id = $1
AND UPPER(bcu.container_number) = ANY($2::varchar[])
AND bc.deleted_at IS NULL
AND bcu.deleted_at IS NULL`,
[bookingId, selectedUnits.map((unit) => unit.containerNumber), grnNumber],
);
} else {
const saved = await manager.getRepository(WarehouseInventory).save(
manager.getRepository(WarehouseInventory).create({
warehouseId: dto.warehouseId,
yardId: dto.yardId,
zoneId: dto.zoneId,
bookingId,
quantity: 1,
weight,
grnNumber,
status: 'RECEIVED',
arrivedAt: now,
notes: receiveNote,
}),
);
inventoryIds.push(saved.id);
}
// Update warehouse/yard/zone capacity counters
await this.applyCapacityDelta(manager, dto, weight, 0, containerCount);
// Receiving the booking flags every container unit as received into the
// port (self-haul export: the delivering truck's goods are now in) so
// staff can raise the per-container GRN over what's received.
await manager.query(
`UPDATE freight.booking_container_units bcu
SET received_to_port = true,
received_at = COALESCE(bcu.received_at, NOW()),
updated_at = NOW()
FROM freight.booking_container bc
WHERE bc.id = bcu.booking_container_id
AND bc.booking_id = $1
AND bc.deleted_at IS NULL
AND bcu.deleted_at IS NULL
AND bcu.received_to_port = false`,
[bookingId],
);
// Export self-haul: this receive IS the truck's arrival — see
// markCustomerTruckArrived / receive()'s single-booking mirror.
if (dto.direction === 'EXPORT') {
@@ -1725,7 +1955,7 @@ export class WarehouseInventoryService {
await this.activityLog.record(
{
activityType: 'INVENTORY_RECEIVED',
inventoryId: saved.id,
inventoryId: inventoryIds[0],
warehouseId: dto.warehouseId,
description: truckEntrance?.truckPlateNumber
? `GRN ${grnNumber}: bulk received ${dto.direction} booking via truck ${truckEntrance.truckPlateNumber}`
@@ -1752,7 +1982,19 @@ export class WarehouseInventoryService {
});
result.receivedCount += 1;
result.results.push({ bookingId, status: 'RECEIVED', inventoryId: saved.id, grnNumber });
result.results.push({
bookingId,
status: 'RECEIVED',
inventoryId: inventoryIds[0],
inventoryIds,
grnNumber,
...(booking.freightType === 'CONTAINER'
? {
receivedContainers: receivedAfter,
remainingContainers: remainingAfter,
}
: {}),
});
}
});
@@ -4026,7 +4268,7 @@ export class WarehouseInventoryService {
`SELECT inv.id,
inv.release_date AS "releaseDate",
inv.release_order_reference AS "releaseOrderReference",
inv.quantity,
COALESCE(receipt_batch.quantity, inv.quantity) AS quantity,
inv.weight,
inv.status,
inv.notes,
@@ -4355,11 +4597,12 @@ export class WarehouseInventoryService {
*/
async bookingContainerWeights(
bookingId: string,
): Promise<Array<{ containerNumber: string; weightTons: number }>> {
const rows: Array<{ containerNumber: string; weightTons: string }> =
): Promise<Array<{ containerNumber: string; weightTons: number; containerSize: string | null }>> {
const rows: Array<{ containerNumber: string; weightTons: string; containerSize: string | null }> =
await this.dataSource.query(
`SELECT bcu.container_number AS "containerNumber",
MAX(COALESCE(bcu.vgm_tons, 0)) AS "weightTons"
MAX(COALESCE(bcu.vgm_tons, 0)) AS "weightTons",
MAX(bc.container_size) AS "containerSize"
FROM freight.booking_container_units bcu
JOIN freight.booking_container bc
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
@@ -4371,6 +4614,7 @@ export class WarehouseInventoryService {
return rows.map((r) => ({
containerNumber: r.containerNumber,
weightTons: Number(r.weightTons) || 0,
containerSize: r.containerSize ?? null,
}));
}
@@ -4572,7 +4816,7 @@ export class WarehouseInventoryService {
-- An unweighed item still reports the cargo weight it holds: fall
-- back to the item's container VGM, then the booking's declared
-- weight, so a GRN never prints "0 t" for goods that are present.
COALESCE(NULLIF(inv.weight, 0), item_vgm.tons, b.cargo_total_weight_vgm, 0) AS weight,
COALESCE(NULLIF(receipt_batch.weight, 0), NULLIF(inv.weight, 0), item_vgm.tons, b.cargo_total_weight_vgm, 0) AS weight,
inv.volume,
inv.status,
inv.notes,
@@ -4589,8 +4833,8 @@ export class WarehouseInventoryService {
origin_yard.code AS "originYardCode",
destination_yard.label AS "destinationYardLabel",
destination_yard.code AS "destinationYardCode",
COALESCE(container.container_number, booking_container.container_number) AS "containerNumber",
booking_container."containerSummary" AS "bookingContainerSummary",
COALESCE(receipt_batch.container_numbers, container.container_number, booking_container.container_number) AS "containerNumber",
COALESCE(receipt_batch.container_summary, booking_container."containerSummary") AS "bookingContainerSummary",
COALESCE(cargo_type.cargo_type_name, b.cargo_free_text, cargo.description) AS "cargoDescription",
wh.name AS "warehouseName",
wh.code AS "warehouseCode",
@@ -4620,6 +4864,40 @@ export class WarehouseInventoryService {
WHERE bc.booking_id = b.id
AND bc.deleted_at IS NULL
) booking_container ON true
LEFT JOIN LATERAL (
SELECT COUNT(*)::int AS quantity,
SUM(batch.weight) AS weight,
string_agg(batch.container_number, ', ' ORDER BY batch.container_number)
FILTER (WHERE batch.container_number IS NOT NULL) AS container_numbers,
string_agg(
CONCAT(batch.container_number, ' (', COALESCE(batch.container_size, 'size unknown'), ')'),
', ' ORDER BY batch.container_number
) FILTER (WHERE batch.container_number IS NOT NULL) AS container_summary
FROM (
SELECT inv2.id,
inv2.weight,
c2.container_number,
bc2.container_size
FROM freight.warehouse_inventory inv2
LEFT JOIN freight.containers c2
ON c2.id = inv2.container_id AND c2.deleted_at IS NULL
LEFT JOIN freight.booking_container_units bcu2
ON bcu2.container_number = c2.container_number AND bcu2.deleted_at IS NULL
LEFT JOIN freight.booking_container bc2
ON bc2.id = bcu2.booking_container_id
AND bc2.booking_id = inv2.booking_id
AND bc2.deleted_at IS NULL
WHERE inv2.booking_id = inv.booking_id
AND inv2.deleted_at IS NULL
AND COALESCE(
NULLIF(TRIM(inv2.grn_number), ''),
substring(inv2.notes FROM 'GRN Number: ([^\\n\\r]+)')
) = COALESCE(
NULLIF(TRIM(inv.grn_number), ''),
substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)')
)
) batch
) receipt_batch ON true
LEFT JOIN LATERAL (
SELECT SUM(bcu.vgm_tons) AS tons
FROM freight.booking_container_units bcu

View File

@@ -58,6 +58,7 @@ import { QUERY_KEYS } from '@/constants/QUERY_KEYS';
import { useToast } from '@/hooks/use-toast';
import { useAllWarehouseYards, useAllWarehouseZones, useInventoryInquiry } from '@/hooks/useWarehouses';
import { firstMileService } from '@/services/first-mile.service';
import { bookingsService } from '@/services/bookings.service';
import { warehouseService } from '@/services/warehouse.service';
import type {
EligibleBooking,
@@ -858,6 +859,8 @@ function EligibleTab({
const [truckForm, setTruckForm] = useState<TruckEntranceFormState>(emptyTruckEntrance());
const [lockedTruckFields, setLockedTruckFields] = useState<LockedTruckEntranceFields>({});
const [packagingFreightType, setPackagingFreightType] = useState<PackagingFreightType>('MIXED');
const [selectedCustomerTruckId, setSelectedCustomerTruckId] = useState<string | null>(null);
const [selectedContainerNumbers, setSelectedContainerNumbers] = useState<string[]>([]);
const locationReady = Boolean(location.warehouseId && location.yardId && location.zoneId);
const canReceiveBooking = (row: EligibleBooking) =>
@@ -944,6 +947,105 @@ function EligibleTab({
const pendingHasFirstMile = pendingReceiveRows.some((row) => row.hasFirstMile);
const pendingUsesFirstMile =
pendingReceiveRows.length > 0 && pendingReceiveRows.every((row) => row.hasFirstMile);
const pendingContainerBooking =
pendingReceiveRows.length === 1 && pendingReceiveRows[0]?.freightType === 'CONTAINER'
? pendingReceiveRows[0]
: null;
const { data: assignedCustomerTrucks = [] } = useQuery({
queryKey: ['receive-customer-trucks', pendingContainerBooking?.id],
queryFn: () => warehouseService.getCustomerTrucks(pendingContainerBooking?.id as string),
enabled: truckOpen && Boolean(pendingContainerBooking) && !pendingUsesFirstMile,
});
const pendingContainerUnits = (pendingContainerBooking?.containerUnits ?? []).filter(
(unit) => !unit.received,
);
const selectedCustomerTruck = assignedCustomerTrucks.find(
(truck) => truck.id === selectedCustomerTruckId,
);
const assignedNumbersForSelectedTruck = new Set(
(selectedCustomerTruck?.containers ?? []).map((container) => container.containerNumber.toUpperCase()),
);
const selectableContainerUnits = pendingContainerUnits.filter(
(unit) =>
assignedNumbersForSelectedTruck.size === 0 ||
assignedNumbersForSelectedTruck.has(unit.containerNumber.toUpperCase()),
);
const selectedContainerUnits = pendingContainerUnits.filter((unit) =>
selectedContainerNumbers.includes(unit.containerNumber),
);
const selectedContainerWeight = selectedContainerUnits.reduce(
(total, unit) => total + Number(unit.weightTons || 0),
0,
);
const containerCapacityError =
selectedContainerNumbers.length > 2
? 'A truck carries no more than 2 containers.'
: selectedContainerNumbers.length > 1 &&
selectedContainerUnits.some((unit) => !String(unit.containerSize ?? '').includes('20'))
? 'Truck capacity is either 1 x 40ft container or up to 2 x 20ft containers.'
: null;
useEffect(() => {
if (!truckOpen || pendingUsesFirstMile || !pendingContainerBooking) return;
if (selectedCustomerTruckId || assignedCustomerTrucks.length === 0) return;
const pendingNumbers = new Set(
pendingContainerUnits.map((unit) => unit.containerNumber.toUpperCase()),
);
const truck =
assignedCustomerTrucks.find(
(candidate) =>
!candidate.arrivedAt &&
(candidate.containers ?? []).some((container) =>
pendingNumbers.has(container.containerNumber.toUpperCase()),
),
) ?? assignedCustomerTrucks[0];
const truckContainers = (truck.containers ?? [])
.map((container) => container.containerNumber.toUpperCase())
.filter((number) => pendingNumbers.has(number));
setSelectedCustomerTruckId(truck.id);
setSelectedContainerNumbers(truckContainers);
setTruckForm((current) => ({
...current,
truckPlateNumber: truck.plateNumber,
driverName: truck.driverName,
truckType: truck.truckType,
assignedEquipmentNumber: truckContainers.join(', '),
unitCount: truckContainers.length,
netWeightKg: pendingContainerUnits
.filter((unit) => truckContainers.includes(unit.containerNumber.toUpperCase()))
.reduce((total, unit) => total + Number(unit.weightTons || 0), 0),
}));
setLockedTruckFields((current) => ({
...current,
truckPlateNumber: true,
driverName: true,
truckType: true,
assignedEquipmentNumber: true,
unitCount: true,
}));
}, [
assignedCustomerTrucks,
pendingContainerBooking,
pendingContainerUnits,
pendingUsesFirstMile,
selectedCustomerTruckId,
truckOpen,
]);
useEffect(() => {
if (!truckOpen || !pendingContainerBooking) return;
setTruckForm((current) => ({
...current,
assignedEquipmentNumber: selectedContainerNumbers.join(', '),
unitCount: selectedContainerNumbers.length,
netWeightKg: selectedContainerWeight,
}));
}, [
pendingContainerBooking,
selectedContainerNumbers,
selectedContainerWeight,
truckOpen,
]);
const toggleAll = () =>
setSelected(allSelected ? new Set() : new Set(selectableRows.map((r) => r.id)));
@@ -954,29 +1056,60 @@ function EligibleTab({
return next;
});
const receiveBookings = async (bookingIds: string[], truckEntrance?: TruckEntrancePayload) => {
const receiveBookings = async (
bookingIds: string[],
truckEntrance?: TruckEntrancePayload,
containerNumbers?: string[],
) => {
const documentBookingId = direction === 'EXPORT' && bookingIds.length === 1 ? bookingIds[0] : null;
const grnWindow = documentBookingId ? window.open('', '_blank') : null;
const acceptanceWindow = documentBookingId ? window.open('', '_blank') : null;
try {
const r = await bulkReceive.mutateAsync({
direction,
...location,
bookingIds,
...(containerNumbers?.length ? { containerNumbers } : {}),
...(truckEntrance ? { truckEntrance } : {}),
});
const receivedProgress = r.results.find(
(item) => item.receivedContainers != null && item.remainingContainers != null,
);
toast({
title: `${r.receivedCount} received at ${direction === 'EXPORT' ? 'facility' : 'warehouse'}`,
description: r.results.find((item) => item.grnNumber)?.grnNumber ?? skippedSummary(r.skippedCount, r.results),
description: receivedProgress
? `${containerNumbers?.length ?? 0} container(s) arrived. ${receivedProgress.remainingContainers} container(s) left. GRN ${receivedProgress.grnNumber}.`
: r.results.find((item) => item.grnNumber)?.grnNumber ?? skippedSummary(r.skippedCount, r.results),
});
const firstGrn = r.results.find((item) => item.inventoryId && item.grnNumber);
if (focusedBookingId && firstGrn?.inventoryId && firstGrn.grnNumber) {
const pdfWindow = window.open('', '_blank');
if (documentBookingId && firstGrn?.inventoryId && firstGrn.grnNumber) {
try {
const response = await warehouseService.downloadGrnDocument(firstGrn.inventoryId);
const opened = openPdfBlob(response.data, `grn-${firstGrn.grnNumber}.pdf`, pdfWindow);
const opened = openPdfBlob(response.data, `grn-${firstGrn.grnNumber}.pdf`, grnWindow);
toast({ title: opened ? 'GRN document opened' : 'GRN document downloaded' });
} catch (error) {
pdfWindow?.close();
grnWindow?.close();
toast({ variant: 'destructive', title: 'GRN document failed', description: await extractDownloadErrorMessage(error) });
}
try {
const acceptance = await bookingsService.downloadCarriageAcceptanceSheet(documentBookingId);
const opened = openPdfBlob(
acceptance,
`carriage-acceptance-${pendingReceiveRows[0]?.reference ?? documentBookingId}.pdf`,
acceptanceWindow,
);
toast({ title: opened ? 'Carriage acceptance sheet opened' : 'Carriage acceptance sheet downloaded' });
} catch (error) {
acceptanceWindow?.close();
toast({
variant: 'destructive',
title: 'Carriage acceptance sheet failed',
description: await extractDownloadErrorMessage(error),
});
}
} else {
grnWindow?.close();
acceptanceWindow?.close();
}
setSelected(new Set());
setTruckOpen(false);
@@ -984,8 +1117,12 @@ function EligibleTab({
setReceivedAt(null);
setLockedTruckFields({});
setPackagingFreightType('MIXED');
setSelectedCustomerTruckId(null);
setSelectedContainerNumbers([]);
onChanged?.();
} catch (error) {
grnWindow?.close();
acceptanceWindow?.close();
toast({ variant: 'destructive', title: 'Receive failed', description: extractErrorMessage(error) });
}
};
@@ -1012,6 +1149,18 @@ function EligibleTab({
void receiveBookings(filteredIds);
return;
}
if (
direction === 'EXPORT' &&
selectedRows.some((row) => row.freightType === 'CONTAINER') &&
selectedRows.length !== 1
) {
toast({
variant: 'destructive',
title: 'Receive one container booking per truck',
description: 'Select the arriving truck and its 1 x 40ft or up to 2 x 20ft containers.',
});
return;
}
const hasFirstMileRows = selectedRows.some((row) => row.hasFirstMile);
const hasCustomerTruckRows = selectedRows.some((row) => !row.hasFirstMile);
if (hasFirstMileRows && hasCustomerTruckRows) {
@@ -1041,6 +1190,8 @@ function EligibleTab({
...form,
};
setPendingReceiveIds(filteredIds);
setSelectedCustomerTruckId(null);
setSelectedContainerNumbers([]);
setReceivedAt(new Date().toISOString());
setTruckForm(normalizedForm);
setLockedTruckFields({
@@ -1073,7 +1224,70 @@ function EligibleTab({
toast({ variant: 'destructive', title: 'Gross weight and exit tare weight are required when weighing is Yes' });
return;
}
await receiveBookings(pendingReceiveIds, toTruckEntrancePayload(truckForm));
if (pendingContainerBooking && selectedContainerNumbers.length === 0) {
toast({ variant: 'destructive', title: 'Select the containers arriving on this truck' });
return;
}
if (containerCapacityError) {
toast({ variant: 'destructive', title: 'Truck capacity exceeded', description: containerCapacityError });
return;
}
await receiveBookings(
pendingReceiveIds,
toTruckEntrancePayload({
...truckForm,
...(pendingContainerBooking
? {
assignedEquipmentNumber: selectedContainerNumbers.join(', '),
unitCount: selectedContainerNumbers.length,
netWeightKg: selectedContainerWeight,
}
: {}),
}),
pendingContainerBooking ? selectedContainerNumbers : undefined,
);
};
const chooseCustomerTruck = (truckId: string | null) => {
setSelectedCustomerTruckId(truckId);
const truck = assignedCustomerTrucks.find((candidate) => candidate.id === truckId);
if (!truck) {
setSelectedContainerNumbers([]);
setLockedTruckFields((current) => ({
...current,
truckPlateNumber: false,
driverName: false,
truckType: false,
}));
return;
}
const pendingNumbers = new Set(
pendingContainerUnits.map((unit) => unit.containerNumber.toUpperCase()),
);
const containers = (truck.containers ?? [])
.map((container) => container.containerNumber.toUpperCase())
.filter((number) => pendingNumbers.has(number));
const weight = pendingContainerUnits
.filter((unit) => containers.includes(unit.containerNumber.toUpperCase()))
.reduce((total, unit) => total + Number(unit.weightTons || 0), 0);
setSelectedContainerNumbers(containers);
setTruckForm((current) => ({
...current,
truckPlateNumber: truck.plateNumber,
driverName: truck.driverName,
truckType: truck.truckType,
assignedEquipmentNumber: containers.join(', '),
unitCount: containers.length,
netWeightKg: weight,
}));
setLockedTruckFields((current) => ({
...current,
truckPlateNumber: true,
driverName: true,
truckType: true,
assignedEquipmentNumber: true,
unitCount: true,
}));
};
@@ -1296,6 +1510,76 @@ function EligibleTab({
: 'Register the customer or third-party truck and driver before export receiving and GRN.'}
</Text>
</Alert>
{pendingContainerBooking && (
<Stack gap="sm">
<Alert icon={<PackageCheck size={16} />} color="teal" variant="light">
<Group gap="xs">
<Text size="sm" fw={600}>
{pendingContainerBooking.receivedContainerCount + selectedContainerNumbers.length} containers arrived
</Text>
<Text size="sm">
· {Math.max(
0,
pendingContainerBooking.remainingContainerCount - selectedContainerNumbers.length,
)} left after this receipt
</Text>
<Badge variant="light" color="blue">
This truck: {selectedContainerNumbers.length}
</Badge>
</Group>
</Alert>
{assignedCustomerTrucks.length > 0 && !pendingUsesFirstMile && (
<Select
label="Arriving assigned truck"
description="Choose the physical truck at the gate; its assigned containers are selected below."
placeholder="Select truck"
data={assignedCustomerTrucks.map((truck) => ({
value: truck.id,
label: `${truck.plateNumber} · ${truck.driverName} · ${(truck.containers ?? [])
.map((container) => container.containerNumber)
.join(', ') || 'no containers'}`,
}))}
value={selectedCustomerTruckId}
onChange={chooseCustomerTruck}
searchable
required
/>
)}
<MultiSelect
label="Containers arriving on this truck"
description="Required: select either 1 x 40ft container or up to 2 x 20ft containers."
placeholder="Select the containers physically arriving"
data={selectableContainerUnits.map((unit) => {
const selected = selectedContainerNumbers.includes(unit.containerNumber);
const selectedHasNon20 = selectedContainerUnits.some(
(selectedUnit) => !String(selectedUnit.containerSize ?? '').includes('20'),
);
const candidateIs20 = String(unit.containerSize ?? '').includes('20');
return {
value: unit.containerNumber,
label: `${unit.containerNumber} · ${unit.containerSize ?? 'size unknown'} · ${Number(
unit.weightTons || 0,
).toLocaleString()} t`,
disabled:
!selected &&
(selectedContainerNumbers.length >= 2 ||
(selectedContainerNumbers.length === 1 &&
(selectedHasNon20 || !candidateIs20))),
};
})}
value={selectedContainerNumbers}
onChange={setSelectedContainerNumbers}
maxValues={2}
searchable
required
/>
{containerCapacityError && (
<Alert color="red" variant="light">
{containerCapacityError}
</Alert>
)}
</Stack>
)}
<Table.ScrollContainer minWidth={900}>
<Table withTableBorder highlightOnHover verticalSpacing="xs">
<Table.Thead>
@@ -1354,7 +1638,9 @@ function EligibleTab({
Cancel
</Button>
<Button leftSection={<Truck size={16} />} loading={bulkReceive.isPending} onClick={receive}>
Register Arrival & Generate GRN
{pendingContainerBooking
? 'Receive Selected Containers & Generate CAS + GRN'
: 'Register Arrival & Generate GRN'}
</Button>
</Group>
</Stack>

View File

@@ -387,6 +387,9 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
const containerWeightByNumber = new Map(
containerWeights.map((c) => [c.containerNumber.toUpperCase(), Number(c.weightTons) || 0]),
);
const containerSizeByNumber = new Map(
containerWeights.map((c) => [c.containerNumber.toUpperCase(), c.containerSize ?? '']),
);
// A truck may only carry out its OWN assigned containers — when the selected
// truck has an assigned load, other trucks' containers are not offered.
const assignedLoad = (selectedOption?.containerNumbers ?? []).map((n) => n.toUpperCase());
@@ -398,7 +401,7 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
c.containerNumber,
{
value: c.containerNumber,
label: `${c.containerNumber} · ${(Number(c.weightTons) || 0).toLocaleString()} t`,
label: `${c.containerNumber} · ${c.containerSize ?? 'size unknown'} · ${(Number(c.weightTons) || 0).toLocaleString()} t`,
},
]),
).values(),
@@ -409,6 +412,15 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
containerNumbers.some((n) => n.trim().toUpperCase() === option.value.toUpperCase()),
);
const selectedContainerNumbers = containerNumbers.map((n) => n.trim()).filter(Boolean);
const selectedContainerSizes = selectedContainerNumbers.map(
(number) => containerSizeByNumber.get(number.toUpperCase()) ?? '',
);
const containerCapacityError =
selectedContainerNumbers.length > 2
? 'A truck carries no more than 2 containers.'
: selectedContainerNumbers.length > 1 && selectedContainerSizes.some((size) => !size.includes('20'))
? 'Truck capacity is either 1 x 40ft container or up to 2 x 20ft containers.'
: null;
const selectedCargoWeight = Number(
selectedContainerNumbers
.reduce((sum, n) => sum + (containerWeightByNumber.get(n.toUpperCase()) ?? 0), 0)
@@ -470,6 +482,10 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
toast({ variant: 'destructive', title: 'Select the containers loaded on this truck' });
return;
}
if (isExitStep && containerCapacityError) {
toast({ variant: 'destructive', title: 'Truck capacity exceeded', description: containerCapacityError });
return;
}
if (isExitStep && !skipWeighing && systemNetWeight === '') {
toast({ variant: 'destructive', title: 'System recorded net weight is missing' });
return;
@@ -636,14 +652,15 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
label="Containers on this truck"
description={
isExitStep
? 'Select the containers loaded on this truck — their cargo weight must match gross tare.'
: 'Containers this truck will carry.'
? 'Select what is leaving: 1 x 40ft or up to 2 x 20ft. Their cargo weight must match gross - tare.'
: 'Truck capacity: 1 x 40ft container or up to 2 x 20ft containers.'
}
placeholder="Select containers"
searchable
data={containerSelectData}
value={selectedContainerNumbers}
onChange={(values) => setContainerNumbers(values.length ? values : [''])}
maxValues={2}
disabled={hasTruckLeft}
/>
) : (
@@ -708,6 +725,11 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
</Text>
</Alert>
)}
{containerCapacityError && (
<Alert icon={<Info size={16} />} color="red" variant="light">
<Text size="sm">{containerCapacityError}</Text>
</Alert>
)}
<Group justify="flex-end" mt="sm">
<Button variant="default" onClick={onClose} disabled={releaseMutation.isPending || downloading}>
Cancel

View File

@@ -206,7 +206,7 @@ export const warehouseService = {
/** A booking's containers with VGM cargo weight (tonnes) for exit weighing. */
getContainerWeights: async (
bookingId: string,
): Promise<Array<{ containerNumber: string; weightTons: number }>> => {
): Promise<Array<{ containerNumber: string; weightTons: number; containerSize: string | null }>> => {
const { data } = await apiClient.get(
`/warehouse-inventory/bookings/${bookingId}/container-weights`,
);

View File

@@ -575,6 +575,15 @@ export interface EligibleBooking {
customerTruckType: string | null;
customerTruckContainerNumber: string | null;
customerTruckAssignedAt: string | null;
containerUnits: Array<{
containerNumber: string;
containerSize: string | null;
weightTons: number;
received: boolean;
grnNumber: string | null;
}>;
receivedContainerCount: number;
remainingContainerCount: number;
}
export interface BulkReceivePayload {
@@ -583,13 +592,23 @@ export interface BulkReceivePayload {
yardId: string;
zoneId: string;
bookingIds: string[];
containerNumbers?: string[];
truckEntrance?: TruckEntrancePayload;
}
export interface BulkReceiveResult {
receivedCount: number;
skippedCount: number;
results: { bookingId: string; status: string; inventoryId?: string; grnNumber?: string; reason?: string }[];
results: {
bookingId: string;
status: string;
inventoryId?: string;
inventoryIds?: string[];
grnNumber?: string;
receivedContainers?: number;
remainingContainers?: number;
reason?: string;
}[];
}
export interface TruckEntrancePayload {

View File

@@ -193,7 +193,7 @@ export function ReadonlyBookingView({
(booking.tradeDirection === "IMPORT"
? // Import self-haul: pickup trucks are assigned only after the train has
// arrived at the destination.
status === "ARRIVED"
(status === "ARRIVED" || booking.trainScheduleStatus === "ARRIVED")
: // Export / domestic self-haul: delivery trucks are assigned only before
// the cargo is loaded onto the train (PAID / TRUCK_ASSIGNED). Once loaded
// (IN_TRANSIT and beyond) assignment is closed.

View File

@@ -306,6 +306,13 @@ export function CustomerTruckAssignmentCard({
</Alert>
)}
{!isBulk && (
<Alert color="blue" variant="light">
Truck capacity: assign either <b>1 x 40ft container</b> or up to{' '}
<b>2 x 20ft containers</b>. A 40ft container must travel alone.
</Alert>
)}
{/* Add-truck form. Container bookings assign 12 containers per truck;
bulk bookings just register the truck (no container picker). */}
{isBulk || availableContainers.length > 0 ? (