mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-03 07:23:40 +00:00
Merge pull request #1473 from Tria-plc/Warehousechanges
Customer Truck Assignmnet
This commit is contained in:
@@ -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({
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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],
|
||||
)
|
||||
: [];
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`,
|
||||
);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 1–2 containers per truck;
|
||||
bulk bookings just register the truck (no container picker). */}
|
||||
{isBulk || availableContainers.length > 0 ? (
|
||||
|
||||
Reference in New Issue
Block a user