Merge pull request #962 from Tria-plc/freight_feature/usermanagement

Freight feature/usermanagement
This commit is contained in:
marshal
2026-07-26 11:12:23 +03:00
committed by GitHub
95 changed files with 3521 additions and 388 deletions

View File

@@ -23,6 +23,14 @@ export const StaffReference = () => applyDecorators(UseGuards(JwtGuard));
export const BookingView = () => BookingStaff(FREIGHT_PERMS.bookings.view);
/**
* The document-review countdown in the backoffice header. Its own permission so
* it can be granted to exactly the position types that decide operation
* requests, instead of every holder of bookings:view.
*/
export const BookingDocReviewAlert = () =>
BookingStaff(FREIGHT_PERMS.bookings.docReviewAlert);
export const TrainSchedulingView = () =>
BookingStaff(FREIGHT_PERMS.trainScheduling.view);

View File

@@ -0,0 +1,47 @@
import { ForbiddenException } from '@nestjs/common';
import {
assertCanApproveContractStep,
canEditContractStep,
} from './freight-permission.util';
import { FREIGHT_PERMS } from '../seed/freight-permissions.registry';
const userWith = (...keys: string[]) => ({
permissions: keys.map((key) => ({ key })),
});
describe('hazardous contract approval steps', () => {
it('rejects an approver who only holds ordinary contract-approve permissions', () => {
// The blanket "any contract approve permission" fallback must NOT reach
// dangerous goods — that is the whole point of the dedicated desks.
const lineStaff = userWith(FREIGHT_PERMS.contracts.approveLineStaff);
expect(() =>
assertCanApproveContractStep(lineStaff, 'HAZARDOUS_APPROVAL_ONE'),
).toThrow(ForbiddenException);
expect(canEditContractStep(lineStaff, 'HAZARDOUS_APPROVAL_ONE')).toBe(false);
});
it('accepts only the matching hazardous permission', () => {
const first = userWith(FREIGHT_PERMS.contracts.hazardousApprovalOne);
expect(() =>
assertCanApproveContractStep(first, 'HAZARDOUS_APPROVAL_ONE'),
).not.toThrow();
// Holding step one does not confer step two.
expect(() =>
assertCanApproveContractStep(first, 'HAZARDOUS_APPROVAL_TWO'),
).toThrow(ForbiddenException);
});
it('does not let a hazardous approver stand in for the commercial chain', () => {
const hazardOnly = userWith(
FREIGHT_PERMS.contracts.hazardousApprovalOne,
FREIGHT_PERMS.contracts.hazardousApprovalTwo,
);
expect(() => assertCanApproveContractStep(hazardOnly, 'CEO')).toThrow(
ForbiddenException,
);
});
});

View File

