mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge remote-tracking branch 'origin/dev' into tests
This commit is contained in:
8
.gitignore
vendored
8
.gitignore
vendored
@@ -29,3 +29,11 @@ coverage/
|
|||||||
\#*\#
|
\#*\#
|
||||||
.\#*
|
.\#*
|
||||||
docker-compose.override.yml
|
docker-compose.override.yml
|
||||||
|
|
||||||
|
# cypress e2e artifacts
|
||||||
|
e2e/**/cypress/videos/
|
||||||
|
e2e/**/cypress/screenshots/
|
||||||
|
e2e/**/cypress/downloads/
|
||||||
|
|
||||||
|
# e2e launcher state (ports of the running stack)
|
||||||
|
e2e/freight/.e2e-ports.json
|
||||||
|
|||||||
@@ -14,6 +14,13 @@ export const BookingStaff = (permission: string | string[]) =>
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read-only reference data (yard dropdowns, search filters): any signed-in
|
||||||
|
* staff. Menu/page visibility stays permission-gated in the frontend — this
|
||||||
|
* only lets forms populate their lookups.
|
||||||
|
*/
|
||||||
|
export const StaffReference = () => applyDecorators(UseGuards(JwtGuard));
|
||||||
|
|
||||||
export const BookingView = () => BookingStaff(FREIGHT_PERMS.bookings.view);
|
export const BookingView = () => BookingStaff(FREIGHT_PERMS.bookings.view);
|
||||||
|
|
||||||
export const TrainSchedulingView = () =>
|
export const TrainSchedulingView = () =>
|
||||||
|
|||||||
61
apps/edr-freight-api/src/common/mile-financials.util.ts
Normal file
61
apps/edr-freight-api/src/common/mile-financials.util.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
|
||||||
|
type MileRecord = {
|
||||||
|
bookingId?: string | null;
|
||||||
|
advancedPayment?: number | string | null;
|
||||||
|
booking?: {
|
||||||
|
cargoTotalWeightVgm?: number | string | null;
|
||||||
|
bookingContainers?: Array<{
|
||||||
|
units?: Array<{ vgmTons?: number | string | null }> | null;
|
||||||
|
}> | null;
|
||||||
|
} | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display enrichment for first/last-mile lists (Assign Vehicle modal etc.):
|
||||||
|
* - Advance payment: mile records are created with advanced_payment 0 — the
|
||||||
|
* real advance is the FIRST_MILE/LAST_MILE line the customer already paid
|
||||||
|
* on the booking invoice.
|
||||||
|
* - Cargo tons: container bookings often carry tonnage on the per-unit VGMs
|
||||||
|
* while cargo_total_weight_vgm stays 0 — fall back to the summed units.
|
||||||
|
* Fills both in-memory on the loaded records; nothing is persisted.
|
||||||
|
*/
|
||||||
|
export async function attachMileFinancials(
|
||||||
|
dataSource: DataSource,
|
||||||
|
records: MileRecord[],
|
||||||
|
chargeType: 'FIRST_MILE' | 'LAST_MILE',
|
||||||
|
): Promise<void> {
|
||||||
|
for (const r of records) {
|
||||||
|
const b = r.booking;
|
||||||
|
if (!b || Number(b.cargoTotalWeightVgm) > 0) continue;
|
||||||
|
const unitTons = (b.bookingContainers ?? []).reduce(
|
||||||
|
(sum, bc) =>
|
||||||
|
sum + (bc.units ?? []).reduce((s, u) => s + (Number(u.vgmTons) || 0), 0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
if (unitTons > 0) b.cargoTotalWeightVgm = Number(unitTons.toFixed(3));
|
||||||
|
}
|
||||||
|
|
||||||
|
const needAdvance = records.filter(
|
||||||
|
(r) => r.bookingId && !(Number(r.advancedPayment) > 0),
|
||||||
|
);
|
||||||
|
if (!needAdvance.length) return;
|
||||||
|
|
||||||
|
const rows: Array<{ bookingId: string; amount: string }> = await dataSource.query(
|
||||||
|
`SELECT i.source_id AS "bookingId", SUM(il.amount) AS amount
|
||||||
|
FROM freight.invoice_lines il
|
||||||
|
JOIN freight.invoices i ON i.id = il.invoice_id AND i.deleted_at IS NULL
|
||||||
|
WHERE i.source = 'booking'
|
||||||
|
AND i.status = 'PAID'
|
||||||
|
AND i.source_id = ANY($1::text[])
|
||||||
|
AND il.charge_type = $2
|
||||||
|
AND il.deleted_at IS NULL
|
||||||
|
GROUP BY i.source_id`,
|
||||||
|
[needAdvance.map((r) => r.bookingId), chargeType],
|
||||||
|
);
|
||||||
|
const byBooking = new Map(rows.map((r) => [r.bookingId, Number(r.amount)]));
|
||||||
|
for (const r of needAdvance) {
|
||||||
|
const paid = byBooking.get(r.bookingId as string);
|
||||||
|
if (paid) r.advancedPayment = paid;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,6 +33,13 @@ async function bootstrap() {
|
|||||||
"delegator-position-id",
|
"delegator-position-id",
|
||||||
"current-project-id",
|
"current-project-id",
|
||||||
"current-position-id",
|
"current-position-id",
|
||||||
|
// x-prefixed variants sent by the user-management / record-management
|
||||||
|
// frontend modules (same values, different naming convention)
|
||||||
|
"x-organization-unit-id",
|
||||||
|
"x-delegator-id",
|
||||||
|
"x-delegator-position-id",
|
||||||
|
"x-current-project-id",
|
||||||
|
"x-current-position-id",
|
||||||
],
|
],
|
||||||
exposedHeaders: ["Content-Disposition"],
|
exposedHeaders: ["Content-Disposition"],
|
||||||
maxAge: 86400, // cache preflight for 24h to cut chatter in dev
|
maxAge: 86400, // cache preflight for 24h to cut chatter in dev
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-truck EDR last-mile handovers. `truck_assignment_id` FKs
|
||||||
|
* customer_truck_assignments (self-haul only), so EDR trucks need their own
|
||||||
|
* link to the last-mile vehicle assignment that hauled the goods. Generated
|
||||||
|
* when the EDR truck exits the warehouse (with its exit paper) and signed by
|
||||||
|
* the customer in the portal — one per truck, or booking-level (both ids null)
|
||||||
|
* when the truck cannot be resolved.
|
||||||
|
*/
|
||||||
|
export class AddHandoverEdrAssignment2440000000000 implements MigrationInterface {
|
||||||
|
name = 'AddHandoverEdrAssignment2440000000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.booking_handovers
|
||||||
|
ADD COLUMN IF NOT EXISTS edr_assignment_id uuid
|
||||||
|
REFERENCES freight.last_mile_vehicle_assignments(id) ON DELETE SET NULL;
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_booking_handovers_booking_edr_truck"
|
||||||
|
ON freight.booking_handovers (booking_id, edr_assignment_id)
|
||||||
|
WHERE deleted_at IS NULL AND edr_assignment_id IS NOT NULL;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`DROP INDEX IF EXISTS freight."UQ_booking_handovers_booking_edr_truck";`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE freight.booking_handovers DROP COLUMN IF EXISTS edr_assignment_id;`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drop the `active_profile_type` "active mode" column. A booking/contract now
|
||||||
|
* resolves its company_profile from the trade direction at creation time (with
|
||||||
|
* a forwarder passing an explicit companyProfileId), so no per-user active mode
|
||||||
|
* is stored. `onboarding_step` / `onboarding_completed` are unaffected.
|
||||||
|
*/
|
||||||
|
export class DropActiveProfileTypeFromExternalProfiles2450000000000
|
||||||
|
implements MigrationInterface
|
||||||
|
{
|
||||||
|
name = 'DropActiveProfileTypeFromExternalProfiles2450000000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.external_profiles
|
||||||
|
DROP COLUMN IF EXISTS active_profile_type;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.external_profiles
|
||||||
|
ADD COLUMN IF NOT EXISTS active_profile_type varchar(32);
|
||||||
|
`);
|
||||||
|
// Rebuild the mode the same way the original column was backfilled:
|
||||||
|
// importer first, then exporter, then whichever profile the company has.
|
||||||
|
await queryRunner.query(`
|
||||||
|
UPDATE freight.external_profiles ep
|
||||||
|
SET active_profile_type = cp.type
|
||||||
|
FROM (
|
||||||
|
SELECT DISTINCT ON (company_id) company_id, type
|
||||||
|
FROM freight.company_profiles
|
||||||
|
ORDER BY company_id,
|
||||||
|
CASE type
|
||||||
|
WHEN 'importer' THEN 0
|
||||||
|
WHEN 'exporter' THEN 1
|
||||||
|
ELSE 2
|
||||||
|
END
|
||||||
|
) cp
|
||||||
|
WHERE ep.company_id = cp.company_id
|
||||||
|
AND ep.active_profile_type IS NULL;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||||
|
|
||||||
|
export class AddCacBankPaymentMethod2460000000000 implements MigrationInterface {
|
||||||
|
name = "AddCacBankPaymentMethod2460000000000";
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
// The entity + frontend already list 'cac-bank' as a valid method, but the
|
||||||
|
// DB enum was never extended. Filtering payments by 'cac-bank' cast the
|
||||||
|
// literal to the enum and errored (invalid input value for enum). EDRFREIGHT-301.
|
||||||
|
await queryRunner.query(`ALTER TYPE freight.payments_method_enum ADD VALUE IF NOT EXISTS 'cac-bank';`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(_queryRunner: QueryRunner): Promise<void> {
|
||||||
|
// PostgreSQL does not support removing enum values directly.
|
||||||
|
// To roll back, recreate the type without the added value and update the column.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Acquisitions describe WHAT was acquired (vehicle, parts, equipment…) — the
|
||||||
|
* vehicle link is optional and only for acquisitions that ARE a fleet vehicle.
|
||||||
|
*/
|
||||||
|
export class AddAcquisitionItemName2470000000000 implements MigrationInterface {
|
||||||
|
name = 'AddAcquisitionItemName2470000000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.asset_acquisitions
|
||||||
|
ADD COLUMN IF NOT EXISTS item_name varchar(200)
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.asset_acquisitions
|
||||||
|
DROP COLUMN IF EXISTS item_name
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
RESET_LINK_TTL_MS,
|
RESET_LINK_TTL_MS,
|
||||||
} from "./forgot-password.service";
|
} from "./forgot-password.service";
|
||||||
import { maskOtpTarget } from "./mask-target.util";
|
import { maskOtpTarget } from "./mask-target.util";
|
||||||
|
import { isDomesticPhone } from "../otp/otp.service";
|
||||||
|
|
||||||
/** The account a staff-triggered reset would land on. */
|
/** The account a staff-triggered reset would land on. */
|
||||||
export interface CustomerResetTarget {
|
export interface CustomerResetTarget {
|
||||||
@@ -19,6 +20,12 @@ export interface CustomerResetTarget {
|
|||||||
name: string;
|
name: string;
|
||||||
email: string | null;
|
email: string | null;
|
||||||
phone: string | null;
|
phone: string | null;
|
||||||
|
/**
|
||||||
|
* Whether the SMS gateway (domestic-only) can reach `phone`. `null` when
|
||||||
|
* there is no phone. The backoffice uses this to disable the SMS channel for
|
||||||
|
* foreign numbers instead of sending a link that will never arrive.
|
||||||
|
*/
|
||||||
|
phoneIsDomestic: boolean | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SentResetLink {
|
export interface SentResetLink {
|
||||||
@@ -58,6 +65,9 @@ export class CustomerResetService {
|
|||||||
name: `${profile.firstName} ${profile.lastName}`.trim(),
|
name: `${profile.firstName} ${profile.lastName}`.trim(),
|
||||||
email: user.email ?? null,
|
email: user.email ?? null,
|
||||||
phone: user.phoneNumber ?? null,
|
phone: user.phoneNumber ?? null,
|
||||||
|
phoneIsDomestic: user.phoneNumber
|
||||||
|
? isDomesticPhone(user.phoneNumber)
|
||||||
|
: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,6 +90,17 @@ export class CustomerResetService {
|
|||||||
const target = this.forgotPasswordService.targetFor(user, channel);
|
const target = this.forgotPasswordService.targetFor(user, channel);
|
||||||
if (!target) return null;
|
if (!target) return null;
|
||||||
|
|
||||||
|
// A foreign number is unreachable by the domestic-only SMS gateway — treat
|
||||||
|
// it like a missing phone rather than reporting "link sent" for a message
|
||||||
|
// that will never arrive. The backoffice disables the channel up front via
|
||||||
|
// `phoneIsDomestic`; this guards direct API calls.
|
||||||
|
if (channel === "phone" && target.phone && !isDomesticPhone(target.phone)) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Staff reset via SMS refused for user ${userId} — non-domestic phone`,
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
// Mint first, send second: a failed send leaves an unused ticket that simply
|
// Mint first, send second: a failed send leaves an unused ticket that simply
|
||||||
// expires, whereas sending a link before the ticket exists would hand the
|
// expires, whereas sending a link before the ticket exists would hand the
|
||||||
// customer a URL that is dead on arrival.
|
// customer a URL that is dead on arrival.
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { InjectDataSource } from '@nestjs/typeorm';
|
||||||
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
collectPermissionKeys,
|
collectPermissionKeys,
|
||||||
@@ -9,7 +11,35 @@ import { PERMISSIONS_CATALOG } from '../../seed/freight-permissions.registry';
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class FreightMeService {
|
export class FreightMeService {
|
||||||
getEnrichedProfile(user: TCurrentUser) {
|
constructor(@InjectDataSource() private readonly dataSource: DataSource) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The JWT session snapshot has no position TYPE, but the backoffice needs it
|
||||||
|
* (GL sub-positions are identified by type key). Resolved live from IAM.
|
||||||
|
*/
|
||||||
|
private async lookupPositionType(
|
||||||
|
positionId: string | undefined,
|
||||||
|
): Promise<{ key: string; name: unknown } | null> {
|
||||||
|
if (!positionId) return null;
|
||||||
|
try {
|
||||||
|
const rows: { key: string; name: unknown }[] = await this.dataSource.query(
|
||||||
|
`SELECT pt.key, pt.name
|
||||||
|
FROM iam.positions p
|
||||||
|
JOIN iam.position_types pt ON pt.id = p.position_type_id
|
||||||
|
WHERE p.id = $1`,
|
||||||
|
[positionId],
|
||||||
|
);
|
||||||
|
return rows[0] ?? null;
|
||||||
|
} catch {
|
||||||
|
return null; // iam schema unreachable — degrade to the old payload shape
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getEnrichedProfile(user: TCurrentUser) {
|
||||||
|
const positionType = await this.lookupPositionType(
|
||||||
|
user.employee?.position?.id,
|
||||||
|
);
|
||||||
|
|
||||||
const employee = user.employee
|
const employee = user.employee
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
@@ -27,6 +57,7 @@ export class FreightMeService {
|
|||||||
isDelegate: user.employee.position.isDelegate,
|
isDelegate: user.employee.position.isDelegate,
|
||||||
parentPositionId: user.employee.position.parentPositionId,
|
parentPositionId: user.employee.position.parentPositionId,
|
||||||
permissions: user.employee.position.permissions ?? [],
|
permissions: user.employee.position.permissions ?? [],
|
||||||
|
positionType,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
: [],
|
: [],
|
||||||
|
|||||||
@@ -163,11 +163,12 @@ describe('BookingPricingService — domestic corridor', () => {
|
|||||||
computeBaseRailLinesWithRates: (
|
computeBaseRailLinesWithRates: (
|
||||||
b: Booking,
|
b: Booking,
|
||||||
input: { containers: [] },
|
input: { containers: [] },
|
||||||
) => Promise<{ lineItems: Array<{ amount: number }> }>;
|
) => Promise<{ lineItems: Array<{ amount: number }>; blocked: string[] }>;
|
||||||
}
|
}
|
||||||
).computeBaseRailLinesWithRates(booking, { containers: [] });
|
).computeBaseRailLinesWithRates(booking, { containers: [] });
|
||||||
|
|
||||||
expect(result.lineItems).toHaveLength(0);
|
expect(result.lineItems).toHaveLength(0);
|
||||||
|
expect(result.blocked).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not price containers off a rate configured for a different leg', async () => {
|
it('does not price containers off a rate configured for a different leg', async () => {
|
||||||
@@ -197,4 +198,45 @@ describe('BookingPricingService — domestic corridor', () => {
|
|||||||
|
|
||||||
expect(result.lineItems).toHaveLength(0);
|
expect(result.lineItems).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// A mixed booking where only one container size has a configured rate must
|
||||||
|
// hard-block, not silently carry the unconfigured size for free.
|
||||||
|
it('blocks the unconfigured container size and prices the configured one', async () => {
|
||||||
|
const fortyOnly: Rate = {
|
||||||
|
...intercityContainerUsd,
|
||||||
|
id: 'rate-ct-40-only',
|
||||||
|
containerTypeId: 'ct-40',
|
||||||
|
} as Rate;
|
||||||
|
ratesService.findLiveRates.mockResolvedValue([fortyOnly]);
|
||||||
|
|
||||||
|
const booking = {
|
||||||
|
id: 'b-5',
|
||||||
|
freightType: 'CONTAINER',
|
||||||
|
tradeDirection: 'DOMESTIC',
|
||||||
|
paymentCurrency: 'USD',
|
||||||
|
originYardId: MOJO,
|
||||||
|
destinationYardId: DIRE,
|
||||||
|
bookingContainers: [],
|
||||||
|
} as unknown as Booking;
|
||||||
|
|
||||||
|
const result = await (
|
||||||
|
service as unknown as {
|
||||||
|
computeBaseRailLinesWithRates: (
|
||||||
|
b: Booking,
|
||||||
|
input: {
|
||||||
|
containers: Array<{ containerTypeId: string; quantity: number }>;
|
||||||
|
},
|
||||||
|
) => Promise<{ lineItems: Array<{ code: string }>; blocked: string[] }>;
|
||||||
|
}
|
||||||
|
).computeBaseRailLinesWithRates(booking, {
|
||||||
|
containers: [
|
||||||
|
{ containerTypeId: 'ct-40', quantity: 2 },
|
||||||
|
{ containerTypeId: 'ct-20', quantity: 3 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.lineItems).toHaveLength(1);
|
||||||
|
expect(result.blocked).toHaveLength(1);
|
||||||
|
expect(result.blocked[0]).toContain('rate is configured');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -137,8 +137,12 @@ export class BookingPricingService {
|
|||||||
const lineItems: PriceLineItemDto[] = [];
|
const lineItems: PriceLineItemDto[] = [];
|
||||||
let total = 0;
|
let total = 0;
|
||||||
|
|
||||||
const { lineItems: baseLines, usedRates: baseRates, warnings: baseWarnings } =
|
const {
|
||||||
await this.computeBaseRailLinesWithRates(booking, evalInput, frozenRates);
|
lineItems: baseLines,
|
||||||
|
usedRates: baseRates,
|
||||||
|
warnings: baseWarnings,
|
||||||
|
blocked: baseBlocked,
|
||||||
|
} = await this.computeBaseRailLinesWithRates(booking, evalInput, frozenRates);
|
||||||
for (const line of baseLines) {
|
for (const line of baseLines) {
|
||||||
lineItems.push(line);
|
lineItems.push(line);
|
||||||
total += line.amount;
|
total += line.amount;
|
||||||
@@ -248,7 +252,7 @@ export class BookingPricingService {
|
|||||||
appliedModifiers: ruleResult.appliedModifiers,
|
appliedModifiers: ruleResult.appliedModifiers,
|
||||||
priorityScore: ruleResult.priorityScore,
|
priorityScore: ruleResult.priorityScore,
|
||||||
warnings: [...ruleResult.warnings, ...baseWarnings],
|
warnings: [...ruleResult.warnings, ...baseWarnings],
|
||||||
hardBlocked: ruleResult.hardBlocked,
|
hardBlocked: [...ruleResult.hardBlocked, ...baseBlocked],
|
||||||
overweightLines,
|
overweightLines,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -454,7 +458,12 @@ export class BookingPricingService {
|
|||||||
booking: Booking,
|
booking: Booking,
|
||||||
evalInput: BookingEvaluationInput,
|
evalInput: BookingEvaluationInput,
|
||||||
frozenRates: Map<string, ContractRateSnapshot> | null = null,
|
frozenRates: Map<string, ContractRateSnapshot> | null = null,
|
||||||
): Promise<{ lineItems: PriceLineItemDto[]; usedRates: Rate[]; warnings: string[] }> {
|
): Promise<{
|
||||||
|
lineItems: PriceLineItemDto[];
|
||||||
|
usedRates: Rate[];
|
||||||
|
warnings: string[];
|
||||||
|
blocked: string[];
|
||||||
|
}> {
|
||||||
const liveRates = await this.ratesService.findLiveRates();
|
const liveRates = await this.ratesService.findLiveRates();
|
||||||
const paymentCurrency = booking.paymentCurrency;
|
const paymentCurrency = booking.paymentCurrency;
|
||||||
const isEtbBooking = paymentCurrency === 'ETB';
|
const isEtbBooking = paymentCurrency === 'ETB';
|
||||||
@@ -477,6 +486,7 @@ export class BookingPricingService {
|
|||||||
const lines: PriceLineItemDto[] = [];
|
const lines: PriceLineItemDto[] = [];
|
||||||
const usedRatesMap = new Map<string, Rate>();
|
const usedRatesMap = new Map<string, Rate>();
|
||||||
const warnings: string[] = [];
|
const warnings: string[] = [];
|
||||||
|
const blocked: string[] = [];
|
||||||
const wagonCount = await this.resolveWagonCount(booking);
|
const wagonCount = await this.resolveWagonCount(booking);
|
||||||
|
|
||||||
for (const container of evalInput.containers) {
|
for (const container of evalInput.containers) {
|
||||||
@@ -500,11 +510,14 @@ export class BookingPricingService {
|
|||||||
const label = await this.containerTypeLabel(container.containerTypeId);
|
const label = await this.containerTypeLabel(container.containerTypeId);
|
||||||
if (!rate && !frozen) {
|
if (!rate && !frozen) {
|
||||||
// Never price this line off another container type's (or another
|
// Never price this line off another container type's (or another
|
||||||
// route's) rate — an unpriced line with a warning is recoverable; a
|
// route's) rate, and never let an unpriced line through: a booking
|
||||||
// silently mischarged one is not.
|
// that ships a container type nobody configured a rate for would be
|
||||||
warnings.push(
|
// carried for free. Hard-block instead — the customer drops the line
|
||||||
|
// or EDR configures the rate.
|
||||||
|
blocked.push(
|
||||||
`No ${rateType} rate is configured for ${label} on this route — ` +
|
`No ${rateType} rate is configured for ${label} on this route — ` +
|
||||||
'the line was not priced.',
|
`the booking cannot be priced. Remove the ${label} line or ask EDR ` +
|
||||||
|
'to configure its rate for this origin → destination.',
|
||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -587,10 +600,18 @@ export class BookingPricingService {
|
|||||||
quantity: this.effectiveUnitQuantity(fallback.rateUnit, quantity, wagonCount),
|
quantity: this.effectiveUnitQuantity(fallback.rateUnit, quantity, wagonCount),
|
||||||
currency: paymentCurrency,
|
currency: paymentCurrency,
|
||||||
});
|
});
|
||||||
|
} else if (isBulk) {
|
||||||
|
// Same rule as container lines: bulk freight with no rate on this leg
|
||||||
|
// must not proceed unpriced.
|
||||||
|
blocked.push(
|
||||||
|
`No ${rateType} rate is configured for this route — the booking ` +
|
||||||
|
'cannot be priced. Ask EDR to configure the rate for this ' +
|
||||||
|
'origin → destination.',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { lineItems: lines, usedRates: [...usedRatesMap.values()], warnings };
|
return { lineItems: lines, usedRates: [...usedRatesMap.values()], warnings, blocked };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
Logger,
|
Logger,
|
||||||
Optional,
|
Optional,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
|
import { OnEvent } from "@nestjs/event-emitter";
|
||||||
|
|
||||||
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
|
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
|
||||||
import { eatDay } from '../train-scheduling/batch-window.util';
|
import { eatDay } from '../train-scheduling/batch-window.util';
|
||||||
@@ -349,6 +350,22 @@ export class BookingTransitionService {
|
|||||||
return fresh;
|
return fresh;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Import EDR last-mile: every handover signed + every truck departed ⇒ the
|
||||||
|
* warehouses module delivered the goods and asks the booking to complete.
|
||||||
|
* Best-effort — a booking already COMPLETED (or not yet in transit) just logs.
|
||||||
|
*/
|
||||||
|
@OnEvent('import.handover.completed')
|
||||||
|
async onImportHandoverCompleted(payload: { bookingId: string }): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.complete(payload.bookingId);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.log(
|
||||||
|
`Booking ${payload.bookingId} not auto-completed on handover sign: ${(err as Error).message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async complete(bookingId: string): Promise<Booking> {
|
async complete(bookingId: string): Promise<Booking> {
|
||||||
const booking = await this.bookingsService.findById(bookingId);
|
const booking = await this.bookingsService.findById(bookingId);
|
||||||
assertBookingStatus(booking, ["IN_TRANSIT", "ARRIVED"]);
|
assertBookingStatus(booking, ["IN_TRANSIT", "ARRIVED"]);
|
||||||
|
|||||||
@@ -480,6 +480,20 @@ export class BookingsController {
|
|||||||
return this.customerTruckService.addTruck(id, dto);
|
return this.customerTruckService.addTruck(id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post(':id/customer-trucks/bulk')
|
||||||
|
@ApiOperation({ summary: 'Bulk add customer trucks from array payload (Excel parsed)' })
|
||||||
|
async bulkAddCustomerTrucks(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body() payload: { trucks: AddCustomerTruckDto[] },
|
||||||
|
@CurrentUser() user: TCurrentUser,
|
||||||
|
) {
|
||||||
|
const booking = await this.bookingsService.findById(id);
|
||||||
|
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
|
||||||
|
await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking);
|
||||||
|
}
|
||||||
|
return this.customerTruckService.addBulkTrucks(id, payload.trucks);
|
||||||
|
}
|
||||||
|
|
||||||
@Patch(':id/customer-trucks/:assignmentId')
|
@Patch(':id/customer-trucks/:assignmentId')
|
||||||
@ApiOperation({ summary: 'Edit a not-yet-arrived customer truck (plate/driver/type + containers)' })
|
@ApiOperation({ summary: 'Edit a not-yet-arrived customer truck (plate/driver/type + containers)' })
|
||||||
async updateCustomerTruck(
|
async updateCustomerTruck(
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import { Freight, SchedulingStatus } from '@edr/types';
|
|||||||
import { insertWithGeneratedReference } from '@edr/api-common';
|
import { insertWithGeneratedReference } from '@edr/api-common';
|
||||||
// import { CustomersService } from '../customers/customers.service';
|
// import { CustomersService } from '../customers/customers.service';
|
||||||
import { CompaniesService } from '../companies/companies.service';
|
import { CompaniesService } from '../companies/companies.service';
|
||||||
import { ProfileType } from '../companies/entities/company-profile.entity';
|
|
||||||
import { CompanyKind, CompanyStatus } from '../companies/entities/company.entity';
|
import { CompanyKind, CompanyStatus } from '../companies/entities/company.entity';
|
||||||
import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service';
|
import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service';
|
||||||
import { eatDay } from '../train-scheduling/batch-window.util';
|
import { eatDay } from '../train-scheduling/batch-window.util';
|
||||||
@@ -635,12 +634,9 @@ export class BookingsService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
const { company } = await this.companiesService.getCompanyInfoByUserId(userId);
|
const { company } = await this.companiesService.getCompanyInfoByUserId(userId);
|
||||||
// A customer can only book once their company has been approved.
|
// A customer can only book once their company has been approved; the
|
||||||
if (company.status !== CompanyStatus.Active) {
|
// helper names the real status (suspended/blacklisted) when it isn't.
|
||||||
throw new ForbiddenException(
|
this.companiesService.assertCompanyActiveFor(company, 'bookings');
|
||||||
"Your company is awaiting approval — you can't create bookings yet.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
companyId = company.id;
|
companyId = company.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -746,21 +742,13 @@ export class BookingsService {
|
|||||||
);
|
);
|
||||||
companyProfileId = profile.id;
|
companyProfileId = profile.id;
|
||||||
} else if (companyId) {
|
} else if (companyId) {
|
||||||
let fallbackType: ProfileType | null = null;
|
// No explicit profile pin: resolve from the booking's trade direction
|
||||||
if (userId) {
|
// (import→importer, export→exporter; otherwise the first profile). A
|
||||||
try {
|
// forwarder booking sends dto.companyProfileId and takes the branch above.
|
||||||
const { profile } =
|
|
||||||
await this.companiesService.getCompanyInfoByUserId(userId);
|
|
||||||
fallbackType = profile.activeProfileType ?? null;
|
|
||||||
} catch {
|
|
||||||
// No profile (e.g. staff creating on behalf) — fall back to mapping.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
companyProfileId =
|
companyProfileId =
|
||||||
await this.companiesService.resolveCompanyProfileIdForBooking(
|
await this.companiesService.resolveCompanyProfileIdForBooking(
|
||||||
companyId,
|
companyId,
|
||||||
tradeDirection,
|
tradeDirection,
|
||||||
fallbackType,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// A customer booking under their own account may only do so once the
|
// A customer booking under their own account may only do so once the
|
||||||
@@ -1068,9 +1056,6 @@ export class BookingsService {
|
|||||||
await this.companiesService.resolveCompanyProfileIdForBooking(
|
await this.companiesService.resolveCompanyProfileIdForBooking(
|
||||||
existing.companyId,
|
existing.companyId,
|
||||||
tradeDirection,
|
tradeDirection,
|
||||||
existing.companyProfileId
|
|
||||||
? undefined
|
|
||||||
: (existing.companyProfile?.type as ProfileType | undefined),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (dto.scheduledDate) updates.scheduledDate = new Date(dto.scheduledDate);
|
if (dto.scheduledDate) updates.scheduledDate = new Date(dto.scheduledDate);
|
||||||
@@ -1228,9 +1213,9 @@ export class BookingsService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Batched version of the findById flag: marks each page item whose booking
|
* Batched version of the findById flag: marks each page item whose booking
|
||||||
* has a generated-but-unsigned SELF_HAUL handover, so list rows (portal
|
* has a generated-but-unsigned handover (self-haul or EDR last-mile), so list
|
||||||
* dashboard) can show "Approve delivery" for exactly the generated→signed
|
* rows (portal dashboard) can show "Approve delivery" for exactly the
|
||||||
* window. One query for the whole page.
|
* generated→signed window. One query for the whole page.
|
||||||
*/
|
*/
|
||||||
private async attachHandoverFlags(bookings: Booking[]): Promise<void> {
|
private async attachHandoverFlags(bookings: Booking[]): Promise<void> {
|
||||||
const ids = bookings.map((b) => b.id);
|
const ids = bookings.map((b) => b.id);
|
||||||
@@ -1239,8 +1224,7 @@ export class BookingsService {
|
|||||||
`SELECT DISTINCT booking_id AS "bookingId"
|
`SELECT DISTINCT booking_id AS "bookingId"
|
||||||
FROM freight.booking_handovers
|
FROM freight.booking_handovers
|
||||||
WHERE booking_id = ANY($1::uuid[])
|
WHERE booking_id = ANY($1::uuid[])
|
||||||
AND signed_at IS NULL AND deleted_at IS NULL
|
AND signed_at IS NULL AND deleted_at IS NULL`,
|
||||||
AND mile_type = 'SELF_HAUL'`,
|
|
||||||
[ids],
|
[ids],
|
||||||
);
|
);
|
||||||
const pending = new Set(rows.map((r) => r.bookingId));
|
const pending = new Set(rows.map((r) => r.bookingId));
|
||||||
@@ -1392,15 +1376,6 @@ export class BookingsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve the active company_profile id a customer's bookings should be
|
|
||||||
* scoped to (importer/exporter mode). Null when not onboarded — callers fall
|
|
||||||
* back to company-level scoping.
|
|
||||||
*/
|
|
||||||
async resolveActiveCompanyProfileId(userId: string): Promise<string | null> {
|
|
||||||
return this.companiesService.resolveActiveCompanyProfileId(userId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Authorize a customer's access to a single booking. Staff are scoped at the
|
* Authorize a customer's access to a single booking. Staff are scoped at the
|
||||||
* controller (they pass `isStaff`); for a customer, the booking must belong
|
* controller (they pass `isStaff`); for a customer, the booking must belong
|
||||||
@@ -1583,14 +1558,12 @@ export class BookingsService {
|
|||||||
schedule?.status ?? null;
|
schedule?.status ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// A generated-but-unsigned SELF_HAUL handover means the customer must approve
|
// A generated-but-unsigned handover means the customer must approve delivery
|
||||||
// delivery from the portal (booking-based, one per booking). EDR last-mile
|
// from the portal. Self-haul: booking-based, one per booking. EDR last-mile:
|
||||||
// handovers are per delivering truck and signed by the receiver at the door,
|
// per delivering truck (generated on truck exit), signed one by one.
|
||||||
// so they never surface the portal "Approve delivery" action.
|
|
||||||
const [pendingHandover] = await this.dataSource.query(
|
const [pendingHandover] = await this.dataSource.query(
|
||||||
`SELECT 1 FROM freight.booking_handovers
|
`SELECT 1 FROM freight.booking_handovers
|
||||||
WHERE booking_id = $1 AND signed_at IS NULL AND deleted_at IS NULL
|
WHERE booking_id = $1 AND signed_at IS NULL AND deleted_at IS NULL
|
||||||
AND mile_type = 'SELF_HAUL'
|
|
||||||
LIMIT 1`,
|
LIMIT 1`,
|
||||||
[id],
|
[id],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -576,4 +576,35 @@ export class CustomerTruckService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Contract container sizes (e.g. "20ft" / "40ft") for the given container numbers. */
|
/** Contract container sizes (e.g. "20ft" / "40ft") for the given container numbers. */
|
||||||
|
|
||||||
|
async addBulkTrucks(
|
||||||
|
bookingId: string,
|
||||||
|
dtos: AddCustomerTruckDto[],
|
||||||
|
): Promise<{
|
||||||
|
success: number;
|
||||||
|
failed: number;
|
||||||
|
errors: Array<{ row: number; truck: string; reason: string }>;
|
||||||
|
}> {
|
||||||
|
const errors: Array<{ row: number; truck: string; reason: string }> = [];
|
||||||
|
let successCount = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < dtos.length; i++) {
|
||||||
|
try {
|
||||||
|
await this.addTruck(bookingId, dtos[i]);
|
||||||
|
successCount++;
|
||||||
|
} catch (err: any) {
|
||||||
|
errors.push({
|
||||||
|
row: i + 2, // Row 1 is header
|
||||||
|
truck: dtos[i].truckPlateNumber,
|
||||||
|
reason: err.message || 'Unknown error',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: successCount,
|
||||||
|
failed: errors.length,
|
||||||
|
errors,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { IsString, IsNotEmpty, IsIn, IsArray, ArrayMaxSize, ArrayUnique, Matches, IsOptional } from 'class-validator';
|
||||||
|
import { CUSTOMER_TRUCK_TYPES } from './customer-truck-assignment.dto';
|
||||||
|
|
||||||
|
export class BulkCustomerTruckRow {
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
truckPlateNumber!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
driverName!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsIn(CUSTOMER_TRUCK_TYPES)
|
||||||
|
truckType!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@ArrayMaxSize(2)
|
||||||
|
@ArrayUnique()
|
||||||
|
@Matches(/^[A-Z]{4}\d{7}$/, {
|
||||||
|
each: true,
|
||||||
|
message: 'each container must be ISO format (e.g. ABCD1234567)',
|
||||||
|
})
|
||||||
|
containerNumbers?: (string | null)[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BulkCustomerTrucksDto {
|
||||||
|
@IsArray()
|
||||||
|
@ArrayMaxSize(100)
|
||||||
|
trucks!: BulkCustomerTruckRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BulkTruckUploadResult {
|
||||||
|
success: number;
|
||||||
|
failed: number;
|
||||||
|
errors: Array<{
|
||||||
|
row: number;
|
||||||
|
truck: string;
|
||||||
|
reason: string;
|
||||||
|
}>;
|
||||||
|
created: Array<{
|
||||||
|
truckPlateNumber: string;
|
||||||
|
driverName: string;
|
||||||
|
containers: number;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
@@ -26,7 +26,6 @@ import { CreateExternalProfileDto } from "./dto/create-external-profile.dto";
|
|||||||
import { CreateCompanyWithProfileDto } from "./dto/create-company-with-profile.dto";
|
import { CreateCompanyWithProfileDto } from "./dto/create-company-with-profile.dto";
|
||||||
import { AddCompanyProfilesDto } from "./dto/add-company-profiles.dto";
|
import { AddCompanyProfilesDto } from "./dto/add-company-profiles.dto";
|
||||||
import { CreateCompanyProfileDto } from "./dto/create-company-profile.dto";
|
import { CreateCompanyProfileDto } from "./dto/create-company-profile.dto";
|
||||||
import { SetActiveModeDto } from "./dto/set-active-mode.dto";
|
|
||||||
import { SetOnboardingStepDto } from "./dto/set-onboarding-step.dto";
|
import { SetOnboardingStepDto } from "./dto/set-onboarding-step.dto";
|
||||||
import { StartOnboardingDto } from "./dto/start-onboarding.dto";
|
import { StartOnboardingDto } from "./dto/start-onboarding.dto";
|
||||||
import { DashboardQueryDto } from "./dto/dashboard-query.dto";
|
import { DashboardQueryDto } from "./dto/dashboard-query.dto";
|
||||||
@@ -353,21 +352,6 @@ export class CompaniesController {
|
|||||||
return this.companiesService.removePoaDelegationLetter(user.id, fileId);
|
return this.companiesService.removePoaDelegationLetter(user.id, fileId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch("active-mode")
|
|
||||||
@ApiOperation({
|
|
||||||
summary: "Switch the current user's active operational mode (importer/exporter)",
|
|
||||||
})
|
|
||||||
async setActiveMode(
|
|
||||||
@CurrentUser() user: CurrentIamUser,
|
|
||||||
@Body() dto: SetActiveModeDto,
|
|
||||||
): Promise<CompanyInfoResponseDto> {
|
|
||||||
const { profile, company } = await this.companiesService.setActiveMode(
|
|
||||||
user.id,
|
|
||||||
dto.type,
|
|
||||||
);
|
|
||||||
return new CompanyInfoResponseDto(profile, company);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Patch("onboarding-step")
|
@Patch("onboarding-step")
|
||||||
@ApiOperation({ summary: "Persist the user's current onboarding wizard step" })
|
@ApiOperation({ summary: "Persist the user's current onboarding wizard step" })
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
|
|||||||
@@ -41,6 +41,18 @@ export class CompaniesRepository extends BaseRepository<Company> {
|
|||||||
AND ccr.deleted_at IS NULL
|
AND ccr.deleted_at IS NULL
|
||||||
)`;
|
)`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `sortBy = 'review'` queue ordering: whatever marketing must act on
|
||||||
|
* floats to the top. Tier 0 — submitted applications awaiting first approval
|
||||||
|
* (drafts excluded: nothing to review yet). Tier 1 — approved customers with
|
||||||
|
* a pending change request. Tier 2 — everyone else, drafts included.
|
||||||
|
*/
|
||||||
|
private static readonly REVIEW_TIER_SQL = `(CASE
|
||||||
|
WHEN company.status = 'pending' AND NOT ${CompaniesRepository.DRAFT_SQL} THEN 0
|
||||||
|
WHEN ${CompaniesRepository.PENDING_CHANGE_REQUEST_SQL} THEN 1
|
||||||
|
ELSE 2
|
||||||
|
END)`;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(Company)
|
@InjectRepository(Company)
|
||||||
repo: Repository<Company>,
|
repo: Repository<Company>,
|
||||||
@@ -80,8 +92,8 @@ export class CompaniesRepository extends BaseRepository<Company> {
|
|||||||
status,
|
status,
|
||||||
onboardingCompleted,
|
onboardingCompleted,
|
||||||
hasPendingChangeRequest,
|
hasPendingChangeRequest,
|
||||||
sortBy = 'name',
|
sortBy = 'review',
|
||||||
sortOrder = 'ASC',
|
sortOrder = 'DESC',
|
||||||
} = query;
|
} = query;
|
||||||
|
|
||||||
const qb = this.repository
|
const qb = this.repository
|
||||||
@@ -137,8 +149,18 @@ export class CompaniesRepository extends BaseRepository<Company> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// sortBy is whitelisted by @IsIn on the DTO, so it is safe to interpolate.
|
// sortBy is whitelisted by @IsIn on the DTO, so it is safe to interpolate.
|
||||||
|
if (sortBy === 'review') {
|
||||||
|
// Queue ordering: actionable tiers first, newest first within each. The
|
||||||
|
// tier is selected under an alias because skip/take pagination with
|
||||||
|
// joins re-derives the ORDER BY in a subquery — a raw expression there
|
||||||
|
// breaks, a selected alias survives.
|
||||||
|
qb.addSelect(CompaniesRepository.REVIEW_TIER_SQL, 'review_tier')
|
||||||
|
.orderBy('review_tier', 'ASC')
|
||||||
|
.addOrderBy('company.createdAt', 'DESC');
|
||||||
|
} else {
|
||||||
|
qb.orderBy(`company.${sortBy}`, sortOrder);
|
||||||
|
}
|
||||||
const [items, total] = await qb
|
const [items, total] = await qb
|
||||||
.orderBy(`company.${sortBy}`, sortOrder)
|
|
||||||
// Names are not unique and createdAt can tie on bulk imports; the id
|
// Names are not unique and createdAt can tie on bulk imports; the id
|
||||||
// tiebreaker keeps paging stable instead of dropping/repeating rows.
|
// tiebreaker keeps paging stable instead of dropping/repeating rows.
|
||||||
.addOrderBy('company.id', 'ASC')
|
.addOrderBy('company.id', 'ASC')
|
||||||
|
|||||||
@@ -201,18 +201,6 @@ export class CompaniesService {
|
|||||||
attributes: dto.attributes ?? null,
|
attributes: dto.attributes ?? null,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Default active mode from the chosen role(s): importer wins when both are
|
|
||||||
// picked, otherwise the first allowed type chosen.
|
|
||||||
const allowedTypes = this.getProfileTypeForCompanyType(company.type);
|
|
||||||
const chosenTypes = (dto.companyProfiles ?? [])
|
|
||||||
.map((p) => p.type)
|
|
||||||
.filter((t) => allowedTypes.includes(t));
|
|
||||||
const activeProfileType =
|
|
||||||
chosenTypes.find((t) => t === ProfileType.importer) ??
|
|
||||||
chosenTypes[0] ??
|
|
||||||
allowedTypes[0] ??
|
|
||||||
null;
|
|
||||||
|
|
||||||
const profile = await this.profilesRepo.create({
|
const profile = await this.profilesRepo.create({
|
||||||
userId: identity.userId,
|
userId: identity.userId,
|
||||||
companyId: company.id,
|
companyId: company.id,
|
||||||
@@ -220,7 +208,6 @@ export class CompaniesService {
|
|||||||
lastName: identity.lastName,
|
lastName: identity.lastName,
|
||||||
jobTitle: dto.jobTitle ?? null,
|
jobTitle: dto.jobTitle ?? null,
|
||||||
isPrimaryContact: dto.isPrimaryContact ?? true,
|
isPrimaryContact: dto.isPrimaryContact ?? true,
|
||||||
activeProfileType,
|
|
||||||
onboardingStep: "company",
|
onboardingStep: "company",
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -293,11 +280,6 @@ export class CompaniesService {
|
|||||||
|
|
||||||
const allowedTypes = this.getProfileTypeForCompanyType(companyType);
|
const allowedTypes = this.getProfileTypeForCompanyType(companyType);
|
||||||
const chosenTypes = roles.filter((t) => allowedTypes.includes(t));
|
const chosenTypes = roles.filter((t) => allowedTypes.includes(t));
|
||||||
const activeProfileType =
|
|
||||||
chosenTypes.find((t) => t === ProfileType.importer) ??
|
|
||||||
chosenTypes[0] ??
|
|
||||||
allowedTypes[0] ??
|
|
||||||
null;
|
|
||||||
|
|
||||||
const company = await this.companiesRepo.create({
|
const company = await this.companiesRepo.create({
|
||||||
name: identity.firstName
|
name: identity.firstName
|
||||||
@@ -316,7 +298,6 @@ export class CompaniesService {
|
|||||||
firstName: identity.firstName,
|
firstName: identity.firstName,
|
||||||
lastName: identity.lastName,
|
lastName: identity.lastName,
|
||||||
isPrimaryContact: true,
|
isPrimaryContact: true,
|
||||||
activeProfileType,
|
|
||||||
onboardingStep: "company",
|
onboardingStep: "company",
|
||||||
onboardingCompleted: false,
|
onboardingCompleted: false,
|
||||||
});
|
});
|
||||||
@@ -1090,6 +1071,23 @@ export class CompaniesService {
|
|||||||
if (!existing)
|
if (!existing)
|
||||||
throw new NotFoundException(`Company profile ${profileId} not found`);
|
throw new NotFoundException(`Company profile ${profileId} not found`);
|
||||||
|
|
||||||
|
// Suspension and reactivation must carry a staff explanation — the customer
|
||||||
|
// sees it, so "why" can never be left blank. Reactivation is the
|
||||||
|
// active-write that leaves Suspended; a first approval stays note-free.
|
||||||
|
const reactivating =
|
||||||
|
status === ProfileStatus.Active &&
|
||||||
|
existing.status === ProfileStatus.Suspended;
|
||||||
|
if (
|
||||||
|
(status === ProfileStatus.Suspended || reactivating) &&
|
||||||
|
!note?.trim()
|
||||||
|
) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
status === ProfileStatus.Suspended
|
||||||
|
? "A message explaining the suspension is required — the customer will see it."
|
||||||
|
: "A message explaining the reactivation is required — the customer will see it.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// A self-registered company is only reviewable once its owner submits the
|
// A self-registered company is only reviewable once its owner submits the
|
||||||
// onboarding wizard (markOnboardingComplete) — until then its profiles are
|
// onboarding wizard (markOnboardingComplete) — until then its profiles are
|
||||||
// half-filled drafts and approving one would mint a reference against an
|
// half-filled drafts and approving one would mint a reference against an
|
||||||
@@ -1176,9 +1174,13 @@ export class CompaniesService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Track the review outcome. Rejection keeps the note so the customer knows
|
// Track the review outcome. Rejection and suspension keep the note so the
|
||||||
// why; approval clears it. Any decision stamps the reviewer + time.
|
// customer knows why; approval/reactivation clears it. Any decision stamps
|
||||||
if (status === ProfileStatus.Rejected) {
|
// the reviewer + time.
|
||||||
|
if (
|
||||||
|
status === ProfileStatus.Rejected ||
|
||||||
|
status === ProfileStatus.Suspended
|
||||||
|
) {
|
||||||
patch.reviewNote = note ?? null;
|
patch.reviewNote = note ?? null;
|
||||||
} else if (status === ProfileStatus.Active) {
|
} else if (status === ProfileStatus.Active) {
|
||||||
patch.reviewNote = null;
|
patch.reviewNote = null;
|
||||||
@@ -1192,23 +1194,50 @@ export class CompaniesService {
|
|||||||
if (!updated)
|
if (!updated)
|
||||||
throw new NotFoundException(`Company profile ${existing.id} not found`);
|
throw new NotFoundException(`Company profile ${existing.id} not found`);
|
||||||
|
|
||||||
// Approving any profile promotes a pending company to active, so the
|
// Every reviewed transition that changes what the customer can do is told
|
||||||
// customer can start working as soon as their first profile is cleared.
|
// to them, carrying the staff message so they know why. Approval has no
|
||||||
if (status === ProfileStatus.Active) {
|
// message (the note is cleared); the others require one.
|
||||||
|
const change =
|
||||||
|
status === ProfileStatus.Suspended
|
||||||
|
? "suspended"
|
||||||
|
: status === ProfileStatus.Rejected
|
||||||
|
? "rejected"
|
||||||
|
: status === ProfileStatus.Active
|
||||||
|
? existing.status === ProfileStatus.Suspended
|
||||||
|
? "reactivated"
|
||||||
|
: "approved"
|
||||||
|
: null;
|
||||||
|
if (change) {
|
||||||
const company = await this.companiesRepo.findById(updated.companyId);
|
const company = await this.companiesRepo.findById(updated.companyId);
|
||||||
if (company && company.status === CompanyStatus.Pending) {
|
if (company) {
|
||||||
await this.companiesRepo.update(updated.companyId, {
|
this.companyNotifier.profileStatusChanged(
|
||||||
status: CompanyStatus.Active,
|
company,
|
||||||
});
|
updated.type,
|
||||||
|
change,
|
||||||
|
note ?? "",
|
||||||
|
);
|
||||||
|
// The first approved role promotes a pending company to active — a
|
||||||
|
// bigger event (the account itself goes live), so tell them that too.
|
||||||
|
if (
|
||||||
|
status === ProfileStatus.Active &&
|
||||||
|
company.status === CompanyStatus.Pending
|
||||||
|
) {
|
||||||
|
await this.companiesRepo.update(updated.companyId, {
|
||||||
|
status: CompanyStatus.Active,
|
||||||
|
});
|
||||||
|
this.companyNotifier.companyApproved(company);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Customer reapplies for a rejected operational role (after fixing whatever the
|
* Customer reapplies for a rejected or suspended operational role (after
|
||||||
* reviewer flagged, e.g. re-uploading a license): flip it back to Pending and
|
* fixing whatever the reviewer flagged, e.g. re-uploading a license): flip it
|
||||||
* clear the rejection note so it re-enters the approval queue.
|
* back to Pending and clear the review note so it re-enters the approval
|
||||||
|
* queue. Suspension is a staff lockout, so resubmitting is an appeal — the
|
||||||
|
* backoffice still has to approve before the role goes live again.
|
||||||
*/
|
*/
|
||||||
async reapplyCompanyProfile(
|
async reapplyCompanyProfile(
|
||||||
userId: string,
|
userId: string,
|
||||||
@@ -1223,9 +1252,12 @@ export class CompaniesService {
|
|||||||
if (!target || target.companyId !== companyId) {
|
if (!target || target.companyId !== companyId) {
|
||||||
throw new NotFoundException(`Company profile ${profileId} not found`);
|
throw new NotFoundException(`Company profile ${profileId} not found`);
|
||||||
}
|
}
|
||||||
if (target.status !== ProfileStatus.Rejected) {
|
if (
|
||||||
|
target.status !== ProfileStatus.Rejected &&
|
||||||
|
target.status !== ProfileStatus.Suspended
|
||||||
|
) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
"Only a rejected role can be resubmitted for approval",
|
"Only a rejected or suspended role can be resubmitted for approval",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1349,10 +1381,9 @@ export class CompaniesService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a single operational profile for the current user's company. The new
|
* Create a single operational profile for the current user's company. The new
|
||||||
* role starts Pending, so it deliberately does NOT become the active mode:
|
* role starts Pending and carries no reference until a backoffice reviewer
|
||||||
* switching onto an unapproved profile would strip the user of `canBook` and
|
* approves it; a booking/contract resolves its profile from the trade
|
||||||
* block them from creating contracts under the role they already had approved.
|
* direction at creation time, so no "active mode" is stored.
|
||||||
* Callers switch explicitly via {@link setActiveMode} once the role is Active.
|
|
||||||
*/
|
*/
|
||||||
async createCompanyProfileForUser(
|
async createCompanyProfileForUser(
|
||||||
userId: string,
|
userId: string,
|
||||||
@@ -1387,40 +1418,6 @@ export class CompaniesService {
|
|||||||
return created;
|
return created;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Switch the user's active operational mode. The target profile must already
|
|
||||||
* exist — clients create it first via createCompanyProfileForUser.
|
|
||||||
*/
|
|
||||||
async setActiveMode(
|
|
||||||
userId: string,
|
|
||||||
type: ProfileType,
|
|
||||||
): Promise<{ profile: ExternalProfile; company: Company }> {
|
|
||||||
const profile = await this.profilesRepo.findByUserId(userId);
|
|
||||||
if (!profile)
|
|
||||||
throw new NotFoundException(`Profile for user ${userId} not found`);
|
|
||||||
|
|
||||||
const companyId = profile.company?.id ?? profile.companyId;
|
|
||||||
const company = await this.findCompanyById(companyId);
|
|
||||||
|
|
||||||
const allowedTypes = this.getProfileTypeForCompanyType(company.type);
|
|
||||||
if (!allowedTypes.includes(type)) {
|
|
||||||
throw new BadRequestException(
|
|
||||||
`Profile type "${type}" is not allowed for company type "${company.type}"`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const existing = await this.companyProfilesRepo.findByType(companyId, type);
|
|
||||||
if (!existing) {
|
|
||||||
throw new ConflictException(
|
|
||||||
`No ${type} profile exists yet — create it before switching`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.profilesRepo.update(profile.id, { activeProfileType: type });
|
|
||||||
|
|
||||||
return this.getCompanyInfoByUserId(userId);
|
|
||||||
}
|
|
||||||
|
|
||||||
async setOnboardingStep(userId: string, step: string): Promise<void> {
|
async setOnboardingStep(userId: string, step: string): Promise<void> {
|
||||||
const profile = await this.profilesRepo.findByUserId(userId);
|
const profile = await this.profilesRepo.findByUserId(userId);
|
||||||
if (!profile)
|
if (!profile)
|
||||||
@@ -1611,21 +1608,68 @@ export class CompaniesService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Block a customer from booking under a profile that isn't approved yet.
|
* Block a self-service action when the company account isn't active, naming
|
||||||
* Called from the booking-create path for self-service bookings; staff- and
|
* the actual status — a suspended customer told "awaiting approval" has no
|
||||||
* government-initiated bookings bypass this. No-op when the profile can't be
|
* idea what happened or who to call.
|
||||||
* found (defensive — resolution is best-effort upstream).
|
*/
|
||||||
|
assertCompanyActiveFor(company: Company, action: string): void {
|
||||||
|
if (company.status === CompanyStatus.Active) return;
|
||||||
|
switch (company.status) {
|
||||||
|
case CompanyStatus.Suspended:
|
||||||
|
throw new ForbiddenException(
|
||||||
|
`Your company account is suspended — you can't create ${action} right now. ` +
|
||||||
|
`Please contact EDR support for details.`,
|
||||||
|
);
|
||||||
|
case CompanyStatus.Blacklisted:
|
||||||
|
throw new ForbiddenException(
|
||||||
|
`Your company account is blacklisted — you can't create ${action}. ` +
|
||||||
|
`Please contact EDR support.`,
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
throw new ForbiddenException(
|
||||||
|
`Your company is awaiting approval — you can't create ${action} yet.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Block a customer from booking under a profile that isn't approved yet — or
|
||||||
|
* that a reviewer has since suspended. Called from the booking/contract
|
||||||
|
* create path for self-service actions; staff- and government-initiated ones
|
||||||
|
* bypass this. No-op when the profile can't be found (defensive — resolution
|
||||||
|
* is best-effort upstream). The message names the profile's real status:
|
||||||
|
* suspension in particular is per-role, so the customer must learn which
|
||||||
|
* operation is blocked (their other roles still work).
|
||||||
*/
|
*/
|
||||||
async assertCompanyProfileApprovedForBooking(
|
async assertCompanyProfileApprovedForBooking(
|
||||||
companyProfileId: string,
|
companyProfileId: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const profile = await this.companyProfilesRepo.findById(companyProfileId);
|
const profile = await this.companyProfilesRepo.findById(companyProfileId);
|
||||||
if (!profile) return;
|
if (!profile) return;
|
||||||
if (profile.status !== ProfileStatus.Active) {
|
if (profile.status === ProfileStatus.Active) return;
|
||||||
const role = profile.type.replace(/_/g, " ");
|
|
||||||
throw new ForbiddenException(
|
const role = profile.type.replace(/_/g, " ");
|
||||||
`Your ${role} profile is awaiting approval. You'll be able to create bookings once it has been approved.`,
|
switch (profile.status) {
|
||||||
);
|
case ProfileStatus.Suspended:
|
||||||
|
throw new ForbiddenException(
|
||||||
|
`Your ${role} role is suspended${
|
||||||
|
profile.reviewNote ? ` — ${profile.reviewNote}` : ""
|
||||||
|
}. Your other roles are unaffected. Please contact EDR support to resolve this.`,
|
||||||
|
);
|
||||||
|
case ProfileStatus.Blacklisted:
|
||||||
|
throw new ForbiddenException(
|
||||||
|
`Your ${role} role is blacklisted. Please contact EDR support.`,
|
||||||
|
);
|
||||||
|
case ProfileStatus.Rejected:
|
||||||
|
throw new ForbiddenException(
|
||||||
|
`Your ${role} role was rejected${
|
||||||
|
profile.reviewNote ? ` — ${profile.reviewNote}` : ""
|
||||||
|
}. Amend and resubmit it from your settings page.`,
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
throw new ForbiddenException(
|
||||||
|
`Your ${role} profile is awaiting approval. You'll be able to proceed once it has been approved.`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2244,15 +2288,14 @@ export class CompaniesService {
|
|||||||
/**
|
/**
|
||||||
* Resolve which company_profile a new booking belongs to, from the company
|
* Resolve which company_profile a new booking belongs to, from the company
|
||||||
* and the booking's trade direction. IMPORT → importer profile, EXPORT →
|
* and the booking's trade direction. IMPORT → importer profile, EXPORT →
|
||||||
* exporter profile; for DOMESTIC or a forwarder/single-profile company (or
|
* exporter profile; for DOMESTIC (or when the natural profile doesn't exist,
|
||||||
* when the natural profile doesn't exist) it falls back to the user's active
|
* e.g. a freight forwarder) it falls back to the company's first profile.
|
||||||
* profile, then the company's first profile. Returns null when the company
|
* Callers that need a specific role (a forwarder) pass an explicit
|
||||||
* has no profiles at all.
|
* companyProfileId instead. Returns null when the company has no profiles.
|
||||||
*/
|
*/
|
||||||
async resolveCompanyProfileIdForBooking(
|
async resolveCompanyProfileIdForBooking(
|
||||||
companyId: string,
|
companyId: string,
|
||||||
tradeDirection: string,
|
tradeDirection: string,
|
||||||
fallbackType?: ProfileType | null,
|
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
const profiles = await this.companyProfilesRepo.findByCompanyId(companyId);
|
const profiles = await this.companyProfilesRepo.findByCompanyId(companyId);
|
||||||
if (profiles.length === 0) return null;
|
if (profiles.length === 0) return null;
|
||||||
@@ -2264,30 +2307,12 @@ export class CompaniesService {
|
|||||||
? ProfileType.exporter
|
? ProfileType.exporter
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const byType = (type?: ProfileType | null) =>
|
const match =
|
||||||
type ? profiles.find((p) => p.type === type) : undefined;
|
(naturalType && profiles.find((p) => p.type === naturalType)) ??
|
||||||
|
profiles[0];
|
||||||
const match = byType(naturalType) ?? byType(fallbackType) ?? profiles[0];
|
|
||||||
return match?.id ?? null;
|
return match?.id ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve the company_profile a customer's data should be scoped to, from
|
|
||||||
* their persisted active mode. Returns null when nothing can be resolved
|
|
||||||
* (not onboarded yet) so callers can fall back to company-level scoping.
|
|
||||||
*/
|
|
||||||
async resolveActiveCompanyProfileId(userId: string): Promise<string | null> {
|
|
||||||
try {
|
|
||||||
const { profile, company } = await this.getCompanyInfoByUserId(userId);
|
|
||||||
const type = profile.activeProfileType;
|
|
||||||
if (!type) return null;
|
|
||||||
const match = company.companyProfiles?.find((p) => p.type === type);
|
|
||||||
return match?.id ?? null;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fetchETradeData(tin: string) {
|
async fetchETradeData(tin: string) {
|
||||||
const { businessInfo, companyInfo } =
|
const { businessInfo, companyInfo } =
|
||||||
await this.etradeService.resolveCompanyData(tin);
|
await this.etradeService.resolveCompanyData(tin);
|
||||||
|
|||||||
@@ -59,23 +59,106 @@ export class CompanyNotifierService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** SMS + email + in-app account-status item to the company contact. */
|
||||||
|
private notifyAccount(
|
||||||
|
company: Company,
|
||||||
|
title: string,
|
||||||
|
body: string,
|
||||||
|
link = "/settings",
|
||||||
|
): void {
|
||||||
|
void this.notifyContact(company, `${title}. ${body}`);
|
||||||
|
void this.inbox.notify({
|
||||||
|
recipients: { companyId: company.id },
|
||||||
|
audience: NotificationAudience.PORTAL,
|
||||||
|
type: NotificationType.ACCOUNT_STATUS,
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
link,
|
||||||
|
data: { companyId: company.id, status: company.status },
|
||||||
|
priority: NotificationPriority.HIGH,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tell the customer their account was suspended or blacklisted. Called only on
|
* Tell the customer their account changed status. Fires on the transitions
|
||||||
* a real transition into one of those statuses; other status writes are silent.
|
* that change what they can do: suspended/blacklisted (locked out) and
|
||||||
|
* reactivated (back to Active from a lockout). Silent otherwise.
|
||||||
*/
|
*/
|
||||||
statusChanged(company: Company, previous: CompanyStatus): void {
|
statusChanged(company: Company, previous: CompanyStatus): void {
|
||||||
const status = company.status;
|
const status = company.status;
|
||||||
if (status === previous) return;
|
if (status === previous) return;
|
||||||
|
|
||||||
|
if (status === CompanyStatus.Active && PUNITIVE_STATUSES.includes(previous)) {
|
||||||
|
this.logger.log(`ACCOUNT_REACTIVATED — ${company.id}`);
|
||||||
|
this.notifyAccount(
|
||||||
|
company,
|
||||||
|
"Account reactivated",
|
||||||
|
"Your company account has been reactivated. " +
|
||||||
|
"You can submit new contracts and bookings again.",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!PUNITIVE_STATUSES.includes(status)) return;
|
if (!PUNITIVE_STATUSES.includes(status)) return;
|
||||||
|
|
||||||
const label = status === CompanyStatus.Suspended ? "suspended" : "blacklisted";
|
const label = status === CompanyStatus.Suspended ? "suspended" : "blacklisted";
|
||||||
const title = `Account ${label}`;
|
|
||||||
const body =
|
|
||||||
`Your company account has been ${label}. ` +
|
|
||||||
`You will not be able to submit new contracts or bookings. ` +
|
|
||||||
`Please contact EDR support for assistance.`;
|
|
||||||
|
|
||||||
this.logger.log(`ACCOUNT_${label.toUpperCase()} — ${company.id}`);
|
this.logger.log(`ACCOUNT_${label.toUpperCase()} — ${company.id}`);
|
||||||
|
this.notifyAccount(
|
||||||
|
company,
|
||||||
|
`Account ${label}`,
|
||||||
|
`Your company account has been ${label}. ` +
|
||||||
|
`You will not be able to submit new contracts or bookings. ` +
|
||||||
|
`Please contact EDR support for assistance.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tell the customer their company account was approved and is now live — the
|
||||||
|
* first operational role clearing review promotes a pending company to Active.
|
||||||
|
*/
|
||||||
|
companyApproved(company: Company): void {
|
||||||
|
this.logger.log(`ACCOUNT_APPROVED — ${company.id}`);
|
||||||
|
this.notifyAccount(
|
||||||
|
company,
|
||||||
|
"Account approved",
|
||||||
|
"Your company account has been approved and is now active. " +
|
||||||
|
"You can start submitting bookings and contracts.",
|
||||||
|
"/dashboard",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tell the customer one of their operational roles changed review status —
|
||||||
|
* approved, rejected, suspended, or reactivated — quoting the staff message
|
||||||
|
* when one was given (rejection/suspension/reactivation require one; approval
|
||||||
|
* carries none).
|
||||||
|
*/
|
||||||
|
profileStatusChanged(
|
||||||
|
company: Company,
|
||||||
|
profileType: string,
|
||||||
|
change: "approved" | "rejected" | "suspended" | "reactivated",
|
||||||
|
staffMessage: string,
|
||||||
|
): void {
|
||||||
|
const title = `${profileType} role ${change}`;
|
||||||
|
const consequence: Record<typeof change, string> = {
|
||||||
|
approved: "You can now operate under this role.",
|
||||||
|
rejected:
|
||||||
|
"You will not be able to operate under this role. Amend the required " +
|
||||||
|
"documents and resubmit it for approval from your settings page.",
|
||||||
|
suspended:
|
||||||
|
"You will not be able to operate under this role until it is " +
|
||||||
|
"reactivated; your other roles are unaffected.",
|
||||||
|
reactivated: "You can operate under this role again.",
|
||||||
|
};
|
||||||
|
const message = staffMessage.trim();
|
||||||
|
const body =
|
||||||
|
`Your company's ${profileType} role has been ${change}. ` +
|
||||||
|
`${consequence[change]}` +
|
||||||
|
(message ? ` Message from EDR staff: ${message}` : "");
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`PROFILE_${change.toUpperCase()} — ${company.id} / ${profileType}`,
|
||||||
|
);
|
||||||
void this.notifyContact(company, `${title}. ${body}`);
|
void this.notifyContact(company, `${title}. ${body}`);
|
||||||
void this.inbox.notify({
|
void this.inbox.notify({
|
||||||
recipients: { companyId: company.id },
|
recipients: { companyId: company.id },
|
||||||
@@ -84,7 +167,7 @@ export class CompanyNotifierService {
|
|||||||
title,
|
title,
|
||||||
body,
|
body,
|
||||||
link: "/settings",
|
link: "/settings",
|
||||||
data: { companyId: company.id, status },
|
data: { companyId: company.id, profileType, change, staffMessage: message },
|
||||||
priority: NotificationPriority.HIGH,
|
priority: NotificationPriority.HIGH,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export class CompanyInfoResponseDto {
|
|||||||
company: Company,
|
company: Company,
|
||||||
changeRequest?: CompanyChangeRequest | null,
|
changeRequest?: CompanyChangeRequest | null,
|
||||||
) {
|
) {
|
||||||
this.profile = new ResponseExternalProfileDto(profile, company);
|
this.profile = new ResponseExternalProfileDto(profile);
|
||||||
this.company = new ResponseCompanyDto(company);
|
this.company = new ResponseCompanyDto(company);
|
||||||
|
|
||||||
const open =
|
const open =
|
||||||
|
|||||||
@@ -60,15 +60,19 @@ export class ListCompaniesQueryDto {
|
|||||||
hasPendingChangeRequest?: boolean;
|
hasPendingChangeRequest?: boolean;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
enum: ["name", "createdAt", "updatedAt"],
|
enum: ["review", "name", "createdAt", "updatedAt"],
|
||||||
default: "name",
|
default: "review",
|
||||||
description: "Column to order by. Defaults to name for backwards compatibility.",
|
description:
|
||||||
|
"Column to order by. The default `review` is a review-queue ordering: " +
|
||||||
|
"companies awaiting first approval, then those with a pending change " +
|
||||||
|
"request, then everyone else — newest first within each group. The " +
|
||||||
|
"other values are plain column sorts.",
|
||||||
})
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsIn(["name", "createdAt", "updatedAt"])
|
@IsIn(["review", "name", "createdAt", "updatedAt"])
|
||||||
sortBy?: "name" | "createdAt" | "updatedAt";
|
sortBy?: "review" | "name" | "createdAt" | "updatedAt";
|
||||||
|
|
||||||
@ApiPropertyOptional({ enum: ["ASC", "DESC"], default: "ASC" })
|
@ApiPropertyOptional({ enum: ["ASC", "DESC"], default: "DESC" })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@Transform(({ value }: { value: unknown }) => String(value).toUpperCase())
|
@Transform(({ value }: { value: unknown }) => String(value).toUpperCase())
|
||||||
@IsIn(["ASC", "DESC"])
|
@IsIn(["ASC", "DESC"])
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { Company } from '../entities/company.entity';
|
|
||||||
import {
|
import {
|
||||||
ExternalProfile,
|
ExternalProfile,
|
||||||
} from '../entities/external-profile.entity';
|
} from '../entities/external-profile.entity';
|
||||||
import { ProfileType } from '../entities/company-profile.entity';
|
|
||||||
|
|
||||||
export class ResponseExternalProfileDto {
|
export class ResponseExternalProfileDto {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -13,20 +11,12 @@ export class ResponseExternalProfileDto {
|
|||||||
nationalId?: string | null;
|
nationalId?: string | null;
|
||||||
jobTitle?: string | null;
|
jobTitle?: string | null;
|
||||||
isPrimaryContact: boolean;
|
isPrimaryContact: boolean;
|
||||||
/** The active operational mode (importer/exporter/forwarder). */
|
|
||||||
activeProfileType?: ProfileType | null;
|
|
||||||
/**
|
|
||||||
* The id of the company_profile matching activeProfileType, resolved
|
|
||||||
* server-side so the client never re-derives it. Null until a company
|
|
||||||
* (with profiles) is loaded and a matching profile exists.
|
|
||||||
*/
|
|
||||||
activeCompanyProfileId?: string | null;
|
|
||||||
onboardingStep?: string | null;
|
onboardingStep?: string | null;
|
||||||
onboardingCompleted: boolean;
|
onboardingCompleted: boolean;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
|
|
||||||
constructor(profile: ExternalProfile, company?: Company) {
|
constructor(profile: ExternalProfile) {
|
||||||
this.id = profile.id;
|
this.id = profile.id;
|
||||||
this.userId = profile.userId;
|
this.userId = profile.userId;
|
||||||
this.companyId = profile.companyId;
|
this.companyId = profile.companyId;
|
||||||
@@ -35,13 +25,8 @@ export class ResponseExternalProfileDto {
|
|||||||
this.nationalId = profile.nationalId;
|
this.nationalId = profile.nationalId;
|
||||||
this.jobTitle = profile.jobTitle;
|
this.jobTitle = profile.jobTitle;
|
||||||
this.isPrimaryContact = profile.isPrimaryContact;
|
this.isPrimaryContact = profile.isPrimaryContact;
|
||||||
this.activeProfileType = profile.activeProfileType ?? null;
|
|
||||||
this.onboardingStep = profile.onboardingStep ?? null;
|
this.onboardingStep = profile.onboardingStep ?? null;
|
||||||
this.onboardingCompleted = profile.onboardingCompleted ?? false;
|
this.onboardingCompleted = profile.onboardingCompleted ?? false;
|
||||||
this.activeCompanyProfileId =
|
|
||||||
company?.companyProfiles?.find(
|
|
||||||
(p) => p.type === profile.activeProfileType,
|
|
||||||
)?.id ?? null;
|
|
||||||
this.createdAt = profile.createdAt;
|
this.createdAt = profile.createdAt;
|
||||||
this.updatedAt = profile.updatedAt;
|
this.updatedAt = profile.updatedAt;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
import { IsEnum } from 'class-validator';
|
|
||||||
import { ProfileType } from '../entities/company-profile.entity';
|
|
||||||
|
|
||||||
export class SetActiveModeDto {
|
|
||||||
@IsEnum(ProfileType)
|
|
||||||
type!: ProfileType;
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import { BaseEntity } from '@edr/api-common';
|
import { BaseEntity } from '@edr/api-common';
|
||||||
import { Column, Entity, Index, ManyToOne, JoinColumn } from 'typeorm';
|
import { Column, Entity, Index, ManyToOne, JoinColumn } from 'typeorm';
|
||||||
import { Company } from './company.entity';
|
import { Company } from './company.entity';
|
||||||
import { ProfileType } from './company-profile.entity';
|
|
||||||
|
|
||||||
@Entity({ schema: 'freight', name: 'external_profiles' })
|
@Entity({ schema: 'freight', name: 'external_profiles' })
|
||||||
@Index(['userId'])
|
@Index(['userId'])
|
||||||
@@ -32,21 +31,6 @@ export class ExternalProfile extends BaseEntity {
|
|||||||
@Column({ name: 'is_primary_contact', type: 'boolean', default: false })
|
@Column({ name: 'is_primary_contact', type: 'boolean', default: false })
|
||||||
isPrimaryContact!: boolean;
|
isPrimaryContact!: boolean;
|
||||||
|
|
||||||
/**
|
|
||||||
* The operational profile the user is currently "in" (importer vs exporter,
|
|
||||||
* or the single forwarder profile). Drives header switching and scopes the
|
|
||||||
* customer's bookings / dashboard to that company_profile. Nullable for
|
|
||||||
* users who haven't picked a role yet.
|
|
||||||
*/
|
|
||||||
@Column({
|
|
||||||
name: 'active_profile_type',
|
|
||||||
type: 'varchar',
|
|
||||||
length: 32,
|
|
||||||
nullable: true,
|
|
||||||
enum: ProfileType,
|
|
||||||
})
|
|
||||||
activeProfileType?: ProfileType | null;
|
|
||||||
|
|
||||||
/** Coarse resume point for the onboarding wizard (e.g. 'role', 'company', 'documents', 'done'). */
|
/** Coarse resume point for the onboarding wizard (e.g. 'role', 'company', 'documents', 'done'). */
|
||||||
@Column({
|
@Column({
|
||||||
name: 'onboarding_step',
|
name: 'onboarding_step',
|
||||||
|
|||||||
@@ -320,6 +320,12 @@ export class ContractBookingService {
|
|||||||
await this.applyWeightResults(loaded);
|
await this.applyWeightResults(loaded);
|
||||||
}
|
}
|
||||||
const computed = await this.bookingPricingService.computePriceForBooking(loaded);
|
const computed = await this.bookingPricingService.computePriceForBooking(loaded);
|
||||||
|
// A partially-priced booking (e.g. 40ft has a rate, 20ft has none) has
|
||||||
|
// a positive total, so the zero-price gate below misses it — enforce
|
||||||
|
// the pricing hard blocks first. The catch below rolls everything back.
|
||||||
|
if (computed.hardBlocked.length > 0) {
|
||||||
|
throw new BadRequestException(computed.hardBlocked.join('; '));
|
||||||
|
}
|
||||||
// Reject a zero-price booking outright. A total of 0 means no contract rate
|
// Reject a zero-price booking outright. A total of 0 means no contract rate
|
||||||
// matched the route/container (or the rate is unset), so the booking is not
|
// matched the route/container (or the rate is unset), so the booking is not
|
||||||
// valid to ship or invoice. The catch below rolls back the row + its lines.
|
// valid to ship or invoice. The catch below rolls back the row + its lines.
|
||||||
@@ -744,15 +750,20 @@ export class ContractBookingService {
|
|||||||
const computed = await this.bookingPricingService.computePriceForBooking(loaded);
|
const computed = await this.bookingPricingService.computePriceForBooking(loaded);
|
||||||
// A zero price means no contract rate matches — roll the cargo back so
|
// A zero price means no contract rate matches — roll the cargo back so
|
||||||
// the instance stays CLEARANCE_READY and can be completed again once
|
// the instance stays CLEARANCE_READY and can be completed again once
|
||||||
// the contract rates are fixed (the clearance work is not lost).
|
// the contract rates are fixed (the clearance work is not lost). A
|
||||||
if (!(computed.totalAmount > 0)) {
|
// pricing hard block (e.g. one of two container sizes has no rate)
|
||||||
|
// rolls back the same way: a partially-priced total is positive but
|
||||||
|
// the booking must not proceed.
|
||||||
|
if (!(computed.totalAmount > 0) || computed.hardBlocked.length > 0) {
|
||||||
await this.bookingsRepository.deleteContainers(booking.id);
|
await this.bookingsRepository.deleteContainers(booking.id);
|
||||||
await this.bookingsRepository.update(booking.id, {
|
await this.bookingsRepository.update(booking.id, {
|
||||||
cargoTotalWeightVgm: 0,
|
cargoTotalWeightVgm: 0,
|
||||||
} as never);
|
} as never);
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
'Booking price came out as 0 — no contract rate matches this ' +
|
computed.hardBlocked.length > 0
|
||||||
'route/cargo. Set the contract rate and try again.',
|
? computed.hardBlocked.join('; ')
|
||||||
|
: 'Booking price came out as 0 — no contract rate matches this ' +
|
||||||
|
'route/cargo. Set the contract rate and try again.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
await this.bookingsRepository.update(booking.id, {
|
await this.bookingsRepository.update(booking.id, {
|
||||||
@@ -1785,6 +1796,10 @@ export class ContractBookingService {
|
|||||||
// pricing service derives wagon counts from the in-memory lines.
|
// pricing service derives wagon counts from the in-memory lines.
|
||||||
const route = await this.resolveRoute(contract, dto.contractRouteId);
|
const route = await this.resolveRoute(contract, dto.contractRouteId);
|
||||||
const previewBooking = Object.assign(new Booking(), {
|
const previewBooking = Object.assign(new Booking(), {
|
||||||
|
// contractId makes the preview price off the contract's frozen rate
|
||||||
|
// snapshots exactly like the persisted booking will — without it the
|
||||||
|
// preview total is 0 on a leg with no live rate and the form blocks.
|
||||||
|
contractId: contract.id,
|
||||||
freightType: contract.freightType,
|
freightType: contract.freightType,
|
||||||
tradeDirection: contract.tradeDirection,
|
tradeDirection: contract.tradeDirection,
|
||||||
paymentCurrency: contract.paymentCurrency,
|
paymentCurrency: contract.paymentCurrency,
|
||||||
@@ -1892,7 +1907,10 @@ export class ContractBookingService {
|
|||||||
overweightSurchargeAmount,
|
overweightSurchargeAmount,
|
||||||
currency: computed.currency,
|
currency: computed.currency,
|
||||||
pairingErrors,
|
pairingErrors,
|
||||||
capacityErrors: [...scopeErrors, ...capacityErrors],
|
// Pricing hard blocks (missing rate for a container size / requested
|
||||||
|
// service) ride the capacity-errors channel so the form hard-blocks in
|
||||||
|
// the preview instead of failing at the create call.
|
||||||
|
capacityErrors: [...scopeErrors, ...capacityErrors, ...computed.hardBlocked],
|
||||||
containerClashErrors,
|
containerClashErrors,
|
||||||
spaceErrors,
|
spaceErrors,
|
||||||
lineItems: computed.lineItems,
|
lineItems: computed.lineItems,
|
||||||
|
|||||||
@@ -196,7 +196,10 @@ export class ContractsController {
|
|||||||
@CurrentUser() user: TCurrentUser,
|
@CurrentUser() user: TCurrentUser,
|
||||||
) {
|
) {
|
||||||
// Staff see every contract; customers are force-scoped to their own company.
|
// Staff see every contract; customers are force-scoped to their own company.
|
||||||
if (hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
|
if (
|
||||||
|
hasFreightPermission(user, FREIGHT_PERMS.bookings.view) ||
|
||||||
|
hasFreightPermission(user, FREIGHT_PERMS.contracts.view)
|
||||||
|
) {
|
||||||
return this.contractsService.findAll(filter);
|
return this.contractsService.findAll(filter);
|
||||||
}
|
}
|
||||||
const userId = user?.id;
|
const userId = user?.id;
|
||||||
@@ -273,7 +276,8 @@ export class ContractsController {
|
|||||||
if (
|
if (
|
||||||
!hasFreightPermission(user, FREIGHT_PERMS.bookings.view) &&
|
!hasFreightPermission(user, FREIGHT_PERMS.bookings.view) &&
|
||||||
!hasFreightPermission(user, FREIGHT_PERMS.bookings.clearanceView) &&
|
!hasFreightPermission(user, FREIGHT_PERMS.bookings.clearanceView) &&
|
||||||
!hasFreightPermission(user, FREIGHT_PERMS.bookings.reviewDocuments)
|
!hasFreightPermission(user, FREIGHT_PERMS.bookings.reviewDocuments) &&
|
||||||
|
!hasFreightPermission(user, FREIGHT_PERMS.contracts.view)
|
||||||
) {
|
) {
|
||||||
await this.contractsService.assertCustomerCanAccessContract(user?.id, contract);
|
await this.contractsService.assertCustomerCanAccessContract(user?.id, contract);
|
||||||
}
|
}
|
||||||
@@ -475,7 +479,10 @@ export class ContractsController {
|
|||||||
@CurrentUser() user: TCurrentUser,
|
@CurrentUser() user: TCurrentUser,
|
||||||
) {
|
) {
|
||||||
const contract = await this.contractsService.findById(id);
|
const contract = await this.contractsService.findById(id);
|
||||||
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
|
if (
|
||||||
|
!hasFreightPermission(user, FREIGHT_PERMS.bookings.view) &&
|
||||||
|
!hasFreightPermission(user, FREIGHT_PERMS.contracts.view)
|
||||||
|
) {
|
||||||
await this.contractsService.assertCustomerCanAccessContract(user?.id, contract);
|
await this.contractsService.assertCustomerCanAccessContract(user?.id, contract);
|
||||||
}
|
}
|
||||||
const { view, html, signatures } =
|
const { view, html, signatures } =
|
||||||
@@ -510,7 +517,10 @@ export class ContractsController {
|
|||||||
@Res() res: Response,
|
@Res() res: Response,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const contract = await this.contractsService.findById(id);
|
const contract = await this.contractsService.findById(id);
|
||||||
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
|
if (
|
||||||
|
!hasFreightPermission(user, FREIGHT_PERMS.bookings.view) &&
|
||||||
|
!hasFreightPermission(user, FREIGHT_PERMS.contracts.view)
|
||||||
|
) {
|
||||||
await this.contractsService.assertCustomerCanAccessContract(user?.id, contract);
|
await this.contractsService.assertCustomerCanAccessContract(user?.id, contract);
|
||||||
}
|
}
|
||||||
const { stream, record } = await this.transitionService.streamContractPdf(id);
|
const { stream, record } = await this.transitionService.streamContractPdf(id);
|
||||||
@@ -566,9 +576,12 @@ export class ContractsController {
|
|||||||
@CurrentUser() user: TCurrentUser,
|
@CurrentUser() user: TCurrentUser,
|
||||||
) {
|
) {
|
||||||
// H12(c): a customer may only renew a contract their company owns. Staff
|
// H12(c): a customer may only renew a contract their company owns. Staff
|
||||||
// with bookings.view bypass, mirroring getContractView/downloadContractDocument.
|
// with bookings.view/contracts.view bypass, mirroring getContractView/downloadContractDocument.
|
||||||
const contract = await this.contractsService.findById(id);
|
const contract = await this.contractsService.findById(id);
|
||||||
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
|
if (
|
||||||
|
!hasFreightPermission(user, FREIGHT_PERMS.bookings.view) &&
|
||||||
|
!hasFreightPermission(user, FREIGHT_PERMS.contracts.view)
|
||||||
|
) {
|
||||||
await this.contractsService.assertCustomerCanAccessContract(user?.id, contract);
|
await this.contractsService.assertCustomerCanAccessContract(user?.id, contract);
|
||||||
}
|
}
|
||||||
return this.transitionService.renew(id, resolveAuthUserId(user));
|
return this.transitionService.renew(id, resolveAuthUserId(user));
|
||||||
@@ -592,9 +605,12 @@ export class ContractsController {
|
|||||||
@UploadedFiles() files: Express.Multer.File[],
|
@UploadedFiles() files: Express.Multer.File[],
|
||||||
) {
|
) {
|
||||||
// H12(c): only the owning company's customer may upload clearance docs.
|
// H12(c): only the owning company's customer may upload clearance docs.
|
||||||
// Staff with bookings.view bypass, mirroring the other contract handlers.
|
// Staff with bookings.view/contracts.view bypass, mirroring the other contract handlers.
|
||||||
const contract = await this.contractsService.findById(id);
|
const contract = await this.contractsService.findById(id);
|
||||||
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
|
if (
|
||||||
|
!hasFreightPermission(user, FREIGHT_PERMS.bookings.view) &&
|
||||||
|
!hasFreightPermission(user, FREIGHT_PERMS.contracts.view)
|
||||||
|
) {
|
||||||
await this.contractsService.assertCustomerCanAccessContract(user?.id, contract);
|
await this.contractsService.assertCustomerCanAccessContract(user?.id, contract);
|
||||||
}
|
}
|
||||||
return this.clearanceService.uploadDocuments(id, files ?? []);
|
return this.clearanceService.uploadDocuments(id, files ?? []);
|
||||||
|
|||||||
@@ -12,8 +12,6 @@ import { YardCountry } from '@edr/types';
|
|||||||
|
|
||||||
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
|
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
|
||||||
import { CompaniesService } from '../companies/companies.service';
|
import { CompaniesService } from '../companies/companies.service';
|
||||||
import { ProfileType } from '../companies/entities/company-profile.entity';
|
|
||||||
import { CompanyStatus } from '../companies/entities/company.entity';
|
|
||||||
import { ServiceType } from '../rule-engine/entities/service-type.entity';
|
import { ServiceType } from '../rule-engine/entities/service-type.entity';
|
||||||
import { Yard } from '../rule-engine/entities/yard.entity';
|
import { Yard } from '../rule-engine/entities/yard.entity';
|
||||||
import { FilesService } from '../files/files.service';
|
import { FilesService } from '../files/files.service';
|
||||||
@@ -181,11 +179,7 @@ export class ContractsService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
const { company } = await this.companiesService.getCompanyInfoByUserId(userId);
|
const { company } = await this.companiesService.getCompanyInfoByUserId(userId);
|
||||||
if (company.status !== CompanyStatus.Active) {
|
this.companiesService.assertCompanyActiveFor(company, 'contracts');
|
||||||
throw new ForbiddenException(
|
|
||||||
"Your company is awaiting approval — you can't create contracts yet.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
companyId = company.id;
|
companyId = company.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,31 +187,31 @@ export class ContractsService {
|
|||||||
this.assertRouteShape(dto.contractKind, dto.routes);
|
this.assertRouteShape(dto.contractKind, dto.routes);
|
||||||
await this.assertRoutesMatchDirection(dto.tradeDirection, dto.routes);
|
await this.assertRoutesMatchDirection(dto.tradeDirection, dto.routes);
|
||||||
|
|
||||||
// Stamp the operational profile (importer/exporter) for portal scoping.
|
// Stamp the operational profile for portal scoping. A forwarder contract
|
||||||
|
// pins its profile explicitly (trade direction can't tell it apart from a
|
||||||
|
// direct import/export); everything else resolves from the trade direction.
|
||||||
let companyProfileId: string | null = null;
|
let companyProfileId: string | null = null;
|
||||||
if (!isGovernment && companyId) {
|
if (!isGovernment && companyId) {
|
||||||
let fallbackType: ProfileType | null = null;
|
if (dto.companyProfileId) {
|
||||||
if (userId) {
|
const profile =
|
||||||
try {
|
await this.companiesService.getActiveCompanyProfileForBooking(
|
||||||
const { profile } =
|
companyId,
|
||||||
await this.companiesService.getCompanyInfoByUserId(userId);
|
dto.companyProfileId,
|
||||||
fallbackType = profile.activeProfileType ?? null;
|
);
|
||||||
} catch {
|
companyProfileId = profile.id;
|
||||||
// No profile (e.g. staff creating on behalf) — fall back to mapping.
|
} else {
|
||||||
}
|
companyProfileId =
|
||||||
}
|
await this.companiesService.resolveCompanyProfileIdForBooking(
|
||||||
companyProfileId =
|
companyId,
|
||||||
await this.companiesService.resolveCompanyProfileIdForBooking(
|
dto.tradeDirection,
|
||||||
companyId,
|
);
|
||||||
dto.tradeDirection,
|
|
||||||
fallbackType,
|
|
||||||
);
|
|
||||||
|
|
||||||
const customerSelfBooking = !dto.companyId && !!userId;
|
const customerSelfBooking = !dto.companyId && !!userId;
|
||||||
if (customerSelfBooking && companyProfileId) {
|
if (customerSelfBooking && companyProfileId) {
|
||||||
await this.companiesService.assertCompanyProfileApprovedForBooking(
|
await this.companiesService.assertCompanyProfileApprovedForBooking(
|
||||||
companyProfileId,
|
companyProfileId,
|
||||||
);
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -124,6 +124,16 @@ export class CreateContractDto {
|
|||||||
@IsUUID()
|
@IsUUID()
|
||||||
companyId?: string;
|
companyId?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
format: 'uuid',
|
||||||
|
description:
|
||||||
|
'Explicit company profile to stamp the contract to (a forwarder contract); ' +
|
||||||
|
'commercial contracts otherwise auto-resolve from trade direction.',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
companyProfileId?: string;
|
||||||
|
|
||||||
@ApiProperty({ enum: CONTRACT_KINDS, description: 'ONE_TIME | GENERAL' })
|
@ApiProperty({ enum: CONTRACT_KINDS, description: 'ONE_TIME | GENERAL' })
|
||||||
@IsIn([...CONTRACT_KINDS])
|
@IsIn([...CONTRACT_KINDS])
|
||||||
contractKind!: string;
|
contractKind!: string;
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export const INCIDENT_TYPES = [
|
|||||||
'CONTAINER_OPENED',
|
'CONTAINER_OPENED',
|
||||||
'CONTAINER_DAMAGED',
|
'CONTAINER_DAMAGED',
|
||||||
'FLUID_LEAKING',
|
'FLUID_LEAKING',
|
||||||
|
'OTHER',
|
||||||
] as const;
|
] as const;
|
||||||
export type IncidentType = (typeof INCIDENT_TYPES)[number];
|
export type IncidentType = (typeof INCIDENT_TYPES)[number];
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { BadRequestException, Injectable, Logger, NotFoundException } from '@nes
|
|||||||
import { FindOptionsWhere, In, IsNull, Not } from 'typeorm';
|
import { FindOptionsWhere, In, IsNull, Not } from 'typeorm';
|
||||||
import { InjectDataSource } from '@nestjs/typeorm';
|
import { InjectDataSource } from '@nestjs/typeorm';
|
||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
|
import { attachMileFinancials } from '../../common/mile-financials.util';
|
||||||
import { VehicleAvailability } from '../vehicles/entities/vehicle.entity';
|
import { VehicleAvailability } from '../vehicles/entities/vehicle.entity';
|
||||||
import { BookingsRepository } from "../bookings/bookings.repository";
|
import { BookingsRepository } from "../bookings/bookings.repository";
|
||||||
import { DriversService } from "../drivers/drivers.service";
|
import { DriversService } from "../drivers/drivers.service";
|
||||||
@@ -66,6 +67,7 @@ export class FirstMileService {
|
|||||||
for (const r of records) {
|
for (const r of records) {
|
||||||
(r as FirstMile & { invoice?: unknown }).invoice = byId.get(r.id) ?? null;
|
(r as FirstMile & { invoice?: unknown }).invoice = byId.get(r.id) ?? null;
|
||||||
}
|
}
|
||||||
|
await attachMileFinancials(this.dataSource, records, 'FIRST_MILE');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Resolve a vehicle's driver + human labels, for stamping mile events onto
|
/** Resolve a vehicle's driver + human labels, for stamping mile events onto
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
SELF_HAUL_CONFLICT_MESSAGE,
|
SELF_HAUL_CONFLICT_MESSAGE,
|
||||||
usesEdrMileService,
|
usesEdrMileService,
|
||||||
} from '../../common/mile-haulage.util';
|
} from '../../common/mile-haulage.util';
|
||||||
|
import { attachMileFinancials } from '../../common/mile-financials.util';
|
||||||
import {
|
import {
|
||||||
assertBulkTonnageRemains,
|
assertBulkTonnageRemains,
|
||||||
assertTruckCountWithinContainers,
|
assertTruckCountWithinContainers,
|
||||||
@@ -88,6 +89,7 @@ export class LastMileService {
|
|||||||
for (const r of records) {
|
for (const r of records) {
|
||||||
(r as LastMile & { invoice?: unknown }).invoice = byId.get(r.id) ?? null;
|
(r as LastMile & { invoice?: unknown }).invoice = byId.get(r.id) ?? null;
|
||||||
}
|
}
|
||||||
|
await attachMileFinancials(this.dataSource, records, 'LAST_MILE');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Resolve a vehicle's driver + human labels, for stamping mile events onto
|
/** Resolve a vehicle's driver + human labels, for stamping mile events onto
|
||||||
@@ -333,6 +335,8 @@ export class LastMileService {
|
|||||||
driverPhone: string | null;
|
driverPhone: string | null;
|
||||||
truckType: string | null;
|
truckType: string | null;
|
||||||
containerNumber: string | null;
|
containerNumber: string | null;
|
||||||
|
arrivedAt: string | null;
|
||||||
|
departedAt: string | null;
|
||||||
}>
|
}>
|
||||||
> {
|
> {
|
||||||
const [lm] = await this.lastMileRepository.findAll({
|
const [lm] = await this.lastMileRepository.findAll({
|
||||||
@@ -347,9 +351,11 @@ export class LastMileService {
|
|||||||
? lm.vehicleAssignments.map((va) => ({
|
? lm.vehicleAssignments.map((va) => ({
|
||||||
vehicle: va.vehicle,
|
vehicle: va.vehicle,
|
||||||
containerNumber: va.containerNumber ?? null,
|
containerNumber: va.containerNumber ?? null,
|
||||||
|
arrivedAt: va.arrivedAt ?? null,
|
||||||
|
departedAt: va.departedAt ?? null,
|
||||||
}))
|
}))
|
||||||
: lm.vehicle
|
: lm.vehicle
|
||||||
? [{ vehicle: lm.vehicle, containerNumber: null }]
|
? [{ vehicle: lm.vehicle, containerNumber: null, arrivedAt: null, departedAt: null }]
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
const out: Array<{
|
const out: Array<{
|
||||||
@@ -361,8 +367,10 @@ export class LastMileService {
|
|||||||
driverPhone: string | null;
|
driverPhone: string | null;
|
||||||
truckType: string | null;
|
truckType: string | null;
|
||||||
containerNumber: string | null;
|
containerNumber: string | null;
|
||||||
|
arrivedAt: string | null;
|
||||||
|
departedAt: string | null;
|
||||||
}> = [];
|
}> = [];
|
||||||
for (const { vehicle, containerNumber } of sources) {
|
for (const { vehicle, containerNumber, arrivedAt, departedAt } of sources) {
|
||||||
if (!vehicle) continue;
|
if (!vehicle) continue;
|
||||||
let driverName = vehicle.assignedDriverName ?? null;
|
let driverName = vehicle.assignedDriverName ?? null;
|
||||||
let driverLicense: string | null = null;
|
let driverLicense: string | null = null;
|
||||||
@@ -386,6 +394,8 @@ export class LastMileService {
|
|||||||
driverPhone,
|
driverPhone,
|
||||||
truckType: vehicle.vehicleType || null,
|
truckType: vehicle.vehicleType || null,
|
||||||
containerNumber,
|
containerNumber,
|
||||||
|
arrivedAt: arrivedAt ? new Date(arrivedAt).toISOString() : null,
|
||||||
|
departedAt: departedAt ? new Date(departedAt).toISOString() : null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Test, TestingModule } from '@nestjs/testing';
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
import { OtpController } from './otp.controller';
|
import { OtpController } from './otp.controller';
|
||||||
|
import { OtpService } from './otp.service';
|
||||||
|
|
||||||
describe('OtpController', () => {
|
describe('OtpController', () => {
|
||||||
let controller: OtpController;
|
let controller: OtpController;
|
||||||
@@ -7,6 +8,9 @@ describe('OtpController', () => {
|
|||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
controllers: [OtpController],
|
controllers: [OtpController],
|
||||||
|
providers: [
|
||||||
|
{ provide: OtpService, useValue: { send: jest.fn(), verify: jest.fn() } },
|
||||||
|
],
|
||||||
}).compile();
|
}).compile();
|
||||||
|
|
||||||
controller = module.get<OtpController>(OtpController);
|
controller = module.get<OtpController>(OtpController);
|
||||||
|
|||||||
@@ -1,33 +1,46 @@
|
|||||||
import { OtpService, normalizeOtpTarget } from './otp.service';
|
import { OtpService, isDomesticPhone, normalizeOtpTarget } from "./otp.service";
|
||||||
|
|
||||||
describe('normalizeOtpTarget', () => {
|
describe("normalizeOtpTarget", () => {
|
||||||
it('canonicalises Ethiopian forms to one E.164 key', () => {
|
it("canonicalises Ethiopian forms to one E.164 key", () => {
|
||||||
const forms = ['+251986680099', '251986680099', '0986680099', '+251 98 668 0099'];
|
const forms = [
|
||||||
|
"+251986680099",
|
||||||
|
"251986680099",
|
||||||
|
"0986680099",
|
||||||
|
"+251 98 668 0099",
|
||||||
|
];
|
||||||
const keys = forms.map((phone) => normalizeOtpTarget({ phone }).phone);
|
const keys = forms.map((phone) => normalizeOtpTarget({ phone }).phone);
|
||||||
expect(new Set(keys)).toEqual(new Set(['+251986680099']));
|
expect(new Set(keys)).toEqual(new Set(["+251986680099"]));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('maps local 07… mobile to +2517…', () => {
|
it("canonicalises email case and surrounding whitespace to one key", () => {
|
||||||
expect(normalizeOtpTarget({ phone: '0712345678' }).phone).toBe('+251712345678');
|
const forms = ["a@b.com", "A@B.com", " a@B.COM ", "A@b.COM"];
|
||||||
});
|
|
||||||
|
|
||||||
it('canonicalises email case and surrounding whitespace to one key', () => {
|
|
||||||
const forms = ['a@b.com', 'A@B.com', ' a@B.COM ', 'A@b.COM'];
|
|
||||||
const keys = forms.map((email) => normalizeOtpTarget({ email }).email);
|
const keys = forms.map((email) => normalizeOtpTarget({ email }).email);
|
||||||
expect(new Set(keys)).toEqual(new Set(['a@b.com']));
|
expect(new Set(keys)).toEqual(new Set(["a@b.com"]));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps an already-normalised email stable (idempotent)', () => {
|
it("keeps an already-normalised email stable (idempotent)", () => {
|
||||||
const once = normalizeOtpTarget({ email: ' User@Example.COM ' }).email!;
|
const once = normalizeOtpTarget({ email: " User@Example.COM " }).email!;
|
||||||
expect(normalizeOtpTarget({ email: once }).email).toBe(once);
|
expect(normalizeOtpTarget({ email: once }).email).toBe(once);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps an already-normalised number stable (idempotent)', () => {
|
it("keeps an already-normalised number stable (idempotent)", () => {
|
||||||
const once = normalizeOtpTarget({ phone: '0986680099' }).phone!;
|
const once = normalizeOtpTarget({ phone: "0986680099" }).phone!;
|
||||||
expect(normalizeOtpTarget({ phone: once }).phone).toBe(once);
|
expect(normalizeOtpTarget({ phone: once }).phone).toBe(once);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("isDomesticPhone", () => {
|
||||||
|
it.each(["+251986680099", "0986680099", "251986680099"])(
|
||||||
|
"accepts Ethiopian mobile form %s",
|
||||||
|
(phone) => expect(isDomesticPhone(phone)).toBe(true),
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each(["+14155550123", "+447911123456", "0712345678", "+2519866", "12345"])(
|
||||||
|
"rejects non-domestic or malformed %s",
|
||||||
|
(phone) => expect(isDomesticPhone(phone)).toBe(false),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
interface FakeRow {
|
interface FakeRow {
|
||||||
id: string;
|
id: string;
|
||||||
phone?: string;
|
phone?: string;
|
||||||
@@ -51,7 +64,8 @@ function makeService(
|
|||||||
let nextId = 1;
|
let nextId = 1;
|
||||||
|
|
||||||
const matches = (row: FakeRow, t: { phone?: string; email?: string }) =>
|
const matches = (row: FakeRow, t: { phone?: string; email?: string }) =>
|
||||||
(!!t.email && row.email === t.email) || (!!t.phone && row.phone === t.phone);
|
(!!t.email && row.email === t.email) ||
|
||||||
|
(!!t.phone && row.phone === t.phone);
|
||||||
|
|
||||||
const repo = {
|
const repo = {
|
||||||
findByTarget: jest.fn(
|
findByTarget: jest.fn(
|
||||||
@@ -89,30 +103,30 @@ function makeService(
|
|||||||
return { service, sms, email, rows: () => rows };
|
return { service, sms, email, rows: () => rows };
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('OtpService — send/verify agree across phone formats', () => {
|
describe("OtpService — send/verify agree across phone formats", () => {
|
||||||
it('verifies a code sent to +251… when verify is called with 09…', async () => {
|
it("verifies a code sent to +251… when verify is called with 09…", async () => {
|
||||||
const { service, rows } = makeService();
|
const { service, rows } = makeService();
|
||||||
await service.sendOtp({ phone: '+251986680099' });
|
await service.sendOtp({ phone: "+251986680099" });
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
service.verifyOtpForAction({ phone: '0986680099' }, rows()[0]!.otp),
|
service.verifyOtpForAction({ phone: "0986680099" }, rows()[0]!.otp),
|
||||||
).resolves.toEqual({ success: true });
|
).resolves.toEqual({ success: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('verifies a code sent to User@X.com when verify is called with user@x.com', async () => {
|
it("verifies a code sent to User@X.com when verify is called with user@x.com", async () => {
|
||||||
const { service, rows } = makeService();
|
const { service, rows } = makeService();
|
||||||
await service.sendOtp({ email: ' User@Example.COM ' });
|
await service.sendOtp({ email: " User@Example.COM " });
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
service.verifyOtpForAction({ email: 'user@example.com' }, rows()[0]!.otp),
|
service.verifyOtpForAction({ email: "user@example.com" }, rows()[0]!.otp),
|
||||||
).resolves.toEqual({ success: true });
|
).resolves.toEqual({ success: true });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('OtpService — dual-channel send', () => {
|
describe("OtpService — dual-channel send", () => {
|
||||||
const both = { phone: '0986680099', email: 'User@Example.COM' };
|
const both = { phone: "0986680099", email: "User@Example.COM" };
|
||||||
|
|
||||||
it('sends ONE code to both transports', async () => {
|
it("sends ONE code to both transports", async () => {
|
||||||
const { service, sms, email, rows } = makeService();
|
const { service, sms, email, rows } = makeService();
|
||||||
await service.sendOtp(both);
|
await service.sendOtp(both);
|
||||||
|
|
||||||
@@ -122,72 +136,95 @@ describe('OtpService — dual-channel send', () => {
|
|||||||
// Same secret on both messages — the user types whichever arrives first.
|
// Same secret on both messages — the user types whichever arrives first.
|
||||||
expect(sms.sendSms).toHaveBeenCalledWith(
|
expect(sms.sendSms).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
to: '+251986680099',
|
to: "+251986680099",
|
||||||
message: expect.stringContaining(otp),
|
message: expect.stringContaining(otp),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
expect(email.sendEmail).toHaveBeenCalledWith(
|
expect(email.sendEmail).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
to: 'user@example.com',
|
to: "user@example.com",
|
||||||
text: expect.stringContaining(otp),
|
text: expect.stringContaining(otp),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
// One row, both channels canonicalised.
|
// One row, both channels canonicalised.
|
||||||
expect(rows()).toHaveLength(1);
|
expect(rows()).toHaveLength(1);
|
||||||
expect(rows()[0]).toMatchObject({
|
expect(rows()[0]).toMatchObject({
|
||||||
phone: '+251986680099',
|
phone: "+251986680099",
|
||||||
email: 'user@example.com',
|
email: "user@example.com",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
['phone alone', { phone: '0986680099' }],
|
["phone alone", { phone: "0986680099" }],
|
||||||
['email alone', { email: 'user@example.com' }],
|
["email alone", { email: "user@example.com" }],
|
||||||
['both', both],
|
["both", both],
|
||||||
])('verifies a dual-channel code when quoted back by %s', async (_label, target) => {
|
])(
|
||||||
const { service, rows } = makeService();
|
"verifies a dual-channel code when quoted back by %s",
|
||||||
await service.sendOtp(both);
|
async (_label, target) => {
|
||||||
|
const { service, rows } = makeService();
|
||||||
|
await service.sendOtp(both);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
service.verifyOtpForAction(target, rows()[0]!.otp),
|
service.verifyOtpForAction(target, rows()[0]!.otp),
|
||||||
).resolves.toEqual({ success: true });
|
).resolves.toEqual({ success: true });
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
it('consuming the code via one channel kills the other', async () => {
|
it("consuming the code via one channel kills the other", async () => {
|
||||||
const { service, rows } = makeService();
|
const { service, rows } = makeService();
|
||||||
await service.sendOtp(both);
|
await service.sendOtp(both);
|
||||||
const otp = rows()[0]!.otp;
|
const otp = rows()[0]!.otp;
|
||||||
|
|
||||||
await service.verifyOtpForAction({ email: 'user@example.com' }, otp);
|
await service.verifyOtpForAction({ email: "user@example.com" }, otp);
|
||||||
|
|
||||||
// Single-use is per-code, not per-channel: the phone half must be dead too.
|
// Single-use is per-code, not per-channel: the phone half must be dead too.
|
||||||
await expect(
|
await expect(
|
||||||
service.verifyOtpForAction({ phone: '0986680099' }, otp),
|
service.verifyOtpForAction({ phone: "0986680099" }, otp),
|
||||||
).rejects.toThrow(/No verification code was requested/);
|
).rejects.toThrow(/No verification code was requested/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('replaces an overlapping single-channel row instead of colliding with it', async () => {
|
it("replaces an overlapping single-channel row instead of colliding with it", async () => {
|
||||||
const { service, rows } = makeService();
|
const { service, rows } = makeService();
|
||||||
// A pending signup code on the phone only, then a dual-channel send.
|
// A pending signup code on the phone only, then a dual-channel send.
|
||||||
await service.sendOtp({ phone: '0986680099' });
|
await service.sendOtp({ phone: "0986680099" });
|
||||||
await service.sendOtp(both);
|
await service.sendOtp(both);
|
||||||
|
|
||||||
expect(rows()).toHaveLength(1);
|
expect(rows()).toHaveLength(1);
|
||||||
expect(rows()[0]).toMatchObject({ email: 'user@example.com' });
|
expect(rows()[0]).toMatchObject({ email: "user@example.com" });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('degrades to one channel when the account has only one contact', async () => {
|
it("degrades to one channel when the account has only one contact", async () => {
|
||||||
const { service, sms, email } = makeService();
|
const { service, sms, email } = makeService();
|
||||||
await service.sendOtp({ phone: '0986680099' });
|
await service.sendOtp({ phone: "0986680099" });
|
||||||
|
|
||||||
expect(sms.sendSms).toHaveBeenCalledTimes(1);
|
expect(sms.sendSms).toHaveBeenCalledTimes(1);
|
||||||
expect(email.sendEmail).not.toHaveBeenCalled();
|
expect(email.sendEmail).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('still succeeds when one transport throws', async () => {
|
it("skips SMS for a foreign number when email is available", async () => {
|
||||||
|
const { service, sms, email, rows } = makeService();
|
||||||
|
await service.sendOtp({ phone: "+14155550123", email: "user@example.com" });
|
||||||
|
|
||||||
|
// The gateway is domestic-only — email is the delivery route, but the
|
||||||
|
// foreign phone stays on the row so verify still matches either channel.
|
||||||
|
expect(sms.sendSms).not.toHaveBeenCalled();
|
||||||
|
expect(email.sendEmail).toHaveBeenCalledTimes(1);
|
||||||
|
await expect(
|
||||||
|
service.verifyOtpForAction({ phone: "+14155550123" }, rows()[0]!.otp),
|
||||||
|
).resolves.toEqual({ success: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still attempts SMS for a foreign number when it is the only channel", async () => {
|
||||||
|
const { service, sms } = makeService();
|
||||||
|
await service.sendOtp({ phone: "+14155550123" });
|
||||||
|
|
||||||
|
expect(sms.sendSms).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still succeeds when one transport throws", async () => {
|
||||||
const { service, rows } = makeService({
|
const { service, rows } = makeService({
|
||||||
sms: async () => {
|
sms: async () => {
|
||||||
throw new Error('broker down');
|
throw new Error("broker down");
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -197,24 +234,24 @@ describe('OtpService — dual-channel send', () => {
|
|||||||
});
|
});
|
||||||
// The code is live and verifiable on the channel that worked.
|
// The code is live and verifiable on the channel that worked.
|
||||||
await expect(
|
await expect(
|
||||||
service.verifyOtpForAction({ email: 'user@example.com' }, rows()[0]!.otp),
|
service.verifyOtpForAction({ email: "user@example.com" }, rows()[0]!.otp),
|
||||||
).resolves.toEqual({ success: true });
|
).resolves.toEqual({ success: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('fails the request when every transport throws', async () => {
|
it("fails the request when every transport throws", async () => {
|
||||||
const { service } = makeService({
|
const { service } = makeService({
|
||||||
sms: async () => {
|
sms: async () => {
|
||||||
throw new Error('broker down');
|
throw new Error("broker down");
|
||||||
},
|
},
|
||||||
email: async () => {
|
email: async () => {
|
||||||
throw new Error('broker down');
|
throw new Error("broker down");
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(service.sendOtp(both)).rejects.toThrow('Failed to send OTP');
|
await expect(service.sendOtp(both)).rejects.toThrow("Failed to send OTP");
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shares one brute-force budget across both channels', async () => {
|
it("shares one brute-force budget across both channels", async () => {
|
||||||
const { service, rows } = makeService();
|
const { service, rows } = makeService();
|
||||||
await service.sendOtp(both);
|
await service.sendOtp(both);
|
||||||
const otp = rows()[0]!.otp;
|
const otp = rows()[0]!.otp;
|
||||||
@@ -222,17 +259,17 @@ describe('OtpService — dual-channel send', () => {
|
|||||||
// Alternating channels must not hand the attacker two independent budgets:
|
// Alternating channels must not hand the attacker two independent budgets:
|
||||||
// 5 wrong guesses in total burn the code regardless of how they are split.
|
// 5 wrong guesses in total burn the code regardless of how they are split.
|
||||||
for (const target of [
|
for (const target of [
|
||||||
{ phone: '0986680099' },
|
{ phone: "0986680099" },
|
||||||
{ email: 'user@example.com' },
|
{ email: "user@example.com" },
|
||||||
{ phone: '0986680099' },
|
{ phone: "0986680099" },
|
||||||
{ email: 'user@example.com' },
|
{ email: "user@example.com" },
|
||||||
]) {
|
]) {
|
||||||
await expect(service.verifyOtpForAction(target, '000000')).rejects.toThrow(
|
await expect(
|
||||||
'Invalid verification code',
|
service.verifyOtpForAction(target, "000000"),
|
||||||
);
|
).rejects.toThrow("Invalid verification code");
|
||||||
}
|
}
|
||||||
await expect(
|
await expect(
|
||||||
service.verifyOtpForAction({ email: 'user@example.com' }, '000000'),
|
service.verifyOtpForAction({ email: "user@example.com" }, "000000"),
|
||||||
).rejects.toThrow(/Too many incorrect attempts/);
|
).rejects.toThrow(/Too many incorrect attempts/);
|
||||||
|
|
||||||
// Burned: even the correct code no longer works.
|
// Burned: even the correct code no longer works.
|
||||||
|
|||||||
@@ -36,9 +36,9 @@ function channelsOf(target: OtpTarget): Array<"email" | "sms"> {
|
|||||||
*/
|
*/
|
||||||
function normalizePhone(rawPhone: string): string {
|
function normalizePhone(rawPhone: string): string {
|
||||||
const raw = rawPhone.trim();
|
const raw = rawPhone.trim();
|
||||||
const digits = raw.replace(/[^\d+]/g, '');
|
const digits = raw.replace(/[^\d+]/g, "");
|
||||||
if (digits.startsWith('+')) return digits;
|
if (digits.startsWith("+")) return digits;
|
||||||
const bare = digits.replace(/^0+/, '');
|
const bare = digits.replace(/^0+/, "");
|
||||||
if (/^251\d{9}$/.test(digits)) return `+${digits}`;
|
if (/^251\d{9}$/.test(digits)) return `+${digits}`;
|
||||||
if (/^9\d{8}$|^7\d{8}$/.test(bare)) return `+251${bare}`;
|
if (/^9\d{8}$|^7\d{8}$/.test(bare)) return `+251${bare}`;
|
||||||
// Unknown shape (foreign number, already-clean intl without +) — prefix + if
|
// Unknown shape (foreign number, already-clean intl without +) — prefix + if
|
||||||
@@ -46,6 +46,16 @@ function normalizePhone(rawPhone: string): string {
|
|||||||
return digits.length >= 11 ? `+${digits}` : raw;
|
return digits.length >= 11 ? `+${digits}` : raw;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a phone is an Ethiopian mobile the SMS gateway can actually reach —
|
||||||
|
* the carrier integration is domestic-only, so a send to anything else is
|
||||||
|
* queued and silently lost. Callers use this to fall back to email instead of
|
||||||
|
* pretending an SMS is on its way.
|
||||||
|
*/
|
||||||
|
export function isDomesticPhone(rawPhone: string): boolean {
|
||||||
|
return /^\+2519\d{8}$/.test(normalizePhone(rawPhone));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Canonicalise every channel present on the target. Each field is normalised
|
* Canonicalise every channel present on the target. Each field is normalised
|
||||||
* independently — a dual-channel target must end up with both halves in their
|
* independently — a dual-channel target must end up with both halves in their
|
||||||
@@ -143,6 +153,20 @@ export class OtpService {
|
|||||||
// /otp/verify routes (a NestJS ThrottlerGuard / @Throttle) — none exists
|
// /otp/verify routes (a NestJS ThrottlerGuard / @Throttle) — none exists
|
||||||
// in the codebase yet.
|
// in the codebase yet.
|
||||||
|
|
||||||
|
// A foreign number is unreachable by the domestic-only SMS gateway; when
|
||||||
|
// email is also on the target, go email-only rather than queueing an SMS
|
||||||
|
// that will never arrive. With no email the SMS attempt stays — it is the
|
||||||
|
// only route there is.
|
||||||
|
const smsPhone =
|
||||||
|
target.phone && (!target.email || isDomesticPhone(target.phone))
|
||||||
|
? target.phone
|
||||||
|
: null;
|
||||||
|
if (target.phone && !smsPhone) {
|
||||||
|
this.logger.warn(
|
||||||
|
`otp.dispatch.sms-skipped target=${label} — non-domestic phone, delivering via email only`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Fan out to every channel the target has, independently: one transport
|
// Fan out to every channel the target has, independently: one transport
|
||||||
// being down must not suppress the other, which is the whole point of
|
// being down must not suppress the other, which is the whole point of
|
||||||
// sending to both. Each helper swallows its own failure so a rejected
|
// sending to both. Each helper swallows its own failure so a rejected
|
||||||
@@ -150,16 +174,14 @@ export class OtpService {
|
|||||||
const outcomes = (
|
const outcomes = (
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
target.email ? this.dispatchEmail(target.email, otp) : null,
|
target.email ? this.dispatchEmail(target.email, otp) : null,
|
||||||
target.phone ? this.dispatchSms(target.phone, otp) : null,
|
smsPhone ? this.dispatchSms(smsPhone, otp) : null,
|
||||||
])
|
])
|
||||||
).filter((outcome): outcome is DispatchOutcome => outcome !== null);
|
).filter((outcome): outcome is DispatchOutcome => outcome !== null);
|
||||||
|
|
||||||
for (const outcome of outcomes) {
|
for (const outcome of outcomes) {
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`otp.dispatch channel=${outcome.channel} target=${label} queued=${
|
`otp.dispatch channel=${outcome.channel} target=${label} queued=${outcome.queued
|
||||||
outcome.queued
|
} latencyMs=${Date.now() - startedAt}${outcome.error ? ` error=${outcome.error}` : ""
|
||||||
} latencyMs=${Date.now() - startedAt}${
|
|
||||||
outcome.error ? ` error=${outcome.error}` : ""
|
|
||||||
}`,
|
}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -183,8 +205,7 @@ export class OtpService {
|
|||||||
// user who never receives a code — indistinguishable from carrier loss,
|
// user who never receives a code — indistinguishable from carrier loss,
|
||||||
// and the misleading success response makes it look like our side worked.
|
// and the misleading success response makes it look like our side worked.
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`otp.dispatch.dropped channels=${channels.join("+")} target=${label} rabbitmqEnabled=${
|
`otp.dispatch.dropped channels=${channels.join("+")} target=${label} rabbitmqEnabled=${process.env.RABBITMQ_ENABLED ?? "unset"
|
||||||
process.env.RABBITMQ_ENABLED ?? "unset"
|
|
||||||
} — no transport reported hand-off; no code will arrive for this send`,
|
} — no transport reported hand-off; no code will arrive for this send`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -209,8 +230,7 @@ export class OtpService {
|
|||||||
// Log the real cause (DB/SMS/email failure) with its stack so a deployed
|
// Log the real cause (DB/SMS/email failure) with its stack so a deployed
|
||||||
// "Failed to send OTP" 400 is diagnosable from the API logs, not opaque.
|
// "Failed to send OTP" 400 is diagnosable from the API logs, not opaque.
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`otp.dispatch.failed channels=${channels.join("+")} target=${label} latencyMs=${
|
`otp.dispatch.failed channels=${channels.join("+")} target=${label} latencyMs=${Date.now() - startedAt
|
||||||
Date.now() - startedAt
|
|
||||||
}: ${error instanceof Error ? error.message : String(error)}`,
|
}: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
error instanceof Error ? error.stack : undefined,
|
error instanceof Error ? error.stack : undefined,
|
||||||
);
|
);
|
||||||
@@ -270,9 +290,7 @@ export class OtpService {
|
|||||||
* address while printing the credential next to it would buy nothing.
|
* address while printing the credential next to it would buy nothing.
|
||||||
*/
|
*/
|
||||||
private targetLabel(target: OtpTarget): string {
|
private targetLabel(target: OtpTarget): string {
|
||||||
return (
|
return [target.email, target.phone].filter(Boolean).join("+") || "unknown";
|
||||||
[target.email, target.phone].filter(Boolean).join("+") || "unknown"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -288,9 +306,8 @@ export class OtpService {
|
|||||||
) {
|
) {
|
||||||
const line = `otp.verify channels=${channelsOf(target).join(
|
const line = `otp.verify channels=${channelsOf(target).join(
|
||||||
"+",
|
"+",
|
||||||
)} target=${this.targetLabel(target)} mode=${mode} result=${result}${
|
)} target=${this.targetLabel(target)} mode=${mode} result=${result}${detail ? ` ${detail}` : ""
|
||||||
detail ? ` ${detail}` : ""
|
}`;
|
||||||
}`;
|
|
||||||
if (result === "ok") this.logger.log(line);
|
if (result === "ok") this.logger.log(line);
|
||||||
else this.logger.warn(line);
|
else this.logger.warn(line);
|
||||||
}
|
}
|
||||||
@@ -439,7 +456,12 @@ export class OtpService {
|
|||||||
await this.otpRepository.deleteOtp(otpData);
|
await this.otpRepository.deleteOtp(otpData);
|
||||||
this.actionAttempts.delete(key);
|
this.actionAttempts.delete(key);
|
||||||
|
|
||||||
this.logVerify(target, "action", "expired", `ageMs=${ageMs} ttlMs=${ttlMs}`);
|
this.logVerify(
|
||||||
|
target,
|
||||||
|
"action",
|
||||||
|
"expired",
|
||||||
|
`ageMs=${ageMs} ttlMs=${ttlMs}`,
|
||||||
|
);
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
"Verification code has expired. Request a new one.",
|
"Verification code has expired. Request a new one.",
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
IsOptional,
|
IsOptional,
|
||||||
IsEnum,
|
IsEnum,
|
||||||
IsBoolean,
|
IsBoolean,
|
||||||
|
MinLength,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
import { VendorType } from '../entities/vendor.entity';
|
import { VendorType } from '../entities/vendor.entity';
|
||||||
import { AcquisitionType, AcquisitionStatus } from '../entities/asset-acquisition.entity';
|
import { AcquisitionType, AcquisitionStatus } from '../entities/asset-acquisition.entity';
|
||||||
@@ -72,6 +73,11 @@ export class UpdateVendorDto {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class CreateAcquisitionDto {
|
export class CreateAcquisitionDto {
|
||||||
|
/** WHAT was acquired — required so an acquisition can't be saved empty. */
|
||||||
|
@IsString()
|
||||||
|
@MinLength(2)
|
||||||
|
itemName!: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsUUID()
|
@IsUUID()
|
||||||
vehicleId?: string;
|
vehicleId?: string;
|
||||||
@@ -120,6 +126,11 @@ export class CreateAcquisitionDto {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class UpdateAcquisitionDto {
|
export class UpdateAcquisitionDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(2)
|
||||||
|
itemName?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsUUID()
|
@IsUUID()
|
||||||
vehicleId?: string;
|
vehicleId?: string;
|
||||||
|
|||||||
@@ -18,6 +18,12 @@ export enum AcquisitionStatus {
|
|||||||
@Entity({ name: 'asset_acquisitions', schema: 'freight' })
|
@Entity({ name: 'asset_acquisitions', schema: 'freight' })
|
||||||
@Index(['vehicleId', 'acquisitionDate'])
|
@Index(['vehicleId', 'acquisitionDate'])
|
||||||
export class AssetAcquisition extends BaseEntity {
|
export class AssetAcquisition extends BaseEntity {
|
||||||
|
/** WHAT was acquired (vehicle, parts, equipment…) — the asset itself. */
|
||||||
|
@Column({ name: 'item_name', type: 'varchar', length: 200, nullable: true })
|
||||||
|
itemName?: string;
|
||||||
|
|
||||||
|
/** Optional link — only when the acquisition IS a fleet vehicle. Parts and
|
||||||
|
* general procurement stay unlinked so reports don't misattribute them. */
|
||||||
@Column({ name: 'vehicle_id', type: 'uuid', nullable: true })
|
@Column({ name: 'vehicle_id', type: 'uuid', nullable: true })
|
||||||
vehicleId?: string;
|
vehicleId?: string;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { BadRequestException } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { ProcurementService } from './procurement.service';
|
||||||
|
import { AcquisitionType } from './entities/asset-acquisition.entity';
|
||||||
|
|
||||||
|
// PURCHASE acquisitions must not carry lease terms; LEASE/RENTAL may.
|
||||||
|
describe('ProcurementService acquisition lease-field guard', () => {
|
||||||
|
const repo = {
|
||||||
|
createAcquisition: jest.fn(async (dto) => dto),
|
||||||
|
findAcquisitionById: jest.fn(async () => ({ acquisitionType: AcquisitionType.PURCHASE })),
|
||||||
|
updateAcquisition: jest.fn(async (_id, dto) => dto),
|
||||||
|
};
|
||||||
|
const svc = new ProcurementService(repo as never);
|
||||||
|
|
||||||
|
it('rejects a PURCHASE with lease dates', async () => {
|
||||||
|
await expect(
|
||||||
|
svc.createAcquisition({
|
||||||
|
itemName: 'Brake pads',
|
||||||
|
acquisitionType: AcquisitionType.PURCHASE,
|
||||||
|
acquisitionDate: '2026-07-22',
|
||||||
|
leaseStart: '2026-07-01',
|
||||||
|
} as never),
|
||||||
|
).rejects.toThrow(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a LEASE with lease dates and a plain PURCHASE', async () => {
|
||||||
|
await expect(
|
||||||
|
svc.createAcquisition({
|
||||||
|
itemName: 'Rented crane',
|
||||||
|
acquisitionType: AcquisitionType.LEASE,
|
||||||
|
acquisitionDate: '2026-07-22',
|
||||||
|
leaseStart: '2026-07-01',
|
||||||
|
leaseEnd: '2027-07-01',
|
||||||
|
} as never),
|
||||||
|
).resolves.toBeDefined();
|
||||||
|
await expect(
|
||||||
|
svc.createAcquisition({
|
||||||
|
itemName: 'Brake pads',
|
||||||
|
acquisitionType: AcquisitionType.PURCHASE,
|
||||||
|
acquisitionDate: '2026-07-22',
|
||||||
|
} as never),
|
||||||
|
).resolves.toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects adding lease terms to an acquisition that is a PURCHASE', async () => {
|
||||||
|
await expect(
|
||||||
|
svc.updateAcquisition('a1', { monthlyPayment: 500 } as never),
|
||||||
|
).rejects.toThrow(BadRequestException);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||||
import { ProcurementRepository } from './procurement.repository';
|
import { ProcurementRepository } from './procurement.repository';
|
||||||
import { Vendor } from './entities/vendor.entity';
|
import { Vendor } from './entities/vendor.entity';
|
||||||
import { AssetAcquisition } from './entities/asset-acquisition.entity';
|
import { AcquisitionType, AssetAcquisition } from './entities/asset-acquisition.entity';
|
||||||
import { AssetDisposal } from './entities/asset-disposal.entity';
|
import { AssetDisposal } from './entities/asset-disposal.entity';
|
||||||
import {
|
import {
|
||||||
CreateVendorDto,
|
CreateVendorDto,
|
||||||
@@ -51,7 +51,23 @@ export class ProcurementService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---- Acquisitions ----
|
// ---- Acquisitions ----
|
||||||
|
/** Lease terms only make sense on LEASE / RENTAL — a PURCHASE must not carry them. */
|
||||||
|
private assertLeaseFieldsValid(dto: {
|
||||||
|
acquisitionType?: string;
|
||||||
|
leaseStart?: string;
|
||||||
|
leaseEnd?: string;
|
||||||
|
monthlyPayment?: number;
|
||||||
|
}): void {
|
||||||
|
if (dto.acquisitionType !== AcquisitionType.PURCHASE) return;
|
||||||
|
if (dto.leaseStart || dto.leaseEnd || dto.monthlyPayment != null) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Lease start/end and monthly payment are not valid for a PURCHASE acquisition',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async createAcquisition(dto: CreateAcquisitionDto): Promise<AssetAcquisition> {
|
async createAcquisition(dto: CreateAcquisitionDto): Promise<AssetAcquisition> {
|
||||||
|
this.assertLeaseFieldsValid(dto);
|
||||||
return this.procurementRepository.createAcquisition(dto);
|
return this.procurementRepository.createAcquisition(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,6 +80,20 @@ export class ProcurementService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async updateAcquisition(id: string, dto: UpdateAcquisitionDto): Promise<AssetAcquisition | null> {
|
async updateAcquisition(id: string, dto: UpdateAcquisitionDto): Promise<AssetAcquisition | null> {
|
||||||
|
// Validate against the resulting record, not just the patch — switching an
|
||||||
|
// acquisition to PURCHASE must also shed any stored lease terms.
|
||||||
|
const existing = await this.procurementRepository.findAcquisitionById(id);
|
||||||
|
if (existing) {
|
||||||
|
const next = { ...existing, ...dto };
|
||||||
|
if (next.acquisitionType === AcquisitionType.PURCHASE) {
|
||||||
|
this.assertLeaseFieldsValid({
|
||||||
|
acquisitionType: next.acquisitionType,
|
||||||
|
leaseStart: dto.leaseStart,
|
||||||
|
leaseEnd: dto.leaseEnd,
|
||||||
|
monthlyPayment: dto.monthlyPayment,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
return this.procurementRepository.updateAcquisition(id, dto);
|
return this.procurementRepository.updateAcquisition(id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import {
|
|||||||
Param, ParseUUIDPipe, Patch, Post, Query,
|
Param, ParseUUIDPipe, Patch, Post, Query,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
|
import { RuleEngineManage } from '../../../common/rule-engine-guards';
|
||||||
|
import { StaffReference } from '../../../common/booking-guards';
|
||||||
import { CreateCargoTypeDto } from '../dto/create-cargo-type.dto';
|
import { CreateCargoTypeDto } from '../dto/create-cargo-type.dto';
|
||||||
import { ListCargoTypesQueryDto } from '../dto/list-rule-engine-query.dto';
|
import { ListCargoTypesQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||||
import { MoveOrderDto } from '../dto/move-order.dto';
|
import { MoveOrderDto } from '../dto/move-order.dto';
|
||||||
@@ -18,7 +19,7 @@ export class CargoTypesController {
|
|||||||
constructor(private readonly service: CargoTypesService) {}
|
constructor(private readonly service: CargoTypesService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@RuleEngineView('cargo-types')
|
@StaffReference()
|
||||||
@ApiOperation({ summary: 'List cargo types' })
|
@ApiOperation({ summary: 'List cargo types' })
|
||||||
findAll(@Query() query: ListCargoTypesQueryDto) {
|
findAll(@Query() query: ListCargoTypesQueryDto) {
|
||||||
return this.service.findAll(query);
|
return this.service.findAll(query);
|
||||||
@@ -41,7 +42,7 @@ export class CargoTypesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
@RuleEngineView('cargo-types')
|
@StaffReference()
|
||||||
@ApiOperation({ summary: 'Get a cargo type by ID' })
|
@ApiOperation({ summary: 'Get a cargo type by ID' })
|
||||||
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
return this.service.findById(id);
|
return this.service.findById(id);
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import {
|
|||||||
Param, ParseUUIDPipe, Patch, Post, Query,
|
Param, ParseUUIDPipe, Patch, Post, Query,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
|
import { RuleEngineManage } from '../../../common/rule-engine-guards';
|
||||||
|
import { StaffReference } from '../../../common/booking-guards';
|
||||||
import { CreateContainerTypeDto } from '../dto/create-container-type.dto';
|
import { CreateContainerTypeDto } from '../dto/create-container-type.dto';
|
||||||
import { ListContainerTypesQueryDto } from '../dto/list-rule-engine-query.dto';
|
import { ListContainerTypesQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||||
import { MoveOrderDto } from '../dto/move-order.dto';
|
import { MoveOrderDto } from '../dto/move-order.dto';
|
||||||
@@ -18,7 +19,7 @@ export class ContainerTypesController {
|
|||||||
constructor(private readonly service: ContainerTypesService) {}
|
constructor(private readonly service: ContainerTypesService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@RuleEngineView('container-types')
|
@StaffReference()
|
||||||
@ApiOperation({ summary: 'List container types' })
|
@ApiOperation({ summary: 'List container types' })
|
||||||
findAll(@Query() query: ListContainerTypesQueryDto) {
|
findAll(@Query() query: ListContainerTypesQueryDto) {
|
||||||
return this.service.findAll(query);
|
return this.service.findAll(query);
|
||||||
@@ -41,7 +42,7 @@ export class ContainerTypesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
@RuleEngineView('container-types')
|
@StaffReference()
|
||||||
@ApiOperation({ summary: 'Get a container type by ID' })
|
@ApiOperation({ summary: 'Get a container type by ID' })
|
||||||
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
return this.service.findById(id);
|
return this.service.findById(id);
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import {
|
|||||||
Body, Controller, Delete, Get, HttpCode, HttpStatus,
|
Body, Controller, Delete, Get, HttpCode, HttpStatus,
|
||||||
Param, ParseUUIDPipe, Patch, Post, Query,
|
Param, ParseUUIDPipe, Patch, Post, Query,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
|
import { RuleEngineManage } from '../../../common/rule-engine-guards';
|
||||||
|
import { StaffReference } from '../../../common/booking-guards';
|
||||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
import { CreateServiceTypeDto } from '../dto/create-service-type.dto';
|
import { CreateServiceTypeDto } from '../dto/create-service-type.dto';
|
||||||
import { ListServiceTypesQueryDto } from '../dto/list-rule-engine-query.dto';
|
import { ListServiceTypesQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||||
@@ -18,7 +19,7 @@ export class ServiceTypesController {
|
|||||||
constructor(private readonly service: ServiceTypesService) {}
|
constructor(private readonly service: ServiceTypesService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@RuleEngineView('service-types')
|
@StaffReference()
|
||||||
@ApiOperation({ summary: 'List service types' })
|
@ApiOperation({ summary: 'List service types' })
|
||||||
findAll(@Query() query: ListServiceTypesQueryDto) {
|
findAll(@Query() query: ListServiceTypesQueryDto) {
|
||||||
return this.service.findAll(query);
|
return this.service.findAll(query);
|
||||||
@@ -41,7 +42,7 @@ export class ServiceTypesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
@RuleEngineView('service-types')
|
@StaffReference()
|
||||||
@ApiOperation({ summary: 'Get a service type by ID' })
|
@ApiOperation({ summary: 'Get a service type by ID' })
|
||||||
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
return this.service.findById(id);
|
return this.service.findById(id);
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import {
|
|||||||
Body, Controller, Delete, Get, HttpCode, HttpStatus,
|
Body, Controller, Delete, Get, HttpCode, HttpStatus,
|
||||||
Param, ParseUUIDPipe, Patch, Post, Query,
|
Param, ParseUUIDPipe, Patch, Post, Query,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
|
import { RuleEngineManage } from '../../../common/rule-engine-guards';
|
||||||
|
import { StaffReference } from '../../../common/booking-guards';
|
||||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
import { CreateShippingLineDto } from '../dto/create-shipping-line.dto';
|
import { CreateShippingLineDto } from '../dto/create-shipping-line.dto';
|
||||||
import { ListRuleEngineQueryDto } from '../dto/list-rule-engine-query.dto';
|
import { ListRuleEngineQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||||
@@ -16,14 +17,14 @@ export class ShippingLinesController {
|
|||||||
constructor(private readonly service: ShippingLinesService) {}
|
constructor(private readonly service: ShippingLinesService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@RuleEngineView('shipping-lines')
|
@StaffReference()
|
||||||
@ApiOperation({ summary: 'List shipping lines' })
|
@ApiOperation({ summary: 'List shipping lines' })
|
||||||
findAll(@Query() query: ListRuleEngineQueryDto) {
|
findAll(@Query() query: ListRuleEngineQueryDto) {
|
||||||
return this.service.findAll(query);
|
return this.service.findAll(query);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
@RuleEngineView('shipping-lines')
|
@StaffReference()
|
||||||
@ApiOperation({ summary: 'Get a shipping line by ID' })
|
@ApiOperation({ summary: 'Get a shipping line by ID' })
|
||||||
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
return this.service.findById(id);
|
return this.service.findById(id);
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ import {
|
|||||||
Query,
|
Query,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
|
import { RuleEngineManage } from '../../../common/rule-engine-guards';
|
||||||
|
import { StaffReference } from '../../../common/booking-guards';
|
||||||
import { CreateYardDistanceDto } from '../dto/create-yard-distance.dto';
|
import { CreateYardDistanceDto } from '../dto/create-yard-distance.dto';
|
||||||
import { ListYardDistancesQueryDto } from '../dto/list-rule-engine-query.dto';
|
import { ListYardDistancesQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||||
import { UpdateYardDistanceDto } from '../dto/update-yard-distance.dto';
|
import { UpdateYardDistanceDto } from '../dto/update-yard-distance.dto';
|
||||||
@@ -25,14 +26,14 @@ export class YardDistancesController {
|
|||||||
constructor(private readonly service: YardDistancesService) {}
|
constructor(private readonly service: YardDistancesService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@RuleEngineView('yard-distances')
|
@StaffReference()
|
||||||
@ApiOperation({ summary: 'List yard distances' })
|
@ApiOperation({ summary: 'List yard distances' })
|
||||||
findAll(@Query() query: ListYardDistancesQueryDto) {
|
findAll(@Query() query: ListYardDistancesQueryDto) {
|
||||||
return this.service.findAll(query);
|
return this.service.findAll(query);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
@RuleEngineView('yard-distances')
|
@StaffReference()
|
||||||
@ApiOperation({ summary: 'Get a yard distance by ID' })
|
@ApiOperation({ summary: 'Get a yard distance by ID' })
|
||||||
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
return this.service.findById(id);
|
return this.service.findById(id);
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import {
|
|||||||
Body, Controller, Delete, Get, HttpCode, HttpStatus,
|
Body, Controller, Delete, Get, HttpCode, HttpStatus,
|
||||||
Param, ParseUUIDPipe, Patch, Post, Query,
|
Param, ParseUUIDPipe, Patch, Post, Query,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
|
import { RuleEngineManage } from '../../../common/rule-engine-guards';
|
||||||
|
import { StaffReference } from '../../../common/booking-guards';
|
||||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
import { CreateYardDto } from '../dto/create-yard.dto';
|
import { CreateYardDto } from '../dto/create-yard.dto';
|
||||||
import { ListYardsQueryDto } from '../dto/list-rule-engine-query.dto';
|
import { ListYardsQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||||
@@ -18,7 +19,9 @@ export class YardsController {
|
|||||||
constructor(private readonly service: YardsService) {}
|
constructor(private readonly service: YardsService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@RuleEngineView('yards')
|
// Reference read: every staff form/search needs the yard list (origin /
|
||||||
|
// destination pickers), so login is the only requirement.
|
||||||
|
@StaffReference()
|
||||||
@ApiOperation({ summary: 'List yards' })
|
@ApiOperation({ summary: 'List yards' })
|
||||||
findAll(@Query() query: ListYardsQueryDto) {
|
findAll(@Query() query: ListYardsQueryDto) {
|
||||||
return this.service.findAll(query);
|
return this.service.findAll(query);
|
||||||
@@ -41,7 +44,7 @@ export class YardsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
@RuleEngineView('yards')
|
@StaffReference()
|
||||||
@ApiOperation({ summary: 'Get a yard by ID' })
|
@ApiOperation({ summary: 'Get a yard by ID' })
|
||||||
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
return this.service.findById(id);
|
return this.service.findById(id);
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { RuleEngineService } from './rule-engine.service';
|
||||||
|
import type { BookingEvaluationInput } from './rule-engine.service';
|
||||||
|
import type { Rate } from './entities/rate.entity';
|
||||||
|
|
||||||
|
describe('RuleEngineService — requested service without a configured surcharge rate', () => {
|
||||||
|
const hazardRate: Rate = {
|
||||||
|
id: 'rate-hazard',
|
||||||
|
rateType: 'HAZARD_SURCHARGE',
|
||||||
|
trigger: 'HAZARDOUS',
|
||||||
|
rateValue: 50,
|
||||||
|
rateUnit: 'PER_CONTAINER',
|
||||||
|
currency: 'USD',
|
||||||
|
status: 'LIVE',
|
||||||
|
containerTypeId: null,
|
||||||
|
cargoTypeId: null,
|
||||||
|
} as Rate;
|
||||||
|
|
||||||
|
let ratesRepo: { findLiveRates: jest.Mock };
|
||||||
|
let service: RuleEngineService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
ratesRepo = { findLiveRates: jest.fn().mockResolvedValue([]) };
|
||||||
|
service = new RuleEngineService(
|
||||||
|
{ findById: jest.fn().mockResolvedValue(null) } as never, // cargoTypes
|
||||||
|
{ findById: jest.fn().mockResolvedValue(null) } as never, // serviceTypes
|
||||||
|
{ findActiveByContainerTypeId: jest.fn().mockResolvedValue([]) } as never, // weightLimits
|
||||||
|
{ findAllActive: jest.fn().mockResolvedValue([]) } as never, // priorityConfigs
|
||||||
|
ratesRepo as never,
|
||||||
|
{ findById: jest.fn().mockResolvedValue(null) } as never, // shippingLines
|
||||||
|
{} as never, // dataSource (unused by evaluate)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const input = (overrides: Partial<BookingEvaluationInput>): BookingEvaluationInput => ({
|
||||||
|
serviceTypeId: 'svc-1',
|
||||||
|
paymentCurrency: 'USD',
|
||||||
|
tradeDirection: 'IMPORT',
|
||||||
|
isHazardous: false,
|
||||||
|
totalWagons: 1,
|
||||||
|
containers: [],
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hard-blocks a hazardous booking when no HAZARDOUS surcharge rate is LIVE', async () => {
|
||||||
|
const result = await service.evaluate(input({ isHazardous: true }));
|
||||||
|
expect(result.hardBlocked).toHaveLength(1);
|
||||||
|
expect(result.hardBlocked[0]).toContain('hazardous');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes a hazardous booking when a HAZARDOUS surcharge rate is LIVE', async () => {
|
||||||
|
ratesRepo.findLiveRates.mockResolvedValue([hazardRate]);
|
||||||
|
const result = await service.evaluate(input({ isHazardous: true }));
|
||||||
|
expect(result.hardBlocked).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not block a non-hazardous booking when no surcharge rates exist', async () => {
|
||||||
|
const result = await service.evaluate(input({}));
|
||||||
|
expect(result.hardBlocked).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hard-blocks on per-container opt-in counts even without the booking-level flag', async () => {
|
||||||
|
const result = await service.evaluate(
|
||||||
|
input({
|
||||||
|
containers: [
|
||||||
|
{
|
||||||
|
containerTypeId: 'ct-20',
|
||||||
|
quantity: 2,
|
||||||
|
vgmPerUnitTons: 10,
|
||||||
|
totalVgmTons: 20,
|
||||||
|
reeferQuantity: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(result.hardBlocked).toHaveLength(1);
|
||||||
|
expect(result.hardBlocked[0]).toContain('reefer');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -28,6 +28,10 @@ import {
|
|||||||
} from './interfaces/shipping-lines.repository.interface';
|
} from './interfaces/shipping-lines.repository.interface';
|
||||||
import { GOVERNMENT_PRIORITY_BONUS } from './government-priority.constants';
|
import { GOVERNMENT_PRIORITY_BONUS } from './government-priority.constants';
|
||||||
|
|
||||||
|
// Coerce defensively: a flag may arrive as the string "true"/"false" (e.g.
|
||||||
|
// from multipart form-data) and a non-empty "false" string is truthy.
|
||||||
|
const truthy = (v: unknown): boolean => v === true || v === 'true';
|
||||||
|
|
||||||
export interface BookingContainerEvalInput {
|
export interface BookingContainerEvalInput {
|
||||||
containerTypeId: string;
|
containerTypeId: string;
|
||||||
quantity: number;
|
quantity: number;
|
||||||
@@ -245,6 +249,48 @@ export class RuleEngineService {
|
|||||||
liveRates.filter((r) => r.trigger && r.trigger !== 'ALWAYS'),
|
liveRates.filter((r) => r.trigger && r.trigger !== 'ALWAYS'),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// A handling service the booking asks for (booking-level flag OR any
|
||||||
|
// per-container opt-in count) with no LIVE surcharge rate configured is a
|
||||||
|
// hard block — pricing would otherwise ship the service for free. System-
|
||||||
|
// derived charges (consolidation, overweight, shipping line, lashing) stay
|
||||||
|
// exempt: the customer never opted into those, so they must not block.
|
||||||
|
const requestedServices: Array<{
|
||||||
|
trigger: RateTrigger;
|
||||||
|
wanted: boolean;
|
||||||
|
label: string;
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
trigger: 'HAZARDOUS',
|
||||||
|
wanted:
|
||||||
|
truthy(input.isHazardous) ||
|
||||||
|
input.containers.some((c) => Number(c.hazardousQuantity ?? 0) > 0),
|
||||||
|
label: 'hazardous cargo',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
trigger: 'REEFER',
|
||||||
|
wanted:
|
||||||
|
hasReefer ||
|
||||||
|
input.containers.some((c) => Number(c.reeferQuantity ?? 0) > 0),
|
||||||
|
label: 'refrigerated (reefer) cargo',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
trigger: 'WITH_RETURN',
|
||||||
|
wanted:
|
||||||
|
truthy(input.withReturn) ||
|
||||||
|
input.containers.some((c) => Number(c.returnQuantity ?? 0) > 0),
|
||||||
|
label: 'empty-container return',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
for (const svc of requestedServices) {
|
||||||
|
if (svc.wanted && !surchargeRates.some((r) => r.trigger === svc.trigger)) {
|
||||||
|
hardBlocked.push(
|
||||||
|
`No ${svc.label} surcharge rate is configured — the booking cannot ` +
|
||||||
|
`be priced with this service. Remove the ${svc.label} option or ` +
|
||||||
|
'ask EDR to configure its rate.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (const rate of surchargeRates) {
|
for (const rate of surchargeRates) {
|
||||||
const triggered = this.matchesTrigger(rate.trigger, {
|
const triggered = this.matchesTrigger(rate.trigger, {
|
||||||
isHazardous: input.isHazardous,
|
isHazardous: input.isHazardous,
|
||||||
@@ -438,9 +484,6 @@ export class RuleEngineService {
|
|||||||
hasLashing: boolean;
|
hasLashing: boolean;
|
||||||
},
|
},
|
||||||
): boolean {
|
): boolean {
|
||||||
// Coerce defensively: a flag may arrive as the string "true"/"false" (e.g.
|
|
||||||
// from multipart form-data) and a non-empty "false" string is truthy.
|
|
||||||
const truthy = (v: unknown): boolean => v === true || v === 'true';
|
|
||||||
switch (trigger) {
|
switch (trigger) {
|
||||||
case 'HAZARDOUS':
|
case 'HAZARDOUS':
|
||||||
return truthy(state.isHazardous);
|
return truthy(state.isHazardous);
|
||||||
|
|||||||
@@ -570,6 +570,104 @@ describe('BookingBatchService — PAID reconcile', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('expireLeftoverExportDay — export day sweep', () => {
|
||||||
|
const exportSchedule = {
|
||||||
|
id: scheduleId,
|
||||||
|
direction: 'EXPORT',
|
||||||
|
originStationId: 'yard-origin',
|
||||||
|
destinationStationId: 'yard-dest',
|
||||||
|
scheduledDepartureDate: new Date('2026-06-20T06:00:00.000Z'),
|
||||||
|
windowPhase: 'DONE',
|
||||||
|
bookingWindowStatus: 'CLOSED',
|
||||||
|
};
|
||||||
|
let unacceptedSpy: jest.SpyInstance;
|
||||||
|
let poolSpy: jest.SpyInstance;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
unacceptedSpy = jest
|
||||||
|
.spyOn(service, 'expireUnacceptedForRouteDay')
|
||||||
|
.mockResolvedValue(undefined);
|
||||||
|
poolSpy = jest.spyOn(service, 'expireLeftoverDayPool').mockResolvedValue(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores non-export schedules', async () => {
|
||||||
|
trainSchedulesRepository.findById.mockResolvedValue({
|
||||||
|
...exportSchedule,
|
||||||
|
direction: 'IMPORT',
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.expireLeftoverExportDay(scheduleId);
|
||||||
|
|
||||||
|
expect(unacceptedSpy).not.toHaveBeenCalled();
|
||||||
|
expect(poolSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defers while another export train on the day can still take bookings', async () => {
|
||||||
|
trainSchedulesRepository.findById.mockResolvedValue(exportSchedule);
|
||||||
|
trainSchedulesRepository.findAll.mockResolvedValue([
|
||||||
|
exportSchedule,
|
||||||
|
{
|
||||||
|
...exportSchedule,
|
||||||
|
id: 'sched-2',
|
||||||
|
windowPhase: 'OPEN',
|
||||||
|
bookingWindowStatus: 'OPEN',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
await service.expireLeftoverExportDay(scheduleId);
|
||||||
|
|
||||||
|
expect(unacceptedSpy).not.toHaveBeenCalled();
|
||||||
|
expect(poolSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defers while a FULL train still has live pay windows', async () => {
|
||||||
|
trainSchedulesRepository.findById.mockResolvedValue(exportSchedule);
|
||||||
|
trainSchedulesRepository.findAll.mockResolvedValue([
|
||||||
|
exportSchedule,
|
||||||
|
{
|
||||||
|
...exportSchedule,
|
||||||
|
id: 'sched-2',
|
||||||
|
windowPhase: 'OPEN',
|
||||||
|
bookingWindowStatus: 'FULL',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
bookingsRepository.findReservedForSchedule.mockResolvedValue([
|
||||||
|
{
|
||||||
|
paymentStatus: 'PENDING',
|
||||||
|
status: 'AWAITING_PAYMENT',
|
||||||
|
paymentDeadline: new Date(Date.now() + 60_000),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
await service.expireLeftoverExportDay(scheduleId);
|
||||||
|
|
||||||
|
expect(unacceptedSpy).not.toHaveBeenCalled();
|
||||||
|
expect(poolSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sweeps un-accepted + waiting bookings once every train on the day is shut', async () => {
|
||||||
|
trainSchedulesRepository.findById.mockResolvedValue(exportSchedule);
|
||||||
|
trainSchedulesRepository.findAll.mockResolvedValue([
|
||||||
|
exportSchedule,
|
||||||
|
{
|
||||||
|
...exportSchedule,
|
||||||
|
id: 'sched-2',
|
||||||
|
windowPhase: 'OPEN',
|
||||||
|
bookingWindowStatus: 'FULL',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
await service.expireLeftoverExportDay(scheduleId);
|
||||||
|
|
||||||
|
expect(unacceptedSpy).toHaveBeenCalledWith({
|
||||||
|
originYardId: 'yard-origin',
|
||||||
|
destinationYardId: 'yard-dest',
|
||||||
|
day: '2026-06-20',
|
||||||
|
});
|
||||||
|
expect(poolSpy).toHaveBeenCalledWith(scheduleId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('maybeOfferPartial — split-eligibility gate', () => {
|
describe('maybeOfferPartial — split-eligibility gate', () => {
|
||||||
const importGeneral = {
|
const importGeneral = {
|
||||||
id: 'b1',
|
id: 'b1',
|
||||||
@@ -817,6 +915,122 @@ describe('BookingBatchService — PAID reconcile', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('acceptIntercity — export pay window expires at window close', () => {
|
||||||
|
const exportScheduleId = 'export-train';
|
||||||
|
// Window closes in 30 minutes; the configured pay window is 60 minutes.
|
||||||
|
const closesAt = new Date(Date.now() + 30 * 60_000);
|
||||||
|
|
||||||
|
const waiting = {
|
||||||
|
id: 'ic-1',
|
||||||
|
reference: 'IC-1',
|
||||||
|
isGovernment: false,
|
||||||
|
status: 'FULLY_EXECUTED',
|
||||||
|
trainScheduleId: null,
|
||||||
|
freightType: 'CONTAINER',
|
||||||
|
cargoTotalWeightVgm: 10,
|
||||||
|
bookingContainers: [],
|
||||||
|
} as unknown as Booking;
|
||||||
|
|
||||||
|
let scheduleRepo: { findOne: jest.Mock };
|
||||||
|
let bookingRepo: { findOne: jest.Mock; update: jest.Mock; find: jest.Mock };
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
bookingRepo = dataSource.getRepository();
|
||||||
|
bookingRepo.findOne.mockResolvedValue(waiting);
|
||||||
|
scheduleRepo = { findOne: jest.fn() };
|
||||||
|
// reserve() reads the target schedule to clamp export deadlines — route
|
||||||
|
// TrainSchedule reads to their own repo, everything else stays as before.
|
||||||
|
dataSource.getRepository.mockImplementation((entity?: { name?: string }) =>
|
||||||
|
entity?.name === 'TrainSchedule' ? scheduleRepo : bookingRepo,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clamps the intercity pay deadline to the export window close', async () => {
|
||||||
|
scheduleRepo.findOne.mockResolvedValue({
|
||||||
|
id: exportScheduleId,
|
||||||
|
direction: 'EXPORT',
|
||||||
|
windowClosesAt: closesAt,
|
||||||
|
scheduledDepartureDate: new Date(closesAt.getTime() + 2 * 3_600_000),
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.acceptIntercity(waiting, exportScheduleId);
|
||||||
|
|
||||||
|
expect(bookingsRepository.update).toHaveBeenCalledWith(
|
||||||
|
'ic-1',
|
||||||
|
expect.objectContaining({
|
||||||
|
status: 'SELECTED_FOR_BATCH',
|
||||||
|
paymentDeadline: closesAt,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(notifier.payNow).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the plain payment window on import trains', async () => {
|
||||||
|
scheduleRepo.findOne.mockResolvedValue({
|
||||||
|
id: 'import-train',
|
||||||
|
direction: 'IMPORT',
|
||||||
|
windowClosesAt: closesAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.acceptIntercity(waiting, 'import-train');
|
||||||
|
|
||||||
|
const deadline = (
|
||||||
|
bookingsRepository.update.mock.calls[0][1] as { paymentDeadline: Date }
|
||||||
|
).paymentDeadline;
|
||||||
|
// 60-minute pay window runs past the 30-minutes-out close: no clamp.
|
||||||
|
expect(deadline.getTime()).toBeGreaterThan(closesAt.getTime());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an accept after the export window closed — no pay window opens', async () => {
|
||||||
|
scheduleRepo.findOne.mockResolvedValue({
|
||||||
|
id: exportScheduleId,
|
||||||
|
direction: 'EXPORT',
|
||||||
|
windowClosesAt: new Date(Date.now() - 60_000),
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.acceptIntercity(waiting, exportScheduleId),
|
||||||
|
).rejects.toThrow(/window has closed/);
|
||||||
|
expect(bookingsRepository.update).not.toHaveBeenCalled();
|
||||||
|
expect(notifier.payNow).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('expires an unpaid export ride-along at close and frees the train', async () => {
|
||||||
|
const lapsed = {
|
||||||
|
...(waiting as unknown as Record<string, unknown>),
|
||||||
|
status: 'SELECTED_FOR_BATCH',
|
||||||
|
trainScheduleId: exportScheduleId,
|
||||||
|
paymentDeadline: new Date(Date.now() - 1_000),
|
||||||
|
originYardId: 'yard-a',
|
||||||
|
destinationYardId: 'yard-b',
|
||||||
|
priorityScore: 0,
|
||||||
|
wagonsRequired: 1,
|
||||||
|
} as unknown as Booking;
|
||||||
|
bookingsRepository.findReservedForSchedule
|
||||||
|
.mockResolvedValueOnce([lapsed])
|
||||||
|
.mockResolvedValue([]);
|
||||||
|
bookingsRepository.findBatchPoolByCorridorDay.mockResolvedValue([]);
|
||||||
|
// expire()'s paid-guard re-reads the booking fresh — still unpaid.
|
||||||
|
bookingRepo.findOne.mockResolvedValue(lapsed);
|
||||||
|
trainSchedulesRepository.findById.mockResolvedValue({
|
||||||
|
id: exportScheduleId,
|
||||||
|
bookingWindowStatus: 'CLOSED',
|
||||||
|
windowPhase: 'DONE',
|
||||||
|
scheduledDepartureDate: new Date(Date.now() + 3_600_000),
|
||||||
|
originStationId: 'yard-a',
|
||||||
|
destinationStationId: 'yard-b',
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.settleDueReservations(exportScheduleId);
|
||||||
|
|
||||||
|
expect(notifier.expired).toHaveBeenCalledTimes(1);
|
||||||
|
expect(bookingsRepository.update).toHaveBeenCalledWith(
|
||||||
|
'ic-1',
|
||||||
|
expect.objectContaining({ status: 'EXPIRED', trainScheduleId: null }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('BookingBatchService — wagonsFor', () => {
|
describe('BookingBatchService — wagonsFor', () => {
|
||||||
|
|||||||
@@ -570,6 +570,10 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
);
|
);
|
||||||
if (schedule && (await this.isTrainFull(schedule))) {
|
if (schedule && (await this.isTrainFull(schedule))) {
|
||||||
await this.setWindow(booking.trainScheduleId, "FULL");
|
await this.setWindow(booking.trainScheduleId, "FULL");
|
||||||
|
// This payment may have been the last live pay window on a now-full
|
||||||
|
// export day — the settle that normally re-runs the sweep finds nothing
|
||||||
|
// left to settle, so trigger it here.
|
||||||
|
void this.expireLeftoverExportDay(booking.trainScheduleId);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await this.trainSchedulingService.tryAutoWagonAllocation(
|
const result = await this.trainSchedulingService.tryAutoWagonAllocation(
|
||||||
@@ -2254,6 +2258,11 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
`— payment phase extended for them`,
|
`— payment phase extended for them`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
// The settle may have resolved the last pay window on a full export day
|
||||||
|
// (paid → allocated, and the top-up found nothing else that fits) — sweep
|
||||||
|
// the date's leftover bookings. Self-guarded: no-op for import/domestic
|
||||||
|
// and while any train on the day can still take bookings.
|
||||||
|
await this.expireLeftoverExportDay(scheduleId);
|
||||||
// Emitted here (not in settleDueReservations/settleBatch, which both wrap
|
// Emitted here (not in settleDueReservations/settleBatch, which both wrap
|
||||||
// this) so one settle produces one push, after every allocation/expiry/
|
// this) so one settle produces one push, after every allocation/expiry/
|
||||||
// top-up extension for this schedule has been persisted.
|
// top-up extension for this schedule has been persisted.
|
||||||
@@ -2350,6 +2359,9 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
);
|
);
|
||||||
if (schedule && (await this.isTrainFull(schedule))) {
|
if (schedule && (await this.isTrainFull(schedule))) {
|
||||||
await this.setWindow(booking.trainScheduleId, "FULL");
|
await this.setWindow(booking.trainScheduleId, "FULL");
|
||||||
|
// Same as the webhook path: a staff mark-paid can settle the last live
|
||||||
|
// pay window on a now-full export day — sweep the date's leftovers.
|
||||||
|
void this.expireLeftoverExportDay(booking.trainScheduleId);
|
||||||
}
|
}
|
||||||
void this.triggerWagonAllocation(booking.trainScheduleId!);
|
void this.triggerWagonAllocation(booking.trainScheduleId!);
|
||||||
this.notifyBoardChanged(booking.trainScheduleId, "booking_marked_paid");
|
this.notifyBoardChanged(booking.trainScheduleId, "booking_marked_paid");
|
||||||
@@ -2461,13 +2473,13 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
if (!schedule || !locomotive) return null;
|
if (!schedule || !locomotive) return null;
|
||||||
const wagonDims = await this.loadWagonDims();
|
const wagonDims = await this.loadWagonDims();
|
||||||
const limits = await this.capacityLimits(locomotive);
|
const limits = await this.capacityLimits(locomotive);
|
||||||
// Built trains: collapse to a single train-wide pool so the freed capacity of
|
// Built trains use the leg-aware corridor budget too: the wagon planner
|
||||||
// a booking that alights mid-corridor is NOT re-offered on the pass-through
|
// consumes stock PER EDGE (planWagonsWithStock legs), so a consist wagon
|
||||||
// leg (see remainingBudget). Keeps intercity accept consistent with the
|
// that runs empty Gelan→Adama genuinely can carry an intercity booking
|
||||||
// train-wide isTrainFull / committedWagons finalize signal.
|
// there before its export cargo boards at Adama. A train full on one leg
|
||||||
const budget = await this.remainingBudget(schedule, limits, wagonDims, {
|
// still accepts ride-alongs on its empty legs — that is the whole point
|
||||||
collapseForBuiltTrain: true,
|
// of the ride-along flow.
|
||||||
});
|
const budget = await this.remainingBudget(schedule, limits, wagonDims);
|
||||||
return { budget, needFor: (booking) => this.needFor(booking, wagonDims) };
|
return { budget, needFor: (booking) => this.needFor(booking, wagonDims) };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2526,7 +2538,26 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const deadline = new Date(now.getTime() + (await this.paymentWindowMs()));
|
let deadline = new Date(now.getTime() + (await this.paymentWindowMs()));
|
||||||
|
// EXPORT parity: pay windows on an export train never outlive its booking
|
||||||
|
// window — export bookings expire at close, so anything reserved onto the
|
||||||
|
// same train (FCFS export or an intercity ride-along) must too. Import
|
||||||
|
// keeps the plain payment window; its cycles re-fill after settle.
|
||||||
|
const targetSchedule = await this.dataSource
|
||||||
|
.getRepository(TrainSchedule)
|
||||||
|
.findOne({ where: { id: scheduleId } });
|
||||||
|
if (targetSchedule?.direction === "EXPORT") {
|
||||||
|
const cutoff =
|
||||||
|
targetSchedule.windowClosesAt ?? targetSchedule.scheduledDepartureDate;
|
||||||
|
if (cutoff && cutoff.getTime() <= now.getTime()) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
"Export booking window has closed — cannot open a pay window on this train",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (cutoff && cutoff.getTime() < deadline.getTime()) {
|
||||||
|
deadline = new Date(cutoff);
|
||||||
|
}
|
||||||
|
}
|
||||||
await this.bookingsRepository.update(booking.id, {
|
await this.bookingsRepository.update(booking.id, {
|
||||||
trainScheduleId: scheduleId,
|
trainScheduleId: scheduleId,
|
||||||
status: "SELECTED_FOR_BATCH",
|
status: "SELECTED_FOR_BATCH",
|
||||||
@@ -2822,6 +2853,60 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
return leftovers.length;
|
return leftovers.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* EXPORT counterpart of the conclude-time sweep. Export has no batch cycle,
|
||||||
|
* so nothing ever concluded its day: bookings still waiting when the trains
|
||||||
|
* filled up or the window closed stayed pending forever. Once every export
|
||||||
|
* train on this route-day is shut — window DONE, or FULL with no pay window
|
||||||
|
* still live that could lapse and free space — the date is dead: expire the
|
||||||
|
* un-accepted bookings staff can no longer accept AND the ready
|
||||||
|
* (FULLY_EXECUTED) bookings that never got a reservation (consolidation
|
||||||
|
* waiters). Runs at export window close and whenever an export train's
|
||||||
|
* fullness settles.
|
||||||
|
*/
|
||||||
|
async expireLeftoverExportDay(scheduleId: string): Promise<void> {
|
||||||
|
const schedule = await this.trainSchedulesRepository.findById(scheduleId);
|
||||||
|
if (schedule?.direction !== "EXPORT" || !schedule.scheduledDepartureDate) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const day = eatDay(schedule.scheduledDepartureDate);
|
||||||
|
const trains = (
|
||||||
|
await this.trainSchedulesRepository.findAll({
|
||||||
|
where: [
|
||||||
|
{
|
||||||
|
originStationId: schedule.originStationId,
|
||||||
|
destinationStationId: schedule.destinationStationId,
|
||||||
|
status: TrainScheduleStatusEnum.Draft,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
originStationId: schedule.originStationId,
|
||||||
|
destinationStationId: schedule.destinationStationId,
|
||||||
|
status: TrainScheduleStatusEnum.Scheduled,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
).filter(
|
||||||
|
(s) =>
|
||||||
|
s.scheduledDepartureDate != null &&
|
||||||
|
eatDay(s.scheduledDepartureDate) === day,
|
||||||
|
);
|
||||||
|
for (const s of trains) {
|
||||||
|
// Any train still taking bookings keeps the date alive.
|
||||||
|
if (s.windowPhase !== "DONE" && s.bookingWindowStatus !== "FULL") return;
|
||||||
|
// A FULL train whose reservations are still inside their pay windows can
|
||||||
|
// reopen when one lapses unpaid — defer; the settle re-runs this sweep.
|
||||||
|
if (s.windowPhase !== "DONE" && (await this.hasLiveReservations(s.id))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await this.expireUnacceptedForRouteDay({
|
||||||
|
originYardId: schedule.originStationId,
|
||||||
|
destinationYardId: schedule.destinationStationId,
|
||||||
|
day,
|
||||||
|
});
|
||||||
|
await this.expireLeftoverDayPool(scheduleId);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Union of stop yards across the day's fillable schedules on this corridor —
|
* Union of stop yards across the day's fillable schedules on this corridor —
|
||||||
* the same pool scope fillRouteDay uses, so full-route AND sub-corridor bookings
|
* the same pool scope fillRouteDay uses, so full-route AND sub-corridor bookings
|
||||||
@@ -3398,7 +3483,6 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
schedule: TrainSchedule,
|
schedule: TrainSchedule,
|
||||||
limits: TrainLimits,
|
limits: TrainLimits,
|
||||||
wagonDims: WagonDims,
|
wagonDims: WagonDims,
|
||||||
opts?: { collapseForBuiltTrain?: boolean },
|
|
||||||
): Promise<CorridorBudget> {
|
): Promise<CorridorBudget> {
|
||||||
const physicalWagons = await this.builtTrainWagonCount(schedule);
|
const physicalWagons = await this.builtTrainWagonCount(schedule);
|
||||||
if (physicalWagons != null) {
|
if (physicalWagons != null) {
|
||||||
@@ -3411,21 +3495,10 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
tolerance: { weightTons: 0, lengthMeters: 0 },
|
tolerance: { weightTons: 0, lengthMeters: 0 },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
// A built train's wagons are coupled for the WHOLE trip, and the allocator
|
// Built trains keep the leg-aware multi-edge corridor too: the wagon
|
||||||
// commits each booking to a wagon for the entire route — it never reloads a
|
// planner consumes stock per edge (planWagonsWithStock legs), so a consist
|
||||||
// wagon at a mid-corridor alight yard. So a built train has no leg concept:
|
// wagon serves disjoint legs — capacity freed past an alight yard is real.
|
||||||
// its capacity is one train-wide pool, exactly as isTrainFull /
|
const stops = await this.stopsForSchedule(schedule);
|
||||||
// committedWagons already count it. When a caller opts in, collapse the
|
|
||||||
// corridor to a single whole-route edge so every booking (full-route OR
|
|
||||||
// mid-corridor) draws from that one pool — a train full of import-to-DireDawa
|
|
||||||
// then correctly shows NO room for a DireDawa->Addis intercity booking on the
|
|
||||||
// leg it merely passes through, instead of over-promising the freed slots.
|
|
||||||
// Locomotive-derived schedules keep the leg-aware multi-edge corridor: their
|
|
||||||
// abstract slot/weight/length budget genuinely frees past an alight yard.
|
|
||||||
const stops =
|
|
||||||
physicalWagons != null && opts?.collapseForBuiltTrain
|
|
||||||
? [schedule.originStationId, schedule.destinationStationId]
|
|
||||||
: await this.stopsForSchedule(schedule);
|
|
||||||
const budget = new CorridorBudget(stops, limits.base, limits.tolerance);
|
const budget = new CorridorBudget(stops, limits.base, limits.tolerance);
|
||||||
const allocated = (schedule.scheduleBookings ?? [])
|
const allocated = (schedule.scheduleBookings ?? [])
|
||||||
.map((sb) => sb.booking)
|
.map((sb) => sb.booking)
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ describe('BookingWindowService — window state machine', () => {
|
|||||||
hasLiveReservations: jest.Mock;
|
hasLiveReservations: jest.Mock;
|
||||||
refreshWindowStatus: jest.Mock;
|
refreshWindowStatus: jest.Mock;
|
||||||
expireLeftoverDayPool: jest.Mock;
|
expireLeftoverDayPool: jest.Mock;
|
||||||
|
expireLeftoverExportDay: jest.Mock;
|
||||||
fillFromWaitingList: jest.Mock;
|
fillFromWaitingList: jest.Mock;
|
||||||
};
|
};
|
||||||
let trainSchedulesRepository: { findById: jest.Mock; findAll: jest.Mock };
|
let trainSchedulesRepository: { findById: jest.Mock; findAll: jest.Mock };
|
||||||
@@ -75,6 +76,7 @@ describe('BookingWindowService — window state machine', () => {
|
|||||||
hasLiveReservations: jest.fn().mockResolvedValue(false),
|
hasLiveReservations: jest.fn().mockResolvedValue(false),
|
||||||
refreshWindowStatus: jest.fn().mockResolvedValue(undefined),
|
refreshWindowStatus: jest.fn().mockResolvedValue(undefined),
|
||||||
expireLeftoverDayPool: jest.fn().mockResolvedValue(0),
|
expireLeftoverDayPool: jest.fn().mockResolvedValue(0),
|
||||||
|
expireLeftoverExportDay: jest.fn().mockResolvedValue(undefined),
|
||||||
// No waiting booking fits by default, so conclude proceeds to reopen/DONE.
|
// No waiting booking fits by default, so conclude proceeds to reopen/DONE.
|
||||||
fillFromWaitingList: jest.fn().mockResolvedValue(0),
|
fillFromWaitingList: jest.fn().mockResolvedValue(0),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -229,6 +229,11 @@ export class BookingWindowService implements OnModuleInit {
|
|||||||
await this.bookingBatchService.setWindow(schedule.id, 'CLOSED');
|
await this.bookingBatchService.setWindow(schedule.id, 'CLOSED');
|
||||||
schedule.bookingWindowStatus = 'CLOSED';
|
schedule.bookingWindowStatus = 'CLOSED';
|
||||||
}
|
}
|
||||||
|
// Export has no conclude step: this close is the last moment the day's
|
||||||
|
// bookings could have boarded. Once every train on the route-day is
|
||||||
|
// shut, expire what is still waiting for this date (the sweep defers
|
||||||
|
// while a sibling train stays open).
|
||||||
|
await this.bookingBatchService.expireLeftoverExportDay(schedule.id);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { IsUUID } from 'class-validator';
|
||||||
|
|
||||||
|
export class MoveWagonLoadDto {
|
||||||
|
/**
|
||||||
|
* Where the source wagon's whole load goes: a train-set wagon slot (empty →
|
||||||
|
* move, loaded → swap the two loads) or an empty consist-only physical wagon
|
||||||
|
* of the built train (→ the slot repins onto it).
|
||||||
|
*/
|
||||||
|
@IsUUID()
|
||||||
|
targetWagonId!: string;
|
||||||
|
}
|
||||||
@@ -146,10 +146,8 @@ export class IntercityService {
|
|||||||
remaining: capacity?.budget.maxRemaining() ?? null,
|
remaining: capacity?.budget.maxRemaining() ?? null,
|
||||||
candidates: waiting.map((booking) => {
|
candidates: waiting.map((booking) => {
|
||||||
const need = capacity?.needFor(booking) ?? null;
|
const need = capacity?.needFor(booking) ?? null;
|
||||||
// legForYards, not legOf: on a built train the budget is a single
|
// legForYards: the booking draws only from ITS OWN leg's edges, with a
|
||||||
// whole-route edge (see intercityCapacity), so a mid-corridor booking
|
// whole-route fallback when its yards aren't on the budget's stop list.
|
||||||
// must draw from that one pool via the whole-route fallback. On a
|
|
||||||
// locomotive-derived schedule it still resolves to the booking's own leg.
|
|
||||||
const leg = capacity?.budget.legForYards(
|
const leg = capacity?.budget.legForYards(
|
||||||
booking.originYardId,
|
booking.originYardId,
|
||||||
booking.destinationYardId,
|
booking.destinationYardId,
|
||||||
@@ -215,11 +213,8 @@ export class IntercityService {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const need = capacity.needFor(booking);
|
const need = capacity.needFor(booking);
|
||||||
// legForYards, not legOf: a built train's budget is a single whole-route
|
// legForYards: charge only the edges this booking rides, so it can still
|
||||||
// pool (mid-corridor wagons are committed for the whole trip and never
|
// board a train that is full only on other legs.
|
||||||
// reloaded), so the booking draws from that pool via the whole-route
|
|
||||||
// fallback; a locomotive-derived schedule still gets the booking's own
|
|
||||||
// leg, so it can still board a train that is full only on other legs.
|
|
||||||
const leg = budget.legForYards(booking.originYardId, booking.destinationYardId);
|
const leg = budget.legForYards(booking.originYardId, booking.destinationYardId);
|
||||||
if (!budget.fits(need, leg)) {
|
if (!budget.fits(need, leg)) {
|
||||||
rejected.push({
|
rejected.push({
|
||||||
|
|||||||
@@ -253,12 +253,18 @@ export function minLocomotiveLimits(
|
|||||||
maxTrainLengthMeters: Math.min(
|
maxTrainLengthMeters: Math.min(
|
||||||
...locomotives.map((l) => num(l.maxTrainLengthMeters, Infinity) || Infinity),
|
...locomotives.map((l) => num(l.maxTrainLengthMeters, Infinity) || Infinity),
|
||||||
),
|
),
|
||||||
// Weakest locomotive's tolerance governs the set, same as its caps.
|
// Weakest CONFIGURED tolerance governs the set — a locomotive with no
|
||||||
overageToleranceTons: Math.min(...locomotives.map((l) => num(l.overageToleranceTons))),
|
// tolerance set has no opinion, it does not zero out the others.
|
||||||
overageToleranceMeters: Math.min(...locomotives.map((l) => num(l.overageToleranceMeters))),
|
overageToleranceTons: minConfigured(locomotives.map((l) => l.overageToleranceTons)),
|
||||||
|
overageToleranceMeters: minConfigured(locomotives.map((l) => l.overageToleranceMeters)),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function minConfigured(values: Array<number | string | null | undefined>): number {
|
||||||
|
const configured = values.filter((v) => v != null).map((v) => num(v));
|
||||||
|
return configured.length ? Math.min(...configured) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
/** Per-booking train length from wagon count and freight-specific wagon type length. */
|
/** Per-booking train length from wagon count and freight-specific wagon type length. */
|
||||||
export function bookingTrainLengthMeters(
|
export function bookingTrainLengthMeters(
|
||||||
freightType: string | null | undefined,
|
freightType: string | null | undefined,
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import { GetEligibleBookingsDto } from "./dto/get-eligible-bookings.dto";
|
|||||||
import { GetEligibleBulkBookingsDto } from "./dto/get-eligible-bulk-bookings.dto";
|
import { GetEligibleBulkBookingsDto } from "./dto/get-eligible-bulk-bookings.dto";
|
||||||
import { GetEligibleContainerBookingsDto } from "./dto/get-eligible-container-bookings.dto";
|
import { GetEligibleContainerBookingsDto } from "./dto/get-eligible-container-bookings.dto";
|
||||||
import { PinWagonsDto } from "./dto/pin-wagons.dto";
|
import { PinWagonsDto } from "./dto/pin-wagons.dto";
|
||||||
|
import { MoveWagonLoadDto } from "./dto/move-wagon-load.dto";
|
||||||
import { UpdateContainerItemDto } from "./dto/update-container-item.dto";
|
import { UpdateContainerItemDto } from "./dto/update-container-item.dto";
|
||||||
import { UpdateImportLoadingStatusDto } from "./dto/update-import-loading-status.dto";
|
import { UpdateImportLoadingStatusDto } from "./dto/update-import-loading-status.dto";
|
||||||
import { PreviewBulkTrainScheduleDto } from "./dto/preview-bulk-train-schedule.dto";
|
import { PreviewBulkTrainScheduleDto } from "./dto/preview-bulk-train-schedule.dto";
|
||||||
@@ -374,6 +375,20 @@ export class TrainSchedulingController {
|
|||||||
return this.trainSchedulingService.updateContainerItem(id, itemId, dto);
|
return this.trainSchedulingService.updateContainerItem(id, itemId, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post("schedules/:id/wagons/:wagonId/move-load")
|
||||||
|
@TrainSchedulingManage()
|
||||||
|
@ApiOperation({
|
||||||
|
summary:
|
||||||
|
"Move a wagon's whole load to another wagon (empty → move/repin, loaded → swap loads)",
|
||||||
|
})
|
||||||
|
moveWagonLoad(
|
||||||
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
|
@Param("wagonId", ParseUUIDPipe) wagonId: string,
|
||||||
|
@Body() dto: MoveWagonLoadDto,
|
||||||
|
) {
|
||||||
|
return this.trainSchedulingService.moveWagonLoad(id, wagonId, dto);
|
||||||
|
}
|
||||||
|
|
||||||
@Get("schedules/:id/unassigned-bookings")
|
@Get("schedules/:id/unassigned-bookings")
|
||||||
@TrainSchedulingView()
|
@TrainSchedulingView()
|
||||||
@ApiOperation({ summary: "Get unassigned bookings for a schedule" })
|
@ApiOperation({ summary: "Get unassigned bookings for a schedule" })
|
||||||
|
|||||||
@@ -80,7 +80,12 @@ const makeBooking = (
|
|||||||
|
|
||||||
describe('TrainSchedulingService', () => {
|
describe('TrainSchedulingService', () => {
|
||||||
let service: TrainSchedulingService;
|
let service: TrainSchedulingService;
|
||||||
let dataSource: { getRepository: jest.Mock; transaction: jest.Mock; query: jest.Mock };
|
let dataSource: {
|
||||||
|
getRepository: jest.Mock;
|
||||||
|
transaction: jest.Mock;
|
||||||
|
query: jest.Mock;
|
||||||
|
manager: { getRepository: jest.Mock };
|
||||||
|
};
|
||||||
let bookingsRepository: Record<string, jest.Mock>;
|
let bookingsRepository: Record<string, jest.Mock>;
|
||||||
let locomotivesRepository: { findById: jest.Mock; findAll: jest.Mock };
|
let locomotivesRepository: { findById: jest.Mock; findAll: jest.Mock };
|
||||||
let wagonTypesRepository: { findAll: jest.Mock };
|
let wagonTypesRepository: { findAll: jest.Mock };
|
||||||
@@ -91,11 +96,23 @@ describe('TrainSchedulingService', () => {
|
|||||||
let wagonAllocationBulkLoadsRepository: Record<string, jest.Mock>;
|
let wagonAllocationBulkLoadsRepository: Record<string, jest.Mock>;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
// findGroupSiblings runs a query builder off dataSource.manager; default it
|
||||||
|
// to "no sibling schedules" so isolated unit tests don't need to wire it.
|
||||||
|
const emptySiblingQb = {
|
||||||
|
where: jest.fn().mockReturnThis(),
|
||||||
|
andWhere: jest.fn().mockReturnThis(),
|
||||||
|
getMany: jest.fn().mockResolvedValue([]),
|
||||||
|
};
|
||||||
dataSource = {
|
dataSource = {
|
||||||
getRepository: jest.fn(),
|
getRepository: jest.fn(),
|
||||||
transaction: jest.fn(),
|
transaction: jest.fn(),
|
||||||
// Raw-SQL helper lookups (e.g. builtTrainIdOfSchedule) default to "no rows".
|
// Raw-SQL helper lookups (e.g. builtTrainIdOfSchedule) default to "no rows".
|
||||||
query: jest.fn().mockResolvedValue([]),
|
query: jest.fn().mockResolvedValue([]),
|
||||||
|
manager: {
|
||||||
|
getRepository: jest.fn(() => ({
|
||||||
|
createQueryBuilder: jest.fn(() => emptySiblingQb),
|
||||||
|
})),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
bookingsRepository = {
|
bookingsRepository = {
|
||||||
findEligibleForScheduling: jest.fn(),
|
findEligibleForScheduling: jest.fn(),
|
||||||
@@ -110,9 +127,11 @@ describe('TrainSchedulingService', () => {
|
|||||||
findByIdWithFullGraph: jest.fn(),
|
findByIdWithFullGraph: jest.fn(),
|
||||||
findAll: jest.fn(),
|
findAll: jest.fn(),
|
||||||
updateStatus: jest.fn(),
|
updateStatus: jest.fn(),
|
||||||
|
maxReferenceSequence: jest.fn().mockResolvedValue(0),
|
||||||
};
|
};
|
||||||
trainScheduleBookingsRepository = {
|
trainScheduleBookingsRepository = {
|
||||||
findByBookingIds: jest.fn(),
|
findByBookingIds: jest.fn(),
|
||||||
|
findByScheduleId: jest.fn().mockResolvedValue([]),
|
||||||
createMany: jest.fn(),
|
createMany: jest.fn(),
|
||||||
deleteByScheduleAndBooking: jest.fn(),
|
deleteByScheduleAndBooking: jest.fn(),
|
||||||
};
|
};
|
||||||
@@ -327,7 +346,11 @@ describe('TrainSchedulingService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('allows preview when bookings are already on the target schedule', async () => {
|
it('allows preview when bookings are already on the target schedule', async () => {
|
||||||
const bookings = [makeBooking('b1', 'BKG-CONT-001', 500, 20, '40FT', 20)];
|
// A booking already pinned to the target schedule is exempt from the
|
||||||
|
// corridor/day/status gates — mark it so on the entity, matching the link row.
|
||||||
|
const bookings = [
|
||||||
|
{ ...makeBooking('b1', 'BKG-CONT-001', 500, 20, '40FT', 20), trainScheduleId: 'sched-target' },
|
||||||
|
];
|
||||||
|
|
||||||
wagonTypesRepository.findAll.mockResolvedValue([nw5]);
|
wagonTypesRepository.findAll.mockResolvedValue([nw5]);
|
||||||
bookingsRepository.findByIdsForScheduling.mockResolvedValue(bookings);
|
bookingsRepository.findByIdsForScheduling.mockResolvedValue(bookings);
|
||||||
@@ -354,7 +377,10 @@ describe('TrainSchedulingService', () => {
|
|||||||
expect(result.valid).toBe(true);
|
expect(result.valid).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('allows preview when selected bookings are on different schedule dates', async () => {
|
it('flags a booking scheduled for a different day than the train departure', async () => {
|
||||||
|
// The old cross-booking "must share the same schedule date" rule is gone;
|
||||||
|
// the live rule is that every booking must match the departure day. b2
|
||||||
|
// departs a day later, so it's the one flagged.
|
||||||
const bookings = [
|
const bookings = [
|
||||||
makeBooking('b1', 'BKG-CONT-001', 500, 20, '40FT', 20, '2026-06-20T08:00:00.000Z'),
|
makeBooking('b1', 'BKG-CONT-001', 500, 20, '40FT', 20, '2026-06-20T08:00:00.000Z'),
|
||||||
makeBooking('b2', 'BKG-CONT-002', 300, 10, '20FT', 10, '2026-06-21T14:00:00.000Z'),
|
makeBooking('b2', 'BKG-CONT-002', 300, 10, '20FT', 10, '2026-06-21T14:00:00.000Z'),
|
||||||
@@ -375,7 +401,8 @@ describe('TrainSchedulingService', () => {
|
|||||||
expect(result.violations).not.toContain(
|
expect(result.violations).not.toContain(
|
||||||
'Selected bookings must share the same schedule date',
|
'Selected bookings must share the same schedule date',
|
||||||
);
|
);
|
||||||
expect(result.valid).toBe(true);
|
expect(result.violations.some((v) => v.includes('different day'))).toBe(true);
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects bookings that are not in schedulable status', async () => {
|
it('rejects bookings that are not in schedulable status', async () => {
|
||||||
@@ -408,6 +435,8 @@ describe('TrainSchedulingService', () => {
|
|||||||
originYardId: 'yard-origin',
|
originYardId: 'yard-origin',
|
||||||
destinationYardId: 'yard-destination',
|
destinationYardId: 'yard-destination',
|
||||||
isActive: true,
|
isActive: true,
|
||||||
|
status: 'AVAILABLE',
|
||||||
|
direction: 'IMPORT',
|
||||||
};
|
};
|
||||||
|
|
||||||
const locomotive2 = { ...locomotive, id: 'loc-2', code: 'LOC-002' };
|
const locomotive2 = { ...locomotive, id: 'loc-2', code: 'LOC-002' };
|
||||||
@@ -421,6 +450,12 @@ describe('TrainSchedulingService', () => {
|
|||||||
const trainScheduleRepo = {
|
const trainScheduleRepo = {
|
||||||
create: jest.fn().mockImplementation((value) => value),
|
create: jest.fn().mockImplementation((value) => value),
|
||||||
save: jest.fn().mockResolvedValue({ id: 'schedule-1' }),
|
save: jest.fn().mockResolvedValue({ id: 'schedule-1' }),
|
||||||
|
// findGroupWindowAnchor looks for same-day sibling schedules; none here.
|
||||||
|
createQueryBuilder: jest.fn(() => ({
|
||||||
|
where: jest.fn().mockReturnThis(),
|
||||||
|
andWhere: jest.fn().mockReturnThis(),
|
||||||
|
getMany: jest.fn().mockResolvedValue([]),
|
||||||
|
})),
|
||||||
};
|
};
|
||||||
const trainSetRepo = {
|
const trainSetRepo = {
|
||||||
create: jest.fn().mockImplementation((value) => value),
|
create: jest.fn().mockImplementation((value) => value),
|
||||||
@@ -464,19 +499,21 @@ describe('TrainSchedulingService', () => {
|
|||||||
callback(manager),
|
callback(manager),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Departure must clear the import lead window (≥ importWindowLeadDays ahead
|
||||||
|
// of now), so use a comfortably-future date rather than a hardcoded one.
|
||||||
|
const futureDeparture = new Date(Date.now() + 10 * 24 * 60 * 60 * 1000).toISOString();
|
||||||
const result = await service.createContainerTrainSchedule({
|
const result = await service.createContainerTrainSchedule({
|
||||||
routeId: 'route-1',
|
routeId: 'route-1',
|
||||||
scheduleDate: '2026-06-20T08:00:00.000Z',
|
scheduleDate: futureDeparture,
|
||||||
locomotiveIds: ['loc-1', 'loc-2'],
|
locomotiveIds: ['loc-1', 'loc-2'],
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(trainSetRepo.save).toHaveBeenCalled();
|
expect(trainSetRepo.save).toHaveBeenCalled();
|
||||||
expect(trainScheduleRepo.save).toHaveBeenCalled();
|
expect(trainScheduleRepo.save).toHaveBeenCalled();
|
||||||
expect(trainSetLocomotiveRepo.save).toHaveBeenCalled();
|
expect(trainSetLocomotiveRepo.save).toHaveBeenCalled();
|
||||||
expect(lockedLocomotiveRepo.update).toHaveBeenCalledWith(
|
// Advance scheduling locks locomotives but does NOT flip them to ASSIGNED —
|
||||||
{ id: expect.objectContaining({ _type: 'in', _value: ['loc-1', 'loc-2'] }) },
|
// one locomotive may sit on several future schedules.
|
||||||
{ status: 'ASSIGNED' },
|
expect(lockedLocomotiveRepo.update).not.toHaveBeenCalled();
|
||||||
);
|
|
||||||
expect(result.id).toBe('schedule-1');
|
expect(result.id).toBe('schedule-1');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -492,7 +529,7 @@ describe('TrainSchedulingService', () => {
|
|||||||
destinationYardId: 'yard-destination',
|
destinationYardId: 'yard-destination',
|
||||||
status: 'PAID',
|
status: 'PAID',
|
||||||
bookingContainers: [],
|
bookingContainers: [],
|
||||||
cargoType: { code: 'COFFEE' },
|
cargoType: { id: 'cargo-coffee', code: 'COFFEE', wagonTypes: [cw3] },
|
||||||
};
|
};
|
||||||
|
|
||||||
wagonTypesRepository.findAll.mockImplementation(async ({ where }: { where?: { code?: string } }) => {
|
wagonTypesRepository.findAll.mockImplementation(async ({ where }: { where?: { code?: string } }) => {
|
||||||
@@ -511,7 +548,9 @@ describe('TrainSchedulingService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(result.valid).toBe(true);
|
expect(result.valid).toBe(true);
|
||||||
expect(result.summary.wagonType).toBe('MIXED');
|
// Mixed freight now labels the summary by the concrete wagon type codes it uses.
|
||||||
|
expect(result.summary.wagonType).toContain('NW5');
|
||||||
|
expect(result.summary.wagonType).toContain('CW3');
|
||||||
expect(result.wagonPlan.length).toBeGreaterThan(2);
|
expect(result.wagonPlan.length).toBeGreaterThan(2);
|
||||||
expect(result.containerUnits).toHaveLength(2);
|
expect(result.containerUnits).toHaveLength(2);
|
||||||
});
|
});
|
||||||
@@ -536,9 +575,11 @@ describe('TrainSchedulingService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('rejects create when the locked locomotive is no longer available', async () => {
|
it('rejects create when the locked locomotive is no longer available', async () => {
|
||||||
|
// Advance scheduling only hard-blocks OUT_OF_SERVICE locomotives; other
|
||||||
|
// non-AVAILABLE states (e.g. ASSIGNED) downgrade to a warning.
|
||||||
const manager = {
|
const manager = {
|
||||||
getRepository: jest.fn(() => ({
|
getRepository: jest.fn(() => ({
|
||||||
findOne: jest.fn().mockResolvedValue({ ...locomotive, status: 'ASSIGNED' }),
|
findOne: jest.fn().mockResolvedValue({ ...locomotive, status: 'OUT_OF_SERVICE' }),
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -551,6 +592,8 @@ describe('TrainSchedulingService', () => {
|
|||||||
originYardId: 'yard-origin',
|
originYardId: 'yard-origin',
|
||||||
destinationYardId: 'yard-destination',
|
destinationYardId: 'yard-destination',
|
||||||
isActive: true,
|
isActive: true,
|
||||||
|
status: 'AVAILABLE',
|
||||||
|
direction: 'IMPORT',
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -907,7 +950,7 @@ describe('TrainSchedulingService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('getAvailableLocomotivesForRoute', () => {
|
describe('getAvailableLocomotivesForRoute', () => {
|
||||||
it('returns locomotives at the route origin yard', async () => {
|
it('returns every in-service locomotive, annotated with origin-yard presence', async () => {
|
||||||
const routeId = 'route-export';
|
const routeId = 'route-export';
|
||||||
const originYardId = 'yard-addis';
|
const originYardId = 'yard-addis';
|
||||||
const routeRepo = {
|
const routeRepo = {
|
||||||
@@ -915,6 +958,7 @@ describe('TrainSchedulingService', () => {
|
|||||||
id: routeId,
|
id: routeId,
|
||||||
name: 'Addis → Djibouti',
|
name: 'Addis → Djibouti',
|
||||||
isActive: true,
|
isActive: true,
|
||||||
|
status: 'AVAILABLE',
|
||||||
originYardId,
|
originYardId,
|
||||||
originYard: { country: 'Ethiopia' },
|
originYard: { country: 'Ethiopia' },
|
||||||
destinationYard: { country: 'Djibouti' },
|
destinationYard: { country: 'Djibouti' },
|
||||||
@@ -924,21 +968,21 @@ describe('TrainSchedulingService', () => {
|
|||||||
if ((entity as { name?: string })?.name === 'Route') return routeRepo;
|
if ((entity as { name?: string })?.name === 'Route') return routeRepo;
|
||||||
return { findOne: jest.fn(), update: jest.fn() };
|
return { findOne: jest.fn(), update: jest.fn() };
|
||||||
});
|
});
|
||||||
|
// Advance-scheduling picker: nothing is filtered by yard — every in-service
|
||||||
|
// locomotive is returned and annotated with whether it's at the origin yet.
|
||||||
locomotivesRepository.findAll.mockResolvedValue([
|
locomotivesRepository.findAll.mockResolvedValue([
|
||||||
{ id: 'l2', code: 'EXP', status: 'AVAILABLE', currentYardId: originYardId },
|
{ id: 'l2', code: 'EXP', status: 'AVAILABLE', currentYardId: originYardId },
|
||||||
|
{ id: 'l3', code: 'FAR', status: 'ASSIGNED', currentYardId: 'yard-elsewhere' },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const result = await service.getAvailableLocomotivesForRoute(routeId);
|
const result = await service.getAvailableLocomotivesForRoute(routeId);
|
||||||
|
|
||||||
expect(locomotivesRepository.findAll).toHaveBeenCalledWith({
|
expect(result).toHaveLength(2);
|
||||||
where: { status: 'AVAILABLE', currentYardId: originYardId },
|
expect(result.find((l) => l.code === 'EXP')?.atOriginYard).toBe(true);
|
||||||
order: { code: 'ASC' },
|
expect(result.find((l) => l.code === 'FAR')?.atOriginYard).toBe(false);
|
||||||
});
|
|
||||||
expect(result).toHaveLength(1);
|
|
||||||
expect(result[0].code).toBe('EXP');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns all locomotives returned by the repository for domestic routes', async () => {
|
it('rejects intercity (domestic) routes — intercity scheduling is not offered', async () => {
|
||||||
const routeId = 'route-domestic';
|
const routeId = 'route-domestic';
|
||||||
const originYardId = 'yard-addis';
|
const originYardId = 'yard-addis';
|
||||||
const routeRepo = {
|
const routeRepo = {
|
||||||
@@ -946,6 +990,7 @@ describe('TrainSchedulingService', () => {
|
|||||||
id: routeId,
|
id: routeId,
|
||||||
name: 'Addis → Dire Dawa',
|
name: 'Addis → Dire Dawa',
|
||||||
isActive: true,
|
isActive: true,
|
||||||
|
status: 'AVAILABLE',
|
||||||
originYardId,
|
originYardId,
|
||||||
originYard: { country: 'Ethiopia' },
|
originYard: { country: 'Ethiopia' },
|
||||||
destinationYard: { country: 'Ethiopia' },
|
destinationYard: { country: 'Ethiopia' },
|
||||||
@@ -955,14 +1000,10 @@ describe('TrainSchedulingService', () => {
|
|||||||
if ((entity as { name?: string })?.name === 'Route') return routeRepo;
|
if ((entity as { name?: string })?.name === 'Route') return routeRepo;
|
||||||
return { findOne: jest.fn(), update: jest.fn() };
|
return { findOne: jest.fn(), update: jest.fn() };
|
||||||
});
|
});
|
||||||
locomotivesRepository.findAll.mockResolvedValue([
|
|
||||||
{ id: 'l1', code: 'IMP', status: 'AVAILABLE', currentYardId: originYardId },
|
|
||||||
{ id: 'l2', code: 'EXP', status: 'AVAILABLE', currentYardId: originYardId },
|
|
||||||
]);
|
|
||||||
|
|
||||||
const result = await service.getAvailableLocomotivesForRoute(routeId);
|
await expect(
|
||||||
|
service.getAvailableLocomotivesForRoute(routeId),
|
||||||
expect(result).toHaveLength(2);
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1081,4 +1122,172 @@ describe('TrainSchedulingService', () => {
|
|||||||
expect(html).not.toContain('EMPTY');
|
expect(html).not.toContain('EMPTY');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('moveWagonLoad — staff rearrange', () => {
|
||||||
|
const containerType = {
|
||||||
|
code: 'NX70',
|
||||||
|
supportedLoadTypes: ['CONTAINER'],
|
||||||
|
supportsContainer: true,
|
||||||
|
};
|
||||||
|
let slotA: Record<string, unknown>;
|
||||||
|
let slotB: Record<string, unknown>;
|
||||||
|
let allocsByWagon: Record<string, Array<Record<string, unknown>>>;
|
||||||
|
let allocRepo: { find: jest.Mock; update: jest.Mock };
|
||||||
|
let slotRepo: { update: jest.Mock };
|
||||||
|
let wagonRepo: { findOne: jest.Mock };
|
||||||
|
|
||||||
|
const makeSchedule = (over: Record<string, unknown> = {}) => ({
|
||||||
|
id: 'sched-1',
|
||||||
|
status: 'SCHEDULED',
|
||||||
|
trainSetId: 'ts-1',
|
||||||
|
trainSet: { trainId: 'train-1', wagons: [slotA, slotB] },
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
slotA = {
|
||||||
|
id: 'wA',
|
||||||
|
sequenceNo: 1,
|
||||||
|
capacityTons: 61,
|
||||||
|
lengthMeters: 14,
|
||||||
|
assignedWeightTons: 40,
|
||||||
|
status: 'RESERVED',
|
||||||
|
boardYardId: 'yard-1',
|
||||||
|
alightYardId: null,
|
||||||
|
wagonType: containerType,
|
||||||
|
};
|
||||||
|
slotB = {
|
||||||
|
id: 'wB',
|
||||||
|
sequenceNo: 2,
|
||||||
|
capacityTons: 61,
|
||||||
|
lengthMeters: 14,
|
||||||
|
assignedWeightTons: 25,
|
||||||
|
status: 'RESERVED',
|
||||||
|
boardYardId: null,
|
||||||
|
alightYardId: null,
|
||||||
|
wagonType: containerType,
|
||||||
|
};
|
||||||
|
allocsByWagon = {
|
||||||
|
// 20ft pair (two allocations sharing wagon A) — must travel together.
|
||||||
|
wA: [
|
||||||
|
{ id: 'alloc-a1', trainSetWagonId: 'wA', bookingId: 'b1', allocatedWeightTons: 20, loadType: 'CONTAINER' },
|
||||||
|
{ id: 'alloc-a2', trainSetWagonId: 'wA', bookingId: 'b2', allocatedWeightTons: 20, loadType: 'CONTAINER' },
|
||||||
|
],
|
||||||
|
// one 40ft on wagon B.
|
||||||
|
wB: [
|
||||||
|
{ id: 'alloc-b1', trainSetWagonId: 'wB', bookingId: 'b3', allocatedWeightTons: 25, loadType: 'CONTAINER' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue(makeSchedule());
|
||||||
|
allocRepo = {
|
||||||
|
find: jest.fn().mockImplementation(({ where }: { where: { trainSetWagonId: string } }) =>
|
||||||
|
Promise.resolve(allocsByWagon[where.trainSetWagonId] ?? []),
|
||||||
|
),
|
||||||
|
update: jest.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
slotRepo = { update: jest.fn().mockResolvedValue(undefined) };
|
||||||
|
wagonRepo = { findOne: jest.fn().mockResolvedValue(null) };
|
||||||
|
dataSource.getRepository.mockImplementation((entity: unknown) => {
|
||||||
|
if (entity === WagonBookingAllocation) return allocRepo;
|
||||||
|
if (entity === TrainSetWagon) return slotRepo;
|
||||||
|
if (entity === Wagon) return wagonRepo;
|
||||||
|
return { find: jest.fn().mockResolvedValue([]) };
|
||||||
|
});
|
||||||
|
dataSource.transaction.mockImplementation(
|
||||||
|
async (fn: (m: unknown) => Promise<void>) =>
|
||||||
|
fn({ getRepository: dataSource.getRepository }),
|
||||||
|
);
|
||||||
|
jest
|
||||||
|
.spyOn(
|
||||||
|
service as never as { getTrainScheduleById: (id: string) => Promise<unknown> },
|
||||||
|
'getTrainScheduleById' as never,
|
||||||
|
)
|
||||||
|
.mockResolvedValue({ id: 'sched-1' } as never);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects moves on a dispatched train', async () => {
|
||||||
|
trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue(
|
||||||
|
makeSchedule({ status: 'DISPATCHED' }),
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
service.moveWagonLoad('sched-1', 'wA', { targetWagonId: 'wB' }),
|
||||||
|
).rejects.toThrow(BadRequestException);
|
||||||
|
expect(dataSource.transaction).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('404s when the target is neither a slot nor a consist wagon of this train', async () => {
|
||||||
|
await expect(
|
||||||
|
service.moveWagonLoad('sched-1', 'wA', { targetWagonId: 'nope' }),
|
||||||
|
).rejects.toThrow(/not part of this schedule/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('swaps two loaded wagons: every allocation crosses over, load fields swap', async () => {
|
||||||
|
await service.moveWagonLoad('sched-1', 'wA', { targetWagonId: 'wB' });
|
||||||
|
|
||||||
|
// The 20ft pair moved together onto wagon B…
|
||||||
|
expect(allocRepo.update).toHaveBeenCalledWith('alloc-a1', { trainSetWagonId: 'wB' });
|
||||||
|
expect(allocRepo.update).toHaveBeenCalledWith('alloc-a2', { trainSetWagonId: 'wB' });
|
||||||
|
// …and the 40ft came back to wagon A.
|
||||||
|
expect(allocRepo.update).toHaveBeenCalledWith('alloc-b1', { trainSetWagonId: 'wA' });
|
||||||
|
// Load-coupled slot fields follow their loads.
|
||||||
|
expect(slotRepo.update).toHaveBeenCalledWith('wB', {
|
||||||
|
assignedWeightTons: 40,
|
||||||
|
status: 'RESERVED',
|
||||||
|
boardYardId: 'yard-1',
|
||||||
|
alightYardId: null,
|
||||||
|
});
|
||||||
|
expect(slotRepo.update).toHaveBeenCalledWith('wA', {
|
||||||
|
assignedWeightTons: 25,
|
||||||
|
status: 'RESERVED',
|
||||||
|
boardYardId: null,
|
||||||
|
alightYardId: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('repins the slot onto an empty consist-only wagon (the 404 case)', async () => {
|
||||||
|
wagonRepo.findOne.mockResolvedValue({
|
||||||
|
id: 'phys-9',
|
||||||
|
wagonTypeId: 'wt-1',
|
||||||
|
wagonNumber: 'WGN-9',
|
||||||
|
wagonType: { ...containerType, capacityTons: 70, lengthMeters: 14 },
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.moveWagonLoad('sched-1', 'wA', { targetWagonId: 'phys-9' });
|
||||||
|
|
||||||
|
expect(wagonRepo.findOne).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ where: { id: 'phys-9', trainId: 'train-1' } }),
|
||||||
|
);
|
||||||
|
// Repin: wagon identity moves onto the slot; allocations stay put.
|
||||||
|
expect(slotRepo.update).toHaveBeenCalledWith('wA', {
|
||||||
|
physicalWagonId: 'phys-9',
|
||||||
|
wagonTypeId: 'wt-1',
|
||||||
|
capacityTons: 70,
|
||||||
|
lengthMeters: 14,
|
||||||
|
});
|
||||||
|
expect(allocRepo.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a bulk load onto a wagon whose type only supports containers', async () => {
|
||||||
|
allocsByWagon.wA = [
|
||||||
|
{ id: 'alloc-bulk', trainSetWagonId: 'wA', bookingId: 'b9', allocatedWeightTons: 50, loadType: 'BULK' },
|
||||||
|
];
|
||||||
|
allocsByWagon.wB = [];
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.moveWagonLoad('sched-1', 'wA', { targetWagonId: 'wB' }),
|
||||||
|
).rejects.toThrow(/cannot carry a bulk load/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects when the incoming load exceeds the receiving wagon payload', async () => {
|
||||||
|
allocsByWagon.wA = [
|
||||||
|
{ id: 'alloc-heavy', trainSetWagonId: 'wA', bookingId: 'b9', allocatedWeightTons: 70, loadType: 'CONTAINER' },
|
||||||
|
];
|
||||||
|
allocsByWagon.wB = [];
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.moveWagonLoad('sched-1', 'wA', { targetWagonId: 'wB' }),
|
||||||
|
).rejects.toThrow(/over its/);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ import {
|
|||||||
TrainScheduleFreightType,
|
TrainScheduleFreightType,
|
||||||
} from './dto/list-train-schedules-query.dto';
|
} from './dto/list-train-schedules-query.dto';
|
||||||
import { PinWagonsDto } from './dto/pin-wagons.dto';
|
import { PinWagonsDto } from './dto/pin-wagons.dto';
|
||||||
|
import { MoveWagonLoadDto } from './dto/move-wagon-load.dto';
|
||||||
import { UpdateContainerItemDto } from './dto/update-container-item.dto';
|
import { UpdateContainerItemDto } from './dto/update-container-item.dto';
|
||||||
import { UpdateImportLoadingStatusDto } from './dto/update-import-loading-status.dto';
|
import { UpdateImportLoadingStatusDto } from './dto/update-import-loading-status.dto';
|
||||||
import { PreviewBulkTrainScheduleDto } from './dto/preview-bulk-train-schedule.dto';
|
import { PreviewBulkTrainScheduleDto } from './dto/preview-bulk-train-schedule.dto';
|
||||||
@@ -122,7 +123,7 @@ import {
|
|||||||
sumWagonsRequired,
|
sumWagonsRequired,
|
||||||
type TrainLimitConfig,
|
type TrainLimitConfig,
|
||||||
validateContainerPlacements,
|
validateContainerPlacements,
|
||||||
validateMixedTrainLimits,
|
validateMixedTrainLimitsPerEdge,
|
||||||
type ContainerPlacementInput,
|
type ContainerPlacementInput,
|
||||||
type WagonPlanSlot,
|
type WagonPlanSlot,
|
||||||
} from './wagon-plan.util';
|
} from './wagon-plan.util';
|
||||||
@@ -1538,8 +1539,21 @@ export class TrainSchedulingService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The rebuild below deletes EVERY schedule↔booking link row and recreates
|
||||||
|
// only what makes the new plan. Ride-along (intercity) bookings are linked
|
||||||
|
// OUTSIDE this flow — by acceptIntercity/allocate — and never appear in the
|
||||||
|
// workspace's picked ids, so planning from dto.bookingIds alone silently
|
||||||
|
// orphans them: PAID + SCHEDULED with no link and no wagon, invisible in
|
||||||
|
// every list. Every (re)assignment therefore re-plans the WHOLE train:
|
||||||
|
// the requested ids plus everything currently linked.
|
||||||
|
const linkedRows =
|
||||||
|
await this.trainScheduleBookingsRepository.findByScheduleId(scheduleId);
|
||||||
|
const allBookingIds = [
|
||||||
|
...new Set([...dto.bookingIds, ...linkedRows.map((row) => row.bookingId)]),
|
||||||
|
];
|
||||||
|
|
||||||
const previewDto = {
|
const previewDto = {
|
||||||
bookingIds: dto.bookingIds,
|
bookingIds: allBookingIds,
|
||||||
scheduleDate: schedule.scheduledDepartureDate.toISOString(),
|
scheduleDate: schedule.scheduledDepartureDate.toISOString(),
|
||||||
originStationId: schedule.originStationId,
|
originStationId: schedule.originStationId,
|
||||||
destinationStationId: schedule.destinationStationId,
|
destinationStationId: schedule.destinationStationId,
|
||||||
@@ -1562,8 +1576,13 @@ export class TrainSchedulingService {
|
|||||||
// preview the wagon plan first, then lay containers into the plan's slots.
|
// preview the wagon plan first, then lay containers into the plan's slots.
|
||||||
// Without this the placement validator rejects container bookings outright
|
// Without this the placement validator rejects container bookings outright
|
||||||
// ("Container placements are required for container bookings").
|
// ("Container placements are required for container bookings").
|
||||||
|
// Callers hand-pick placements only for the bookings they know about; the
|
||||||
|
// union above may have folded in linked ride-alongs those placements never
|
||||||
|
// covered. Auto-fill whatever units are missing (all of them when no
|
||||||
|
// placements were sent at all) so the placement validator doesn't reject
|
||||||
|
// container bookings the caller couldn't have placed.
|
||||||
let containerPlacements = dto.containerPlacements;
|
let containerPlacements = dto.containerPlacements;
|
||||||
if (!containerPlacements?.length) {
|
{
|
||||||
const preview = await this.validateBookingsForScheduling(
|
const preview = await this.validateBookingsForScheduling(
|
||||||
previewDto,
|
previewDto,
|
||||||
freightType ?? null,
|
freightType ?? null,
|
||||||
@@ -1578,18 +1597,28 @@ export class TrainSchedulingService {
|
|||||||
);
|
);
|
||||||
if (containerBookings.length) {
|
if (containerBookings.length) {
|
||||||
const units = expandBookingContainerUnits(containerBookings);
|
const units = expandBookingContainerUnits(containerBookings);
|
||||||
const slots = getContainerSlotSequenceNos(preview.wagonPlan);
|
const providedKeys = new Set(
|
||||||
const generated = autoFillPlacements(units, slots);
|
(containerPlacements ?? []).map(
|
||||||
const missing = findMissingContainerNumberIssues(units, generated);
|
(p) => `${p.bookingContainerId}:${p.unitIndex}`,
|
||||||
if (missing.length) {
|
),
|
||||||
throw new BadRequestException({
|
);
|
||||||
message: `Booking validation failed: ${missing
|
const unplacedUnits = units.filter(
|
||||||
.map((m) => m.issue)
|
(u) => !providedKeys.has(`${u.bookingContainerId}:${u.unitIndex}`),
|
||||||
.join('; ')}`,
|
);
|
||||||
violations: missing.map((m) => m.issue),
|
if (unplacedUnits.length) {
|
||||||
});
|
const slots = getContainerSlotSequenceNos(preview.wagonPlan);
|
||||||
|
const generated = autoFillPlacements(unplacedUnits, slots);
|
||||||
|
const missing = findMissingContainerNumberIssues(unplacedUnits, generated);
|
||||||
|
if (missing.length) {
|
||||||
|
throw new BadRequestException({
|
||||||
|
message: `Booking validation failed: ${missing
|
||||||
|
.map((m) => m.issue)
|
||||||
|
.join('; ')}`,
|
||||||
|
violations: missing.map((m) => m.issue),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
containerPlacements = [...(containerPlacements ?? []), ...generated];
|
||||||
}
|
}
|
||||||
containerPlacements = generated;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1632,8 +1661,10 @@ export class TrainSchedulingService {
|
|||||||
// NW5 free) — the caller saw HTTP 200 and a green toast over a no-op.
|
// NW5 free) — the caller saw HTTP 200 and a green toast over a no-op.
|
||||||
// A stock shortage is a physical impossibility, so forceAssign cannot
|
// A stock shortage is a physical impossibility, so forceAssign cannot
|
||||||
// override it either.
|
// override it either.
|
||||||
|
// Linked ride-alongs count as requested too: silently dropping one here is
|
||||||
|
// exactly the delete-and-recreate orphan this method must never produce.
|
||||||
const plannedIds = new Set(validation.bookings.map((b) => b.id));
|
const plannedIds = new Set(validation.bookings.map((b) => b.id));
|
||||||
const droppedRequested = dto.bookingIds.filter((id) => !plannedIds.has(id));
|
const droppedRequested = allBookingIds.filter((id) => !plannedIds.has(id));
|
||||||
if (droppedRequested.length) {
|
if (droppedRequested.length) {
|
||||||
const reasonById = new Map(
|
const reasonById = new Map(
|
||||||
validation.deferredBookings.map((d) => [d.id, `${d.reference}: ${d.reason}`]),
|
validation.deferredBookings.map((d) => [d.id, `${d.reference}: ${d.reason}`]),
|
||||||
@@ -2780,13 +2811,12 @@ export class TrainSchedulingService {
|
|||||||
return [
|
return [
|
||||||
`<tr class="empty">
|
`<tr class="empty">
|
||||||
${wagonCells}
|
${wagonCells}
|
||||||
<td colspan="6">EMPTY — no cargo allocated</td>
|
<td colspan="4">EMPTY — no cargo allocated</td>
|
||||||
</tr>`,
|
</tr>`,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
return allocations.map((allocation) => {
|
return allocations.map((allocation) => {
|
||||||
const booking = allocation.booking ?? bookingById.get(allocation.bookingId);
|
const booking = allocation.booking ?? bookingById.get(allocation.bookingId);
|
||||||
const company = booking?.company as Record<string, unknown> | null | undefined;
|
|
||||||
const cargoType = (booking as unknown as { cargoType?: { name?: string; code?: string } } | undefined)?.cargoType;
|
const cargoType = (booking as unknown as { cargoType?: { name?: string; code?: string } } | undefined)?.cargoType;
|
||||||
const containerItems = allocation.containerItems ?? [];
|
const containerItems = allocation.containerItems ?? [];
|
||||||
const firstContainer = containerItems[0];
|
const firstContainer = containerItems[0];
|
||||||
@@ -2795,8 +2825,6 @@ export class TrainSchedulingService {
|
|||||||
const chassisNumbers = containerItems.map((item) => item.chassisNumber).filter(Boolean).join(', ');
|
const chassisNumbers = containerItems.map((item) => item.chassisNumber).filter(Boolean).join(', ');
|
||||||
return `<tr>
|
return `<tr>
|
||||||
${wagonCells}
|
${wagonCells}
|
||||||
<td>${esc(company?.name ?? company?.legalName ?? company?.tradeName ?? booking?.companyId)}</td>
|
|
||||||
<td>${esc(booking?.companyId)}</td>
|
|
||||||
<td>${esc(cargoType?.name ?? cargoType?.code ?? allocation.loadType)}</td>
|
<td>${esc(cargoType?.name ?? cargoType?.code ?? allocation.loadType)}</td>
|
||||||
<td>${esc(containerNumbers || firstContainer?.containerNumber)}</td>
|
<td>${esc(containerNumbers || firstContainer?.containerNumber)}</td>
|
||||||
<td>${esc(chassisNumbers)}</td>
|
<td>${esc(chassisNumbers)}</td>
|
||||||
@@ -2878,8 +2906,6 @@ export class TrainSchedulingService {
|
|||||||
<th class="num">Equated Length</th>
|
<th class="num">Equated Length</th>
|
||||||
<th class="num">Tare Weight</th>
|
<th class="num">Tare Weight</th>
|
||||||
<th class="num">Load Capacity</th>
|
<th class="num">Load Capacity</th>
|
||||||
<th>Customer Name</th>
|
|
||||||
<th>Customer ID</th>
|
|
||||||
<th>Cargo Type</th>
|
<th>Cargo Type</th>
|
||||||
<th>Container No</th>
|
<th>Container No</th>
|
||||||
<th>Chassis No</th>
|
<th>Chassis No</th>
|
||||||
@@ -2887,7 +2913,7 @@ export class TrainSchedulingService {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
${rows || '<tr><td colspan="12">No wagons on this train set.</td></tr>'}
|
${rows || '<tr><td colspan="10">No wagons on this train set.</td></tr>'}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
@@ -3852,25 +3878,24 @@ export class TrainSchedulingService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Corridor-aware: a booking belongs on this train when its origin and
|
||||||
|
// destination lie on the schedule's stop list in order — sub-corridor
|
||||||
|
// bookings (Dire→Djibouti on an Addis→…→Djibouti train) are valid. The
|
||||||
|
// stop list is also what makes the wagon plan leg-aware below.
|
||||||
|
let stops = [dto.originStationId, dto.destinationStationId];
|
||||||
|
if (targetScheduleId) {
|
||||||
|
const target = await this.trainSchedulesRepository.findById(targetScheduleId);
|
||||||
|
if (target) stops = await this.stopYardsForSchedule(target);
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
await (async () => {
|
bookings.some((b) => {
|
||||||
// Corridor-aware: a booking belongs on this train when its origin and
|
if (targetScheduleId && b.trainScheduleId === targetScheduleId) {
|
||||||
// destination lie on the schedule's stop list in order — sub-corridor
|
return false;
|
||||||
// bookings (Dire→Djibouti on an Addis→…→Djibouti train) are valid.
|
|
||||||
let stops = [dto.originStationId, dto.destinationStationId];
|
|
||||||
if (targetScheduleId) {
|
|
||||||
const target = await this.trainSchedulesRepository.findById(targetScheduleId);
|
|
||||||
if (target) stops = await this.stopYardsForSchedule(target);
|
|
||||||
}
|
}
|
||||||
return bookings.some((b) => {
|
const fromIdx = stops.indexOf(b.originYardId);
|
||||||
if (targetScheduleId && b.trainScheduleId === targetScheduleId) {
|
const toIdx = stops.indexOf(b.destinationYardId);
|
||||||
return false;
|
return fromIdx < 0 || toIdx < 0 || fromIdx >= toIdx;
|
||||||
}
|
})
|
||||||
const fromIdx = stops.indexOf(b.originYardId);
|
|
||||||
const toIdx = stops.indexOf(b.destinationYardId);
|
|
||||||
return fromIdx < 0 || toIdx < 0 || fromIdx >= toIdx;
|
|
||||||
});
|
|
||||||
})()
|
|
||||||
) {
|
) {
|
||||||
violations.push('Selected bookings must lie on the schedule route (origin before destination)');
|
violations.push('Selected bookings must lie on the schedule route (origin before destination)');
|
||||||
}
|
}
|
||||||
@@ -3956,7 +3981,22 @@ export class TrainSchedulingService {
|
|||||||
stock = { mode: 'YARD', remainingByTypeId, codesByTypeId };
|
stock = { mode: 'YARD', remainingByTypeId, codesByTypeId };
|
||||||
}
|
}
|
||||||
|
|
||||||
const planned = planWagonsWithStock({ bookings, allowed, stock });
|
// Leg-aware stock: each booking consumes wagons only on the edges it rides,
|
||||||
|
// so a ride-along on an empty leg never competes with cargo on a full one.
|
||||||
|
const legByBookingId = new Map(
|
||||||
|
bookings.flatMap((b) => {
|
||||||
|
const from = stops.indexOf(b.originYardId);
|
||||||
|
const to = stops.indexOf(b.destinationYardId);
|
||||||
|
return from >= 0 && to > from ? [[b.id, { from, to }] as const] : [];
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const planned = planWagonsWithStock({
|
||||||
|
bookings,
|
||||||
|
allowed,
|
||||||
|
stock,
|
||||||
|
legs: legByBookingId,
|
||||||
|
edgeCount: Math.max(1, stops.length - 1),
|
||||||
|
});
|
||||||
violations.push(...planned.configIssues);
|
violations.push(...planned.configIssues);
|
||||||
const fittingBookings = planned.fitting;
|
const fittingBookings = planned.fitting;
|
||||||
const deferredBookings: DeferredBookingRow[] = planned.deferred;
|
const deferredBookings: DeferredBookingRow[] = planned.deferred;
|
||||||
@@ -4018,10 +4058,11 @@ export class TrainSchedulingService {
|
|||||||
).values(),
|
).values(),
|
||||||
];
|
];
|
||||||
pushLimit(
|
pushLimit(
|
||||||
validateMixedTrainLimits(
|
validateMixedTrainLimitsPerEdge(
|
||||||
wagonPlan,
|
wagonPlan,
|
||||||
plannedWagonTypes.length ? plannedWagonTypes : [{ lengthMeters: 14 }],
|
plannedWagonTypes.length ? plannedWagonTypes : [{ lengthMeters: 14 }],
|
||||||
trainLimits,
|
trainLimits,
|
||||||
|
stops,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
if (requireContainerPlacements && resolvedMode !== 'BULK') {
|
if (requireContainerPlacements && resolvedMode !== 'BULK') {
|
||||||
@@ -6810,12 +6851,33 @@ export class TrainSchedulingService {
|
|||||||
status: sb.booking?.status ?? null,
|
status: sb.booking?.status ?? null,
|
||||||
schedulingStatus: sb.booking?.schedulingStatus ?? null,
|
schedulingStatus: sb.booking?.schedulingStatus ?? null,
|
||||||
freightType: sb.booking?.freightType ?? null,
|
freightType: sb.booking?.freightType ?? null,
|
||||||
|
// Which leg of the corridor this booking rides — the workspace can't
|
||||||
|
// tell a ride-along (intercity) or sub-corridor booking from through
|
||||||
|
// cargo without it.
|
||||||
|
tradeDirection: sb.booking?.tradeDirection ?? null,
|
||||||
|
originYardId: sb.booking?.originYardId ?? null,
|
||||||
|
destinationYardId: sb.booking?.destinationYardId ?? null,
|
||||||
|
origin:
|
||||||
|
sb.booking?.originYard?.label ?? sb.booking?.originYard?.code ?? null,
|
||||||
|
destination:
|
||||||
|
sb.booking?.destinationYard?.label ??
|
||||||
|
sb.booking?.destinationYard?.code ??
|
||||||
|
null,
|
||||||
|
wagonsRequired:
|
||||||
|
sb.booking?.wagonsRequired != null
|
||||||
|
? Number(sb.booking.wagonsRequired)
|
||||||
|
: null,
|
||||||
|
loadedAt: sb.booking?.loadedAt?.toISOString() ?? null,
|
||||||
|
arrivedAt: sb.booking?.arrivedAt?.toISOString() ?? null,
|
||||||
// Loaded/unloaded is tracked on the schedule↔booking link, not the
|
// Loaded/unloaded is tracked on the schedule↔booking link, not the
|
||||||
// booking itself — staff flip it per booking in the workspace before
|
// booking itself — staff flip it per booking in the workspace before
|
||||||
// dispatch. Defaults UNLOADED for links written before the column.
|
// dispatch. Defaults UNLOADED for links written before the column.
|
||||||
loadingStatus: sb.loadingStatus ?? LoadingStatus.Unloaded,
|
loadingStatus: sb.loadingStatus ?? LoadingStatus.Unloaded,
|
||||||
wagonAssigned: allocatedBookingIds.has(sb.booking?.id ?? sb.bookingId),
|
wagonAssigned: allocatedBookingIds.has(sb.booking?.id ?? sb.bookingId),
|
||||||
})) ?? [],
|
})) ?? [],
|
||||||
|
// Ordered corridor stops (route milestones; falls back to the two
|
||||||
|
// endpoints) — lets the UI draw per-segment occupancy and label legs.
|
||||||
|
stops: this.mapScheduleStops(schedule),
|
||||||
// True when the wagon plan above is served from the frozen snapshot (schedule
|
// True when the wagon plan above is served from the frozen snapshot (schedule
|
||||||
// is dispatched/arrived/cancelled) rather than the live joins — the UI can badge
|
// is dispatched/arrived/cancelled) rather than the live joins — the UI can badge
|
||||||
// it "historical" and skip re-pin affordances.
|
// it "historical" and skip re-pin affordances.
|
||||||
@@ -6824,6 +6886,42 @@ export class TrainSchedulingService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Ordered corridor stops with labels, from the loaded route graph (no extra query). */
|
||||||
|
private mapScheduleStops(
|
||||||
|
schedule: TrainSchedule,
|
||||||
|
): Array<{ yardId: string; label: string }> {
|
||||||
|
const milestones = [...(schedule.route?.milestones ?? [])].sort(
|
||||||
|
(a, b) => a.sequenceNo - b.sequenceNo,
|
||||||
|
);
|
||||||
|
const raw = milestones.length >= 2
|
||||||
|
? milestones.map((m) => ({
|
||||||
|
yardId: m.yardId,
|
||||||
|
label: m.yard?.label ?? m.yard?.code ?? m.yardId,
|
||||||
|
}))
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
yardId: schedule.originStationId,
|
||||||
|
label:
|
||||||
|
schedule.originStation?.label ??
|
||||||
|
schedule.originStation?.code ??
|
||||||
|
schedule.originStationId,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
yardId: schedule.destinationStationId,
|
||||||
|
label:
|
||||||
|
schedule.destinationStation?.label ??
|
||||||
|
schedule.destinationStation?.code ??
|
||||||
|
schedule.destinationStationId,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
return raw.filter((stop) => {
|
||||||
|
if (!stop.yardId || seen.has(stop.yardId)) return false;
|
||||||
|
seen.add(stop.yardId);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private isHoldActive(booking: Booking): boolean {
|
private isHoldActive(booking: Booking): boolean {
|
||||||
if (!booking.holdExpiresAt) return false;
|
if (!booking.holdExpiresAt) return false;
|
||||||
return booking.holdExpiresAt.getTime() > Date.now();
|
return booking.holdExpiresAt.getTime() > Date.now();
|
||||||
@@ -7232,6 +7330,171 @@ export class TrainSchedulingService {
|
|||||||
return { id: itemId, containerNumber: dto.containerNumber ?? null };
|
return { id: itemId, containerNumber: dto.containerNumber ?? null };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Staff rearrange: relocate a wagon's ENTIRE load (all its allocations —
|
||||||
|
* a 40ft, a 20ft pair, or a bulk load) to another wagon of the same train.
|
||||||
|
* Whole-load moves keep every packing rule intact by construction (a valid
|
||||||
|
* load stays valid on any wagon whose type supports it), which is what lets
|
||||||
|
* a 20ft pair travel together and swap places with a 40ft, and lets bulk
|
||||||
|
* swap with containers.
|
||||||
|
*
|
||||||
|
* Three shapes, picked from the target:
|
||||||
|
* - target is an empty consist-only wagon (coupled on the built train, no
|
||||||
|
* slot row): REPIN — the source slot simply points at that physical wagon
|
||||||
|
* (type/capacity/length follow), and the wagon it left shows as empty.
|
||||||
|
* - target is an empty slot: allocations repoint to it and the load-coupled
|
||||||
|
* slot fields (assigned weight, status, board/alight leg) move across.
|
||||||
|
* - target is a loaded slot: the two loads swap wagons the same way.
|
||||||
|
*
|
||||||
|
* Validated per direction: the receiving wagon's type must support the
|
||||||
|
* incoming load type, and the incoming cargo must fit its rated payload.
|
||||||
|
*/
|
||||||
|
async moveWagonLoad(
|
||||||
|
scheduleId: string,
|
||||||
|
sourceWagonId: string,
|
||||||
|
dto: MoveWagonLoadDto,
|
||||||
|
): Promise<any> {
|
||||||
|
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||||||
|
if (!schedule) {
|
||||||
|
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
||||||
|
}
|
||||||
|
if (['DISPATCHED', 'ARRIVED'].includes(schedule.status)) {
|
||||||
|
throw new BadRequestException('Cannot rearrange loads on a dispatched train');
|
||||||
|
}
|
||||||
|
if (sourceWagonId === dto.targetWagonId) {
|
||||||
|
return this.getTrainScheduleById(scheduleId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const slots = schedule.trainSet?.wagons ?? [];
|
||||||
|
const source = slots.find((w) => w.id === sourceWagonId);
|
||||||
|
if (!source) {
|
||||||
|
throw new NotFoundException('Source wagon is not part of this schedule');
|
||||||
|
}
|
||||||
|
|
||||||
|
const allocRepo = this.dataSource.getRepository(WagonBookingAllocation);
|
||||||
|
const loadAllocations = (trainSetWagonId: string) =>
|
||||||
|
allocRepo.find({ where: { trainSetWagonId } });
|
||||||
|
const sourceAllocs = await loadAllocations(source.id);
|
||||||
|
if (!sourceAllocs.length) {
|
||||||
|
throw new BadRequestException('Source wagon has no load to move');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Target: a slot of this train set, or an empty consist-only wagon of the
|
||||||
|
// built train (physical wagon with no slot row yet).
|
||||||
|
const targetSlot = slots.find((w) => w.id === dto.targetWagonId) ?? null;
|
||||||
|
const consistWagon = targetSlot
|
||||||
|
? null
|
||||||
|
: schedule.trainSet?.trainId
|
||||||
|
? await this.dataSource.getRepository(Wagon).findOne({
|
||||||
|
where: { id: dto.targetWagonId, trainId: schedule.trainSet.trainId },
|
||||||
|
relations: { wagonType: true },
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
if (!targetSlot && !consistWagon) {
|
||||||
|
throw new NotFoundException('Target wagon is not part of this schedule');
|
||||||
|
}
|
||||||
|
const targetAllocs = targetSlot ? await loadAllocations(targetSlot.id) : [];
|
||||||
|
|
||||||
|
const loadTypesOf = (allocs: WagonBookingAllocation[]) => [
|
||||||
|
...new Set(allocs.map((a) => (a.loadType ?? 'CONTAINER').toUpperCase())),
|
||||||
|
];
|
||||||
|
const cargoOf = (allocs: WagonBookingAllocation[]) =>
|
||||||
|
allocs.reduce((sum, a) => sum + Number(a.allocatedWeightTons || 0), 0);
|
||||||
|
const wagonLabel = (slot: { sequenceNo: number } | null, wagon: Wagon | null) =>
|
||||||
|
slot ? `#${slot.sequenceNo}` : (wagon?.wagonNumber ?? 'the target wagon');
|
||||||
|
const checkReceives = (
|
||||||
|
allocs: WagonBookingAllocation[],
|
||||||
|
label: string,
|
||||||
|
wagonType: { code?: string; supportedLoadTypes?: string[]; supportsContainer?: boolean } | null | undefined,
|
||||||
|
capacityTons: number,
|
||||||
|
) => {
|
||||||
|
const incoming = loadTypesOf(allocs);
|
||||||
|
// Unknown type or no declared support list → staff decides; don't block.
|
||||||
|
if (wagonType) {
|
||||||
|
const supported = (wagonType.supportedLoadTypes ?? []).map((t) => t.toUpperCase());
|
||||||
|
for (const loadType of incoming) {
|
||||||
|
const ok =
|
||||||
|
supported.includes(loadType) ||
|
||||||
|
(loadType === 'CONTAINER' && wagonType.supportsContainer) ||
|
||||||
|
supported.length === 0;
|
||||||
|
if (!ok) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Wagon ${label} (${wagonType.code ?? 'unknown type'}) cannot carry a ${loadType.toLowerCase()} load`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const cargo = cargoOf(allocs);
|
||||||
|
if (capacityTons > 0 && cargo > capacityTons + 0.001) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Wagon ${label} would carry ${roundTons(cargo)}T — over its ${roundTons(capacityTons)}T payload`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// What the target must be able to receive…
|
||||||
|
checkReceives(
|
||||||
|
sourceAllocs,
|
||||||
|
wagonLabel(targetSlot, consistWagon),
|
||||||
|
targetSlot ? targetSlot.wagonType : consistWagon?.wagonType,
|
||||||
|
Number(targetSlot ? targetSlot.capacityTons : (consistWagon?.wagonType?.capacityTons ?? 0)),
|
||||||
|
);
|
||||||
|
// …and, on a swap, what comes back to the source.
|
||||||
|
if (targetAllocs.length) {
|
||||||
|
checkReceives(
|
||||||
|
targetAllocs,
|
||||||
|
`#${source.sequenceNo}`,
|
||||||
|
source.wagonType,
|
||||||
|
Number(source.capacityTons),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.dataSource.transaction(async (manager) => {
|
||||||
|
const slotRepo = manager.getRepository(TrainSetWagon);
|
||||||
|
const allocs = manager.getRepository(WagonBookingAllocation);
|
||||||
|
|
||||||
|
// Empty consist wagon: repin the loaded slot onto that physical wagon.
|
||||||
|
// Allocations and load fields stay put; only the wagon identity changes.
|
||||||
|
if (consistWagon) {
|
||||||
|
await slotRepo.update(source.id, {
|
||||||
|
physicalWagonId: consistWagon.id,
|
||||||
|
wagonTypeId: consistWagon.wagonTypeId,
|
||||||
|
capacityTons: roundTons(Number(consistWagon.wagonType?.capacityTons ?? source.capacityTons)),
|
||||||
|
lengthMeters: roundTons(Number(consistWagon.wagonType?.lengthMeters ?? source.lengthMeters)),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = targetSlot as TrainSetWagon;
|
||||||
|
// Load-coupled slot fields travel with the load; wagon identity stays.
|
||||||
|
const loadFieldsOf = (slot: TrainSetWagon) => ({
|
||||||
|
assignedWeightTons: slot.assignedWeightTons,
|
||||||
|
status: slot.status,
|
||||||
|
boardYardId: slot.boardYardId ?? null,
|
||||||
|
alightYardId: slot.alightYardId ?? null,
|
||||||
|
});
|
||||||
|
const emptyLoadFields = {
|
||||||
|
assignedWeightTons: 0,
|
||||||
|
status: 'PLANNED',
|
||||||
|
boardYardId: null,
|
||||||
|
alightYardId: null,
|
||||||
|
};
|
||||||
|
const sourceLoadFields = loadFieldsOf(source);
|
||||||
|
const targetLoadFields = targetAllocs.length ? loadFieldsOf(target) : emptyLoadFields;
|
||||||
|
|
||||||
|
for (const alloc of sourceAllocs) {
|
||||||
|
await allocs.update(alloc.id, { trainSetWagonId: target.id });
|
||||||
|
}
|
||||||
|
for (const alloc of targetAllocs) {
|
||||||
|
await allocs.update(alloc.id, { trainSetWagonId: source.id });
|
||||||
|
}
|
||||||
|
await slotRepo.update(target.id, sourceLoadFields);
|
||||||
|
await slotRepo.update(source.id, targetLoadFields);
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.getTrainScheduleById(scheduleId);
|
||||||
|
}
|
||||||
|
|
||||||
async getUnassignedBookings(scheduleId: string): Promise<UnassignedBookingsResponse> {
|
async getUnassignedBookings(scheduleId: string): Promise<UnassignedBookingsResponse> {
|
||||||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||||||
if (!schedule) {
|
if (!schedule) {
|
||||||
|
|||||||
@@ -187,3 +187,115 @@ describe('applyWagonOrderReversal', () => {
|
|||||||
expect(plan.map((s) => s.wagonTypeId)).toEqual(['wt-a', 'wt-b', 'wt-c']);
|
expect(plan.map((s) => s.wagonTypeId)).toEqual(['wt-a', 'wt-b', 'wt-c']);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('planWagonsWithStock — leg-aware stock (intercity ride-along)', () => {
|
||||||
|
const allowed = {
|
||||||
|
byContainerTypeId: new Map([['ct-1', [nw6]]]),
|
||||||
|
byCargoTypeId: new Map(),
|
||||||
|
};
|
||||||
|
// Corridor Gelan(0) → Adama(1) → Doraleh(2): edges 0 and 1.
|
||||||
|
const legs = (entries: Array<[string, { from: number; to: number }]>) =>
|
||||||
|
new Map(entries);
|
||||||
|
|
||||||
|
it('lets an intercity booking ride the empty leg of a train that is full on the other leg', () => {
|
||||||
|
// 1 wagon in stock. Export rides edge 1 only; intercity rides edge 0 only.
|
||||||
|
const result = planWagonsWithStock({
|
||||||
|
bookings: [
|
||||||
|
containerBooking('EXPORT-1', 1, 1),
|
||||||
|
containerBooking('INTERCITY-1', 1, 1),
|
||||||
|
],
|
||||||
|
allowed,
|
||||||
|
stock: {
|
||||||
|
mode: 'TRAIN',
|
||||||
|
remainingByTypeId: new Map([[nw6.id, 1]]),
|
||||||
|
codesByTypeId: new Map([[nw6.id, nw6.code]]),
|
||||||
|
},
|
||||||
|
legs: legs([
|
||||||
|
['EXPORT-1', { from: 1, to: 2 }],
|
||||||
|
['INTERCITY-1', { from: 0, to: 1 }],
|
||||||
|
]),
|
||||||
|
edgeCount: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.deferred).toHaveLength(0);
|
||||||
|
expect(result.fitting.map((b) => b.id).sort()).toEqual([
|
||||||
|
'EXPORT-1',
|
||||||
|
'INTERCITY-1',
|
||||||
|
]);
|
||||||
|
// Two slots planned, but both drawn from the single physical wagon.
|
||||||
|
expect(result.plan).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still defers when the legs overlap and stock is exhausted', () => {
|
||||||
|
const result = planWagonsWithStock({
|
||||||
|
bookings: [
|
||||||
|
containerBooking('EXPORT-1', 1, 1),
|
||||||
|
containerBooking('INTERCITY-1', 1, 1),
|
||||||
|
],
|
||||||
|
allowed,
|
||||||
|
stock: {
|
||||||
|
mode: 'TRAIN',
|
||||||
|
remainingByTypeId: new Map([[nw6.id, 1]]),
|
||||||
|
codesByTypeId: new Map([[nw6.id, nw6.code]]),
|
||||||
|
},
|
||||||
|
legs: legs([
|
||||||
|
// Both ride edge 0 — they compete for the one wagon.
|
||||||
|
['EXPORT-1', { from: 0, to: 2 }],
|
||||||
|
['INTERCITY-1', { from: 0, to: 1 }],
|
||||||
|
]),
|
||||||
|
edgeCount: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.fitting.map((b) => b.id)).toEqual(['EXPORT-1']);
|
||||||
|
expect(result.deferred).toHaveLength(1);
|
||||||
|
expect(result.deferred[0]!.reference).toBe('INTERCITY-1');
|
||||||
|
expect(result.deferred[0]!.reason).toContain('Train has no free NW6 wagon left');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never packs bookings with different legs into the same wagon slot', () => {
|
||||||
|
// Two 20ft units with room to share one wagon by TEU — but disjoint legs
|
||||||
|
// must open separate slots (each with its own leg), not one mixed slot.
|
||||||
|
const result = planWagonsWithStock({
|
||||||
|
bookings: [
|
||||||
|
containerBooking('EXPORT-1', 1, 1),
|
||||||
|
containerBooking('INTERCITY-1', 1, 1),
|
||||||
|
],
|
||||||
|
allowed,
|
||||||
|
stock: {
|
||||||
|
mode: 'TRAIN',
|
||||||
|
remainingByTypeId: new Map([[nw6.id, 2]]),
|
||||||
|
codesByTypeId: new Map([[nw6.id, nw6.code]]),
|
||||||
|
},
|
||||||
|
legs: legs([
|
||||||
|
['EXPORT-1', { from: 1, to: 2 }],
|
||||||
|
['INTERCITY-1', { from: 0, to: 1 }],
|
||||||
|
]),
|
||||||
|
edgeCount: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.plan).toHaveLength(2);
|
||||||
|
const bookingsPerSlot = result.plan.map((s) =>
|
||||||
|
[...new Set(s.allocations.map((a) => a.bookingId))].sort(),
|
||||||
|
);
|
||||||
|
expect(bookingsPerSlot).toEqual([['EXPORT-1'], ['INTERCITY-1']]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('behaves exactly like the whole-route planner when no legs are given', () => {
|
||||||
|
const result = planWagonsWithStock({
|
||||||
|
bookings: [
|
||||||
|
containerBooking('EXPORT-1', 1, 1),
|
||||||
|
containerBooking('INTERCITY-1', 1, 1),
|
||||||
|
],
|
||||||
|
allowed,
|
||||||
|
stock: {
|
||||||
|
mode: 'TRAIN',
|
||||||
|
remainingByTypeId: new Map([[nw6.id, 1]]),
|
||||||
|
codesByTypeId: new Map([[nw6.id, nw6.code]]),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// One wagon, two 20ft bookings: they TEU-share the single slot (legacy).
|
||||||
|
expect(result.deferred).toHaveLength(0);
|
||||||
|
expect(result.plan).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -58,8 +58,18 @@ type OpenSlot = {
|
|||||||
/** Kind purity: a bulk wagon carries ONE cargo type at a time. */
|
/** Kind purity: a bulk wagon carries ONE cargo type at a time. */
|
||||||
cargoTypeId: string | null;
|
cargoTypeId: string | null;
|
||||||
freeCapacityTons: number;
|
freeCapacityTons: number;
|
||||||
|
/**
|
||||||
|
* Corridor leg this slot rides (`"from-to"` stop indexes). Bookings only
|
||||||
|
* share a slot when their legs are identical — mixing corridors in one slot
|
||||||
|
* would degrade it to a whole-route slot (see stampSlotLegs) and silently
|
||||||
|
* re-occupy edges the cargo never rides.
|
||||||
|
*/
|
||||||
|
legKey: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Stop-index range a booking occupies: edges `from..to-1` of the corridor. */
|
||||||
|
export type BookingLeg = { from: number; to: number };
|
||||||
|
|
||||||
type PlacementProblem = {
|
type PlacementProblem = {
|
||||||
kind: 'config' | 'stock';
|
kind: 'config' | 'stock';
|
||||||
message: string;
|
message: string;
|
||||||
@@ -87,7 +97,7 @@ const slotFromWagonType = (wagonType: WagonType, kind: SlotLoadType): WagonPlanS
|
|||||||
const shortageFor = (
|
const shortageFor = (
|
||||||
booking: Booking,
|
booking: Booking,
|
||||||
candidates: WagonType[],
|
candidates: WagonType[],
|
||||||
remaining: Map<string, number>,
|
availableOf: (wagonTypeId: string) => number,
|
||||||
): BookingWagonShortage => {
|
): BookingWagonShortage => {
|
||||||
const wagonsNeeded =
|
const wagonsNeeded =
|
||||||
booking.freightType === 'BULK'
|
booking.freightType === 'BULK'
|
||||||
@@ -100,7 +110,7 @@ const shortageFor = (
|
|||||||
)
|
)
|
||||||
: Math.max(1, containerWagonsForLines(booking.bookingContainers ?? []));
|
: Math.max(1, containerWagonsForLines(booking.bookingContainers ?? []));
|
||||||
const wagonsAvailable = candidates.reduce(
|
const wagonsAvailable = candidates.reduce(
|
||||||
(sum, wt) => sum + (remaining.get(wt.id) ?? 0),
|
(sum, wt) => sum + availableOf(wt.id),
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
@@ -140,14 +150,53 @@ export function planWagonsWithStock(params: {
|
|||||||
bookings: Booking[];
|
bookings: Booking[];
|
||||||
allowed: AllowedWagonTypeMap;
|
allowed: AllowedWagonTypeMap;
|
||||||
stock: WagonStock;
|
stock: WagonStock;
|
||||||
|
/**
|
||||||
|
* Leg-aware stock: booking id → the stop-index range it rides. When given
|
||||||
|
* (with `edgeCount`), a wagon type's stock is consumed PER CORRIDOR EDGE, so
|
||||||
|
* the same physical wagon can serve an intercity booking on Gelan→Adama and
|
||||||
|
* an export booking on Adama→Doraleh — disjoint legs never compete for
|
||||||
|
* stock. Omitted → one edge, byte-identical to the old whole-route behavior.
|
||||||
|
*/
|
||||||
|
legs?: Map<string, BookingLeg>;
|
||||||
|
edgeCount?: number;
|
||||||
}): FlexPlanResult {
|
}): FlexPlanResult {
|
||||||
const { bookings, allowed, stock } = params;
|
const { bookings, allowed, stock, legs } = params;
|
||||||
const remaining = new Map(stock.remainingByTypeId);
|
const edgeCount = Math.max(1, params.edgeCount ?? 1);
|
||||||
const openSlots: OpenSlot[] = [];
|
const openSlots: OpenSlot[] = [];
|
||||||
const fitting: Booking[] = [];
|
const fitting: Booking[] = [];
|
||||||
const deferred: DeferredBookingRow[] = [];
|
const deferred: DeferredBookingRow[] = [];
|
||||||
const configIssues = new Set<string>();
|
const configIssues = new Set<string>();
|
||||||
|
|
||||||
|
const legFor = (booking: Booking): BookingLeg => {
|
||||||
|
const leg = legs?.get(booking.id);
|
||||||
|
if (!leg || leg.from < 0 || leg.to > edgeCount || leg.from >= leg.to) {
|
||||||
|
return { from: 0, to: edgeCount };
|
||||||
|
}
|
||||||
|
return leg;
|
||||||
|
};
|
||||||
|
const legKeyOf = (leg: BookingLeg) => `${leg.from}-${leg.to}`;
|
||||||
|
|
||||||
|
// Wagons of a type in use per corridor edge. A type is available for a leg
|
||||||
|
// when its busiest edge WITHIN that leg still has stock spare — the max over
|
||||||
|
// edges is the number of physical wagons the type needs simultaneously.
|
||||||
|
const usedPerEdge = new Map<string, number[]>();
|
||||||
|
const usedRow = (wagonTypeId: string): number[] => {
|
||||||
|
let row = usedPerEdge.get(wagonTypeId);
|
||||||
|
if (!row) {
|
||||||
|
row = new Array<number>(edgeCount).fill(0);
|
||||||
|
usedPerEdge.set(wagonTypeId, row);
|
||||||
|
}
|
||||||
|
return row;
|
||||||
|
};
|
||||||
|
const availableFor = (wagonTypeId: string, leg: BookingLeg): number => {
|
||||||
|
const total = stock.remainingByTypeId.get(wagonTypeId) ?? 0;
|
||||||
|
const row = usedPerEdge.get(wagonTypeId);
|
||||||
|
if (!row) return total;
|
||||||
|
let busiest = 0;
|
||||||
|
for (let e = leg.from; e < leg.to; e += 1) busiest = Math.max(busiest, row[e] ?? 0);
|
||||||
|
return total - busiest;
|
||||||
|
};
|
||||||
|
|
||||||
const noStockMessage = (candidates: WagonType[]): string => {
|
const noStockMessage = (candidates: WagonType[]): string => {
|
||||||
const codes = candidates.map((wt) => wt.code).join('/');
|
const codes = candidates.map((wt) => wt.code).join('/');
|
||||||
return stock.mode === 'TRAIN'
|
return stock.mode === 'TRAIN'
|
||||||
@@ -155,13 +204,14 @@ export function planWagonsWithStock(params: {
|
|||||||
: `No available ${codes} wagon at the yard`;
|
: `No available ${codes} wagon at the yard`;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Open a new wagon of one of the candidate types, consuming stock. */
|
/** Open a new wagon of one of the candidate types, consuming stock on the leg's edges. */
|
||||||
const openSlot = (
|
const openSlot = (
|
||||||
candidates: WagonType[],
|
candidates: WagonType[],
|
||||||
kind: SlotLoadType,
|
kind: SlotLoadType,
|
||||||
cargoTypeId: string | null,
|
cargoTypeId: string | null,
|
||||||
|
leg: BookingLeg,
|
||||||
): OpenSlot | PlacementProblem => {
|
): OpenSlot | PlacementProblem => {
|
||||||
const inStock = candidates.filter((wt) => (remaining.get(wt.id) ?? 0) > 0);
|
const inStock = candidates.filter((wt) => availableFor(wt.id, leg) > 0);
|
||||||
if (!inStock.length) {
|
if (!inStock.length) {
|
||||||
return { kind: 'stock', message: noStockMessage(candidates), candidates };
|
return { kind: 'stock', message: noStockMessage(candidates), candidates };
|
||||||
}
|
}
|
||||||
@@ -170,22 +220,26 @@ export function planWagonsWithStock(params: {
|
|||||||
const chosen = [...inStock].sort((a, b) =>
|
const chosen = [...inStock].sort((a, b) =>
|
||||||
kind === 'BULK'
|
kind === 'BULK'
|
||||||
? Number(b.capacityTons) - Number(a.capacityTons) ||
|
? Number(b.capacityTons) - Number(a.capacityTons) ||
|
||||||
(remaining.get(b.id) ?? 0) - (remaining.get(a.id) ?? 0)
|
availableFor(b.id, leg) - availableFor(a.id, leg)
|
||||||
: (remaining.get(b.id) ?? 0) - (remaining.get(a.id) ?? 0),
|
: availableFor(b.id, leg) - availableFor(a.id, leg),
|
||||||
)[0];
|
)[0];
|
||||||
remaining.set(chosen.id, (remaining.get(chosen.id) ?? 0) - 1);
|
const row = usedRow(chosen.id);
|
||||||
|
for (let e = leg.from; e < leg.to; e += 1) row[e] = (row[e] ?? 0) + 1;
|
||||||
const open: OpenSlot = {
|
const open: OpenSlot = {
|
||||||
slot: slotFromWagonType(chosen, kind),
|
slot: slotFromWagonType(chosen, kind),
|
||||||
teuUsed: 0,
|
teuUsed: 0,
|
||||||
kind,
|
kind,
|
||||||
cargoTypeId,
|
cargoTypeId,
|
||||||
freeCapacityTons: Number(chosen.capacityTons),
|
freeCapacityTons: Number(chosen.capacityTons),
|
||||||
|
legKey: legKeyOf(leg),
|
||||||
};
|
};
|
||||||
openSlots.push(open);
|
openSlots.push(open);
|
||||||
return open;
|
return open;
|
||||||
};
|
};
|
||||||
|
|
||||||
const tryPlaceBooking = (booking: Booking): PlacementProblem | null => {
|
const tryPlaceBooking = (booking: Booking): PlacementProblem | null => {
|
||||||
|
const leg = legFor(booking);
|
||||||
|
const legKey = legKeyOf(leg);
|
||||||
if (booking.freightType === 'CONTAINER') {
|
if (booking.freightType === 'CONTAINER') {
|
||||||
const units = expandBookingContainerUnits([booking]);
|
const units = expandBookingContainerUnits([booking]);
|
||||||
if (!units.length) {
|
if (!units.length) {
|
||||||
@@ -209,11 +263,12 @@ export function planWagonsWithStock(params: {
|
|||||||
let target = openSlots.find(
|
let target = openSlots.find(
|
||||||
(open) =>
|
(open) =>
|
||||||
open.kind === 'CONTAINER' &&
|
open.kind === 'CONTAINER' &&
|
||||||
|
open.legKey === legKey &&
|
||||||
allowedIds.has(open.slot.wagonTypeId) &&
|
allowedIds.has(open.slot.wagonTypeId) &&
|
||||||
open.teuUsed + teu <= MAX_TEU_SLOTS_PER_WAGON,
|
open.teuUsed + teu <= MAX_TEU_SLOTS_PER_WAGON,
|
||||||
);
|
);
|
||||||
if (!target) {
|
if (!target) {
|
||||||
const openedSlot = openSlot(candidates, 'CONTAINER', null);
|
const openedSlot = openSlot(candidates, 'CONTAINER', null, leg);
|
||||||
if ('message' in openedSlot) return openedSlot;
|
if ('message' in openedSlot) return openedSlot;
|
||||||
target = openedSlot;
|
target = openedSlot;
|
||||||
}
|
}
|
||||||
@@ -246,6 +301,7 @@ export function planWagonsWithStock(params: {
|
|||||||
for (const open of openSlots) {
|
for (const open of openSlots) {
|
||||||
if (remainingWeight <= 0) break;
|
if (remainingWeight <= 0) break;
|
||||||
if (open.kind !== 'BULK') continue;
|
if (open.kind !== 'BULK') continue;
|
||||||
|
if (open.legKey !== legKey) continue;
|
||||||
if (open.cargoTypeId !== cargoTypeId) continue;
|
if (open.cargoTypeId !== cargoTypeId) continue;
|
||||||
if (!allowedIds.has(open.slot.wagonTypeId)) continue;
|
if (!allowedIds.has(open.slot.wagonTypeId)) continue;
|
||||||
if (open.freeCapacityTons <= 0) continue;
|
if (open.freeCapacityTons <= 0) continue;
|
||||||
@@ -263,7 +319,7 @@ export function planWagonsWithStock(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
while (remainingWeight > 0 || !placedAnywhere) {
|
while (remainingWeight > 0 || !placedAnywhere) {
|
||||||
const openedSlot = openSlot(candidates, 'BULK', cargoTypeId);
|
const openedSlot = openSlot(candidates, 'BULK', cargoTypeId, leg);
|
||||||
if ('message' in openedSlot) return openedSlot;
|
if ('message' in openedSlot) return openedSlot;
|
||||||
const take = roundTons(Math.min(openedSlot.freeCapacityTons, remainingWeight));
|
const take = roundTons(Math.min(openedSlot.freeCapacityTons, remainingWeight));
|
||||||
addAllocation(
|
addAllocation(
|
||||||
@@ -282,7 +338,9 @@ export function planWagonsWithStock(params: {
|
|||||||
|
|
||||||
for (const booking of sortBookingsForScheduling(bookings)) {
|
for (const booking of sortBookingsForScheduling(bookings)) {
|
||||||
// Snapshot so a booking that doesn't fully fit leaves no half-placed wagons.
|
// Snapshot so a booking that doesn't fully fit leaves no half-placed wagons.
|
||||||
const remainingSnapshot = new Map(remaining);
|
const usedSnapshot = new Map(
|
||||||
|
[...usedPerEdge.entries()].map(([typeId, row]) => [typeId, [...row]]),
|
||||||
|
);
|
||||||
const slotCountSnapshot = openSlots.length;
|
const slotCountSnapshot = openSlots.length;
|
||||||
const slotStateSnapshot = openSlots.map((open) => ({
|
const slotStateSnapshot = openSlots.map((open) => ({
|
||||||
teuUsed: open.teuUsed,
|
teuUsed: open.teuUsed,
|
||||||
@@ -299,8 +357,8 @@ export function planWagonsWithStock(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Roll back this booking's partial placements.
|
// Roll back this booking's partial placements.
|
||||||
remaining.clear();
|
usedPerEdge.clear();
|
||||||
for (const [key, value] of remainingSnapshot) remaining.set(key, value);
|
for (const [key, value] of usedSnapshot) usedPerEdge.set(key, value);
|
||||||
openSlots.length = slotCountSnapshot;
|
openSlots.length = slotCountSnapshot;
|
||||||
openSlots.forEach((open, index) => {
|
openSlots.forEach((open, index) => {
|
||||||
const snap = slotStateSnapshot[index];
|
const snap = slotStateSnapshot[index];
|
||||||
@@ -315,11 +373,14 @@ export function planWagonsWithStock(params: {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (problem.kind === 'config') configIssues.add(problem.message);
|
if (problem.kind === 'config') configIssues.add(problem.message);
|
||||||
// remaining is rolled back here, so the shortage counts the stock this
|
// Usage is rolled back here, so the shortage counts the stock this
|
||||||
// booking actually saw — not what its own partial placement consumed.
|
// booking actually saw — not what its own partial placement consumed.
|
||||||
|
const bookingLeg = legFor(booking);
|
||||||
const shortage =
|
const shortage =
|
||||||
problem.kind === 'stock' && problem.candidates?.length
|
problem.kind === 'stock' && problem.candidates?.length
|
||||||
? shortageFor(booking, problem.candidates, remaining)
|
? shortageFor(booking, problem.candidates, (wagonTypeId) =>
|
||||||
|
Math.max(0, availableFor(wagonTypeId, bookingLeg)),
|
||||||
|
)
|
||||||
: null;
|
: null;
|
||||||
deferred.push({
|
deferred.push({
|
||||||
id: booking.id,
|
id: booking.id,
|
||||||
|
|||||||
@@ -525,6 +525,40 @@ export function validateMixedTrainLimits(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Leg-aware limit check: with a real stop list, a slot only counts on the
|
||||||
|
* edges it actually rides (boardYardId→alightYardId; null = the schedule's
|
||||||
|
* own endpoint). Each edge is validated as its own consist, so an intercity
|
||||||
|
* wagon on Gelan→Adama never counts against a train that is full only on
|
||||||
|
* Adama→Doraleh. Two stops (or fewer) degrade to the whole-train check.
|
||||||
|
*/
|
||||||
|
export function validateMixedTrainLimitsPerEdge(
|
||||||
|
wagonPlan: WagonPlanSlot[],
|
||||||
|
wagonTypes: Array<Pick<WagonType, 'lengthMeters'>>,
|
||||||
|
limits: TrainLimitConfig | undefined,
|
||||||
|
stops: string[],
|
||||||
|
): string[] {
|
||||||
|
if (stops.length <= 2) return validateMixedTrainLimits(wagonPlan, wagonTypes, limits);
|
||||||
|
const lastIdx = stops.length - 1;
|
||||||
|
const spans = wagonPlan.map((slot) => {
|
||||||
|
const from = slot.boardYardId ? stops.indexOf(slot.boardYardId) : 0;
|
||||||
|
const to = slot.alightYardId ? stops.indexOf(slot.alightYardId) : lastIdx;
|
||||||
|
// A yard missing from the stop list keeps the slot on the whole route.
|
||||||
|
return { from: from >= 0 ? from : 0, to: to > 0 ? to : lastIdx };
|
||||||
|
});
|
||||||
|
const violations = new Set<string>();
|
||||||
|
for (let edge = 0; edge < lastIdx; edge += 1) {
|
||||||
|
const active = wagonPlan.filter(
|
||||||
|
(_, i) => spans[i].from <= edge && edge < spans[i].to,
|
||||||
|
);
|
||||||
|
if (!active.length) continue;
|
||||||
|
for (const violation of validateMixedTrainLimits(active, wagonTypes, limits)) {
|
||||||
|
violations.add(violation);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...violations];
|
||||||
|
}
|
||||||
|
|
||||||
export function validate20ftContainerRules(
|
export function validate20ftContainerRules(
|
||||||
units: ContainerUnitRow[],
|
units: ContainerUnitRow[],
|
||||||
placements: ContainerPlacementInput[],
|
placements: ContainerPlacementInput[],
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { plainToInstance } from 'class-transformer';
|
||||||
|
import { validate } from 'class-validator';
|
||||||
|
|
||||||
|
import { CreateVehicleDto } from './create-vehicle.dto';
|
||||||
|
|
||||||
|
const base = {
|
||||||
|
vehicleType: 'TRUCK',
|
||||||
|
manufacturer: 'IVECO',
|
||||||
|
model: 'HYT',
|
||||||
|
year: 2020,
|
||||||
|
fuelType: 'DIESEL',
|
||||||
|
capacity: 0,
|
||||||
|
status: 'ACTIVE',
|
||||||
|
};
|
||||||
|
|
||||||
|
const errorsFor = (over: Record<string, unknown>) =>
|
||||||
|
validate(plainToInstance(CreateVehicleDto, { ...base, ...over }));
|
||||||
|
|
||||||
|
const plateErrors = (
|
||||||
|
errors: Awaited<ReturnType<typeof errorsFor>>,
|
||||||
|
property: string,
|
||||||
|
) => errors.find((e) => e.property === property && e.constraints?.matches);
|
||||||
|
|
||||||
|
describe('CreateVehicleDto — plate format', () => {
|
||||||
|
it('accepts a plate like ET-9875', async () => {
|
||||||
|
expect(plateErrors(await errorsFor({ plateNumber: 'ET-9875' }), 'plateNumber')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a plate like AA-8642', async () => {
|
||||||
|
expect(plateErrors(await errorsFor({ plateNumber: 'AA-8642' }), 'plateNumber')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('upper-cases a lower-case plate before validating', async () => {
|
||||||
|
const dto = plainToInstance(CreateVehicleDto, { ...base, plateNumber: 'et-9875' });
|
||||||
|
expect(dto.plateNumber).toBe('ET-9875');
|
||||||
|
expect(plateErrors(await validate(dto), 'plateNumber')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a free-text plate like assadasd', async () => {
|
||||||
|
expect(plateErrors(await errorsFor({ plateNumber: 'assadasd' }), 'plateNumber')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a plate with no letters or no digits', async () => {
|
||||||
|
expect(plateErrors(await errorsFor({ plateNumber: '1234' }), 'plateNumber')).toBeDefined();
|
||||||
|
expect(plateErrors(await errorsFor({ plateNumber: 'ABCD' }), 'plateNumber')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a bad trailer plate but allows a valid one', async () => {
|
||||||
|
expect(
|
||||||
|
plateErrors(await errorsFor({ plateNumber: 'ET-1', trailerPlateNo: 'asdasdasda' }), 'trailerPlateNo'),
|
||||||
|
).toBeDefined();
|
||||||
|
expect(
|
||||||
|
plateErrors(await errorsFor({ plateNumber: 'ET-1', trailerPlateNo: 'AA-8642' }), 'trailerPlateNo'),
|
||||||
|
).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows an empty trailer plate (optional)', async () => {
|
||||||
|
const errors = await errorsFor({ plateNumber: 'ET-1', trailerPlateNo: '' });
|
||||||
|
expect(plateErrors(errors, 'trailerPlateNo')).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,7 +1,30 @@
|
|||||||
import { IsString, IsEnum, IsNumber, IsOptional, IsUUID } from 'class-validator';
|
import { IsString, IsEnum, IsNumber, IsOptional, IsUUID, Matches } from 'class-validator';
|
||||||
|
import { Transform } from 'class-transformer';
|
||||||
import { VehicleType, FuelType, VehicleStatus, VehicleAvailability } from '../entities/vehicle.entity';
|
import { VehicleType, FuelType, VehicleStatus, VehicleAvailability } from '../entities/vehicle.entity';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A vehicle plate is two or three letters, a hyphen, then two to six digits —
|
||||||
|
* e.g. ET-9875 or AA-8642. Kept in one place so plate, power-plate and trailer
|
||||||
|
* all match and the message stays consistent.
|
||||||
|
*/
|
||||||
|
export const VEHICLE_PLATE_REGEX = /^[A-Z]{2,3}-\d{2,6}$/;
|
||||||
|
export const VEHICLE_PLATE_MESSAGE =
|
||||||
|
'must be letters and numbers like ET-9875 or AA-8642';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trim and upper-case a plate before validating, so "et-9875" is accepted. An
|
||||||
|
* empty optional plate (trailer/power) becomes undefined so @IsOptional skips it
|
||||||
|
* rather than failing the pattern.
|
||||||
|
*/
|
||||||
|
const normalizePlate = ({ value }: { value: unknown }) => {
|
||||||
|
if (typeof value !== 'string') return value;
|
||||||
|
const trimmed = value.trim().toUpperCase();
|
||||||
|
return trimmed === '' ? undefined : trimmed;
|
||||||
|
};
|
||||||
|
|
||||||
export class CreateVehicleDto {
|
export class CreateVehicleDto {
|
||||||
|
@Transform(normalizePlate)
|
||||||
|
@Matches(VEHICLE_PLATE_REGEX, { message: `Plate number ${VEHICLE_PLATE_MESSAGE}` })
|
||||||
@IsString()
|
@IsString()
|
||||||
plateNumber!: string;
|
plateNumber!: string;
|
||||||
|
|
||||||
@@ -47,10 +70,14 @@ export class CreateVehicleDto {
|
|||||||
code?: string;
|
code?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
@Transform(normalizePlate)
|
||||||
|
@Matches(VEHICLE_PLATE_REGEX, { message: `Power plate number ${VEHICLE_PLATE_MESSAGE}` })
|
||||||
@IsString()
|
@IsString()
|
||||||
powerPlateNo?: string;
|
powerPlateNo?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
@Transform(normalizePlate)
|
||||||
|
@Matches(VEHICLE_PLATE_REGEX, { message: `Trailer plate number ${VEHICLE_PLATE_MESSAGE}` })
|
||||||
@IsString()
|
@IsString()
|
||||||
trailerPlateNo?: string;
|
trailerPlateNo?: string;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { ConflictException } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { VehiclesService } from './vehicles.service';
|
||||||
|
|
||||||
|
// One driver ⇒ one truck: create/update must refuse a driver already assigned
|
||||||
|
// to another (non-deleted) vehicle until they are detached.
|
||||||
|
describe('VehiclesService driver assignment guard', () => {
|
||||||
|
const otherTruck = { id: 'v2', plateNumber: '3-11111', assignedDriverId: 'd1' };
|
||||||
|
|
||||||
|
const makeService = (findOne: jest.Mock) =>
|
||||||
|
new VehiclesService(
|
||||||
|
{ findOne, create: jest.fn((x) => x), save: jest.fn(async (x) => x) } as any,
|
||||||
|
{ record: jest.fn() } as any,
|
||||||
|
);
|
||||||
|
|
||||||
|
it('rejects create when the driver is on another truck', async () => {
|
||||||
|
// First findOne = plate uniqueness (null), second = driver holder.
|
||||||
|
const findOne = jest.fn().mockResolvedValueOnce(null).mockResolvedValueOnce(otherTruck);
|
||||||
|
const svc = makeService(findOne);
|
||||||
|
await expect(
|
||||||
|
svc.create({ plateNumber: '3-22222', vehicleType: 'TRUCK', assignedDriverId: 'd1' } as any),
|
||||||
|
).rejects.toThrow(ConflictException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects update when reassigning a driver still attached elsewhere', async () => {
|
||||||
|
const findOne = jest
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce({ id: 'v1', plateNumber: '3-22222', assignedDriverId: null }) // findById
|
||||||
|
.mockResolvedValueOnce(otherTruck); // driver holder
|
||||||
|
const svc = makeService(findOne);
|
||||||
|
await expect(svc.update('v1', { assignedDriverId: 'd1' } as any)).rejects.toThrow(
|
||||||
|
ConflictException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows update that keeps the same driver on the same truck', async () => {
|
||||||
|
const findOne = jest
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce({ id: 'v1', plateNumber: '3-22222', assignedDriverId: 'd1' });
|
||||||
|
const svc = makeService(findOne);
|
||||||
|
await expect(svc.update('v1', { assignedDriverId: 'd1' } as any)).resolves.toBeDefined();
|
||||||
|
expect(findOne).toHaveBeenCalledTimes(1); // guard skipped — no holder lookup
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -20,6 +20,25 @@ export class VehiclesService {
|
|||||||
private readonly history: FleetHistoryService,
|
private readonly history: FleetHistoryService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A driver holds one truck at a time — reassignment requires detaching them
|
||||||
|
* from their current truck first.
|
||||||
|
* ponytail: app-level guard only (race window); add a partial unique index on
|
||||||
|
* assigned_driver_id if concurrent fleet edits ever become real.
|
||||||
|
*/
|
||||||
|
private async assertDriverUnassigned(driverId: string, exceptVehicleId?: string): Promise<void> {
|
||||||
|
const holder = await this.vehicleRepo.findOne({
|
||||||
|
where: exceptVehicleId
|
||||||
|
? { assignedDriverId: driverId, id: Not(exceptVehicleId) }
|
||||||
|
: { assignedDriverId: driverId },
|
||||||
|
});
|
||||||
|
if (holder) {
|
||||||
|
throw new ConflictException(
|
||||||
|
`This driver is already assigned to truck ${holder.plateNumber ?? holder.code ?? holder.id} — detach the driver from that truck first`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async create(dto: CreateVehicleDto): Promise<Vehicle> {
|
async create(dto: CreateVehicleDto): Promise<Vehicle> {
|
||||||
const existing = await this.vehicleRepo.findOne({
|
const existing = await this.vehicleRepo.findOne({
|
||||||
where: { plateNumber: dto.plateNumber },
|
where: { plateNumber: dto.plateNumber },
|
||||||
@@ -31,6 +50,10 @@ export class VehiclesService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (dto.assignedDriverId) {
|
||||||
|
await this.assertDriverUnassigned(dto.assignedDriverId);
|
||||||
|
}
|
||||||
|
|
||||||
const registrationNumber = `REG-${dto.vehicleType}-${Date.now()}`;
|
const registrationNumber = `REG-${dto.vehicleType}-${Date.now()}`;
|
||||||
const vehicle = this.vehicleRepo.create({
|
const vehicle = this.vehicleRepo.create({
|
||||||
...dto,
|
...dto,
|
||||||
@@ -121,6 +144,10 @@ export class VehiclesService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (dto.assignedDriverId && dto.assignedDriverId !== vehicle.assignedDriverId) {
|
||||||
|
await this.assertDriverUnassigned(dto.assignedDriverId, id);
|
||||||
|
}
|
||||||
|
|
||||||
const prev = {
|
const prev = {
|
||||||
assignedDriverId: vehicle.assignedDriverId,
|
assignedDriverId: vehicle.assignedDriverId,
|
||||||
assignedDriverName: vehicle.assignedDriverName,
|
assignedDriverName: vehicle.assignedDriverName,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import { IsEnum, IsOptional, IsString, IsUUID } from 'class-validator';
|
import { Transform } from 'class-transformer';
|
||||||
|
import { IsBoolean, IsEnum, IsInt, IsOptional, IsString, IsUUID, Min } from 'class-validator';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
WAREHOUSE_INVENTORY_STATUSES,
|
WAREHOUSE_INVENTORY_STATUSES,
|
||||||
@@ -71,4 +72,27 @@ export class FilterWarehouseInventoryDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
dateTo?: string;
|
dateTo?: string;
|
||||||
|
|
||||||
|
// ── KPI drill-down filters ──────────────────────────────────────────────
|
||||||
|
// Each mirrors one opsStats() counter so a dashboard card's count always
|
||||||
|
// equals the length of the list it opens.
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ type: Boolean, description: 'Only items received (created) today' })
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(({ value }) => (value == null ? undefined : value === true || value === 'true' || value === '1'))
|
||||||
|
@IsBoolean()
|
||||||
|
receivedToday?: boolean;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ type: Boolean, description: 'Only RECEIVED items with no inspection yet' })
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(({ value }) => (value == null ? undefined : value === true || value === 'true' || value === '1'))
|
||||||
|
@IsBoolean()
|
||||||
|
pendingInspection?: boolean;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ type: Number, minimum: 1, description: 'Only in-warehouse items older than N days' })
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(({ value }) => (value === '' || value == null ? undefined : Number(value)))
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
agingOverDays?: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,8 +8,9 @@ export type HandoverMileType = (typeof HANDOVER_MILE_TYPES)[number];
|
|||||||
* One import handover. A booking has a single handover when one truck takes the
|
* One import handover. A booking has a single handover when one truck takes the
|
||||||
* whole booking (`truckAssignmentId` null = per-booking), or one per truck when
|
* whole booking (`truckAssignmentId` null = per-booking), or one per truck when
|
||||||
* multiple trucks are used. Self-haul handovers are generated on truck arrival
|
* multiple trucks are used. Self-haul handovers are generated on truck arrival
|
||||||
* and signed before the truck leaves; EDR last-mile handovers are generated at
|
* and signed before the truck leaves; EDR last-mile handovers are generated
|
||||||
* delivery (after exit).
|
* when the EDR truck exits the warehouse (with its exit paper) and signed by
|
||||||
|
* the customer in the portal on delivery — one signature per truck.
|
||||||
*/
|
*/
|
||||||
@Entity({ schema: 'freight', name: 'booking_handovers' })
|
@Entity({ schema: 'freight', name: 'booking_handovers' })
|
||||||
@Index(['bookingId'])
|
@Index(['bookingId'])
|
||||||
@@ -21,6 +22,10 @@ export class BookingHandover extends BaseEntity {
|
|||||||
@Column({ name: 'truck_assignment_id', type: 'uuid', nullable: true })
|
@Column({ name: 'truck_assignment_id', type: 'uuid', nullable: true })
|
||||||
truckAssignmentId?: string | null;
|
truckAssignmentId?: string | null;
|
||||||
|
|
||||||
|
/** EDR last-mile vehicle assignment this handover belongs to; null = per-booking. */
|
||||||
|
@Column({ name: 'edr_assignment_id', type: 'uuid', nullable: true })
|
||||||
|
edrAssignmentId?: string | null;
|
||||||
|
|
||||||
/** Denormalised plate for display / EDR trucks (which aren't customer trucks). */
|
/** Denormalised plate for display / EDR trucks (which aren't customer trucks). */
|
||||||
@Column({ name: 'truck_plate', type: 'varchar', length: 32, nullable: true })
|
@Column({ name: 'truck_plate', type: 'varchar', length: 32, nullable: true })
|
||||||
truckPlate?: string | null;
|
truckPlate?: string | null;
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { WarehouseInventoryService } from './warehouse-inventory.service';
|
||||||
|
|
||||||
|
// Exercises the per-truck [Exit Inspection] block helpers directly (no DI).
|
||||||
|
const svc = Object.create(WarehouseInventoryService.prototype) as any;
|
||||||
|
|
||||||
|
const arrivalA =
|
||||||
|
'[Exit Inspection]\nTruck Plate: 3-15288/56858\nDriver: Abebe Lemeno\nGate In Time: 2026-07-21T08:00:00.000Z\nTare Weight: 12 t';
|
||||||
|
const arrivalB =
|
||||||
|
'[Exit Inspection]\nTruck Plate: 3-85957/48562\nDriver: Suleman Tamrat\nGate In Time: 2026-07-21T09:00:00.000Z\nWeighing: SKIPPED';
|
||||||
|
|
||||||
|
describe('per-truck exit inspection blocks', () => {
|
||||||
|
it('keeps truck A intact when truck B arrives', () => {
|
||||||
|
const afterA = svc.replaceExitInspectionNote('Receive note', arrivalA, '3-15288/56858');
|
||||||
|
const afterB = svc.replaceExitInspectionNote(afterA, arrivalB, '3-85957/48562');
|
||||||
|
expect(afterB).toContain('Abebe Lemeno');
|
||||||
|
expect(afterB).toContain('Suleman Tamrat');
|
||||||
|
expect(afterB.match(/\[Exit Inspection\]/g)).toHaveLength(2);
|
||||||
|
expect(afterB.startsWith('Receive note')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exit for truck A updates only A's block and preserves arrival data", () => {
|
||||||
|
const notes = svc.replaceExitInspectionNote(
|
||||||
|
svc.replaceExitInspectionNote(null, arrivalA, '3-15288/56858'),
|
||||||
|
arrivalB,
|
||||||
|
'3-85957/48562',
|
||||||
|
);
|
||||||
|
const dto = svc.preserveTruckArrivalForExit(
|
||||||
|
{ truckPlateNumber: '3-15288/56858', grossWeight: 40, gateOutTime: '2026-07-21T12:00:00.000Z' },
|
||||||
|
notes,
|
||||||
|
);
|
||||||
|
expect(dto.driverName).toBe('Abebe Lemeno');
|
||||||
|
expect(dto.tareWeight).toBe(12);
|
||||||
|
expect(dto.weighingSkipped).toBeUndefined();
|
||||||
|
const exitNote = svc.buildExitInspectionNote(dto);
|
||||||
|
const replaced = svc.replaceExitInspectionNote(notes, exitNote, dto.truckPlateNumber);
|
||||||
|
expect(replaced).toContain('Gross Weight: 40 t');
|
||||||
|
expect(replaced).toContain('Net Weight: 28 t');
|
||||||
|
expect(replaced).toContain('Suleman Tamrat'); // B untouched
|
||||||
|
expect(replaced.match(/\[Exit Inspection\]/g)).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skipped weighing records the container-derived net in the note', () => {
|
||||||
|
const dto = {
|
||||||
|
truckPlateNumber: '3-85957/48562',
|
||||||
|
driverName: 'Suleman Tamrat',
|
||||||
|
weighingSkipped: true,
|
||||||
|
netWeight: 27.5,
|
||||||
|
gateInTime: '2026-07-21T09:00:00.000Z',
|
||||||
|
gateOutTime: '2026-07-21T13:00:00.000Z',
|
||||||
|
};
|
||||||
|
const note = svc.buildExitInspectionNote(dto);
|
||||||
|
expect(note).toContain('Weighing: SKIPPED');
|
||||||
|
expect(note).toContain('Net Weight: 27.5 t');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches a legacy comma-joined plate list and keeps foreign notes', () => {
|
||||||
|
const legacy =
|
||||||
|
'Receive note\n\n[Exit Inspection]\nTruck Plate: 3-15288/56858, 3-85957/48562\nDriver: Abebe Lemeno\nTare Weight: 12 t\nCUSTOMER_DELIVERY_APPROVAL:{"ok":true}';
|
||||||
|
const block = svc.extractExitInspectionForPlate(legacy, '3-15288/56858');
|
||||||
|
expect(block).toContain('Abebe Lemeno');
|
||||||
|
const replaced = svc.replaceExitInspectionNote(legacy, arrivalA, '3-15288/56858');
|
||||||
|
expect(replaced.match(/\[Exit Inspection\]/g)).toHaveLength(1);
|
||||||
|
expect(replaced).toContain('CUSTOMER_DELIVERY_APPROVAL:{"ok":true}');
|
||||||
|
expect(replaced).toContain('Receive note');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||||
import { NotificationAudience, NotificationType } from '@edr/types';
|
import { NotificationAudience, NotificationType } from '@edr/types';
|
||||||
import { DataSource, EntityManager, IsNull } from 'typeorm';
|
import { DataSource, EntityManager, IsNull, Repository } from 'typeorm';
|
||||||
|
|
||||||
import { BookingHandover } from './entities/booking-handover.entity';
|
import { BookingHandover, HandoverMileType } from './entities/booking-handover.entity';
|
||||||
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
|
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
|
||||||
import { NotificationsService } from '../notifications/notifications.service';
|
import { NotificationsService } from '../notifications/notifications.service';
|
||||||
import { sendCompanyChannels } from '../notifications/notify-company.util';
|
import { sendCompanyChannels } from '../notifications/notify-company.util';
|
||||||
@@ -12,7 +12,10 @@ import { sendCompanyChannels } from '../notifications/notify-company.util';
|
|||||||
* Import handover records. A booking has one handover per truck (single truck ⇒
|
* Import handover records. A booking has one handover per truck (single truck ⇒
|
||||||
* one, effectively per-booking; multiple trucks ⇒ one each). Timing by mile type:
|
* one, effectively per-booking; multiple trucks ⇒ one each). Timing by mile type:
|
||||||
* - SELF_HAUL: generated when the customer truck arrives, signed before it leaves.
|
* - SELF_HAUL: generated when the customer truck arrives, signed before it leaves.
|
||||||
* - EDR_LAST_MILE: generated at delivery (after exit).
|
* - EDR_LAST_MILE: generated when the EDR truck exits the warehouse (with its
|
||||||
|
* exit paper), signed by the customer in the portal per truck; once every
|
||||||
|
* handover is signed the delivery auto-completes (inventory / cargo /
|
||||||
|
* booking → delivered).
|
||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class HandoverService {
|
export class HandoverService {
|
||||||
@@ -25,14 +28,22 @@ export class HandoverService {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
/** Tell the customer a handover is ready and needs their signature. */
|
/** Tell the customer a handover is ready and needs their signature. */
|
||||||
private async notifySignNeeded(bookingId: string, reference: string): Promise<void> {
|
private async notifySignNeeded(
|
||||||
|
bookingId: string,
|
||||||
|
reference: string,
|
||||||
|
opts: { mileType?: HandoverMileType; truckPlate?: string | null } = {},
|
||||||
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const [b]: Array<{ companyId: string | null; reference: string }> = await this.dataSource.query(
|
const [b]: Array<{ companyId: string | null; reference: string }> = await this.dataSource.query(
|
||||||
`SELECT company_id AS "companyId", reference FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`,
|
`SELECT company_id AS "companyId", reference FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`,
|
||||||
[bookingId],
|
[bookingId],
|
||||||
);
|
);
|
||||||
if (!b?.companyId) return;
|
if (!b?.companyId) return;
|
||||||
const body = `Your import handover ${reference} for booking ${b.reference} is ready. Please review and sign it from the portal before the truck leaves.`;
|
const truck = opts.truckPlate ? ` (truck ${opts.truckPlate})` : '';
|
||||||
|
const body =
|
||||||
|
opts.mileType === 'EDR_LAST_MILE'
|
||||||
|
? `Your goods for booking ${b.reference} are on their way${truck}. Please review and sign handover ${reference} from the portal to confirm receipt of the delivery.`
|
||||||
|
: `Your import handover ${reference} for booking ${b.reference} is ready. Please review and sign it from the portal before the truck leaves.`;
|
||||||
await this.inbox.notify({
|
await this.inbox.notify({
|
||||||
recipients: { companyId: b.companyId },
|
recipients: { companyId: b.companyId },
|
||||||
audience: NotificationAudience.PORTAL,
|
audience: NotificationAudience.PORTAL,
|
||||||
@@ -117,31 +128,98 @@ export class HandoverService {
|
|||||||
return saved;
|
return saved;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Find an existing EDR handover by assignment, else by plate, else booking-level. */
|
||||||
* EDR last-mile: generate a handover at delivery (after exit). One per EDR
|
private async findEdrHandover(
|
||||||
* truck (by plate) or per booking. Idempotent by (booking, plate).
|
repo: Repository<BookingHandover>,
|
||||||
*/
|
|
||||||
async ensureAtDelivery(
|
|
||||||
bookingId: string,
|
bookingId: string,
|
||||||
opts: { truckPlate?: string | null; truckAssignmentId?: string | null },
|
opts: { truckPlate?: string | null; edrAssignmentId?: string | null },
|
||||||
|
): Promise<BookingHandover | null> {
|
||||||
|
if (opts.edrAssignmentId) {
|
||||||
|
const byAssignment = await repo.findOne({
|
||||||
|
where: { bookingId, edrAssignmentId: opts.edrAssignmentId },
|
||||||
|
});
|
||||||
|
if (byAssignment) return byAssignment;
|
||||||
|
}
|
||||||
|
if (opts.truckPlate) {
|
||||||
|
return repo.findOne({
|
||||||
|
where: { bookingId, mileType: 'EDR_LAST_MILE', truckPlate: opts.truckPlate },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return repo.findOne({
|
||||||
|
where: {
|
||||||
|
bookingId,
|
||||||
|
mileType: 'EDR_LAST_MILE',
|
||||||
|
truckPlate: IsNull(),
|
||||||
|
edrAssignmentId: IsNull(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* EDR last-mile: generate the handover when the EDR truck exits the warehouse
|
||||||
|
* (alongside its exit paper) and ask the customer to sign it from the portal.
|
||||||
|
* One per truck (multiple trucks ⇒ one each) or booking-level when the truck
|
||||||
|
* cannot be resolved. Idempotent by (booking, assignment) / (booking, plate).
|
||||||
|
*/
|
||||||
|
async ensureForDepartedEdrTruck(
|
||||||
|
bookingId: string,
|
||||||
|
opts: { truckPlate?: string | null; edrAssignmentId?: string | null },
|
||||||
manager?: EntityManager,
|
manager?: EntityManager,
|
||||||
): Promise<BookingHandover> {
|
): Promise<BookingHandover> {
|
||||||
const m = manager ?? this.dataSource.manager;
|
const m = manager ?? this.dataSource.manager;
|
||||||
const repo = m.getRepository(BookingHandover);
|
const repo = m.getRepository(BookingHandover);
|
||||||
const existing = await repo.findOne({
|
const existing = await this.findEdrHandover(repo, bookingId, opts);
|
||||||
where: {
|
|
||||||
bookingId,
|
|
||||||
truckPlate: opts.truckPlate ?? IsNull(),
|
|
||||||
truckAssignmentId: opts.truckAssignmentId ?? IsNull(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (existing) return existing;
|
if (existing) return existing;
|
||||||
|
|
||||||
const reference = await this.generateReference(bookingId, m);
|
const reference = await this.generateReference(bookingId, m);
|
||||||
return repo.save(
|
const saved = await repo.save(
|
||||||
repo.create({
|
repo.create({
|
||||||
bookingId,
|
bookingId,
|
||||||
truckAssignmentId: opts.truckAssignmentId ?? null,
|
edrAssignmentId: opts.edrAssignmentId ?? null,
|
||||||
|
truckPlate: opts.truckPlate ?? null,
|
||||||
|
mileType: 'EDR_LAST_MILE',
|
||||||
|
reference,
|
||||||
|
generatedAt: new Date(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
this.logger.log(
|
||||||
|
`EDR handover ${reference} generated on truck exit for booking ${bookingId}` +
|
||||||
|
(opts.truckPlate ? ` (truck ${opts.truckPlate})` : ''),
|
||||||
|
);
|
||||||
|
void this.notifySignNeeded(bookingId, reference, {
|
||||||
|
mileType: 'EDR_LAST_MILE',
|
||||||
|
truckPlate: opts.truckPlate,
|
||||||
|
});
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* EDR last-mile: ensure a handover exists at delivery and stamp delivered_at.
|
||||||
|
* Normally the handover was already generated on truck exit — this only fills
|
||||||
|
* the delivery timestamp; a handover is created here only for legacy flows
|
||||||
|
* where the exit was recorded before this feature existed.
|
||||||
|
*/
|
||||||
|
async ensureAtDelivery(
|
||||||
|
bookingId: string,
|
||||||
|
opts: { truckPlate?: string | null; edrAssignmentId?: string | null },
|
||||||
|
manager?: EntityManager,
|
||||||
|
): Promise<BookingHandover> {
|
||||||
|
const m = manager ?? this.dataSource.manager;
|
||||||
|
const repo = m.getRepository(BookingHandover);
|
||||||
|
const existing = await this.findEdrHandover(repo, bookingId, opts);
|
||||||
|
if (existing) {
|
||||||
|
if (!existing.deliveredAt) {
|
||||||
|
existing.deliveredAt = new Date();
|
||||||
|
await repo.save(existing);
|
||||||
|
}
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reference = await this.generateReference(bookingId, m);
|
||||||
|
const saved = await repo.save(
|
||||||
|
repo.create({
|
||||||
|
bookingId,
|
||||||
|
edrAssignmentId: opts.edrAssignmentId ?? null,
|
||||||
truckPlate: opts.truckPlate ?? null,
|
truckPlate: opts.truckPlate ?? null,
|
||||||
mileType: 'EDR_LAST_MILE',
|
mileType: 'EDR_LAST_MILE',
|
||||||
reference,
|
reference,
|
||||||
@@ -149,6 +227,25 @@ export class HandoverService {
|
|||||||
deliveredAt: new Date(),
|
deliveredAt: new Date(),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
void this.notifySignNeeded(bookingId, reference, {
|
||||||
|
mileType: 'EDR_LAST_MILE',
|
||||||
|
truckPlate: opts.truckPlate,
|
||||||
|
});
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Re-send the sign notification for every unsigned handover on the booking. */
|
||||||
|
async notifyUnsignedForBooking(bookingId: string): Promise<void> {
|
||||||
|
const unsigned = await this.dataSource.getRepository(BookingHandover).find({
|
||||||
|
where: { bookingId, signedAt: IsNull() },
|
||||||
|
order: { generatedAt: 'ASC' },
|
||||||
|
});
|
||||||
|
for (const h of unsigned) {
|
||||||
|
await this.notifySignNeeded(bookingId, h.reference, {
|
||||||
|
mileType: h.mileType,
|
||||||
|
truckPlate: h.truckPlate,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -182,6 +279,27 @@ export class HandoverService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sign one handover (EDR last-mile: the customer signs per truck). Returns the
|
||||||
|
* fresh handover; idempotent — an already-signed handover is returned as-is.
|
||||||
|
*/
|
||||||
|
async sign(
|
||||||
|
handoverId: string,
|
||||||
|
userId?: string | null,
|
||||||
|
signerName?: string | null,
|
||||||
|
): Promise<BookingHandover> {
|
||||||
|
const repo = this.dataSource.getRepository(BookingHandover);
|
||||||
|
const handover = await repo.findOne({ where: { id: handoverId } });
|
||||||
|
if (!handover) {
|
||||||
|
throw new NotFoundException(`Handover ${handoverId} not found`);
|
||||||
|
}
|
||||||
|
if (handover.signedAt) return handover;
|
||||||
|
handover.signedAt = new Date();
|
||||||
|
handover.signedByUserId = userId ?? null;
|
||||||
|
handover.signerName = signerName?.trim() || null;
|
||||||
|
return repo.save(handover);
|
||||||
|
}
|
||||||
|
|
||||||
/** Sign all unsigned handovers on a booking (self-haul: before the truck leaves). */
|
/** Sign all unsigned handovers on a booking (self-haul: before the truck leaves). */
|
||||||
async signForBooking(
|
async signForBooking(
|
||||||
bookingId: string,
|
bookingId: string,
|
||||||
|
|||||||
@@ -486,6 +486,21 @@ export class WarehouseInventoryController {
|
|||||||
return this.handoverService.list(bookingId);
|
return this.handoverService.list(bookingId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('handovers/:handoverId/sign')
|
||||||
|
@ApiOperation({ summary: 'Customer signs one handover (EDR last-mile: one signature per truck)' })
|
||||||
|
signHandover(
|
||||||
|
@Param('handoverId', ParseUUIDPipe) handoverId: string,
|
||||||
|
@Body() dto: ApproveDeliveryDto,
|
||||||
|
@Request() req: { user?: { id?: string; sub?: string } },
|
||||||
|
@CurrentUser() user: TCurrentUser,
|
||||||
|
) {
|
||||||
|
return this.inventoryService.signHandover(
|
||||||
|
handoverId,
|
||||||
|
user?.id ?? req.user?.id ?? req.user?.sub,
|
||||||
|
dto.signerName,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@Post('bookings/:bookingId/request-handover-signature')
|
@Post('bookings/:bookingId/request-handover-signature')
|
||||||
@ApiOperation({ summary: 'Ask the customer to sign the handover (creates one if none, then notifies)' })
|
@ApiOperation({ summary: 'Ask the customer to sign the handover (creates one if none, then notifies)' })
|
||||||
requestHandoverSignature(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
|
requestHandoverSignature(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
|
||||||
@@ -513,9 +528,16 @@ export class WarehouseInventoryController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('bookings/:bookingId/handover-document')
|
@Get('bookings/:bookingId/handover-document')
|
||||||
@ApiOperation({ summary: 'View import goods handover document PDF (resolved by booking)' })
|
@ApiOperation({ summary: 'View import goods handover document PDF (resolved by booking; ?handoverId= for the per-truck variant)' })
|
||||||
async bookingHandoverDocument(@Param('bookingId', ParseUUIDPipe) bookingId: string, @Res() res: Response) {
|
async bookingHandoverDocument(
|
||||||
const { filename, buffer } = await this.inventoryService.handoverDocumentForBooking(bookingId);
|
@Param('bookingId', ParseUUIDPipe) bookingId: string,
|
||||||
|
@Res() res: Response,
|
||||||
|
@Query('handoverId') handoverId?: string,
|
||||||
|
) {
|
||||||
|
const { filename, buffer } = await this.inventoryService.handoverDocumentForBooking(
|
||||||
|
bookingId,
|
||||||
|
handoverId || undefined,
|
||||||
|
);
|
||||||
res.setHeader('Content-Type', 'application/pdf');
|
res.setHeader('Content-Type', 'application/pdf');
|
||||||
res.setHeader('Content-Disposition', `inline; filename="${filename}"`);
|
res.setHeader('Content-Disposition', `inline; filename="${filename}"`);
|
||||||
res.setHeader('Content-Length', buffer.length);
|
res.setHeader('Content-Length', buffer.length);
|
||||||
@@ -534,6 +556,12 @@ export class WarehouseInventoryController {
|
|||||||
return this.inventoryService.bookingContainerWeights(bookingId);
|
return this.inventoryService.bookingContainerWeights(bookingId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('bookings/:bookingId/location')
|
||||||
|
@ApiOperation({ summary: "Warehouse location of a booking's inventory (customer portal)" })
|
||||||
|
bookingLocation(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
|
||||||
|
return this.inventoryService.bookingLocation(bookingId);
|
||||||
|
}
|
||||||
|
|
||||||
@Post(':id/deliver')
|
@Post(':id/deliver')
|
||||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.deliver)
|
@BookingStaff(FREIGHT_PERMS.warehouseInventory.deliver)
|
||||||
@ApiOperation({ summary: 'Deliver import goods to the customer + capture proof of delivery' })
|
@ApiOperation({ summary: 'Deliver import goods to the customer + capture proof of delivery' })
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
|||||||
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post } from '@nestjs/common';
|
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post } from '@nestjs/common';
|
||||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
|
|
||||||
import { BookingStaff } from '../../common/booking-guards';
|
import { BookingStaff, StaffReference } from '../../common/booking-guards';
|
||||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||||
import { CreateWarehouseZoneDto } from './dto/create-warehouse-zone.dto';
|
import { CreateWarehouseZoneDto } from './dto/create-warehouse-zone.dto';
|
||||||
import { UpdateWarehouseYardDto } from './dto/update-warehouse-yard.dto';
|
import { UpdateWarehouseYardDto } from './dto/update-warehouse-yard.dto';
|
||||||
@@ -10,8 +10,9 @@ import { WarehouseZonesService } from './warehouse-zones.service';
|
|||||||
|
|
||||||
@ApiTags('warehouse-yards')
|
@ApiTags('warehouse-yards')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
|
// No class-level guard: the two reference GETs are open to any signed-in
|
||||||
|
// staff (StaffReference), every other route carries its own permission.
|
||||||
@Controller('warehouse-yards')
|
@Controller('warehouse-yards')
|
||||||
@BookingStaff(FREIGHT_PERMS.warehouseYards.view)
|
|
||||||
export class WarehouseYardsController {
|
export class WarehouseYardsController {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly yardsService: WarehouseYardsService,
|
private readonly yardsService: WarehouseYardsService,
|
||||||
@@ -19,12 +20,14 @@ export class WarehouseYardsController {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
|
@StaffReference()
|
||||||
@ApiOperation({ summary: 'List all warehouse yards' })
|
@ApiOperation({ summary: 'List all warehouse yards' })
|
||||||
findAll() {
|
findAll() {
|
||||||
return this.yardsService.findAll();
|
return this.yardsService.findAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
|
@StaffReference()
|
||||||
@ApiOperation({ summary: 'Get warehouse yard by ID' })
|
@ApiOperation({ summary: 'Get warehouse yard by ID' })
|
||||||
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
return this.yardsService.findById(id);
|
return this.yardsService.findById(id);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
|
||||||
import { CreateWarehouseYardDto } from './dto/create-warehouse-yard.dto';
|
import { CreateWarehouseYardDto } from './dto/create-warehouse-yard.dto';
|
||||||
import { UpdateWarehouseYardDto } from './dto/update-warehouse-yard.dto';
|
import { UpdateWarehouseYardDto } from './dto/update-warehouse-yard.dto';
|
||||||
@@ -44,6 +44,7 @@ export class WarehouseYardsService {
|
|||||||
// Ensure the parent warehouse exists.
|
// Ensure the parent warehouse exists.
|
||||||
await this.warehousesService.findById(warehouseId);
|
await this.warehousesService.findById(warehouseId);
|
||||||
await this.assertCodeUnique(warehouseId, dto.code.trim());
|
await this.assertCodeUnique(warehouseId, dto.code.trim());
|
||||||
|
await this.assertCapacityWithinWarehouse(warehouseId, dto.capacityWeight ?? null, dto.capacityContainers ?? null);
|
||||||
|
|
||||||
return this.yardsRepository.create({
|
return this.yardsRepository.create({
|
||||||
warehouseId,
|
warehouseId,
|
||||||
@@ -69,14 +70,22 @@ export class WarehouseYardsService {
|
|||||||
await this.assertCodeUnique(existing.warehouseId, dto.code.trim(), id);
|
await this.assertCodeUnique(existing.warehouseId, dto.code.trim(), id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const newCapacityWeight = dto.capacityWeight ?? existing.capacityWeight ?? null;
|
||||||
|
const newCapacityContainers = dto.capacityContainers ?? existing.capacityContainers ?? null;
|
||||||
|
|
||||||
|
// Validate updated capacity doesn't exceed warehouse limits
|
||||||
|
if (newCapacityWeight !== (existing.capacityWeight ?? null) || newCapacityContainers !== (existing.capacityContainers ?? null)) {
|
||||||
|
await this.assertCapacityWithinWarehouse(existing.warehouseId, newCapacityWeight, newCapacityContainers, id);
|
||||||
|
}
|
||||||
|
|
||||||
const status = dto.status ?? existing.status;
|
const status = dto.status ?? existing.status;
|
||||||
|
|
||||||
const updated = await this.yardsRepository.update(id, {
|
const updated = await this.yardsRepository.update(id, {
|
||||||
name: dto.name?.trim() ?? existing.name,
|
name: dto.name?.trim() ?? existing.name,
|
||||||
code: dto.code?.trim() ?? existing.code,
|
code: dto.code?.trim() ?? existing.code,
|
||||||
type: dto.type ?? existing.type,
|
type: dto.type ?? existing.type,
|
||||||
capacityWeight: dto.capacityWeight ?? existing.capacityWeight,
|
capacityWeight: newCapacityWeight,
|
||||||
capacityContainers: dto.capacityContainers ?? existing.capacityContainers,
|
capacityContainers: newCapacityContainers,
|
||||||
maxWeight: dto.maxWeight ?? existing.maxWeight,
|
maxWeight: dto.maxWeight ?? existing.maxWeight,
|
||||||
maxVolume: dto.maxVolume ?? existing.maxVolume,
|
maxVolume: dto.maxVolume ?? existing.maxVolume,
|
||||||
status,
|
status,
|
||||||
@@ -97,4 +106,39 @@ export class WarehouseYardsService {
|
|||||||
throw new ConflictException(`Yard code ${code} already exists in this warehouse`);
|
throw new ConflictException(`Yard code ${code} already exists in this warehouse`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async assertCapacityWithinWarehouse(
|
||||||
|
warehouseId: string,
|
||||||
|
newCapacityWeight: number | null,
|
||||||
|
newCapacityContainers: number | null,
|
||||||
|
excludeYardId?: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const warehouse = await this.warehousesService.findById(warehouseId);
|
||||||
|
const yards = await this.findByWarehouse(warehouseId);
|
||||||
|
|
||||||
|
// Sum existing yard capacities, excluding the yard being updated if provided
|
||||||
|
const otherYards = excludeYardId ? yards.filter((y) => y.id !== excludeYardId) : yards;
|
||||||
|
const totalExistingWeight = otherYards.reduce((sum, y) => sum + (y.capacityWeight ?? 0), 0);
|
||||||
|
const totalExistingContainers = otherYards.reduce((sum, y) => sum + (y.capacityContainers ?? 0), 0);
|
||||||
|
|
||||||
|
// Check weight capacity
|
||||||
|
if (newCapacityWeight !== null && warehouse.capacityWeight != null) {
|
||||||
|
const totalWeight = totalExistingWeight + newCapacityWeight;
|
||||||
|
if (totalWeight > warehouse.capacityWeight) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Total yard weight capacity (${totalWeight}t) exceeds warehouse limit (${warehouse.capacityWeight}t)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check container capacity
|
||||||
|
if (newCapacityContainers !== null && warehouse.capacityContainers != null) {
|
||||||
|
const totalContainers = totalExistingContainers + newCapacityContainers;
|
||||||
|
if (totalContainers > warehouse.capacityContainers) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Total yard container capacity (${totalContainers}) exceeds warehouse limit (${warehouse.capacityContainers})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
|
||||||
import { CreateWarehouseZoneDto } from './dto/create-warehouse-zone.dto';
|
import { CreateWarehouseZoneDto } from './dto/create-warehouse-zone.dto';
|
||||||
import { UpdateWarehouseZoneDto } from './dto/update-warehouse-zone.dto';
|
import { UpdateWarehouseZoneDto } from './dto/update-warehouse-zone.dto';
|
||||||
@@ -43,6 +43,7 @@ export class WarehouseZonesService {
|
|||||||
// Ensure the parent yard exists.
|
// Ensure the parent yard exists.
|
||||||
await this.yardsService.findById(yardId);
|
await this.yardsService.findById(yardId);
|
||||||
await this.assertCodeUnique(yardId, dto.code.trim());
|
await this.assertCodeUnique(yardId, dto.code.trim());
|
||||||
|
await this.assertCapacityWithinYard(yardId, dto.capacityWeight ?? null, dto.capacityContainers ?? null);
|
||||||
|
|
||||||
return this.zonesRepository.create({
|
return this.zonesRepository.create({
|
||||||
yardId,
|
yardId,
|
||||||
@@ -68,14 +69,22 @@ export class WarehouseZonesService {
|
|||||||
await this.assertCodeUnique(existing.yardId, dto.code.trim(), id);
|
await this.assertCodeUnique(existing.yardId, dto.code.trim(), id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const newCapacityWeight = dto.capacityWeight ?? existing.capacityWeight ?? null;
|
||||||
|
const newCapacityContainers = dto.capacityContainers ?? existing.capacityContainers ?? null;
|
||||||
|
|
||||||
|
// Validate updated capacity doesn't exceed yard limits
|
||||||
|
if (newCapacityWeight !== (existing.capacityWeight ?? null) || newCapacityContainers !== (existing.capacityContainers ?? null)) {
|
||||||
|
await this.assertCapacityWithinYard(existing.yardId, newCapacityWeight, newCapacityContainers, id);
|
||||||
|
}
|
||||||
|
|
||||||
const status = dto.status ?? existing.status;
|
const status = dto.status ?? existing.status;
|
||||||
|
|
||||||
const updated = await this.zonesRepository.update(id, {
|
const updated = await this.zonesRepository.update(id, {
|
||||||
name: dto.name?.trim() ?? existing.name,
|
name: dto.name?.trim() ?? existing.name,
|
||||||
code: dto.code?.trim() ?? existing.code,
|
code: dto.code?.trim() ?? existing.code,
|
||||||
type: dto.type ?? existing.type,
|
type: dto.type ?? existing.type,
|
||||||
capacityWeight: dto.capacityWeight ?? existing.capacityWeight,
|
capacityWeight: newCapacityWeight,
|
||||||
capacityContainers: dto.capacityContainers ?? existing.capacityContainers,
|
capacityContainers: newCapacityContainers,
|
||||||
maxWeight: dto.maxWeight ?? existing.maxWeight,
|
maxWeight: dto.maxWeight ?? existing.maxWeight,
|
||||||
maxVolume: dto.maxVolume ?? existing.maxVolume,
|
maxVolume: dto.maxVolume ?? existing.maxVolume,
|
||||||
status,
|
status,
|
||||||
@@ -96,4 +105,39 @@ export class WarehouseZonesService {
|
|||||||
throw new ConflictException(`Zone code ${code} already exists in this yard`);
|
throw new ConflictException(`Zone code ${code} already exists in this yard`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async assertCapacityWithinYard(
|
||||||
|
yardId: string,
|
||||||
|
newCapacityWeight: number | null,
|
||||||
|
newCapacityContainers: number | null,
|
||||||
|
excludeZoneId?: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const yard = await this.yardsService.findById(yardId);
|
||||||
|
const zones = await this.findByYard(yardId);
|
||||||
|
|
||||||
|
// Sum existing zone capacities, excluding the zone being updated if provided
|
||||||
|
const otherZones = excludeZoneId ? zones.filter((z) => z.id !== excludeZoneId) : zones;
|
||||||
|
const totalExistingWeight = otherZones.reduce((sum, z) => sum + (z.capacityWeight ?? 0), 0);
|
||||||
|
const totalExistingContainers = otherZones.reduce((sum, z) => sum + (z.capacityContainers ?? 0), 0);
|
||||||
|
|
||||||
|
// Check weight capacity
|
||||||
|
if (newCapacityWeight !== null && yard.capacityWeight != null) {
|
||||||
|
const totalWeight = totalExistingWeight + newCapacityWeight;
|
||||||
|
if (totalWeight > yard.capacityWeight) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Total zone weight capacity (${totalWeight}t) exceeds yard limit (${yard.capacityWeight}t)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check container capacity
|
||||||
|
if (newCapacityContainers !== null && yard.capacityContainers != null) {
|
||||||
|
const totalContainers = totalExistingContainers + newCapacityContainers;
|
||||||
|
if (totalContainers > yard.capacityContainers) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Total zone container capacity (${totalContainers}) exceeds yard limit (${yard.capacityContainers})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -304,4 +304,6 @@ export const EDR_FREIGHT_POSITIONS: FreightSeedPosition[] = [
|
|||||||
{ key: "djibouti_gl", name: { en: "Djibouti GL" }, rank: 3, permissionKeys: [...POSITION_PERMISSION_PRESETS.djiboutiGl] },
|
{ key: "djibouti_gl", name: { en: "Djibouti GL" }, rank: 3, permissionKeys: [...POSITION_PERMISSION_PRESETS.djiboutiGl] },
|
||||||
{ key: "marketer", name: { en: "Marketer" }, rank: 4, permissionKeys: [...POSITION_PERMISSION_PRESETS.marketer] },
|
{ key: "marketer", name: { en: "Marketer" }, rank: 4, permissionKeys: [...POSITION_PERMISSION_PRESETS.marketer] },
|
||||||
{ key: "operation", name: { en: "Operation" }, rank: 4, permissionKeys: [...POSITION_PERMISSION_PRESETS.operation] },
|
{ key: "operation", name: { en: "Operation" }, rank: 4, permissionKeys: [...POSITION_PERMISSION_PRESETS.operation] },
|
||||||
|
{ key: "operations_chief", name: { en: "Operations Chief" }, rank: 2, permissionKeys: [...POSITION_PERMISSION_PRESETS.operationsChief] },
|
||||||
|
{ key: "dispatcher", name: { en: "Dispatcher" }, rank: 4, permissionKeys: [...POSITION_PERMISSION_PRESETS.dispatcher] },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -804,6 +804,48 @@ export const POSITION_PERMISSION_PRESETS = {
|
|||||||
...ROLE_PERMISSION_PRESETS.operationsOfficer,
|
...ROLE_PERMISSION_PRESETS.operationsOfficer,
|
||||||
FREIGHT_PERMS.allocation.manage,
|
FREIGHT_PERMS.allocation.manage,
|
||||||
]),
|
]),
|
||||||
|
// Operations Chief: full operational authority — the entire freight
|
||||||
|
// permission catalog (all CRUD across bookings, contracts, scheduling,
|
||||||
|
// fleet, warehouse, mile, finance, settings, staff).
|
||||||
|
operationsChief: dedupe([...BOOKING_RULE_ENGINE_PERMISSION_KEYS]),
|
||||||
|
// Dispatcher: full CRUD on warehouse management (incl. import/export/intercity
|
||||||
|
// inventory flows) and fleet management, plus truck dispatch on the mile legs
|
||||||
|
// and operational context. The ONE carve-out: allocation & fee rules stay
|
||||||
|
// VIEW-ONLY — a dispatcher never creates/updates/deletes those rules.
|
||||||
|
dispatcher: dedupe([
|
||||||
|
// Warehouse management — full CRUD.
|
||||||
|
FREIGHT_PERMS.warehouseDashboard.view,
|
||||||
|
...Object.values(FREIGHT_PERMS.warehouses),
|
||||||
|
...Object.values(FREIGHT_PERMS.warehouseYards),
|
||||||
|
...Object.values(FREIGHT_PERMS.warehouseZones),
|
||||||
|
...Object.values(FREIGHT_PERMS.warehouseInventory),
|
||||||
|
...Object.values(FREIGHT_PERMS.warehouseInspectionReports),
|
||||||
|
...Object.values(FREIGHT_PERMS.interchangeDocuments),
|
||||||
|
...Object.values(FREIGHT_PERMS.warehouseFeeInvoices),
|
||||||
|
// View-only on the rules that govern allocation and fees.
|
||||||
|
FREIGHT_PERMS.warehouseAllocationRules.view,
|
||||||
|
FREIGHT_PERMS.warehouseFeeRules.view,
|
||||||
|
// Fleet management — full CRUD.
|
||||||
|
...Object.values(FREIGHT_PERMS.fleet),
|
||||||
|
FREIGHT_PERMS.fleetDashboard.view,
|
||||||
|
...Object.values(FREIGHT_PERMS.fleetReports),
|
||||||
|
...Object.values(FREIGHT_PERMS.vehicles),
|
||||||
|
...Object.values(FREIGHT_PERMS.drivers),
|
||||||
|
...Object.values(FREIGHT_PERMS.tracking),
|
||||||
|
...Object.values(FREIGHT_PERMS.fuel),
|
||||||
|
...Object.values(FREIGHT_PERMS.maintenance),
|
||||||
|
...Object.values(FREIGHT_PERMS.locomotives),
|
||||||
|
...Object.values(FREIGHT_PERMS.wagons),
|
||||||
|
...Object.values(FREIGHT_PERMS.trains),
|
||||||
|
...Object.values(FREIGHT_PERMS.routes),
|
||||||
|
...Object.values(FREIGHT_PERMS.containers),
|
||||||
|
...Object.values(FREIGHT_PERMS.cargoes),
|
||||||
|
// Truck dispatch on the EDR mile legs + operational context.
|
||||||
|
...Object.values(FREIGHT_PERMS.firstMile),
|
||||||
|
...Object.values(FREIGHT_PERMS.lastMile),
|
||||||
|
FREIGHT_PERMS.trainScheduling.view,
|
||||||
|
FREIGHT_PERMS.bookings.operations,
|
||||||
|
]),
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
/** Derive the module bucket from the resource segment of a permission key. */
|
/** Derive the module bucket from the resource segment of a permission key. */
|
||||||
|
|||||||
@@ -146,11 +146,13 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
|||||||
label: "Overview",
|
label: "Overview",
|
||||||
href: "/dashboard/overview",
|
href: "/dashboard/overview",
|
||||||
icon: <LayoutDashboard />,
|
icon: <LayoutDashboard />,
|
||||||
|
permission: FREIGHT_PERMS.overview.view,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Customers",
|
label: "Customers",
|
||||||
href: "/dashboard/customers",
|
href: "/dashboard/customers",
|
||||||
icon: <Building2 />,
|
icon: <Building2 />,
|
||||||
|
permission: FREIGHT_PERMS.customers.view,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Contracts",
|
label: "Contracts",
|
||||||
@@ -162,6 +164,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
|||||||
label: "Bookings",
|
label: "Bookings",
|
||||||
href: "/dashboard/booking-requests",
|
href: "/dashboard/booking-requests",
|
||||||
icon: <FileText />,
|
icon: <FileText />,
|
||||||
|
permission: FREIGHT_PERMS.bookings.view,
|
||||||
},
|
},
|
||||||
// Operations hub: clearance-document review for contracts WITHOUT
|
// Operations hub: clearance-document review for contracts WITHOUT
|
||||||
// customs clearing (contract-level for one-time, per-booking for general).
|
// customs clearing (contract-level for one-time, per-booking for general).
|
||||||
@@ -187,6 +190,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
|||||||
label: "Support",
|
label: "Support",
|
||||||
href: "/dashboard/support",
|
href: "/dashboard/support",
|
||||||
icon: <LifeBuoy />,
|
icon: <LifeBuoy />,
|
||||||
|
permission: FREIGHT_PERMS.support.view,
|
||||||
},
|
},
|
||||||
...demoItems,
|
...demoItems,
|
||||||
],
|
],
|
||||||
@@ -375,31 +379,37 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
|||||||
label: "Imports",
|
label: "Imports",
|
||||||
href: "/dashboard/import-warehouse",
|
href: "/dashboard/import-warehouse",
|
||||||
icon: <PackageOpen />,
|
icon: <PackageOpen />,
|
||||||
|
permission: FREIGHT_PERMS.warehouseInventory.view,
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
label: "Import Overview",
|
label: "Import Overview",
|
||||||
href: "/dashboard/import-warehouse",
|
href: "/dashboard/import-warehouse",
|
||||||
icon: <PackageOpen />,
|
icon: <PackageOpen />,
|
||||||
|
permission: FREIGHT_PERMS.warehouseInventory.view,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Arrival Queue",
|
label: "Arrival Queue",
|
||||||
href: "/dashboard/arrival-queue",
|
href: "/dashboard/arrival-queue",
|
||||||
icon: <PackageOpen />,
|
icon: <PackageOpen />,
|
||||||
|
permission: FREIGHT_PERMS.warehouseInventory.view,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Dispatch Queue",
|
label: "Dispatch Queue",
|
||||||
href: "/dashboard/dispatch-queue",
|
href: "/dashboard/dispatch-queue",
|
||||||
icon: <Send />,
|
icon: <Send />,
|
||||||
|
permission: FREIGHT_PERMS.warehouseInventory.view,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Terminal Inventory",
|
label: "Terminal Inventory",
|
||||||
href: "/dashboard/warehouse-inventory?direction=IMPORT",
|
href: "/dashboard/warehouse-inventory?direction=IMPORT",
|
||||||
icon: <Package />,
|
icon: <Package />,
|
||||||
|
permission: FREIGHT_PERMS.warehouseInventory.view,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Inventory Inquiry",
|
label: "Inventory Inquiry",
|
||||||
href: "/dashboard/inventory-inquiry",
|
href: "/dashboard/inventory-inquiry",
|
||||||
icon: <Boxes />,
|
icon: <Boxes />,
|
||||||
|
permission: FREIGHT_PERMS.warehouseInventory.view,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -407,41 +417,49 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
|||||||
label: "Exports",
|
label: "Exports",
|
||||||
href: "/dashboard/export-warehouse",
|
href: "/dashboard/export-warehouse",
|
||||||
icon: <Truck />,
|
icon: <Truck />,
|
||||||
|
permission: FREIGHT_PERMS.warehouseInventory.view,
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
label: "Export Overview",
|
label: "Export Overview",
|
||||||
href: "/dashboard/export-warehouse",
|
href: "/dashboard/export-warehouse",
|
||||||
icon: <Truck />,
|
icon: <Truck />,
|
||||||
|
permission: FREIGHT_PERMS.warehouseInventory.view,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Loading Queue",
|
label: "Loading Queue",
|
||||||
href: "/dashboard/loading-queue",
|
href: "/dashboard/loading-queue",
|
||||||
icon: <Truck />,
|
icon: <Truck />,
|
||||||
|
permission: FREIGHT_PERMS.warehouseInventory.view,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Loaded Inventory",
|
label: "Loaded Inventory",
|
||||||
href: "/dashboard/loaded-inventory",
|
href: "/dashboard/loaded-inventory",
|
||||||
icon: <PackageCheck />,
|
icon: <PackageCheck />,
|
||||||
|
permission: FREIGHT_PERMS.warehouseInventory.view,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Dispatch Queue",
|
label: "Dispatch Queue",
|
||||||
href: "/dashboard/dispatch-queue",
|
href: "/dashboard/dispatch-queue",
|
||||||
icon: <Send />,
|
icon: <Send />,
|
||||||
|
permission: FREIGHT_PERMS.warehouseInventory.view,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Djibouti Unloading",
|
label: "Djibouti Unloading",
|
||||||
href: "/dashboard/export-djibouti-unloading",
|
href: "/dashboard/export-djibouti-unloading",
|
||||||
icon: <PackageOpen />,
|
icon: <PackageOpen />,
|
||||||
|
permission: FREIGHT_PERMS.warehouseInventory.view,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Interchange Documents",
|
label: "Interchange Documents",
|
||||||
href: "/dashboard/interchange-documents",
|
href: "/dashboard/interchange-documents",
|
||||||
icon: <FileText />,
|
icon: <FileText />,
|
||||||
|
permission: FREIGHT_PERMS.interchangeDocuments.view,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Terminal Inventory",
|
label: "Terminal Inventory",
|
||||||
href: "/dashboard/warehouse-inventory?direction=EXPORT",
|
href: "/dashboard/warehouse-inventory?direction=EXPORT",
|
||||||
icon: <Package />,
|
icon: <Package />,
|
||||||
|
permission: FREIGHT_PERMS.warehouseInventory.view,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -449,11 +467,13 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
|||||||
label: "Intercity",
|
label: "Intercity",
|
||||||
href: "/dashboard/intercity",
|
href: "/dashboard/intercity",
|
||||||
icon: <TrainFront />,
|
icon: <TrainFront />,
|
||||||
|
permission: FREIGHT_PERMS.trainScheduling.view,
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
label: "Intercity Cargo",
|
label: "Intercity Cargo",
|
||||||
href: "/dashboard/intercity",
|
href: "/dashboard/intercity",
|
||||||
icon: <TrainFront />,
|
icon: <TrainFront />,
|
||||||
|
permission: FREIGHT_PERMS.warehouseInventory.view,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -465,6 +485,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
|||||||
label: "Warehouse Dashboard",
|
label: "Warehouse Dashboard",
|
||||||
href: "/dashboard/warehouse-dashboard",
|
href: "/dashboard/warehouse-dashboard",
|
||||||
icon: <LayoutDashboard />,
|
icon: <LayoutDashboard />,
|
||||||
|
permission: FREIGHT_PERMS.warehouseDashboard.view,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// Yard-wide, not per-direction: the gate sees import and export
|
// Yard-wide, not per-direction: the gate sees import and export
|
||||||
@@ -472,21 +493,28 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
|||||||
label: "Trucks on Site",
|
label: "Trucks on Site",
|
||||||
href: "/dashboard/trucks-on-site",
|
href: "/dashboard/trucks-on-site",
|
||||||
icon: <Truck />,
|
icon: <Truck />,
|
||||||
|
permission: FREIGHT_PERMS.warehouseInventory.view,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Warehouses",
|
label: "Warehouses",
|
||||||
href: "/dashboard/warehouses",
|
href: "/dashboard/warehouses",
|
||||||
icon: <Container />,
|
icon: <Container />,
|
||||||
|
permission: FREIGHT_PERMS.warehouses.view,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Allocation & Fees",
|
label: "Allocation & Fees",
|
||||||
href: "/dashboard/warehouse-rules",
|
href: "/dashboard/warehouse-rules",
|
||||||
icon: <SlidersHorizontal />,
|
icon: <SlidersHorizontal />,
|
||||||
|
permission: [
|
||||||
|
FREIGHT_PERMS.warehouseAllocationRules.view,
|
||||||
|
FREIGHT_PERMS.warehouseFeeRules.view,
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Fee Invoices",
|
label: "Fee Invoices",
|
||||||
href: "/dashboard/warehouse-fee-invoices",
|
href: "/dashboard/warehouse-fee-invoices",
|
||||||
icon: <Wallet />,
|
icon: <Wallet />,
|
||||||
|
permission: FREIGHT_PERMS.warehouseFeeInvoices.view,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -523,10 +551,12 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
|||||||
{
|
{
|
||||||
label: "Contract validity",
|
label: "Contract validity",
|
||||||
href: "/dashboard/configuration/contract-validity-periods",
|
href: "/dashboard/configuration/contract-validity-periods",
|
||||||
|
permission: FREIGHT_PERMS.config.contractValidity.view,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Train scheduling rules",
|
label: "Train scheduling rules",
|
||||||
href: "/dashboard/configuration/train-scheduling-rules",
|
href: "/dashboard/configuration/train-scheduling-rules",
|
||||||
|
permission: FREIGHT_PERMS.trainScheduling.rulesManage,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -541,6 +571,12 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
|||||||
label: "Staff",
|
label: "Staff",
|
||||||
href: "/user-management",
|
href: "/user-management",
|
||||||
icon: <Users />,
|
icon: <Users />,
|
||||||
|
permission: [
|
||||||
|
FREIGHT_PERMS.admin,
|
||||||
|
FREIGHT_PERMS.staff.roles.view,
|
||||||
|
FREIGHT_PERMS.staff.employeeRegistration.view,
|
||||||
|
FREIGHT_PERMS.staff.roleAssignment.view,
|
||||||
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -585,21 +621,32 @@ const filterSidebarByPermission = (
|
|||||||
return keys.some((key) => hasFreightPermission(user, key));
|
return keys.some((key) => hasFreightPermission(user, key));
|
||||||
};
|
};
|
||||||
|
|
||||||
const itemAllowed = (item: SidebarItem): boolean => {
|
// Recursive: children are filtered first; a group (item with children) stays
|
||||||
// GL positions are locked to their single clearance page.
|
// only while it still has visible children — so parents without their own
|
||||||
if (etGl) return isEtClearanceItem(item);
|
// permission key never leak a whole subtree the user cannot open.
|
||||||
if (djGl) return isDjClearanceItem(item);
|
const filterItems = (items: SidebarItem[]): SidebarItem[] =>
|
||||||
|
items
|
||||||
// Everyone else: hide the GL-only clearance pages entirely.
|
.map((item) =>
|
||||||
if (isClearanceItem(item)) return false;
|
item.children ? { ...item, children: filterItems(item.children) } : item,
|
||||||
|
)
|
||||||
return permissionAllowed(item);
|
.filter((item) => {
|
||||||
};
|
if (etGl || djGl) {
|
||||||
|
// GL positions are locked to their single clearance page (parents
|
||||||
|
// survive only as the path to that page).
|
||||||
|
const isTarget = etGl ? isEtClearanceItem : isDjClearanceItem;
|
||||||
|
return isTarget(item) || (item.children?.length ?? 0) > 0;
|
||||||
|
}
|
||||||
|
// Everyone else: hide the GL-only clearance pages entirely.
|
||||||
|
if (isClearanceItem(item)) return false;
|
||||||
|
if (!permissionAllowed(item)) return false;
|
||||||
|
if (item.children) return item.children.length > 0;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
return sections
|
return sections
|
||||||
.map((section) => ({
|
.map((section) => ({
|
||||||
...section,
|
...section,
|
||||||
items: section.items.filter(itemAllowed),
|
items: filterItems(section.items),
|
||||||
}))
|
}))
|
||||||
.filter((section) => section.items.length > 0);
|
.filter((section) => section.items.length > 0);
|
||||||
};
|
};
|
||||||
@@ -708,7 +755,7 @@ const App = () => {
|
|||||||
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
|
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
|
||||||
{/* <Route path="/um/*" element={<UserManagementHostPage />} /> */}
|
{/* <Route path="/um/*" element={<UserManagementHostPage />} /> */}
|
||||||
<Route path="um/set-password" element={<SetPassword />} />
|
<Route path="um/set-password" element={<SetPassword />} />
|
||||||
<Route path="/callback" element={<FaydaCallbackPage />} />
|
<Route path="/fayda/callback" element={<FaydaCallbackPage />} />
|
||||||
<Route path="*" element={<Navigate to="/auth" replace />} />
|
<Route path="*" element={<Navigate to="/auth" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ interface AuthEmployeePosition {
|
|||||||
permissions?: AuthPermission[];
|
permissions?: AuthPermission[];
|
||||||
/** Some IAM payloads nest the position record instead of flattening its key. */
|
/** Some IAM payloads nest the position record instead of flattening its key. */
|
||||||
position?: { id?: string; key?: string; name?: LocaleText };
|
position?: { id?: string; key?: string; name?: LocaleText };
|
||||||
|
positionType?: { id?: string; key?: string; name?: LocaleText } | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AuthEmployeeRecord {
|
interface AuthEmployeeRecord {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { Textarea } from '@/components/ui/textarea';
|
|||||||
import { useMutation } from '@tanstack/react-query';
|
import { useMutation } from '@tanstack/react-query';
|
||||||
import { api } from '@/services/api';
|
import { api } from '@/services/api';
|
||||||
import { useToast } from '@/hooks/use-toast';
|
import { useToast } from '@/hooks/use-toast';
|
||||||
|
import { isBackdated, nowLocalDateTimeInput } from '@/lib/no-backdate';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Customer Pickup + Proof of Delivery capture for a LOADED cargo.
|
* Customer Pickup + Proof of Delivery capture for a LOADED cargo.
|
||||||
@@ -27,6 +28,10 @@ export function DeliverCargoDialog({ cargoId, onSuccess }: { cargoId: string; on
|
|||||||
toast({ title: 'Receiver name is required', variant: 'destructive' });
|
toast({ title: 'Receiver name is required', variant: 'destructive' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (isBackdated(pickupDate)) {
|
||||||
|
toast({ title: 'Pickup date cannot be in the past', variant: 'destructive' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await deliver.mutateAsync({
|
await deliver.mutateAsync({
|
||||||
id: cargoId,
|
id: cargoId,
|
||||||
@@ -75,6 +80,7 @@ export function DeliverCargoDialog({ cargoId, onSuccess }: { cargoId: string; on
|
|||||||
<Label>Pickup date</Label>
|
<Label>Pickup date</Label>
|
||||||
<Input
|
<Input
|
||||||
type="datetime-local"
|
type="datetime-local"
|
||||||
|
min={nowLocalDateTimeInput()}
|
||||||
value={pickupDate}
|
value={pickupDate}
|
||||||
onChange={(e) => setPickupDate(e.target.value)}
|
onChange={(e) => setPickupDate(e.target.value)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ const INCIDENT_OPTIONS: { value: Freight.IncidentType; label: string }[] = [
|
|||||||
{ value: "CONTAINER_OPENED", label: "Container opened" },
|
{ value: "CONTAINER_OPENED", label: "Container opened" },
|
||||||
{ value: "CONTAINER_DAMAGED", label: "Container damaged" },
|
{ value: "CONTAINER_DAMAGED", label: "Container damaged" },
|
||||||
{ value: "FLUID_LEAKING", label: "Fluid leaking" },
|
{ value: "FLUID_LEAKING", label: "Fluid leaking" },
|
||||||
|
{ value: "OTHER", label: "Other" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const LABEL: Record<Freight.IncidentType, string> = {
|
const LABEL: Record<Freight.IncidentType, string> = {
|
||||||
@@ -30,6 +31,7 @@ const LABEL: Record<Freight.IncidentType, string> = {
|
|||||||
CONTAINER_OPENED: "Container opened",
|
CONTAINER_OPENED: "Container opened",
|
||||||
CONTAINER_DAMAGED: "Container damaged",
|
CONTAINER_DAMAGED: "Container damaged",
|
||||||
FLUID_LEAKING: "Fluid leaking",
|
FLUID_LEAKING: "Fluid leaking",
|
||||||
|
OTHER: "Other",
|
||||||
};
|
};
|
||||||
|
|
||||||
export function IncidentReportCard({ bookingId }: { bookingId: string }) {
|
export function IncidentReportCard({ bookingId }: { bookingId: string }) {
|
||||||
|
|||||||
@@ -61,8 +61,11 @@ export default function ResetPasswordAction({
|
|||||||
|
|
||||||
if (!allowed) return null;
|
if (!allowed) return null;
|
||||||
|
|
||||||
|
// SMS is domestic-only: a foreign number counts as unavailable, same as a
|
||||||
|
// missing one, so staff can't send a link that will never arrive.
|
||||||
|
const phoneUsable = !!target?.phone && target.phoneIsDomestic !== false;
|
||||||
const channelMissing =
|
const channelMissing =
|
||||||
!!target && (channel === "email" ? !target.email : !target.phone);
|
!!target && (channel === "email" ? !target.email : !phoneUsable);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -106,9 +109,13 @@ export default function ResetPasswordAction({
|
|||||||
<Radio
|
<Radio
|
||||||
value="phone"
|
value="phone"
|
||||||
label="SMS"
|
label="SMS"
|
||||||
disabled={!target.phone}
|
disabled={!phoneUsable}
|
||||||
description={
|
description={
|
||||||
target.phone ?? "No phone number on this account"
|
!target.phone
|
||||||
|
? "No phone number on this account"
|
||||||
|
: target.phoneIsDomestic === false
|
||||||
|
? `${target.phone} — foreign number, SMS unavailable; use email`
|
||||||
|
: target.phone
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Radio
|
<Radio
|
||||||
|
|||||||
@@ -298,34 +298,82 @@ export function ProfileApprovalActions({
|
|||||||
const { mutate, isPending } = useMutation(
|
const { mutate, isPending } = useMutation(
|
||||||
api.customers.setProfileStatus.mutationOptions(),
|
api.customers.setProfileStatus.mutationOptions(),
|
||||||
);
|
);
|
||||||
const [rejectOpen, setRejectOpen] = useState(false);
|
const [decision, setDecision] = useState<
|
||||||
|
"reject" | "suspend" | "reactivate" | null
|
||||||
|
>(null);
|
||||||
const [note, setNote] = useState("");
|
const [note, setNote] = useState("");
|
||||||
|
|
||||||
const act = (next: ProfileStatus) => mutate({ profileId, status: next });
|
const act = (next: ProfileStatus) => mutate({ profileId, status: next });
|
||||||
|
|
||||||
const confirmReject = () => {
|
// Decisions the customer must be given a reason for. Reject/suspend/reactivate
|
||||||
|
// all capture a required message through the same modal; the API refuses
|
||||||
|
// suspend/reactivate without one.
|
||||||
|
const DECISIONS = {
|
||||||
|
reject: {
|
||||||
|
title: "Reject profile",
|
||||||
|
intro:
|
||||||
|
"Tell the customer what needs fixing. They'll see this note and can " +
|
||||||
|
"amend and resubmit the role for approval.",
|
||||||
|
label: "Reason for rejection",
|
||||||
|
placeholder: "e.g. The uploaded business license is expired.",
|
||||||
|
confirmLabel: "Reject profile",
|
||||||
|
color: "red",
|
||||||
|
status: "rejected" as ProfileStatus,
|
||||||
|
},
|
||||||
|
suspend: {
|
||||||
|
title: "Suspend role",
|
||||||
|
intro:
|
||||||
|
"Explain why this role is being suspended. The customer will see this " +
|
||||||
|
"message and cannot operate under the role until it is reactivated.",
|
||||||
|
label: "Reason for suspension",
|
||||||
|
placeholder: "e.g. Outstanding invoices unpaid for over 90 days.",
|
||||||
|
confirmLabel: "Suspend role",
|
||||||
|
color: "orange",
|
||||||
|
status: "suspended" as ProfileStatus,
|
||||||
|
},
|
||||||
|
reactivate: {
|
||||||
|
title: "Reactivate role",
|
||||||
|
intro:
|
||||||
|
"Explain why this role is being reactivated. The customer will see " +
|
||||||
|
"this message and can operate under the role again.",
|
||||||
|
label: "Reactivation message",
|
||||||
|
placeholder: "e.g. Outstanding payments have been settled.",
|
||||||
|
confirmLabel: "Reactivate role",
|
||||||
|
color: "edr-green",
|
||||||
|
status: "active" as ProfileStatus,
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const openDecision = (kind: keyof typeof DECISIONS) => {
|
||||||
|
setNote("");
|
||||||
|
setDecision(kind);
|
||||||
|
};
|
||||||
|
|
||||||
|
const active = decision ? DECISIONS[decision] : null;
|
||||||
|
|
||||||
|
const confirmDecision = () => {
|
||||||
|
if (!active) return;
|
||||||
mutate(
|
mutate(
|
||||||
{ profileId, status: "rejected", note: note.trim() },
|
{ profileId, status: active.status, note: note.trim() },
|
||||||
{ onSuccess: () => setRejectOpen(false) },
|
{ onSuccess: () => setDecision(null) },
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const rejectModal = (
|
const decisionModal = active && (
|
||||||
<Modal
|
<Modal
|
||||||
opened={rejectOpen}
|
opened
|
||||||
onClose={() => setRejectOpen(false)}
|
onClose={() => setDecision(null)}
|
||||||
title="Reject profile"
|
title={active.title}
|
||||||
centered
|
centered
|
||||||
radius="lg"
|
radius="lg"
|
||||||
>
|
>
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
Tell the customer what needs fixing. They'll see this note and can
|
{active.intro}
|
||||||
amend and resubmit the role for approval.
|
|
||||||
</Text>
|
</Text>
|
||||||
<Textarea
|
<Textarea
|
||||||
label="Reason for rejection"
|
label={active.label}
|
||||||
placeholder="e.g. The uploaded business license is expired."
|
placeholder={active.placeholder}
|
||||||
autosize
|
autosize
|
||||||
minRows={3}
|
minRows={3}
|
||||||
value={note}
|
value={note}
|
||||||
@@ -335,18 +383,18 @@ export function ProfileApprovalActions({
|
|||||||
<Group justify="flex-end" gap="sm">
|
<Group justify="flex-end" gap="sm">
|
||||||
<Button
|
<Button
|
||||||
variant="default"
|
variant="default"
|
||||||
onClick={() => setRejectOpen(false)}
|
onClick={() => setDecision(null)}
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
color="red"
|
color={active.color}
|
||||||
loading={isPending}
|
loading={isPending}
|
||||||
disabled={note.trim().length === 0}
|
disabled={note.trim().length === 0}
|
||||||
onClick={confirmReject}
|
onClick={confirmDecision}
|
||||||
>
|
>
|
||||||
Reject profile
|
{active.confirmLabel}
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
@@ -368,7 +416,7 @@ export function ProfileApprovalActions({
|
|||||||
if (status === "pending") {
|
if (status === "pending") {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{rejectModal}
|
{decisionModal}
|
||||||
<Group gap={6} wrap="nowrap">
|
<Group gap={6} wrap="nowrap">
|
||||||
<Button
|
<Button
|
||||||
size="xs"
|
size="xs"
|
||||||
@@ -385,7 +433,7 @@ export function ProfileApprovalActions({
|
|||||||
variant="light"
|
variant="light"
|
||||||
color="red"
|
color="red"
|
||||||
radius="md"
|
radius="md"
|
||||||
onClick={() => setRejectOpen(true)}
|
onClick={() => openDecision("reject")}
|
||||||
>
|
>
|
||||||
Reject
|
Reject
|
||||||
</Button>
|
</Button>
|
||||||
@@ -411,29 +459,33 @@ export function ProfileApprovalActions({
|
|||||||
|
|
||||||
if (status === "active") {
|
if (status === "active") {
|
||||||
return (
|
return (
|
||||||
<Button
|
<>
|
||||||
size="xs"
|
{decisionModal}
|
||||||
variant="light"
|
<Button
|
||||||
color="orange"
|
size="xs"
|
||||||
radius="md"
|
variant="light"
|
||||||
loading={isPending}
|
color="orange"
|
||||||
onClick={() => act("suspended")}
|
radius="md"
|
||||||
>
|
loading={isPending}
|
||||||
Suspend
|
onClick={() => openDecision("suspend")}
|
||||||
</Button>
|
>
|
||||||
|
Suspend
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status === "suspended") {
|
if (status === "suspended") {
|
||||||
return (
|
return (
|
||||||
<Group gap={6} wrap="nowrap">
|
<Group gap={6} wrap="nowrap">
|
||||||
|
{decisionModal}
|
||||||
<Button
|
<Button
|
||||||
size="xs"
|
size="xs"
|
||||||
variant="light"
|
variant="light"
|
||||||
color="edr-green"
|
color="edr-green"
|
||||||
radius="md"
|
radius="md"
|
||||||
loading={isPending}
|
loading={isPending}
|
||||||
onClick={() => act("active")}
|
onClick={() => openDecision("reactivate")}
|
||||||
>
|
>
|
||||||
Reactivate
|
Reactivate
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -249,6 +249,16 @@ const FleetFormDialog = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Format check (e.g. plate numbers). Skipped for an empty optional field —
|
||||||
|
// "required" above already owns the empty case. Upper-cased to match the
|
||||||
|
// server, which stores plates upper-case.
|
||||||
|
if (field.pattern && stringValue && stringValue !== FLEET_SELECT_NONE) {
|
||||||
|
const candidate = field.pattern.uppercase === false ? stringValue : stringValue.toUpperCase();
|
||||||
|
if (!field.pattern.regex.test(candidate)) {
|
||||||
|
next[field.name] = field.pattern.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
setErrors(next);
|
setErrors(next);
|
||||||
return Object.keys(next).length === 0;
|
return Object.keys(next).length === 0;
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { useEffect, useState } from 'react';
|
|||||||
|
|
||||||
import { QUERY_KEYS } from '@/constants/QUERY_KEYS';
|
import { QUERY_KEYS } from '@/constants/QUERY_KEYS';
|
||||||
import { useToast } from '@/hooks/use-toast';
|
import { useToast } from '@/hooks/use-toast';
|
||||||
|
import { isBackdated } from '@/lib/no-backdate';
|
||||||
import { lastMileService, type LastMileRecord } from '@/services/last-mile.service';
|
import { lastMileService, type LastMileRecord } from '@/services/last-mile.service';
|
||||||
|
|
||||||
interface TruckDetentionModalProps {
|
interface TruckDetentionModalProps {
|
||||||
@@ -111,6 +112,7 @@ export function TruckDetentionModal({ opened, onClose, record }: TruckDetentionM
|
|||||||
description="Detention clock start"
|
description="Detention clock start"
|
||||||
value={arrived}
|
value={arrived}
|
||||||
onChange={(v) => setArrived(v ? new Date(v) : null)}
|
onChange={(v) => setArrived(v ? new Date(v) : null)}
|
||||||
|
minDate={new Date()}
|
||||||
clearable
|
clearable
|
||||||
/>
|
/>
|
||||||
<DateTimePicker
|
<DateTimePicker
|
||||||
@@ -118,11 +120,26 @@ export function TruckDetentionModal({ opened, onClose, record }: TruckDetentionM
|
|||||||
description="Clock end (blank = still out)"
|
description="Clock end (blank = still out)"
|
||||||
value={delivered}
|
value={delivered}
|
||||||
onChange={(v) => setDelivered(v ? new Date(v) : null)}
|
onChange={(v) => setDelivered(v ? new Date(v) : null)}
|
||||||
|
minDate={new Date()}
|
||||||
clearable
|
clearable
|
||||||
/>
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
<Group justify="flex-end">
|
<Group justify="flex-end">
|
||||||
<Button variant="light" loading={saveTimes.isPending} onClick={() => saveTimes.mutate()}>
|
<Button
|
||||||
|
variant="light"
|
||||||
|
loading={saveTimes.isPending}
|
||||||
|
onClick={() => {
|
||||||
|
// No backdating: detention times are recorded as they happen.
|
||||||
|
if (isBackdated(arrived) || isBackdated(delivered)) {
|
||||||
|
toast({
|
||||||
|
variant: 'destructive',
|
||||||
|
title: 'Detention times cannot be in the past',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
saveTimes.mutate();
|
||||||
|
}}
|
||||||
|
>
|
||||||
Save times
|
Save times
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
|
|||||||
@@ -2,41 +2,59 @@ import { useNavigate } from "react-router-dom";
|
|||||||
import { ArrowRight, FileText, Train, Users } from "lucide-react";
|
import { ArrowRight, FileText, Train, Users } from "lucide-react";
|
||||||
import { Card, Group, SimpleGrid, Stack, Text, ThemeIcon } from "@mantine/core";
|
import { Card, Group, SimpleGrid, Stack, Text, ThemeIcon } from "@mantine/core";
|
||||||
|
|
||||||
|
import { useAuth } from "@/auth/useAuth";
|
||||||
|
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||||
|
|
||||||
const links = [
|
const links = [
|
||||||
{
|
{
|
||||||
title: "Booking requests",
|
title: "Booking requests",
|
||||||
description: "Review and action incoming freight bookings",
|
description: "Review and action incoming freight bookings",
|
||||||
href: "/dashboard/booking-requests",
|
href: "/dashboard/booking-requests",
|
||||||
icon: FileText,
|
icon: FileText,
|
||||||
|
permission: [FREIGHT_PERMS.bookings.view],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Train scheduling v2",
|
title: "Train scheduling v2",
|
||||||
description: "Full allocation workflow — assign, pin wagons, finalize",
|
description: "Full allocation workflow — assign, pin wagons, finalize",
|
||||||
href: "/dashboard/operations/train-scheduling-v2",
|
href: "/dashboard/operations/train-scheduling-v2",
|
||||||
icon: Train,
|
icon: Train,
|
||||||
|
permission: [FREIGHT_PERMS.trainScheduling.view],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Trains",
|
title: "Trains",
|
||||||
description: "Manage train master data and fleet status",
|
description: "Manage train master data and fleet status",
|
||||||
href: "/dashboard/trains",
|
href: "/dashboard/trains",
|
||||||
icon: Train,
|
icon: Train,
|
||||||
|
permission: [FREIGHT_PERMS.fleet.view, FREIGHT_PERMS.trains.view],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "User management",
|
title: "User management",
|
||||||
description: "Employees, roles, and permissions",
|
description: "Employees, roles, and permissions",
|
||||||
href: "/user-management",
|
href: "/user-management",
|
||||||
icon: Users,
|
icon: Users,
|
||||||
|
permission: [
|
||||||
|
FREIGHT_PERMS.admin,
|
||||||
|
FREIGHT_PERMS.staff.roles.view,
|
||||||
|
FREIGHT_PERMS.staff.employeeRegistration.view,
|
||||||
|
FREIGHT_PERMS.staff.roleAssignment.view,
|
||||||
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export function OverviewQuickLinks() {
|
export function OverviewQuickLinks() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { user } = useAuth();
|
||||||
|
|
||||||
|
const visible = links.filter((link) =>
|
||||||
|
link.permission.some((key) => hasPermission(user, key)),
|
||||||
|
);
|
||||||
|
if (!visible.length) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="md" h="100%">
|
<Stack gap="md" h="100%">
|
||||||
<Text fw={600}>Quick links</Text>
|
<Text fw={600}>Quick links</Text>
|
||||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="sm">
|
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="sm">
|
||||||
{links.map((link) => {
|
{visible.map((link) => {
|
||||||
const Icon = link.icon;
|
const Icon = link.icon;
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
|
|||||||
@@ -97,6 +97,15 @@ function CorridorCell({ row }: { row: IntercityBookingRow }) {
|
|||||||
* confirmed manually when the train is physically at the booking's origin /
|
* confirmed manually when the train is physically at the booking's origin /
|
||||||
* destination yard (the server validates against recorded checkpoints).
|
* destination yard (the server validates against recorded checkpoints).
|
||||||
*/
|
*/
|
||||||
|
/** Plain-language journey states for the accepted ride-along table. */
|
||||||
|
const INTERCITY_STATUS_META: Record<string, { label: string; color: string }> = {
|
||||||
|
SELECTED_FOR_BATCH: { label: "Awaiting payment", color: "yellow" },
|
||||||
|
APPROVED: { label: "Ready to load (gov)", color: "edr-green" },
|
||||||
|
PAID: { label: "Paid — ready to load", color: "edr-green" },
|
||||||
|
IN_TRANSIT: { label: "Loaded — in transit", color: "indigo" },
|
||||||
|
COMPLETED: { label: "Delivered", color: "teal" },
|
||||||
|
};
|
||||||
|
|
||||||
export function IntercityRideAlongPanel({
|
export function IntercityRideAlongPanel({
|
||||||
scheduleId,
|
scheduleId,
|
||||||
direction,
|
direction,
|
||||||
@@ -115,10 +124,20 @@ export function IntercityRideAlongPanel({
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const invalidate = () =>
|
// Accepting/loading/unloading a ride-along changes the schedule's booking
|
||||||
queryClient.invalidateQueries({
|
// list, the yard worklists AND this panel — refresh all three so the
|
||||||
|
// workspace board and yard-work tables never show a stale picture.
|
||||||
|
const invalidate = () => {
|
||||||
|
void queryClient.invalidateQueries({
|
||||||
queryKey: api.trainScheduling.intercityCandidates.queryKey({ scheduleId }),
|
queryKey: api.trainScheduling.intercityCandidates.queryKey({ scheduleId }),
|
||||||
});
|
});
|
||||||
|
void queryClient.invalidateQueries({
|
||||||
|
queryKey: api.trainScheduling.yardWork.queryKey({ scheduleId }),
|
||||||
|
});
|
||||||
|
void queryClient.invalidateQueries({
|
||||||
|
queryKey: api.trainScheduling.scheduleDetail.queryKey({ id: scheduleId }),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const accept = useMutation(
|
const accept = useMutation(
|
||||||
api.trainScheduling.acceptIntercityBookings.mutationOptions({
|
api.trainScheduling.acceptIntercityBookings.mutationOptions({
|
||||||
@@ -330,8 +349,14 @@ export function IntercityRideAlongPanel({
|
|||||||
<CorridorCell row={row} />
|
<CorridorCell row={row} />
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Badge size="sm" variant="light">
|
<Badge
|
||||||
{row.status}
|
size="sm"
|
||||||
|
variant="light"
|
||||||
|
color={
|
||||||
|
INTERCITY_STATUS_META[row.status ?? ""]?.color ?? "gray"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{INTERCITY_STATUS_META[row.status ?? ""]?.label ?? row.status}
|
||||||
</Badge>
|
</Badge>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
|
|||||||
@@ -584,6 +584,15 @@ export function ScheduleWorkspacePanel({
|
|||||||
customer={b.customer}
|
customer={b.customer}
|
||||||
weightTons={b.weightTons}
|
weightTons={b.weightTons}
|
||||||
status={b.status}
|
status={b.status}
|
||||||
|
intercity={b.tradeDirection === "DOMESTIC"}
|
||||||
|
leg={
|
||||||
|
b.origin &&
|
||||||
|
b.destination &&
|
||||||
|
(b.originYardId !== schedule.originStation?.id ||
|
||||||
|
b.destinationYardId !== schedule.destinationStation?.id)
|
||||||
|
? `${b.origin} → ${b.destination}`
|
||||||
|
: null
|
||||||
|
}
|
||||||
loadingStatus={b.wagonAssigned ? b.loadingStatus ?? "UNLOADED" : undefined}
|
loadingStatus={b.wagonAssigned ? b.loadingStatus ?? "UNLOADED" : undefined}
|
||||||
right={
|
right={
|
||||||
canManage ? (
|
canManage ? (
|
||||||
@@ -840,6 +849,8 @@ function BookingCard({
|
|||||||
status,
|
status,
|
||||||
loadingStatus,
|
loadingStatus,
|
||||||
waitingForWagon,
|
waitingForWagon,
|
||||||
|
intercity,
|
||||||
|
leg,
|
||||||
right,
|
right,
|
||||||
}: {
|
}: {
|
||||||
reference: string;
|
reference: string;
|
||||||
@@ -849,6 +860,10 @@ function BookingCard({
|
|||||||
loadingStatus?: "LOADED" | "UNLOADED";
|
loadingStatus?: "LOADED" | "UNLOADED";
|
||||||
/** Paid, but no wagon of the required type was free — waiting for one. */
|
/** Paid, but no wagon of the required type was free — waiting for one. */
|
||||||
waitingForWagon?: boolean;
|
waitingForWagon?: boolean;
|
||||||
|
/** DOMESTIC ride-along riding only part of this train's corridor. */
|
||||||
|
intercity?: boolean;
|
||||||
|
/** "Origin → Destination" when the booking rides a sub-corridor leg. */
|
||||||
|
leg?: string | null;
|
||||||
right?: React.ReactNode;
|
right?: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
@@ -874,6 +889,16 @@ function BookingCard({
|
|||||||
{reference}
|
{reference}
|
||||||
</Text>
|
</Text>
|
||||||
{status ? <BookingStatusBadge status={status} /> : null}
|
{status ? <BookingStatusBadge status={status} /> : null}
|
||||||
|
{intercity ? (
|
||||||
|
<Tooltip
|
||||||
|
label="Intercity ride-along — rides only its own leg of this train's corridor"
|
||||||
|
withArrow
|
||||||
|
>
|
||||||
|
<Badge size="sm" radius="sm" variant="filled" color="indigo">
|
||||||
|
Intercity
|
||||||
|
</Badge>
|
||||||
|
</Tooltip>
|
||||||
|
) : null}
|
||||||
{waitingForWagon ? (
|
{waitingForWagon ? (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
label="Paid, but no wagon of the required type was free. Free a wagon or assign it to a same-day train that has one."
|
label="Paid, but no wagon of the required type was free. Free a wagon or assign it to a same-day train that has one."
|
||||||
@@ -907,6 +932,11 @@ function BookingCard({
|
|||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
) : null}
|
) : null}
|
||||||
|
{leg ? (
|
||||||
|
<Text size="xs" c="indigo.7" fw={600} style={{ whiteSpace: "nowrap" }}>
|
||||||
|
{leg}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
{right ? <Box style={{ flexShrink: 0 }}>{right}</Box> : null}
|
{right ? <Box style={{ flexShrink: 0 }}>{right}</Box> : null}
|
||||||
|
|||||||
@@ -2,7 +2,10 @@ import { Badge, Card, Group, Progress, SimpleGrid, Stack, Text, ThemeIcon } from
|
|||||||
import { Box, Package } from "lucide-react";
|
import { Box, Package } from "lucide-react";
|
||||||
import type { TrainScheduleWagonAllocation, WagonPlanRow } from "@/types/trainScheduling";
|
import type { TrainScheduleWagonAllocation, WagonPlanRow } from "@/types/trainScheduling";
|
||||||
|
|
||||||
type WagonSlot = (WagonPlanRow & { physicalWagonNumber?: string | null }) | {
|
type WagonSlot = (WagonPlanRow & {
|
||||||
|
physicalWagonNumber?: string | null;
|
||||||
|
tareWeightTons?: number | null;
|
||||||
|
}) | {
|
||||||
sequenceNo: number;
|
sequenceNo: number;
|
||||||
capacityTons: number;
|
capacityTons: number;
|
||||||
assignedWeightTons: number;
|
assignedWeightTons: number;
|
||||||
|
|||||||
@@ -201,10 +201,19 @@ export function YardWorkPanel({ scheduleId }: { scheduleId: string }) {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const invalidate = () =>
|
// Loading/unloading changes booking status on the schedule detail and the
|
||||||
queryClient.invalidateQueries({
|
// intercity panel too — refresh all three so no surface shows a stale state.
|
||||||
|
const invalidate = () => {
|
||||||
|
void queryClient.invalidateQueries({
|
||||||
queryKey: api.trainScheduling.yardWork.queryKey({ scheduleId }),
|
queryKey: api.trainScheduling.yardWork.queryKey({ scheduleId }),
|
||||||
});
|
});
|
||||||
|
void queryClient.invalidateQueries({
|
||||||
|
queryKey: api.trainScheduling.intercityCandidates.queryKey({ scheduleId }),
|
||||||
|
});
|
||||||
|
void queryClient.invalidateQueries({
|
||||||
|
queryKey: api.trainScheduling.scheduleDetail.queryKey({ id: scheduleId }),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const load = useMutation(
|
const load = useMutation(
|
||||||
api.trainScheduling.loadScheduleBooking.mutationOptions({
|
api.trainScheduling.loadScheduleBooking.mutationOptions({
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useState } from "react";
|
||||||
import { Badge, Box, Group, HoverCard, Stack, Text } from "@mantine/core";
|
import { Badge, Box, Group, HoverCard, Stack, Text } from "@mantine/core";
|
||||||
import {
|
import {
|
||||||
Building2,
|
Building2,
|
||||||
@@ -14,6 +15,13 @@ import { freightBrand } from "@/theme/freight-brand";
|
|||||||
type Wagon = NonNullable<TrainScheduleDetail["trainSet"]>["wagons"][number];
|
type Wagon = NonNullable<TrainScheduleDetail["trainSet"]>["wagons"][number];
|
||||||
type Locomotive = NonNullable<TrainScheduleDetail["trainSet"]>["locomotive"];
|
type Locomotive = NonNullable<TrainScheduleDetail["trainSet"]>["locomotive"];
|
||||||
|
|
||||||
|
export interface WagonLoadMove {
|
||||||
|
sourceWagonId: string;
|
||||||
|
targetWagonId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type DragState = { sourceWagonId: string } | null;
|
||||||
|
|
||||||
interface InteractiveTrainConsistProps {
|
interface InteractiveTrainConsistProps {
|
||||||
wagons: Wagon[];
|
wagons: Wagon[];
|
||||||
locomotive: Locomotive | null | undefined;
|
locomotive: Locomotive | null | undefined;
|
||||||
@@ -23,8 +31,16 @@ interface InteractiveTrainConsistProps {
|
|||||||
onSelectWagon: (wagon: Wagon) => void;
|
onSelectWagon: (wagon: Wagon) => void;
|
||||||
/** Booking id to highlight across the train (e.g. selected in the side panel). */
|
/** Booking id to highlight across the train (e.g. selected in the side panel). */
|
||||||
highlightBookingId?: string | null;
|
highlightBookingId?: string | null;
|
||||||
|
/** Wagon loads become draggable: drop on an empty wagon to move, a loaded one to swap. */
|
||||||
|
canRearrange?: boolean;
|
||||||
|
onMoveLoad?: (move: WagonLoadMove) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const wagonItems = (wagon: Wagon) =>
|
||||||
|
(wagon.allocations ?? [])
|
||||||
|
.flatMap((a) => a.containerItems ?? [])
|
||||||
|
.sort((a, b) => (a.positionOnWagon ?? 99) - (b.positionOnWagon ?? 99));
|
||||||
|
|
||||||
const CONTAINER_GRADIENTS = [
|
const CONTAINER_GRADIENTS = [
|
||||||
"linear-gradient(180deg, var(--mantine-color-cyan-5), var(--mantine-color-cyan-7))",
|
"linear-gradient(180deg, var(--mantine-color-cyan-5), var(--mantine-color-cyan-7))",
|
||||||
"linear-gradient(180deg, var(--mantine-color-blue-5), var(--mantine-color-blue-7))",
|
"linear-gradient(180deg, var(--mantine-color-blue-5), var(--mantine-color-blue-7))",
|
||||||
@@ -151,16 +167,27 @@ function WagonCar({
|
|||||||
selected,
|
selected,
|
||||||
highlighted,
|
highlighted,
|
||||||
onSelect,
|
onSelect,
|
||||||
|
drag,
|
||||||
|
onDragChange,
|
||||||
|
onMoveLoad,
|
||||||
|
canRearrange,
|
||||||
}: {
|
}: {
|
||||||
wagon: Wagon;
|
wagon: Wagon;
|
||||||
company: string | null;
|
company: string | null;
|
||||||
selected: boolean;
|
selected: boolean;
|
||||||
highlighted: boolean;
|
highlighted: boolean;
|
||||||
onSelect: () => void;
|
onSelect: () => void;
|
||||||
|
drag: DragState;
|
||||||
|
onDragChange: (drag: DragState) => void;
|
||||||
|
onMoveLoad?: (move: WagonLoadMove) => void;
|
||||||
|
canRearrange: boolean;
|
||||||
}) {
|
}) {
|
||||||
|
const [dropHover, setDropHover] = useState(false);
|
||||||
const allocation = wagon.allocations?.[0];
|
const allocation = wagon.allocations?.[0];
|
||||||
const isEmpty = !allocation;
|
const isEmpty = !allocation;
|
||||||
const isBulk = (allocation?.loadType ?? "").toUpperCase().includes("BULK");
|
const isBulk = (wagon.allocations ?? []).some((a) =>
|
||||||
|
(a.loadType ?? "").toUpperCase().includes("BULK"),
|
||||||
|
);
|
||||||
// GROSS on both sides: cargo + tare vs rated payload + tare.
|
// GROSS on both sides: cargo + tare vs rated payload + tare.
|
||||||
const tare = wagon.tareWeightTons ?? 0;
|
const tare = wagon.tareWeightTons ?? 0;
|
||||||
const assigned =
|
const assigned =
|
||||||
@@ -170,10 +197,20 @@ function WagonCar({
|
|||||||
const accent = isEmpty ? "gray" : isBulk ? "orange" : "cyan";
|
const accent = isEmpty ? "gray" : isBulk ? "orange" : "cyan";
|
||||||
const accentVar = `var(--mantine-color-${accent}-6)`;
|
const accentVar = `var(--mantine-color-${accent}-6)`;
|
||||||
|
|
||||||
const containerNumbers = (allocation?.containerItems ?? []).map(
|
const items = wagonItems(wagon);
|
||||||
(c) => c.containerNumber?.trim() || "—",
|
const blocks = items.slice(0, 2);
|
||||||
);
|
const containerNumbers = items.map((c) => c.containerNumber?.trim() || "—");
|
||||||
const blocks = containerNumbers.slice(0, 2);
|
|
||||||
|
// The whole load drags as one unit (a 20ft pair never splits). Any OTHER
|
||||||
|
// wagon is a drop target: empty → move (a consist-only wagon repins), loaded
|
||||||
|
// → the two loads swap. The API validates wagon type + payload weight.
|
||||||
|
const draggable = canRearrange && !isEmpty;
|
||||||
|
const beingDragged = drag?.sourceWagonId === wagon.id;
|
||||||
|
const dropEligible = Boolean(drag && !beingDragged);
|
||||||
|
const endDrag = () => {
|
||||||
|
onDragChange(null);
|
||||||
|
setDropHover(false);
|
||||||
|
};
|
||||||
|
|
||||||
const ringColor = selected
|
const ringColor = selected
|
||||||
? freightBrand.primary
|
? freightBrand.primary
|
||||||
@@ -189,6 +226,21 @@ function WagonCar({
|
|||||||
style={{ width: 120, flexShrink: 0, cursor: "pointer" }}
|
style={{ width: 120, flexShrink: 0, cursor: "pointer" }}
|
||||||
>
|
>
|
||||||
<Box
|
<Box
|
||||||
|
onDragOver={(e) => {
|
||||||
|
if (dropEligible) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.dataTransfer.dropEffect = "move";
|
||||||
|
setDropHover(true);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onDragLeave={() => setDropHover(false)}
|
||||||
|
onDrop={(e) => {
|
||||||
|
if (dropEligible && drag) {
|
||||||
|
e.preventDefault();
|
||||||
|
onMoveLoad?.({ sourceWagonId: drag.sourceWagonId, targetWagonId: wagon.id });
|
||||||
|
}
|
||||||
|
endDrag();
|
||||||
|
}}
|
||||||
style={{
|
style={{
|
||||||
position: "relative",
|
position: "relative",
|
||||||
height: 70,
|
height: 70,
|
||||||
@@ -205,10 +257,16 @@ function WagonCar({
|
|||||||
: isEmpty
|
: isEmpty
|
||||||
? "none"
|
? "none"
|
||||||
: "0 3px 10px rgba(15,41,27,0.08)",
|
: "0 3px 10px rgba(15,41,27,0.08)",
|
||||||
|
outline: dropHover
|
||||||
|
? "2px solid var(--mantine-color-cyan-6)"
|
||||||
|
: dropEligible
|
||||||
|
? "2px dashed var(--mantine-color-cyan-4)"
|
||||||
|
: "none",
|
||||||
|
outlineOffset: 2,
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
transition: "box-shadow 120ms ease",
|
transition: "box-shadow 120ms ease, outline-color 120ms ease",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* top accent strip */}
|
{/* top accent strip */}
|
||||||
@@ -243,8 +301,27 @@ function WagonCar({
|
|||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
{/* body */}
|
{/* body — the cargo area is the drag handle for the wagon's whole load */}
|
||||||
<Box style={{ flex: 1, padding: "3px 7px", display: "flex", alignItems: "center" }}>
|
<Box
|
||||||
|
draggable={draggable}
|
||||||
|
onDragStart={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
e.dataTransfer.effectAllowed = "move";
|
||||||
|
// Firefox needs data set for the drag to start.
|
||||||
|
e.dataTransfer.setData("text/plain", wagon.id);
|
||||||
|
onDragChange({ sourceWagonId: wagon.id });
|
||||||
|
}}
|
||||||
|
onDragEnd={endDrag}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
padding: "3px 7px",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
cursor: draggable ? "grab" : undefined,
|
||||||
|
opacity: beingDragged ? 0.35 : 1,
|
||||||
|
transition: "opacity 120ms ease",
|
||||||
|
}}
|
||||||
|
>
|
||||||
{isEmpty ? (
|
{isEmpty ? (
|
||||||
<Text size="9px" c="dimmed" ta="center" style={{ width: "100%" }}>
|
<Text size="9px" c="dimmed" ta="center" style={{ width: "100%" }}>
|
||||||
Available
|
Available
|
||||||
@@ -274,28 +351,30 @@ function WagonCar({
|
|||||||
</Stack>
|
</Stack>
|
||||||
) : (
|
) : (
|
||||||
<Group gap={3} justify="center" wrap="nowrap" style={{ width: "100%" }}>
|
<Group gap={3} justify="center" wrap="nowrap" style={{ width: "100%" }}>
|
||||||
{(blocks.length ? blocks : ["—"]).map((cn, i) => (
|
{(blocks.length ? blocks.map((c) => c.containerNumber?.trim() || "—") : ["—"]).map(
|
||||||
<Box
|
(cn, i) => (
|
||||||
key={i}
|
<Box
|
||||||
style={{
|
key={i}
|
||||||
flex: 1,
|
style={{
|
||||||
minWidth: 0,
|
flex: 1,
|
||||||
height: 26,
|
minWidth: 0,
|
||||||
borderRadius: 4,
|
height: 26,
|
||||||
background: CONTAINER_GRADIENTS[i % CONTAINER_GRADIENTS.length],
|
borderRadius: 4,
|
||||||
border: `1px solid ${CONTAINER_BORDERS[i % CONTAINER_BORDERS.length]}`,
|
background: CONTAINER_GRADIENTS[i % CONTAINER_GRADIENTS.length],
|
||||||
boxShadow: "inset 0 1px 0 rgba(255,255,255,0.3)",
|
border: `1px solid ${CONTAINER_BORDERS[i % CONTAINER_BORDERS.length]}`,
|
||||||
display: "flex",
|
boxShadow: "inset 0 1px 0 rgba(255,255,255,0.3)",
|
||||||
alignItems: "center",
|
display: "flex",
|
||||||
justifyContent: "center",
|
alignItems: "center",
|
||||||
padding: "0 2px",
|
justifyContent: "center",
|
||||||
}}
|
padding: "0 2px",
|
||||||
>
|
}}
|
||||||
<Text size="8px" fw={700} c="white" truncate style={{ maxWidth: "100%" }}>
|
>
|
||||||
{cn}
|
<Text size="8px" fw={700} c="white" truncate style={{ maxWidth: "100%" }}>
|
||||||
</Text>
|
{cn}
|
||||||
</Box>
|
</Text>
|
||||||
))}
|
</Box>
|
||||||
|
),
|
||||||
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
@@ -447,7 +526,10 @@ export const InteractiveTrainConsist = ({
|
|||||||
selectedWagonId,
|
selectedWagonId,
|
||||||
onSelectWagon,
|
onSelectWagon,
|
||||||
highlightBookingId,
|
highlightBookingId,
|
||||||
|
canRearrange = false,
|
||||||
|
onMoveLoad,
|
||||||
}: InteractiveTrainConsistProps) => {
|
}: InteractiveTrainConsistProps) => {
|
||||||
|
const [drag, setDrag] = useState<DragState>(null);
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
style={{
|
style={{
|
||||||
@@ -477,6 +559,10 @@ export const InteractiveTrainConsist = ({
|
|||||||
selected={selectedWagonId === wagon.id}
|
selected={selectedWagonId === wagon.id}
|
||||||
highlighted={Boolean(highlightBookingId && bookingId === highlightBookingId)}
|
highlighted={Boolean(highlightBookingId && bookingId === highlightBookingId)}
|
||||||
onSelect={() => onSelectWagon(wagon)}
|
onSelect={() => onSelectWagon(wagon)}
|
||||||
|
drag={drag}
|
||||||
|
onDragChange={setDrag}
|
||||||
|
onMoveLoad={onMoveLoad}
|
||||||
|
canRearrange={canRearrange}
|
||||||
/>
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { Badge, Box, Group, Paper, Stack, Text, ThemeIcon } from "@mantine/core";
|
import { Badge, Box, Group, Paper, Stack, Text, ThemeIcon } from "@mantine/core";
|
||||||
import { MousePointerClick, TrainFront } from "lucide-react";
|
import { isAxiosError } from "axios";
|
||||||
|
import { Hand, MousePointerClick, TrainFront } from "lucide-react";
|
||||||
import type { TrainScheduleDetail } from "@/types/trainScheduling";
|
import type { TrainScheduleDetail } from "@/types/trainScheduling";
|
||||||
import { TrainStatsBar } from "./TrainStatsBar";
|
import { TrainStatsBar } from "./TrainStatsBar";
|
||||||
import { WagonCard } from "./WagonCard";
|
import { WagonCard } from "./WagonCard";
|
||||||
import { InteractiveTrainConsist } from "./InteractiveTrainConsist";
|
import { InteractiveTrainConsist, type WagonLoadMove } from "./InteractiveTrainConsist";
|
||||||
import { RemoveBookingModal } from "./RemoveBookingModal";
|
import { RemoveBookingModal } from "./RemoveBookingModal";
|
||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import { freightBrand } from "@/theme/freight-brand";
|
import { freightBrand } from "@/theme/freight-brand";
|
||||||
|
|
||||||
type Wagon = NonNullable<TrainScheduleDetail["trainSet"]>["wagons"][number];
|
type Wagon = NonNullable<TrainScheduleDetail["trainSet"]>["wagons"][number];
|
||||||
@@ -47,6 +49,7 @@ export const TrainConsistView = ({
|
|||||||
}: TrainConsistViewProps) => {
|
}: TrainConsistViewProps) => {
|
||||||
const [selectedWagonId, setSelectedWagonId] = useState<string | null>(null);
|
const [selectedWagonId, setSelectedWagonId] = useState<string | null>(null);
|
||||||
const [removeModalOpen, setRemoveModalOpen] = useState(false);
|
const [removeModalOpen, setRemoveModalOpen] = useState(false);
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
const unassignMutation = useMutation(
|
const unassignMutation = useMutation(
|
||||||
api.trainScheduling.unassignBooking.mutationOptions(),
|
api.trainScheduling.unassignBooking.mutationOptions(),
|
||||||
@@ -54,9 +57,38 @@ export const TrainConsistView = ({
|
|||||||
const removeWagonMutation = useMutation(
|
const removeWagonMutation = useMutation(
|
||||||
api.trainScheduling.removeWagonSlot.mutationOptions(),
|
api.trainScheduling.removeWagonSlot.mutationOptions(),
|
||||||
);
|
);
|
||||||
|
const moveLoadMutation = useMutation(
|
||||||
|
api.trainScheduling.moveWagonLoad.mutationOptions(),
|
||||||
|
);
|
||||||
|
|
||||||
const trainSet = scheduleDetail.trainSet;
|
const trainSet = scheduleDetail.trainSet;
|
||||||
const wagons = trainSet?.wagons ?? [];
|
const wagons = trainSet?.wagons ?? [];
|
||||||
|
const canRearrange = !["DISPATCHED", "ARRIVED"].includes(scheduleDetail.status);
|
||||||
|
|
||||||
|
const handleMoveLoad = async (move: WagonLoadMove) => {
|
||||||
|
if (moveLoadMutation.isPending) return;
|
||||||
|
const targetLoaded =
|
||||||
|
(wagons.find((w) => w.id === move.targetWagonId)?.allocations?.length ?? 0) > 0;
|
||||||
|
try {
|
||||||
|
await moveLoadMutation.mutateAsync({
|
||||||
|
scheduleId,
|
||||||
|
wagonId: move.sourceWagonId,
|
||||||
|
targetWagonId: move.targetWagonId,
|
||||||
|
});
|
||||||
|
toast({ title: targetLoaded ? "Wagon loads swapped" : "Load moved" });
|
||||||
|
} catch (error) {
|
||||||
|
const message = isAxiosError(error)
|
||||||
|
? ((error.response?.data as { message?: string | string[] } | undefined)?.message ?? null)
|
||||||
|
: null;
|
||||||
|
toast({
|
||||||
|
title: "Could not move the load",
|
||||||
|
description: Array.isArray(message)
|
||||||
|
? message.join(", ")
|
||||||
|
: (message ?? "The move was rejected — check the wagon's type and payload."),
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Join company/customer name from schedule bookings by booking id.
|
// Join company/customer name from schedule bookings by booking id.
|
||||||
const companyByBooking = useMemo(() => {
|
const companyByBooking = useMemo(() => {
|
||||||
@@ -152,13 +184,21 @@ export const TrainConsistView = ({
|
|||||||
</div>
|
</div>
|
||||||
</Group>
|
</Group>
|
||||||
<Group gap="md" wrap="nowrap" visibleFrom="sm">
|
<Group gap="md" wrap="nowrap" visibleFrom="sm">
|
||||||
|
{canRearrange ? (
|
||||||
|
<Group gap={5} wrap="nowrap">
|
||||||
|
<Hand size={12} color="var(--mantine-color-cyan-7)" />
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
Drag a wagon's cargo onto an empty wagon to move it — onto a loaded one to swap
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
) : null}
|
||||||
<LegendDot color="cyan" label="Container" />
|
<LegendDot color="cyan" label="Container" />
|
||||||
<LegendDot color="orange" label="Bulk" />
|
<LegendDot color="orange" label="Bulk" />
|
||||||
<LegendDot color="gray" label="Empty" dashed />
|
<LegendDot color="gray" label="Empty" dashed />
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<Box p="md">
|
<Box p="md" style={{ opacity: moveLoadMutation.isPending ? 0.6 : 1 }}>
|
||||||
<InteractiveTrainConsist
|
<InteractiveTrainConsist
|
||||||
wagons={wagons}
|
wagons={wagons}
|
||||||
locomotive={trainSet?.locomotive}
|
locomotive={trainSet?.locomotive}
|
||||||
@@ -166,6 +206,8 @@ export const TrainConsistView = ({
|
|||||||
selectedWagonId={selectedWagonId}
|
selectedWagonId={selectedWagonId}
|
||||||
onSelectWagon={(w) => setSelectedWagonId((prev) => (prev === w.id ? null : w.id))}
|
onSelectWagon={(w) => setSelectedWagonId((prev) => (prev === w.id ? null : w.id))}
|
||||||
highlightBookingId={highlightBookingId}
|
highlightBookingId={highlightBookingId}
|
||||||
|
canRearrange={canRearrange && !moveLoadMutation.isPending}
|
||||||
|
onMoveLoad={(move) => void handleMoveLoad(move)}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
</Paper>
|
</Paper>
|
||||||
@@ -178,7 +220,7 @@ export const TrainConsistView = ({
|
|||||||
Editing wagon #{selectedWagon.sequenceNo}
|
Editing wagon #{selectedWagon.sequenceNo}
|
||||||
</Badge>
|
</Badge>
|
||||||
<Text size="xs" c="dimmed">
|
<Text size="xs" c="dimmed">
|
||||||
Update container numbers or remove the booking
|
Update container numbers, move containers to another wagon, or remove the booking
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
<WagonCard
|
<WagonCard
|
||||||
@@ -192,6 +234,8 @@ export const TrainConsistView = ({
|
|||||||
scheduleStatus={scheduleDetail.status}
|
scheduleStatus={scheduleDetail.status}
|
||||||
onRemoveBooking={handleRemoveBooking}
|
onRemoveBooking={handleRemoveBooking}
|
||||||
onRemoveWagon={handleRemoveWagon}
|
onRemoveWagon={handleRemoveWagon}
|
||||||
|
wagons={wagons}
|
||||||
|
onMoveLoad={canRearrange ? (move) => void handleMoveLoad(move) : undefined}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
) : wagons.length ? (
|
) : wagons.length ? (
|
||||||
@@ -209,7 +253,8 @@ export const TrainConsistView = ({
|
|||||||
<MousePointerClick size={13} />
|
<MousePointerClick size={13} />
|
||||||
</ThemeIcon>
|
</ThemeIcon>
|
||||||
<Text size="xs" c="dimmed">
|
<Text size="xs" c="dimmed">
|
||||||
Click a wagon in the train to edit container numbers or remove its booking.
|
Click a wagon to edit its containers — or drag a container between wagons to
|
||||||
|
rearrange the load.
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|||||||
@@ -1,5 +1,17 @@
|
|||||||
import { Badge, Box, Button, Card, Group, Progress, Stack, Text, ThemeIcon } from "@mantine/core";
|
|
||||||
import {
|
import {
|
||||||
|
Badge,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Group,
|
||||||
|
Menu,
|
||||||
|
Progress,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
ThemeIcon,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import {
|
||||||
|
ArrowLeftRight,
|
||||||
Building2,
|
Building2,
|
||||||
Container as ContainerIcon,
|
Container as ContainerIcon,
|
||||||
Fuel,
|
Fuel,
|
||||||
@@ -10,6 +22,7 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { TrainScheduleDetail } from "@/types/trainScheduling";
|
import type { TrainScheduleDetail } from "@/types/trainScheduling";
|
||||||
import { ContainerNumberInput } from "./ContainerNumberInput";
|
import { ContainerNumberInput } from "./ContainerNumberInput";
|
||||||
|
import type { WagonLoadMove } from "./InteractiveTrainConsist";
|
||||||
import { freightBrand } from "@/theme/freight-brand";
|
import { freightBrand } from "@/theme/freight-brand";
|
||||||
|
|
||||||
type Wagon = NonNullable<TrainScheduleDetail["trainSet"]>["wagons"][number];
|
type Wagon = NonNullable<TrainScheduleDetail["trainSet"]>["wagons"][number];
|
||||||
@@ -21,8 +34,16 @@ interface WagonCardProps {
|
|||||||
scheduleStatus?: string;
|
scheduleStatus?: string;
|
||||||
onRemoveBooking: (wagon: Wagon) => void;
|
onRemoveBooking: (wagon: Wagon) => void;
|
||||||
onRemoveWagon: (wagonId: string) => void;
|
onRemoveWagon: (wagonId: string) => void;
|
||||||
|
/** All wagons of the consist — targets for the move-load menu. */
|
||||||
|
wagons?: Wagon[];
|
||||||
|
onMoveLoad?: (move: WagonLoadMove) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const itemAllocCount = (w: Wagon) => w.allocations?.length ?? 0;
|
||||||
|
|
||||||
|
const isBulkWagon = (w: Wagon) =>
|
||||||
|
(w.allocations ?? []).some((a) => (a.loadType ?? "").toUpperCase().includes("BULK"));
|
||||||
|
|
||||||
export const WagonCard = ({
|
export const WagonCard = ({
|
||||||
wagon,
|
wagon,
|
||||||
company,
|
company,
|
||||||
@@ -30,6 +51,8 @@ export const WagonCard = ({
|
|||||||
scheduleStatus,
|
scheduleStatus,
|
||||||
onRemoveBooking,
|
onRemoveBooking,
|
||||||
onRemoveWagon,
|
onRemoveWagon,
|
||||||
|
wagons,
|
||||||
|
onMoveLoad,
|
||||||
}: WagonCardProps) => {
|
}: WagonCardProps) => {
|
||||||
const isDispatched = scheduleStatus === "DISPATCHED";
|
const isDispatched = scheduleStatus === "DISPATCHED";
|
||||||
const allocation = wagon.allocations?.[0];
|
const allocation = wagon.allocations?.[0];
|
||||||
@@ -153,16 +176,62 @@ export const WagonCard = ({
|
|||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{!isDispatched ? (
|
{!isDispatched ? (
|
||||||
<Button
|
<Group gap="xs" grow>
|
||||||
variant="light"
|
{onMoveLoad ? (
|
||||||
color="red"
|
<Menu shadow="md" width={240} position="bottom" withinPortal>
|
||||||
size="xs"
|
<Menu.Target>
|
||||||
leftSection={<X size={14} />}
|
<Button
|
||||||
onClick={() => onRemoveBooking(wagon)}
|
variant="light"
|
||||||
fullWidth
|
color="cyan"
|
||||||
>
|
size="xs"
|
||||||
Remove booking
|
leftSection={<ArrowLeftRight size={14} />}
|
||||||
</Button>
|
>
|
||||||
|
Move load
|
||||||
|
</Button>
|
||||||
|
</Menu.Target>
|
||||||
|
<Menu.Dropdown>
|
||||||
|
<Menu.Label>Move this wagon's load to</Menu.Label>
|
||||||
|
{(wagons ?? [])
|
||||||
|
.filter((w) => w.id !== wagon.id)
|
||||||
|
.sort((a, b) => itemAllocCount(a) - itemAllocCount(b))
|
||||||
|
.map((w) => {
|
||||||
|
const loaded = itemAllocCount(w) > 0;
|
||||||
|
return (
|
||||||
|
<Menu.Item
|
||||||
|
key={w.id}
|
||||||
|
onClick={() =>
|
||||||
|
onMoveLoad({ sourceWagonId: wagon.id, targetWagonId: w.id })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Group gap={6} wrap="nowrap" justify="space-between">
|
||||||
|
<Text size="xs" fw={600} truncate>
|
||||||
|
#{w.sequenceNo} ·{" "}
|
||||||
|
{w.physicalWagonNumber ?? w.wagonType?.code ?? "Wagon"}
|
||||||
|
</Text>
|
||||||
|
<Badge
|
||||||
|
size="xs"
|
||||||
|
variant="light"
|
||||||
|
color={loaded ? (isBulkWagon(w) ? "orange" : "cyan") : "gray"}
|
||||||
|
>
|
||||||
|
{loaded ? "swap" : "empty"}
|
||||||
|
</Badge>
|
||||||
|
</Group>
|
||||||
|
</Menu.Item>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Menu.Dropdown>
|
||||||
|
</Menu>
|
||||||
|
) : null}
|
||||||
|
<Button
|
||||||
|
variant="light"
|
||||||
|
color="red"
|
||||||
|
size="xs"
|
||||||
|
leftSection={<X size={14} />}
|
||||||
|
onClick={() => onRemoveBooking(wagon)}
|
||||||
|
>
|
||||||
|
Remove booking
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -280,3 +280,135 @@ export function RouteCorridor({
|
|||||||
</Group>
|
</Group>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Minimal booking shape the occupancy strip needs from TrainScheduleDetail. */
|
||||||
|
export type SegmentStripBooking = {
|
||||||
|
originYardId?: string | null;
|
||||||
|
destinationYardId?: string | null;
|
||||||
|
tradeDirection?: string | null;
|
||||||
|
wagonsRequired?: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-segment wagon occupancy along the corridor: which legs are full and
|
||||||
|
* which still run empty. Through cargo (unknown/off-route yards) occupies the
|
||||||
|
* whole corridor; a ride-along counts only on its own leg — this is what makes
|
||||||
|
* "export full Adama→Doraleh, intercity riding Gelan→Adama" legible at a
|
||||||
|
* glance instead of two disconnected booking lists.
|
||||||
|
*/
|
||||||
|
export function SegmentOccupancyStrip({
|
||||||
|
stops,
|
||||||
|
bookings,
|
||||||
|
maxWagons,
|
||||||
|
}: {
|
||||||
|
stops: Array<{ yardId: string; label: string }>;
|
||||||
|
bookings: SegmentStripBooking[];
|
||||||
|
maxWagons?: number | null;
|
||||||
|
}) {
|
||||||
|
if (stops.length < 2) return null;
|
||||||
|
const lastIdx = stops.length - 1;
|
||||||
|
const indexOf = new Map(stops.map((s, i) => [s.yardId, i]));
|
||||||
|
|
||||||
|
const segments = stops.slice(0, -1).map((stop, edge) => {
|
||||||
|
let cargo = 0;
|
||||||
|
let intercity = 0;
|
||||||
|
for (const b of bookings) {
|
||||||
|
const from = (b.originYardId ? indexOf.get(b.originYardId) : undefined) ?? 0;
|
||||||
|
const to =
|
||||||
|
(b.destinationYardId ? indexOf.get(b.destinationYardId) : undefined) ??
|
||||||
|
lastIdx;
|
||||||
|
const rides = from <= edge && edge < (to > from ? to : lastIdx);
|
||||||
|
if (!rides) continue;
|
||||||
|
const wagons = Number(b.wagonsRequired) || 1;
|
||||||
|
if (b.tradeDirection === "DOMESTIC") intercity += wagons;
|
||||||
|
else cargo += wagons;
|
||||||
|
}
|
||||||
|
return { from: stop, to: stops[edge + 1], cargo, intercity };
|
||||||
|
});
|
||||||
|
|
||||||
|
const cap = Number(maxWagons) || null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Group gap={0} wrap="nowrap" align="stretch" style={{ overflowX: "auto", paddingBottom: 4 }}>
|
||||||
|
{segments.map((seg, i) => {
|
||||||
|
const used = seg.cargo + seg.intercity;
|
||||||
|
const pct = cap ? Math.min(100, Math.round((used / cap) * 100)) : null;
|
||||||
|
const full = cap != null && used >= cap;
|
||||||
|
return (
|
||||||
|
<Group key={seg.from.yardId} gap={0} wrap="nowrap" align="stretch">
|
||||||
|
<Stack gap={2} align="center" justify="flex-end" style={{ minWidth: 0 }}>
|
||||||
|
<Box
|
||||||
|
w={9}
|
||||||
|
h={9}
|
||||||
|
style={{
|
||||||
|
borderRadius: 999,
|
||||||
|
border: `2px solid ${freightBrand.primary}`,
|
||||||
|
background: i === 0 ? "white" : freightBrand.primary,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Text size="xs" fw={600} style={{ whiteSpace: "nowrap" }}>
|
||||||
|
{seg.from.label}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
<Stack gap={3} px={10} pb={16} justify="flex-end" style={{ minWidth: 130 }}>
|
||||||
|
<Text size="xs" ta="center" fw={600} c={full ? "orange.8" : "dimmed"}>
|
||||||
|
{used}
|
||||||
|
{cap ? `/${cap}` : ""} wagons
|
||||||
|
{full ? " · full" : ""}
|
||||||
|
</Text>
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
height: 6,
|
||||||
|
borderRadius: 999,
|
||||||
|
background: "var(--mantine-color-gray-2)",
|
||||||
|
overflow: "hidden",
|
||||||
|
display: "flex",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{cap ? (
|
||||||
|
<>
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
width: `${Math.min(100, (seg.cargo / cap) * 100)}%`,
|
||||||
|
background: freightBrand.primary,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
width: `${Math.min(100, (seg.intercity / cap) * 100)}%`,
|
||||||
|
background: "var(--mantine-color-indigo-6)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Box style={{ width: pct ? `${pct}%` : 0 }} />
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<Text size="xs" ta="center" c="dimmed" style={{ whiteSpace: "nowrap" }}>
|
||||||
|
{seg.cargo} cargo
|
||||||
|
{seg.intercity > 0 ? (
|
||||||
|
<Text span size="xs" fw={700} c="indigo.7">
|
||||||
|
{" "}
|
||||||
|
· {seg.intercity} intercity
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
{i === segments.length - 1 ? (
|
||||||
|
<Stack gap={2} align="center" justify="flex-end" style={{ minWidth: 0 }}>
|
||||||
|
<Box
|
||||||
|
w={9}
|
||||||
|
h={9}
|
||||||
|
style={{ borderRadius: 999, background: freightBrand.primary }}
|
||||||
|
/>
|
||||||
|
<Text size="xs" fw={600} style={{ whiteSpace: "nowrap" }}>
|
||||||
|
{seg.to.label}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
) : null}
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -2647,7 +2647,9 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
|
|||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
{/* Primary stage action stays visible; the rest live under the kebab. */}
|
{/* Primary stage action stays visible; the rest live under the kebab. */}
|
||||||
{r.currentStatus === 'READY_FOR_PICKUP' && !r.releaseDate && r.hasAssignedTruck && (
|
{/* Stays visible after the first exit — multi-truck bookings
|
||||||
|
weigh each truck in and out until all have left. */}
|
||||||
|
{r.currentStatus === 'READY_FOR_PICKUP' && r.hasAssignedTruck && (
|
||||||
<Button
|
<Button
|
||||||
size="compact-xs"
|
size="compact-xs"
|
||||||
variant="light"
|
variant="light"
|
||||||
@@ -2655,7 +2657,7 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
|
|||||||
leftSection={<Truck size={14} />}
|
leftSection={<Truck size={14} />}
|
||||||
onClick={() => setReleaseItem(toInventoryItem(r))}
|
onClick={() => setReleaseItem(toInventoryItem(r))}
|
||||||
>
|
>
|
||||||
{r.releaseOrderReference ? 'Truck Leaving' : 'Truck Arrival'}
|
{r.releaseOrderReference ? 'Truck Arrival / Leaving' : 'Truck Arrival'}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{r.currentStatus === 'READY_FOR_PICKUP' && r.releaseDate && (
|
{r.currentStatus === 'READY_FOR_PICKUP' && r.releaseDate && (
|
||||||
@@ -2692,7 +2694,7 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
|
|||||||
Ready for pickup
|
Ready for pickup
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
)}
|
)}
|
||||||
{r.currentStatus === 'READY_FOR_PICKUP' && !r.releaseDate && (
|
{r.currentStatus === 'READY_FOR_PICKUP' && (
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
leftSection={<Truck size={14} />}
|
leftSection={<Truck size={14} />}
|
||||||
disabled={!r.hasAssignedTruck}
|
disabled={!r.hasAssignedTruck}
|
||||||
@@ -2700,7 +2702,7 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
|
|||||||
>
|
>
|
||||||
{r.hasAssignedTruck
|
{r.hasAssignedTruck
|
||||||
? r.releaseOrderReference
|
? r.releaseOrderReference
|
||||||
? 'Truck leaving'
|
? 'Truck arrival / leaving'
|
||||||
: 'Truck arrival'
|
: 'Truck arrival'
|
||||||
: 'Truck arrival — assign a truck first'}
|
: 'Truck arrival — assign a truck first'}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { Alert, Button, Group, Modal, MultiSelect, NumberInput, SegmentedControl, Select, SimpleGrid, Stack, Text, TextInput } from '@mantine/core';
|
import { Alert, Badge, Button, Group, Modal, MultiSelect, NumberInput, SegmentedControl, Select, SimpleGrid, Stack, Text, TextInput } from '@mantine/core';
|
||||||
import { Info, Scale } from 'lucide-react';
|
import { Info, Scale, Truck } from 'lucide-react';
|
||||||
|
|
||||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
|
||||||
import { api } from '@/services/api';
|
import { api } from '@/services/api';
|
||||||
import { useToast } from '@/hooks/use-toast';
|
import { useToast } from '@/hooks/use-toast';
|
||||||
|
import { isBackdated, nowLocalDateTimeInput } from '@/lib/no-backdate';
|
||||||
import { warehouseService } from '@/services/warehouse.service';
|
import { warehouseService } from '@/services/warehouse.service';
|
||||||
import type { WarehouseInventoryItem } from '@/types/warehouse';
|
import type { WarehouseInventoryItem } from '@/types/warehouse';
|
||||||
import { extractErrorMessage } from './options';
|
import { extractErrorMessage } from './options';
|
||||||
@@ -28,6 +29,8 @@ export interface ReleaseOrderTruckPrefill {
|
|||||||
containerNumber?: string | null;
|
containerNumber?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const EXIT_INSPECTION_MARKER = '[Exit Inspection]';
|
||||||
|
|
||||||
const toIsoDateTime = (value: string) => {
|
const toIsoDateTime = (value: string) => {
|
||||||
if (!value) return undefined;
|
if (!value) return undefined;
|
||||||
const date = new Date(value);
|
const date = new Date(value);
|
||||||
@@ -70,9 +73,6 @@ const splitContainerNumbers = (value: string | null | undefined) =>
|
|||||||
const getItemContainerNumber = (item: WarehouseInventoryItem | null) =>
|
const getItemContainerNumber = (item: WarehouseInventoryItem | null) =>
|
||||||
(item as (WarehouseInventoryItem & { containerNumber?: string | null }) | null)?.containerNumber ?? '';
|
(item as (WarehouseInventoryItem & { containerNumber?: string | null }) | null)?.containerNumber ?? '';
|
||||||
|
|
||||||
const assignedTruckValue = (item: WarehouseInventoryItem | null, key: keyof NonNullable<WarehouseInventoryItem['booking']>) =>
|
|
||||||
item?.booking?.[key] == null ? '' : String(item.booking[key]);
|
|
||||||
|
|
||||||
const isContainerInventory = (item: WarehouseInventoryItem | null, containerCount: number) => {
|
const isContainerInventory = (item: WarehouseInventoryItem | null, containerCount: number) => {
|
||||||
const freightType = (item as (WarehouseInventoryItem & { booking?: { freightType?: string | null } | null }) | null)
|
const freightType = (item as (WarehouseInventoryItem & { booking?: { freightType?: string | null } | null }) | null)
|
||||||
?.booking?.freightType;
|
?.booking?.freightType;
|
||||||
@@ -88,29 +88,66 @@ const initialContainerNumbers = (item: WarehouseInventoryItem | null, savedConta
|
|||||||
return Array.from({ length: expectedCount }, (_, index) => sourceNumbers[index] ?? '');
|
return Array.from({ length: expectedCount }, (_, index) => sourceNumbers[index] ?? '');
|
||||||
};
|
};
|
||||||
|
|
||||||
const parseInspectionNote = (notes: string | null | undefined) => {
|
/** One truck's saved arrival/exit weighing, parsed from its inspection block. */
|
||||||
const marker = '[Exit Inspection]';
|
interface InspectionBlock {
|
||||||
const index = notes?.lastIndexOf(marker) ?? -1;
|
truckPlateNumber: string;
|
||||||
const note = index >= 0 ? notes?.slice(index + marker.length) : notes;
|
trailerPlateNumber: string;
|
||||||
return {
|
driverName: string;
|
||||||
truckPlateNumber: lineValue(note, 'Truck Plate'),
|
driverLicense: string;
|
||||||
trailerPlateNumber: lineValue(note, 'Trailer Plate'),
|
driverPhone: string;
|
||||||
driverName: lineValue(note, 'Driver'),
|
truckType: string;
|
||||||
driverLicense: lineValue(note, 'Driver License'),
|
containerNumber: string;
|
||||||
driverPhone: lineValue(note, 'Driver Phone'),
|
gateInTime: string;
|
||||||
truckType: lineValue(note, 'Truck Type'),
|
tareWeight: number | '';
|
||||||
containerNumber: lineValue(note, 'Container Number'),
|
grossWeight: number | '';
|
||||||
gateInTime: toLocalDateTimeInput(lineValue(note, 'Gate In Time')),
|
netWeight: number | '';
|
||||||
tareWeight: lineNumber(note, 'Tare Weight'),
|
gateOutTime: string;
|
||||||
grossWeight: lineNumber(note, 'Gross Weight'),
|
weighingSkipped: boolean;
|
||||||
netWeight: lineNumber(note, 'Net Weight'),
|
}
|
||||||
gateOutTime: toLocalDateTimeInput(lineValue(note, 'Gate Out Time')),
|
|
||||||
weighingSkipped: /^Weighing:\s*SKIPPED/im.test(note ?? ''),
|
const parseInspectionSection = (note: string): InspectionBlock => ({
|
||||||
};
|
truckPlateNumber: lineValue(note, 'Truck Plate'),
|
||||||
|
trailerPlateNumber: lineValue(note, 'Trailer Plate'),
|
||||||
|
driverName: lineValue(note, 'Driver'),
|
||||||
|
driverLicense: lineValue(note, 'Driver License'),
|
||||||
|
driverPhone: lineValue(note, 'Driver Phone'),
|
||||||
|
truckType: lineValue(note, 'Truck Type'),
|
||||||
|
containerNumber: lineValue(note, 'Container Number'),
|
||||||
|
gateInTime: toLocalDateTimeInput(lineValue(note, 'Gate In Time')),
|
||||||
|
tareWeight: lineNumber(note, 'Tare Weight'),
|
||||||
|
grossWeight: lineNumber(note, 'Gross Weight'),
|
||||||
|
netWeight: lineNumber(note, 'Net Weight'),
|
||||||
|
gateOutTime: toLocalDateTimeInput(lineValue(note, 'Gate Out Time')),
|
||||||
|
weighingSkipped: /^Weighing:\s*SKIPPED/im.test(note),
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Every truck's saved block — multi-truck bookings weigh each truck separately. */
|
||||||
|
const parseInspectionBlocks = (notes: string | null | undefined): InspectionBlock[] =>
|
||||||
|
(notes ?? '')
|
||||||
|
.split(EXIT_INSPECTION_MARKER)
|
||||||
|
.slice(1)
|
||||||
|
.map(parseInspectionSection)
|
||||||
|
.filter((block) => block.truckPlateNumber);
|
||||||
|
|
||||||
|
/** Match by plate; a legacy block may hold a comma-joined plate list. */
|
||||||
|
const blockForPlate = (blocks: InspectionBlock[], plate: string): InspectionBlock | undefined => {
|
||||||
|
const key = plate.trim().toUpperCase();
|
||||||
|
if (!key) return undefined;
|
||||||
|
return blocks.find((block) => {
|
||||||
|
const stored = block.truckPlateNumber.toUpperCase();
|
||||||
|
return stored === key || stored.split(/[,;]+/).map((p) => p.trim()).includes(key);
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const blockArrived = (block: InspectionBlock | undefined) =>
|
||||||
|
Boolean(block && (block.tareWeight !== '' || block.weighingSkipped));
|
||||||
|
|
||||||
|
const blockLeft = (block: InspectionBlock | undefined) =>
|
||||||
|
Boolean(block?.gateOutTime && (block.grossWeight !== '' || block.weighingSkipped));
|
||||||
|
|
||||||
export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: ReleaseOrderModalProps) {
|
export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: ReleaseOrderModalProps) {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const releaseMutation = useMutation(api.warehouses.release.mutationOptions());
|
const releaseMutation = useMutation(api.warehouses.release.mutationOptions());
|
||||||
// Some openers (inventory workbench) supply bookingId without the booking
|
// Some openers (inventory workbench) supply bookingId without the booking
|
||||||
// relation — fall back to it, or the truck/container-weight queries never run.
|
// relation — fall back to it, or the truck/container-weight queries never run.
|
||||||
@@ -152,78 +189,16 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
|||||||
const [netWeight, setNetWeight] = useState<number | ''>('');
|
const [netWeight, setNetWeight] = useState<number | ''>('');
|
||||||
const [gateOutTime, setGateOutTime] = useState('');
|
const [gateOutTime, setGateOutTime] = useState('');
|
||||||
const [downloading, setDownloading] = useState(false);
|
const [downloading, setDownloading] = useState(false);
|
||||||
|
// Plate whose saved block was last loaded into the form — stops the
|
||||||
|
// per-plate loader effect from clobbering operator edits in a loop.
|
||||||
|
const loadedPlateRef = useRef<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
const savedBlocks = parseInspectionBlocks(item?.notes);
|
||||||
if (opened) {
|
|
||||||
const inspection = parseInspectionNote(item?.notes);
|
|
||||||
const assignedTruckPlate = assignedTruckValue(item, 'customerTruckPlateNumber');
|
|
||||||
const assignedDriverName = assignedTruckValue(item, 'customerTruckDriverName');
|
|
||||||
const assignedTruckType = assignedTruckValue(item, 'customerTruckType');
|
|
||||||
const assignedContainerNumber = assignedTruckValue(item, 'customerTruckContainerNumber');
|
|
||||||
const prefillContainerNumber = truckPrefill?.containerNumber ?? '';
|
|
||||||
setReference(item?.releaseOrderReference ?? generateReleaseReference(item));
|
|
||||||
setTruckPlateNumber(inspection.truckPlateNumber || truckPrefill?.truckPlateNumber || assignedTruckPlate || '');
|
|
||||||
setTrailerPlateNumber(inspection.trailerPlateNumber || truckPrefill?.trailerPlateNumber || '');
|
|
||||||
setDriverName(inspection.driverName || truckPrefill?.driverName || assignedDriverName || '');
|
|
||||||
setDriverLicense(inspection.driverLicense || truckPrefill?.driverLicense || '');
|
|
||||||
setDriverPhone(inspection.driverPhone || truckPrefill?.driverPhone || '');
|
|
||||||
setTruckType(inspection.truckType || truckPrefill?.truckType || assignedTruckType || '');
|
|
||||||
setContainerNumbers(initialContainerNumbers(item, inspection.containerNumber || prefillContainerNumber || assignedContainerNumber));
|
|
||||||
setGateInTime(inspection.gateInTime);
|
|
||||||
setTareWeight(inspection.tareWeight);
|
|
||||||
setWeighTruck(inspection.weighingSkipped ? 'no' : 'yes');
|
|
||||||
setGrossWeight(inspection.grossWeight);
|
|
||||||
setNetWeight(item?.weight == null ? inspection.netWeight : Number(item.weight));
|
|
||||||
setGateOutTime(inspection.gateOutTime);
|
|
||||||
}
|
|
||||||
}, [opened, item, truckPrefill]);
|
|
||||||
|
|
||||||
const savedInspection = parseInspectionNote(item?.notes);
|
|
||||||
const isExitStep = savedInspection.tareWeight !== '' || savedInspection.weighingSkipped;
|
|
||||||
const isEntranceLocked = isExitStep;
|
|
||||||
const isCustomerAssignedTruck = Boolean(item?.booking?.customerTruckAssignedAt);
|
|
||||||
const hasLastMileTruckPrefill = Boolean(truckPrefill?.truckPlateNumber || truckPrefill?.trailerPlateNumber);
|
|
||||||
|
|
||||||
// Opened from the warehouse flow (no truckPrefill prop): once the last-mile
|
|
||||||
// truck query resolves, auto-fill the first assigned EDR truck — without
|
|
||||||
// overwriting anything the operator typed or the locked exit-step values.
|
|
||||||
useEffect(() => {
|
|
||||||
if (!opened || truckPrefill || isExitStep) return;
|
|
||||||
const first = lastMileTrucks[0];
|
|
||||||
if (!first) return;
|
|
||||||
setTruckPlateNumber((p) => p || first.truckPlateNumber || '');
|
|
||||||
setTrailerPlateNumber((p) => p || first.trailerPlateNumber || '');
|
|
||||||
setDriverName((p) => p || first.driverName || '');
|
|
||||||
setDriverLicense((p) => p || first.driverLicense || '');
|
|
||||||
setDriverPhone((p) => p || first.driverPhone || '');
|
|
||||||
setTruckType((p) => p || first.truckType || '');
|
|
||||||
setContainerNumbers((prev) =>
|
|
||||||
prev.length === 1 && !prev[0] && first.containerNumber ? [first.containerNumber] : prev,
|
|
||||||
);
|
|
||||||
}, [opened, truckPrefill, isExitStep, lastMileTrucks]);
|
|
||||||
|
|
||||||
// The same for a customer self-haul truck. The prefill above reads the
|
|
||||||
// booking.customer_truck_* columns, but multi-truck self-haul writes the plate
|
|
||||||
// and driver to customer_truck_assignments and leaves those columns null — so
|
|
||||||
// a booking with a truck on file still opened this form blank. Only auto-fills
|
|
||||||
// a single truck: with several, the operator picks which one is at the gate.
|
|
||||||
useEffect(() => {
|
|
||||||
if (!opened || truckPrefill || isExitStep) return;
|
|
||||||
if (customerTrucks.length !== 1) return;
|
|
||||||
const [truck] = customerTrucks;
|
|
||||||
setTruckPlateNumber((p) => p || truck.plateNumber || '');
|
|
||||||
setDriverName((p) => p || truck.driverName || '');
|
|
||||||
setTruckType((p) => p || truck.truckType || '');
|
|
||||||
setContainerNumbers((prev) => {
|
|
||||||
const loaded = (truck.containers ?? []).map((c) => c.containerNumber).filter(Boolean);
|
|
||||||
return prev.every((n) => !n) && loaded.length ? loaded : prev;
|
|
||||||
});
|
|
||||||
}, [opened, truckPrefill, isExitStep, customerTrucks]);
|
|
||||||
|
|
||||||
// Registered trucks for THIS booking, from both sources: EDR last-mile
|
// Registered trucks for THIS booking, from both sources: EDR last-mile
|
||||||
// (truckPrefill) and the customer portal (customer_truck_assignments).
|
// (truckPrefill) and the customer portal (customer_truck_assignments).
|
||||||
const assignedTruckOptions = [
|
const assignedTruckOptions = [
|
||||||
...(truckPrefill?.truckPlateNumber
|
...(truckPrefill?.truckPlateNumber && !truckPrefill.truckPlateNumber.includes(',')
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
value: truckPrefill.truckPlateNumber,
|
value: truckPrefill.truckPlateNumber,
|
||||||
@@ -232,6 +207,9 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
|||||||
driverName: truckPrefill.driverName ?? '',
|
driverName: truckPrefill.driverName ?? '',
|
||||||
driverPhone: truckPrefill.driverPhone ?? '',
|
driverPhone: truckPrefill.driverPhone ?? '',
|
||||||
truckType: truckPrefill.truckType ?? '',
|
truckType: truckPrefill.truckType ?? '',
|
||||||
|
containerNumbers: splitContainerNumbers(truckPrefill.containerNumber),
|
||||||
|
arrived: false,
|
||||||
|
left: false,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
@@ -242,6 +220,9 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
|||||||
driverName: t.driverName,
|
driverName: t.driverName,
|
||||||
driverPhone: '',
|
driverPhone: '',
|
||||||
truckType: t.truckType,
|
truckType: t.truckType,
|
||||||
|
containerNumbers: (t.containers ?? []).map((c) => c.containerNumber).filter(Boolean),
|
||||||
|
arrived: Boolean(t.arrivedAt),
|
||||||
|
left: Boolean(t.departedAt),
|
||||||
})),
|
})),
|
||||||
...lastMileTrucks
|
...lastMileTrucks
|
||||||
.filter((t) => t.truckPlateNumber || t.vehicleId)
|
.filter((t) => t.truckPlateNumber || t.vehicleId)
|
||||||
@@ -252,6 +233,9 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
|||||||
driverName: t.driverName ?? '',
|
driverName: t.driverName ?? '',
|
||||||
driverPhone: t.driverPhone ?? '',
|
driverPhone: t.driverPhone ?? '',
|
||||||
truckType: t.truckType ?? '',
|
truckType: t.truckType ?? '',
|
||||||
|
containerNumbers: splitContainerNumbers(t.containerNumber),
|
||||||
|
arrived: Boolean(t.arrivedAt),
|
||||||
|
left: Boolean(t.departedAt),
|
||||||
})),
|
})),
|
||||||
];
|
];
|
||||||
// Only trucks actually assigned to THIS booking (last-mile prefill or customer
|
// Only trucks actually assigned to THIS booking (last-mile prefill or customer
|
||||||
@@ -261,10 +245,132 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
|||||||
const truckSelectOptions = [
|
const truckSelectOptions = [
|
||||||
...new Map(assignedTruckOptions.map((t) => [t.value, t])).values(),
|
...new Map(assignedTruckOptions.map((t) => [t.value, t])).values(),
|
||||||
];
|
];
|
||||||
|
const isCustomerAssignedTruck = Boolean(item?.booking?.customerTruckAssignedAt);
|
||||||
// Neither a last-mile truck nor a customer truck has been assigned yet.
|
// Neither a last-mile truck nor a customer truck has been assigned yet.
|
||||||
const noTruckAssigned = assignedTruckOptions.length === 0 && !isCustomerAssignedTruck;
|
const noTruckAssigned = assignedTruckOptions.length === 0 && !isCustomerAssignedTruck;
|
||||||
const isTruckIdentityLocked = isEntranceLocked || isCustomerAssignedTruck || hasLastMileTruckPrefill;
|
|
||||||
const isDriverNameLocked = isEntranceLocked || isCustomerAssignedTruck || Boolean(truckPrefill?.driverName);
|
// Per-truck progress: every truck is weighed in and out on its own; the saved
|
||||||
|
// blocks also cover walk-in trucks that were never formally assigned.
|
||||||
|
const truckProgress = new Map<string, { arrived: boolean; left: boolean }>();
|
||||||
|
for (const option of truckSelectOptions) {
|
||||||
|
truckProgress.set(option.value.trim().toUpperCase(), { arrived: option.arrived, left: option.left });
|
||||||
|
}
|
||||||
|
for (const block of savedBlocks) {
|
||||||
|
const key = block.truckPlateNumber.trim().toUpperCase();
|
||||||
|
const prior = truckProgress.get(key);
|
||||||
|
truckProgress.set(key, {
|
||||||
|
arrived: Boolean(prior?.arrived) || blockArrived(block),
|
||||||
|
left: Boolean(prior?.left) || blockLeft(block),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const totalTrucks = truckProgress.size;
|
||||||
|
const arrivedTrucks = [...truckProgress.values()].filter((t) => t.arrived).length;
|
||||||
|
const leftTrucks = [...truckProgress.values()].filter((t) => t.left).length;
|
||||||
|
|
||||||
|
// The step is decided PER TRUCK: the selected plate's saved block. A new plate
|
||||||
|
// (or a truck without a saved arrival) starts at the arrival step even when
|
||||||
|
// other trucks of the booking are already mid-flow or gone.
|
||||||
|
const selectedBlock = blockForPlate(savedBlocks, truckPlateNumber);
|
||||||
|
const isExitStep = blockArrived(selectedBlock);
|
||||||
|
const hasTruckLeft = blockLeft(selectedBlock);
|
||||||
|
const isEntranceLocked = isExitStep;
|
||||||
|
const selectedOption = truckSelectOptions.find(
|
||||||
|
(option) => option.value.trim().toUpperCase() === truckPlateNumber.trim().toUpperCase(),
|
||||||
|
);
|
||||||
|
// Identity comes from the arrival record or the assignment — locked either
|
||||||
|
// way. A walk-in truck (typed plate, no assignment) stays editable at arrival.
|
||||||
|
const isTruckIdentityLocked = isEntranceLocked || Boolean(selectedOption);
|
||||||
|
const isDriverNameLocked = isEntranceLocked || Boolean(selectedOption?.driverName);
|
||||||
|
const referenceLocked = Boolean(item?.releaseOrderReference) || savedBlocks.length > 0;
|
||||||
|
|
||||||
|
/** Load a truck into the form: its saved block if any, else its assignment. */
|
||||||
|
const applyTruckSelection = (plate: string) => {
|
||||||
|
const block = blockForPlate(savedBlocks, plate);
|
||||||
|
const option = truckSelectOptions.find(
|
||||||
|
(o) => o.value.trim().toUpperCase() === plate.trim().toUpperCase(),
|
||||||
|
);
|
||||||
|
loadedPlateRef.current = plate.trim().toUpperCase();
|
||||||
|
setTruckPlateNumber(plate);
|
||||||
|
setTrailerPlateNumber(block?.trailerPlateNumber || option?.trailerPlate || '');
|
||||||
|
setDriverName(block?.driverName || option?.driverName || '');
|
||||||
|
setDriverLicense(block?.driverLicense || '');
|
||||||
|
setDriverPhone(block?.driverPhone || option?.driverPhone || '');
|
||||||
|
setTruckType(block?.truckType || option?.truckType || '');
|
||||||
|
const loaded = block
|
||||||
|
? splitContainerNumbers(block.containerNumber)
|
||||||
|
: (option?.containerNumbers ?? []);
|
||||||
|
setContainerNumbers(loaded.length ? loaded : initialContainerNumbers(item, ''));
|
||||||
|
setGateInTime(block?.gateInTime ?? '');
|
||||||
|
setTareWeight(block?.tareWeight ?? '');
|
||||||
|
setWeighTruck(block?.weighingSkipped ? 'no' : 'yes');
|
||||||
|
setGrossWeight(block?.grossWeight ?? '');
|
||||||
|
setNetWeight(block?.netWeight ?? (item?.weight == null ? '' : Number(item.weight)));
|
||||||
|
setGateOutTime(block?.gateOutTime ?? '');
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (opened) {
|
||||||
|
loadedPlateRef.current = null;
|
||||||
|
setReference(item?.releaseOrderReference ?? generateReleaseReference(item));
|
||||||
|
// Initial truck: the caller's prefill, else the first truck still mid-flow
|
||||||
|
// (arrived but not left) — the operator can switch trucks in the select.
|
||||||
|
const prefillPlate =
|
||||||
|
truckPrefill?.truckPlateNumber && !truckPrefill.truckPlateNumber.includes(',')
|
||||||
|
? truckPrefill.truckPlateNumber
|
||||||
|
: '';
|
||||||
|
const blocks = parseInspectionBlocks(item?.notes);
|
||||||
|
const inProgress = blocks.find((block) => blockArrived(block) && !blockLeft(block));
|
||||||
|
// Legacy single-truck bookings stored the truck on the booking columns; a
|
||||||
|
// comma-joined value means several trucks, so the operator picks instead.
|
||||||
|
const bookingPlate = item?.booking?.customerTruckPlateNumber ?? '';
|
||||||
|
const legacyPlate = bookingPlate && !bookingPlate.includes(',') ? bookingPlate : '';
|
||||||
|
const initialPlate = prefillPlate || inProgress?.truckPlateNumber || legacyPlate || '';
|
||||||
|
const block = blockForPlate(blocks, initialPlate);
|
||||||
|
loadedPlateRef.current = initialPlate ? initialPlate.trim().toUpperCase() : null;
|
||||||
|
setTruckPlateNumber(initialPlate);
|
||||||
|
setTrailerPlateNumber(block?.trailerPlateNumber || truckPrefill?.trailerPlateNumber || '');
|
||||||
|
setDriverName(
|
||||||
|
block?.driverName ||
|
||||||
|
truckPrefill?.driverName ||
|
||||||
|
(legacyPlate && initialPlate === legacyPlate ? (item?.booking?.customerTruckDriverName ?? '') : ''),
|
||||||
|
);
|
||||||
|
setDriverLicense(block?.driverLicense || truckPrefill?.driverLicense || '');
|
||||||
|
setDriverPhone(block?.driverPhone || truckPrefill?.driverPhone || '');
|
||||||
|
setTruckType(
|
||||||
|
block?.truckType ||
|
||||||
|
truckPrefill?.truckType ||
|
||||||
|
(legacyPlate && initialPlate === legacyPlate ? (item?.booking?.customerTruckType ?? '') : ''),
|
||||||
|
);
|
||||||
|
setContainerNumbers(
|
||||||
|
initialContainerNumbers(item, block?.containerNumber || truckPrefill?.containerNumber || ''),
|
||||||
|
);
|
||||||
|
setGateInTime(block?.gateInTime ?? '');
|
||||||
|
setTareWeight(block?.tareWeight ?? '');
|
||||||
|
setWeighTruck(block?.weighingSkipped ? 'no' : 'yes');
|
||||||
|
setGrossWeight(block?.grossWeight ?? '');
|
||||||
|
setNetWeight(block?.netWeight ?? (item?.weight == null ? '' : Number(item.weight)));
|
||||||
|
setGateOutTime(block?.gateOutTime ?? '');
|
||||||
|
}
|
||||||
|
}, [opened, item, truckPrefill]);
|
||||||
|
|
||||||
|
// No truck chosen yet and exactly one is assigned — load it. With several
|
||||||
|
// trucks the operator picks which one is at the gate.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!opened || truckPlateNumber || loadedPlateRef.current) return;
|
||||||
|
if (truckSelectOptions.length !== 1) return;
|
||||||
|
applyTruckSelection(truckSelectOptions[0].value);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [opened, truckPlateNumber, customerTrucks, lastMileTrucks]);
|
||||||
|
|
||||||
|
// A typed plate that matches a saved arrival reloads that truck's record, so
|
||||||
|
// the exit step opens with the weigh-in data instead of blank fields.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!opened) return;
|
||||||
|
const key = truckPlateNumber.trim().toUpperCase();
|
||||||
|
if (!key || loadedPlateRef.current === key) return;
|
||||||
|
if (blockForPlate(savedBlocks, truckPlateNumber)) applyTruckSelection(truckPlateNumber);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [opened, truckPlateNumber]);
|
||||||
|
|
||||||
// Which containers ride this truck, and their combined cargo weight. When the
|
// Which containers ride this truck, and their combined cargo weight. When the
|
||||||
// booking has container weights, that sum is the authoritative net; the
|
// booking has container weights, that sum is the authoritative net; the
|
||||||
@@ -273,6 +379,9 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
|||||||
const containerWeightByNumber = new Map(
|
const containerWeightByNumber = new Map(
|
||||||
containerWeights.map((c) => [c.containerNumber.toUpperCase(), Number(c.weightTons) || 0]),
|
containerWeights.map((c) => [c.containerNumber.toUpperCase(), Number(c.weightTons) || 0]),
|
||||||
);
|
);
|
||||||
|
// A truck may only carry out its OWN assigned containers — when the selected
|
||||||
|
// truck has an assigned load, other trucks' containers are not offered.
|
||||||
|
const assignedLoad = (selectedOption?.containerNumbers ?? []).map((n) => n.toUpperCase());
|
||||||
// Mantine Selects throw on duplicate option values — legacy bookings can carry
|
// Mantine Selects throw on duplicate option values — legacy bookings can carry
|
||||||
// the same container number on two lines, so dedupe defensively.
|
// the same container number on two lines, so dedupe defensively.
|
||||||
const containerSelectData = [
|
const containerSelectData = [
|
||||||
@@ -285,7 +394,12 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
|||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
).values(),
|
).values(),
|
||||||
];
|
].filter(
|
||||||
|
(option) =>
|
||||||
|
assignedLoad.length === 0 ||
|
||||||
|
assignedLoad.includes(option.value.toUpperCase()) ||
|
||||||
|
containerNumbers.some((n) => n.trim().toUpperCase() === option.value.toUpperCase()),
|
||||||
|
);
|
||||||
const selectedContainerNumbers = containerNumbers.map((n) => n.trim()).filter(Boolean);
|
const selectedContainerNumbers = containerNumbers.map((n) => n.trim()).filter(Boolean);
|
||||||
const selectedCargoWeight = Number(
|
const selectedCargoWeight = Number(
|
||||||
selectedContainerNumbers
|
selectedContainerNumbers
|
||||||
@@ -294,7 +408,9 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
|||||||
);
|
);
|
||||||
// Skip is only offered for container bookings; bulk always weighs.
|
// Skip is only offered for container bookings; bulk always weighs.
|
||||||
const skipWeighing = hasContainerWeights && weighTruck === 'no';
|
const skipWeighing = hasContainerWeights && weighTruck === 'no';
|
||||||
const useContainerNet = hasContainerWeights && selectedContainerNumbers.length > 0 && !skipWeighing;
|
// Even an unweighed truck records the cargo weight it is holding — the
|
||||||
|
// selected containers' sum is the net that goes on the exit record.
|
||||||
|
const useContainerNet = hasContainerWeights && selectedContainerNumbers.length > 0;
|
||||||
|
|
||||||
const systemNetWeight = useContainerNet
|
const systemNetWeight = useContainerNet
|
||||||
? selectedCargoWeight
|
? selectedCargoWeight
|
||||||
@@ -314,6 +430,10 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
|||||||
toast({ variant: 'destructive', title: 'Truck plate and driver name are required' });
|
toast({ variant: 'destructive', title: 'Truck plate and driver name are required' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (hasTruckLeft) {
|
||||||
|
toast({ variant: 'destructive', title: `Truck ${truckPlateNumber} has already left — its exit record is locked` });
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!gateInTime || (!skipWeighing && tareWeight === '')) {
|
if (!gateInTime || (!skipWeighing && tareWeight === '')) {
|
||||||
toast({
|
toast({
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
@@ -321,6 +441,12 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// No backdating: gate times are recorded as they happen. The locked
|
||||||
|
// entrance (exit step) keeps its original past gate-in untouched.
|
||||||
|
if (!isEntranceLocked && isBackdated(gateInTime)) {
|
||||||
|
toast({ variant: 'destructive', title: 'Gate in time cannot be in the past' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (isExitStep && (!gateOutTime || (!skipWeighing && grossWeight === ''))) {
|
if (isExitStep && (!gateOutTime || (!skipWeighing && grossWeight === ''))) {
|
||||||
toast({
|
toast({
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
@@ -328,6 +454,10 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (isExitStep && isBackdated(gateOutTime)) {
|
||||||
|
toast({ variant: 'destructive', title: 'Gate out time cannot be in the past' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (isExitStep && hasContainerWeights && selectedContainerNumbers.length === 0) {
|
if (isExitStep && hasContainerWeights && selectedContainerNumbers.length === 0) {
|
||||||
toast({ variant: 'destructive', title: 'Select the containers loaded on this truck' });
|
toast({ variant: 'destructive', title: 'Select the containers loaded on this truck' });
|
||||||
return;
|
return;
|
||||||
@@ -363,13 +493,17 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
|||||||
weighingSkipped: skipWeighing || undefined,
|
weighingSkipped: skipWeighing || undefined,
|
||||||
tareWeight: skipWeighing ? undefined : Number(tareWeight),
|
tareWeight: skipWeighing ? undefined : Number(tareWeight),
|
||||||
grossWeight: skipWeighing || grossWeight === '' ? undefined : Number(grossWeight),
|
grossWeight: skipWeighing || grossWeight === '' ? undefined : Number(grossWeight),
|
||||||
netWeight: !skipWeighing && isExitStep && systemNetWeight !== '' ? Number(systemNetWeight) : undefined,
|
// Skipped weighing still records the net from what the truck holds.
|
||||||
|
netWeight: isExitStep && systemNetWeight !== '' ? Number(systemNetWeight) : undefined,
|
||||||
gateOutTime: isExitStep ? toIsoDateTime(gateOutTime) : undefined,
|
gateOutTime: isExitStep ? toIsoDateTime(gateOutTime) : undefined,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['release-customer-trucks', bookingId] });
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['release-last-mile-trucks', bookingId] });
|
||||||
if (!isExitStep) {
|
if (!isExitStep) {
|
||||||
|
const remaining = totalTrucks > 1 ? ` (${Math.min(arrivedTrucks + 1, totalTrucks)} of ${totalTrucks} trucks arrived)` : '';
|
||||||
toast({
|
toast({
|
||||||
title: 'Truck arrival saved',
|
title: `Truck ${truckPlateNumber.trim()} arrival saved${remaining}`,
|
||||||
description: `${released.releaseOrderReference ?? reference} is ready for exit weighing.`,
|
description: `${released.releaseOrderReference ?? reference} is ready for exit weighing.`,
|
||||||
});
|
});
|
||||||
onClose();
|
onClose();
|
||||||
@@ -380,11 +514,12 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
|||||||
const blob = response.data;
|
const blob = response.data;
|
||||||
const filename = `release-${released.booking?.reference ?? released.bookingId ?? item.id}.pdf`;
|
const filename = `release-${released.booking?.reference ?? released.bookingId ?? item.id}.pdf`;
|
||||||
const opened = openPdfBlob(blob, filename, pdfWindow);
|
const opened = openPdfBlob(blob, filename, pdfWindow);
|
||||||
|
const remainingExit = totalTrucks > 1 ? ` ${Math.min(leftTrucks + 1, totalTrucks)} of ${totalTrucks} trucks have left.` : '';
|
||||||
toast({
|
toast({
|
||||||
title: 'Release exit paper issued',
|
title: 'Release exit paper issued',
|
||||||
description: opened
|
description: (opened
|
||||||
? 'The PDF opened in a browser tab for printing or saving.'
|
? 'The PDF opened in a browser tab for printing or saving.'
|
||||||
: 'The browser blocked the preview tab, so the PDF was downloaded.',
|
: 'The browser blocked the preview tab, so the PDF was downloaded.') + remainingExit,
|
||||||
});
|
});
|
||||||
onClose();
|
onClose();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -411,12 +546,35 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
|||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</Alert>
|
</Alert>
|
||||||
|
{totalTrucks > 1 && (
|
||||||
|
<Alert icon={<Truck size={16} />} color="blue" variant="light">
|
||||||
|
<Group gap="xs">
|
||||||
|
<Text size="sm">
|
||||||
|
{totalTrucks} trucks on this booking — each is weighed in and out separately.
|
||||||
|
</Text>
|
||||||
|
<Badge size="sm" variant="light" color={arrivedTrucks === totalTrucks ? 'green' : 'blue'}>
|
||||||
|
{arrivedTrucks}/{totalTrucks} arrived
|
||||||
|
</Badge>
|
||||||
|
<Badge size="sm" variant="light" color={leftTrucks === totalTrucks ? 'green' : 'gray'}>
|
||||||
|
{leftTrucks}/{totalTrucks} left
|
||||||
|
</Badge>
|
||||||
|
</Group>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
{hasTruckLeft && (
|
||||||
|
<Alert icon={<Info size={16} />} color="green" variant="light">
|
||||||
|
<Text size="sm">
|
||||||
|
Truck {truckPlateNumber} has already left — its exit record is locked. Pick another
|
||||||
|
truck to continue the remaining arrivals and exits.
|
||||||
|
</Text>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
<TextInput
|
<TextInput
|
||||||
label="Release document reference"
|
label="Release document reference"
|
||||||
placeholder="e.g. REL-2026-001"
|
placeholder="e.g. REL-2026-001"
|
||||||
value={reference}
|
value={reference}
|
||||||
onChange={(e) => setReference(e.currentTarget.value)}
|
onChange={(e) => setReference(e.currentTarget.value)}
|
||||||
readOnly={isEntranceLocked}
|
readOnly={referenceLocked}
|
||||||
/>
|
/>
|
||||||
{noTruckAssigned && (
|
{noTruckAssigned && (
|
||||||
<Alert color="orange" variant="light" icon={<Info size={16} />}>
|
<Alert color="orange" variant="light" icon={<Info size={16} />}>
|
||||||
@@ -425,22 +583,19 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
|||||||
)}
|
)}
|
||||||
{truckSelectOptions.length > 0 && (
|
{truckSelectOptions.length > 0 && (
|
||||||
<Select
|
<Select
|
||||||
label="Assigned first / last-mile truck"
|
label="Truck at the gate"
|
||||||
|
description="Pick which assigned truck is being processed — switching trucks loads that truck's own arrival/exit record."
|
||||||
placeholder="Select the assigned truck"
|
placeholder="Select the assigned truck"
|
||||||
searchable
|
searchable
|
||||||
clearable
|
clearable
|
||||||
// Enabled at arrival so the operator picks which assigned truck came;
|
disabled={releaseMutation.isPending || downloading}
|
||||||
// only locked on the exit (leaving) step once identity is captured.
|
data={truckSelectOptions.map(({ value, label, arrived, left }) => ({
|
||||||
disabled={isEntranceLocked}
|
value,
|
||||||
data={truckSelectOptions}
|
label: `${label}${left ? ' · LEFT' : arrived ? ' · ON SITE' : ''}`,
|
||||||
|
}))}
|
||||||
value={truckSelectOptions.some((truck) => truck.value === truckPlateNumber) ? truckPlateNumber : null}
|
value={truckSelectOptions.some((truck) => truck.value === truckPlateNumber) ? truckPlateNumber : null}
|
||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
const truck = truckSelectOptions.find((row) => row.value === value);
|
if (value) applyTruckSelection(value);
|
||||||
setTruckPlateNumber(truck?.value ?? '');
|
|
||||||
setTrailerPlateNumber(truck?.trailerPlate ?? '');
|
|
||||||
if (truck?.driverName) setDriverName(truck.driverName);
|
|
||||||
if (truck?.driverPhone) setDriverPhone(truck.driverPhone);
|
|
||||||
if (truck?.truckType) setTruckType(truck.truckType);
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -481,6 +636,7 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
|||||||
data={containerSelectData}
|
data={containerSelectData}
|
||||||
value={selectedContainerNumbers}
|
value={selectedContainerNumbers}
|
||||||
onChange={(values) => setContainerNumbers(values.length ? values : [''])}
|
onChange={(values) => setContainerNumbers(values.length ? values : [''])}
|
||||||
|
disabled={hasTruckLeft}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Stack gap={6}>
|
<Stack gap={6}>
|
||||||
@@ -501,7 +657,7 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
|||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
</Stack>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
<TextInput label="Gate in time" type="datetime-local" value={gateInTime} onChange={(e) => setGateInTime(e.currentTarget.value)} readOnly={isEntranceLocked} />
|
<TextInput label="Gate in time" type="datetime-local" min={isEntranceLocked ? undefined : nowLocalDateTimeInput()} value={gateInTime} onChange={(e) => setGateInTime(e.currentTarget.value)} readOnly={isEntranceLocked} />
|
||||||
</Group>
|
</Group>
|
||||||
{hasContainerWeights && (
|
{hasContainerWeights && (
|
||||||
<Group gap="md" align="center">
|
<Group gap="md" align="center">
|
||||||
@@ -514,13 +670,15 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
|||||||
disabled={isEntranceLocked}
|
disabled={isEntranceLocked}
|
||||||
/>
|
/>
|
||||||
{skipWeighing && (
|
{skipWeighing && (
|
||||||
<Text size="xs" c="dimmed">Weighbridge skipped — container passes without tare/gross.</Text>
|
<Text size="xs" c="dimmed">
|
||||||
|
Weighbridge skipped — the selected containers' cargo weight is recorded as the net.
|
||||||
|
</Text>
|
||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
)}
|
)}
|
||||||
<Group grow>
|
<Group grow>
|
||||||
<NumberInput label="Tare weight (t)" required={!skipWeighing} min={0} value={tareWeight} onChange={(v) => setTareWeight(v === '' ? '' : Number(v))} readOnly={isEntranceLocked} disabled={skipWeighing} />
|
<NumberInput label="Tare weight (t)" required={!skipWeighing} min={0} value={tareWeight} onChange={(v) => setTareWeight(v === '' ? '' : Number(v))} readOnly={isEntranceLocked} disabled={skipWeighing} />
|
||||||
<NumberInput label="Gross weight (t)" required={isExitStep && !skipWeighing} min={0} value={grossWeight} onChange={(v) => setGrossWeight(v === '' ? '' : Number(v))} disabled={!isExitStep || skipWeighing} />
|
<NumberInput label="Gross weight (t)" required={isExitStep && !skipWeighing} min={0} value={grossWeight} onChange={(v) => setGrossWeight(v === '' ? '' : Number(v))} disabled={!isExitStep || skipWeighing || hasTruckLeft} />
|
||||||
<NumberInput
|
<NumberInput
|
||||||
label={useContainerNet ? 'Selected cargo net (t)' : 'Recorded net weight (system t)'}
|
label={useContainerNet ? 'Selected cargo net (t)' : 'Recorded net weight (system t)'}
|
||||||
min={0}
|
min={0}
|
||||||
@@ -532,7 +690,7 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
|||||||
<Text size="sm" c={weightMismatch ? 'red' : 'dimmed'}>
|
<Text size="sm" c={weightMismatch ? 'red' : 'dimmed'}>
|
||||||
Computed net: <b>{computedNetWeight == null ? '-' : `${computedNetWeight.toLocaleString()} t`}</b>
|
Computed net: <b>{computedNetWeight == null ? '-' : `${computedNetWeight.toLocaleString()} t`}</b>
|
||||||
</Text>
|
</Text>
|
||||||
<TextInput label="Gate out time" type="datetime-local" value={gateOutTime} onChange={(e) => setGateOutTime(e.currentTarget.value)} disabled={!isExitStep} />
|
<TextInput label="Gate out time" type="datetime-local" min={nowLocalDateTimeInput()} value={gateOutTime} onChange={(e) => setGateOutTime(e.currentTarget.value)} disabled={!isExitStep || hasTruckLeft} />
|
||||||
</Group>
|
</Group>
|
||||||
{weightMismatch && (
|
{weightMismatch && (
|
||||||
<Alert icon={<Scale size={16} />} color="red" variant="light">
|
<Alert icon={<Scale size={16} />} color="red" variant="light">
|
||||||
@@ -546,7 +704,7 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
|||||||
<Button variant="default" onClick={onClose} disabled={releaseMutation.isPending || downloading}>
|
<Button variant="default" onClick={onClose} disabled={releaseMutation.isPending || downloading}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button color="orange" onClick={handleSubmit} loading={releaseMutation.isPending || downloading}>
|
<Button color="orange" onClick={handleSubmit} loading={releaseMutation.isPending || downloading} disabled={hasTruckLeft}>
|
||||||
{isExitStep ? 'Save Truck Leaving & View Exit Paper' : 'Save Truck Arrival'}
|
{isExitStep ? 'Save Truck Leaving & View Exit Paper' : 'Save Truck Arrival'}
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
|
|||||||
@@ -51,8 +51,10 @@ const actionColor: Record<InventoryAction, string> = {
|
|||||||
deliver: 'green',
|
deliver: 'green',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// After the first truck registers, the modal decides per truck whether it is
|
||||||
|
// arriving or leaving — the item-level label covers both for multi-truck.
|
||||||
const releaseActionLabel = (item: WarehouseInventoryItem) =>
|
const releaseActionLabel = (item: WarehouseInventoryItem) =>
|
||||||
item.releaseOrderReference ? 'Truck Leaving' : 'Truck Arrival';
|
item.releaseOrderReference ? 'Truck Arrival / Leaving' : 'Truck Arrival';
|
||||||
|
|
||||||
const noteLineValue = (notes: string | null | undefined, label: string) => {
|
const noteLineValue = (notes: string | null | undefined, label: string) => {
|
||||||
const match = notes?.match(new RegExp(`^${label}:\\s*(.+)$`, 'im'));
|
const match = notes?.match(new RegExp(`^${label}:\\s*(.+)$`, 'im'));
|
||||||
@@ -276,6 +278,19 @@ export function WarehouseInventoryTable({
|
|||||||
{nextAction === 'release' ? releaseActionLabel(item) : humanizeEnum(nextAction.replace(/-/g, '_'))}
|
{nextAction === 'release' ? releaseActionLabel(item) : humanizeEnum(nextAction.replace(/-/g, '_'))}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
{/* After the first exit the primary action flips to Deliver, but a
|
||||||
|
multi-truck booking still weighs its remaining trucks in and out. */}
|
||||||
|
{item.status === 'READY_FOR_PICKUP' && item.releaseDate && nextAction !== 'release' && (
|
||||||
|
<Button
|
||||||
|
size="compact-xs"
|
||||||
|
variant="light"
|
||||||
|
color="yellow"
|
||||||
|
loading={busy}
|
||||||
|
onClick={() => onAdvance(item, 'release')}
|
||||||
|
>
|
||||||
|
Truck Arrival / Leaving
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
{item.status === 'READY_FOR_PICKUP' && (
|
{item.status === 'READY_FOR_PICKUP' && (
|
||||||
<Button
|
<Button
|
||||||
size="compact-xs"
|
size="compact-xs"
|
||||||
|
|||||||
@@ -23,23 +23,24 @@ export function WarehouseOpsKpiStrip() {
|
|||||||
delta:
|
delta:
|
||||||
data != null ? data.receivedToday - data.receivedYesterday : undefined,
|
data != null ? data.receivedToday - data.receivedYesterday : undefined,
|
||||||
hint: "vs yesterday",
|
hint: "vs yesterday",
|
||||||
// The received cargo itself, on the inventory board.
|
// Exactly the items behind the counter: received today.
|
||||||
href: "/dashboard/warehouse-inventory?status=RECEIVED",
|
href: "/dashboard/warehouse-inventory?receivedToday=1",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Pending inspection",
|
label: "Pending inspection",
|
||||||
value: data?.pendingInspection ?? 0,
|
value: data?.pendingInspection ?? 0,
|
||||||
icon: ClipboardCheck,
|
icon: ClipboardCheck,
|
||||||
color: "yellow",
|
color: "yellow",
|
||||||
// Received cargo still awaiting inspection lives in the RECEIVED bucket.
|
// RECEIVED items with no inspection recorded yet.
|
||||||
href: "/dashboard/warehouse-inventory?status=RECEIVED",
|
href: "/dashboard/warehouse-inventory?pendingInspection=1",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Trucks on-site",
|
label: "Trucks on-site",
|
||||||
value: data?.trucksOnSite ?? 0,
|
value: data?.trucksOnSite ?? 0,
|
||||||
icon: Truck,
|
icon: Truck,
|
||||||
color: "blue",
|
color: "blue",
|
||||||
href: "/dashboard/trucks-on-site",
|
// Land on the On-site tab — the counter excludes inbound trucks.
|
||||||
|
href: "/dashboard/trucks-on-site?scope=ON_SITE",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Items aging (>7d)",
|
label: "Items aging (>7d)",
|
||||||
@@ -47,8 +48,7 @@ export function WarehouseOpsKpiStrip() {
|
|||||||
icon: AlertTriangle,
|
icon: AlertTriangle,
|
||||||
color: (data?.itemsAging ?? 0) > 0 ? "red" : "edr-green",
|
color: (data?.itemsAging ?? 0) > 0 ? "red" : "edr-green",
|
||||||
hint: "In warehouse over 7 days",
|
hint: "In warehouse over 7 days",
|
||||||
// No aging filter on the board; the inventory list is the landing.
|
href: "/dashboard/warehouse-inventory?agingOverDays=7",
|
||||||
href: "/dashboard/warehouse-inventory",
|
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -399,6 +399,8 @@ export const URL_CONSTANTS = {
|
|||||||
`/train-scheduling/schedules/${scheduleId}/wagons/${wagonId}`,
|
`/train-scheduling/schedules/${scheduleId}/wagons/${wagonId}`,
|
||||||
UPDATE_CONTAINER_ITEM: (scheduleId: string, itemId: string) =>
|
UPDATE_CONTAINER_ITEM: (scheduleId: string, itemId: string) =>
|
||||||
`/train-scheduling/schedules/${scheduleId}/container-items/${itemId}`,
|
`/train-scheduling/schedules/${scheduleId}/container-items/${itemId}`,
|
||||||
|
MOVE_WAGON_LOAD: (scheduleId: string, wagonId: string) =>
|
||||||
|
`/train-scheduling/schedules/${scheduleId}/wagons/${wagonId}/move-load`,
|
||||||
UNASSIGNED_BOOKINGS: (scheduleId: string) =>
|
UNASSIGNED_BOOKINGS: (scheduleId: string) =>
|
||||||
`/train-scheduling/schedules/${scheduleId}/unassigned-bookings`,
|
`/train-scheduling/schedules/${scheduleId}/unassigned-bookings`,
|
||||||
COMPOSITION_REMOVALS: (scheduleId: string) =>
|
COMPOSITION_REMOVALS: (scheduleId: string) =>
|
||||||
|
|||||||
20
apps/edr-freight-web/backoffice/src/lib/no-backdate.ts
Normal file
20
apps/edr-freight-web/backoffice/src/lib/no-backdate.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
/**
|
||||||
|
* Backdating guard for operational time entries (gate in/out, mile truck
|
||||||
|
* times, delivery pickups): times must be recorded as they happen, never
|
||||||
|
* dated back. A one-hour grace covers real-world lag (weighbridge queue,
|
||||||
|
* operator finishing the form after the event).
|
||||||
|
*/
|
||||||
|
export const BACKDATE_GRACE_MS = 60 * 60 * 1000;
|
||||||
|
|
||||||
|
/** Local-time "YYYY-MM-DDTHH:mm" for a datetime-local input's `min`. */
|
||||||
|
export const nowLocalDateTimeInput = (): string =>
|
||||||
|
new Date(Date.now() - new Date().getTimezoneOffset() * 60_000)
|
||||||
|
.toISOString()
|
||||||
|
.slice(0, 16);
|
||||||
|
|
||||||
|
/** True when the value is more than the grace period in the past. */
|
||||||
|
export const isBackdated = (value: string | Date | null | undefined): boolean => {
|
||||||
|
if (!value) return false;
|
||||||
|
const t = value instanceof Date ? value.getTime() : new Date(value).getTime();
|
||||||
|
return Number.isFinite(t) && t < Date.now() - BACKDATE_GRACE_MS;
|
||||||
|
};
|
||||||
@@ -2,6 +2,12 @@ import type { AuthUser } from "@/auth/types";
|
|||||||
import type { RuleEngineResourceSlug } from "@/types/rule-engine";
|
import type { RuleEngineResourceSlug } from "@/types/rule-engine";
|
||||||
|
|
||||||
export const FREIGHT_PERMS = {
|
export const FREIGHT_PERMS = {
|
||||||
|
overview: {
|
||||||
|
view: "edr_freight_app:overview:view",
|
||||||
|
},
|
||||||
|
support: {
|
||||||
|
view: "edr_freight_app:support:view",
|
||||||
|
},
|
||||||
bookings: {
|
bookings: {
|
||||||
view: "edr_freight_app:bookings:view",
|
view: "edr_freight_app:bookings:view",
|
||||||
create: "edr_freight_app:bookings:create",
|
create: "edr_freight_app:bookings:create",
|
||||||
@@ -353,12 +359,38 @@ export const POSITION_KEYS = {
|
|||||||
djiboutiGl: "djibouti_gl",
|
djiboutiGl: "djibouti_gl",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
/** Position-type keys held by the user (e.g. "djibouti-gl-officer"). */
|
||||||
|
export function getPositionTypeKeys(
|
||||||
|
user: AuthUser | null | undefined,
|
||||||
|
): string[] {
|
||||||
|
if (!user) return [];
|
||||||
|
const keys = new Set<string>();
|
||||||
|
for (const emp of user.employee ?? []) {
|
||||||
|
for (const pos of emp.positions ?? []) {
|
||||||
|
if (pos.positionType?.key) keys.add(pos.positionType.key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...keys];
|
||||||
|
}
|
||||||
|
|
||||||
|
// GL staff are identified by the root position key (department heads) OR by
|
||||||
|
// their position-type key (sub-positions: director/chief/officer) — both
|
||||||
|
// forms get the clearance-only locked view.
|
||||||
|
const ET_GL_TYPE_PREFIX = "commercial-global-logistics-(et)";
|
||||||
|
const DJ_GL_TYPE_PREFIX = "djibouti-gl";
|
||||||
|
|
||||||
export function isEthiopianGl(user: AuthUser | null | undefined): boolean {
|
export function isEthiopianGl(user: AuthUser | null | undefined): boolean {
|
||||||
return hasPosition(user, POSITION_KEYS.ethiopianGl);
|
return (
|
||||||
|
hasPosition(user, POSITION_KEYS.ethiopianGl) ||
|
||||||
|
getPositionTypeKeys(user).some((k) => k.startsWith(ET_GL_TYPE_PREFIX))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isDjiboutiGl(user: AuthUser | null | undefined): boolean {
|
export function isDjiboutiGl(user: AuthUser | null | undefined): boolean {
|
||||||
return hasPosition(user, POSITION_KEYS.djiboutiGl);
|
return (
|
||||||
|
hasPosition(user, POSITION_KEYS.djiboutiGl) ||
|
||||||
|
getPositionTypeKeys(user).some((k) => k.startsWith(DJ_GL_TYPE_PREFIX))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isSuperAdmin(user: AuthUser | null | undefined): boolean {
|
export function isSuperAdmin(user: AuthUser | null | undefined): boolean {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user