Merge branch 'dev' of github.com:Tria-plc/edr-platform into freight_feature/usermanagement

This commit is contained in:
marshal
2026-09-07 08:08:19 +00:00
34 changed files with 1393 additions and 473 deletions

View File

@@ -0,0 +1,26 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Adds the currency-level DJF switch to `manual_payment_settings`.
*
* Distinct from `djf_enabled`, which governs only the MANUAL rail: this one
* says whether DJF may be used as a payment currency at all — offered on the
* booking forms and accepted for online payment. Defaults to `true`, the
* behaviour before the switch existed.
*/
export class AddDjfPaymentsEnabled3890000000000 implements MigrationInterface {
name = 'AddDjfPaymentsEnabled3890000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.manual_payment_settings
ADD COLUMN IF NOT EXISTS djf_payments_enabled boolean NOT NULL DEFAULT true;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.manual_payment_settings DROP COLUMN IF EXISTS djf_payments_enabled;
`);
}
}

View File

@@ -82,7 +82,7 @@ describe("BillingService.generateInvoice", () => {
{} as never, // invoiceDocuments
{} as never, // files
{ get: () => undefined } as never, // config
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"], isCurrencyOffered: async () => true } as never, // manualPaymentSettings
{ directSend: jest.fn() } as never, // notifications
{ notify: jest.fn() } as never, // inbox
);
@@ -167,7 +167,7 @@ describe("BillingService.issueMemo", () => {
{} as never,
{} as never,
{ get: () => undefined } as never,
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"], isCurrencyOffered: async () => true } as never, // manualPaymentSettings
{ directSend: jest.fn() } as never, // notifications
{ notify: jest.fn() } as never, // inbox
);
@@ -304,7 +304,7 @@ describe("BillingService.markInvoiceAsPaid", () => {
{} as never, // invoiceDocuments
{} as never, // files
{ get: () => undefined } as never, // config
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"], isCurrencyOffered: async () => true } as never, // manualPaymentSettings
{ directSend: jest.fn() } as never, // notifications
{ notify: jest.fn() } as never, // inbox
);
@@ -362,7 +362,7 @@ describe("BillingService.markInvoiceAsPaid", () => {
{} as never, // invoiceDocuments
{} as never, // files
{ get: () => undefined } as never, // config
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"], isCurrencyOffered: async () => true } as never, // manualPaymentSettings
{ directSend: jest.fn() } as never, // notifications
{ notify: jest.fn() } as never, // inbox
);
@@ -410,7 +410,7 @@ describe("BillingService.settleByPaymentId", () => {
{} as never, // invoiceDocuments
{} as never, // files
{ get: () => undefined } as never, // config
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"], isCurrencyOffered: async () => true } as never, // manualPaymentSettings
{ directSend: jest.fn() } as never, // notifications
{ notify: jest.fn() } as never, // inbox
);
@@ -526,7 +526,7 @@ describe("BillingService.recordPayment", () => {
{} as never, // invoiceDocuments
{} as never, // files
{ get: () => undefined } as never, // config
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"], isCurrencyOffered: async () => true } as never, // manualPaymentSettings
{ directSend: jest.fn() } as never, // notifications
{ notify: jest.fn() } as never, // inbox
);
@@ -646,7 +646,7 @@ describe("BillingService.expirePayable — locked write runs in a transaction",
{} as never,
{} as never,
{} as never, // config
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"], isCurrencyOffered: async () => true } as never, // manualPaymentSettings
{ directSend: jest.fn() } as never, // notifications
{ notify: jest.fn() } as never, // inbox
);
@@ -722,7 +722,7 @@ describe("BillingService.issuePayable", () => {
{} as never,
{} as never,
{} as never, // config
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"], isCurrencyOffered: async () => true } as never, // manualPaymentSettings
{ directSend: jest.fn() } as never, // notifications
{ notify: jest.fn() } as never, // inbox
);
@@ -816,7 +816,7 @@ describe("BillingService — CAC Bank (OTP debit)", () => {
{} as never,
{} as never,
{} as never, // config
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"], isCurrencyOffered: async () => true } as never, // manualPaymentSettings
{ directSend: jest.fn() } as never, // notifications
{ notify: jest.fn() } as never, // inbox
);
@@ -902,7 +902,7 @@ describe("BillingService — CBE bill amounts carry cents, never rounded", () =>
{} as never,
{} as never,
{} as never, // config
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"], isCurrencyOffered: async () => true } as never, // manualPaymentSettings
{ directSend: jest.fn() } as never, // notifications
{ notify: jest.fn() } as never, // inbox
);
@@ -978,7 +978,7 @@ describe("BillingService.document", () => {
? { tin: "0053481357", invoice: { sellerVatNumber: "43256663343256663322" } }
: undefined,
} as never, // config
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"], isCurrencyOffered: async () => true } as never, // manualPaymentSettings
{ directSend: jest.fn() } as never, // notifications
{ notify: jest.fn() } as never, // inbox
);

View File

@@ -806,7 +806,7 @@ export class BillingService {
// settle by hand in a currency whose channel is switched off.
if (!(await this.manualPaymentSettings.isEnabled(invoice.currency))) {
throw new BadRequestException(
`Manual payment is disabled for ${invoice.currency ?? "this"} invoices. Enable it in Configuration → Manual payments first.`,
`Manual payment is disabled for ${invoice.currency ?? "this"} invoices. Enable it in Configuration → Payments first.`,
);
}
if (!file) {
@@ -2297,6 +2297,15 @@ export class BillingService {
);
}
// DJF switched off as a payment currency: the online rails (Waafi / CAC
// Bank) stop taking it. The invoice itself is untouched — Finance can
// still settle it by hand while the DJF manual channel is on.
if (!(await this.manualPaymentSettings.isCurrencyOffered(invoice.currency))) {
throw new BadRequestException(
`${invoice.currency} payments are switched off. Enable them in Configuration → Payments first.`,
);
}
// A booking's PREPAID invoice is only payable inside its pay window —
// `dueAt` mirrors booking.paymentDeadline (issuePayable at reserve time).
// Blocking INITIATION here is what makes the deadline real: a payment

View File

@@ -7,6 +7,7 @@ import {
} from '@nestjs/common';
import type { Freight } from '@edr/types';
import { ManualPaymentSettingsService } from '../payment-settings/manual-payment-settings.service';
import { YardScopeService } from '../rule-engine/services/yard-scope.service';
import { BookingRequestRepository } from './booking-request.repository';
import { ContractsService } from './contracts.service';
@@ -30,6 +31,7 @@ export class BookingRequestService {
private readonly contractBookingService: ContractBookingService,
private readonly notifier: ContractNotifierService,
private readonly yardScope: YardScopeService,
private readonly paymentSettings: ManualPaymentSettingsService,
) {}
/**
@@ -101,6 +103,17 @@ export class BookingRequestService {
}
}
}
// The currency picker hides a switched-off currency, but a stale tab must
// not be able to raise a shipment nobody can pay for.
if (
dto.paymentCurrency &&
!(await this.paymentSettings.isCurrencyOffered(dto.paymentCurrency))
) {
throw new BadRequestException(
`${dto.paymentCurrency.toUpperCase()} is not accepted as a billing currency right now. Pick another currency.`,
);
}
await this.contractBookingService.assertRequestWithinCapacity(contract, {
containers: dto.containers,
bulk: dto.bulk,

View File

@@ -54,6 +54,33 @@ export interface EmptyReturnQuote {
unavailableReason: string | null;
}
/** A queue row: the request plus the booking and payer names staff read it by. */
export type EmptyReturnRequestRow = Pick<
EmptyReturnRequest,
| 'id'
| 'bookingId'
| 'companyId'
| 'status'
| 'containerNumbers'
| 'containerCount'
| 'quotedUnitAmount'
| 'quotedTotalAmount'
| 'currency'
| 'invoiceId'
| 'paidAt'
| 'requestedReturnDate'
| 'truckPlateNumber'
| 'truckDriverName'
| 'truckType'
| 'scheduledAt'
| 'submittedByUserId'
| 'submittedAt'
| 'reviewedByStaffId'
| 'reviewedAt'
| 'rejectionReason'
| 'completedAt'
> & { bookingReference: string | null; companyName: string | null };
export interface EmptyReturnEligibility {
eligible: boolean;
/** Why the customer cannot request one, when `eligible` is false. */
@@ -82,13 +109,34 @@ export class EmptyReturnRequestsService {
async findAll(filter: {
status?: EmptyReturnRequestStatus;
bookingId?: string;
}): Promise<
Array<EmptyReturnRequest & { bookingReference: string | null; companyName: string | null }>
> {
}): Promise<EmptyReturnRequestRow[]> {
// Raw SQL bypasses the entity mapping, so every column is aliased to the
// property name the clients read — `r.*` would hand them snake_case.
return this.dataSource.query(
`SELECT r.*,
b.reference AS "bookingReference",
c.name AS "companyName"
`SELECT r.id,
r.booking_id AS "bookingId",
r.company_id AS "companyId",
r.status,
r.container_numbers AS "containerNumbers",
r.container_count AS "containerCount",
r.quoted_unit_amount::float8 AS "quotedUnitAmount",
r.quoted_total_amount::float8 AS "quotedTotalAmount",
r.currency,
r.invoice_id AS "invoiceId",
r.paid_at AS "paidAt",
r.requested_return_date AS "requestedReturnDate",
r.truck_plate_number AS "truckPlateNumber",
r.truck_driver_name AS "truckDriverName",
r.truck_type AS "truckType",
r.scheduled_at AS "scheduledAt",
r.submitted_by_user_id AS "submittedByUserId",
r.submitted_at AS "submittedAt",
r.reviewed_by_staff_id AS "reviewedByStaffId",
r.reviewed_at AS "reviewedAt",
r.rejection_reason AS "rejectionReason",
r.completed_at AS "completedAt",
b.reference AS "bookingReference",
c.name AS "companyName"
FROM freight.empty_return_requests r
LEFT JOIN freight.bookings b ON b.id = r.booking_id AND b.deleted_at IS NULL
LEFT JOIN freight.companies c ON c.id = r.company_id

View File

@@ -0,0 +1,56 @@
import { ManualPaymentSettingsService } from "./manual-payment-settings.service";
import type { ManualPaymentSetting } from "./entities/manual-payment-setting.entity";
/** Single-row repository stub: enough for get/update, nothing more. */
const repoWith = (row: Partial<ManualPaymentSetting>) => {
const stored = { id: "settings-1", ...row } as ManualPaymentSetting;
return {
findOne: async () => stored,
create: (v: Partial<ManualPaymentSetting>) => v as ManualPaymentSetting,
save: async (v: ManualPaymentSetting) => v,
update: async (_id: string, patch: Partial<ManualPaymentSetting>) => {
Object.assign(stored, patch);
},
};
};
const serviceWith = (row: Partial<ManualPaymentSetting>) =>
new ManualPaymentSettingsService(repoWith(row) as never);
describe("DJF currency switch", () => {
it("offers DJF, and accepts it, while the switch is on", async () => {
const service = serviceWith({ djfPaymentsEnabled: true });
await expect(service.offeredCurrencies()).resolves.toEqual([
"ETB",
"USD",
"DJF",
]);
await expect(service.isCurrencyOffered("DJF")).resolves.toBe(true);
});
it("drops DJF from the offered currencies once switched off", async () => {
const service = serviceWith({ djfPaymentsEnabled: false });
await expect(service.offeredCurrencies()).resolves.toEqual(["ETB", "USD"]);
await expect(service.isCurrencyOffered("djf")).resolves.toBe(false);
});
it("never switches off ETB or USD — only DJF has a currency-level switch", async () => {
const service = serviceWith({ djfPaymentsEnabled: false });
await expect(service.isCurrencyOffered("ETB")).resolves.toBe(true);
await expect(service.isCurrencyOffered("USD")).resolves.toBe(true);
});
it("leaves the manual rail alone when the currency switch flips", async () => {
const service = serviceWith({ djfEnabled: true, djfPaymentsEnabled: true });
const updated = await service.update({ djfPaymentsEnabled: false }, "user-1");
expect(updated.djfPaymentsEnabled).toBe(false);
// Existing DJF invoices stay hand-settleable, so nothing is stranded.
expect(updated.djfEnabled).toBe(true);
await expect(service.isEnabled("DJF")).resolves.toBe(true);
});
});

View File

@@ -20,4 +20,12 @@ export class UpdateManualPaymentSettingDto {
@IsOptional()
@IsBoolean()
djfEnabled?: boolean;
@ApiPropertyOptional({
description:
"Accept DJF as a payment currency at all — booking forms and online payment",
})
@IsOptional()
@IsBoolean()
djfPaymentsEnabled?: boolean;
}

View File

@@ -24,6 +24,17 @@ export class ManualPaymentSetting extends BaseEntity {
@Column({ name: "djf_enabled", type: "boolean", default: true })
djfEnabled!: boolean;
/**
* Whether DJF may be used as a payment currency AT ALL — offered on the
* booking/shipment forms and accepted for online payment (Waafi / CAC Bank).
*
* Wider than `djfEnabled`, which only governs the manual rail. Off leaves
* existing DJF invoices settleable by hand (while `djfEnabled` is on), so
* switching it off strands nothing — it only stops new DJF business.
*/
@Column({ name: "djf_payments_enabled", type: "boolean", default: true })
djfPaymentsEnabled!: boolean;
/** IAM user id of the last operator to change either toggle. */
@Column({ name: "updated_by_id", type: "uuid", nullable: true })
updatedById?: string | null;

View File

@@ -30,13 +30,26 @@ export class ManualPaymentSettingsController {
return this.service.get();
}
/**
* Which currencies may be picked for new bookings and paid online. Read by
* the customer portal's booking forms, so it stays open like the other
* form-shaping settings reads (file-upload / dropdown settings) — it exposes
* nothing beyond what the currency picker already shows.
*/
@Get("currencies")
@ApiOperation({ summary: "Currencies customers may be billed and pay in" })
currencies() {
return this.service.offeredCurrencies();
}
@Patch()
@BookingStaff([
FREIGHT_PERMS.settings.manualPayment.manage,
FREIGHT_PERMS.admin,
])
@ApiOperation({
summary: "Enable or disable manual invoice settlement for ETB and/or USD",
summary:
"Enable or disable manual invoice settlement per currency, and whether DJF is accepted at all",
})
update(
@Body() dto: UpdateManualPaymentSettingDto,

View File

@@ -37,7 +37,12 @@ export class ManualPaymentSettingsService {
if (existing) return existing;
return this.repository.save(
this.repository.create({ etbEnabled: false, usdEnabled: true, djfEnabled: true }),
this.repository.create({
etbEnabled: false,
usdEnabled: true,
djfEnabled: true,
djfPaymentsEnabled: true,
}),
);
}
@@ -60,9 +65,34 @@ export class ManualPaymentSettingsService {
return setting[field];
}
/**
* Currencies customers may be billed and pay in right now. ETB and USD are
* always offered; DJF only while its currency-level switch is on. Read by
* the booking forms (portal and backoffice) to decide which options to show.
*/
async offeredCurrencies(): Promise<ManualPaymentCurrency[]> {
const setting = await this.get();
return setting.djfPaymentsEnabled ? ["ETB", "USD", "DJF"] : ["ETB", "USD"];
}
/**
* Whether a currency may be used for NEW business and online payment — the
* currency-level switch, not the manual rail's {@link isEnabled}. Only DJF
* is switchable; ETB and USD have no off switch.
*/
async isCurrencyOffered(currency: string | null | undefined): Promise<boolean> {
if (currency?.toUpperCase() !== "DJF") return true;
return (await this.get()).djfPaymentsEnabled;
}
/** Flip any toggle; an omitted field leaves that currency unchanged. */
async update(
patch: { etbEnabled?: boolean; usdEnabled?: boolean; djfEnabled?: boolean },
patch: {
etbEnabled?: boolean;
usdEnabled?: boolean;
djfEnabled?: boolean;
djfPaymentsEnabled?: boolean;
},
updatedById?: string | null,
): Promise<ManualPaymentSetting> {
const current = await this.get();
@@ -70,11 +100,14 @@ export class ManualPaymentSettingsService {
...(patch.etbEnabled === undefined ? {} : { etbEnabled: patch.etbEnabled }),
...(patch.usdEnabled === undefined ? {} : { usdEnabled: patch.usdEnabled }),
...(patch.djfEnabled === undefined ? {} : { djfEnabled: patch.djfEnabled }),
...(patch.djfPaymentsEnabled === undefined
? {}
: { djfPaymentsEnabled: patch.djfPaymentsEnabled }),
updatedById: updatedById ?? null,
});
const updated = await this.get();
this.logger.warn(
`Manual payment channels set to ETB=${updated.etbEnabled} USD=${updated.usdEnabled} DJF=${updated.djfEnabled} by ${updatedById ?? "unknown user"}`,
`Manual payment channels set to ETB=${updated.etbEnabled} USD=${updated.usdEnabled} DJF=${updated.djfEnabled} (DJF accepted as a currency: ${updated.djfPaymentsEnabled}) by ${updatedById ?? "unknown user"}`,
);
return updated;
}

View File

@@ -431,6 +431,12 @@ interface BookingWindowRow {
route_stations: string[] | null;
}
/**
* Dispatch crew gate (ITLMS Rolling Stock §1.2). Temporarily disabled so a
* train can depart with no crew assigned; set back to true to enforce.
*/
const ENFORCE_CREW_GATE_ON_DISPATCH = false;
@Injectable()
export class TrainSchedulingService {
private readonly logger = new Logger(TrainSchedulingService.name);
@@ -3198,7 +3204,11 @@ export class TrainSchedulingService {
// Stock §1.2 enforces composition "prior to departure", so an incomplete
// crew saves freely on the assignment page but cannot depart. Optional
// dependency: the positional spec constructors omit it.
await this.trainCrewAssignments?.assertCrewReadyForDispatch(scheduleId);
// TODO(crew-gate): temporarily off so trains can dispatch with no crew
// assigned. Flip ENFORCE_CREW_GATE_ON_DISPATCH once crew rostering is in use.
if (ENFORCE_CREW_GATE_ON_DISPATCH) {
await this.trainCrewAssignments?.assertCrewReadyForDispatch(scheduleId);
}
// Staff may record the departure after the fact — past is fine, future is not.
const now = dto.actualDepartureAt ? new Date(dto.actualDepartureAt) : new Date();
this.assertNotFuture(now, 'Departure time');

View File

@@ -176,6 +176,49 @@ export class TruckEntranceDto {
warehouseManagerName?: string;
}
/**
* One physical truck at the gate, with the containers it is carrying.
*
* A customer whose containers arrive together sends several trucks, and each
* carries its own load: the plate, driver and boxes belong to that truck, not
* to the receive operation as a whole. Each truck is validated and given its
* own GRN batch exactly as a single-truck receive always was.
*/
export class ReceiveTruckDto {
/**
* The physical containers delivered by this truck: either one 40ft box or up
* to two 20ft boxes, the same physical limit a single-truck receive enforces.
*/
@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[];
@ApiProperty({ type: TruckEntranceDto })
@ValidateNested()
@Type(() => TruckEntranceDto)
truckEntrance!: TruckEntranceDto;
/**
* The bookings this truck delivers against. Defaults to the operation's
* `bookingIds` when omitted; container freight still requires exactly one,
* so its containers and documents stay separate per truck.
*/
@ApiPropertyOptional({ type: [String], format: 'uuid' })
@IsOptional()
@IsArray()
@ArrayNotEmpty()
@IsUUID('all', { each: true })
bookingIds?: string[];
}
/** Bulk-receive eligible PAID bookings into a warehouse location, by trade direction. */
export class BulkReceiveDto {
@ApiProperty({ enum: ['IMPORT', 'EXPORT'] })
@@ -200,9 +243,24 @@ export class BulkReceiveDto {
@IsUUID('all', { each: true })
bookingIds!: string[];
/**
* Several trucks arriving together, each with its own plate, driver and
* containers. When present this supersedes the single-truck
* `containerNumbers` / `truckEntrance` pair below, which is kept so existing
* callers (and single-truck arrivals) keep working unchanged.
*/
@ApiPropertyOptional({ type: [ReceiveTruckDto] })
@IsOptional()
@IsArray()
@ArrayNotEmpty()
@ValidateNested({ each: true })
@Type(() => ReceiveTruckDto)
trucks?: ReceiveTruckDto[];
/**
* 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.
* Ignored when `trucks` is given.
*/
@ApiPropertyOptional({ type: [String] })
@IsOptional()

View File

@@ -0,0 +1,72 @@
import { WarehouseInventoryService } from './warehouse-inventory.service';
/**
* A booking's cargo spans one inventory row per container, and a multi-truck
* arrival files a GRN batch per truck. Inspection is a judgement on the cargo,
* not on the row it happens to sit in, so ticking one row must pass every
* still-inspectable row of the same booking — otherwise a six-container
* booking stays half-inspected and never reaches Ready To Load.
*
* Only the DataSource is touched, so the instance is built off the prototype
* rather than stubbing all 20-odd collaborators.
*/
type Expand = (inventoryIds: string[], eligibleStatuses: string[]) => Promise<string[]>;
const ELIGIBLE = ['UNLOADED', 'RECEIVED', 'STORED', 'RESERVED'];
function makeExpand(rows: Array<{ id: string }>) {
const query = jest.fn().mockResolvedValue(rows);
const service = Object.create(WarehouseInventoryService.prototype) as Record<string, unknown>;
service.dataSource = { query };
const expand = (
service as unknown as { expandInspectionToBooking: Expand }
).expandInspectionToBooking.bind(service);
return { expand, query };
}
describe('bulkMarkInspected — booking cascade', () => {
it('pulls in the booking siblings of a selected row', async () => {
const { expand } = makeExpand([{ id: 'inv-1' }, { id: 'inv-2' }, { id: 'inv-3' }]);
await expect(expand(['inv-1'], ELIGIBLE)).resolves.toEqual([
'inv-1',
'inv-2',
'inv-3',
]);
});
it('leads with the rows the operator actually ticked', async () => {
// The response the operator reads should open with their own selection,
// whatever order the database returned the siblings in.
const { expand } = makeExpand([{ id: 'inv-3' }, { id: 'inv-2' }, { id: 'inv-1' }]);
const result = await expand(['inv-1'], ELIGIBLE);
expect(result[0]).toBe('inv-1');
expect(result.slice(1).sort()).toEqual(['inv-2', 'inv-3']);
});
it('never repeats a row when two siblings are both selected', async () => {
const { expand } = makeExpand([{ id: 'inv-1' }, { id: 'inv-2' }]);
const result = await expand(['inv-1', 'inv-2'], ELIGIBLE);
expect(result).toEqual(['inv-1', 'inv-2']);
expect(new Set(result).size).toBe(result.length);
});
it('passes the eligible statuses to the query rather than hard-coding them', async () => {
const { expand, query } = makeExpand([{ id: 'inv-1' }]);
await expand(['inv-1'], ELIGIBLE);
expect(query).toHaveBeenCalledWith(expect.any(String), [['inv-1'], ELIGIBLE]);
});
it('does not query at all for an empty selection', async () => {
const { expand, query } = makeExpand([]);
await expect(expand([], ELIGIBLE)).resolves.toEqual([]);
expect(query).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,146 @@
import { BadRequestException } from '@nestjs/common';
import { WarehouseInspectionService } from './warehouse-inspection.service';
import { WarehouseInventoryService } from './warehouse-inventory.service';
/**
* Cargo whose inspection failed or is under review must not travel. It becomes
* loadable only by being re-inspected and passed, and that reversal has to say
* why — the cargo was deliberately held, so its release is deliberate too.
*
* Only the collaborators each rule touches are stubbed; the instances are built
* off the prototype rather than wiring all 20-odd dependencies.
*/
describe('load() — inspection gate', () => {
const loadWithInspection = (inspectionStatus: string | null) => {
const service = Object.create(WarehouseInventoryService.prototype) as Record<string, unknown>;
service.findById = jest.fn().mockResolvedValue({
id: 'inv-1',
status: 'READY_FOR_LOADING',
inspectionStatus,
warehouseId: 'w-1',
yardId: 'y-1',
zoneId: 'z-1',
});
service.assertTransition = jest.fn();
// Reached only if the gate lets the item through — failing loudly here
// proves the gate did NOT stop it.
service.scheduling = {
findWagon: jest.fn().mockRejectedValue(new Error('gate did not block')),
};
return (
service as unknown as { load: (id: string, dto: unknown) => Promise<unknown> }
).load.bind(service);
};
it.each(['FAILED', 'NEEDS_REVIEW'])('refuses to load %s cargo', async (status) => {
await expect(loadWithInspection(status)('inv-1', { wagonId: 'w' })).rejects.toBeInstanceOf(
BadRequestException,
);
});
it('refuses to load cargo that was never inspected', async () => {
await expect(loadWithInspection(null)('inv-1', { wagonId: 'w' })).rejects.toBeInstanceOf(
BadRequestException,
);
});
it('names the outcome so the operator knows what to fix', async () => {
await expect(loadWithInspection('FAILED')('inv-1', { wagonId: 'w' })).rejects.toThrow(
/FAILED/,
);
});
it('lets passed cargo through the gate', async () => {
// It fails later, at the wagon lookup — which is the proof it got past the
// inspection gate rather than being stopped by it.
await expect(loadWithInspection('PASSED')('inv-1', { wagonId: 'w' })).rejects.toThrow(
'gate did not block',
);
});
});
describe('inspection report — reversing a held inspection', () => {
const createReport = (previousStatus: string, itemStatus = 'RECEIVED') => {
const update = jest.fn().mockResolvedValue(undefined);
const service = Object.create(WarehouseInspectionService.prototype) as Record<string, unknown>;
service.dataSource = {
getRepository: () => ({
findOne: jest.fn().mockResolvedValue({
id: 'inv-1',
bookingId: 'b-1',
inspectionStatus: previousStatus,
status: itemStatus,
}),
update,
}),
};
service.inspectionRepository = {
findAll: jest.fn().mockResolvedValue([]),
create: jest.fn().mockResolvedValue({ id: 'rep-1' }),
};
service.markImportPickupReadyAndAcceptLastMile = jest.fn().mockResolvedValue(undefined);
const create = (
service as unknown as {
create: (id: string, dto: unknown) => Promise<unknown>;
}
).create.bind(service);
return { create, update };
};
it.each(['FAILED', 'NEEDS_REVIEW'])(
'rejects passing %s cargo with no reason given',
async (previous) => {
const { create } = createReport(previous);
await expect(
create('inv-1', { reportType: 'INSPECTION', inspectionStatus: 'PASSED' }),
).rejects.toBeInstanceOf(BadRequestException);
},
);
it('rejects whitespace as a reason', async () => {
const { create } = createReport('FAILED');
await expect(
create('inv-1', { reportType: 'INSPECTION', inspectionStatus: 'PASSED', remarks: ' ' }),
).rejects.toBeInstanceOf(BadRequestException);
});
it('accepts the reversal once a reason is recorded', async () => {
const { create } = createReport('FAILED');
await expect(
create('inv-1', {
reportType: 'INSPECTION',
inspectionStatus: 'PASSED',
remarks: 'Reworked packaging, re-weighed and verified.',
}),
).resolves.toBeDefined();
});
it('needs no reason for a first-time pass', async () => {
const { create } = createReport(null as unknown as string);
await expect(
create('inv-1', { reportType: 'INSPECTION', inspectionStatus: 'PASSED' }),
).resolves.toBeDefined();
});
it('pulls failed cargo back out of the ready-to-load queue', async () => {
const { create, update } = createReport('PASSED', 'READY_FOR_LOADING');
await create('inv-1', { reportType: 'INSPECTION', inspectionStatus: 'FAILED' });
expect(update).toHaveBeenCalledWith('inv-1', { status: 'RECEIVED' });
});
it('leaves cargo that never reached the ready queue where it is', async () => {
const { create, update } = createReport('PASSED', 'STORED');
await create('inv-1', { reportType: 'INSPECTION', inspectionStatus: 'FAILED' });
expect(update).not.toHaveBeenCalledWith('inv-1', { status: 'RECEIVED' });
});
});

View File

@@ -1,4 +1,4 @@
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { NotificationAudience, NotificationType } from '@edr/types';
@@ -37,6 +37,20 @@ export class WarehouseInspectionService {
throw new NotFoundException(`Inventory item ${inventoryId} not found`);
}
// Overturning a held inspection is a deliberate act: the cargo was kept off
// the train, and the record has to say why it may now travel. A bare PASS
// with no remarks leaves the release unexplained.
const previousStatus = inventory.inspectionStatus;
if (
dto.inspectionStatus === 'PASSED' &&
(previousStatus === 'FAILED' || previousStatus === 'NEEDS_REVIEW') &&
!dto.remarks?.trim()
) {
throw new BadRequestException(
`Give a reason in Remarks for passing cargo whose inspection is ${previousStatus}`,
);
}
const expected = dto.expectedWeight ?? null;
const actual = dto.actualWeight ?? null;
const weightLoss = expected !== null && actual !== null ? Math.max(0, expected - actual) : null;
@@ -83,6 +97,15 @@ export class WarehouseInspectionService {
if (dto.inspectionStatus === 'PASSED') {
await this.markImportPickupReadyAndAcceptLastMile(inventoryId);
} else if (
inventory.status === 'READY_FOR_LOADING' ||
inventory.status === 'READY_FOR_PICKUP'
) {
// A failed or under-review re-inspection pulls the cargo back out of the
// ready queue. load() refuses it either way, but leaving it READY_FOR_*
// would keep it sitting on the loading and pickup lists as if nothing
// had happened.
await inventoryRepo.update(inventoryId, { status: 'RECEIVED' });
}
return report;

View File

@@ -1441,8 +1441,10 @@ export class WarehouseInventoryService {
-- true regardless of the customer's actual self-haul/EDR-haul choice.
-- The address is the only per-booking record of that choice.
(NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NOT NULL) AS "lastMileRequested",
oy.code AS "origin",
dy.code AS "destination",
-- Operators know a yard by its name: KALITY is universally called
-- GMP / Gelan Multipurpose Port. Code is only a fallback.
COALESCE(oy.label, oy.code) AS "origin",
COALESCE(dy.label, dy.code) AS "destination",
oy.country AS "originCountry",
dy.country AS "destinationCountry",
b.freight_type AS "freightType",
@@ -1580,335 +1582,412 @@ export class WarehouseInventoryService {
bookingId: string;
}> = [];
// A customer's containers often arrive on several trucks at once. Each
// truck carries its own load, so the operation is a list of trucks; the
// legacy single-truck fields collapse to a one-element list so existing
// callers behave exactly as before.
const trucks: Array<{
truckEntrance?: BulkReceiveDto['truckEntrance'];
containerNumbers?: string[];
bookingIds: string[];
}> = dto.trucks?.length
? dto.trucks.map((truck) => ({
truckEntrance: truck.truckEntrance,
containerNumbers: truck.containerNumbers,
bookingIds: truck.bookingIds?.length ? truck.bookingIds : dto.bookingIds,
}))
: [
{
truckEntrance: dto.truckEntrance,
containerNumbers: dto.containerNumbers,
bookingIds: dto.bookingIds,
},
];
// Two trucks cannot deliver the same box. The per-booking check below only
// catches this once a unit is marked received, which would let a duplicate
// through on the truck that happens to be processed first.
const seenContainers = new Set<string>();
for (const truck of trucks) {
for (const raw of truck.containerNumbers ?? []) {
const number = raw.trim().toUpperCase();
if (seenContainers.has(number)) {
throw new BadRequestException(
`Container ${number} is listed on more than one truck`,
);
}
seenContainers.add(number);
}
}
await this.dataSource.transaction(async (manager) => {
const { warehouse, yard, zone } = await this.validateLocation(manager, {
warehouseId: dto.warehouseId,
yardId: dto.yardId,
zoneId: dto.zoneId,
});
// The receive location is whatever the operator selected above — never a
// hand-typed string. Stamp it on the truck entrance for the GRN/notes.
if (dto.truckEntrance && !dto.truckEntrance.warehouseCodeLocation) {
dto.truckEntrance.warehouseCodeLocation = [warehouse.code, yard.code, zone.code]
.filter(Boolean)
.join(' / ');
}
for (const bookingId of dto.bookingIds) {
const skip = (reason: string) => {
result.skippedCount += 1;
result.results.push({ bookingId, status: 'SKIPPED', reason });
};
const [booking] = await manager.query(
`SELECT b.reference AS "reference",
b.payment_status AS "paymentStatus",
b.freight_type AS "freightType",
b.cargo_total_weight_vgm AS "weight",
company.name AS "customer",
company.tin AS "customerTin",
${companyNotifyPhoneExpr('company')} AS "customerPhone",
bc.container_numbers AS "containerNumber",
bc.container_quantity AS "containerQuantity",
bc.container_packaging_type AS "containerPackagingType",
COALESCE(ct.cargo_type_name, b.cargo_free_text) AS "cargoDescription",
oy.country AS "originCountry", dy.country AS "destinationCountry",
-- No service_types OR here either — see eligibleBookings above.
(NULLIF(TRIM(COALESCE(b.first_mile_pickup_address, '')), '') IS NOT NULL) AS "hasFirstMile",
fm.id AS "firstMileRequestId",
fm.status AS "firstMileStatus",
v.plate_number AS "firstMileTruckPlateNumber",
v.trailer_plate_no AS "firstMileTrailerPlateNumber",
COALESCE(
NULLIF(TRIM(CONCAT(COALESCE(driver.first_name, ''), ' ', COALESCE(driver.last_name, ''))), ''),
v.assigned_driver_name
) AS "firstMileDriverName",
driver.phone_number AS "firstMileDriverPhone",
driver.license_number AS "firstMileDriverLicenseNumber",
v.vehicle_type AS "firstMileTruckType",
COALESCE((SELECT string_agg(DISTINCT cta.plate_number, ', ')
FROM freight.customer_truck_assignments cta
WHERE cta.booking_id = b.id AND cta.deleted_at IS NULL),
b.customer_truck_plate_number) AS "customerTruckPlateNumber",
COALESCE((SELECT string_agg(DISTINCT cta.driver_name, ', ')
FROM freight.customer_truck_assignments cta
WHERE cta.booking_id = b.id AND cta.deleted_at IS NULL),
b.customer_truck_driver_name) AS "customerTruckDriverName",
b.customer_truck_type AS "customerTruckType",
b.customer_truck_container_number AS "customerTruckContainerNumber",
b.customer_truck_assigned_at AS "customerTruckAssignedAt",
b.company_id AS "companyId",
(NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NOT NULL) AS "hasLastMile"
FROM freight.bookings b
LEFT JOIN freight.companies company ON company.id = b.company_id
${primaryContactUserJoin('company')}
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 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,
CASE
WHEN COUNT(booking_container.id) = 0 THEN NULL
WHEN bool_or(container_type.is_reefer) THEN 'REEFER_CONTAINER'
WHEN bool_or(container_type.is_open_top) THEN 'OPEN_TOP_CONTAINER'
WHEN MIN(container_type.size_ft) = 20 THEN 'CONTAINER_20FT'
WHEN MIN(container_type.size_ft) = 40 THEN 'CONTAINER_40FT'
WHEN MIN(container_type.size_ft) = 45 THEN 'CONTAINER_45FT'
ELSE 'OTHER_CONTAINER'
END AS container_packaging_type
FROM freight.booking_container booking_container
LEFT JOIN freight.container_types container_type ON container_type.id = booking_container.container_type_id
WHERE booking_container.booking_id = b.id AND booking_container.deleted_at IS NULL
) bc ON true
LEFT JOIN LATERAL (
SELECT first_mile.id, first_mile.status, first_mile.vehicle_id
FROM freight.first_mile first_mile
WHERE first_mile.booking_id = b.id AND first_mile.deleted_at IS NULL
ORDER BY first_mile.created_at DESC
LIMIT 1
) fm ON true
LEFT JOIN freight.vehicles v ON v.id = fm.vehicle_id
LEFT JOIN freight.drivers driver ON driver.id = v.assigned_driver_id
WHERE b.id = $1 AND b.deleted_at IS NULL LIMIT 1`,
[bookingId],
);
if (!booking) { skip('Booking not found'); continue; }
if (booking.paymentStatus !== 'PAID') { skip('Booking not PAID'); continue; }
// Direction is derived from the route (yard countries), not the stored field.
const bookingDirection = deriveTradeDirection(
{ country: booking.originCountry },
{ country: booking.destinationCountry },
);
if (bookingDirection !== dto.direction) {
skip(`Booking route is ${bookingDirection}, not ${dto.direction}`);
continue;
}
if (dto.direction === 'EXPORT' && booking.hasFirstMile) {
if (!booking.firstMileRequestId) {
skip('First-mile request not created');
continue;
}
if (booking.firstMileStatus !== 'RECEIVED_TO_PORT') {
skip('First-mile truck has not arrived');
continue;
}
// Each arriving truck is its own unit of work: its own plate and driver,
// its own containers, its own physical-load check and its own GRN batch.
// A single-truck arrival is just the one-element case, so the per-truck
// body below is unchanged from when this only ever handled one truck.
for (const truck of trucks) {
const truckEntranceInput = truck.truckEntrance;
const truckContainerNumbers = truck.containerNumbers;
const truckBookingIds = truck.bookingIds;
// The receive location is whatever the operator selected above — never a
// hand-typed string. Stamp it on the truck entrance for the GRN/notes.
if (truckEntranceInput && !truckEntranceInput.warehouseCodeLocation) {
truckEntranceInput.warehouseCodeLocation = [warehouse.code, yard.code, zone.code]
.filter(Boolean)
.join(' / ');
}
const containerQuantity = Number(booking.containerQuantity ?? 0);
if (booking.freightType === 'CONTAINER' && containerQuantity <= 0) {
skip('Container booking has no container quantity');
continue;
}
for (const bookingId of truckBookingIds) {
const skip = (reason: string) => {
result.skippedCount += 1;
result.results.push({ bookingId, status: 'SKIPPED', reason });
};
const now = new Date();
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`,
const [booking] = await manager.query(
`SELECT b.reference AS "reference",
b.payment_status AS "paymentStatus",
b.freight_type AS "freightType",
b.cargo_total_weight_vgm AS "weight",
company.name AS "customer",
company.tin AS "customerTin",
${companyNotifyPhoneExpr('company')} AS "customerPhone",
bc.container_numbers AS "containerNumber",
bc.container_quantity AS "containerQuantity",
bc.container_packaging_type AS "containerPackagingType",
COALESCE(ct.cargo_type_name, b.cargo_free_text) AS "cargoDescription",
oy.country AS "originCountry", dy.country AS "destinationCountry",
-- No service_types OR here either — see eligibleBookings above.
(NULLIF(TRIM(COALESCE(b.first_mile_pickup_address, '')), '') IS NOT NULL) AS "hasFirstMile",
fm.id AS "firstMileRequestId",
fm.status AS "firstMileStatus",
v.plate_number AS "firstMileTruckPlateNumber",
v.trailer_plate_no AS "firstMileTrailerPlateNumber",
COALESCE(
NULLIF(TRIM(CONCAT(COALESCE(driver.first_name, ''), ' ', COALESCE(driver.last_name, ''))), ''),
v.assigned_driver_name
) AS "firstMileDriverName",
driver.phone_number AS "firstMileDriverPhone",
driver.license_number AS "firstMileDriverLicenseNumber",
v.vehicle_type AS "firstMileTruckType",
COALESCE((SELECT string_agg(DISTINCT cta.plate_number, ', ')
FROM freight.customer_truck_assignments cta
WHERE cta.booking_id = b.id AND cta.deleted_at IS NULL),
b.customer_truck_plate_number) AS "customerTruckPlateNumber",
COALESCE((SELECT string_agg(DISTINCT cta.driver_name, ', ')
FROM freight.customer_truck_assignments cta
WHERE cta.booking_id = b.id AND cta.deleted_at IS NULL),
b.customer_truck_driver_name) AS "customerTruckDriverName",
b.customer_truck_type AS "customerTruckType",
b.customer_truck_container_number AS "customerTruckContainerNumber",
b.customer_truck_assigned_at AS "customerTruckAssignedAt",
b.company_id AS "companyId",
(NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NOT NULL) AS "hasLastMile"
FROM freight.bookings b
LEFT JOIN freight.companies company ON company.id = b.company_id
${primaryContactUserJoin('company')}
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 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,
CASE
WHEN COUNT(booking_container.id) = 0 THEN NULL
WHEN bool_or(container_type.is_reefer) THEN 'REEFER_CONTAINER'
WHEN bool_or(container_type.is_open_top) THEN 'OPEN_TOP_CONTAINER'
WHEN MIN(container_type.size_ft) = 20 THEN 'CONTAINER_20FT'
WHEN MIN(container_type.size_ft) = 40 THEN 'CONTAINER_40FT'
WHEN MIN(container_type.size_ft) = 45 THEN 'CONTAINER_45FT'
ELSE 'OTHER_CONTAINER'
END AS container_packaging_type
FROM freight.booking_container booking_container
LEFT JOIN freight.container_types container_type ON container_type.id = booking_container.container_type_id
WHERE booking_container.booking_id = b.id AND booking_container.deleted_at IS NULL
) bc ON true
LEFT JOIN LATERAL (
SELECT first_mile.id, first_mile.status, first_mile.vehicle_id
FROM freight.first_mile first_mile
WHERE first_mile.booking_id = b.id AND first_mile.deleted_at IS NULL
ORDER BY first_mile.created_at DESC
LIMIT 1
) fm ON true
LEFT JOIN freight.vehicles v ON v.id = fm.vehicle_id
LEFT JOIN freight.drivers driver ON driver.id = v.assigned_driver_id
WHERE b.id = $1 AND b.deleted_at IS NULL LIMIT 1`,
[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 (!booking) { skip('Booking not found'); continue; }
if (booking.paymentStatus !== 'PAID') { skip('Booking not PAID'); continue; }
// Direction is derived from the route (yard countries), not the stored field.
const bookingDirection = deriveTradeDirection(
{ country: booking.originCountry },
{ country: booking.destinationCountry },
);
if (bookingDirection !== dto.direction) {
skip(`Booking route is ${bookingDirection}, not ${dto.direction}`);
continue;
}
// 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}`,
);
if (dto.direction === 'EXPORT' && booking.hasFirstMile) {
if (!booking.firstMileRequestId) {
skip('First-mile request not created');
continue;
}
if (booking.firstMileStatus !== 'RECEIVED_TO_PORT') {
skip('First-mile truck has not arrived');
continue;
}
}
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');
const containerQuantity = Number(booking.containerQuantity ?? 0);
if (booking.freightType === 'CONTAINER' && containerQuantity <= 0) {
skip('Container booking has no container quantity');
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:
booking.freightType === 'CONTAINER'
? `${selectedUnits.length} container(s) arrived: ${selectedUnits
.map((unit) => unit.containerNumber)
.join(', ')}. ${remainingAfter} container(s) left.`
: `Bulk received (${dto.direction})`,
truckEntrance,
});
const now = new Date();
const truckEntrance = truckEntranceInput
? this.mergeSystemTruckEntrance(truckEntranceInput, 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 && truckEntranceInput) {
truckEntrance.truckPlateNumber = truckEntranceInput.truckPlateNumber;
truckEntrance.driverName = truckEntranceInput.driverName;
truckEntrance.driverPhone = truckEntranceInput.driverPhone;
truckEntrance.truckType = truckEntranceInput.truckType;
}
if (dto.direction === 'EXPORT') {
this.assertTruckEntrance(truckEntrance);
}
// Validate capacity before saving
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);
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);
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 (booking.freightType === 'CONTAINER') {
if (truckBookingIds.length !== 1) {
throw new BadRequestException(
'Receive one container booking per arriving truck so its containers and documents stay separate',
);
}
const selectedNumbers = (truckContainerNumbers ?? []).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 ?? ''),
});
if (!container && !unit.containerTypeId) {
throw new BadRequestException(
`Container ${unit.containerNumber} has no container type and cannot be received`,
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,
);
}
if (!container) {
container = await containers.save(
containers.create({
containerNumber: unit.containerNumber,
containerTypeId: unit.containerTypeId as string,
bookingContainerId: unit.bookingContainerId,
} 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:
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 =
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 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,
tareWeight: 0,
maxGrossWeight: Number(unit.weightTons || 0),
status: 'LOADED',
wagonId: null,
position: null,
wagonBookingAllocationId: null,
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,
}),
);
} else {
await containers.update(container.id, {
bookingId,
bookingContainerId: unit.bookingContainerId,
sealNumber: unit.sealNumber,
status: 'LOADED',
deletedAt: null,
});
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,
containerId: container.id,
quantity: 1,
weight: Number(unit.weightTons || 0),
weight,
grnNumber,
status: 'RECEIVED',
arrivedAt: now,
@@ -1917,90 +1996,60 @@ export class WarehouseInventoryService {
);
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({
// Update warehouse/yard/zone capacity counters
await this.applyCapacityDelta(manager, dto, weight, 0, containerCount);
// Export self-haul: this receive IS the truck's arrival — see
// markCustomerTruckArrived / receive()'s single-booking mirror.
if (dto.direction === 'EXPORT') {
await this.markCustomerTruckArrived(manager, bookingId, truckEntrance?.truckPlateNumber);
}
await this.activityLog.record(
{
activityType: 'INVENTORY_RECEIVED',
inventoryId: inventoryIds[0],
warehouseId: dto.warehouseId,
yardId: dto.yardId,
zoneId: dto.zoneId,
bookingId,
quantity: 1,
weight,
grnNumber,
status: 'RECEIVED',
arrivedAt: now,
notes: receiveNote,
}),
description: truckEntrance?.truckPlateNumber
? `GRN ${grnNumber}: bulk received ${dto.direction} booking via truck ${truckEntrance.truckPlateNumber}`
: `GRN ${grnNumber}: bulk received ${dto.direction} booking`,
performedBy: dto.performedBy,
},
manager,
);
inventoryIds.push(saved.id);
}
// Update warehouse/yard/zone capacity counters
await this.applyCapacityDelta(manager, dto, weight, 0, containerCount);
// Export self-haul: this receive IS the truck's arrival — see
// markCustomerTruckArrived / receive()'s single-booking mirror.
if (dto.direction === 'EXPORT') {
await this.markCustomerTruckArrived(manager, bookingId, truckEntrance?.truckPlateNumber);
}
await this.activityLog.record(
{
activityType: 'INVENTORY_RECEIVED',
inventoryId: inventoryIds[0],
warehouseId: dto.warehouseId,
description: truckEntrance?.truckPlateNumber
? `GRN ${grnNumber}: bulk received ${dto.direction} booking via truck ${truckEntrance.truckPlateNumber}`
: `GRN ${grnNumber}: bulk received ${dto.direction} booking`,
performedBy: dto.performedBy,
},
manager,
);
// Queued, not sent here: an SMS/email round-trip inside the transaction
// holds capacity/location locks open for the whole gateway latency.
pendingNotifications.push({
owner: {
phone: truckEntrance?.customerPhone ?? booking.customerPhone,
ownerName: truckEntrance?.ownerName ?? booking.customer,
bookingReference: truckEntrance?.edrDigitalBookingId ?? booking.reference,
grnNumber,
direction: dto.direction,
warehouseId: dto.warehouseId,
// Queued, not sent here: an SMS/email round-trip inside the transaction
// holds capacity/location locks open for the whole gateway latency.
pendingNotifications.push({
owner: {
phone: truckEntrance?.customerPhone ?? booking.customerPhone,
ownerName: truckEntrance?.ownerName ?? booking.customer,
bookingReference: truckEntrance?.edrDigitalBookingId ?? booking.reference,
grnNumber,
direction: dto.direction,
warehouseId: dto.warehouseId,
bookingId,
},
booking,
bookingId,
},
booking,
bookingId,
});
});
result.receivedCount += 1;
result.results.push({
bookingId,
status: 'RECEIVED',
inventoryId: inventoryIds[0],
inventoryIds,
grnNumber,
...(booking.freightType === 'CONTAINER'
? {
receivedContainers: receivedAfter,
remainingContainers: remainingAfter,
}
: {}),
});
result.receivedCount += 1;
result.results.push({
bookingId,
status: 'RECEIVED',
inventoryId: inventoryIds[0],
inventoryIds,
grnNumber,
...(booking.freightType === 'CONTAINER'
? {
receivedContainers: receivedAfter,
remainingContainers: remainingAfter,
}
: {}),
});
}
}
});
@@ -2032,8 +2081,10 @@ export class WarehouseInventoryService {
COALESCE(cgt.cargo_type_name, b.cargo_free_text) AS "cargoType",
inv.weight AS "weight",
COALESCE(inv.grn_number, substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)')) AS "grnNumber",
oy.code AS "origin",
dy.code AS "destination",
-- Operators know a yard by its name: KALITY is universally called
-- GMP / Gelan Multipurpose Port. Code is only a fallback.
COALESCE(oy.label, oy.code) AS "origin",
COALESCE(dy.label, dy.code) AS "destination",
oy.country AS "originCountry",
dy.country AS "destinationCountry",
inv.inspection_status AS "inspectionStatus",
@@ -3075,7 +3126,15 @@ export class WarehouseInventoryService {
// UNLOADED added for Batch 9 import destination inspection (arrived-train unload landing state).
const eligible = ['UNLOADED', 'RECEIVED', 'STORED', 'RESERVED'];
for (const inventoryId of dto.inventoryIds) {
// Inspection is a judgement on the booking's cargo, not on the row it
// happens to sit in. A booking's cargo spans one inventory row per
// container, and a multi-truck arrival adds a GRN batch per truck — so
// ticking one row passes every eligible row of the same booking and the
// whole booking advances together. Without this a six-container booking
// stayed half-inspected and never reached Ready To Load.
const inventoryIds = await this.expandInspectionToBooking(dto.inventoryIds, eligible);
for (const inventoryId of inventoryIds) {
const skip = (reason: string) => {
result.skippedCount += 1;
result.results.push({ inventoryId, status: 'SKIPPED', reason });
@@ -3084,6 +3143,13 @@ export class WarehouseInventoryService {
const item = await this.inventoryRepository.findById(inventoryId);
if (!item) { skip('Inventory not found'); continue; }
if (item.inspectionStatus === 'PASSED') { skip('Already inspected'); continue; }
// Overturning a failure is a deliberate, reasoned act — never a side
// effect of ticking a row in a list. Those items are held back for an
// individual re-inspection that records why the cargo may now travel.
if (item.inspectionStatus === 'FAILED' || item.inspectionStatus === 'NEEDS_REVIEW') {
skip(`Inspection ${item.inspectionStatus} — re-inspect this item individually and give a reason`);
continue;
}
if (!eligible.includes(item.status)) { skip(`Status ${item.status} not eligible for inspection`); continue; }
// Reuse the existing inspection service: creates a minimal PASSED report + sets inspectionStatus/inspectedAt.
@@ -3144,6 +3210,41 @@ export class WarehouseInventoryService {
return result;
}
/**
* Widen a set of selected inventory rows to every still-inspectable row of
* the same booking.
*
* The originally selected ids are always kept, even when ineligible, so the
* caller still reports their skip reason rather than dropping them silently.
* Rows with no booking (ad-hoc inventory) expand to themselves.
*/
private async expandInspectionToBooking(
inventoryIds: string[],
eligibleStatuses: string[],
): Promise<string[]> {
if (inventoryIds.length === 0) return [];
const rows: Array<{ id: string }> = await this.dataSource.query(
`SELECT DISTINCT sibling.id AS id
FROM freight.warehouse_inventory selected
JOIN freight.warehouse_inventory sibling
ON sibling.booking_id = selected.booking_id
AND sibling.deleted_at IS NULL
AND sibling.inspection_status IS DISTINCT FROM 'PASSED'
AND sibling.status = ANY($2::text[])
WHERE selected.id = ANY($1::uuid[])
AND selected.deleted_at IS NULL
AND selected.booking_id IS NOT NULL
UNION
SELECT id FROM freight.warehouse_inventory
WHERE id = ANY($1::uuid[]) AND deleted_at IS NULL`,
[inventoryIds, eligibleStatuses],
);
// Selected rows first so their results lead the response the operator sees.
const expanded = rows.map((row) => row.id);
const selectedFirst = inventoryIds.filter((id) => expanded.includes(id));
return [...selectedFirst, ...expanded.filter((id) => !selectedFirst.includes(id))];
}
// ── Receive ──────────────────────────────────────────────────────────────
private async acceptLastMileIfRequested(bookingId?: string | null): Promise<void> {
@@ -5769,6 +5870,18 @@ export class WarehouseInventoryService {
// 1. inventory status must be READY_FOR_LOADING (and not already LOADED).
this.assertTransition(item.status, 'LOADED');
// 1b. Failed or under-review cargo does not travel. Status alone is not
// enough: an item that passed, reached READY_FOR_LOADING and was then
// re-inspected as FAILED keeps that status, so the inspection outcome is
// checked here — the one choke point every loading path runs through.
if (item.inspectionStatus !== 'PASSED') {
throw new BadRequestException(
item.inspectionStatus
? `Inspection is ${item.inspectionStatus} — the cargo must be re-inspected and passed, with a reason, before it can be loaded`
: 'Inventory must pass inspection before it can be loaded',
);
}
// 2. inventory is at a valid warehouse/yard/zone location.
if (!item.warehouseId || !item.yardId || !item.zoneId) {
throw new BadRequestException('Inventory must be at a warehouse/yard/zone before loading');

View File

@@ -17,9 +17,8 @@ export function BookingRouteCard({ booking }: BookingRouteCardProps) {
<Text size="xs" c="dimmed" fw={500} tt="uppercase" lts="0.04em">
Origin
</Text>
<Text fw={600}>{booking.originYard?.label}</Text>
<Text size="xs" c="dimmed">
{booking.originYard?.code}
<Text fw={600}>
{booking.originYard?.label ?? booking.originYard?.code}
</Text>
</Stack>
@@ -57,10 +56,7 @@ export function BookingRouteCard({ booking }: BookingRouteCardProps) {
Destination
</Text>
<Text fw={600} ta="right">
{booking.destinationYard?.label}
</Text>
<Text size="xs" c="dimmed">
{booking.destinationYard?.code}
{booking.destinationYard?.label ?? booking.destinationYard?.code}
</Text>
</Stack>
</Group>

View File

@@ -65,6 +65,7 @@ import {
type ConsolidationCandidate,
} from "@/services/contracts.service";
import { bookingsService } from "@/services/bookings.service";
import { usePaymentCurrenciesQuery } from "@/hooks/useManualPaymentSettings";
import {
useContractCapacity,
useContractDetail,
@@ -346,6 +347,9 @@ export default function GlCreateBookingForm() {
// IMPORT bookings pick ETB or USD — starts empty so the choice is
// deliberate (required before pricing). Everything else is forced to ETB.
const [paymentCurrency, setPaymentCurrency] = useState<"USD" | "ETB" | "DJF" | "">("");
const { data: offeredCurrencies } = usePaymentCurrenciesQuery();
// Undefined while the read is in flight — assume on, the switch is rarely off.
const djfOffered = offeredCurrencies ? offeredCurrencies.includes("DJF") : true;
// What the containers carry — captured per booking (moved off the contract).
const [cargoDescription, setCargoDescription] = useState("");
const [containerLines, setContainerLines] = useState<ContainerLineDraft[]>([]);
@@ -2395,7 +2399,9 @@ export default function GlCreateBookingForm() {
onChange={setPaymentCurrency}
disabled={!isImport || requestCurrencyLocked}
allowUsd={isImport}
allowDjf={isImport}
// A currency switched off in Configuration → Payments is not
// offered, but one the customer already chose stays visible.
allowDjf={isImport && (djfOffered || paymentCurrency === "DJF")}
error={currencyError}
/>
</Box>

View File

@@ -624,7 +624,7 @@ export const buildSidebarSections = (
permission: FREIGHT_PERMS.settings.operationsStandards.view,
},
{
label: "Manual payments",
label: "Payments",
href: "/dashboard/configuration/manual-payments",
permission: FREIGHT_PERMS.settings.manualPayment.view,
},

View File

@@ -72,6 +72,7 @@ import type {
ImportUnloadedItem,
ReadyToLoadRow,
ReceiveInventoryPayload,
ReceiveTruckPayload,
TruckEntrancePayload,
Warehouse,
WarehouseInventoryItem,
@@ -864,6 +865,12 @@ function EligibleTab({
const [packagingFreightType, setPackagingFreightType] = useState<PackagingFreightType>('MIXED');
const [selectedCustomerTruckId, setSelectedCustomerTruckId] = useState<string | null>(null);
const [selectedContainerNumbers, setSelectedContainerNumbers] = useState<string[]>([]);
/**
* Trucks already staged for this arrival. A customer's containers often come
* on several trucks at once; each is captured with its own plate, driver and
* boxes, then the whole arrival is received in one operation.
*/
const [stagedTrucks, setStagedTrucks] = useState<ReceiveTruckPayload[]>([]);
const locationReady = Boolean(location.warehouseId && location.yardId && location.zoneId);
const canReceiveBooking = (row: EligibleBooking) =>
@@ -968,10 +975,18 @@ function EligibleTab({
const assignedNumbersForSelectedTruck = new Set(
(selectedCustomerTruck?.containers ?? []).map((container) => container.containerNumber.toUpperCase()),
);
// A box already staged on an earlier truck is spoken for — offering it again
// would send the same container twice and be rejected by the API.
const stagedContainerNumbers = new Set(
stagedTrucks.flatMap((truck) =>
(truck.containerNumbers ?? []).map((number) => number.toUpperCase()),
),
);
const selectableContainerUnits = pendingContainerUnits.filter(
(unit) =>
assignedNumbersForSelectedTruck.size === 0 ||
assignedNumbersForSelectedTruck.has(unit.containerNumber.toUpperCase()),
!stagedContainerNumbers.has(unit.containerNumber.toUpperCase()) &&
(assignedNumbersForSelectedTruck.size === 0 ||
assignedNumbersForSelectedTruck.has(unit.containerNumber.toUpperCase())),
);
const selectedContainerUnits = pendingContainerUnits.filter((unit) =>
selectedContainerNumbers.includes(unit.containerNumber),
@@ -1063,17 +1078,25 @@ function EligibleTab({
bookingIds: string[],
truckEntrance?: TruckEntrancePayload,
containerNumbers?: string[],
trucks?: ReceiveTruckPayload[],
) => {
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 {
// A multi-truck arrival sends `trucks` and nothing else: the API reads the
// single-truck fields only when `trucks` is absent, so sending both would
// silently drop the staged list.
const r = await bulkReceive.mutateAsync({
direction,
...location,
bookingIds,
...(containerNumbers?.length ? { containerNumbers } : {}),
...(truckEntrance ? { truckEntrance } : {}),
...(trucks?.length
? { trucks }
: {
...(containerNumbers?.length ? { containerNumbers } : {}),
...(truckEntrance ? { truckEntrance } : {}),
}),
});
const receivedProgress = r.results.find(
(item) => item.receivedContainers != null && item.remainingContainers != null,
@@ -1081,7 +1104,11 @@ function EligibleTab({
toast({
title: `${r.receivedCount} received at ${direction === 'EXPORT' ? 'facility' : 'warehouse'}`,
description: receivedProgress
? `${containerNumbers?.length ?? 0} container(s) arrived. ${receivedProgress.remainingContainers} container(s) left. GRN ${receivedProgress.grnNumber}.`
? `${
trucks?.length
? trucks.reduce((sum, t) => sum + (t.containerNumbers?.length ?? 0), 0)
: (containerNumbers?.length ?? 0)
} container(s) arrived on ${trucks?.length ?? 1} truck(s). ${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);
@@ -1195,6 +1222,7 @@ function EligibleTab({
setPendingReceiveIds(filteredIds);
setSelectedCustomerTruckId(null);
setSelectedContainerNumbers([]);
setStagedTrucks([]);
setReceivedAt(new Date().toISOString());
setTruckForm(normalizedForm);
setLockedTruckFields({
@@ -1214,40 +1242,98 @@ function EligibleTab({
setTruckOpen(true);
};
const receive = async () => {
/**
* Validate whatever is currently in the truck form. Shared by "Add truck" and
* the final receive so a staged truck is held to exactly the same rules as a
* single-truck arrival.
*/
const truckFormError = (): string | null => {
if (!truckForm.truckPlateNumber.trim() || !truckForm.driverName.trim() || !truckForm.driverPhone.trim()) {
toast({ variant: 'destructive', title: 'Truck and driver information are required' });
return;
return 'Truck and driver information are required';
}
if (!pendingUsesFirstMile && truckForm.weighingRequired == null) {
toast({ variant: 'destructive', title: 'Select whether customer truck weighing is required' });
return;
return 'Select whether customer truck weighing is required';
}
if (truckForm.weighingRequired && (truckForm.grossWeightKg === '' || truckForm.exitTareWeightKg === '')) {
toast({ variant: 'destructive', title: 'Gross weight and exit tare weight are required when weighing is Yes' });
return;
return 'Gross weight and exit tare weight are required when weighing is Yes';
}
if (pendingContainerBooking && selectedContainerNumbers.length === 0) {
toast({ variant: 'destructive', title: 'Select the containers arriving on this truck' });
return 'Select the containers arriving on this truck';
}
return containerCapacityError;
};
/** The current form as a payload, with the container summary fields filled in. */
const currentTruckPayload = (): ReceiveTruckPayload => ({
truckEntrance: toTruckEntrancePayload({
...truckForm,
...(pendingContainerBooking
? {
assignedEquipmentNumber: selectedContainerNumbers.join(', '),
unitCount: selectedContainerNumbers.length,
netWeightKg: selectedContainerWeight,
}
: {}),
}),
...(pendingContainerBooking ? { containerNumbers: selectedContainerNumbers } : {}),
});
/** Stage the truck on screen and clear the form for the next one. */
const addTruck = () => {
const error = truckFormError();
if (error) {
toast({ variant: 'destructive', title: error });
return;
}
if (containerCapacityError) {
toast({ variant: 'destructive', title: 'Truck capacity exceeded', description: containerCapacityError });
setStagedTrucks((current) => [...current, currentTruckPayload()]);
setTruckForm(emptyTruckEntrance());
setSelectedContainerNumbers([]);
setSelectedCustomerTruckId(null);
setLockedTruckFields({});
};
const removeStagedTruck = (index: number) =>
setStagedTrucks((current) => current.filter((_, position) => position !== index));
const receive = async () => {
// With trucks staged, a part-filled form is the operator still typing the
// next truck — receiving would silently drop it, so make them finish or
// clear it. An empty form just means every truck is already staged.
const formTouched =
truckForm.truckPlateNumber.trim() !== '' ||
truckForm.driverName.trim() !== '' ||
selectedContainerNumbers.length > 0;
if (stagedTrucks.length > 0 && !formTouched) {
await receiveBookings(pendingReceiveIds, undefined, undefined, stagedTrucks);
return;
}
const error = truckFormError();
if (error) {
toast({
variant: 'destructive',
title: error,
...(stagedTrucks.length > 0
? { description: 'Finish this truck or clear it, then receive the arrival.' }
: {}),
});
return;
}
if (stagedTrucks.length > 0) {
await receiveBookings(pendingReceiveIds, undefined, undefined, [
...stagedTrucks,
currentTruckPayload(),
]);
return;
}
const single = currentTruckPayload();
await receiveBookings(
pendingReceiveIds,
toTruckEntrancePayload({
...truckForm,
...(pendingContainerBooking
? {
assignedEquipmentNumber: selectedContainerNumbers.join(', '),
unitCount: selectedContainerNumbers.length,
netWeightKg: selectedContainerWeight,
}
: {}),
}),
pendingContainerBooking ? selectedContainerNumbers : undefined,
single.truckEntrance,
single.containerNumbers,
);
};
@@ -1629,6 +1715,44 @@ function EligibleTab({
</Table.Tbody>
</Table>
</Table.ScrollContainer>
{stagedTrucks.length > 0 ? (
<Stack gap={6}>
<Text size="sm" fw={600}>
Trucks in this arrival ({stagedTrucks.length})
</Text>
{stagedTrucks.map((truck, index) => (
<Group
key={`${truck.truckEntrance.truckPlateNumber}-${index}`}
justify="space-between"
wrap="nowrap"
px="sm"
py={6}
style={{ border: '1px solid var(--mantine-color-gray-3)', borderRadius: 8 }}
>
<Stack gap={0} style={{ minWidth: 0 }}>
<Text size="sm" fw={600}>
{truck.truckEntrance.truckPlateNumber}
{truck.truckEntrance.driverName ? `${truck.truckEntrance.driverName}` : ''}
</Text>
<Text size="xs" c="dimmed">
{truck.containerNumbers?.length
? truck.containerNumbers.join(', ')
: 'Bulk arrival'}
</Text>
</Stack>
<Button
variant="subtle"
color="red"
size="compact-sm"
onClick={() => removeStagedTruck(index)}
disabled={bulkReceive.isPending}
>
Remove
</Button>
</Group>
))}
</Stack>
) : null}
<TruckEntranceFields
value={truckForm}
onChange={setTruckForm}
@@ -1636,15 +1760,29 @@ function EligibleTab({
packagingFreightType={packagingFreightType}
allowTruckWeighing={!pendingUsesFirstMile}
/>
<Group justify="flex-end">
<Button variant="default" onClick={() => setTruckOpen(false)} disabled={bulkReceive.isPending}>
Cancel
</Button>
<Button leftSection={<Truck size={16} />} loading={bulkReceive.isPending} onClick={receive}>
{pendingContainerBooking
? 'Receive Selected Containers & Generate CAS + GRN'
: 'Register Arrival & Generate GRN'}
<Group justify="space-between">
{/* Staging a truck clears the form for the next one; the arrival is
received once every truck has been entered. */}
<Button
variant="light"
leftSection={<Truck size={16} />}
onClick={addTruck}
disabled={bulkReceive.isPending || selectableContainerUnits.length === 0}
>
Add another truck
</Button>
<Group>
<Button variant="default" onClick={() => setTruckOpen(false)} disabled={bulkReceive.isPending}>
Cancel
</Button>
<Button leftSection={<Truck size={16} />} loading={bulkReceive.isPending} onClick={receive}>
{stagedTrucks.length > 0
? `Receive ${stagedTrucks.length} Truck(s) & Generate CAS + GRN`
: pendingContainerBooking
? 'Receive Selected Containers & Generate CAS + GRN'
: 'Register Arrival & Generate GRN'}
</Button>
</Group>
</Group>
</Stack>
</Modal>

View File

@@ -63,6 +63,7 @@ export const URL_CONSTANTS = {
MANUAL_PAYMENT_SETTINGS: {
BASE: "/payment-settings/manual",
CURRENCIES: "/payment-settings/manual/currencies",
},
AUDIT_LOGS: {

View File

@@ -9,6 +9,18 @@ import {
import { useErrorHandler } from "@/shared/hooks/useErrorHandler";
export const MANUAL_PAYMENT_SETTINGS_KEY = ["manualPaymentSettings"];
export const PAYMENT_CURRENCIES_KEY = ["paymentCurrencies"];
/**
* Currencies a booking may be billed in right now. Open read, so forms can
* hide a switched-off currency without needing the settings permission.
*/
export const usePaymentCurrenciesQuery = () =>
useQuery({
queryKey: PAYMENT_CURRENCIES_KEY,
queryFn: () => manualPaymentSettingsService.currencies(),
staleTime: 60_000,
});
export const useManualPaymentSettingsQuery = () =>
useQuery({
@@ -25,13 +37,18 @@ export const useUpdateManualPaymentSettings = () => {
return useMutation({
mutationFn: (
patch: Partial<
Pick<ManualPaymentSettings, "etbEnabled" | "usdEnabled" | "djfEnabled">
Pick<
ManualPaymentSettings,
"etbEnabled" | "usdEnabled" | "djfEnabled" | "djfPaymentsEnabled"
>
>,
) => manualPaymentSettingsService.update(patch),
onSuccess: (data) => {
queryClient.setQueryData(MANUAL_PAYMENT_SETTINGS_KEY, data);
// The Manual Payments worklist only lists enabled currencies.
// The Manual Payments worklist only lists enabled currencies, and the
// booking forms only offer currencies that are switched on.
queryClient.invalidateQueries({ queryKey: ["invoices"] });
queryClient.invalidateQueries({ queryKey: PAYMENT_CURRENCIES_KEY });
toast.success(
t("manualPaymentSettings.updated", "Manual payment settings updated"),
);

View File

@@ -67,8 +67,11 @@ const emptyForm = (): RouteFormState => ({
/** Order-insensitive pair key — yard distances are symmetric. */
const pairKey = (a: string, b: string) => (a < b ? `${a}|${b}` : `${b}|${a}`);
// Yards are identified to operators by name, not by their internal code: the
// yard coded KALITY is universally called GMP / Gelan Multipurpose Port, and
// showing both read as two different places. The code stays in the data.
const yardLabel = (yard?: YardRef | null) =>
yard ? `${yard.label} (${yard.code})` : "—";
yard ? (yard.label ?? yard.code) : "—";
const statusColor = (status: RouteStatus) => {
switch (status) {
@@ -232,7 +235,7 @@ export default function RoutesPage() {
() =>
(yardsQuery.data ?? []).map((yard) => ({
value: yard.id,
label: `${yard.label} (${yard.code})`,
label: yard.label ?? yard.code,
})),
[yardsQuery.data],
);

View File

@@ -291,8 +291,8 @@ export default function UsdPaymentsPanel({
const { user } = useAuth();
const canConfirm = hasPermission(user, FREIGHT_PERMS.invoices.confirmOffline);
// Manual settlement is switched on per currency in Configuration → Manual
// payments. FinanceHubPage hides the tab for a disabled currency; this is
// Manual settlement is switched on per currency in Configuration →
// Payments. FinanceHubPage hides the tab for a disabled currency; this is
// the fallback for a direct `?tab=` link, and the API refuses regardless.
const { data: manualSettings } = useManualPaymentSettingsQuery();
const currencyEnabled = manualSettings
@@ -549,7 +549,7 @@ export default function UsdPaymentsPanel({
onRowClick={(row) => navigate(`/dashboard/invoices/${row.id}`)}
emptyMessage={
!currencyEnabled
? `Manual payment is switched off for ${currency} invoices. Enable it in Configuration → Manual payments.`
? `Manual payment is switched off for ${currency} invoices. Enable it in Configuration → Payments.`
: controls.activeCount > 0
? "No invoices match these filters."
: `No ${currency} invoices awaiting manual payment confirmation.`

View File

@@ -83,7 +83,7 @@ export function YardDesksModal({
<Modal
opened={opened}
onClose={onClose}
title={yard ? `Desks at ${yard.label} (${yard.code})` : "Desks"}
title={yard ? `Desks at ${yard.label ?? yard.code}` : "Desks"}
size="lg"
>
<Stack gap="md">

View File

@@ -8,7 +8,7 @@ import {
import { Badge } from "@/shared/common/ui/badge";
import { Switch } from "@/shared/common/ui/switch";
import { Skeleton } from "@/shared/common/ui/skeleton";
import { AlertTriangle, Banknote, Landmark } from "lucide-react";
import { AlertTriangle, Banknote, Coins, Landmark } from "lucide-react";
import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
@@ -53,7 +53,8 @@ const CURRENCIES: {
];
/**
* Switches the manual (offline) payment channel on or off per currency.
* Two levels of switch: whether DJF is accepted as a payment currency at all,
* and whether the manual (offline) channel is open, per currency.
*
* Off means gone, not greyed out: the Manual Payments worklist lists only
* enabled currencies, and the API refuses a confirmation in a disabled one —
@@ -75,11 +76,12 @@ export default function ManualPaymentSettingsCard() {
return (
<Card className="shadow-lg border-gray-200 dark:border-gray-700">
<CardHeader>
<CardTitle>Manual payments</CardTitle>
<CardTitle>Payments</CardTitle>
<CardDescription>
Whether Finance staff may mark invoices as paid by hand, from
Invoices Manual Payments. Each currency is switched separately.
Confirming still requires the payment slip and the booking&apos;s pay
Which currencies customers may be billed in, and whether Finance
staff may mark invoices as paid by hand from Invoices Manual
Payments. Each currency is switched separately. Confirming a manual
payment still requires the payment slip and the booking&apos;s pay
window to be open.
</CardDescription>
</CardHeader>
@@ -95,6 +97,38 @@ export default function ManualPaymentSettingsCard() {
</div>
)}
{isLoading || !data ? (
<Skeleton className="h-[86px] w-full rounded-md" />
) : (
<div className="flex items-start justify-between gap-4 rounded-md border p-4 dark:border-gray-700">
<div className="space-y-1">
<div className="flex items-center gap-2">
<Coins className="h-4 w-4 text-muted-foreground" />
<p className="font-medium">Accept Djibouti Franc (DJF)</p>
<Badge variant={data.djfPaymentsEnabled ? "default" : "secondary"}>
{data.djfPaymentsEnabled ? "Enabled" : "Disabled"}
</Badge>
</div>
<p className="text-sm text-muted-foreground">
Whether DJF is offered as a billing currency on new bookings and
shipment requests, and whether DJF invoices can be paid online
(Waafi / CAC Bank). Switching this off stops new DJF business
invoices already in DJF stay settleable by hand below.
</p>
</div>
<Switch
checked={data.djfPaymentsEnabled}
disabled={!canManage || update.isPending}
aria-label="Accept DJF as a payment currency"
onCheckedChange={(checked) =>
update.mutate({ djfPaymentsEnabled: checked })
}
/>
</div>
)}
<p className="pt-2 text-sm font-medium">Manual (offline) settlement</p>
{isLoading || !data
? CURRENCIES.map((c) => (
<Skeleton key={c.code} className="h-[86px] w-full rounded-md" />

View File

@@ -79,7 +79,7 @@ export default function EmptyReturnRequestsPage() {
const controls = useListControls(requests, {
dateKey: "submittedAt",
searchValue: (row) =>
`${row.bookingReference ?? ""} ${row.companyName ?? ""} ${row.containerNumbers.join(" ")}`,
`${row.bookingReference ?? ""} ${row.companyName ?? ""} ${(row.containerNumbers ?? []).join(" ")}`,
});
const invalidate = () => {
@@ -143,7 +143,7 @@ export default function EmptyReturnRequestsPage() {
<Stack gap={2}>
<Badge size="sm">{row.original.containerCount}</Badge>
<Text size="xs" c="dimmed" lineClamp={2}>
{row.original.containerNumbers.join(", ")}
{(row.original.containerNumbers ?? []).join(", ")}
</Text>
</Stack>
),
@@ -386,7 +386,7 @@ function ApproveModal({
Containers coming back
</Text>
<Text size="sm" c="dimmed">
{request.containerNumbers.join(", ")}
{(request.containerNumbers ?? []).join(", ")}
</Text>
</div>

View File

@@ -14,6 +14,12 @@ export interface ManualPaymentSettings {
etbEnabled: boolean;
usdEnabled: boolean;
djfEnabled: boolean;
/**
* Wider than `djfEnabled`: whether DJF is accepted as a payment currency at
* all — offered on the booking forms and payable online. Off leaves existing
* DJF invoices settleable by hand.
*/
djfPaymentsEnabled: boolean;
updatedById: string | null;
updatedAt?: string;
}
@@ -24,10 +30,21 @@ export const manualPaymentSettingsService = {
return unwrap(response.data);
},
/** Currencies customers may be billed and pay in — open read, no permission. */
currencies: async (): Promise<string[]> => {
const response = await client.get<ApiResponse<string[]>>(
URL_CONSTANTS.MANUAL_PAYMENT_SETTINGS.CURRENCIES,
);
return unwrap(response.data);
},
/** Partial: an omitted currency keeps its current setting. */
update: async (
patch: Partial<
Pick<ManualPaymentSettings, "etbEnabled" | "usdEnabled" | "djfEnabled">
Pick<
ManualPaymentSettings,
"etbEnabled" | "usdEnabled" | "djfEnabled" | "djfPaymentsEnabled"
>
>,
): Promise<ManualPaymentSettings> => {
const response = await client.patch<ApiResponse<ManualPaymentSettings>>(

View File

@@ -588,14 +588,31 @@ export interface EligibleBooking {
remainingContainerCount: number;
}
/**
* One truck in a multi-truck arrival, with the containers it carries. Each
* truck keeps its own plate, driver and load, and the API validates capacity
* and container-to-plate assignment per truck.
*/
export interface ReceiveTruckPayload {
truckEntrance: TruckEntrancePayload;
containerNumbers?: string[];
/** Defaults to the operation's bookingIds when omitted. */
bookingIds?: string[];
}
export interface BulkReceivePayload {
direction: 'IMPORT' | 'EXPORT';
warehouseId: string;
yardId: string;
zoneId: string;
bookingIds: string[];
/**
* Single-truck arrival. Superseded by `trucks` when several trucks deliver
* the same arrival; the API accepts either shape.
*/
containerNumbers?: string[];
truckEntrance?: TruckEntrancePayload;
trucks?: ReceiveTruckPayload[];
}
export interface BulkReceiveResult {

View File

@@ -0,0 +1,31 @@
import { useQuery } from "@tanstack/react-query";
import { client } from "@/utils/api";
import { unwrap } from "@/utils/endpoint";
import type { ApiResponse } from "@/types/apiResponse";
/**
* Currencies a booking may be billed and paid in right now — DJF drops out
* when staff switch it off in Configuration → Payments. One open GET, so it
* is fetched here rather than through a service file of its own.
*/
export const PAYMENT_CURRENCIES_KEY = ["payment-currencies"] as const;
export function usePaymentCurrencies() {
const { data } = useQuery({
queryKey: PAYMENT_CURRENCIES_KEY,
queryFn: async () => {
const response = await client.get<ApiResponse<string[]>>(
"/api/payment-settings/manual/currencies",
);
return unwrap(response.data);
},
staleTime: 60_000,
});
return {
currencies: data,
/** In flight or failed → assume on; the switch is off only by exception. */
djfEnabled: data ? data.includes("DJF") : true,
};
}

View File

@@ -13,6 +13,7 @@ import { Check, Landmark, ShieldCheck } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import type { InvoicePaymentFlow } from "@/hooks/useInvoicePayment";
import { usePaymentCurrencies } from "@/hooks/usePaymentCurrencies";
import { PayerAccountNote } from "@/pages/bookings/payments/PayerAccountNote";
import type { PaymentMethod } from "@/services/payments.service";
@@ -207,13 +208,21 @@ export function PaymentMethodModal({
/** CBE bill-reference step, from `useInvoicePayment`. Omit to disable CBE_BILL. */
bill?: InvoicePaymentFlow["bill"];
}) {
// DJF switched off in Configuration → Payments closes its online rails; the
// API refuses the charge too, so offering them would only fail at the gate.
const { djfEnabled } = usePaymentCurrencies();
const currencyOff =
!djfEnabled && currency?.trim().toUpperCase() === "DJF";
const providers = useMemo(
() =>
providersForCurrency(currency).filter(
(p) =>
(otp || !isOtpMethod(p.method)) && (bill || !isBillMethod(p.method)),
),
[currency, otp, bill],
currencyOff
? []
: providersForCurrency(currency).filter(
(p) =>
(otp || !isOtpMethod(p.method)) &&
(bill || !isBillMethod(p.method)),
),
[currency, otp, bill, currencyOff],
);
const [method, setMethod] = useState<PaymentMethod>(providers[0]?.method ?? PROVIDERS[0].method);
const [mobile, setMobile] = useState("");

View File

@@ -7,6 +7,7 @@ import {
type BookingFormValues,
type PaymentCurrency,
} from "./schema";
import { usePaymentCurrencies } from "@/hooks/usePaymentCurrencies";
import { OptionFieldError, StepLabel } from "./shared";
const CURRENCY_ICONS: Record<
@@ -29,10 +30,14 @@ export function PaymentCurrencyField({
*/
allowUsd?: boolean;
}) {
// DJF is offered wherever USD is — both are import-shipment-only currencies.
const options = allowUsd
? PAYMENT_CURRENCY_OPTIONS
: PAYMENT_CURRENCY_OPTIONS.filter((o) => o.value === "ETB");
// DJF is offered wherever USD is — both are import-shipment-only currencies
// — and only while staff keep it switched on in Configuration → Payments.
const { djfEnabled } = usePaymentCurrencies();
const options = (
allowUsd
? PAYMENT_CURRENCY_OPTIONS
: PAYMENT_CURRENCY_OPTIONS.filter((o) => o.value === "ETB")
).filter((o) => o.value !== "DJF" || djfEnabled);
return (
<Box mt={24}>
<StepLabel>Payment currency</StepLabel>

View File

@@ -58,6 +58,8 @@ import {
ExportTrainPicker,
OperationDatePicker,
} from "@edr/ui-common";
import { usePaymentCurrencies } from "@/hooks/usePaymentCurrencies";
import { api } from "@/services/api";
import {
contractsService,
@@ -1327,6 +1329,7 @@ function ScheduleStep({
}) {
const contractRouteId = form.watch("contractRouteId");
const route = routes.find((r) => r.id === contractRouteId) ?? routes[0];
const { djfEnabled } = usePaymentCurrencies();
// Read the cargo entered in the previous step so the day list reflects what
// can actually be shipped (matching wagons + open train capacity).
@@ -1483,7 +1486,8 @@ function ScheduleStep({
onChange={(v) => field.onChange(v)}
error={fieldState.error?.message}
allowUsd={isImport}
allowDjf={isImport}
// Hidden once staff switch DJF off, unless it is already picked.
allowDjf={isImport && (djfEnabled || field.value === "DJF")}
/>
</Box>
)}

View File

@@ -15,6 +15,8 @@ import {
Title,
} from "@mantine/core";
import { CurrencySelector } from "@edr/ui-common";
import { usePaymentCurrencies } from "@/hooks/usePaymentCurrencies";
import { AlertCircle, ArrowLeft, CalendarDays, Send } from "lucide-react";
import toast from "react-hot-toast";
import type { Freight } from "@edr/types";
@@ -39,6 +41,7 @@ export default function NewShipmentRequestPage() {
// Starts empty so the billing-currency choice is deliberate — required at
// submit. Intercity/export are forced to ETB (server-enforced too).
const [paymentCurrency, setPaymentCurrency] = useState<"USD" | "ETB" | "DJF" | "">("");
const { djfEnabled } = usePaymentCurrencies();
const [currencyError, setCurrencyError] = useState<string | undefined>();
const [notes, setNotes] = useState("");
@@ -267,7 +270,7 @@ export default function NewShipmentRequestPage() {
}}
disabled={isIntercity || isExport}
allowUsd={!isIntercity && !isExport}
allowDjf={!isIntercity && !isExport}
allowDjf={!isIntercity && !isExport && djfEnabled}
error={currencyError}
/>
</Box>