@@ -151,6 +151,24 @@ const APPROVE_ROLE_PERMISSION: Record<string, string> = {
CEO: FREIGHT_PERMS.bookings.approveCeo,
};
/**
* Approval-chain roles synthesized for hazardous contracts (see
* `instantiateApprovalSteps`). Unlike the legacy roles below they are NOT
* position types — they authorize purely on their own dedicated permission, and
* they deliberately opt out of the blanket "holds any contract-approve
* permission" fallback so a normal approver cannot sign off dangerous goods.
*/
export const HAZARDOUS_APPROVAL_ROLE_PERMISSION: Record<string, string> = {
HAZARDOUS_APPROVAL_ONE: FREIGHT_PERMS.contracts.hazardousApprovalOne,
HAZARDOUS_APPROVAL_TWO: FREIGHT_PERMS.contracts.hazardousApprovalTwo,
};
/** The two hazardous steps, in the order they are prepended to the chain. */
export const HAZARDOUS_APPROVAL_ROLES = [
'HAZARDOUS_APPROVAL_ONE',
'HAZARDOUS_APPROVAL_TWO',
] as const;
const CONTRACT_APPROVE_ROLE_PERMISSION: Record<string, string> = {
LINE_STAFF: FREIGHT_PERMS.contracts.approveLineStaff,
DIRECTOR: FREIGHT_PERMS.contracts.approveDirector,
@@ -183,6 +201,16 @@ export function assertCanApproveContractStep(
): void {
if (isFreightApprovalAdmin(user)) return;
// Hazardous steps are permission-only and strict — no legacy alias, no
// blanket approve fallback.
const hazardousPermission = HAZARDOUS_APPROVAL_ROLE_PERMISSION[requiredRole];
if (hazardousPermission) {
if (hasFreightPermission(user, hazardousPermission)) return;
throw new ForbiddenException(
`Missing permission: ${hazardousPermission}`,
);
}
const positionTypes = collectPositionTypeKeys(user);
if (positionTypes.includes(requiredRole)) return;
@@ -219,6 +247,11 @@ export function canEditContractStep(
): boolean {
if (isFreightApprovalAdmin(user)) return true;
const hazardousPermission = HAZARDOUS_APPROVAL_ROLE_PERMISSION[requiredRole];
if (hazardousPermission) {
return hasFreightPermission(user, hazardousPermission);
}
const positionTypes = collectPositionTypeKeys(user);
if (positionTypes.includes(requiredRole)) return true;

View File

@@ -1,4 +1,5 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { hazardClassLabel } from '@edr/types';
import { ContractsRepository } from '../modules/contracts/contracts.repository';
import {
@@ -30,6 +31,8 @@ export interface ContractDocumentSignatureView {
signerDisplayName: string;
signedAt: string;
signatureImageUrl?: string | null;
/** Company stamp/seal; rendered next to the signature when present. */
stampImageUrl?: string | null;
}
/** A single unit-rate row on the contract PDF — price per unit, NO total. */
@@ -141,6 +144,11 @@ export class ContractDocumentViewModelBuilder {
const hasCustomer = signatures.some((s) => s.role === 'CUSTOMER');
const hasStaff = signatures.some((s) => s.role === 'STAFF');
// Signed before company stamps were required — the customer has to sign
// again to attach one, otherwise EDR can never counter-sign the contract.
const customerStampMissing = signatures.some(
(s) => s.role === 'CUSTOMER' && !s.stampImageUrl,
);
const hasContractFile = Boolean(
contract.files?.some((f) => f.code === 'contract'),
);
@@ -183,7 +191,9 @@ export class ContractDocumentViewModelBuilder {
// Cast: contract signers (CUSTOMER|STAFF|DIRECTOR|CEO) widen the booking
// view-model's narrower CUSTOMER|STAFF role union.
signatures: signatures as unknown as ContractViewModel['signatures'],
canSignCustomer: contract.status === 'CONTRACT_READY' && !hasCustomer,
canSignCustomer:
(contract.status === 'CONTRACT_READY' && !hasCustomer) ||
(contract.status === 'SIGNED_CUSTOMER' && customerStampMissing),
canSignStaff:
contract.status === 'SIGNED_CUSTOMER' && hasCustomer && !hasStaff,
hasContractDocument: hasContractFile,
@@ -208,6 +218,7 @@ export class ContractDocumentViewModelBuilder {
signerDisplayName: row.signerDisplayName,
signedAt: this.formatDate(row.signedAt),
signatureImageUrl: row.signatureFile?.url ?? null,
stampImageUrl: row.stampFile?.url ?? null,
};
}
@@ -292,7 +303,16 @@ export class ContractDocumentViewModelBuilder {
cargoDescription: this.valueOrDash(cargoName),
totalWeightVgm: '—',
equipmentReturn: this.valueOrDash(contract.equipmentReturn),
hazardousLabel: contract.isHazardous ? 'Yes' : 'No',
// A hazardous contract names the declared class + UN number on the
// schedule — the flag alone is not a dangerous-goods declaration.
hazardousLabel: contract.isHazardous
? [
hazardClassLabel(contract.hazardClass) ?? 'Yes',
contract.unNumber ? `UN ${contract.unNumber}` : null,
]
.filter(Boolean)
.join(' · ')
: 'No',
firstMilePickupAddress: this.valueOrDash(contract.firstMilePickupAddress),
lastMileDeliveryAddress: this.valueOrDash(contract.lastMileDeliveryAddress),
};

View File

@@ -11,6 +11,12 @@
<p class="sig-line"><strong>Name:</strong> {{signerDisplayName}}</p>
<p class="sig-meta"><strong>Role:</strong> Authorized EDR representative</p>
<p class="sig-meta"><strong>Date:</strong> {{signedAt}}</p>
{{#if stampImageUrl}}
<div class="sig-stamp">
<span class="sig-stamp-label">Company stamp</span>
<div class="sig-stamp-box"><img src="{{stampImageUrl}}" alt="Service provider stamp" /></div>
</div>
{{/if}}
{{/if}}
{{/each}}
{{else}}
@@ -32,6 +38,12 @@
<p class="sig-line"><strong>Name:</strong> {{signerDisplayName}}</p>
<p class="sig-meta"><strong>Role:</strong> Authorized client representative</p>
<p class="sig-meta"><strong>Date:</strong> {{signedAt}}</p>
{{#if stampImageUrl}}
<div class="sig-stamp">
<span class="sig-stamp-label">Company stamp</span>
<div class="sig-stamp-box"><img src="{{stampImageUrl}}" alt="Client stamp" /></div>
</div>
{{/if}}
{{/if}}
{{/each}}
{{else}}

View File

@@ -372,25 +372,30 @@
font-size: 9pt;
margin: 4px 0;
}
/* ── Witnesses ────────────────────────────────────────────────────────── */
.witnesses { margin-top: 20px; }
.witness-table {
font-size: 9.5pt;
margin-top: 6px;
.sig-stamp {
margin-top: 12px;
}
.witness-table th,
.witness-table td {
border-bottom: 1px solid #c9e4d9;
padding: 9px 8px;
text-align: left;
}
.witness-table th {
.sig-stamp-label {
color: #0e5b45;
font-family: Arial, sans-serif;
font-size: 8.5pt;
font-size: 7.5pt;
font-weight: 700;
letter-spacing: 0.4pt;
text-transform: uppercase;
}
.sig-stamp-box {
align-items: center;
display: flex;
height: 30mm;
justify-content: center;
margin-top: 5px;
}
.sig-stamp-box img {
display: block;
max-height: 30mm;
max-width: 45mm;
mix-blend-mode: multiply;
}
@media print {
body { background: #fff; }

View File

@@ -147,19 +147,6 @@
authorized to sign and execute this Contract Agreement.
</p>
{{> signatures_block}}
<div class="witnesses">
<p class="sig-title">Witnesses</p>
<table class="witness-table">
<thead>
<tr><th></th><th>Name</th><th>Signature</th><th>Date</th></tr>
</thead>
<tbody>
<tr><td>1.</td><td></td><td></td><td></td></tr>
<tr><td>2.</td><td></td><td></td><td></td></tr>
</tbody>
</table>
</div>
</section>
</main>
</body>

View File

@@ -0,0 +1,27 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Company stamp (seal) attached alongside the drawn signature, for both the
* client and the EDR side. Stored the same way the signature image is: a
* FileRecord on the contract (`resource: 'contracts'`, `code: 'stamp_<role>'`)
* referenced from the signature row.
*
* Nullable — existing signature rows predate the stamp requirement. The
* "both stamps recorded" gate lives in ContractTransitionService.counterSign,
* not in a NOT NULL constraint, so historical rows stay readable.
*/
export class AddContractSignatureStamp2910000000000 implements MigrationInterface {
name = 'AddContractSignatureStamp2910000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.contract_signatures ADD COLUMN IF NOT EXISTS stamp_file_id uuid;`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.contract_signatures DROP COLUMN IF EXISTS stamp_file_id;`,
);
}
}

View File

@@ -0,0 +1,34 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Hazardous contracts now declare WHAT the dangerous good is, not just that it
* exists: the UN/ADR class (CLASS_1..CLASS_9) and the shipment's UN number. Both
* are captured in the portal alongside the hazard documents and reviewed by the
* two hazardous approval desks.
*
* Nullable — non-hazardous contracts leave both null, and contracts created
* before this change have no declaration to backfill.
*/
export class AddContractHazardDeclaration2920000000000
implements MigrationInterface
{
name = 'AddContractHazardDeclaration2920000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.contracts ADD COLUMN IF NOT EXISTS hazard_class varchar(16);`,
);
await queryRunner.query(
`ALTER TABLE freight.contracts ADD COLUMN IF NOT EXISTS un_number varchar(16);`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.contracts DROP COLUMN IF EXISTS un_number;`,
);
await queryRunner.query(
`ALTER TABLE freight.contracts DROP COLUMN IF EXISTS hazard_class;`,
);
}
}

View File

@@ -0,0 +1,76 @@
import { BookingLifecycleNotifierService } from './booking-lifecycle-notifier.service';
import type { Booking } from './entities/booking.entity';
/**
* Who hears "Operations wants changes" depends on who owns the booking. A
* customs (Path B) booking is created BY GL Ethiopia on the customer's behalf —
* the customer can neither edit nor resubmit it, so the note has to reach the GL
* who made it, not the portal.
*/
describe('BookingLifecycleNotifierService — operation changes requested', () => {
const booking = (over: Partial<Booking> = {}): Booking =>
({
id: 'b-1',
reference: 'BKG-0001',
companyId: 'co-1',
contractId: 'ctr-1',
createdByRole: 'CUSTOMER',
company: { email: 'customer@example.com' },
...over,
}) as Booking;
let notifications: { directSend: jest.Mock };
let inbox: { notify: jest.Mock };
let service: BookingLifecycleNotifierService;
const flush = () => new Promise((resolve) => setImmediate(resolve));
beforeEach(() => {
notifications = { directSend: jest.fn().mockResolvedValue(undefined) };
inbox = { notify: jest.fn().mockResolvedValue(undefined) };
service = new BookingLifecycleNotifierService(
notifications as never,
inbox as never,
{ query: jest.fn().mockResolvedValue([{ phone: '+251900000000' }]) } as never,
);
});
it('sends a GL-created booking back to the GL who created it, not the customer', async () => {
service.operationChangesRequested(
booking({ createdByRole: 'GL_ET', createdByUserId: 'gl-user-1' }),
'Cargo weight does not match the declaration',
);
await flush();
expect(inbox.notify).toHaveBeenCalledTimes(1);
const sent = inbox.notify.mock.calls[0][0];
expect(sent.recipients).toEqual({ userIds: ['gl-user-1'] });
expect(sent.audience).toBe('BACKOFFICE');
expect(sent.body).toContain('Cargo weight does not match the declaration');
// Deep-links the clearance page GL works from, not the portal booking.
expect(sent.link).toBe('/dashboard/contracts/clearance/ctr-1');
// The customer is not told to fix something they cannot touch.
expect(notifications.directSend).not.toHaveBeenCalled();
});
it('still tells the customer when the booking is their own', async () => {
service.operationChangesRequested(booking(), 'Please attach the packing list');
await flush();
const sent = inbox.notify.mock.calls[0][0];
expect(sent.recipients).toEqual({ companyId: 'co-1' });
expect(sent.audience).toBe('PORTAL');
expect(sent.link).toBe('/bookings/b-1');
expect(notifications.directSend).toHaveBeenCalled();
});
it('falls back to the customer when the GL creator is unknown (legacy rows)', async () => {
service.operationChangesRequested(
booking({ createdByRole: 'GL_ET', createdByUserId: null }),
'Fix the declaration',
);
await flush();
expect(inbox.notify.mock.calls[0][0].recipients).toEqual({ companyId: 'co-1' });
});
});

View File

@@ -179,8 +179,35 @@ export class BookingLifecycleNotifierService {
});
}
/** Operations returned the operation request for changes. */
/**
* Operations returned the operation request for changes.
*
* A customs (Path B) booking was created BY GL Ethiopia on the customer's
* behalf — the customer cannot edit or resubmit it, so telling them to "update
* from the portal" is a dead end. Those go to the GL who created it, linking
* the contract clearance page they work from. Everything else (customer-made
* bookings) keeps the portal message.
*/
operationChangesRequested(b: Booking, note: string): void {
if (b.createdByRole === 'GL_ET' && b.createdByUserId) {
const msg =
`Operations returned booking ${b.reference} for changes: ${note}. ` +
`Address it on the contract clearance page and resubmit to Operations.`;
this.logger.log(`OPERATION CHANGES REQUESTED (to GL) — ${this.ref(b)}`);
void this.inbox.notify({
recipients: { userIds: [b.createdByUserId] },
audience: NotificationAudience.BACKOFFICE,
type: NotificationType.BOOKING_STATUS,
title: `Booking ${b.reference} needs changes`,
body: msg,
link: b.contractId
? `/dashboard/contracts/clearance/${b.contractId}`
: `/dashboard/bookings/${b.id}/clearance`,
data: { bookingId: b.id, reference: b.reference, note },
});
return;
}
const msg =
`Your operation request for booking ${b.reference} needs changes: ${note}. ` +
`Please update and resubmit from the portal.`;

View File

@@ -473,3 +473,124 @@ describe('BookingPricingService — customs clearance fee billed on the booking
expect(result.lineItems.some((l) => l.code.startsWith('CUSTOMS_CLEARANCE'))).toBe(false);
});
});
/**
* Bulk freight bills in the commodity's own unit: tonnage for a weighed
* commodity (PER_TON), item count for a counted one (PER_ITEM). Both read the
* booking's cargo amount; PER_WAGON bills the wagons the cargo occupies.
*/
describe('BookingPricingService — bulk base freight units', () => {
const DJ = 'yard-dj-bulk';
const DIRE_B = 'yard-dire-bulk';
const bulkRate = (overrides: Partial<Rate> = {}): Rate =>
({
id: 'rate-bulk',
rateType: 'BULK_IMPORT',
appliesTo: 'BULK',
trigger: 'ALWAYS',
currency: 'USD',
rateValue: 200,
rateUnit: 'PER_ITEM',
status: 'LIVE',
containerTypeId: null,
cargoTypeId: null,
tradeDirection: 'IMPORT',
originYardId: DJ,
destinationYardId: DIRE_B,
...overrides,
}) as Rate;
const makeService = (liveRates: Rate[], wagonCapacity?: number) =>
new BookingPricingService(
{
calculateWagonCount: jest.fn().mockResolvedValue(0),
findContractRateSnapshots: jest.fn().mockResolvedValue([]),
} as never,
{
evaluate: jest.fn().mockResolvedValue({
priorityScore: 0,
appliedModifiers: [],
containerWeightResults: [],
warnings: [],
hardBlocked: [],
requiresDirectorApproval: false,
}),
} as never,
{ findById: jest.fn() } as never,
{ findLiveRates: jest.fn().mockResolvedValue(liveRates) } as never,
{ getRate: jest.fn().mockResolvedValue(MOCK_CBE_RATE) } as never,
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
{
findById: jest.fn().mockResolvedValue({
wagonTypes: wagonCapacity !== undefined ? [{ capacityTons: wagonCapacity }] : [],
}),
} as never,
);
// 12 machines, not 12 tonnes — a PER_ITEM commodity records its count here.
const booking = (overrides: Record<string, unknown> = {}) =>
({
id: 'b-bulk',
freightType: 'BULK',
tradeDirection: 'IMPORT',
paymentCurrency: 'USD',
cargoTypeId: 'cargo-machinery',
cargoTotalWeightVgm: 12,
originYardId: DJ,
destinationYardId: DIRE_B,
bookingContainers: [],
...overrides,
}) as unknown as Booking;
it('bills a PER_ITEM rate on the item count', async () => {
const result = await makeService([bulkRate()]).computePriceForBooking(booking());
const line = result.lineItems.find((l) => l.code === 'BULK_IMPORT');
expect(line!.unit).toBe('PER_ITEM');
expect(line!.quantity).toBe(12);
expect(line!.amount).toBe(2400);
});
it('bills a PER_TON rate on the tonnage', async () => {
const result = await makeService([
bulkRate({ rateUnit: 'PER_TON', rateValue: 35 }),
]).computePriceForBooking(booking({ cargoTotalWeightVgm: 120 }));
const line = result.lineItems.find((l) => l.code === 'BULK_IMPORT');
expect(line!.unit).toBe('PER_TON');
expect(line!.amount).toBe(35 * 120);
});
it('bills a PER_WAGON rate on the wagons the cargo occupies, not zero', async () => {
const result = await makeService(
[bulkRate({ rateUnit: 'PER_WAGON', rateValue: 500 })],
60,
).computePriceForBooking(booking({ cargoTotalWeightVgm: 120 }));
const line = result.lineItems.find((l) => l.code === 'BULK_IMPORT');
expect(line!.unit).toBe('PER_WAGON');
expect(line!.quantity).toBe(2); // 120 t ÷ 60 t per wagon
expect(line!.amount).toBe(1000);
});
it('prices off the rate scoped to the booking commodity, not another one', async () => {
const result = await makeService([
bulkRate({ id: 'rate-wheat', cargoTypeId: 'cargo-wheat', rateUnit: 'PER_TON', rateValue: 35 }),
bulkRate({ id: 'rate-machinery', cargoTypeId: 'cargo-machinery', rateValue: 200 }),
]).computePriceForBooking(booking());
const line = result.lineItems.find((l) => l.code === 'BULK_IMPORT');
expect(line!.unit).toBe('PER_ITEM');
expect(line!.amount).toBe(2400);
});
it('hard-blocks when the leg only carries another commoditys rate', async () => {
const result = await makeService([
bulkRate({ id: 'rate-wheat', cargoTypeId: 'cargo-wheat' }),
]).computePriceForBooking(booking());
expect(result.lineItems.some((l) => l.code === 'BULK_IMPORT')).toBe(false);
expect(result.hardBlocked.some((m) => m.includes('rate is configured'))).toBe(true);
});
});

View File

@@ -4,6 +4,7 @@ import { CargoTypesService } from '../rule-engine/services/cargo-types.service';
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
import { RatesService } from '../rule-engine/services/rates.service';
import { Rate } from '../rule-engine/entities/rate.entity';
import { isBulkQuantityUnit } from '../rule-engine/entities/rate-unit.util';
import { ContractRateSnapshot } from '../contracts/entities/contract-rate-snapshot.entity';
import { ExchangeService } from '@edr/api-common';
import {
@@ -529,7 +530,13 @@ export class BookingPricingService {
const usedRatesMap = new Map<string, Rate>();
const warnings: string[] = [];
const blocked: string[] = [];
const wagonCount = await this.resolveWagonCount(booking);
// Bulk bookings carry no container lines, so the container-based wagon
// aggregate is 0 for them — a PER_WAGON bulk rate would bill nothing. Use
// the tonnage-derived estimate instead (the eval input already carries it
// for saved bookings; a preview derives it here).
const wagonCount = isBulk
? Number(evalInput.bulkWagons ?? 0) || (await this.bulkWagonCount(booking)) || 0
: await this.resolveWagonCount(booking);
for (const container of evalInput.containers) {
const rate = this.pickRate(
@@ -600,7 +607,10 @@ export class BookingPricingService {
// container type above or stay unpriced with a warning — falling back to
// a corridor rate of a DIFFERENT container type billed once (qty 1) is
// how a 38-container booking was invoiced 40 USD instead of 1900.
const fallback = liveRates.find(
// Within the leg, the rate scoped to the booking's own commodity wins over
// the commodity-wide catch-all — a per-item machinery rate must never
// price a per-ton wheat booking (or the reverse).
const onLeg = liveRates.filter(
(r) =>
r.rateType === rateType &&
r.currency === 'USD' &&
@@ -608,11 +618,17 @@ export class BookingPricingService {
r.originYardId === booking.originYardId &&
r.destinationYardId === booking.destinationYardId,
);
const fallback =
(booking.cargoTypeId
? onLeg.find((r) => r.cargoTypeId === booking.cargoTypeId)
: undefined) ?? onLeg.find((r) => !r.cargoTypeId);
if (fallback) {
usedRatesMap.set(fallback.id, fallback);
const bulkTons = Number(booking.cargoTotalWeightVgm ?? 0);
// Bulk quantity is stored in the commodity's own unit — tonnes for a
// PER_TON commodity, item count for a PER_ITEM one.
const bulkQuantity = Number(booking.cargoTotalWeightVgm ?? 0);
const quantity =
isBulk && fallback.rateUnit === 'PER_TON' ? Math.max(bulkTons, 0) : 1;
isBulk && isBulkQuantityUnit(fallback.rateUnit) ? Math.max(bulkQuantity, 0) : 1;
const unitUsd = Number(fallback.rateValue);
// H15: bulk freight uses the frozen BULK_FREIGHT snapshot when present.
const frozen = isBulk
@@ -719,6 +735,7 @@ export class BookingPricingService {
quantity = containerCount;
break;
case 'PER_TON':
case 'PER_ITEM':
quantity = bulkTons;
break;
case 'FLAT':
@@ -804,6 +821,7 @@ export class BookingPricingService {
return 1;
case 'PER_CONTAINER':
case 'PER_TON':
case 'PER_ITEM':
default:
return quantity;
}
@@ -860,6 +878,7 @@ export class BookingPricingService {
case 'PER_WAGON':
return unitValue * wagonCount;
case 'PER_TON':
case 'PER_ITEM':
return unitValue * quantity;
case 'FLAT':
return unitValue;
@@ -1044,7 +1063,7 @@ export class BookingPricingService {
const unit = frozen ? this.rateUnitFromSnapshot(frozen.unitOfMeasure) : live!.rateUnit;
const unitAmount = frozen ? Number(frozen.unitPrice) : convert(Number(live!.rateValue));
let billedQty = 1;
if (unit === 'PER_TON') {
if (isBulkQuantityUnit(unit)) {
billedQty = Math.max(0, Number(booking.cargoTotalWeightVgm ?? 0));
} else if (unit === 'PER_WAGON') {
const wagons = await this.bulkWagonCount(booking);
@@ -1081,6 +1100,8 @@ export class BookingPricingService {
return 'PER_WAGON';
case 'per_ton':
return 'PER_TON';
case 'per_item':
return 'PER_ITEM';
case 'per_container':
return 'PER_CONTAINER';
default:

View File

@@ -33,41 +33,86 @@ import {
BookingReferenceYardDto,
} from "./dto/booking-reference-data.dto";
/**
* Reference cargo tree: top-level groups, each carrying its selectable
* commodities.
*
* `cargo_types` is an arbitrary-depth tree (Bulk → Steel Billet → S1 → …), but
* only a LEAF is a real commodity — an intermediate node is a container for
* finer types, and booking against it would be ambiguous. So each group's
* `children` are all of its leaf descendants, flattened, whatever the depth.
* Deep leaves carry their path below the group ("Steel Billet → S1") so a
* generically-named leaf still reads unambiguously in a dropdown.
*
* A group with no active descendants is its own leaf and is emitted as its
* single child — otherwise it is selectable as a group but offers no commodity,
* which dead-ends every form that requires one.
*/
export function buildCargoTypeTree(
rows: CargoType[],
): BookingReferenceCargoTypeGroupDto[] {
const active = rows.filter((r) => r.isActive);
const parents = active
.filter((r) => !r.parentGroupId)
.sort(
(a, b) => a.displayOrder - b.displayOrder || a.code.localeCompare(b.code),
);
const byOrder = (a: CargoType, b: CargoType) =>
a.displayOrder - b.displayOrder || a.code.localeCompare(b.code);
const childrenOf = new Map<string, CargoType[]>();
for (const row of active) {
if (!row.parentGroupId) continue;
const siblings = childrenOf.get(row.parentGroupId) ?? [];
siblings.push(row);
childrenOf.set(row.parentGroupId, siblings);
}
for (const siblings of childrenOf.values()) siblings.sort(byOrder);
const parents = active.filter((r) => !r.parentGroupId).sort(byOrder);
/** Depth-first leaf walk; `trail` is the path below the group. */
const collectLeaves = (
node: CargoType,
trail: string[],
seen: Set<string>,
): BookingReferenceCargoTypeChildDto[] => {
// Admin-entered parent pointers could in principle cycle — never loop.
if (seen.has(node.id)) return [];
seen.add(node.id);
const kids = childrenOf.get(node.id) ?? [];
if (kids.length === 0) {
return [
{
id: node.id,
name: [...trail, node.cargoTypeName].join(" → "),
code: node.code,
unit_of_measure: node.unitOfMeasure ?? null,
},
];
}
const nextTrail = [...trail, node.cargoTypeName];
return kids.flatMap((kid) => collectLeaves(kid, nextTrail, seen));
};
return parents.map((parent) => {
const children = active
.filter((r) => r.parentGroupId === parent.id)
.sort(
(a, b) =>
a.displayOrder - b.displayOrder || a.code.localeCompare(b.code),
)
.map(
(child): BookingReferenceCargoTypeChildDto => ({
id: child.id,
name: child.cargoTypeName,
code: child.code,
unit_of_measure: child.unitOfMeasure ?? null,
}),
);
const kids = childrenOf.get(parent.id) ?? [];
const children =
kids.length === 0
? // The group itself is the commodity.
[
{
id: parent.id,
name: parent.cargoTypeName,
code: parent.code,
unit_of_measure: parent.unitOfMeasure ?? null,
},
]
: kids.flatMap((kid) => collectLeaves(kid, [], new Set<string>()));
const group: BookingReferenceCargoTypeGroupDto = {
return {
id: parent.id,
name: parent.cargoTypeName,
code: parent.code,
children,
};
if (children.length > 0) {
group.children = children;
}
return group;
});
}

View File

@@ -0,0 +1,72 @@
import { buildCargoTypeTree } from './booking-reference-data.service';
import type { CargoType } from '../rule-engine/entities/cargo-type.entity';
const node = (
id: string,
name: string,
parentGroupId: string | null,
isActive = true,
): CargoType =>
({
id,
cargoTypeName: name,
code: name.toUpperCase().replace(/\s+/g, '_'),
parentGroupId,
displayOrder: 0,
isActive,
unitOfMeasure: 'PER_TON',
}) as unknown as CargoType;
describe('buildCargoTypeTree', () => {
// Bulk ──┬─ Wheat (leaf, depth 2)
// └─ Steel Billet ──┬─ S1 (leaf, depth 3)
// └─ S2 ─ S2a (leaf, depth 4)
const rows = [
node('bulk', 'Bulk', null),
node('wheat', 'Wheat', 'bulk'),
node('steel', 'Steel Billet', 'bulk'),
node('s1', 'S1', 'steel'),
node('s2', 'S2', 'steel'),
node('s2a', 'S2a', 's2'),
node('general', 'General Cargo', null),
];
it('offers only leaves as commodities, at any depth', () => {
const [bulk] = buildCargoTypeTree(rows);
// Leaves stay grouped under their branch (siblings ordered by
// displayOrder then code — STEEL_BILLET before WHEAT here).
expect(bulk.children?.map((c) => c.id)).toEqual(['s1', 's2a', 'wheat']);
// "Steel Billet" is a container for finer types, never bookable itself.
expect(bulk.children?.some((c) => c.id === 'steel')).toBe(false);
});
it('labels deep leaves with their path below the group', () => {
const [bulk] = buildCargoTypeTree(rows);
const byId = new Map(bulk.children?.map((c) => [c.id, c.name]));
expect(byId.get('wheat')).toBe('Wheat');
expect(byId.get('s1')).toBe('Steel Billet → S1');
expect(byId.get('s2a')).toBe('Steel Billet → S2 → S2a');
});
it('emits a childless group as its own commodity', () => {
const general = buildCargoTypeTree(rows).find((g) => g.id === 'general');
expect(general?.children).toEqual([
expect.objectContaining({ id: 'general', name: 'General Cargo' }),
]);
});
it('skips inactive nodes and their descendants', () => {
const withRetired = [
...rows,
node('retired', 'Retired', 'bulk', false),
node('retiredKid', 'Retired Kid', 'retired', false),
];
const [bulk] = buildCargoTypeTree(withRetired);
expect(bulk.children?.map((c) => c.id)).not.toContain('retired');
expect(bulk.children?.map((c) => c.id)).not.toContain('retiredKid');
});
});

View File

@@ -84,6 +84,14 @@ export interface ContractClearanceView {
/** Reference + status of the GL-created shipment booking, once it exists. */
linkedBookingReference?: string | null;
linkedBookingStatus?: string | null;
/**
* Operations' latest "needs changes" note on that booking. GL created the
* booking, so GL is the one who has to act on it — surfaced here because the
* clearance page is where GL works, not the portal.
*/
linkedBookingReviewNote?: string | null;
/** Shipment day the booking currently holds — the default when GL resubmits. */
linkedBookingScheduledDate?: string | null;
dutyAdvice?: {
amount: number;
currency: string;
@@ -301,11 +309,24 @@ export class ContractClearanceService {
// shortly" message. Reuse the export booking load; fetch for import too.
let linkedBookingReference: string | null = null;
let linkedBookingStatus: string | null = null;
let linkedBookingReviewNote: string | null = null;
let linkedBookingScheduledDate: string | null = null;
if (cycle?.bookingId) {
const booking = await this.bookingsService.findById(cycle.bookingId);
if (booking) {
linkedBookingReference = booking.reference ?? null;
linkedBookingStatus = booking.status ?? null;
linkedBookingScheduledDate = booking.scheduledDate
? new Date(booking.scheduledDate).toISOString()
: null;
// Newest changes-requested note (reviewNotes ride along on findById).
linkedBookingReviewNote =
[...(booking.reviewNotes ?? [])]
.filter((n) => n.type === 'CHANGES_REQUESTED')
.sort(
(a, b) =>
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
)[0]?.note ?? null;
if (contract.tradeDirection === 'EXPORT') {
nextAction = this.workflowService.computeNextActionForBooking(
booking,
@@ -349,6 +370,8 @@ export class ContractClearanceService {
linkedBookingId: cycle?.bookingId ?? null,
linkedBookingReference,
linkedBookingStatus,
linkedBookingReviewNote,
linkedBookingScheduledDate,
dutyAdvice,
workflowFiles,
t1,

View File

@@ -0,0 +1,63 @@
import { ContractExpiryService } from './contract-expiry.service';
import type { Contract } from './entities/contract.entity';
/**
* The reminder must warn each customer once, ten days out, and must never let a
* notification failure escape into the scheduler (that would also take out the
* expiry sweep sharing this service).
*/
describe('ContractExpiryService — expiry reminder', () => {
const contract = (over: Partial<Contract> = {}): Contract =>
({
id: 'c-1',
reference: 'CTR-2026-00042',
companyId: 'co-1',
contractValidUntil: new Date('2026-08-10T00:00:00.000Z'),
status: 'CONTRACT_ACTIVE',
...over,
}) as Contract;
let repo: { expireLapsedContracts: jest.Mock; findExpiringInDays: jest.Mock };
let inbox: { notify: jest.Mock };
let service: ContractExpiryService;
beforeEach(() => {
repo = {
expireLapsedContracts: jest.fn().mockResolvedValue(0),
findExpiringInDays: jest.fn().mockResolvedValue([]),
};
inbox = { notify: jest.fn().mockResolvedValue(undefined) };
service = new ContractExpiryService(repo as never, inbox as never);
});
it('asks for the contracts lapsing ten days out', async () => {
await service.remindExpiringContracts();
expect(repo.findExpiringInDays).toHaveBeenCalledWith(10);
});
it('notifies the owning company once, deep-linking the contract list', async () => {
repo.findExpiringInDays.mockResolvedValue([contract()]);
await service.remindExpiringContracts();
expect(inbox.notify).toHaveBeenCalledTimes(1);
const sent = inbox.notify.mock.calls[0][0];
expect(sent.recipients).toEqual({ companyId: 'co-1' });
expect(sent.title).toContain('CTR-2026-00042');
expect(sent.title).toContain('10 days');
expect(sent.link).toBe('/contracts');
expect(sent.data).toMatchObject({ contractId: 'c-1', action: 'CONTRACT_EXPIRING' });
});
it('skips a contract with no owning company (nobody to notify)', async () => {
repo.findExpiringInDays.mockResolvedValue([contract({ companyId: null })]);
await service.remindExpiringContracts();
expect(inbox.notify).not.toHaveBeenCalled();
});
it('swallows a notification failure instead of throwing into the scheduler', async () => {
repo.findExpiringInDays.mockResolvedValue([contract()]);
inbox.notify.mockRejectedValue(new Error('inbox down'));
await expect(service.remindExpiringContracts()).resolves.toBeUndefined();
});
});

View File

@@ -0,0 +1,96 @@
import { Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { NotificationAudience, NotificationType } from '@edr/types';
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
import { ContractsRepository } from './contracts.repository';
/**
* How many days before a contract lapses the customer is reminded. Mirrored by
* the portal contract list (EXPIRY_NOTICE_DAYS in contract-ui.tsx), which shows
* the same countdown on the row.
*/
const EXPIRY_NOTICE_DAYS = 10;
/** Nightly sweep that flips contracts past contractValidUntil to EXPIRED. */
@Injectable()
export class ContractExpiryService {
private readonly logger = new Logger(ContractExpiryService.name);
constructor(
private readonly contractsRepository: ContractsRepository,
private readonly inbox: NotificationInboxService,
) {}
/**
* Warn every customer whose contract lapses in ~10 days, once. The repository
* window is a rolling 24h slice, so a contract is picked up by exactly one
* daily run — no reminded-flag column needed.
*
* ponytail: a missed run (API down over the slice) skips that contract's
* reminder; the portal list still shows its countdown for the whole window.
*/
@Cron(CronExpression.EVERY_DAY_AT_2AM, { name: 'contract-expiry-reminder' })
async remindExpiringContracts(): Promise<void> {
try {
const expiring =
await this.contractsRepository.findExpiringInDays(EXPIRY_NOTICE_DAYS);
let notified = 0;
for (const contract of expiring) {
if (!contract.companyId || !contract.contractValidUntil) continue;
const endsOn = contract.contractValidUntil.toLocaleDateString('en-GB');
await this.inbox.notify({
recipients: { companyId: contract.companyId },
audience: NotificationAudience.PORTAL,
type: NotificationType.CONTRACT_STATUS,
title: `Contract ${contract.reference} expires in ${EXPIRY_NOTICE_DAYS} days`,
body:
`Your contract ${contract.reference} is valid until ${endsOn}. ` +
'After that date it stops accepting new bookings — contact EDR if ' +
'you need it renewed.',
link: '/contracts',
data: { contractId: contract.id, action: 'CONTRACT_EXPIRING' },
});
notified += 1;
}
this.logger.log(
`Contract expiry reminder: ${notified} customer(s) warned of a contract ` +
`lapsing in ${EXPIRY_NOTICE_DAYS} days`,
);
} catch (err) {
// Never throws into the scheduler — a failed reminder must not stop the
// expiry sweep from running.
this.logger.error(
`Contract expiry reminder failed: ${(err as Error).message}`,
(err as Error).stack,
);
}
}
@Cron(CronExpression.EVERY_DAY_AT_1AM, { name: 'contract-expiry-sweep' })
async expireLapsedContracts(): Promise<void> {
try {
const affected = await this.contractsRepository.expireLapsedContracts();
this.logger.log(`Contract expiry sweep: ${affected} contract(s) marked EXPIRED`);
} catch (err) {
this.logger.error(
`Contract expiry sweep failed: ${(err as Error).message}`,
(err as Error).stack,
);
try {
await this.inbox.notify({
recipients: { allBackoffice: true },
audience: NotificationAudience.BACKOFFICE,
type: NotificationType.GENERIC,
title: 'Contract expiry sweep failed',
body: `The nightly job that expires lapsed contracts failed: ${(err as Error).message}. Contracts past their validity date may still show as active until this is fixed.`,
data: { action: 'CONTRACT_EXPIRY_SWEEP_FAILED' },
});
} catch (notifyErr) {
this.logger.error(
`Contract expiry sweep failure alert also failed: ${(notifyErr as Error).message}`,
);
}
}
}
}

View File

@@ -35,6 +35,8 @@ function toContractUnit(rateUnit: string): ContractUnitRateLineItem['unit'] {
switch (rateUnit) {
case 'PER_TON':
return 'per_ton';
case 'PER_ITEM':
return 'per_item';
case 'PER_KM':
return 'per_km';
case 'PER_WAGON':
@@ -115,9 +117,19 @@ export class ContractPricingService {
});
}
} else {
const bulkRate =
liveRates.find((r) => r.rateType === baseType && r.currency === 'USD') ?? null;
const cargoScope = (contract.cargoScope ?? []).find((c) => c.cargoTypeId);
// Freeze the rate for the contract's own commodity when one is configured
// — a per-item machinery rate and a per-ton wheat rate live side by side.
const bulkRates = liveRates.filter(
(r) => r.rateType === baseType && r.currency === 'USD',
);
const bulkRate =
(cargoScope?.cargoTypeId
? bulkRates.find((r) => r.cargoTypeId === cargoScope.cargoTypeId)
: undefined) ??
bulkRates.find((r) => !r.cargoTypeId) ??
bulkRates[0] ??
null;
if (bulkRate) {
lineItems.push({
code: 'BULK_FREIGHT',

View File

@@ -0,0 +1,81 @@
import { Readable } from 'stream';
import { ContractTransitionService } from './contract-transition.service';
/**
* A stamp may be uploaded as JPEG/WebP while a drawn signature is always PNG.
* The type must survive the round-trip: data URL in → stored object extension
* → data URL out. Getting this wrong labels JPEG bytes as image/png in the
* contract PDF and leaves the seal to browser content-sniffing.
*/
describe('ContractTransitionService signature/stamp asset typing', () => {
const pngPixel =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAAAAAA6fptVAAAACklEQVR4nGMAAQAABQABDQottAAAAABJRU5ErkJggg==';
const jpegPixel = `data:image/jpeg;base64,${Buffer.from('fake-jpeg').toString('base64')}`;
/** Minimal service instance — only filesService/minioService are exercised. */
const build = () => {
const uploaded: Array<{ code: string; mimetype: string; name: string }> = [];
const filesService = {
upsertByCode: jest.fn(({ code, file }) => {
uploaded.push({ code, mimetype: file.mimetype, name: file.originalname });
return Promise.resolve({ id: `file-${code}`, url: `https://minio/x/${file.originalname}` });
}),
};
const minioService = {
getObjectNameFromUrl: (url: string) => url.split('/').pop() ?? '',
getFileStream: () => Promise.resolve(Readable.from(Buffer.from('bytes'))),
};
const service = Object.create(
ContractTransitionService.prototype,
) as ContractTransitionService;
Object.assign(service, { filesService, minioService });
return { service, uploaded };
};
const contract = { id: 'c-1', reference: 'CTR-2026-00001' };
it('stores a drawn PNG signature as image/png', async () => {
const { service, uploaded } = build();
await (service as never as {
uploadSignatureAsset: (c: unknown, code: string, b64: string) => Promise<unknown>;
}).uploadSignatureAsset(contract, 'signature_customer', pngPixel);
expect(uploaded[0].mimetype).toBe('image/png');
expect(uploaded[0].name).toBe('signature-customer-CTR-2026-00001.png');
});
it('keeps an uploaded JPEG stamp as image/jpeg, not image/png', async () => {
const { service, uploaded } = build();
await (service as never as {
uploadSignatureAsset: (c: unknown, code: string, b64: string) => Promise<unknown>;
}).uploadSignatureAsset(contract, 'stamp_customer', jpegPixel);
expect(uploaded[0].mimetype).toBe('image/jpeg');
expect(uploaded[0].name).toBe('stamp-customer-CTR-2026-00001.jpg');
});
it('inlines a stored .jpg back as a data:image/jpeg URI', async () => {
const { service } = build();
const inline = (service as never as {
inlineImageUrl: (url?: string | null) => Promise<string | null | undefined>;
}).inlineImageUrl.bind(service);
await expect(inline('https://minio/x/stamp-customer-CTR.jpg')).resolves.toMatch(
/^data:image\/jpeg;base64,/,
);
await expect(inline('https://minio/x/signature-customer-CTR.png')).resolves.toMatch(
/^data:image\/png;base64,/,
);
});
it('passes through empty and already-inlined values untouched', async () => {
const { service } = build();
const inline = (service as never as {
inlineImageUrl: (url?: string | null) => Promise<string | null | undefined>;
}).inlineImageUrl.bind(service);
await expect(inline(null)).resolves.toBeNull();
await expect(inline(pngPixel)).resolves.toBe(pngPixel);
});
});

View File

@@ -0,0 +1,131 @@
import { BadRequestException, ConflictException } from '@nestjs/common';
import { ContractTransitionService } from './contract-transition.service';
/**
* Signing is one-shot. The single exception: a contract signed before company
* stamps were required must be re-signable so the customer can attach one —
* otherwise counterSign's both-stamps gate strands it forever. These specs pin
* that exception open and pin everything else shut.
*/
describe('customer re-sign to attach a missing stamp', () => {
const contractReady = { id: 'c-1', reference: 'CTR-1', status: 'CONTRACT_READY' };
const signedNoStamp = { id: 'c-1', reference: 'CTR-1', status: 'SIGNED_CUSTOMER' };
const build = (contract: unknown, existingSignature: unknown) => {
const applied: unknown[] = [];
const service = Object.create(
ContractTransitionService.prototype,
) as ContractTransitionService;
Object.assign(service, {
contractsService: {
findById: jest.fn().mockResolvedValue(contract),
assertCustomerCanAccessContract: jest.fn().mockResolvedValue(undefined),
},
contractsRepository: {
findSignature: jest.fn().mockResolvedValue(existingSignature),
update: jest.fn().mockResolvedValue(undefined),
},
otpService: {
verifyOtpForAction: jest.fn().mockResolvedValue(undefined),
sendOtp: jest.fn().mockResolvedValue(undefined),
},
notifier: { customerSignedToStaff: jest.fn() },
resolveSignerContacts: jest.fn().mockResolvedValue({ phone: '+251900000000' }),
applySignature: jest.fn((...args: unknown[]) => {
applied.push(args);
return Promise.resolve();
}),
regenerateContractPdf: jest.fn().mockResolvedValue(undefined),
});
return { service, applied };
};
const dto = {
role: 'CUSTOMER' as const,
signerDisplayName: 'C. Customer',
signatureImageBase64: 'data:image/png;base64,AAAA',
stampImageBase64: 'data:image/png;base64,BBBB',
otp: '123456',
};
it('lets a customer sign again when their signature has no stamp', async () => {
const { service, applied } = build(signedNoStamp, {
id: 's-1',
role: 'CUSTOMER',
stampFileId: null,
});
await expect(service.sign('c-1', dto, { signerUserId: 'u-1' })).resolves.toBeDefined();
expect(applied).toHaveLength(1);
});
it('still refuses a second signature once a stamp is on file', async () => {
const { service } = build(signedNoStamp, {
id: 's-1',
role: 'CUSTOMER',
stampFileId: 'file-1',
});
// Stamped already → not the re-sign case, so the status guard rejects
// SIGNED_CUSTOMER before the already-signed check is reached.
await expect(service.sign('c-1', dto, { signerUserId: 'u-1' })).rejects.toBeInstanceOf(
ConflictException,
);
});
it('refuses a second signature on a still-ready contract', async () => {
const { service } = build(contractReady, {
id: 's-1',
role: 'CUSTOMER',
stampFileId: 'file-1',
});
await expect(service.sign('c-1', dto, { signerUserId: 'u-1' })).rejects.toThrow(
/already signed/i,
);
});
it('signs normally when nothing is on file yet', async () => {
const { service, applied } = build(contractReady, null);
await expect(service.sign('c-1', dto, { signerUserId: 'u-1' })).resolves.toBeDefined();
expect(applied).toHaveLength(1);
});
it('sends a signing OTP for the stamp re-sign', async () => {
const { service } = build(signedNoStamp, {
id: 's-1',
role: 'CUSTOMER',
stampFileId: null,
});
await expect(
service.sendSigningOtp('c-1', { signerUserId: 'u-1' }),
).resolves.toEqual(expect.objectContaining({ sentTo: expect.any(String) }));
});
it('refuses a signing OTP once the contract is signed and stamped', async () => {
const { service } = build(signedNoStamp, {
id: 's-1',
role: 'CUSTOMER',
stampFileId: 'file-1',
});
await expect(
service.sendSigningOtp('c-1', { signerUserId: 'u-1' }),
).rejects.toBeInstanceOf(ConflictException);
});
it('requires the OTP on the re-sign path too', async () => {
const { service } = build(signedNoStamp, {
id: 's-1',
role: 'CUSTOMER',
stampFileId: null,
});
await expect(
service.sign('c-1', { ...dto, otp: undefined }, { signerUserId: 'u-1' }),
).rejects.toBeInstanceOf(BadRequestException);
});
});

View File

@@ -22,6 +22,7 @@ import {
assertCanApproveContractStep,
assertFreightPermission,
canEditContractStep,
HAZARDOUS_APPROVAL_ROLES,
} from '../../common/freight-permission.util';
import {
FREIGHT_PERMS,
@@ -530,12 +531,26 @@ export class ContractTransitionService {
);
}
for (const rule of chain) {
await this.contractsRepository.createApprovalStep({
contractId: contract.id,
stepOrder: rule.stepOrder,
// Dangerous goods clear two dedicated hazardous desks BEFORE the commercial
// chain — if either refuses, the contract never reaches the approvers who
// would price and sign it. Steps are renumbered sequentially so the prefix
// and the configured chain form one ordered list.
const roles: Array<{ requiredRole: string; blocksRole: string | null }> = [
...(contract.isHazardous ? [...HAZARDOUS_APPROVAL_ROLES] : []).map(
(requiredRole) => ({ requiredRole, blocksRole: null }),
),
...chain.map((rule) => ({
requiredRole: rule.requiredRole,
blocksRole: rule.blocksRole ?? null,
})),
];
for (const [index, role] of roles.entries()) {
await this.contractsRepository.createApprovalStep({
contractId: contract.id,
stepOrder: index + 1,
requiredRole: role.requiredRole,
blocksRole: role.blocksRole,
status: 'PENDING',
});
}
@@ -928,23 +943,41 @@ export class ContractTransitionService {
});
}
/** Replace MinIO signature URLs with inline data URIs so they render in the PDF. */
/**
* Replace MinIO signature/stamp URLs with inline data URIs so they render in
* the PDF — Chromium cannot fetch the private bucket.
*/
private async inlineSignatureImages(
signatures: Array<{ signatureImageUrl?: string | null }>,
signatures: Array<{
signatureImageUrl?: string | null;
stampImageUrl?: string | null;
}>,
): Promise<void> {
for (const sig of signatures) {
if (!sig.signatureImageUrl) continue;
try {
if (sig.signatureImageUrl.startsWith('data:')) continue;
const objectName = this.minioService.getObjectNameFromUrl(
sig.signatureImageUrl,
);
const stream = await this.minioService.getFileStream(objectName);
const buffer = await this.streamToBuffer(stream);
sig.signatureImageUrl = `data:image/png;base64,${buffer.toString('base64')}`;
} catch {
/* keep original url */
}
sig.signatureImageUrl = await this.inlineImageUrl(sig.signatureImageUrl);
sig.stampImageUrl = await this.inlineImageUrl(sig.stampImageUrl);
}
}
/** MinIO URL → data URI. Returns the input unchanged if absent or on failure. */
private async inlineImageUrl(
url?: string | null,
): Promise<string | null | undefined> {
if (!url || url.startsWith('data:')) return url;
try {
const objectName = this.minioService.getObjectNameFromUrl(url);
const stream = await this.minioService.getFileStream(objectName);
const buffer = await this.streamToBuffer(stream);
const extension = objectName.split('.').pop()?.toLowerCase();
const mime =
extension === 'jpg' || extension === 'jpeg'
? 'image/jpeg'
: extension === 'webp'
? 'image/webp'
: 'image/png';
return `data:${mime};base64,${buffer.toString('base64')}`;
} catch {
return url;
}
}
@@ -959,6 +992,46 @@ export class ContractTransitionService {
});
}
/**
* base64 (data URL or raw) → image FileRecord stored on the contract under
* `code`. Drawn signatures are always PNG; an uploaded stamp may be JPEG or
* WebP, so the type is read off the data-URL prefix rather than assumed —
* the stored extension is what {@link inlineImageUrl} reads it back as.
*/
private async uploadSignatureAsset(
contract: Contract,
code: string,
imageBase64: string,
): Promise<FileRecord> {
const mimetype =
/^data:(image\/[a-z+]+);base64,/i.exec(imageBase64)?.[1]?.toLowerCase() ??
'image/png';
const extension = mimetype === 'image/jpeg' ? 'jpg' : mimetype.split('/')[1];
const raw = imageBase64.includes(',')
? imageBase64.split(',')[1]!
: imageBase64;
const buffer = Buffer.from(raw, 'base64');
const file: Express.Multer.File = {
fieldname: code,
originalname: `${code.replace(/_/g, '-')}-${contract.reference}.${extension}`,
encoding: '7bit',
mimetype,
size: buffer.length,
buffer,
stream: Readable.from(buffer),
destination: '',
filename: '',
path: '',
};
return this.filesService.upsertByCode({
resourceId: contract.id,
resource: 'contracts',
code,
file,
});
}
/** Apply a digital signature row (mirrors booking-contract.service). */
private async applySignature(
contract: Contract,
@@ -985,29 +1058,28 @@ export class ContractTransitionService {
);
}
const raw = imageBase64.includes(',')
? imageBase64.split(',')[1]!
: imageBase64;
const buffer = Buffer.from(raw, 'base64');
const sigFile: Express.Multer.File = {
fieldname: `signature_${role.toLowerCase()}`,
originalname: `signature-${role.toLowerCase()}-${contract.reference}.png`,
encoding: '7bit',
mimetype: 'image/png',
size: buffer.length,
buffer,
stream: Readable.from(buffer),
destination: '',
filename: '',
path: '',
};
// The company stamp is a separate image from the drawn signature. Both
// parties to the contract (client + EDR) must seal it; DIRECTOR/CEO rows
// are internal approval signatures, not party seals, so they stay exempt.
const stampRequired = role === 'CUSTOMER' || role === 'STAFF';
if (stampRequired && !dto.stampImageBase64) {
throw new BadRequestException(
'A company stamp is required to sign this contract.',
);
}
const fileRecord = await this.filesService.upsertByCode({
resourceId: contract.id,
resource: 'contracts',
code: `signature_${role.toLowerCase()}`,
file: sigFile,
});
const fileRecord = await this.uploadSignatureAsset(
contract,
`signature_${role.toLowerCase()}`,
imageBase64,
);
const stampRecord = dto.stampImageBase64
? await this.uploadSignatureAsset(
contract,
`stamp_${role.toLowerCase()}`,
dto.stampImageBase64,
)
: null;
await this.contractsRepository.saveSignature({
contractId: contract.id,
@@ -1015,6 +1087,7 @@ export class ContractTransitionService {
signerDisplayName,
signedAt: new Date(),
signatureFileId: fileRecord.id,
stampFileId: stampRecord?.id ?? null,
consentText: dto.consentText ?? null,
});
@@ -1053,7 +1126,17 @@ export class ContractTransitionService {
options.signerUserId,
contract,
);
assertContractStatus(contract, ['CONTRACT_READY']);
// SIGNED_CUSTOMER is allowed only for the re-sign-to-add-a-stamp case that
// {@link sign} permits — otherwise the code would be useless on arrival.
const existing = await this.contractsRepository.findSignature(
contractId,
'CUSTOMER',
);
const addingMissingStamp = Boolean(existing) && !existing?.stampFileId;
assertContractStatus(
contract,
addingMissingStamp ? ['CONTRACT_READY', 'SIGNED_CUSTOMER'] : ['CONTRACT_READY'],
);
const signerContacts = await this.resolveSignerContacts(options.signerUserId);
await this.otpService.sendOtp(signerContacts);
@@ -1077,9 +1160,16 @@ export class ContractTransitionService {
options.signerUserId,
contract,
);
assertContractStatus(contract, ['CONTRACT_READY']);
const existing = await this.contractsRepository.findSignature(contractId, 'CUSTOMER');
if (existing) {
// Signing is one-shot, with one exception: a contract signed before the
// company stamp was required has to be sealed before EDR can counter-sign
// it, so the customer may sign again purely to attach the missing stamp.
const addingMissingStamp = Boolean(existing) && !existing?.stampFileId;
assertContractStatus(
contract,
addingMissingStamp ? ['CONTRACT_READY', 'SIGNED_CUSTOMER'] : ['CONTRACT_READY'],
);
if (existing && !addingMissingStamp) {
throw new BadRequestException('Customer has already signed this contract');
}
// Sudo-mode gate: a fresh, single-use OTP must be verified before the
@@ -1127,6 +1217,19 @@ export class ContractTransitionService {
const contract = await this.contractsService.findById(contractId);
assertContractStatus(contract, ['SIGNED_CUSTOMER']);
// Both parties' stamps must be on file before the contract executes. The
// EDR stamp is enforced by applySignature below; the customer's is checked
// here so a contract signed before stamps existed can't slip through.
const customerSignature = await this.contractsRepository.findSignature(
contractId,
'CUSTOMER',
);
if (!customerSignature?.stampFileId) {
throw new BadRequestException(
'The customer stamp is missing on this contract — it cannot be counter-signed until the customer signs again with their company stamp.',
);
}
await this.applySignature(contract, dto, options);
const now = new Date();

View File

@@ -21,6 +21,7 @@ import { ContractTemplatesModule } from '../contract-templates/contract-template
import { ContractsController } from './contracts.controller';
import { ContractsService } from './contracts.service';
import { ContractsRepository } from './contracts.repository';
import { ContractExpiryService } from './contract-expiry.service';
import { ContractPricingService } from './contract-pricing.service';
import { ContractNotifierService } from './contract-notifier.service';
import { ContractTransitionService } from './contract-transition.service';
@@ -105,6 +106,7 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum
providers: [
ContractsService,
ContractsRepository,
ContractExpiryService,
ContractPricingService,
ContractNotifierService,
ContractTransitionService,

View File

@@ -14,6 +14,7 @@ import {
import { ContractRateSnapshot } from './entities/contract-rate-snapshot.entity';
import { ContractReviewNote, ContractReviewNoteType } from './entities/contract-review-note.entity';
import { ContractSignature, ContractSignerRole } from './entities/contract-signature.entity';
import { TERMINAL_CONTRACT_STATUSES } from './utils/contract-expiry.util';
export interface ContractListFilterOptions {
statuses?: string[];
@@ -66,6 +67,70 @@ export class ContractsRepository extends BaseRepository<Contract> {
return Number(row?.max ?? 0);
}
/**
* Non-terminal contracts for the same company + service type, with routes
* loaded — candidates for the duplicate-contract check on create(). Terminal
* filtering happens in JS via isEffectivelyExpired (also covers the
* date-passed-but-not-yet-cron-flipped case).
*/
async findDuplicateCandidates(
companyId: string,
serviceTypeId: string,
): Promise<Contract[]> {
return this.repository
.createQueryBuilder('contract')
.leftJoinAndSelect('contract.routes', 'routes')
.where('contract.deleted_at IS NULL')
.andWhere('contract.company_id = :companyId', { companyId })
.andWhere('contract.service_type_id = :serviceTypeId', { serviceTypeId })
.andWhere('contract.status NOT IN (:...terminal)', {
terminal: TERMINAL_CONTRACT_STATUSES,
})
.getMany();
}
/**
* Nightly expiry sweep: flips lapsed contracts to EXPIRED. Returns the
* number of rows updated (for cron logging).
*/
async expireLapsedContracts(): Promise<number> {
const result = await this.repository
.createQueryBuilder()
.update(Contract)
.set({ status: 'EXPIRED' })
.where('deleted_at IS NULL')
.andWhere('status NOT IN (:...terminal)', { terminal: TERMINAL_CONTRACT_STATUSES })
.andWhere('contract_valid_until IS NOT NULL AND contract_valid_until < :now', {
now: new Date(),
})
.execute();
return result.affected ?? 0;
}
/**
* Live contracts whose validity ends between `days` and `days + 1` days from
* now — the slice the daily expiry-reminder cron warns about. The window is
* rolling and exactly 24h wide, so consecutive daily runs tile it without
* gaps or overlaps: each contract is picked up by exactly one run and the
* customer is notified once, with no "already reminded" flag to store.
*/
async findExpiringInDays(days: number): Promise<Contract[]> {
const now = Date.now();
return this.repository
.createQueryBuilder('contract')
.where('contract.deleted_at IS NULL')
.andWhere('contract.status NOT IN (:...terminal)', {
terminal: TERMINAL_CONTRACT_STATUSES,
})
.andWhere('contract.contract_valid_until >= :from', {
from: new Date(now + days * 86_400_000),
})
.andWhere('contract.contract_valid_until < :to', {
to: new Date(now + (days + 1) * 86_400_000),
})
.getMany();
}
/** Find a contract by ID with all child collections, service type, company and files. */
async findByIdWithRelations(id: string): Promise<Contract | null> {
if (!id) return null;
@@ -434,7 +499,7 @@ export class ContractsRepository extends BaseRepository<Contract> {
findSignatures(contractId: string): Promise<ContractSignature[]> {
return this.dataSource.getRepository(ContractSignature).find({
where: { contractId },
relations: ['signatureFile'],
relations: ['signatureFile', 'stampFile'],
order: { signedAt: 'ASC' },
});
}
@@ -445,7 +510,7 @@ export class ContractsRepository extends BaseRepository<Contract> {
): Promise<ContractSignature | null> {
return this.dataSource.getRepository(ContractSignature).findOne({
where: { contractId, role },
relations: ['signatureFile'],
relations: ['signatureFile', 'stampFile'],
});
}

View File

@@ -1,5 +1,6 @@
import {
BadRequestException,
ConflictException,
ForbiddenException,
Injectable,
NotFoundException,
@@ -24,6 +25,7 @@ import { ContractListSummaryDto } from './dto/contract-list-summary.dto';
import { Contract, CONTRACT_STATUSES, CONTRACT_CUSTOMER_EDITABLE_STATUSES } from './entities/contract.entity';
import { ContractRoute } from './entities/contract-route.entity';
import { ContractCargoScope } from './entities/contract-cargo-scope.entity';
import { isEffectivelyExpired } from './utils/contract-expiry.util';
import { FileRecord } from '../files/entities/file.entity';
/** Paginated contract list: flat `total` (backoffice) + `meta` block (portal). */
@@ -155,6 +157,42 @@ export class ContractsService {
}
}
/**
* Same customer + same service type + an overlapping route already has a
* non-expired contract → block. A route "overlaps" if any origin/destination
* pair matches — good enough today since ONE_TIME and GENERAL contracts both
* carry a single route in practice, and still correct if that changes.
*/
private async assertNoDuplicateContract(
companyId: string,
serviceTypeId: string,
routes: CreateContractDto['routes'],
): Promise<void> {
const candidates = await this.contractsRepository.findDuplicateCandidates(
companyId,
serviceTypeId,
);
const duplicate = candidates.find(
(c) =>
!isEffectivelyExpired(c) &&
(c.routes ?? []).some((existingRoute) =>
routes.some(
(r) =>
r.originYardId === existingRoute.originYardId &&
r.destinationYardId === existingRoute.destinationYardId,
),
),
);
if (duplicate) {
const until = duplicate.contractValidUntil
? duplicate.contractValidUntil.toISOString().slice(0, 10)
: 'its approval completes';
throw new ConflictException(
`An active contract already exists for this service type and route (${duplicate.reference}, valid until ${until}). A new request can't be submitted until it expires or is rejected/cancelled.`,
);
}
}
/** Create a new contract (DRAFT) with its routes and cargo-scope rows. */
async create(
dto: CreateContractDto,
@@ -186,6 +224,9 @@ export class ContractsService {
this.assertCargoScopeShape(dto.freightType, dto.cargoScope);
this.assertRouteShape(dto.contractKind, dto.routes);
await this.assertRoutesMatchDirection(dto.tradeDirection, dto.routes);
if (companyId) {
await this.assertNoDuplicateContract(companyId, dto.serviceTypeId, dto.routes);
}
// Stamp the operational profile for portal scoping. A forwarder contract
// pins its profile explicitly (trade direction can't tell it apart from a
@@ -302,6 +343,10 @@ export class ContractsService {
lastMileDeliveryLat: dto.lastMileDeliveryLat ?? null,
lastMileDeliveryLng: dto.lastMileDeliveryLng ?? null,
isHazardous: dto.isHazardous ?? false,
// Hazard class / UN number only exist on a hazardous contract — a stale
// pair from an earlier draft must never survive the flag being turned off.
hazardClass: dto.isHazardous ? (dto.hazardClass ?? null) : null,
unNumber: dto.isHazardous ? (dto.unNumber ?? null) : null,
isReefer: dto.isReefer ?? false,
contractType: dto.contractType ?? null,
status: 'DRAFT',
@@ -474,6 +519,13 @@ export class ContractsService {
paymentCurrency: dto.paymentCurrency ?? existing.paymentCurrency,
isHazardous: dto.isHazardous ?? existing.isHazardous,
isReefer: dto.isReefer ?? existing.isReefer,
// Same rule as create: clearing the flag clears the declaration with it.
hazardClass: (dto.isHazardous ?? existing.isHazardous)
? (dto.hazardClass ?? existing.hazardClass ?? null)
: null,
unNumber: (dto.isHazardous ?? existing.isHazardous)
? (dto.unNumber ?? existing.unNumber ?? null)
: null,
equipmentReturn: dto.equipmentReturn ?? existing.equipmentReturn,
firstMilePickupAddress: dto.firstMilePickupAddress ?? existing.firstMilePickupAddress,
firstMilePickupLat: dto.firstMilePickupLat ?? existing.firstMilePickupLat,

View File

@@ -17,6 +17,8 @@ import {
ValidateNested,
} from 'class-validator';
import { HAZARD_CLASS_VALUES } from '@edr/types';
import { CONTRACT_KINDS } from '../entities/contract.entity';
const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'DOMESTIC'] as const;
@@ -228,6 +230,28 @@ export class CreateContractDto {
@Transform(({ value }) => value === 'true' || value === true)
isHazardous?: boolean;
@ApiPropertyOptional({
enum: HAZARD_CLASS_VALUES,
description: 'UN/ADR dangerous-goods class. Required when isHazardous.',
})
@ValidateIf((o: CreateContractDto) => o.isHazardous === true)
@IsIn(HAZARD_CLASS_VALUES, {
message: `hazardClass must be one of: ${HAZARD_CLASS_VALUES.join(', ')}`,
})
hazardClass?: string;
@ApiPropertyOptional({
description: 'UN number of the dangerous good. Required when isHazardous.',
})
@ValidateIf((o: CreateContractDto) => o.isHazardous === true)
@IsString()
@MinLength(1)
@MaxLength(16)
@Transform(({ value }) =>
typeof value === 'string' ? value.trim().toUpperCase() : value,
)
unNumber?: string;
@ApiPropertyOptional({ default: false, description: 'Sets contracts.is_reefer' })
@IsOptional()
@IsBoolean()

View File

@@ -17,6 +17,17 @@ export class SignContractDto {
@MinLength(20)
signatureImageBase64?: string;
@ApiPropertyOptional({
description:
'PNG company stamp/seal image as base64 (with or without data URL prefix). ' +
'Required for the CUSTOMER and STAFF roles — both parties must seal the ' +
'contract before it is fully executed.',
})
@IsOptional()
@IsString()
@MinLength(20)
stampImageBase64?: string;
@ApiProperty()
@IsString()
@MinLength(1)

View File

@@ -29,6 +29,14 @@ export class ContractSignature extends BaseEntity {
@JoinColumn({ name: 'signature_file_id' })
signatureFile?: FileRecord | null;
/** Company stamp/seal image, uploaded alongside the drawn signature. */
@Column({ name: 'stamp_file_id', type: 'uuid', nullable: true })
stampFileId?: string | null;
@ManyToOne(() => FileRecord, { nullable: true })
@JoinColumn({ name: 'stamp_file_id' })
stampFile?: FileRecord | null;
@Column({ name: 'consent_text', type: 'text', nullable: true })
consentText?: string | null;

View File

@@ -188,6 +188,14 @@ export class Contract extends BaseEntity {
@Column({ name: 'is_hazardous', type: 'boolean', default: false })
isHazardous!: boolean;
/** UN/ADR dangerous-goods class (CLASS_1..CLASS_9); null unless hazardous. */
@Column({ name: 'hazard_class', type: 'varchar', length: 16, nullable: true })
hazardClass?: string | null;
/** UN number of the dangerous good; null unless hazardous. */
@Column({ name: 'un_number', type: 'varchar', length: 16, nullable: true })
unNumber?: string | null;
@Column({ name: 'is_reefer', type: 'boolean', default: false })
isReefer!: boolean;

View File

@@ -0,0 +1,25 @@
import type { Contract } from '../entities/contract.entity';
/** Statuses that already mean "done/void" — a contract in one of these never blocks a duplicate. */
export const TERMINAL_CONTRACT_STATUSES = [
'REJECTED',
'CANCELLED',
'CONTRACT_CLOSED',
'ARCHIVED',
'EXPIRED',
] as const;
/**
* True once a contract is done, either explicitly (terminal status) or by date
* (past contractValidUntil). Checked by date too because the nightly expiry
* cron only flips the status once a day — this keeps same-day checks correct
* even a few hours before the cron runs.
*/
export function isEffectivelyExpired(
contract: Pick<Contract, 'status' | 'contractValidUntil'>,
): boolean {
if ((TERMINAL_CONTRACT_STATUSES as readonly string[]).includes(contract.status)) {
return true;
}
return Boolean(contract.contractValidUntil && contract.contractValidUntil < new Date());
}

View File

@@ -65,25 +65,4 @@ export class CreateLocomotiveDto {
@IsNumber()
@Min(0)
overageToleranceMeters?: number;
@ApiPropertyOptional({ example: 4200 })
@IsOptional()
@Transform(({ value }) => (value === '' || value == null ? undefined : Number(value)))
@IsNumber()
@Min(0)
powerKw?: number;
@ApiPropertyOptional({ example: 300 })
@IsOptional()
@Transform(({ value }) => (value === '' || value == null ? undefined : Number(value)))
@IsNumber()
@Min(0)
tractionForceKn?: number;
@ApiPropertyOptional({ example: 120 })
@IsOptional()
@Transform(({ value }) => (value === '' || value == null ? undefined : Number(value)))
@IsNumber()
@Min(0)
maxSpeedKmh?: number;
}

View File

@@ -76,15 +76,6 @@ export class Locomotive extends BaseEntity {
@JoinColumn({ name: 'current_yard_id' })
currentYard?: Yard | null;
@Column({ name: 'power_kw', type: 'numeric', precision: 10, scale: 3, nullable: true })
powerKw?: number | null;
@Column({ name: 'traction_force_kn', type: 'numeric', precision: 10, scale: 3, nullable: true })
tractionForceKn?: number | null;
@Column({ name: 'max_speed_kmh', type: 'numeric', precision: 10, scale: 3, nullable: true })
maxSpeedKmh?: number | null;
@OneToMany(() => TrainSet, (trainSet) => trainSet.locomotive)
trainSets?: TrainSet[];
}

View File

@@ -113,9 +113,6 @@ export class LocomotivesService {
maxTrainLengthMeters: dto.maxTrainLengthMeters,
overageToleranceTons: dto.overageToleranceTons ?? null,
overageToleranceMeters: dto.overageToleranceMeters ?? null,
powerKw: dto.powerKw ?? null,
tractionForceKn: dto.tractionForceKn ?? null,
maxSpeedKmh: dto.maxSpeedKmh ?? null,
});
}
@@ -176,11 +173,6 @@ export class LocomotivesService {
? locomotive.currentYardId
: (dto.currentYardId ?? null),
name: dto.name === undefined ? locomotive.name : dto.name?.trim() || null,
powerKw: dto.powerKw === undefined ? locomotive.powerKw : dto.powerKw ?? null,
tractionForceKn:
dto.tractionForceKn === undefined ? locomotive.tractionForceKn : dto.tractionForceKn ?? null,
maxSpeedKmh:
dto.maxSpeedKmh === undefined ? locomotive.maxSpeedKmh : dto.maxSpeedKmh ?? null,
});
if (!updated) {

View File

@@ -0,0 +1,80 @@
import { ConflictException } from '@nestjs/common';
import type { DataSource } from 'typeorm';
import { RoutesService } from './routes.service';
import type { RoutesRepository } from './routes.repository';
type StopSeq = Array<{ yardId: string; sequenceNo: number }>;
/** DataSource stub whose Route repository returns the given existing routes. */
const serviceWith = (
existing: Array<{ id: string; milestones: StopSeq }>,
): RoutesService => {
const dataSource = {
getRepository: () => ({ find: async () => existing }),
} as unknown as DataSource;
return new RoutesService(dataSource, {} as RoutesRepository);
};
const assertNotDuplicate = (
service: RoutesService,
yardIds: string[],
excludeRouteId?: string,
): Promise<void> =>
(
service as unknown as {
assertNotDuplicate: (
m: Array<{ yardId: string }>,
id?: string,
) => Promise<void>;
}
).assertNotDuplicate(
yardIds.map((yardId) => ({ yardId })),
excludeRouteId,
);
describe('RoutesService duplicate guard', () => {
const addisAdamaDire: StopSeq = [
{ yardId: 'addis', sequenceNo: 1 },
{ yardId: 'adama', sequenceNo: 2 },
{ yardId: 'dire', sequenceNo: 3 },
];
it('rejects an identical stop sequence', async () => {
const service = serviceWith([{ id: 'r1', milestones: addisAdamaDire }]);
await expect(
assertNotDuplicate(service, ['addis', 'adama', 'dire']),
).rejects.toBeInstanceOf(ConflictException);
});
it('allows the same endpoints with a different corridor', async () => {
// Same origin + destination, but skipping Adama is a genuinely other route.
const service = serviceWith([{ id: 'r1', milestones: addisAdamaDire }]);
await expect(
assertNotDuplicate(service, ['addis', 'dire']),
).resolves.toBeUndefined();
});
it('does not flag the route being edited against itself', async () => {
const service = serviceWith([{ id: 'r1', milestones: addisAdamaDire }]);
await expect(
assertNotDuplicate(service, ['addis', 'adama', 'dire'], 'r1'),
).resolves.toBeUndefined();
});
it('compares stops by sequence, not storage order', async () => {
const shuffled: StopSeq = [
{ yardId: 'dire', sequenceNo: 3 },
{ yardId: 'addis', sequenceNo: 1 },
{ yardId: 'adama', sequenceNo: 2 },
];
const service = serviceWith([{ id: 'r1', milestones: shuffled }]);
await expect(
assertNotDuplicate(service, ['addis', 'adama', 'dire']),
).rejects.toBeInstanceOf(ConflictException);
});
});

View File

@@ -5,7 +5,7 @@ import {
NotFoundException,
} from '@nestjs/common';
import { TrainScheduleStatus } from '@edr/types';
import { DataSource, In } from 'typeorm';
import { DataSource, In, Not } from 'typeorm';
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
import { Yard } from '../rule-engine/entities/yard.entity';
@@ -15,7 +15,7 @@ import { CreateRouteDto } from './dto/create-route.dto';
import { FilterRoutesDto } from './dto/filter-routes.dto';
import { UpdateRouteDto } from './dto/update-route.dto';
import { RouteMilestone } from './entities/route-milestone.entity';
import { formatRouteLabel, Route } from './entities/route.entity';
import { formatRouteLabel, Route, type RouteStatus } from './entities/route.entity';
import { RoutesRepository } from './routes.repository';
/** Order-insensitive key: distances are symmetric. */
@@ -88,6 +88,7 @@ export class RoutesService {
async create(dto: CreateRouteDto): Promise<Route> {
const validated = await this.validateMilestones(dto.milestones);
await this.assertNotDuplicate(validated.milestones);
const route = await this.dataSource.transaction(async (manager) => {
const savedRoute = await manager.getRepository(Route).save(
@@ -123,6 +124,11 @@ export class RoutesService {
? await this.validateMilestones(dto.milestones)
: null;
// An edit can collide with another route just as easily as a create can.
if (milestoneInput) {
await this.assertNotDuplicate(milestoneInput.milestones, id);
}
// Milestones or endpoints are about to be rewritten — reject if any
// non-terminal schedule still references this route, otherwise its stop list
// and distances would silently shift under a live plan. Status-only /
@@ -187,6 +193,51 @@ export class RoutesService {
return this.findById(id);
}
/**
* A route IS its ordered stop list — "Addis → Adama → Dire Dawa" and
* "Addis → Dire Dawa" share endpoints but are different corridors. So the
* duplicate test compares the full yard sequence, not just origin/destination.
*
* Decommissioned routes (STOP_WORKING) are ignored: replacing a retired
* corridor with a fresh one is exactly what an admin does after deactivating,
* and there is no reactivate action to fall back on.
*/
private async assertNotDuplicate(
milestones: Array<{ yardId: string }>,
excludeRouteId?: string,
): Promise<void> {
const signature = milestones.map((m) => m.yardId).join('>');
const candidates = await this.dataSource.getRepository(Route).find({
where: {
originYardId: milestones[0].yardId,
destinationYardId: milestones[milestones.length - 1].yardId,
status: Not<RouteStatus>('STOP_WORKING'),
},
relations: {
originYard: true,
destinationYard: true,
milestones: { yard: true },
},
});
const duplicate = candidates.find((route) => {
if (route.id === excludeRouteId) return false;
const stops = [...(route.milestones ?? [])]
.sort((a, b) => a.sequenceNo - b.sequenceNo)
.map((m) => m.yardId)
.join('>');
return stops === signature;
});
if (duplicate) {
throw new ConflictException(
`This route already exists: ${formatRouteLabel(duplicate)}. ` +
'Edit the existing route instead of creating a duplicate.',
);
}
}
private async validateMilestones(milestones: Array<{ yardId: string }>) {
if (milestones.length < 2) {
throw new BadRequestException('A route requires at least two yards');

View File

@@ -0,0 +1,70 @@
import { allowedRateUnits, isBulkQuantityUnit } from "./rate-unit.util";
/**
* A bulk rate's weighting unit follows how its commodity is counted: wheat is
* weighed (per ton), machinery is counted (per item). Per-wagon is offered
* either way.
*/
describe("allowedRateUnits — bulk unit of measure", () => {
it("offers per-ton for a weighed commodity", () => {
expect(
allowedRateUnits({
appliesTo: "BULK",
trigger: "ALWAYS",
cargoUnitOfMeasure: "PER_TON",
}),
).toEqual(["PER_TON", "PER_WAGON"]);
});
it("offers per-item for a counted commodity", () => {
expect(
allowedRateUnits({
appliesTo: "BULK",
trigger: "ALWAYS",
cargoUnitOfMeasure: "PER_ITEM",
}),
).toEqual(["PER_ITEM", "PER_WAGON"]);
});
it("falls back to per-ton when the rate is not scoped to a commodity", () => {
expect(allowedRateUnits({ appliesTo: "BULK", trigger: "ALWAYS" })).toEqual([
"PER_TON",
"PER_WAGON",
]);
});
it("swaps the per-ton slot for counted commodities on every bulk-capable shape", () => {
expect(
allowedRateUnits({
appliesTo: "OTHER",
trigger: "CUSTOMS_CLEARANCE",
cargoKind: "BULK",
cargoUnitOfMeasure: "PER_ITEM",
}),
).toEqual(["PER_ITEM", "PER_WAGON"]);
expect(
allowedRateUnits({
appliesTo: "INTERCITY",
trigger: "ALWAYS",
cargoUnitOfMeasure: "PER_ITEM",
}),
).toEqual(["PER_CONTAINER", "PER_ITEM", "PER_WAGON", "PER_KM"]);
});
it("never offers per-item for overweight, which is always per excess ton", () => {
expect(
allowedRateUnits({
appliesTo: "OTHER",
trigger: "OVERWEIGHT",
cargoUnitOfMeasure: "PER_TON",
}),
).toEqual(["PER_TON"]);
});
it("treats per-ton and per-item as the same booking quantity", () => {
expect(isBulkQuantityUnit("PER_TON")).toBe(true);
expect(isBulkQuantityUnit("PER_ITEM")).toBe(true);
expect(isBulkQuantityUnit("PER_WAGON")).toBe(false);
expect(isBulkQuantityUnit("FLAT")).toBe(false);
});
});

View File

@@ -1,5 +1,17 @@
import type { RateAppliesTo, RateTrigger, RateUnit } from './rate.entity';
/** How the bulk commodity a rate is scoped to is counted (cargo_types.unit_of_measure). */
export type CargoUom = 'PER_TON' | 'PER_ITEM' | null | undefined;
/**
* Units billed against a booking's bulk quantity. That quantity is recorded in
* the commodity's own unit — tonnes for a PER_TON commodity, item count for a
* PER_ITEM one — so both units scale off the same field and only differ in what
* they are called.
*/
export const isBulkQuantityUnit = (unit: string): boolean =>
unit === 'PER_TON' || unit === 'PER_ITEM';
/**
* Which rate units make sense for a given rate shape. The weighting basis is
* driven by the *type* of thing being billed — a container leg bills per
@@ -8,6 +20,10 @@ import type { RateAppliesTo, RateTrigger, RateUnit } from './rate.entity';
* ton. This keeps the rate table dynamic yet non-conflicting: the admin can
* only pick a unit the pricing engine knows how to apply.
*
* A rate scoped to a break-bulk commodity (unit_of_measure = PER_ITEM) offers
* PER_ITEM wherever a weighed commodity offers PER_TON — machinery is priced
* per unit shipped, wheat per tonne. Per-wagon is offered either way.
*
* Returned lists are ordered with the most natural/default unit first.
*/
export function allowedRateUnits(input: {
@@ -15,6 +31,19 @@ export function allowedRateUnits(input: {
trigger: RateTrigger;
/** CUSTOMS_CLEARANCE only: which cargo kind the fee covers. */
cargoKind?: 'CONTAINER' | 'BULK' | null;
/** Unit of measure of the bulk commodity the rate is scoped to, when any. */
cargoUnitOfMeasure?: CargoUom;
}): RateUnit[] {
const units = unitsForShape(input);
return input.cargoUnitOfMeasure === 'PER_ITEM'
? units.map((u) => (u === 'PER_TON' ? 'PER_ITEM' : u))
: units;
}
function unitsForShape(input: {
appliesTo: RateAppliesTo;
trigger: RateTrigger;
cargoKind?: 'CONTAINER' | 'BULK' | null;
}): RateUnit[] {
const { appliesTo, trigger } = input;
@@ -81,6 +110,7 @@ export function isRateUnitAllowed(input: {
appliesTo: RateAppliesTo;
trigger: RateTrigger;
cargoKind?: 'CONTAINER' | 'BULK' | null;
cargoUnitOfMeasure?: CargoUom;
unit: RateUnit;
}): boolean {
return allowedRateUnits(input).includes(input.unit);

View File

@@ -34,6 +34,9 @@ export type RateStatus = typeof RATE_STATUSES[number];
export const RATE_UNITS = [
'PER_WAGON',
'PER_TON',
// Break-bulk commodities are counted, not weighed (cargo_types.unit_of_measure
// = PER_ITEM) — their rates bill per item off the same booking quantity field.
'PER_ITEM',
'PER_CONTAINER',
'PER_KM',
'PER_INVOICE',

View File

@@ -2,6 +2,7 @@ import { Inject, Injectable, BadRequestException } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.entity';
import { Rate, RateTrigger } from './entities/rate.entity';
import { isBulkQuantityUnit } from './entities/rate-unit.util';
import {
ICargoTypesRepository,
CARGO_TYPES_REPOSITORY,
@@ -377,6 +378,9 @@ export class RuleEngineService {
let calculatedAmount: number;
switch (rate.rateUnit) {
// PER_ITEM is PER_TON for a counted (break-bulk) commodity — the bulk
// quantity is recorded in the commodity's own unit either way.
case 'PER_ITEM':
case 'PER_TON':
// OVERWEIGHT bills the excess tons; every other PER_TON surcharge
// (e.g. bulk reefer) bills the full bulk tonnage.
@@ -608,7 +612,7 @@ export class RuleEngineService {
if (!rate) return modifiers;
const billedQty =
rate.rateUnit === 'PER_TON'
isBulkQuantityUnit(rate.rateUnit)
? Math.max(0, Number(input.bulkTons ?? 0))
: rate.rateUnit === 'PER_WAGON'
? Math.max(0, Number(input.bulkWagons ?? 0))

View File

@@ -12,7 +12,11 @@ import { ListRatesQueryDto } from '../dto/list-rule-engine-query.dto';
import { UpdateRateDto } from '../dto/update-rate.dto';
import { Rate } from '../entities/rate.entity';
import { deriveRateType } from '../entities/rate-type.util';
import { allowedRateUnits, isRateUnitAllowed } from '../entities/rate-unit.util';
import { CargoUom, allowedRateUnits, isRateUnitAllowed } from '../entities/rate-unit.util';
import {
CARGO_TYPES_REPOSITORY,
ICargoTypesRepository,
} from '../interfaces/cargo-types.repository.interface';
import { IRatesRepository, RATES_REPOSITORY } from '../interfaces/rates.repository.interface';
import { IYardsRepository, YARDS_REPOSITORY } from '../interfaces/yards.repository.interface';
@@ -32,6 +36,8 @@ export class RatesService {
private readonly repository: IRatesRepository,
@Inject(YARDS_REPOSITORY)
private readonly yardsRepository: IYardsRepository,
@Inject(CARGO_TYPES_REPOSITORY)
private readonly cargoTypesRepository: ICargoTypesRepository,
) {}
/** List rates — standard paginated envelope with server-side search. */
@@ -63,25 +69,37 @@ export class RatesService {
* Normalise + validate the weighting unit for a rate shape. Overweight is
* always billed per excess ton, so its unit is forced to PER_TON regardless
* of what the client sent. Every other shape must pick a unit the pricing
* engine can actually apply (see `allowedRateUnits`).
* engine can actually apply (see `allowedRateUnits`) — for a rate scoped to a
* bulk commodity that means the commodity's own unit of measure: a PER_ITEM
* commodity bills per item where a weighed one bills per ton.
*/
private resolveRateUnit(
private async resolveRateUnit(
appliesTo: Rate['appliesTo'],
trigger: Rate['trigger'],
requestedUnit: Rate['rateUnit'] | undefined,
cargoKind?: 'CONTAINER' | 'BULK' | null,
): Rate['rateUnit'] {
cargoTypeId?: string | null,
): Promise<Rate['rateUnit']> {
// Overweight is per-ton, full stop — the admin form hides the unit field
// for it and omits rateUnit from the payload entirely.
if (trigger === 'OVERWEIGHT') return 'PER_TON';
const allowed = allowedRateUnits({ appliesTo, trigger, cargoKind });
const cargoUnitOfMeasure = await this.cargoUnitOfMeasure(cargoTypeId);
const allowed = allowedRateUnits({ appliesTo, trigger, cargoKind, cargoUnitOfMeasure });
if (!requestedUnit) {
throw new BadRequestException(
`Pick a rate unit for this rate. Allowed: ${allowed.join(', ')}.`,
);
}
if (!isRateUnitAllowed({ appliesTo, trigger, cargoKind, unit: requestedUnit })) {
if (
!isRateUnitAllowed({
appliesTo,
trigger,
cargoKind,
cargoUnitOfMeasure,
unit: requestedUnit,
})
) {
throw new BadRequestException(
`Rate unit "${requestedUnit}" is not valid for this rate. Allowed: ${allowed.join(', ')}.`,
);
@@ -89,6 +107,13 @@ export class RatesService {
return requestedUnit;
}
/** Unit of measure of the bulk commodity a rate is scoped to; null when unscoped. */
private async cargoUnitOfMeasure(cargoTypeId?: string | null): Promise<CargoUom> {
if (!cargoTypeId) return null;
const cargo = await this.cargoTypesRepository.findById(cargoTypeId);
return cargo?.unitOfMeasure ?? null;
}
/** Base rail freight is priced per leg; surcharges and truck legs are not. */
private isBaseFreight(appliesTo: Rate['appliesTo'], trigger: Rate['trigger']): boolean {
return trigger === 'ALWAYS' && BASE_FREIGHT_CATEGORIES.includes(appliesTo);
@@ -380,11 +405,12 @@ export class RatesService {
tradeDirection,
isBulk: this.resolvesToBulk(appliesTo, intercityKind),
});
const rateUnit = this.resolveRateUnit(
const rateUnit = await this.resolveRateUnit(
appliesTo,
trigger,
dto.rateUnit as Rate['rateUnit'] | undefined,
cargoKind,
cargoTypeId,
);
await this.assertNoDuplicatePattern({
@@ -562,12 +588,19 @@ export class RatesService {
// Re-validate the unit against the (possibly changed) shape; overweight is
// forced to PER_TON.
const requestedUnit = (dto.rateUnit as Rate['rateUnit']) ?? existing.rateUnit;
updates.rateUnit = this.resolveRateUnit(appliesTo, trigger, requestedUnit, cargoKind);
const rateUnit = await this.resolveRateUnit(
appliesTo,
trigger,
requestedUnit,
cargoKind,
updates.cargoTypeId,
);
updates.rateUnit = rateUnit;
// Guard the pattern uniqueness for the new identity, ignoring this row.
await this.assertNoDuplicatePattern({
rateType,
rateUnit: updates.rateUnit,
rateUnit,
containerTypeId: updates.containerTypeId,
cargoTypeId: updates.cargoTypeId,
tradeDirection: updates.tradeDirection,

View File

@@ -3000,6 +3000,22 @@ export class BookingBatchService implements OnModuleInit {
}
}
/**
* How many bookings on this route-day would be expired if document review
* ended right now — i.e. requests staff have neither accepted nor rejected.
* Same query the doc-review-end sweep runs, so the number staff see is
* exactly what is at risk.
*/
async countUnacceptedForRouteDay(group: RouteDayGroup): Promise<number> {
const corridorYards = await this.corridorYardsForRouteDay(group);
if (corridorYards.length === 0) return 0;
const unaccepted = await this.bookingsRepository.findUnacceptedForRouteDay(
corridorYards,
group.day,
);
return unaccepted.length;
}
/**
* Free capacity for a government booking by displacing the lowest-priority commercial
* bookings (reserved first, then allocated — including PAID). Displaced → EXPIRED + notified.

View File

@@ -22,6 +22,7 @@ describe('BookingWindowService — window state machine', () => {
expireLeftoverDayPool: jest.Mock;
expireLeftoverExportDay: jest.Mock;
fillFromWaitingList: jest.Mock;
countUnacceptedForRouteDay: jest.Mock;
};
let trainSchedulesRepository: { findById: jest.Mock; findAll: jest.Mock };
let trainSchedulingService: { finalizeSchedule: jest.Mock; getWindowConfig: jest.Mock };
@@ -79,6 +80,7 @@ describe('BookingWindowService — window state machine', () => {
expireLeftoverExportDay: jest.fn().mockResolvedValue(undefined),
// No waiting booking fits by default, so conclude proceeds to reopen/DONE.
fillFromWaitingList: jest.fn().mockResolvedValue(0),
countUnacceptedForRouteDay: jest.fn().mockResolvedValue(0),
};
trainSchedulesRepository = {
findById: jest.fn().mockResolvedValue(null),
@@ -267,4 +269,87 @@ describe('BookingWindowService — window state machine', () => {
expect(s.windowPhase).toBe('OPEN');
expect(batch.setWindow).not.toHaveBeenCalled();
});
// ---- header alarm ---------------------------------------------------------
describe('getDocReviewAlert', () => {
const reviewing = (over: Partial<TrainSchedule>): TrainSchedule =>
baseSchedule({
windowPhase: 'DOC_REVIEW',
docReviewEndsAt: new Date('2026-07-01T01:30:00.000Z'),
...over,
});
it('returns null when nothing is under document review', async () => {
trainSchedulesRepository.findAll.mockResolvedValue([
baseSchedule({ windowPhase: 'OPEN' }),
]);
expect(await service.getDocReviewAlert()).toBeNull();
});
it('returns null when every request on the route-day is decided', async () => {
trainSchedulesRepository.findAll.mockResolvedValue([reviewing({})]);
batch.countUnacceptedForRouteDay.mockResolvedValue(0);
expect(await service.getDocReviewAlert()).toBeNull();
});
it('reports the deadline, its own pending count and the phase length', async () => {
trainSchedulesRepository.findAll.mockResolvedValue([reviewing({})]);
batch.countUnacceptedForRouteDay.mockResolvedValue(3);
const alert = await service.getDocReviewAlert();
expect(alert).toMatchObject({
scheduleId,
originYardId: 'yard-o',
destinationYardId: 'yard-d',
tradeDirection: 'IMPORT',
pendingCount: 3,
docReviewMinutes: 30,
docReviewEndsAt: '2026-07-01T01:30:00.000Z',
});
});
it('skips the nearest deadline when it has nothing pending', async () => {
trainSchedulesRepository.findAll.mockResolvedValue([
reviewing({
id: 'sched-later',
destinationStationId: 'yard-far',
docReviewEndsAt: new Date('2026-07-01T02:00:00.000Z'),
}),
reviewing({ id: 'sched-soon' }),
]);
// Nearest (sched-soon, yard-d) is clear; the later route-day still isn't.
batch.countUnacceptedForRouteDay.mockImplementation(
async (g: { destinationYardId: string }) =>
g.destinationYardId === 'yard-far' ? 2 : 0,
);
const alert = await service.getDocReviewAlert();
expect(alert?.scheduleId).toBe('sched-later');
expect(alert?.pendingCount).toBe(2);
});
it('counts a route-day once when sibling trains share the review phase', async () => {
trainSchedulesRepository.findAll.mockResolvedValue([
reviewing({ id: 'sched-a' }),
reviewing({ id: 'sched-b' }),
]);
batch.countUnacceptedForRouteDay.mockResolvedValue(4);
const alert = await service.getDocReviewAlert();
expect(alert?.pendingCount).toBe(4);
expect(batch.countUnacceptedForRouteDay).toHaveBeenCalledTimes(1);
});
it('ignores a phase staff already completed early', async () => {
trainSchedulesRepository.findAll.mockResolvedValue([
reviewing({ docReviewCompletedAt: new Date('2026-07-01T01:10:00.000Z') }),
]);
batch.countUnacceptedForRouteDay.mockResolvedValue(5);
expect(await service.getDocReviewAlert()).toBeNull();
});
});
});

View File

@@ -30,6 +30,28 @@ import {
} from './batch-window.util';
import { type BookingWindowConfig } from './booking-window.config';
/**
* The most urgent document-review deadline that still has un-accepted booking
* requests behind it. Backoffice counts down to it and warns staff, because
* everything still pending when the phase ends is expired automatically.
*/
export interface DocReviewAlert {
/** A schedule of the route-day group under review (deep-link target). */
scheduleId: string;
originYardId: string;
destinationYardId: string;
/** EAT booking day of the group, YYYY-MM-DD. */
day: string;
/** IMPORT (the usual) or DOMESTIC — both run a review phase; export does not. */
tradeDirection: string;
/** ISO deadline the review phase ends at. */
docReviewEndsAt: string;
/** Full length of the review phase — the client warns past its halfway mark. */
docReviewMinutes: number;
/** Requests neither accepted nor rejected — they expire at the deadline. */
pendingCount: number;
}
/**
* Drives the one-booking-day window cycle for IMPORT schedules and the FCFS
* booking window for EXPORT schedules. All state lives in DB timestamps on the
@@ -130,6 +152,65 @@ export class BookingWindowService implements OnModuleInit {
}
}
/**
* The route-day currently in document review whose deadline is nearest and
* which still has un-accepted requests. Null when nothing is under review or
* every request has been decided — the backoffice header shows nothing then.
*
* One card, one deadline, one count: route-days are checked in deadline order
* and the first with pending work wins, so the number always belongs to the
* clock beside it.
*/
async getDocReviewAlert(): Promise<DocReviewAlert | null> {
const reviewing = (
await this.trainSchedulesRepository.findAll({
where: [
{ status: TrainScheduleStatusEnum.Draft },
{ status: TrainScheduleStatusEnum.Scheduled },
],
})
)
.filter(
(s) =>
s.windowPhase === 'DOC_REVIEW' &&
s.docReviewCompletedAt == null &&
s.docReviewEndsAt != null &&
s.scheduledDepartureDate != null,
)
.sort((a, b) => a.docReviewEndsAt!.getTime() - b.docReviewEndsAt!.getTime());
if (reviewing.length === 0) return null;
const liveCfg = await this.trainSchedulingService.getWindowConfig();
const seen = new Set<string>();
for (const schedule of reviewing) {
const group = {
originYardId: schedule.originStationId,
destinationYardId: schedule.destinationStationId,
day: eatDay(schedule.scheduledDepartureDate),
};
// Sibling trains share one review phase for the route-day pool — count it once.
const key = `${group.originYardId}|${group.destinationYardId}|${group.day}`;
if (seen.has(key)) continue;
seen.add(key);
const pendingCount =
await this.bookingBatchService.countUnacceptedForRouteDay(group);
if (pendingCount === 0) continue;
return {
scheduleId: schedule.id,
...group,
// Carried so the backoffice list opens on the same direction the
// at-risk requests belong to (import corridor, or a domestic day).
tradeDirection: schedule.direction ?? 'IMPORT',
docReviewEndsAt: schedule.docReviewEndsAt!.toISOString(),
docReviewMinutes: effectiveWindowConfig(schedule, liveCfg).docReviewMinutes,
pendingCount,
};
}
return null;
}
/** Staff finished document review early — start the batch/payment phase now. */
async completeDocReview(scheduleId: string): Promise<TrainSchedule> {
const schedule = await this.trainSchedulesRepository.findById(scheduleId);

View File

@@ -8,6 +8,7 @@ import {
} from "@nestjs/common";
import { CurrentUser } from "@edr/api-common";
import {
BookingDocReviewAlert,
TrainSchedulingCancel,
TrainSchedulingCreate,
TrainSchedulingReschedule,
@@ -747,6 +748,18 @@ export class TrainSchedulingController {
return this.trainSchedulingService.getContainerTrainScheduleById(id);
}
@Get("doc-review-alert")
// Dedicated permission, not scheduling or bookings:view — the alarm is meant
// for the position types that actually decide operation requests.
@BookingDocReviewAlert()
@ApiOperation({
summary:
"Nearest document-review deadline that still has un-accepted booking requests behind it (null when there is none) — drives the backoffice header countdown",
})
async getDocReviewAlert() {
return this.bookingWindowService.getDocReviewAlert();
}
@Post("schedules/:id/doc-review-complete")
@TrainSchedulingUpdate()
@ApiOperation({

View File

@@ -6659,6 +6659,8 @@ export class TrainSchedulingService {
: slot.physicalWagonId ?? null;
if (physicalId) coveredPhysicalIds.add(physicalId);
}
// Fallback only — real empty rows below carry the wagon's OWN physical
// sequenceNumber, not an invented tail position (see emptyConsistWagons).
const maxSlotSequenceNo = Math.max(
0,
...(schedule.trainSet?.wagons ?? []).map((w) => w.sequenceNo),
@@ -6669,7 +6671,11 @@ export class TrainSchedulingService {
// Physical wagon id — there is no TrainSetWagon slot behind this
// row, so remove/edit affordances must stay disabled (consistOnly).
id: wagon.id,
sequenceNo: maxSlotSequenceNo + index + 1,
// The wagon's REAL coupling position, so an empty wagon in the middle
// of the train draws in the middle — not appended after every loaded
// slot. Falls back to a tail position only if the wagon somehow has
// no sequence number of its own.
sequenceNo: wagon.sequenceNumber ?? maxSlotSequenceNo + index + 1,
capacityTons: roundTons(Number(wagon.wagonType?.capacityTons ?? 0)),
lengthMeters: roundTons(Number(wagon.wagonType?.lengthMeters ?? 0)),
assignedWeightTons: 0,
@@ -6779,8 +6785,7 @@ export class TrainSchedulingService {
maxPullWeightTons: roundTons(Number(loco.maxPullWeightTons)),
maxTrainLengthMeters: roundTons(Number(loco.maxTrainLengthMeters)),
})),
wagons: [...(schedule.trainSet.wagons ?? [])]
.sort((a, b) => a.sequenceNo - b.sequenceNo)
wagons: (schedule.trainSet.wagons ?? [])
.map((wagon) => {
// Frozen schedules read the wagon number + allocations from the
// snapshot slot; the immutable slot geometry (capacity/type) still
@@ -6788,9 +6793,20 @@ export class TrainSchedulingService {
const frozenSlot = isWagonAllocationFrozen
? snapshotSlotByTrainSetWagonId.get(wagon.id)
: undefined;
// Draw the slot at its physical wagon's REAL coupling position,
// not the planning-time slot index — the two diverge once a
// load has been dragged onto a different wagon (moveWagonLoad
// repoints physicalWagonId but a slot keeps its own sequenceNo),
// or once wagon types were interleaved at pinning time. Frozen
// and not-yet-pinned slots have no live physical wagon to trust,
// so they keep their own slot sequence.
const sequenceNo =
frozenSlot || !wagon.physicalWagon
? wagon.sequenceNo
: (wagon.physicalWagon.sequenceNumber ?? wagon.sequenceNo);
return {
id: wagon.id,
sequenceNo: wagon.sequenceNo,
sequenceNo,
capacityTons: roundTons(Number(wagon.capacityTons)),
lengthMeters: roundTons(Number(wagon.lengthMeters)),
assignedWeightTons: roundTons(Number(wagon.assignedWeightTons)),
@@ -6859,7 +6875,12 @@ export class TrainSchedulingService {
})) ?? [],
};
})
.concat(emptyConsistWagons),
.concat(emptyConsistWagons)
.sort((a, b) =>
schedule.reverseWagonOrder
? b.sequenceNo - a.sequenceNo
: a.sequenceNo - b.sequenceNo,
),
}
: null,
bookings:

View File

@@ -548,6 +548,26 @@ export class TrainBuilderService {
return rows.length > 0;
}
/** Batched form of {@link isWagonPinnedToLiveSchedule} for a whole consist. */
private async isAnyWagonPinnedToLiveSchedule(
manager: EntityManager,
wagonIds: string[],
): Promise<boolean> {
if (!wagonIds.length) return false;
const rows: { exists: boolean }[] = await manager.query(
`SELECT TRUE AS exists
FROM freight.train_set_wagons tsw
JOIN freight.train_schedules ts ON ts.train_set_id = tsw.train_set_id
WHERE tsw.physical_wagon_id = ANY($1::uuid[])
AND ts.status IN ('DRAFT', 'SCHEDULED', 'DISPATCHED')
AND ts.deleted_at IS NULL
AND tsw.deleted_at IS NULL
LIMIT 1`,
[wagonIds],
);
return rows.length > 0;
}
/** Persist a drag-reorder: `wagonIds` is the full consist in its new order. */
async reorderWagons(id: string, dto: ReorderTrainWagonsDto) {
await this.dataSource.transaction(async (manager) => {
@@ -560,6 +580,17 @@ export class TrainBuilderService {
if (current.size !== incoming.size || [...current].some((wid) => !incoming.has(wid))) {
throw new BadRequestException('Reorder must include every wagon of the train exactly once');
}
// A live schedule (DRAFT/SCHEDULED/DISPATCHED) reads each wagon's slot at
// its OWN frozen sequenceNo, never the wagon's live sequenceNumber — so
// renumbering here would silently desync that schedule's drawn consist
// from the built train's real order (loaded slots keep the old order,
// empty ones show the new one). Same guard as remove/maintenance.
if (await this.isAnyWagonPinnedToLiveSchedule(manager, [...current])) {
throw new ConflictException(
'This train has wagons pinned to an active schedule and cannot be reordered — ' +
"it would desync the schedule's consist view from the built train's real order.",
);
}
for (let i = 0; i < dto.wagonIds.length; i++) {
await manager.getRepository(Wagon).update(dto.wagonIds[i], { sequenceNumber: i + 1 });
}

View File

@@ -1,7 +0,0 @@
import { IsArray, IsUUID } from 'class-validator';
export class ReorderWagonsDto {
@IsArray()
@IsUUID(4, { each: true })
wagonIds!: string[];
}

View File

@@ -12,13 +12,12 @@ import {
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { CurrentUser } from '@edr/api-common';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import { FleetManage, FleetView, StaffReference } from '../../common/booking-guards';
import { FleetManage, StaffReference } from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { CreateWagonDto } from './dto/create-wagon.dto';
import { ListWagonsQueryDto } from './dto/list-wagons-query.dto';
import { UpdateWagonDto } from './dto/update-wagon.dto';
import { AssignWagonToTrainDto } from './dto/assign-wagon-to-train.dto';
import { ReorderWagonsDto } from './dto/reorder-wagons.dto';
import { BulkTransferWagonsDto } from './dto/bulk-transfer-wagons.dto';
import { BulkSetWagonStatusDto } from './dto/bulk-set-wagon-status.dto';
import { WagonsService } from './wagons.service';
@@ -103,17 +102,3 @@ export class WagonsController {
return this.wagonsService.bulkSetStatus(dto);
}
}
// Separate controller for trainspecific reorder (registered in module)
@Controller('trains/:trainId/reorder-wagons')
@FleetView(FREIGHT_PERMS.trains.view)
export class TrainWagonsReorderController {
constructor(private readonly wagonsService: WagonsService) {}
@Post()
@FleetManage(FREIGHT_PERMS.trains.assignWagons)
@ApiOperation({ summary: 'Reorder wagons of a train' })
reorder(@Param('trainId', ParseUUIDPipe) trainId: string, @Body() dto: ReorderWagonsDto) {
return this.wagonsService.reorderWagons(trainId, dto);
}
}

View File

@@ -5,7 +5,7 @@ import { WagonMovement } from './entities/wagon-movement.entity';
import { WagonTransferRequest } from './entities/wagon-transfer-request.entity';
import { Train } from '../trains/entities/train.entity';
import { Yard } from '../rule-engine/entities/yard.entity';
import { WagonsController, TrainWagonsReorderController } from './wagons.controller';
import { WagonsController } from './wagons.controller';
import { WagonTransferRequestsController } from './wagon-transfer-requests.controller';
import { WagonsService } from './wagons.service';
import { WagonTransferRequestsService } from './wagon-transfer-requests.service';
@@ -22,7 +22,6 @@ import { WagonTransferRequestsService } from './wagon-transfer-requests.service'
],
controllers: [
WagonsController,
TrainWagonsReorderController,
WagonTransferRequestsController,
],
providers: [WagonsService, WagonTransferRequestsService],

View File

@@ -11,7 +11,6 @@ import { CreateWagonDto } from './dto/create-wagon.dto';
import { ListWagonsQueryDto } from './dto/list-wagons-query.dto';
import { UpdateWagonDto } from './dto/update-wagon.dto';
import { AssignWagonToTrainDto } from './dto/assign-wagon-to-train.dto';
import { ReorderWagonsDto } from './dto/reorder-wagons.dto';
import { BulkTransferWagonsDto } from './dto/bulk-transfer-wagons.dto';
import { BulkSetWagonStatusDto } from './dto/bulk-set-wagon-status.dto';
import { Wagon } from './entities/wagon.entity';
@@ -392,20 +391,4 @@ export class WagonsService {
}
}
async reorderWagons(_trainId: string, dto: ReorderWagonsDto): Promise<void> {
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
for (let i = 0; i < dto.wagonIds.length; i++) {
await queryRunner.manager.update(Wagon, dto.wagonIds[i], { sequenceNumber: i + 1 });
}
await queryRunner.commitTransaction();
} catch (err) {
await queryRunner.rollbackTransaction();
throw err;
} finally {
await queryRunner.release();
}
}
}

View File

@@ -411,7 +411,7 @@ The governing law shall be the laws of the Federal Democratic Republic of Ethiop
a(
"effectiveness",
"Contract Effectiveness",
`The contract shall come into full force and effect on the date when the contract is signed by the parties and witnesses.`,
`The contract shall come into full force and effect on the date when the contract is signed by both parties.`,
),
],
};
@@ -550,7 +550,7 @@ Notwithstanding the above, the Service Provider may revise transport tariffs due
a(
"effectiveness",
"Contract Effectiveness",
`The contract is valid once signed by both parties and witnesses.`,
`The contract is valid once signed by both parties.`,
),
a(
"duration",
@@ -698,7 +698,7 @@ If terminated for cause, the terminating party must issue a 15-day written notic
a(
"effectiveness",
"Contract Effectiveness",
`The contract is valid once signed by both parties and witnesses.`,
`The contract is valid once signed by both parties.`,
),
a(
"duration",
@@ -838,7 +838,7 @@ Notwithstanding the above, the Service Provider may revise transport tariffs due
a(
"effectiveness",
"Contract Effectiveness",
`The contract is valid once signed by both parties and witnesses.`,
`The contract is valid once signed by both parties.`,
),
a(
"duration",

View File

@@ -64,6 +64,9 @@ export const BOOKING_PERMISSIONS: FreightPermissionSeed[] = [
perm('a1000001-0001-4000-8000-000000000012', 'edr_freight_app:fleet:manage', 'Manage fleet'),
perm('a1000001-0001-4000-8000-000000000013', 'edr_freight_app:admin', 'Freight administration'),
perm('a1000001-0001-4000-8000-000000000024', 'edr_freight_app:bookings:create', 'Create booking'),
// Header alarm for the document-review deadline: its own key so only the
// position types that actually decide operation requests are alerted.
perm('a1000001-0001-4000-8000-000000000025', 'edr_freight_app:bookings:doc_review_alert', 'See document-review deadline alarm'),
];
/**
@@ -96,6 +99,10 @@ export const CONTRACT_PERMISSIONS: FreightPermissionSeed[] = [
perm('a3000001-0001-4000-8000-00000000000e', 'edr_freight_app:contracts:clearance_et_actions', 'GL Ethiopia phased clearance actions'),
perm('a3000001-0001-4000-8000-00000000000f', 'edr_freight_app:contracts:clearance_dj_actions', 'GL Djibouti phased clearance actions'),
perm('a3000001-0001-4000-8000-000000000010', 'edr_freight_app:contracts:clearance_duty_advise', 'Advise contract duty/tax'),
// Hazardous contracts get two extra approval steps ahead of the normal chain.
// Each has its own permission so the two desks are genuinely separate people.
perm('a3000001-0001-4000-8000-000000000019', 'edr_freight_app:contracts:hazardous_approval_one', 'Hazardous approval — first review'),
perm('a3000001-0001-4000-8000-00000000001a', 'edr_freight_app:contracts:hazardous_approval_two', 'Hazardous approval — second review'),
];
// Existing per-slug view ids are kept as-is: position-type grants reference
@@ -401,6 +408,7 @@ export const FREIGHT_PERMS = {
reviewDocuments: 'edr_freight_app:bookings:review_documents',
uploadClearanceOutput: 'edr_freight_app:bookings:upload_clearance_output',
finalizeClearance: 'edr_freight_app:bookings:finalize_clearance',
docReviewAlert: 'edr_freight_app:bookings:doc_review_alert',
},
contracts: {
view: 'edr_freight_app:contracts:view',
@@ -419,6 +427,8 @@ export const FREIGHT_PERMS = {
approveLineStaff: 'edr_freight_app:contracts:approve_line_staff',
approveDirector: 'edr_freight_app:contracts:approve_director',
approveCeo: 'edr_freight_app:contracts:approve_ceo',
hazardousApprovalOne: 'edr_freight_app:contracts:hazardous_approval_one',
hazardousApprovalTwo: 'edr_freight_app:contracts:hazardous_approval_two',
generateContract: 'edr_freight_app:contracts:generate_contract',
signStaff: {
bulk: 'edr_freight_app:contracts:sign_staff:bulk',
@@ -781,6 +791,9 @@ export const ROLE_PERMISSION_PRESETS = {
operationsOfficer: [
FREIGHT_PERMS.bookings.view,
FREIGHT_PERMS.bookings.operations,
// They are the ones who accept/reject operation requests, so they are the
// ones the doc-review countdown is for.
FREIGHT_PERMS.bookings.docReviewAlert,
FREIGHT_PERMS.trainScheduling.view,
FREIGHT_PERMS.trainScheduling.create,
FREIGHT_PERMS.trainScheduling.update,

View File

@@ -0,0 +1,132 @@
import { Alert, Button, Group, Paper, Stack, Text } from "@mantine/core";
import { DateInput } from "@mantine/dates";
import { AlertTriangle, Send } from "lucide-react";
import { useState } from "react";
import { Link } from "react-router-dom";
import toast from "react-hot-toast";
import { bookingsService } from "@/services/bookings.service";
export interface BookingChangesRequestedAlertProps {
bookingId: string;
reference?: string | null;
/** Operations' note — what has to change before this can go back to them. */
note?: string | null;
/** Shipment day the booking currently holds; the resubmit default. */
scheduledDate?: string | null;
/** GL Ethiopia owns customs bookings, so only they get the resubmit control. */
canResubmit: boolean;
onResubmitted?: () => void;
}
/**
* Operations sent a GL-created booking back for changes.
*
* The customer cannot act on this — GL created the booking on their behalf — so
* the note and the way out both live here, on the page GL works from. Resubmit
* re-requests operation on the chosen shipment day; the server re-checks the day
* has a departure that can carry the cargo and refuses with the reason if not.
*/
export function BookingChangesRequestedAlert({
bookingId,
reference,
note,
scheduledDate,
canResubmit,
onResubmitted,
}: BookingChangesRequestedAlertProps) {
const [day, setDay] = useState<Date | null>(
scheduledDate ? new Date(scheduledDate) : null,
);
const [sending, setSending] = useState(false);
const resubmit = async () => {
if (!day) return;
setSending(true);
try {
await bookingsService.proceedToOperation(bookingId, day.toISOString());
toast.success("Sent back to Operations for review");
onResubmitted?.();
} catch {
// The http interceptor already toasts the server's own reason (no
// departure that day, no wagon that can carry the cargo, export train
// full…) — a second toast here would just duplicate it.
} finally {
setSending(false);
}
};
return (
<Alert
color="red"
radius="md"
icon={<AlertTriangle size={16} />}
title={`Operations returned booking ${reference ?? ""} for changes`.trim()}
>
<Stack gap="sm" align="flex-start">
{note ? (
<Paper
withBorder
radius="md"
p="sm"
bg="red.0"
style={{ borderColor: "var(--mantine-color-red-3)", width: "100%" }}
>
<Text size="xs" fw={700} c="red.9" tt="uppercase" mb={4}>
What Operations asked for
</Text>
<Text size="sm" style={{ whiteSpace: "pre-wrap" }}>
{note}
</Text>
</Paper>
) : (
<Text size="sm">
Operations returned this booking without a note contact them for
the detail before resubmitting.
</Text>
)}
<Text size="sm">
This booking was created by GL Ethiopia, so the customer cannot fix it.
Make the correction Operations asked for, then send it back for review.{" "}
<Text
component={Link}
to={`/dashboard/bookings/${bookingId}/clearance`}
inherit
fw={600}
c="red.8"
>
Open the booking
</Text>
</Text>
{canResubmit ? (
<Group gap="sm" align="flex-end" wrap="wrap">
<DateInput
label="Shipment day"
description="Keep the day or pick another with an open departure"
value={day}
onChange={(v) => setDay(v ? new Date(v) : null)}
minDate={new Date()}
size="sm"
w={230}
/>
<Button
color="red"
radius="md"
size="sm"
loading={sending}
disabled={!day}
leftSection={<Send size={15} />}
onClick={() => void resubmit()}
>
Resubmit to Operations
</Button>
</Group>
) : null}
</Stack>
</Alert>
);
}
export default BookingChangesRequestedAlert;

View File

@@ -1,5 +1,5 @@
import { useMemo, useState } from "react";
import { Check, ShieldCheck, X } from "lucide-react";
import { Check, Flame, ShieldCheck, X } from "lucide-react";
import {
Stack,
Group,
@@ -14,10 +14,22 @@ import {
import type { Freight } from "@edr/types";
import { formatContractApprovalProgress } from "@/features/contracts/contract-approval-progress";
import { HazardDeclarationPanel } from "./HazardDeclarationPanel";
import { SectionCard } from "@/components/bookings/detail/SectionCard";
import type { useContractMutations } from "@/hooks/contracts/useContracts";
import { useAuth } from "@/auth/useAuth";
import { canApproveContractStep } from "@/lib/permissions";
import {
canApproveContractStep,
CONTRACT_APPROVAL_ROLE_LABELS,
HAZARDOUS_APPROVAL_ROLE_PERMISSION,
} from "@/lib/permissions";
/** Chain roles that exist only because the contract carries dangerous goods. */
const isHazardStep = (requiredRole: string): boolean =>
requiredRole in HAZARDOUS_APPROVAL_ROLE_PERMISSION;
const roleLabel = (requiredRole: string): string =>
CONTRACT_APPROVAL_ROLE_LABELS[requiredRole] ?? requiredRole;
type Mutations = ReturnType<typeof useContractMutations>;
@@ -126,7 +138,7 @@ export function ContractApprovalStepsCard({
const subtitle =
summary.detail ||
(nextPending
? `Next: ${nextPending.requiredRole} · step ${nextPending.stepOrder}`
? `Next: ${roleLabel(nextPending.requiredRole)} · step ${nextPending.stepOrder}`
: steps.length
? "All steps complete"
: "Accept submission to begin");
@@ -196,7 +208,7 @@ export function ContractApprovalStepsCard({
<Text size="sm" c="dimmed">
You are about to approve the{" "}
<Text span fw={600} c="dark">
{pendingStep?.requiredRole}
{roleLabel(pendingStep?.requiredRole ?? "")}
</Text>{" "}
step for contract{" "}
<Text span fw={600} c="dark">
@@ -204,6 +216,9 @@ export function ContractApprovalStepsCard({
</Text>
. This action cannot be undone from this screen.
</Text>
{pendingStep && isHazardStep(pendingStep.requiredRole) && (
<HazardDeclarationPanel contract={contract} />
)}
<Group justify="flex-end" gap="sm">
<Button variant="default" radius="md" onClick={closeApprove}>
Cancel
@@ -241,7 +256,7 @@ export function ContractApprovalStepsCard({
{ value: "CUSTOMER", label: "Customer — must resubmit" },
...returnableSteps.map((s) => ({
value: s.id,
label: `${s.requiredRole} — step ${s.stepOrder} re-approves`,
label: `${roleLabel(s.requiredRole)} — step ${s.stepOrder} re-approves`,
})),
]}
/>
@@ -254,7 +269,7 @@ export function ContractApprovalStepsCard({
</Text>{" "}
will go back to the{" "}
<Text span fw={600} c="dark">
{targetStep?.requiredRole}
{roleLabel(targetStep?.requiredRole ?? "")}
</Text>{" "}
step. That approver fixes the contract and approves again, and
every later step re-approves in order. The customer is not
@@ -264,7 +279,7 @@ export function ContractApprovalStepsCard({
<Text size="sm" c="dimmed">
Rejecting the{" "}
<Text span fw={600} c="dark">
{rejectStepRow?.requiredRole}
{roleLabel(rejectStepRow?.requiredRole ?? "")}
</Text>{" "}
step rejects contract{" "}
<Text span fw={600} c="dark">
@@ -300,7 +315,7 @@ export function ContractApprovalStepsCard({
onClick={runReject}
>
{sendBack
? `Send back to ${targetStep?.requiredRole ?? "step"}`
? `Send back to ${targetStep ? roleLabel(targetStep.requiredRole) : "step"}`
: "Reject contract"}
</Button>
</Group>
@@ -333,6 +348,7 @@ function StepRow({
: isNext
? "edr-green"
: "gray";
const hazard = isHazardStep(step.requiredRole);
return (
<Group
@@ -343,11 +359,19 @@ function StepRow({
py="xs"
style={{
borderRadius: 8,
border: "1px solid var(--mantine-color-gray-2)",
borderLeft: isNext
? "3px solid var(--freight-brand)"
border: hazard
? "1px solid #F3D5D0"
: "1px solid var(--mantine-color-gray-2)",
background: isNext ? "var(--mantine-color-gray-0)" : "white",
borderLeft: isNext
? `3px solid ${hazard ? "#C0392B" : "var(--freight-brand)"}`
: hazard
? "1px solid #F3D5D0"
: "1px solid var(--mantine-color-gray-2)",
background: hazard
? "#FEF7F6"
: isNext
? "var(--mantine-color-gray-0)"
: "white",
}}
>
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
@@ -371,9 +395,23 @@ function StepRow({
{step.stepOrder}
</Box>
<Box style={{ minWidth: 0 }}>
<Text size="sm" fw={600}>
{step.requiredRole}
</Text>
<Group gap={6} wrap="nowrap" align="center">
<Text size="sm" fw={600} truncate>
{roleLabel(step.requiredRole)}
</Text>
{hazard && (
<Badge
color="red"
variant="light"
size="xs"
radius="sm"
leftSection={<Flame size={10} />}
style={{ flexShrink: 0 }}
>
Hazmat
</Badge>
)}
</Group>
{step.note && (
<Text size="xs" c="dimmed" truncate>
{step.note}

View File

@@ -9,7 +9,7 @@ import {
Group,
Loader,
Modal,
Select,
// Select, // ponytail: unused now the validity dropdown below is commented out
Stack,
Text,
Textarea,
@@ -25,6 +25,7 @@ import {
Plus,
Trash2,
} from "lucide-react";
import { DateInput } from "@mantine/dates";
import type { Freight } from "@edr/types";
import { contractsService } from "@/services/contracts.service";
@@ -75,8 +76,10 @@ export function ContractDocumentEditorModal({
onClose,
contractId,
mode,
validityOptions = [],
validityLoading = false,
// ponytail: validityOptions/validityLoading fed the now-commented dropdown
// above — caller still passes them, left unread here for a quick revert.
// validityOptions = [],
// validityLoading = false,
accepting = false,
saving = false,
onAccept,
@@ -93,7 +96,12 @@ export function ContractDocumentEditorModal({
const [documentTitle, setDocumentTitle] = useState("");
const [whereasClauses, setWhereasClauses] = useState<string[]>([]);
const [articles, setArticles] = useState<EditableArticle[]>([]);
const [validityDays, setValidityDays] = useState<string | null>(null);
// const [validityDays, setValidityDays] = useState<string | null>(null);
// ponytail: client keeps flip-flopping the validity requirement — swapped
// the validity dropdown for explicit start/end dates, kept above commented
// instead of deleted so it's a one-line revert if they flip back.
const [validityStart, setValidityStart] = useState<Date | null>(null);
const [validityEnd, setValidityEnd] = useState<Date | null>(null);
// Seed the editor from the loaded draft whenever the dialog (re)opens.
useEffect(() => {
@@ -110,11 +118,11 @@ export function ContractDocumentEditorModal({
}, [opened, draft]);
// Default validity to the first configured option (accept mode).
useEffect(() => {
if (mode === "accept" && !validityDays && validityOptions.length > 0) {
setValidityDays(validityOptions[0].value);
}
}, [mode, validityDays, validityOptions]);
// useEffect(() => {
// if (mode === "accept" && !validityDays && validityOptions.length > 0) {
// setValidityDays(validityOptions[0].value);
// }
// }, [mode, validityDays, validityOptions]);
// Editing rights belong to the approver whose turn it is, so the server
// decides per-caller — the client cannot derive this from the contract alone.
@@ -169,8 +177,13 @@ export function ContractDocumentEditorModal({
const submit = () => {
const snapshot = buildSnapshot();
if (mode === "accept") {
const days = Number(validityDays);
if (!days) return;
// const days = Number(validityDays);
// if (!days) return;
if (!validityStart || !validityEnd) return;
const days = Math.ceil(
(validityEnd.getTime() - validityStart.getTime()) / (24 * 60 * 60 * 1000),
);
if (days <= 0) return;
onAccept?.(days, snapshot);
} else {
onSaveEdit?.(snapshot);
@@ -181,7 +194,8 @@ export function ContractDocumentEditorModal({
const canSubmit =
hasArticles &&
!locked &&
(mode === "edit" || Boolean(validityDays)) &&
// (mode === "edit" || Boolean(validityDays)) &&
(mode === "edit" || Boolean(validityStart && validityEnd)) &&
!submitting;
return (
@@ -375,7 +389,10 @@ export function ContractDocumentEditorModal({
{mode === "accept" && (
<>
{validityOptions.length > 0 ? (
{/* ponytail: client keeps changing this requirement — swapped
the validity-period dropdown for explicit start/end dates,
left the old block commented instead of deleted. */}
{/* {validityOptions.length > 0 ? (
<Select
label="Contract validity"
placeholder="Select a validity period"
@@ -391,7 +408,25 @@ export function ContractDocumentEditorModal({
? "Loading validity periods…"
: "No validity periods are configured yet. Add them under Dropdown Settings."}
</Text>
)}
)} */}
<Group grow align="flex-start">
<DateInput
label="Start date"
placeholder="Contract validity start"
value={validityStart}
onChange={(v) => setValidityStart(v ? new Date(v) : null)}
maxDate={validityEnd ?? undefined}
clearable
/>
<DateInput
label="End date"
placeholder="Contract validity end"
value={validityEnd}
onChange={(v) => setValidityEnd(v ? new Date(v) : null)}
minDate={validityStart ?? undefined}
clearable
/>
</Group>
</>
)}

View File

@@ -0,0 +1,65 @@
import { Badge, Box, Group, Stack, Text } from "@mantine/core";
import { Flame } from "lucide-react";
import { hazardClassLabel, type Freight } from "@edr/types";
/**
* The contract's dangerous-goods declaration — the UN/ADR class and UN number
* the customer declared alongside the hazard documents. Shown wherever a
* hazardous contract is reviewed: the cargo-scope card and the two hazardous
* approval confirmations, so no one signs off without seeing what is moving.
*/
export function HazardDeclarationPanel({
contract,
}: {
contract: Pick<Freight.IContract, "hazardClass" | "unNumber">;
}) {
const classLabel = hazardClassLabel(contract.hazardClass);
return (
<Group
gap="sm"
align="flex-start"
wrap="nowrap"
px="md"
py="sm"
style={{
borderRadius: 12,
border: "1px solid #F3D5D0",
background: "#FEF7F6",
}}
>
<Box
style={{
width: 34,
height: 34,
borderRadius: 10,
flexShrink: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "#FBEAE7",
color: "#C0392B",
}}
>
<Flame size={16} />
</Box>
<Stack gap={6} style={{ minWidth: 0 }}>
<Text size="sm" fw={700}>
Dangerous goods declaration
</Text>
<Group gap={6} wrap="wrap">
<Badge color="red" variant="light" radius="sm" size="sm">
{classLabel ?? "Class not declared"}
</Badge>
<Badge color="red" variant="light" radius="sm" size="sm">
{contract.unNumber ? `UN ${contract.unNumber}` : "UN number not declared"}
</Badge>
</Group>
<Text size="xs" c="dimmed">
Check the declaration against the uploaded hazard documents before
approving.
</Text>
</Stack>
</Group>
);
}

View File

@@ -0,0 +1,171 @@
import { useRef, useState } from "react";
import { Box, Button, Group, Image, Paper, Stack, Text } from "@mantine/core";
import { RefreshCw, Stamp, X } from "lucide-react";
const MAX_STAMP_MB = 5;
export interface StampUploadProps {
/** Stamp image as a data URL, or null when none is attached yet. */
value: string | null;
onChange: (dataUrl: string | null) => void;
label?: string;
description?: string;
}
/**
* Company stamp/seal attachment for the contract signing modal. Reads the
* picked image straight into a data URL because the signing endpoint takes
* base64 in JSON (same transport as the drawn signature), not multipart.
*/
export function StampUpload({
value,
onChange,
label = "Company stamp",
description = "Attach your official company stamp or seal.",
}: StampUploadProps) {
const inputRef = useRef<HTMLInputElement>(null);
const [dragging, setDragging] = useState(false);
const [error, setError] = useState<string | null>(null);
const [fileName, setFileName] = useState<string | null>(null);
const readFile = (file: File | undefined | null) => {
if (!file) return;
if (!file.type.startsWith("image/")) {
setError("The stamp must be an image file (PNG or JPG).");
return;
}
if (file.size > MAX_STAMP_MB * 1024 * 1024) {
setError(`The stamp image must be under ${MAX_STAMP_MB} MB.`);
return;
}
const reader = new FileReader();
reader.onload = () => {
setError(null);
setFileName(file.name);
onChange(typeof reader.result === "string" ? reader.result : null);
};
reader.onerror = () => setError("Could not read that file. Try another.");
reader.readAsDataURL(file);
};
const openPicker = () => inputRef.current?.click();
const clear = () => {
setFileName(null);
setError(null);
onChange(null);
if (inputRef.current) inputRef.current.value = "";
};
return (
<Stack gap={6}>
<Text size="sm" fw={500}>
{label}
</Text>
<input
ref={inputRef}
type="file"
accept="image/png,image/jpeg,image/webp"
hidden
onChange={(e) => readFile(e.currentTarget.files?.[0])}
/>
{value ? (
<Paper withBorder radius="md" p="sm">
<Group gap="md" wrap="nowrap" align="center">
<Box
style={{
background:
"repeating-conic-gradient(var(--mantine-color-gray-1) 0% 25%, transparent 0% 50%) 50% / 14px 14px",
borderRadius: 8,
flexShrink: 0,
padding: 6,
}}
>
<Image
src={value}
alt="Company stamp"
fit="contain"
h={92}
w={92}
/>
</Box>
<Stack gap={4} style={{ flex: 1, minWidth: 0 }}>
<Text size="sm" fw={500} truncate>
{fileName ?? "Stamp attached"}
</Text>
<Text size="xs" c="dimmed">
This stamp is applied next to your signature on the contract.
</Text>
<Group gap="xs" mt={2}>
<Button
size="compact-xs"
variant="light"
color="edr-green"
leftSection={<RefreshCw size={13} />}
onClick={openPicker}
>
Replace
</Button>
<Button
size="compact-xs"
variant="subtle"
color="red"
leftSection={<X size={13} />}
onClick={clear}
>
Remove
</Button>
</Group>
</Stack>
</Group>
</Paper>
) : (
<Paper
withBorder
radius="md"
p="lg"
onClick={openPicker}
onDragOver={(e) => {
e.preventDefault();
setDragging(true);
}}
onDragLeave={() => setDragging(false)}
onDrop={(e) => {
e.preventDefault();
setDragging(false);
readFile(e.dataTransfer.files?.[0]);
}}
style={{
borderColor: dragging
? "var(--mantine-color-edr-green-6)"
: undefined,
borderStyle: "dashed",
backgroundColor: dragging
? "var(--mantine-color-edr-green-0)"
: undefined,
cursor: "pointer",
}}
>
<Stack gap={6} align="center">
<Stamp size={26} color="var(--mantine-color-edr-green-6)" />
<Text size="sm" fw={500}>
Upload company stamp
</Text>
<Text size="xs" c="dimmed" ta="center">
{description} Drop an image here or click to browse PNG or JPG,
up to {MAX_STAMP_MB} MB.
</Text>
</Stack>
</Paper>
)}
{error && (
<Text size="xs" c="red.7">
{error}
</Text>
)}
</Stack>
);
}

View File

@@ -172,7 +172,7 @@ export function computeGlShipmentTotal(
// it (the commodity needs lashing). Per-ton scales by tonnage; per-wagon
// depends on the wagon capacity the train stocks — shown at real pricing.
const lashing = items.find((i) => i.conditionalOn === "has_lashing");
if (lashing && lashing.unit === "per_ton") {
if (lashing && (lashing.unit === "per_ton" || lashing.unit === "per_item")) {
const tons = q.bulkQuantity;
if (tons > 0) {
lines.push({
@@ -199,7 +199,7 @@ export function computeGlShipmentTotal(
cl.unit === "per_wagon"
? Math.ceil(boxes * (cl.containerSize === "40ft" ? 1 : 0.5))
: boxes;
} else if (cl.unit === "per_ton") {
} else if (cl.unit === "per_ton" || cl.unit === "per_item") {
qty = q.bulkQuantity;
} else if (cl.unit === "flat") {
qty = 1;

View File

@@ -23,6 +23,7 @@ import {
import { type ReactNode } from "react";
import { useNavigate } from "react-router-dom";
import DocReviewAlertButton from "@/features/bookingWindows/DocReviewAlertButton";
import NotificationBellContainer from "@/features/notifications/NotificationBellContainer";
import type { PageMeta } from "./types";
@@ -114,8 +115,12 @@ const FreightDashboardHeader = ({
</Group>
</Group>
{/* Right: actions + avatar */}
{/* Right: actions + avatar. The doc-review alarm leads the group — it
only renders in the last half of a review phase that still has
undecided requests, so it never competes for space otherwise. */}
<Group gap={10} wrap="nowrap" align="center">
<DocReviewAlertButton />
<Tooltip label="Language" withArrow openDelay={300}>
<UnstyledButton className={ISLAND} aria-label="Language">
<Languages size={17} strokeWidth={1.8} />

View File

@@ -156,6 +156,8 @@ export const URL_CONSTANTS = {
`/bookings/${id}/clearance/ro-amendment`,
CLEARANCE_EXPORT_RELEASE: (id: string) =>
`/bookings/${id}/clearance/export-release`,
// Re-request operation after Operations sent the booking back for changes.
CLEARANCE_PROCEED: (id: string) => `/bookings/${id}/clearance/proceed`,
CLEARANCE_ET_QUEUE: "/bookings/clearance/et-queue",
CLEARANCE_DJ_QUEUE: "/bookings/clearance/dj-queue",
},
@@ -294,6 +296,7 @@ export const URL_CONSTANTS = {
`/train-scheduling/schedules/${id}/run-allocation`,
DOC_REVIEW_COMPLETE: (id: string) =>
`/train-scheduling/schedules/${id}/doc-review-complete`,
DOC_REVIEW_ALERT: "/train-scheduling/doc-review-alert",
ASSIGN_UNASSIGNED_BOOKING: (id: string) =>
`/train-scheduling/schedules/${id}/assign-unassigned-booking`,
BOOKING_WINDOW: (id: string) =>

View File

@@ -0,0 +1,125 @@
import { Text, Tooltip, UnstyledButton } from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import { AlertTriangle, ChevronRight } from "lucide-react";
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useAuth } from "@/auth/useAuth";
import { canSeeDocReviewAlert } from "@/lib/permissions";
import { api } from "@/services/api";
/**
* Booking-request statuses that count as "nobody decided yet". These are the
* exact statuses the window engine expires when document review ends
* (findUnacceptedForRouteDay), so the deep-linked list shows precisely the
* requests the countdown is warning about.
*/
const UNDECIDED_STATUSES = [
"OPERATION_REQUESTED",
"OPERATION_REQUEST_PENDING",
"OPERATION_CHANGES_REQUESTED",
"OPERATION_PRICE_PENDING_CONFIRM",
].join(",");
const pendingRequestsHref = (tradeDirection: string) =>
`/dashboard/booking-requests?statuses=${UNDECIDED_STATUSES}&tradeDirection=${tradeDirection}`;
/** mm:ss (or h:mm:ss past an hour), fixed width so the pill never jitters. */
function formatRemaining(ms: number): string {
const total = Math.max(0, Math.floor(ms / 1000));
const hours = Math.floor(total / 3600);
const minutes = Math.floor((total % 3600) / 60);
const seconds = total % 60;
const mm = String(minutes).padStart(2, "0");
const ss = String(seconds).padStart(2, "0");
return hours > 0 ? `${hours}:${mm}:${ss}` : `${mm}:${ss}`;
}
/**
* Header alarm for the document-review deadline. Appears only once the review
* phase is half spent AND requests are still undecided — everything still
* pending when the clock runs out is expired automatically, so this is the last
* call to accept or reject. Clicking opens the booking requests already
* filtered to those undecided import requests.
*/
export default function DocReviewAlertButton() {
const navigate = useNavigate();
const { user } = useAuth();
const { data: alert } = useQuery({
...api.trainScheduling.docReviewAlert.queryOptions(),
// Dedicated permission: only the position types granted it are alarmed.
enabled: canSeeDocReviewAlert(user),
// The window engine ticks every 10s; a minute is close enough for a header
// chip — the countdown itself runs locally.
refetchInterval: 60_000,
});
const deadlineMs = alert ? new Date(alert.docReviewEndsAt).getTime() : 0;
const [remaining, setRemaining] = useState(() => deadlineMs - Date.now());
useEffect(() => {
if (!deadlineMs) return;
const tick = () => setRemaining(deadlineMs - Date.now());
tick();
const id = window.setInterval(tick, 1000);
return () => window.clearInterval(id);
}, [deadlineMs]);
if (!alert) return null;
// Half the review phase has to be gone before staff are alarmed — a 30-minute
// review warns with 15 minutes left.
const halfMs = (Math.max(alert.docReviewMinutes, 1) * 60_000) / 2;
if (remaining <= 0 || remaining > halfMs) return null;
const requestLabel = alert.pendingCount === 1 ? "request" : "requests";
return (
<Tooltip
withArrow
openDelay={200}
multiline
w={260}
label={`Document review ends in ${formatRemaining(remaining)}. ${alert.pendingCount} ${alert.tradeDirection.toLowerCase()} booking ${requestLabel} ${alert.pendingCount === 1 ? "is" : "are"} still neither accepted nor rejected and will expire automatically. Click to review them.`}
>
<UnstyledButton
onClick={() => navigate(pendingRequestsHref(alert.tradeDirection))}
aria-label={`${alert.pendingCount} import booking ${requestLabel} awaiting a decision — document review ends in ${formatRemaining(remaining)}`}
className="group flex h-9 shrink-0 items-center gap-2 rounded-full border border-red-600/60 bg-red-600 pl-2.5 pr-2 text-white shadow-[0_2px_10px_rgba(220,38,38,0.35)] transition-transform hover:scale-[1.02] hover:bg-red-700"
>
{/* Live dot: a ping ring behind a solid core, so the pill reads as
active without animating the whole chip. */}
<span className="relative flex size-2 shrink-0">
<span className="absolute inline-flex size-full animate-ping rounded-full bg-white opacity-75" />
<span className="relative inline-flex size-2 rounded-full bg-white" />
</span>
<AlertTriangle size={15} strokeWidth={2.2} className="shrink-0" />
<Text
size="xs"
fw={700}
visibleFrom="sm"
className="whitespace-nowrap text-white!"
>
{alert.pendingCount} undecided
</Text>
<Text
size="xs"
fw={700}
className="text-white!"
style={{ fontVariantNumeric: "tabular-nums", letterSpacing: "0.02em" }}
>
{formatRemaining(remaining)}
</Text>
<ChevronRight
size={14}
strokeWidth={2.2}
className="shrink-0 opacity-80 transition-transform group-hover:translate-x-0.5"
/>
</UnstyledButton>
</Tooltip>
);
}

View File

@@ -119,7 +119,13 @@ export const useCargoLeafOptions = (enabled = true) =>
const code = String(row.code ?? "").trim();
const label =
name && code ? `${name} (${code})` : name || code || String(row.id);
return { label, value: String(row.id) };
// The commodity's unit of measure rides along so the rate form can
// offer per-item units for counted (break-bulk) commodities.
return {
label,
value: String(row.id),
unitOfMeasure: String(row.unitOfMeasure ?? ""),
};
});
},
});

View File

@@ -26,6 +26,7 @@ export const FREIGHT_PERMS = {
reviewDocuments: "edr_freight_app:bookings:review_documents",
uploadClearanceOutput: "edr_freight_app:bookings:upload_clearance_output",
finalizeClearance: "edr_freight_app:bookings:finalize_clearance",
docReviewAlert: "edr_freight_app:bookings:doc_review_alert",
},
contracts: {
view: "edr_freight_app:contracts:view",
@@ -45,6 +46,8 @@ export const FREIGHT_PERMS = {
approveLineStaff: "edr_freight_app:contracts:approve_line_staff",
approveDirector: "edr_freight_app:contracts:approve_director",
approveCeo: "edr_freight_app:contracts:approve_ceo",
hazardousApprovalOne: "edr_freight_app:contracts:hazardous_approval_one",
hazardousApprovalTwo: "edr_freight_app:contracts:hazardous_approval_two",
generateContract: "edr_freight_app:contracts:generate_contract",
signStaff: {
bulk: "edr_freight_app:contracts:sign_staff:bulk",
@@ -441,6 +444,22 @@ const CONTRACT_APPROVE_ROLE_PERMISSION: Record<string, string> = {
CEO: FREIGHT_PERMS.contracts.approveCeo,
};
/**
* The two hazardous-goods steps prepended to a hazardous contract's chain.
* They are not position types — they authorize purely on their own permission,
* exactly as the API's HAZARDOUS_APPROVAL_ROLE_PERMISSION does.
*/
export const HAZARDOUS_APPROVAL_ROLE_PERMISSION: Record<string, string> = {
HAZARDOUS_APPROVAL_ONE: FREIGHT_PERMS.contracts.hazardousApprovalOne,
HAZARDOUS_APPROVAL_TWO: FREIGHT_PERMS.contracts.hazardousApprovalTwo,
};
/** Display label for an approval step's role (hazardous steps get real names). */
export const CONTRACT_APPROVAL_ROLE_LABELS: Record<string, string> = {
HAZARDOUS_APPROVAL_ONE: "Hazardous review — first approver",
HAZARDOUS_APPROVAL_TWO: "Hazardous review — second approver",
};
/**
* Can this user action a contract approval step requiring `requiredRole`?
*
@@ -463,6 +482,10 @@ export function canApproveContractStep(
if (!user || !requiredRole) return false;
if (isFreightApprovalAdmin(user)) return true;
// Hazardous steps are permission-only — no position type stands in for them.
const hazardousPermission = HAZARDOUS_APPROVAL_ROLE_PERMISSION[requiredRole];
if (hazardousPermission) return hasPermission(user, hazardousPermission);
const positionTypes = getPositionTypeKeys(user);
if (positionTypes.includes(requiredRole)) return true;
@@ -477,6 +500,17 @@ export function canAccessBookings(user: AuthUser | null | undefined): boolean {
return hasPermission(user, FREIGHT_PERMS.bookings.view);
}
/**
* Sees the header countdown warning that document review is about to end with
* requests still undecided. Its own permission — granted per position type, so
* only the desks that act on those requests get alarmed.
*/
export function canSeeDocReviewAlert(
user: AuthUser | null | undefined,
): boolean {
return hasPermission(user, FREIGHT_PERMS.bookings.docReviewAlert);
}
export function canAccessContracts(user: AuthUser | null | undefined): boolean {
return hasPermission(user, FREIGHT_PERMS.contracts.view);
}

View File

@@ -27,8 +27,8 @@ import {
User,
X,
} from "lucide-react";
import { useCallback, useMemo, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { BookingActionsMenu } from "@/components/bookings/BookingActionsMenu";
@@ -123,14 +123,25 @@ function formatDate(value: string | null | undefined): string {
export default function BookingRequestsPage() {
const navigate = useNavigate();
// Deep links land here pre-filtered (?statuses=A,B&tradeDirection=IMPORT) —
// the header's document-review alarm opens exactly the undecided requests it
// is counting down for. Read once as the initial state so staff can then
// change the filters like any other visit.
const [searchParams] = useSearchParams();
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [query, setQuery] = useState("");
const [debouncedQuery] = useDebouncedValue(query, 300);
// Booking kind is a filter now — one list holds both kinds (null = "all").
const [kindFilter, setKindFilter] = useState<BookingKind | null>(null);
// Filter controls (empty/null = "all").
const [statusFilter, setStatusFilter] = useState<string[]>([]);
const [directionFilter, setDirectionFilter] = useState<string | null>(null);
const paramStatuses = searchParams.get("statuses") ?? "";
const paramDirection = searchParams.get("tradeDirection");
const [statusFilter, setStatusFilter] = useState<string[]>(() =>
paramStatuses.split(",").filter(Boolean),
);
const [directionFilter, setDirectionFilter] = useState<string | null>(
paramDirection,
);
const [freightTypeFilter, setFreightTypeFilter] = useState<string | null>(null);
const [paymentStatusFilter, setPaymentStatusFilter] = useState<string | null>(null);
const [ownershipFilter, setOwnershipFilter] = useState<string | null>(null);
@@ -150,6 +161,15 @@ export default function BookingRequestsPage() {
}, 400);
}, []);
// Follow the URL when a deep link arrives while the page is already open
// (clicking the header alarm from this very list). Same-value writes are
// dropped so a manual filter change is never undone.
useEffect(() => {
const next = paramStatuses.split(",").filter(Boolean);
setStatusFilter((prev) => (prev.join(",") === next.join(",") ? prev : next));
setDirectionFilter(paramDirection);
}, [paramStatuses, paramDirection]);
const filter: BookingListFilter = useMemo(() => {
return {
page: pagination.pageIndex + 1,

View File

@@ -35,6 +35,7 @@ import {
isDjiboutiGl,
} from "@/lib/permissions";
import { BookingChangesRequestedAlert } from "@/components/contracts/BookingChangesRequestedAlert";
import { ClearanceOpsTabs } from "@/components/contracts/ClearanceOpsTabs";
import { PageContainer } from "@/components/page/PageContainer";
import { PageHeader } from "@/components/page/PageHeader";
@@ -137,10 +138,15 @@ export default function ContractClearanceDetailPage() {
// The GL-created booking expired unpaid — the slot is free again and GL
// rebooks on the customer's behalf (customs bookings are never self-booked).
const bookingExpired = clearance?.linkedBookingStatus === "EXPIRED";
const canRebook =
bookingExpired &&
// Operations sent the GL-created booking back. GL owns customs bookings, so
// the note and the resubmit belong here, not in the customer's portal.
const bookingNeedsChanges =
clearance?.linkedBookingStatus === "OPERATION_CHANGES_REQUESTED";
const isGlBookingOwner =
hasPermission(user, FREIGHT_PERMS.contracts.createBooking) &&
!isDjiboutiGl(user);
const canResubmitBooking = bookingNeedsChanges && isGlBookingOwner;
const canRebook = bookingExpired && isGlBookingOwner;
const rebookHref = linkedBookingId
? `${bookingHref}?copyFrom=${linkedBookingId}`
: bookingHref;
@@ -294,6 +300,19 @@ export default function ContractClearanceDetailPage() {
) : null}
</Stack>
</Alert>
) : bookingNeedsChanges && linkedBookingId ? (
<BookingChangesRequestedAlert
bookingId={linkedBookingId}
reference={clearance.linkedBookingReference}
note={clearance.linkedBookingReviewNote}
scheduledDate={clearance.linkedBookingScheduledDate}
canResubmit={canResubmitBooking}
onResubmitted={() => {
void refetch();
void refetchContract();
refetchBookingMilestonesIfLinked();
}}
/>
) : bookingAlreadyCreated ? (
<Alert
color="blue"

View File

@@ -135,9 +135,10 @@ function toClearanceRow(contract: Freight.IContract): ClearanceRow {
return {
id: contract.id,
reference: contract.reference,
// The queue joins the company relation — show its name, never the raw uuid.
customerLabel: contract.isGovernment
? (contract.governmentInstitution ?? "Government")
: (contract.companyId ?? "—"),
: (contract.company?.name ?? "—"),
tradeDirection: contract.tradeDirection ?? "—",
freightType: contract.freightType ?? "—",
originLabel: yardLabel(first?.originYard),

View File

@@ -50,6 +50,7 @@ import { ContractStatusBadge } from "@/components/contracts/ContractStatusBadge"
import { ContractWorkflowStepper } from "@/components/contracts/ContractWorkflowStepper";
import { ContractActionsToolbar } from "@/components/contracts/ContractActionsToolbar";
import { ContractApprovalStepsCard } from "@/components/contracts/ContractApprovalStepsCard";
import { HazardDeclarationPanel } from "@/components/contracts/HazardDeclarationPanel";
import { ClearanceWorkflowFilesPanel } from "@/components/contracts/ClearanceWorkflowFilesPanel";
import { ContractRevisionTimeline } from "@/components/contracts/ContractRevisionTimeline";
import {
@@ -587,6 +588,11 @@ export default function ContractRequestDetailPage() {
</Badge>
) : null}
</Group>
{contract.isHazardous ? (
<Box mb="md">
<HazardDeclarationPanel contract={contract} />
</Box>
) : null}
{(contract.cargoScope ?? []).length === 0 ? (
<Text size="sm" c="dimmed">
No cargo scope lines.

View File

@@ -18,7 +18,9 @@ import toast from "react-hot-toast";
import { ContractSignSuccessModal } from "@/components/contracts/ContractSignSuccessModal";
import { ContractSignaturePad } from "@/components/bookings/ContractSignaturePad";
import { StampUpload } from "@/components/contracts/StampUpload";
import { contractsService } from "@/services/contracts.service";
import { extractApiError } from "@/utils/result";
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
@@ -38,6 +40,7 @@ export default function ContractViewPage() {
const [successOpen, setSuccessOpen] = useState(false);
const [signerName, setSignerName] = useState("");
const [signatureData, setSignatureData] = useState<string | null>(null);
const [stampData, setStampData] = useState<string | null>(null);
const [drawNew, setDrawNew] = useState(false);
const { data, isLoading, isError, refetch } = useQuery({
@@ -56,6 +59,7 @@ export default function ContractViewPage() {
signatureImageBase64: usingSaved
? (savedSignatureImage as string)
: (signatureData ?? ""),
stampImageBase64: stampData ?? "",
signerDisplayName: signerName.trim(),
consentText: "I confirm this contract on behalf of EDR.",
}),
@@ -66,7 +70,12 @@ export default function ContractViewPage() {
void qc.invalidateQueries({ queryKey: QUERY_KEYS.CONTRACTS.byId(id!) });
void qc.invalidateQueries({ queryKey: QUERY_KEYS.CONTRACTS.ROOT });
},
onError: () => toast.error("Failed to sign contract"),
// Surface the server's reason verbatim — the missing-customer-stamp gate
// and the status guards all explain themselves in the message.
onError: (err) =>
toast.error(
extractApiError(err).message ?? "Failed to sign contract",
),
});
const handlePrint = () => iframeRef.current?.contentWindow?.print();
@@ -89,12 +98,13 @@ export default function ContractViewPage() {
const openSign = () => {
setSignerName(data?.savedSignature?.signerDisplayName ?? "");
setSignatureData(null);
setStampData(null);
setDrawNew(false);
setSignOpen(true);
};
const confirmSign = () => {
if (!signerName.trim()) return;
if (!signerName.trim() || !stampData) return;
const image = usingSaved ? savedSignatureImage : signatureData;
if (!image) return;
signMutation.mutate();
@@ -231,6 +241,14 @@ export default function ContractViewPage() {
) : (
<ContractSignaturePad onChange={setSignatureData} />
)}
<StampUpload
value={stampData}
onChange={setStampData}
label="EDR company stamp"
description="Attach the official EDR stamp or seal — it is applied to the contract next to the signature."
/>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={() => setSignOpen(false)}>
Cancel
@@ -241,7 +259,8 @@ export default function ContractViewPage() {
disabled={
signMutation.isPending ||
!signerName.trim() ||
(!usingSaved && !signatureData)
(!usingSaved && !signatureData) ||
!stampData
}
onClick={confirmSign}
>

View File

@@ -1180,22 +1180,16 @@ export function LocomotivesCrudPage() {
// of the weight tolerance.
{ key: 'overageToleranceTons', label: 'Weight tolerance (tons over max pull)', type: 'number' },
{ key: 'overageToleranceMeters', label: 'Length tolerance (meters over max length)', type: 'number' },
{ key: 'powerKw', label: 'Power (kW)', type: 'number' },
{ key: 'tractionForceKn', label: 'Traction force (kN)', type: 'number' },
{ key: 'maxSpeedKmh', label: 'Max speed (km/h)', type: 'number' },
]}
emptyValues={{
code: '',
name: '',
locomotiveType: 'DIESEL',
status: 'AVAILABLE',
maxPullWeightTons: 0,
maxPullWeightTons: 3500,
maxTrainLengthMeters: 760,
overageToleranceTons: '',
overageToleranceMeters: '',
powerKw: '',
tractionForceKn: '',
maxSpeedKmh: '',
overageToleranceTons: 93,
overageToleranceMeters: 10,
}}
/>
);

View File

@@ -233,22 +233,16 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [
// of the weight tolerance.
{ name: "overageToleranceTons", label: "Weight tolerance (tons over max pull)", type: "number" },
{ name: "overageToleranceMeters", label: "Length tolerance (meters over max length)", type: "number" },
{ name: "powerKw", label: "Power (kW)", type: "number" },
{ name: "tractionForceKn", label: "Traction force (kN)", type: "number" },
{ name: "maxSpeedKmh", label: "Max speed (km/h)", type: "number" },
],
emptyValues: {
name: "",
locomotiveType: "DIESEL",
status: "AVAILABLE",
currentYardId: "",
maxPullWeightTons: 2500,
maxPullWeightTons: 3500,
maxTrainLengthMeters: 760,
overageToleranceTons: "",
overageToleranceMeters: "",
powerKw: "",
tractionForceKn: "",
maxSpeedKmh: "",
overageToleranceTons: 93,
overageToleranceMeters: 10,
},
},
{

View File

@@ -59,6 +59,7 @@ import {
RULE_ENGINE_CATEGORY_BASE_PATH,
RULE_ENGINE_SELECT_NONE,
getRuleEngineResource,
rateUnitOptions,
type RuleEngineNavCategory,
} from "@/pages/ruleEngine/config/resources";
import type { RateChangeRequest } from "@/services/ruleEngine/ruleEngine.service";
@@ -344,6 +345,20 @@ const RuleEngineResourcePage = () => {
options: cargoLeafOptions ?? [],
};
}
// Rate units follow the picked commodity: a per-item (break-bulk) cargo
// is priced per item where a weighed one is priced per ton.
if (field.name === "rateUnit" && field.optionsFromValues) {
return {
...field,
optionsFromValues: (values: Record<string, unknown>) =>
rateUnitOptions(
values,
(cargoLeafOptions ?? []).find(
(o) => o.value === String(values.cargoTypeId ?? ""),
)?.unitOfMeasure ?? "",
),
};
}
if (field.name === "rateId") {
return {
...field,

View File

@@ -207,13 +207,27 @@ const unitOption = (value: string) => ({ label: value.replace(/_/g, " "), value
/**
* Valid weighting units for a rate shape — mirrors the API's
* `allowedRateUnits`. The unit is driven by the *type* being billed: containers
* bill per container, bulk per ton, overweight always per excess ton, etc. Kept
* in sync with apps/edr-freight-api/.../entities/rate-unit.util.ts.
* bill per container, bulk per ton, overweight always per excess ton, etc. A
* rate scoped to a break-bulk commodity (unit of measure = PER_ITEM) offers
* PER_ITEM wherever a weighed one offers PER_TON. Kept in sync with
* apps/edr-freight-api/.../entities/rate-unit.util.ts.
*/
const allowedRateUnits = (
appliesTo: string,
trigger: string,
cargoKind = "",
cargoUnitOfMeasure = "",
): string[] => {
const units = unitsForShape(appliesTo, trigger, cargoKind);
return cargoUnitOfMeasure === "PER_ITEM"
? units.map((u) => (u === "PER_TON" ? "PER_ITEM" : u))
: units;
};
const unitsForShape = (
appliesTo: string,
trigger: string,
cargoKind = "",
): string[] => {
if (appliesTo === "OTHER") {
switch (trigger) {
@@ -259,7 +273,15 @@ const allowedRateUnits = (
}
};
const rateUnitOptions = (values: Record<string, unknown>) => {
/**
* Unit choices for the rate form. `cargoUnitOfMeasure` is how the bulk
* commodity picked in the form is counted (PER_TON / PER_ITEM) — injected by
* RuleEngineResourcePage, which is the layer that has the cargo type list.
*/
export const rateUnitOptions = (
values: Record<string, unknown>,
cargoUnitOfMeasure = "",
) => {
const appliesTo = String(values.appliesTo ?? "");
const trigger = appliesTo === "OTHER" ? String(values.trigger ?? "") : "ALWAYS";
if (!appliesTo) return [];
@@ -267,6 +289,7 @@ const rateUnitOptions = (values: Record<string, unknown>) => {
appliesTo,
trigger,
String(values.cargoKind ?? ""),
cargoUnitOfMeasure,
).map(unitOption);
};
@@ -903,7 +926,8 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
type: "select",
required: true,
optionsFromValues: rateUnitOptions,
description: "Weighting basis — options depend on what the rate applies to.",
description:
"Weighting basis — options depend on what the rate applies to, and for bulk on how the picked commodity is counted (per ton or per item).",
hideWhen: { field: "trigger", equals: ["OVERWEIGHT"] },
},
],

View File

@@ -57,6 +57,7 @@ import type {
EligibleContainerBookingsResponse,
FreightType,
ImportLoadingBookingsResponse,
DocReviewAlert,
LoadingStatus,
LocomotiveRecord,
PinWagonsPayload,
@@ -276,6 +277,13 @@ export const api = {
() => ["train-scheduling", "all-booking-windows"],
),
docReviewAlert: endpoint<void, DocReviewAlert | null>(
"train-scheduling",
"doc-review-alert",
() => trainSchedulingService.getDocReviewAlert(),
() => ["train-scheduling", "doc-review-alert"],
),
batchBoardDetail: endpoint<
{ scheduleId: string },
BatchBoardScheduleDetail
@@ -1660,15 +1668,6 @@ export const api = {
() => [["wagons"]],
),
reorder: endpoint<{ trainId: string; wagonIds: string[] }, Wagon[]>(
"wagons",
"reorder",
({ trainId, wagonIds }) =>
wagonService.reorder(trainId, wagonIds).then((r) => r.data),
undefined,
() => [["wagons"]],
),
create: endpoint<Partial<Wagon>, Wagon>(
"wagons",
"create",
@@ -2534,6 +2533,13 @@ export const api = {
bookingsService.reviewOperation(id, decision, { note }),
),
proceedToOperation: endpoint<
{ id: string; scheduledDate: string },
BookingDetail
>("bookings", "proceedToOperation", ({ id, scheduledDate }) =>
bookingsService.proceedToOperation(id, scheduledDate),
),
generateContract: endpoint<{ id: string }, BookingDetail>(
"bookings",
"generateContract",

View File

@@ -243,6 +243,14 @@ export const bookingsService = {
...options,
}),
/**
* Re-request operation on a booking Operations sent back for changes. The
* customer path uses the same endpoint from the portal; GL needs it here
* because a customs booking is GL's to fix, not the customer's.
*/
proceedToOperation: (id: string, scheduledDate: string) =>
postBooking<BookingDetail>(B.CLEARANCE_PROCEED(id), { scheduledDate }),
generateContract: (id: string) =>
postBooking<BookingDetail>(B.CONTRACT_GENERATE(id)),

View File

@@ -109,6 +109,7 @@ export interface ContractView {
signerDisplayName: string;
signedAt: string;
signatureImageUrl?: string | null;
stampImageUrl?: string | null;
}>;
savedSignature?: {
signerDisplayName: string;
@@ -119,6 +120,8 @@ export interface ContractView {
export interface SignContractPayload {
role: "CUSTOMER" | "STAFF";
signatureImageBase64: string;
/** Company stamp/seal image; required to sign a contract. */
stampImageBase64?: string;
signerDisplayName: string;
consentText?: string;
}

View File

@@ -35,9 +35,6 @@ export interface Locomotive {
overageToleranceTons?: number | null;
/** Metres a train may exceed maxTrainLengthMeters by before scheduling blocks it. */
overageToleranceMeters?: number | null;
powerKw?: number | null;
tractionForceKn?: number | null;
maxSpeedKmh?: number | null;
createdAt: string;
updatedAt: string;
}

View File

@@ -12,6 +12,7 @@ import type {
BookingLoadResult,
BookingUnloadResult,
CompositionRemovalEntry,
DocReviewAlert,
UnassignedBookingsResponse,
CreateTrainSchedulePayload,
EligibleContainerBookingsResponse,
@@ -709,6 +710,15 @@ export const trainSchedulingService = {
return unwrap(response.data);
},
getDocReviewAlert: async (): Promise<DocReviewAlert | null> => {
const response = await client.get<DocReviewAlert | null>(
URL_CONSTANTS.TRAIN_SCHEDULING.DOC_REVIEW_ALERT,
);
// "No alert" comes back as null — which Nest sends as an empty body, so
// coerce anything falsy to null (react-query rejects undefined).
return unwrap(response.data) || null;
},
updateGlobalRules: async (
payload: Partial<Omit<TrainSchedulingGlobalRules, "id">>,
): Promise<TrainSchedulingGlobalRules> => {

View File

@@ -88,8 +88,6 @@ export const wagonService = {
assignToTrain: (wagonId: string, trainId: string, sequenceNumber?: number) =>
apiClient.post(`/wagons/${wagonId}/assign-train`, { trainId, sequenceNumber }),
unassign: (wagonId: string) => apiClient.post(`/wagons/${wagonId}/unassign-train`),
reorder: (trainId: string, wagonIds: string[]) =>
apiClient.post(`/trains/${trainId}/reorder-wagons`, { wagonIds }),
create: (data: Partial<Wagon>) => apiClient.post('/wagons', data),
update: (id: string, data: Partial<Wagon>) => apiClient.patch(`/wagons/${id}`, data),
delete: (id: string) => apiClient.delete(`/wagons/${id}`),

View File

@@ -287,6 +287,25 @@ export interface BatchBoardBooking {
* An announced booking window on any lane (import cycle or export FCFS), for
* staff dashboards. Mirrors the customer portal's MyBookingWindow.
*/
/**
* The nearest document-review deadline that still has booking requests nobody
* accepted or rejected. Everything still pending when it lapses is expired by
* the window engine, so the header counts down to it.
*/
export interface DocReviewAlert {
scheduleId: string;
originYardId: string;
destinationYardId: string;
/** EAT booking day, YYYY-MM-DD. */
day: string;
/** IMPORT (the usual) or DOMESTIC — the direction the at-risk requests belong to. */
tradeDirection: string;
docReviewEndsAt: string;
/** Full length of the review phase — warn past its halfway mark. */
docReviewMinutes: number;
pendingCount: number;
}
export interface StaffBookingWindow {
scheduleId: string;
reference: string | null;

View File

@@ -0,0 +1,171 @@
import { useRef, useState } from "react";
import { Box, Button, Group, Image, Paper, Stack, Text } from "@mantine/core";
import { RefreshCw, Stamp, X } from "lucide-react";
const MAX_STAMP_MB = 5;
export interface StampUploadProps {
/** Stamp image as a data URL, or null when none is attached yet. */
value: string | null;
onChange: (dataUrl: string | null) => void;
label?: string;
description?: string;
}
/**
* Company stamp/seal attachment for the contract signing modal. Reads the
* picked image straight into a data URL because the signing endpoint takes
* base64 in JSON (same transport as the drawn signature), not multipart.
*/
export function StampUpload({
value,
onChange,
label = "Company stamp",
description = "Attach your official company stamp or seal.",
}: StampUploadProps) {
const inputRef = useRef<HTMLInputElement>(null);
const [dragging, setDragging] = useState(false);
const [error, setError] = useState<string | null>(null);
const [fileName, setFileName] = useState<string | null>(null);
const readFile = (file: File | undefined | null) => {
if (!file) return;
if (!file.type.startsWith("image/")) {
setError("The stamp must be an image file (PNG or JPG).");
return;
}
if (file.size > MAX_STAMP_MB * 1024 * 1024) {
setError(`The stamp image must be under ${MAX_STAMP_MB} MB.`);
return;
}
const reader = new FileReader();
reader.onload = () => {
setError(null);
setFileName(file.name);
onChange(typeof reader.result === "string" ? reader.result : null);
};
reader.onerror = () => setError("Could not read that file. Try another.");
reader.readAsDataURL(file);
};
const openPicker = () => inputRef.current?.click();
const clear = () => {
setFileName(null);
setError(null);
onChange(null);
if (inputRef.current) inputRef.current.value = "";
};
return (
<Stack gap={6}>
<Text size="sm" fw={500}>
{label}
</Text>
<input
ref={inputRef}
type="file"
accept="image/png,image/jpeg,image/webp"
hidden
onChange={(e) => readFile(e.currentTarget.files?.[0])}
/>
{value ? (
<Paper withBorder radius="md" p="sm">
<Group gap="md" wrap="nowrap" align="center">
<Box
style={{
background:
"repeating-conic-gradient(var(--mantine-color-gray-1) 0% 25%, transparent 0% 50%) 50% / 14px 14px",
borderRadius: 8,
flexShrink: 0,
padding: 6,
}}
>
<Image
src={value}
alt="Company stamp"
fit="contain"
h={92}
w={92}
/>
</Box>
<Stack gap={4} style={{ flex: 1, minWidth: 0 }}>
<Text size="sm" fw={500} truncate>
{fileName ?? "Stamp attached"}
</Text>
<Text size="xs" c="dimmed">
This stamp is applied next to your signature on the contract.
</Text>
<Group gap="xs" mt={2}>
<Button
size="compact-xs"
variant="light"
color="edr-green"
leftSection={<RefreshCw size={13} />}
onClick={openPicker}
>
Replace
</Button>
<Button
size="compact-xs"
variant="subtle"
color="red"
leftSection={<X size={13} />}
onClick={clear}
>
Remove
</Button>
</Group>
</Stack>
</Group>
</Paper>
) : (
<Paper
withBorder
radius="md"
p="lg"
onClick={openPicker}
onDragOver={(e) => {
e.preventDefault();
setDragging(true);
}}
onDragLeave={() => setDragging(false)}
onDrop={(e) => {
e.preventDefault();
setDragging(false);
readFile(e.dataTransfer.files?.[0]);
}}
style={{
borderColor: dragging
? "var(--mantine-color-edr-green-6)"
: undefined,
borderStyle: "dashed",
backgroundColor: dragging
? "var(--mantine-color-edr-green-0)"
: undefined,
cursor: "pointer",
}}
>
<Stack gap={6} align="center">
<Stamp size={26} color="var(--mantine-color-edr-green-6)" />
<Text size="sm" fw={500}>
Upload company stamp
</Text>
<Text size="xs" c="dimmed" ta="center">
{description} Drop an image here or click to browse PNG or JPG,
up to {MAX_STAMP_MB} MB.
</Text>
</Stack>
</Paper>
)}
{error && (
<Text size="xs" c="red.7">
{error}
</Text>
)}
</Stack>
);
}

View File

@@ -923,7 +923,7 @@ export default function ContractDetailPage() {
</Text>
)}
{item.isClearance && (
<Text fz={12} c="orange.7" fw={600}>
<Text fz={12} fw={600} style={{ color: GREEN }}>
Customs service fee billed on your shipment booking
invoice together with the freight
</Text>

View File

@@ -15,7 +15,14 @@ import {
import type { LucideIcon } from "lucide-react";
import type { Freight } from "@edr/types";
import { BORDER, GREEN, GREEN_DARK, INK, MUTED } from "./contract-ui";
import {
BORDER,
GREEN,
GREEN_DARK,
INK,
MUTED,
expiryNoticeDays,
} from "./contract-ui";
/**
* The customer-facing contract journey, in order. This is the *contract track*
@@ -124,15 +131,6 @@ function resolveStep(status: string): StepState {
}
}
/** Days until the contract validity lapses, if any (negative = already lapsed). */
function daysUntil(dateIso?: string | null): number | null {
if (!dateIso) return null;
const end = new Date(dateIso).getTime();
if (Number.isNaN(end)) return null;
const ms = end - Date.now();
return Math.ceil(ms / 86_400_000);
}
export interface ContractStepBannerProps {
contract: Freight.IContract;
}
@@ -145,9 +143,9 @@ export function ContractStepBanner({ contract }: ContractStepBannerProps) {
const { activeIdx, terminal, next } = resolveStep(contract.status);
const isTerminalBad = terminal === "REJECTED" || terminal === "CANCELLED" || terminal === "EXPIRED";
const expiryDays = daysUntil(contract.contractValidUntil);
const expirySoon =
!terminal && expiryDays !== null && expiryDays >= 0 && expiryDays <= 14;
// Same notice window as the list badge and the API's reminder.
const expiryDays = expiryNoticeDays(contract);
const expirySoon = !terminal && expiryDays !== null;
return (
<Box

View File

@@ -28,6 +28,7 @@ import toast from "react-hot-toast";
import { ContractSignSuccessModal } from "@/components/contracts/ContractSignSuccessModal";
import { ContractSignaturePad } from "@/components/bookings/ContractSignaturePad";
import { StampUpload } from "@/components/contracts/StampUpload";
import { contractsService } from "@/services/contracts.service";
import { api } from "@/services/api";
import { extractApiError } from "@/utils/result";
@@ -52,6 +53,7 @@ export default function ContractViewPage() {
const [successOpen, setSuccessOpen] = useState(false);
const [signerName, setSignerName] = useState("");
const [signatureData, setSignatureData] = useState<string | null>(null);
const [stampData, setStampData] = useState<string | null>(null);
const [drawNew, setDrawNew] = useState(false);
const [hasScrolledToBottom, setHasScrolledToBottom] = useState(false);
const [agreedToTerms, setAgreedToTerms] = useState(false);
@@ -138,6 +140,7 @@ export default function ContractViewPage() {
signatureImageBase64: usingSaved
? (savedSignatureImage as string)
: (signatureData as string),
stampImageBase64: stampData as string,
signerDisplayName: signerName.trim(),
consentText: CONSENT_TEXT,
otp: otpCode.trim(),
@@ -161,6 +164,7 @@ export default function ContractViewPage() {
if (!canProceedToSign) return;
setSignerName(data?.savedSignature?.signerDisplayName ?? "");
setSignatureData(null);
setStampData(null);
setDrawNew(false);
setSignOpen(true);
};
@@ -168,7 +172,7 @@ export default function ContractViewPage() {
const confirmSign = () => {
if (!signerName.trim()) return;
const image = usingSaved ? savedSignatureImage : signatureData;
if (!image) return;
if (!image || !stampData) return;
// The server resolves and validates the signer's own contacts; if the
// account has neither phone nor email it returns a clear 400 that surfaces
// via the mutation's onError.
@@ -248,6 +252,15 @@ export default function ContractViewPage() {
</Group>
</Group>
{/* Signed before company stamps were required — re-signing is the only
way to attach one, and EDR cannot counter-sign until it is there. */}
{data.canSignCustomer && data.status === "SIGNED_CUSTOMER" && (
<Alert color="orange" variant="light" radius="md" mb="md">
This contract was signed before a company stamp was required. Please
sign again and attach your stamp so EDR can counter-sign it.
</Alert>
)}
{data.canSignCustomer && !hasScrolledToBottom && (
<Alert color="blue" variant="light" radius="md" mb="md">
Please scroll through the entire contract before signing.
@@ -360,6 +373,13 @@ export default function ContractViewPage() {
) : (
<ContractSignaturePad onChange={setSignatureData} />
)}
<StampUpload
value={stampData}
onChange={setStampData}
description="Attach your official company stamp or seal — it is applied to the contract next to your signature."
/>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={() => setSignOpen(false)}>
Cancel
@@ -370,7 +390,8 @@ export default function ContractViewPage() {
disabled={
sendOtpMutation.isPending ||
!signerName.trim() ||
(!usingSaved && !signatureData)
(!usingSaved && !signatureData) ||
!stampData
}
onClick={confirmSign}
>

View File

@@ -43,6 +43,7 @@ import { usePagination } from "@edr/ui-common";
import {
BORDER,
ContractDocButton,
ContractExpiryBadge,
ContractStatusBadge,
GREEN,
INK,
@@ -600,6 +601,9 @@ export default function ContractsList() {
).toLocaleDateString()
: "—"}
</Text>
{/* Countdown once the contract is inside the notice
window — renders nothing before that. */}
<ContractExpiryBadge contract={c} />
</Table.Td>
<Table.Td>
<ContractStatusBadge status={c.status} />

View File

@@ -70,7 +70,11 @@ import {
Step4Route,
Step8Review,
} from "./new-contract-form/steps";
import { StepCard, StepHeader } from "./new-contract-form/shared";
import {
NotFinalPriceNotice,
StepCard,
StepHeader,
} from "./new-contract-form/shared";
import { formatRateUnit } from "./new-contract-form/unit-rates";
type PriceModalMode = "submit" | "draft";
@@ -225,6 +229,19 @@ export default function NewContractPage({
isAxiosError(persistAndPriceMutation.error) &&
persistAndPriceMutation.error.response?.status === 422;
// 409 from create = an active contract already covers this service type +
// route for this customer — surfaced as a blocking modal, same as above.
const duplicateContract =
isAxiosError(persistAndPriceMutation.error) &&
persistAndPriceMutation.error.response?.status === 409;
// The API bakes the existing contract's reference + valid-until date into
// this message (global exception filter drops any other response fields).
const duplicateContractMessage =
(isAxiosError(persistAndPriceMutation.error) &&
(persistAndPriceMutation.error.response?.data as { message?: string })
?.message) ||
"An active contract already exists for this service type and route.";
const confirmMutation = useMutation({
mutationFn: async () => {
if (!priceContractId) throw new Error("No contract to confirm");
@@ -606,6 +623,11 @@ export default function NewContractPage({
}
: {}),
isHazardous: data.isHazardous,
// The dangerous-goods declaration only travels with the flag — the API
// rejects a hazardous contract that omits either field.
...(data.isHazardous
? { hazardClass: data.hazardClass, unNumber: data.unNumber }
: {}),
// Reefer is a contract-level flag for both container and bulk.
isReefer: data.isRefrigerated,
...(data.previousContractRef
@@ -766,7 +788,9 @@ export default function NewContractPage({
<StepIndicator step={step} steps={visibleSteps} />
</Box>
{persistAndPriceMutation.isError && !rateNotConfigured && (
{persistAndPriceMutation.isError &&
!rateNotConfigured &&
!duplicateContract && (
<Alert
color="red"
icon={<AlertCircle size={16} />}
@@ -806,6 +830,24 @@ export default function NewContractPage({
</Stack>
</Modal>
{/* Duplicate contract (409) — same customer already has a non-expired
contract for this service type + route. Block with a modal. */}
<Modal
opened={duplicateContract}
onClose={() => persistAndPriceMutation.reset()}
title="Contract creation unavailable"
centered
>
<Stack gap="sm">
<Text size="sm">{duplicateContractMessage}</Text>
<Group justify="flex-end">
<Button onClick={() => persistAndPriceMutation.reset()}>
OK
</Button>
</Group>
</Stack>
</Modal>
{/* Step 0 — Setup: operation, contract, service, currency, miles. */}
{step === 0 && (
<StepCard>
@@ -964,15 +1006,14 @@ export default function NewContractPage({
fw={700}
tt="uppercase"
c="edr-green"
mb="xs"
mb="md"
style={{ letterSpacing: "0.06em" }}
>
Pricing schedule
</Text>
<Text size="xs" c="dimmed" mb="md">
Final amount is calculated at booking quantities are unknown
at the contract stage.
</Text>
<Box mb="md">
<NotFinalPriceNotice />
</Box>
<Stack gap={10}>
{pricingData.lineItems.map((item) => (
<Group
@@ -992,7 +1033,7 @@ export default function NewContractPage({
</Text>
)}
{item.isClearance && (
<Text size="xs" c="orange.7" fw={600}>
<Text size="xs" c="edr-green" fw={600}>
Customs service fee billed on your shipment booking
invoice together with the freight
</Text>

View File

@@ -0,0 +1,56 @@
import { describe, expect, it } from "vitest";
import { EXPIRY_NOTICE_DAYS, expiryNoticeDays, expiryNoticeLabel } from "./contract-ui";
const inDays = (days: number): string =>
// Half a day past the boundary so ceil() lands on `days` regardless of the
// clock at test time.
new Date(Date.now() + (days - 0.5) * 86_400_000).toISOString();
const contract = (over: Record<string, unknown> = {}) =>
({
status: "CONTRACT_ACTIVE",
contractValidUntil: inDays(5),
...over,
}) as never;
describe("expiryNoticeDays", () => {
it("counts the days left once inside the notice window", () => {
expect(expiryNoticeDays(contract({ contractValidUntil: inDays(5) }))).toBe(5);
expect(
expiryNoticeDays(
contract({ contractValidUntil: inDays(EXPIRY_NOTICE_DAYS) }),
),
).toBe(EXPIRY_NOTICE_DAYS);
});
it("stays silent while the contract is further out than the window", () => {
expect(
expiryNoticeDays(
contract({ contractValidUntil: inDays(EXPIRY_NOTICE_DAYS + 1) }),
),
).toBeNull();
});
it("stays silent for a contract with no validity date", () => {
expect(expiryNoticeDays(contract({ contractValidUntil: null }))).toBeNull();
});
it("stays silent once the date has passed — that is expiry, not a warning", () => {
expect(expiryNoticeDays(contract({ contractValidUntil: inDays(-1) }))).toBeNull();
});
it("stays silent on contracts that are already over", () => {
for (const status of ["EXPIRED", "CANCELLED", "REJECTED", "CONTRACT_CLOSED"]) {
expect(expiryNoticeDays(contract({ status }))).toBeNull();
}
});
});
describe("expiryNoticeLabel", () => {
it("reads naturally at the edges", () => {
expect(expiryNoticeLabel(0)).toBe("Expires today");
expect(expiryNoticeLabel(1)).toBe("1 day left");
expect(expiryNoticeLabel(10)).toBe("10 days left");
});
});

View File

@@ -1,5 +1,5 @@
import { Box, Group, Paper, Text, Tooltip } from "@mantine/core";
import { FileText } from "lucide-react";
import { AlertTriangle, FileText } from "lucide-react";
import type { LucideIcon } from "lucide-react";
import type { ReactNode } from "react";
import type { Freight } from "@edr/types";
@@ -209,6 +209,88 @@ export function ContractStatusBadge({ status }: { status: string }) {
);
}
/**
* How close to its validity end a contract has to be before the customer is
* warned. The API notifies at the same distance (contract-expiry.service), so
* the inbox message and the list badge agree.
*/
export const EXPIRY_NOTICE_DAYS = 10;
/** Whole days until a date (0 = today, negative = already past). Null if unset. */
export function daysUntil(dateIso?: string | null): number | null {
if (!dateIso) return null;
const end = new Date(dateIso).getTime();
if (Number.isNaN(end)) return null;
return Math.ceil((end - Date.now()) / 86_400_000);
}
/** Contracts that are already over — no point warning about their expiry. */
const CLOSED_CONTRACT_STATUSES = [
"REJECTED",
"CANCELLED",
"CONTRACT_CLOSED",
"ARCHIVED",
"EXPIRED",
];
/**
* Days left on a live contract, but only inside the notice window — null when
* the contract is closed, has no validity date, has already lapsed, or is still
* further out than {@link EXPIRY_NOTICE_DAYS}.
*/
export function expiryNoticeDays(
contract: Pick<Freight.IContract, "status" | "contractValidUntil">,
): number | null {
if (CLOSED_CONTRACT_STATUSES.includes(contract.status)) return null;
const days = daysUntil(contract.contractValidUntil);
if (days == null || days < 0 || days > EXPIRY_NOTICE_DAYS) return null;
return days;
}
/** "Expires today" / "5 days left" — the wording shared by list and banner. */
export function expiryNoticeLabel(days: number): string {
if (days === 0) return "Expires today";
return `${days} day${days === 1 ? "" : "s"} left`;
}
/**
* Amber countdown pill shown on a contract that is about to lapse. Renders
* nothing outside the notice window, so callers can drop it in unconditionally.
*/
export function ContractExpiryBadge({
contract,
}: {
contract: Pick<Freight.IContract, "status" | "contractValidUntil">;
}) {
const days = expiryNoticeDays(contract);
if (days == null) return null;
return (
<Tooltip
withArrow
label="This contract stops accepting bookings when its validity ends. Contact EDR to renew it."
>
<Group
gap={5}
align="center"
wrap="nowrap"
mt={4}
style={{
display: "inline-flex",
borderRadius: 999,
backgroundColor: "#FEF6E7",
border: "1px solid #F5D9A3",
padding: "2px 8px",
}}
>
<AlertTriangle size={12} color="#9A6700" />
<Text fz={11} fw={700} style={{ color: "#9A6700", whiteSpace: "nowrap" }}>
{expiryNoticeLabel(days)}
</Text>
</Group>
</Tooltip>
);
}
/** A labelled value used across the contract detail summary cards. */
export function MetaItem({
label,

View File

@@ -122,6 +122,8 @@ export function contractToFormValues(
bulkQuantityCap:
isGeneral && bulkRow?.quantityCap != null ? bulkRow.quantityCap : 0,
isHazardous: contract.isHazardous,
hazardClass: contract.hazardClass ?? "",
unNumber: contract.unNumber ?? "",
isRefrigerated: contract.isReefer,
originYard: primaryRoute?.originYardId ?? "",

View File

@@ -1,5 +1,6 @@
import { DeepPartial, Path } from "react-hook-form";
import * as z from "zod";
import { HAZARD_CLASS_VALUES } from "@edr/types";
// Wizard steps for the contract creation flow. Condensed to four steps: the
// pickers are dropdown selects so each step fits one screen without scrolling.
@@ -177,6 +178,10 @@ export const contractFormSchema = z
bulkQuantityCap: nonNegativeQuantityCap.default(0),
// Contract-level billing flags.
isHazardous: z.boolean().default(false),
// Dangerous-goods declaration — collected with the hazard documents and
// mandatory whenever isHazardous (enforced in the superRefine below).
hazardClass: z.string().default(""),
unNumber: z.string().default(""),
isRefrigerated: z.boolean().default(false),
// ── Route ── (one route per contract — general contracts included)
@@ -241,6 +246,24 @@ export const contractFormSchema = z
});
}
}
// Hazardous cargo must name its UN/ADR class and UN number — the API
// rejects the contract otherwise, so catch it before the wizard submits.
if (data.isHazardous) {
if (!HAZARD_CLASS_VALUES.includes(data.hazardClass)) {
ctx.addIssue({
code: "custom",
path: ["hazardClass"],
message: "Select the dangerous-goods class.",
});
}
if (!data.unNumber.trim()) {
ctx.addIssue({
code: "custom",
path: ["unNumber"],
message: "Enter the UN number.",
});
}
}
// GENERAL contracts are uncapped: no quantity cap is collected, so the
// customer can book repeatedly until the contract's validity expires. The
// cap fields default to 0/empty and map to quantityCap = NULL (uncapped) at
@@ -271,6 +294,8 @@ export const initialContractFormValues: DeepPartial<ContractFormValues> = {
cargoFreeText: "",
bulkQuantityCap: 0,
isHazardous: false,
hazardClass: "",
unNumber: "",
isRefrigerated: false,
originYard: "",
@@ -307,6 +332,8 @@ export const contractStepFields: Record<
"cargoFreeText",
"bulkQuantityCap",
"isHazardous",
"hazardClass",
"unNumber",
"isRefrigerated",
"originYard",
"destinationYard",

View File

@@ -1,11 +1,15 @@
import {
Box,
Combobox,
Group,
Input,
InputBase,
Select,
Stack,
Text,
useCombobox,
} from "@mantine/core";
import { Loader } from "lucide-react";
import { Loader, Receipt } from "lucide-react";
import type { ReactNode } from "react";
import { useMemo } from "react";
import type {
@@ -84,6 +88,55 @@ export function SelectField<
);
}
/**
* Prominent "these are unit rates, not your bill" banner. Shown wherever the
* customer is looking at contract pricing (review step + quotation modal) —
* the contract quotes per-unit rates only; the payable total is computed at
* booking from the quantities actually shipped.
*/
export function NotFinalPriceNotice() {
return (
<Group
gap={13}
align="flex-start"
wrap="nowrap"
px={16}
py={14}
style={{
borderRadius: 14,
border: "1.5px solid #F2DFB4",
background: "linear-gradient(135deg, #FFF9EC 0%, #FFFDF8 75%)",
}}
>
<Box
style={{
width: 38,
height: 38,
borderRadius: 11,
flexShrink: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "#FBEED0",
color: "#A9741A",
}}
>
<Receipt size={18} />
</Box>
<Stack gap={3} style={{ minWidth: 0 }}>
<Text fz={14} fw={700} c="#10202F">
This is not your final price
</Text>
<Text fz={12.5} c="#6B7C8E" style={{ lineHeight: 1.5 }}>
The figures below are <strong>per-unit rates</strong> for each service
not a total. Your payable amount is calculated on every booking from
the quantities you actually ship, and invoiced then.
</Text>
</Stack>
</Group>
);
}
interface AsyncComboboxOption {
value: string;
label: string;

View File

@@ -3,6 +3,7 @@ import { Controller, type UseFormReturn } from "react-hook-form";
// Snowflake — restore with the Refrigerated Cargo switch below.
import { Container, Flame, RotateCcw } from "lucide-react";
import {
Badge,
Box,
Button,
Group,
@@ -13,10 +14,11 @@ import {
Stack,
Switch,
Text,
TextInput,
} from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import { SmartFileInput } from "@edr/ui-common";
import type { Freight } from "@edr/types";
import { HAZARD_CLASSES, hazardClassLabel, type Freight } from "@edr/types";
import { api } from "@/services/api";
import {
CONTAINER_SIZES,
@@ -25,7 +27,7 @@ import {
type ContractFormValues,
} from "./schema";
import { operationToTradeDirection } from "./helpers";
import { fieldStyles, SelectField, StepLabel } from "./shared";
import { AlertBox, fieldStyles, SelectField, StepLabel } from "./shared";
/**
* file_upload_settings code holding the hazardous-cargo document requirements.
@@ -95,6 +97,18 @@ export function Step3CargoScope({
const [hazardModalOpen, setHazardModalOpen] = useState(false);
const [hazardDraft, setHazardDraft] = useState<ContractDocuments>({});
const [hazardErrors, setHazardErrors] = useState<Record<string, string>>({});
// Dangerous-goods declaration, edited in the modal and only written back to
// the form once the customer confirms — cancelling must leave the contract
// exactly as it was.
const [classDraft, setClassDraft] = useState<string | null>(null);
const [unDraft, setUnDraft] = useState("");
const [declErrors, setDeclErrors] = useState<{
hazardClass?: string;
unNumber?: string;
}>({});
const hazardClass = form.watch("hazardClass");
const unNumber = form.watch("unNumber");
/** Drop every hazardous document from the contract's document map. */
const clearHazardDocs = () => {
@@ -117,6 +131,9 @@ export function Step3CargoScope({
),
);
setHazardErrors({});
setClassDraft(form.getValues("hazardClass") || null);
setUnDraft(form.getValues("unNumber") ?? "");
setDeclErrors({});
setHazardModalOpen(true);
};
@@ -124,14 +141,20 @@ export function Step3CargoScope({
const missing = hazardFields.filter(
(f) => f.isRequired && !hasUploaded(hazardDraft[f.fileKey]),
);
if (missing.length > 0) {
setHazardErrors(
Object.fromEntries(
missing.map((f) => [f.fileKey, `${f.fileLabel} is required.`]),
),
);
return;
const nextDeclErrors: typeof declErrors = {};
if (!classDraft) {
nextDeclErrors.hazardClass = "Select the dangerous-goods class.";
}
if (!unDraft.trim()) nextDeclErrors.unNumber = "Enter the UN number.";
setHazardErrors(
Object.fromEntries(
missing.map((f) => [f.fileKey, `${f.fileLabel} is required.`]),
),
);
setDeclErrors(nextDeclErrors);
if (missing.length > 0 || Object.keys(nextDeclErrors).length > 0) return;
form.setValue(
"documents",
{
@@ -140,16 +163,29 @@ export function Step3CargoScope({
},
{ shouldDirty: true },
);
form.setValue("hazardClass", classDraft!, { shouldDirty: true });
form.setValue("unNumber", unDraft.trim().toUpperCase(), {
shouldDirty: true,
});
form.clearErrors(["hazardClass", "unNumber"]);
form.setValue("isHazardous", true, { shouldDirty: true });
setHazardModalOpen(false);
};
/** Turning the switch off drops the declaration with the documents. */
const clearHazardDeclaration = () => {
form.setValue("hazardClass", "", { shouldDirty: true });
form.setValue("unNumber", "", { shouldDirty: true });
form.clearErrors(["hazardClass", "unNumber"]);
};
// A hidden flag must never leak into the payload: a general contract can't be
// hazardous, and a non-import contract carries neither reefer nor empty return.
useEffect(() => {
if (isOneTime) return;
if (form.getValues("isHazardous")) {
form.setValue("isHazardous", false, { shouldDirty: true });
clearHazardDeclaration();
}
// Runs again once the hazard field list loads — a no-op when nothing matches.
clearHazardDocs();
@@ -356,25 +392,42 @@ export function Step3CargoScope({
name="isHazardous"
control={form.control}
render={({ field }) => (
<ToggleRow
icon={<Flame size={18} />}
iconBg="#FBEAE7"
iconColor="#C0392B"
title="Hazardous Material"
description="Applies a hazard surcharge as a per-container unit rate. Requires hazard documents."
checked={field.value ?? false}
onChange={(v) => {
if (v) {
openHazardModal();
return;
}
field.onChange(false);
clearHazardDocs();
}}
/>
<Stack gap={0}>
<ToggleRow
icon={<Flame size={18} />}
iconBg="#FBEAE7"
iconColor="#C0392B"
title="Hazardous Material"
description="Applies a hazard surcharge as a per-container unit rate. Requires a UN class, UN number and hazard documents."
checked={field.value ?? false}
onChange={(v) => {
if (v) {
openHazardModal();
return;
}
field.onChange(false);
clearHazardDocs();
clearHazardDeclaration();
}}
/>
{field.value && (
<HazardDeclarationSummary
hazardClass={hazardClass}
unNumber={unNumber}
onEdit={openHazardModal}
/>
)}
</Stack>
)}
/>
)}
{!isOneTime && (
<AlertBox tone="info">
Hazardous material can only be declared on a one-time contract.
Switch <strong>Contract Kind</strong> to one-time to carry
hazardous cargo.
</AlertBox>
)}
{/* Refrigerated cargo is hidden for now — import-only when re-enabled.
The effect above keeps isRefrigerated false while it's off.
{isImport && (
@@ -422,17 +475,83 @@ export function Step3CargoScope({
<Modal
opened={hazardModalOpen}
onClose={() => setHazardModalOpen(false)}
title="Hazardous cargo documents"
title={
<Group gap={10} align="center" wrap="nowrap">
<Box
style={{
width: 32,
height: 32,
borderRadius: 10,
flexShrink: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "#FBEAE7",
color: "#C0392B",
}}
>
<Flame size={16} />
</Box>
<Text fw={700} fz={15} c="#10202F">
Hazardous cargo declaration
</Text>
</Group>
}
size="lg"
centered
radius={14}
>
<Stack gap={16}>
<Stack gap={18}>
<Text fz={13} c="#6B7C8E">
Hazardous cargo can only move once the documents below are attached
to the contract.
Dangerous goods move only once the class and UN number are declared
and the documents below are attached to the contract. EDR reviews
this declaration in two dedicated hazardous approval steps.
</Text>
<Stack gap={12}>
<StepLabel>Dangerous-goods declaration</StepLabel>
<div className="grid gap-4 sm:grid-cols-2">
<Select
label="UN / ADR class *"
placeholder="Select the hazard class…"
data={HAZARD_CLASSES.map((c) => ({
value: c.value,
label: c.label,
}))}
value={classDraft}
onChange={(v) => {
setClassDraft(v);
setDeclErrors((e) => ({ ...e, hazardClass: undefined }));
}}
error={declErrors.hazardClass}
allowDeselect={false}
radius={10}
checkIconPosition="right"
comboboxProps={{
withinPortal: true,
shadow: "md",
radius: "md",
}}
styles={fieldStyles}
/>
<TextInput
label="UN number *"
placeholder="e.g. 1203"
description="The four-digit UN number of the dangerous good."
value={unDraft}
onChange={(e) => {
setUnDraft(e.currentTarget.value);
setDeclErrors((err) => ({ ...err, unNumber: undefined }));
}}
error={declErrors.unNumber}
radius={10}
styles={fieldStyles}
/>
</div>
</Stack>
<StepLabel>Hazard documents</StepLabel>
{hazardSettingQuery.isLoading ? (
<Group justify="center" py="lg">
<Loader size="sm" color="edr-green" />
@@ -461,7 +580,7 @@ export function Step3CargoScope({
Cancel
</Button>
<Button color="edr-green" radius={10} onClick={confirmHazardDocs}>
Save &amp; mark hazardous
Save declaration
</Button>
</Group>
</Stack>
@@ -470,6 +589,59 @@ export function Step3CargoScope({
);
}
/**
* Reads back the saved dangerous-goods declaration under the hazard switch, so
* the customer can see (and fix) the class + UN number without reopening the
* modal blind. Rendered only while the contract is flagged hazardous.
*/
function HazardDeclarationSummary({
hazardClass,
unNumber,
onEdit,
}: {
hazardClass?: string;
unNumber?: string;
onEdit: () => void;
}) {
const label = hazardClassLabel(hazardClass);
return (
<Group
justify="space-between"
align="center"
wrap="nowrap"
gap="sm"
px={16}
py={11}
mt={-1}
style={{
borderRadius: "0 0 14px 14px",
border: "1.5px solid #F3D5D0",
borderTop: "none",
background: "#FEF7F6",
}}
>
<Group gap={8} align="center" wrap="wrap" style={{ minWidth: 0 }}>
<Badge color="red" variant="light" radius="sm" size="sm">
{label ?? "Class not set"}
</Badge>
<Badge color="red" variant="light" radius="sm" size="sm">
{unNumber ? `UN ${unNumber}` : "UN number not set"}
</Badge>
</Group>
<Button
variant="subtle"
color="red"
size="compact-sm"
radius={8}
onClick={onEdit}
style={{ flexShrink: 0 }}
>
Edit
</Button>
</Group>
);
}
function ToggleRow({
icon,
iconBg,

View File

@@ -22,12 +22,12 @@ import {
Send,
Truck,
} from "lucide-react";
import type { Freight } from "@edr/types";
import { hazardClassLabel, type Freight } from "@edr/types";
import {
type ContractFormInputValues,
type ContractFormValues,
} from "./schema";
import { StepHeader } from "./shared";
import { NotFinalPriceNotice, StepHeader } from "./shared";
import { formatRateUnit } from "./unit-rates";
type ContractForm = UseFormReturn<
@@ -125,15 +125,14 @@ function UnitRatePanel({
fw={700}
tt="uppercase"
c="edr-green"
mb="xs"
mb="md"
style={{ letterSpacing: "0.06em" }}
>
Pricing schedule
</Text>
<Text size="xs" c="dimmed" mb="md">
Estimated unit rates the final amount is calculated at booking from the
quantities you ship.
</Text>
<Box mb="md">
<NotFinalPriceNotice />
</Box>
<Stack gap={10}>
{lineItems.map((item) => (
<Group
@@ -231,30 +230,28 @@ export function Step8Review({
// Mirror the step-2 gating: imports never truck the first mile, exports never
// truck the last mile, and a service that doesn't bundle a mile can't have it.
// A mile the customer didn't take is left off the summary entirely rather than
// shown as an empty "not applicable" row.
const firstMileValue =
direction === "IMPORT"
? "Not applicable for import"
: serviceType && !serviceType.includesFirstMile
? "Not included in service"
: values.firstMile.enabled
? `${values.firstMile.pickUpAddress || "Pinned"}${
values.firstMile.exactLocation
? ` · ${values.firstMile.exactLocation}`
: ""
}`
: "Not requested";
direction !== "IMPORT" &&
(!serviceType || serviceType.includesFirstMile) &&
values.firstMile.enabled
? `${values.firstMile.pickUpAddress || "Pinned"}${
values.firstMile.exactLocation
? ` · ${values.firstMile.exactLocation}`
: ""
}`
: null;
const lastMileValue =
direction === "EXPORT"
? "Not applicable for export"
: serviceType && !serviceType.includesLastMile
? "Not included in service"
: values.lastMile.enabled
? `${values.lastMile.deliveryAddress || "Pinned"}${
values.lastMile.exactLocation
? ` · ${values.lastMile.exactLocation}`
: ""
}`
: "Not requested";
direction !== "EXPORT" &&
(!serviceType || serviceType.includesLastMile) &&
values.lastMile.enabled
? `${values.lastMile.deliveryAddress || "Pinned"}${
values.lastMile.exactLocation
? ` · ${values.lastMile.exactLocation}`
: ""
}`
: null;
const cargoValue = (() => {
if (values.cargoType === "container") {
@@ -399,16 +396,20 @@ export function Step8Review({
</>
}
/>
<SummaryItem
icon={<Truck size={18} />}
label="First mile — pick-up"
value={firstMileValue}
/>
<SummaryItem
icon={<Truck size={18} />}
label="Last mile — delivery"
value={lastMileValue}
/>
{firstMileValue && (
<SummaryItem
icon={<Truck size={18} />}
label="First mile — pick-up"
value={firstMileValue}
/>
)}
{lastMileValue && (
<SummaryItem
icon={<Truck size={18} />}
label="Last mile — delivery"
value={lastMileValue}
/>
)}
<SummaryItem
icon={<FileText size={18} />}
label="Customs clearing"
@@ -417,7 +418,23 @@ export function Step8Review({
<SummaryItem
icon={<Package size={18} />}
label="Hazardous cargo"
value={values.isHazardous ? "Yes" : "No"}
value={
values.isHazardous ? (
<>
Yes
<Group gap={6} mt={6}>
<Badge size="sm" variant="light" color="red" radius="sm">
{hazardClassLabel(values.hazardClass) ?? "Class —"}
</Badge>
<Badge size="sm" variant="light" color="red" radius="sm">
UN {values.unNumber || "—"}
</Badge>
</Group>
</>
) : (
"No"
)
}
/>
<SummaryItem
icon={<Package size={18} />}

View File

@@ -154,11 +154,12 @@ export function computeShipmentTotal(
}
// Lashing / cargo securing — bulk-only, applies whenever the contract shows
// it (the commodity needs lashing). Per-ton scales by tonnage; per-wagon
// depends on the wagon capacity the train stocks — shown at real pricing.
// it (the commodity needs lashing). Per-ton/per-item scales by the cargo
// amount; per-wagon depends on the wagon capacity the train stocks — shown at
// real pricing.
const lashing = items.find((i) => i.conditionalOn === "has_lashing");
if (lashing && lashing.unit === "per_ton") {
const tons = Number(values.cargoWeightTons || 0);
if (lashing && (lashing.unit === "per_ton" || lashing.unit === "per_item")) {
const tons = Number(values.cargoWeightTons || values.itemCount || 0);
if (tons > 0) {
lines.push({
label: lashing.label,
@@ -184,8 +185,8 @@ export function computeShipmentTotal(
cl.unit === "per_wagon"
? Math.ceil(boxes * (cl.containerSize === "40ft" ? 1 : 0.5))
: boxes;
} else if (cl.unit === "per_ton") {
qty = Number(values.cargoWeightTons || 0);
} else if (cl.unit === "per_ton" || cl.unit === "per_item") {
qty = Number(values.cargoWeightTons || values.itemCount || 0);
} else if (cl.unit === "flat") {
qty = 1;
}

View File

@@ -44,6 +44,7 @@ export interface ContractView {
signerDisplayName: string;
signedAt: string;
signatureImageUrl?: string | null;
stampImageUrl?: string | null;
}>;
/** Current viewer's reusable saved signature, if they have one. */
savedSignature?: {
@@ -124,6 +125,8 @@ export interface SubmitBookingResponse {
export interface SignContractPayload {
role: "CUSTOMER" | "STAFF";
signatureImageBase64: string;
/** Company stamp/seal image; required to sign a contract (not booking contracts). */
stampImageBase64?: string;
signerDisplayName: string;
consentText?: string;
/** Sudo-mode OTP challenge; required when role=CUSTOMER. */

View File

@@ -20,6 +20,38 @@ export enum ContractFreightType {
Bulk = "BULK",
}
/**
* The nine UN/ADR dangerous-goods classes. A hazardous contract must declare
* exactly one class plus the shipment's UN number — both are captured with the
* hazard documents in the portal and reviewed by the two hazardous approvers in
* the backoffice.
*/
export const HAZARD_CLASSES = [
{ value: "CLASS_1", label: "Class 1: Explosives" },
{ value: "CLASS_2", label: "Class 2: Gases" },
{ value: "CLASS_3", label: "Class 3: Flammable Liquids" },
{ value: "CLASS_4", label: "Class 4: Flammable Solids" },
{ value: "CLASS_5", label: "Class 5: Oxidizers and Organic Peroxides" },
{ value: "CLASS_6", label: "Class 6: Toxic and Infectious Substances" },
{ value: "CLASS_7", label: "Class 7: Radioactive Materials" },
{ value: "CLASS_8", label: "Class 8: Corrosive Substances" },
{ value: "CLASS_9", label: "Class 9: Miscellaneous Dangerous Goods" },
] as const;
export type HazardClass = (typeof HAZARD_CLASSES)[number]["value"];
export const HAZARD_CLASS_VALUES: readonly string[] = HAZARD_CLASSES.map(
(c) => c.value,
);
/** Human label for a stored hazard class code; falls back to the raw code. */
export function hazardClassLabel(
value: string | null | undefined,
): string | null {
if (!value) return null;
return HAZARD_CLASSES.find((c) => c.value === value)?.label ?? value;
}
/** Who created a shipment booking under a contract. */
export type BookingCreatedByRole = "CUSTOMER" | "GL_ET" | "STAFF";
@@ -184,6 +216,8 @@ export interface IContractSignature {
role: ContractSignatureRole;
signerDisplayName: string;
signatureFileId?: string | null;
/** Company stamp/seal image, required for the CUSTOMER and STAFF parties. */
stampFileId?: string | null;
consentText?: string | null;
signedAt: string;
}
@@ -426,6 +460,10 @@ export interface ContractClearanceView {
/** Reference + status of the GL-created shipment booking, once it exists. */
linkedBookingReference?: string | null;
linkedBookingStatus?: string | null;
/** Operations' latest "needs changes" note on that booking — GL acts on it. */
linkedBookingReviewNote?: string | null;
/** Shipment day the booking holds; the default when GL resubmits it. */
linkedBookingScheduledDate?: string | null;
dutyAdvice?: {
amount: number;
currency: string;
@@ -659,6 +697,10 @@ export interface IContract extends BaseEntity {
lastMileDeliveryLng?: number | null;
isHazardous: boolean;
/** UN/ADR dangerous-goods class — set only when `isHazardous`. */
hazardClass?: HazardClass | string | null;
/** UN number of the dangerous good — set only when `isHazardous`. */
unNumber?: string | null;
isReefer: boolean;
estimatedShipmentDate?: string | null;
@@ -786,6 +828,10 @@ export interface CreateContractDto {
lastMileDeliveryLng?: number;
isHazardous?: boolean;
/** Required when `isHazardous` — one of HAZARD_CLASSES. */
hazardClass?: string;
/** Required when `isHazardous` — the shipment's UN number. */
unNumber?: string;
isReefer?: boolean;
estimatedShipmentDate?: string;