mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 21:50:57 +00:00
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:
@@ -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 commodity’s 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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
try {
|
||||
if (sig.signatureImageUrl.startsWith('data:')) continue;
|
||||
const objectName = this.minioService.getObjectNameFromUrl(
|
||||
sig.signatureImageUrl,
|
||||
);
|
||||
const stream = await this.minioService.getFileStream(objectName);
|
||||
const buffer = await this.streamToBuffer(stream);
|
||||
sig.signatureImageUrl = `data:image/png;base64,${buffer.toString('base64')}`;
|
||||
} catch {
|
||||
/* keep original url */
|
||||
}
|
||||
sig.signatureImageUrl = await this.inlineImageUrl(sig.signatureImageUrl);
|
||||
sig.stampImageUrl = await this.inlineImageUrl(sig.stampImageUrl);
|
||||
}
|
||||
}
|
||||
|
||||
/** MinIO URL → data URI. Returns the input unchanged if absent or on failure. */
|
||||
private async inlineImageUrl(
|
||||
url?: string | null,
|
||||
): Promise<string | null | undefined> {
|
||||
if (!url || url.startsWith('data:')) return url;
|
||||
try {
|
||||
const objectName = this.minioService.getObjectNameFromUrl(url);
|
||||
const stream = await this.minioService.getFileStream(objectName);
|
||||
const buffer = await this.streamToBuffer(stream);
|
||||
const extension = objectName.split('.').pop()?.toLowerCase();
|
||||
const mime =
|
||||
extension === 'jpg' || extension === 'jpeg'
|
||||
? 'image/jpeg'
|
||||
: extension === 'webp'
|
||||
? 'image/webp'
|
||||
: 'image/png';
|
||||
return `data:${mime};base64,${buffer.toString('base64')}`;
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -959,6 +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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'],
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
import { IsArray, IsUUID } from 'class-validator';
|
||||
|
||||
export class ReorderWagonsDto {
|
||||
@IsArray()
|
||||
@IsUUID(4, { each: true })
|
||||
wagonIds!: string[];
|
||||
}
|
||||
@@ -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 train‑specific 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user