add company stamp upload functionality for contract signing

- Introduced StampUpload component for uploading company stamp images.
- Integrated stamp upload in contract signing modal, supporting PNG and JPG formats.
- Implemented validation for file type and size (max 5 MB).
- Added visual feedback for drag-and-drop functionality.
- Updated contract-related pages to handle duplicate contract alerts and pricing notices.
- Enhanced contract expiry management with a nightly sweep service.
- Added unit tests for new features and updated existing tests for contract handling.
This commit is contained in:
Marshal
2026-07-25 17:14:58 +00:00
parent 54b5882355
commit fde5e6de4b
68 changed files with 1858 additions and 289 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

@@ -30,6 +30,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. */
@@ -208,6 +210,7 @@ export class ContractDocumentViewModelBuilder {
signerDisplayName: row.signerDisplayName,
signedAt: this.formatDate(row.signedAt),
signatureImageUrl: row.signatureFile?.url ?? null,
stampImageUrl: row.stampFile?.url ?? null,
};
}

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

@@ -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

@@ -0,0 +1,44 @@
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';
/** 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,
) {}
@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

@@ -928,23 +928,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;
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 {
if (sig.signatureImageUrl.startsWith('data:')) continue;
const objectName = this.minioService.getObjectNameFromUrl(
sig.signatureImageUrl,
);
const objectName = this.minioService.getObjectNameFromUrl(url);
const stream = await this.minioService.getFileStream(objectName);
const buffer = await this.streamToBuffer(stream);
sig.signatureImageUrl = `data:image/png;base64,${buffer.toString('base64')}`;
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 {
/* keep original url */
}
return url;
}
}
@@ -959,6 +977,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 +1043,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 +1072,7 @@ export class ContractTransitionService {
signerDisplayName,
signedAt: new Date(),
signatureFileId: fileRecord.id,
stampFileId: stampRecord?.id ?? null,
consentText: dto.consentText ?? null,
});
@@ -1127,6 +1185,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,46 @@ 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;
}
/** 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 +475,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 +486,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

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

@@ -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,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'),
];
/**
@@ -401,6 +404,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',
@@ -781,6 +785,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

@@ -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,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

@@ -294,6 +294,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",
@@ -477,6 +478,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

@@ -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);
};
@@ -889,7 +912,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",

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

@@ -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.
@@ -360,6 +364,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 +381,8 @@ export default function ContractViewPage() {
disabled={
sendOtpMutation.isPending ||
!signerName.trim() ||
(!usingSaved && !signatureData)
(!usingSaved && !signatureData) ||
!stampData
}
onClick={confirmSign}
>

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");
@@ -766,7 +783,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 +825,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 +1001,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 +1028,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

@@ -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

@@ -25,7 +25,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.
@@ -375,6 +375,13 @@ export function Step3CargoScope({
)}
/>
)}
{!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 && (

View File

@@ -27,7 +27,7 @@ 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
direction !== "IMPORT" &&
(!serviceType || serviceType.includesFirstMile) &&
values.firstMile.enabled
? `${values.firstMile.pickUpAddress || "Pinned"}${
values.firstMile.exactLocation
? ` · ${values.firstMile.exactLocation}`
: ""
}`
: "Not requested";
: null;
const lastMileValue =
direction === "EXPORT"
? "Not applicable for export"
: serviceType && !serviceType.includesLastMile
? "Not included in service"
: values.lastMile.enabled
direction !== "EXPORT" &&
(!serviceType || serviceType.includesLastMile) &&
values.lastMile.enabled
? `${values.lastMile.deliveryAddress || "Pinned"}${
values.lastMile.exactLocation
? ` · ${values.lastMile.exactLocation}`
: ""
}`
: "Not requested";
: null;
const cargoValue = (() => {
if (values.cargoType === "container") {
@@ -399,16 +396,20 @@ export function Step8Review({
</>
}
/>
{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"

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

@@ -184,6 +184,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;
}