mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 14:50:57 +00:00
changes
This commit is contained in:
@@ -1003,18 +1003,26 @@ export class BookingTransitionService {
|
||||
}
|
||||
|
||||
// The binding shipment day must have at least one OPEN departure on the
|
||||
// route — only schedule-backed days are selectable. The batch engine
|
||||
// assigns the specific train within that (route, day) pool later.
|
||||
const hasDeparture = await this.bookingsService.hasOpenDepartureOnDay(
|
||||
booking.originYardId,
|
||||
booking.destinationYardId,
|
||||
eatDay(date),
|
||||
);
|
||||
// route — only schedule-backed days are selectable — AND some departure
|
||||
// that day must be able to physically carry this cargo type (wagon-TYPE
|
||||
// gate; quantity never blocks — oversized bookings get a partial split
|
||||
// offer). The batch engine assigns the specific train within that
|
||||
// (route, day) pool later.
|
||||
const { hasDeparture, hasCompatible } =
|
||||
await this.bookingsService.checkDayCompatibilityForBooking(
|
||||
booking,
|
||||
eatDay(date),
|
||||
);
|
||||
if (!hasDeparture) {
|
||||
throw new BadRequestException(
|
||||
"No departures available on the selected day for this route",
|
||||
);
|
||||
}
|
||||
if (!hasCompatible) {
|
||||
throw new BadRequestException(
|
||||
"No wagon on the selected day can carry this cargo type — please choose another day",
|
||||
);
|
||||
}
|
||||
|
||||
await this.bookingsRepository.update(bookingId, {
|
||||
status: "OPERATION_REQUEST_PENDING",
|
||||
|
||||
@@ -348,6 +348,28 @@ export class BookingsController {
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Get(':id/available-days')
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Days bookable for THIS booking (cargo-aware wagon-TYPE gate; days only, no capacity counts)',
|
||||
})
|
||||
async availableDays(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
const booking = await this.bookingsService.findById(id);
|
||||
if (
|
||||
!hasFreightPermission(user, FREIGHT_PERMS.bookings.view) &&
|
||||
!hasFreightPermission(user, FREIGHT_PERMS.bookings.clearanceView)
|
||||
) {
|
||||
await this.bookingsService.assertCustomerCanAccessBooking(
|
||||
user?.id,
|
||||
booking,
|
||||
);
|
||||
}
|
||||
return this.bookingsService.availableDaysForBooking(id);
|
||||
}
|
||||
|
||||
@Get(':id/mile-summary')
|
||||
@ApiOperation({
|
||||
summary: 'First/last-mile operational summary for a booking (customer-safe)',
|
||||
|
||||
@@ -653,22 +653,37 @@ export class BookingsService {
|
||||
} else if (dto.scheduledDate) {
|
||||
// A real (binding) scheduledDate was supplied (e.g. staff pinning a day
|
||||
// directly). Require that the route has at least one OPEN departure on
|
||||
// that EAT day. The booking wizard does NOT send scheduledDate at creation
|
||||
// — it captures a non-binding estimatedShipmentDate instead, and the
|
||||
// binding day is chosen later at the operation-request step. General
|
||||
// contracts also skip this (each drawdown order validates its own day).
|
||||
// that EAT day AND that some departure that day can physically carry the
|
||||
// cargo (wagon-TYPE gate — quantity never blocks; oversized bookings get
|
||||
// a partial split offer later). The booking wizard does NOT send
|
||||
// scheduledDate at creation — it captures a non-binding
|
||||
// estimatedShipmentDate instead, and the binding day is chosen later at
|
||||
// the operation-request step. General contracts also skip this (each
|
||||
// drawdown order validates its own day).
|
||||
const day = eatDay(new Date(dto.scheduledDate));
|
||||
const hasDeparture =
|
||||
await this.trainSchedulingService.existsOpenScheduleOnRouteDay(
|
||||
const { hasDeparture, hasCompatible } =
|
||||
await this.trainSchedulingService.checkDayCargoCompatibility(
|
||||
dto.originYardId,
|
||||
dto.destinationYardId,
|
||||
day,
|
||||
{
|
||||
freightType: dto.freightType as 'CONTAINER' | 'BULK',
|
||||
cargoTypeId: dto.cargoTypeId,
|
||||
containerTypeIds: (dto.containers ?? [])
|
||||
.map((c) => c.containerTypeId)
|
||||
.filter((id): id is string => Boolean(id)),
|
||||
},
|
||||
);
|
||||
if (!hasDeparture) {
|
||||
throw new BadRequestException(
|
||||
'No departures available on the selected day for this route',
|
||||
);
|
||||
}
|
||||
if (!hasCompatible) {
|
||||
throw new BadRequestException(
|
||||
'No wagon on the selected day can carry this cargo type — please choose another day',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const containers = dto.containers ?? [];
|
||||
@@ -1149,6 +1164,52 @@ export class BookingsService {
|
||||
);
|
||||
}
|
||||
|
||||
/** Cargo identity of a booking for the wagon-TYPE compatibility gate. */
|
||||
private cargoIdentityOf(booking: Booking): {
|
||||
freightType: 'CONTAINER' | 'BULK';
|
||||
cargoTypeId?: string | null;
|
||||
containerTypeIds?: string[];
|
||||
} {
|
||||
return {
|
||||
freightType: booking.freightType as 'CONTAINER' | 'BULK',
|
||||
cargoTypeId: booking.cargoTypeId ?? null,
|
||||
containerTypeIds: (booking.bookingContainers ?? [])
|
||||
.map((line) => line.containerTypeId)
|
||||
.filter((id): id is string => Boolean(id)),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Day gate for a specific booking: OPEN departure exists AND some departure
|
||||
* that day can physically carry the booking's cargo/container type.
|
||||
* Quantity never blocks — oversized bookings get a partial split offer.
|
||||
*/
|
||||
async checkDayCompatibilityForBooking(
|
||||
booking: Booking,
|
||||
day: string,
|
||||
): Promise<{ hasDeparture: boolean; hasCompatible: boolean }> {
|
||||
return this.trainSchedulingService.checkDayCargoCompatibility(
|
||||
booking.originYardId,
|
||||
booking.destinationYardId,
|
||||
day,
|
||||
this.cargoIdentityOf(booking),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Days the customer may pick for THIS booking (operation-request step):
|
||||
* cargo-aware — only days whose departures can carry the booking's cargo
|
||||
* type. Returns days only, no capacity counts.
|
||||
*/
|
||||
async availableDaysForBooking(bookingId: string): Promise<{ days: string[] }> {
|
||||
const booking = await this.findById(bookingId);
|
||||
return this.trainSchedulingService.getAvailableDaysForCargo({
|
||||
originYardId: booking.originYardId,
|
||||
destinationYardId: booking.destinationYardId,
|
||||
...this.cargoIdentityOf(booking),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Batched version of the findById flag: marks each page item whose booking
|
||||
* has a generated-but-unsigned SELF_HAUL handover, so list rows (portal
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
Injectable,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { Readable } from 'stream';
|
||||
import { insertWithGeneratedReference } from '@edr/api-common';
|
||||
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
||||
@@ -21,16 +22,35 @@ import { DropdownSettingsService } from '../dropdown-settings/dropdown-settings.
|
||||
import { FilesService } from '../files/files.service';
|
||||
import { SignaturesService } from '../signatures/signatures.service';
|
||||
import { OtpService } from '../otp/otp.service';
|
||||
import { ContractTemplatesService } from '../contract-templates/contract-templates.service';
|
||||
import { ContractPricingService } from './contract-pricing.service';
|
||||
import { ContractNotifierService } from './contract-notifier.service';
|
||||
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
||||
import { ContractsRepository } from './contracts.repository';
|
||||
import { ContractsService } from './contracts.service';
|
||||
import { contractClearanceSettingCode } from './contract-clearance.util';
|
||||
import { Contract } from './entities/contract.entity';
|
||||
import {
|
||||
Contract,
|
||||
ContractDocumentArticle,
|
||||
ContractDocumentSnapshot,
|
||||
ContractDocumentSnapshotInput,
|
||||
} from './entities/contract.entity';
|
||||
import { ContractSignerRole } from './entities/contract-signature.entity';
|
||||
import { SignContractDto } from './dto/sign-contract.dto';
|
||||
|
||||
/** The editable contract-document draft returned for the accept/edit dialog. */
|
||||
export interface ContractDocumentDraft {
|
||||
documentTitle: string | null;
|
||||
whereasClauses: string[];
|
||||
articles: ContractDocumentArticle[];
|
||||
code: string | null;
|
||||
name: string | null;
|
||||
/** True once the document may no longer be edited/regenerated. */
|
||||
locked: boolean;
|
||||
generatedAt: Date | null;
|
||||
status: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dropdown-settings code holding the admin-configured contract validity options
|
||||
* (each option's `value` is a day count). The staff accept dialog reads the same
|
||||
@@ -68,6 +88,7 @@ export class ContractTransitionService {
|
||||
private readonly minioService: MinioService,
|
||||
private readonly otpService: OtpService,
|
||||
private readonly notifier: ContractNotifierService,
|
||||
private readonly contractTemplates: ContractTemplatesService,
|
||||
) {}
|
||||
|
||||
/** Customer submits the contract for approval → SUBMITTED; freeze unit rates. */
|
||||
@@ -110,6 +131,7 @@ export class ContractTransitionService {
|
||||
contractId: string,
|
||||
actorId: string,
|
||||
validityDays: number,
|
||||
documentSnapshot?: ContractDocumentSnapshotInput | null,
|
||||
): Promise<Contract> {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
assertContractStatus(contract, ['SUBMITTED']);
|
||||
@@ -128,6 +150,12 @@ export class ContractTransitionService {
|
||||
|
||||
await this.instantiateApprovalSteps(contract);
|
||||
|
||||
// Freeze the contract document for THIS contract only. Staff may have edited
|
||||
// the articles in the accept dialog; otherwise the live template is captured
|
||||
// as-is so later template edits never change an in-flight contract. The
|
||||
// shared six templates are never written here.
|
||||
const snapshot = await this.resolveDocumentSnapshot(contract, documentSnapshot);
|
||||
|
||||
await this.contractsRepository.update(contractId, {
|
||||
status: 'PENDING_APPROVAL',
|
||||
approvedByStaffId: actorId,
|
||||
@@ -135,12 +163,148 @@ export class ContractTransitionService {
|
||||
contractValidityDays: validityDays,
|
||||
contractValidFrom: validFrom,
|
||||
contractValidUntil: validUntil,
|
||||
documentSnapshot: snapshot,
|
||||
} as never);
|
||||
const updated = await this.contractsService.findById(contractId);
|
||||
this.notifier.accepted(updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
// ── Per-contract document snapshot (US: edit articles for one contract) ─────
|
||||
|
||||
/**
|
||||
* The editable document draft for the accept/edit dialog: the frozen snapshot
|
||||
* if one exists, else the live active template resolved for this contract's
|
||||
* direction/freight pair. `locked` flips true once the document may no longer
|
||||
* be edited (an approver has acted, or the contract has left the pre-approval
|
||||
* window).
|
||||
*/
|
||||
async getContractDocumentDraft(
|
||||
contractId: string,
|
||||
): Promise<ContractDocumentDraft> {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
const snapshot =
|
||||
(contract.documentSnapshot as ContractDocumentSnapshot | null) ??
|
||||
(await this.resolveDocumentSnapshot(contract));
|
||||
return {
|
||||
documentTitle: snapshot?.documentTitle ?? null,
|
||||
whereasClauses: snapshot?.whereasClauses ?? [],
|
||||
articles: snapshot?.articles ?? [],
|
||||
code: snapshot?.code ?? null,
|
||||
name: snapshot?.name ?? null,
|
||||
locked: !this.documentIsEditable(contract),
|
||||
generatedAt: contract.contractGeneratedAt ?? null,
|
||||
status: contract.status,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace this contract's document articles from the editor. Per-contract
|
||||
* only — it writes the contract's own snapshot and never the shared templates.
|
||||
* Allowed while the document is still editable (PENDING_APPROVAL, no approver
|
||||
* has acted).
|
||||
*/
|
||||
async updateContractDocument(
|
||||
contractId: string,
|
||||
input: ContractDocumentSnapshotInput,
|
||||
): Promise<Contract> {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
assertContractStatus(contract, ['PENDING_APPROVAL']);
|
||||
this.assertDocumentEditable(contract);
|
||||
|
||||
const current =
|
||||
(contract.documentSnapshot as ContractDocumentSnapshot | null) ??
|
||||
(await this.resolveDocumentSnapshot(contract));
|
||||
const merged: ContractDocumentSnapshotInput = {
|
||||
code: current?.code ?? null,
|
||||
name: input.name ?? current?.name ?? null,
|
||||
documentTitle: input.documentTitle ?? current?.documentTitle ?? null,
|
||||
whereasClauses: input.whereasClauses ?? current?.whereasClauses ?? [],
|
||||
articles: input.articles ?? current?.articles ?? [],
|
||||
};
|
||||
await this.contractsRepository.update(contractId, {
|
||||
documentSnapshot: this.normalizeSnapshot(merged),
|
||||
} as never);
|
||||
return this.contractsService.findById(contractId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the per-contract document snapshot. Prefer the staff's edited articles
|
||||
* from the dialog; otherwise freeze the active template matching the
|
||||
* contract's direction/freight. Returns null when no active template exists
|
||||
* (the renderer then falls back to the built-in generic layout at render time).
|
||||
*/
|
||||
private async resolveDocumentSnapshot(
|
||||
contract: Contract,
|
||||
provided?: ContractDocumentSnapshotInput | null,
|
||||
): Promise<ContractDocumentSnapshot | null> {
|
||||
if (provided && (provided.articles?.length ?? 0) > 0) {
|
||||
return this.normalizeSnapshot(provided);
|
||||
}
|
||||
const active = await this.contractTemplates.findActiveForContract(
|
||||
contract.tradeDirection,
|
||||
contract.freightType,
|
||||
);
|
||||
if (!active) return null;
|
||||
return {
|
||||
code: active.code,
|
||||
name: active.name,
|
||||
documentTitle: active.documentTitle,
|
||||
whereasClauses: active.whereasClauses ?? [],
|
||||
articles: this.normalizeArticles(active.articles ?? []),
|
||||
};
|
||||
}
|
||||
|
||||
private normalizeSnapshot(
|
||||
input: ContractDocumentSnapshotInput,
|
||||
): ContractDocumentSnapshot {
|
||||
return {
|
||||
code: input.code ?? null,
|
||||
name: input.name ?? null,
|
||||
documentTitle: input.documentTitle ?? null,
|
||||
whereasClauses: Array.isArray(input.whereasClauses)
|
||||
? input.whereasClauses
|
||||
.map((c) => String(c))
|
||||
.filter((c) => c.trim().length > 0)
|
||||
: [],
|
||||
articles: this.normalizeArticles(input.articles ?? []),
|
||||
};
|
||||
}
|
||||
|
||||
/** Re-key ids and renumber order sequentially, dropping empty-title rows. */
|
||||
private normalizeArticles(
|
||||
articles: Array<{ id?: string; title?: string; body?: string; order?: number }>,
|
||||
): ContractDocumentArticle[] {
|
||||
return articles
|
||||
.filter((a) => (a.title ?? '').trim().length > 0 || (a.body ?? '').trim().length > 0)
|
||||
.map((a, index) => ({
|
||||
id: a.id ?? randomUUID(),
|
||||
title: (a.title ?? '').trim(),
|
||||
body: a.body ?? '',
|
||||
order: index + 1,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* The per-contract document may be edited/regenerated while the contract is at
|
||||
* the accept stage (SUBMITTED) or in approval with NO approver having acted
|
||||
* yet. The first approval action freezes it.
|
||||
*/
|
||||
private documentIsEditable(contract: Contract): boolean {
|
||||
if (contract.status === 'SUBMITTED') return true;
|
||||
if (contract.status !== 'PENDING_APPROVAL') return false;
|
||||
return !(contract.approvalSteps ?? []).some((s) => s.status !== 'PENDING');
|
||||
}
|
||||
|
||||
private assertDocumentEditable(contract: Contract): void {
|
||||
if (!this.documentIsEditable(contract)) {
|
||||
throw new ConflictException(
|
||||
'The contract document is locked — an approver has already acted or the ' +
|
||||
'contract has advanced. It can no longer be edited or regenerated.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the chosen validity (days) is one of the admin-configured options in
|
||||
* the `contract_validity_periods` dropdown setting. If the setting is missing
|
||||
@@ -303,6 +467,15 @@ export class ContractTransitionService {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
assertContractStatus(contract, ['PENDING_APPROVAL', 'APPROVED_PENDING_SIGNATURE']);
|
||||
|
||||
// Approvers review the generated contract document, so it must exist before
|
||||
// the first approval can be recorded. Staff generate it (from the frozen,
|
||||
// optionally-edited snapshot) at the accept stage.
|
||||
if (contract.status === 'PENDING_APPROVAL' && !contract.contractGeneratedAt) {
|
||||
throw new BadRequestException(
|
||||
'Generate the contract document before it can be approved.',
|
||||
);
|
||||
}
|
||||
|
||||
const step = await this.contractsRepository.findApprovalStepById(contractId, stepId);
|
||||
if (!step || step.status !== 'PENDING') {
|
||||
throw new BadRequestException('Approval step not found or already actioned');
|
||||
@@ -350,15 +523,14 @@ export class ContractTransitionService {
|
||||
const updated = await this.contractsService.findById(contractId);
|
||||
if (allDone) {
|
||||
this.notifier.approved(updated);
|
||||
// Final approval step also generates the contract document from the
|
||||
// template matching the contract's direction/freight pair. Best-effort:
|
||||
// a rendering hiccup must not roll back the approval — the document can
|
||||
// still be generated manually or lazily on view/download.
|
||||
// Every step approved → CONTRACT_READY. The document was already generated
|
||||
// (and reviewed) at the accept stage, so we reuse it rather than
|
||||
// re-rendering. Best-effort: a hiccup must not roll back the approval.
|
||||
try {
|
||||
return await this.generateContract(contractId);
|
||||
return await this.finalizeApprovedContract(contractId);
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Auto contract generation after final approval failed for ${updated.reference}: ${err}`,
|
||||
`Finalizing contract after final approval failed for ${updated.reference}: ${err}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -366,30 +538,66 @@ export class ContractTransitionService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the contract PDF from the Contract aggregate, store it via FilesService,
|
||||
* stamp the template key, and move to CONTRACT_READY. PDF rendering (Puppeteer/
|
||||
* Chromium) is best-effort and must NOT block the contract from becoming ready —
|
||||
* the document is (re)rendered lazily on view/download once Chromium is available.
|
||||
* Staff (re)generate the contract PDF. Two stages:
|
||||
* - PENDING_APPROVAL: render from the frozen (optionally staff-edited)
|
||||
* snapshot so approvers review the real document. Status is UNCHANGED, and
|
||||
* it is blocked once an approver has acted (the document is then locked).
|
||||
* - APPROVED / APPROVED_PENDING_SIGNATURE (fallback): render and advance to
|
||||
* CONTRACT_READY.
|
||||
* PDF rendering (Puppeteer/Chromium) is best-effort and never blocks the
|
||||
* transition — the document re-renders lazily on view/download.
|
||||
*/
|
||||
async generateContract(contractId: string): Promise<Contract> {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
|
||||
if (contract.status === 'PENDING_APPROVAL') {
|
||||
this.assertDocumentEditable(contract);
|
||||
await this.renderContractDocument(contract);
|
||||
return this.contractsService.findById(contractId);
|
||||
}
|
||||
|
||||
assertContractStatus(contract, ['APPROVED', 'APPROVED_PENDING_SIGNATURE']);
|
||||
await this.renderContractDocument(contract);
|
||||
await this.contractsRepository.update(contractId, {
|
||||
status: 'CONTRACT_READY',
|
||||
} as never);
|
||||
return this.contractsService.findById(contractId);
|
||||
}
|
||||
|
||||
const { view } = await this.documentViewModelBuilder.build(contractId);
|
||||
|
||||
/**
|
||||
* Render the contract PDF from the Contract aggregate (snapshot-driven), store
|
||||
* it via FilesService, and stamp the template key + generated timestamp. Never
|
||||
* changes status. Rendering is best-effort — a Chromium hiccup defers the file
|
||||
* (it re-renders on view/download) but the timestamp is still stamped.
|
||||
*/
|
||||
private async renderContractDocument(contract: Contract): Promise<void> {
|
||||
const { view } = await this.documentViewModelBuilder.build(contract.id);
|
||||
try {
|
||||
await this.upsertContractPdf(contractId, contract.reference, view);
|
||||
await this.upsertContractPdf(contract.id, contract.reference, view);
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Contract PDF deferred for ${contract.reference}: ${err}. It will render on view/download once Chromium is available.`,
|
||||
);
|
||||
}
|
||||
|
||||
await this.contractsRepository.update(contractId, {
|
||||
status: 'CONTRACT_READY',
|
||||
await this.contractsRepository.update(contract.id, {
|
||||
contractTemplateKey: view.templateKey,
|
||||
contractGeneratedAt: new Date(),
|
||||
} as never);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every approval step landed → CONTRACT_READY. The document was already
|
||||
* generated (and reviewed) at the accept stage, so reuse it; render now only
|
||||
* if it was somehow never generated. Never re-renders over an existing file.
|
||||
*/
|
||||
private async finalizeApprovedContract(contractId: string): Promise<Contract> {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
if (!contract.contractGeneratedAt) {
|
||||
await this.renderContractDocument(contract);
|
||||
}
|
||||
await this.contractsRepository.update(contractId, {
|
||||
status: 'CONTRACT_READY',
|
||||
} as never);
|
||||
return this.contractsService.findById(contractId);
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
Res,
|
||||
UnauthorizedException,
|
||||
@@ -57,6 +58,7 @@ import { UpdateContractDto } from './dto/update-contract.dto';
|
||||
import { FilterContractDto } from './dto/filter-contract.dto';
|
||||
import { ContractListSummaryDto } from './dto/contract-list-summary.dto';
|
||||
import { AcceptContractDto } from './dto/accept-contract.dto';
|
||||
import { UpdateContractDocumentDto } from './dto/contract-document.dto';
|
||||
import {
|
||||
ApproveStepDto,
|
||||
RejectContractDto,
|
||||
@@ -340,9 +342,33 @@ export class ContractsController {
|
||||
id,
|
||||
resolveAuthUserId(user),
|
||||
dto.validityDays,
|
||||
dto.documentSnapshot,
|
||||
);
|
||||
}
|
||||
|
||||
@Get(':id/document/draft')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.staffAccept)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Editable contract-document draft (this contract\'s snapshot, or the live template) for the accept/edit dialog',
|
||||
})
|
||||
getContractDocumentDraft(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.transitionService.getContractDocumentDraft(id);
|
||||
}
|
||||
|
||||
@Put(':id/document/articles')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.staffAccept)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Edit this contract\'s document articles only (per-contract; never touches the six shared templates)',
|
||||
})
|
||||
updateContractDocument(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: UpdateContractDocumentDto,
|
||||
) {
|
||||
return this.transitionService.updateContractDocument(id, dto);
|
||||
}
|
||||
|
||||
@Post(':id/staff/request-changes')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.requestChanges)
|
||||
@ApiOperation({ summary: 'Staff return contract for customer updates' })
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsInt, Max, Min } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsInt, IsOptional, Max, Min, ValidateNested } from 'class-validator';
|
||||
|
||||
import { UpdateContractDocumentDto } from './contract-document.dto';
|
||||
|
||||
export class AcceptContractDto {
|
||||
@ApiProperty({
|
||||
@@ -14,4 +17,16 @@ export class AcceptContractDto {
|
||||
@Min(1)
|
||||
@Max(3650)
|
||||
validityDays!: number;
|
||||
|
||||
/**
|
||||
* Optional per-contract document override edited by staff in the accept
|
||||
* dialog. When present its articles are frozen onto THIS contract; when
|
||||
* omitted the live template is snapshotted as-is. Never edits the shared
|
||||
* six templates.
|
||||
*/
|
||||
@ApiPropertyOptional({ type: UpdateContractDocumentDto })
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => UpdateContractDocumentDto)
|
||||
documentSnapshot?: UpdateContractDocumentDto;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsArray,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
|
||||
/** One article of a per-contract document override sent from the editor. */
|
||||
export class ContractDocumentArticleDto {
|
||||
@ApiPropertyOptional({ description: 'Stable id; omitted for a new article.' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
id?: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
title!: string;
|
||||
|
||||
@ApiProperty({ description: 'Plain multiline body; each line becomes a clause.' })
|
||||
@IsString()
|
||||
body!: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
order?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The per-contract document override sent from the accept/edit editor. It edits
|
||||
* ONLY this contract's frozen snapshot — it is never written back to the shared
|
||||
* six {@link ContractTemplate} rows.
|
||||
*/
|
||||
export class UpdateContractDocumentDto {
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
code?: string | null;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
name?: string | null;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
documentTitle?: string | null;
|
||||
|
||||
@ApiPropertyOptional({ type: [String] })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
whereasClauses?: string[];
|
||||
|
||||
@ApiProperty({ type: [ContractDocumentArticleDto] })
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => ContractDocumentArticleDto)
|
||||
articles!: ContractDocumentArticleDto[];
|
||||
}
|
||||
@@ -42,6 +42,43 @@ export const CONTRACT_STATUSES = [
|
||||
|
||||
export type ContractStatus = (typeof CONTRACT_STATUSES)[number];
|
||||
|
||||
/** One article on a per-contract document snapshot (mirrors the template shape). */
|
||||
export interface ContractDocumentArticle {
|
||||
id: string;
|
||||
title: string;
|
||||
body: string;
|
||||
order: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A per-contract copy of the resolved contract-document template, frozen when
|
||||
* staff accept the contract for approval. Staff may edit these articles for a
|
||||
* single contract in the accept/edit dialog — editing NEVER writes back to the
|
||||
* shared six {@link ContractTemplate} rows. The PDF is rendered from this
|
||||
* snapshot when present; a null snapshot renders from the live template.
|
||||
*/
|
||||
export interface ContractDocumentSnapshot {
|
||||
code?: string | null;
|
||||
name?: string | null;
|
||||
documentTitle?: string | null;
|
||||
whereasClauses: string[];
|
||||
articles: ContractDocumentArticle[];
|
||||
}
|
||||
|
||||
/** Loose inbound shape (article ids/order optional) — normalized before store. */
|
||||
export interface ContractDocumentSnapshotInput {
|
||||
code?: string | null;
|
||||
name?: string | null;
|
||||
documentTitle?: string | null;
|
||||
whereasClauses?: string[];
|
||||
articles?: Array<{
|
||||
id?: string;
|
||||
title?: string;
|
||||
body?: string;
|
||||
order?: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export const CONTRACT_KINDS = ['ONE_TIME', 'GENERAL'] as const;
|
||||
export type ContractKindValue = (typeof CONTRACT_KINDS)[number];
|
||||
|
||||
@@ -193,6 +230,14 @@ export class Contract extends BaseEntity {
|
||||
@Column({ name: 'contract_generated_at', type: 'timestamptz', nullable: true })
|
||||
contractGeneratedAt?: Date | null;
|
||||
|
||||
/**
|
||||
* Per-contract frozen copy of the document template (articles + WHEREAS),
|
||||
* captured at staff accept. Editing it affects only this contract, never the
|
||||
* shared six templates. Null → the PDF renders from the live template.
|
||||
*/
|
||||
@Column({ name: 'document_snapshot', type: 'jsonb', nullable: true })
|
||||
documentSnapshot?: ContractDocumentSnapshot | null;
|
||||
|
||||
@Column({ name: 'contract_summary', type: 'text', nullable: true })
|
||||
contractSummary?: string | null;
|
||||
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Post,
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiQuery, 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 { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
|
||||
import {
|
||||
DecidePriorityRuleChangeDto,
|
||||
SubmitPriorityRuleChangeDto,
|
||||
} from '../dto/priority-rule-change-request.dto';
|
||||
import { PriorityRuleChangeStatus } from '../entities/priority-rule-change-request.entity';
|
||||
import { PriorityRuleChangeRequestsService } from '../services/priority-rule-change-requests.service';
|
||||
|
||||
/**
|
||||
* Approval workflow for priority-rule changes. Anyone with the manage
|
||||
* permission SUBMITS a change; an approver (same permission — the team decides
|
||||
* who reviews) approves or rejects it. The team is notified at each step.
|
||||
*/
|
||||
@ApiTags('priority-rule-change-requests')
|
||||
@Controller('priority-rule-change-requests')
|
||||
@ApiBearerAuth()
|
||||
export class PriorityRuleChangeRequestsController {
|
||||
constructor(private readonly service: PriorityRuleChangeRequestsService) {}
|
||||
|
||||
@Post()
|
||||
@RuleEngineManage('priority-configs')
|
||||
@ApiOperation({ summary: 'Submit a priority-rule change for approval' })
|
||||
submit(
|
||||
@Body() dto: SubmitPriorityRuleChangeDto,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
return this.service.submit(dto, user?.id);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@RuleEngineView('priority-configs')
|
||||
@ApiQuery({ name: 'status', required: false, enum: ['PENDING', 'APPROVED', 'REJECTED'] })
|
||||
@ApiOperation({ summary: 'List priority-rule change requests' })
|
||||
list(@Query('status') status?: PriorityRuleChangeStatus) {
|
||||
return this.service.list(status);
|
||||
}
|
||||
|
||||
@Post(':id/approve')
|
||||
@RuleEngineManage('priority-configs')
|
||||
@ApiOperation({ summary: 'Approve and apply a pending change' })
|
||||
approve(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: DecidePriorityRuleChangeDto,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
return this.service.approve(id, user?.id, dto.decisionNote);
|
||||
}
|
||||
|
||||
@Post(':id/reject')
|
||||
@RuleEngineManage('priority-configs')
|
||||
@ApiOperation({ summary: 'Reject a pending change' })
|
||||
reject(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: DecidePriorityRuleChangeDto,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
return this.service.reject(id, user?.id, dto.decisionNote);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsIn,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
MaxLength,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
|
||||
import { CreatePriorityConfigDto } from './create-priority-config.dto';
|
||||
import { UpdatePriorityConfigDto } from './update-priority-config.dto';
|
||||
|
||||
/**
|
||||
* File a priority-rule change for approval. CREATE carries a full `create`
|
||||
* payload; UPDATE carries the target id + an `update` patch; DELETE carries
|
||||
* only the target id.
|
||||
*/
|
||||
export class SubmitPriorityRuleChangeDto {
|
||||
@ApiProperty({ enum: ['CREATE', 'UPDATE', 'DELETE'] })
|
||||
@IsIn(['CREATE', 'UPDATE', 'DELETE'])
|
||||
action!: 'CREATE' | 'UPDATE' | 'DELETE';
|
||||
|
||||
@ApiPropertyOptional({ description: 'Target rule id (UPDATE / DELETE)' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
priorityConfigId?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Proposed new rule (CREATE)' })
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => CreatePriorityConfigDto)
|
||||
create?: CreatePriorityConfigDto;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Proposed field changes (UPDATE)' })
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => UpdatePriorityConfigDto)
|
||||
update?: UpdatePriorityConfigDto;
|
||||
}
|
||||
|
||||
export class DecidePriorityRuleChangeDto {
|
||||
@ApiPropertyOptional({ description: 'Optional note shown to the requester' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(1000)
|
||||
decisionNote?: string;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
||||
|
||||
import { PriorityConfig } from './priority-config.entity';
|
||||
|
||||
export type PriorityRuleChangeAction = 'CREATE' | 'UPDATE' | 'DELETE';
|
||||
export type PriorityRuleChangeStatus = 'PENDING' | 'APPROVED' | 'REJECTED';
|
||||
|
||||
/**
|
||||
* One proposed change to a priority rule, awaiting approval. Every
|
||||
* create/update/delete of a priority config is filed here first; an approver
|
||||
* applies (which runs the real mutation, including range-collision checks) or
|
||||
* rejects it. `payload` holds the proposed field values (null for DELETE);
|
||||
* `priorityConfigId` the target rule (null for CREATE).
|
||||
*/
|
||||
@Entity({ schema: 'freight', name: 'priority_rule_change_requests' })
|
||||
@Index(['status'])
|
||||
export class PriorityRuleChangeRequest extends BaseEntity {
|
||||
@Column({ name: 'action', type: 'varchar', length: 10 })
|
||||
action!: PriorityRuleChangeAction;
|
||||
|
||||
@Column({ name: 'priority_config_id', type: 'uuid', nullable: true })
|
||||
priorityConfigId?: string | null;
|
||||
|
||||
@ManyToOne(() => PriorityConfig, { nullable: true })
|
||||
@JoinColumn({ name: 'priority_config_id' })
|
||||
priorityConfig?: PriorityConfig | null;
|
||||
|
||||
@Column({ name: 'payload', type: 'jsonb', nullable: true })
|
||||
payload?: Record<string, unknown> | null;
|
||||
|
||||
@Column({ name: 'status', type: 'varchar', length: 10, default: 'PENDING' })
|
||||
status!: PriorityRuleChangeStatus;
|
||||
|
||||
@Column({ name: 'requested_by_user_id', type: 'uuid', nullable: true })
|
||||
requestedByUserId?: string | null;
|
||||
|
||||
@Column({ name: 'decided_by_user_id', type: 'uuid', nullable: true })
|
||||
decidedByUserId?: string | null;
|
||||
|
||||
@Column({ name: 'decided_at', type: 'timestamptz', nullable: true })
|
||||
decidedAt?: Date | null;
|
||||
|
||||
@Column({ name: 'decision_note', type: 'text', nullable: true })
|
||||
decisionNote?: string | null;
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { ApprovalRulesController } from './controllers/approval-rules.controller
|
||||
import { CargoTypesController } from './controllers/cargo-types.controller';
|
||||
import { ContainerTypesController } from './controllers/container-types.controller';
|
||||
import { PriorityConfigsController } from './controllers/priority-configs.controller';
|
||||
import { PriorityRuleChangeRequestsController } from './controllers/priority-rule-change-requests.controller';
|
||||
import { RatesController } from './controllers/rates.controller';
|
||||
import { ServiceTypesController } from './controllers/service-types.controller';
|
||||
import { ShippingLinesController } from './controllers/shipping-lines.controller';
|
||||
@@ -15,6 +16,7 @@ import { ApprovalRule } from './entities/approval-rule.entity';
|
||||
import { CargoType } from './entities/cargo-type.entity';
|
||||
import { ContainerType } from './entities/container-type.entity';
|
||||
import { PriorityConfig } from './entities/priority-config.entity';
|
||||
import { PriorityRuleChangeRequest } from './entities/priority-rule-change-request.entity';
|
||||
import { Rate } from './entities/rate.entity';
|
||||
import { ServiceType } from './entities/service-type.entity';
|
||||
import { ShippingLine } from './entities/shipping-line.entity';
|
||||
@@ -46,6 +48,7 @@ import { DisplayOrderService } from './services/display-order.service';
|
||||
import { CargoTypesService } from './services/cargo-types.service';
|
||||
import { ContainerTypesService } from './services/container-types.service';
|
||||
import { PriorityConfigsService } from './services/priority-configs.service';
|
||||
import { PriorityRuleChangeRequestsService } from './services/priority-rule-change-requests.service';
|
||||
import { RatesService } from './services/rates.service';
|
||||
import { ServiceTypesService } from './services/service-types.service';
|
||||
import { ShippingLinesService } from './services/shipping-lines.service';
|
||||
@@ -54,6 +57,8 @@ import { YardsService } from './services/yards.service';
|
||||
|
||||
import { RuleEngineService } from './rule-engine.service';
|
||||
|
||||
import { NotificationInboxModule } from '../notification-inbox/notification-inbox.module';
|
||||
|
||||
import { BookingApprovalStep } from '../bookings/entities/booking-approval-step.entity';
|
||||
import { BookingCargoModifier } from '../bookings/entities/booking-cargo-modifier.entity';
|
||||
import { BookingContainer } from '../bookings/entities/booking-container.entity';
|
||||
@@ -66,6 +71,7 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.
|
||||
CargoType,
|
||||
ContainerType,
|
||||
PriorityConfig,
|
||||
PriorityRuleChangeRequest,
|
||||
ServiceType,
|
||||
WeightLimitRule,
|
||||
Yard,
|
||||
@@ -77,11 +83,14 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.
|
||||
BookingApprovalStep,
|
||||
BookingRateSnapshot,
|
||||
]),
|
||||
// Team notifications for the priority-rule approval workflow.
|
||||
NotificationInboxModule,
|
||||
],
|
||||
controllers: [
|
||||
CargoTypesController,
|
||||
ContainerTypesController,
|
||||
PriorityConfigsController,
|
||||
PriorityRuleChangeRequestsController,
|
||||
ServiceTypesController,
|
||||
WeightLimitRulesController,
|
||||
YardsController,
|
||||
@@ -111,6 +120,7 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.
|
||||
CargoTypesService,
|
||||
ContainerTypesService,
|
||||
PriorityConfigsService,
|
||||
PriorityRuleChangeRequestsService,
|
||||
ServiceTypesService,
|
||||
WeightLimitRulesService,
|
||||
YardsService,
|
||||
|
||||
@@ -31,6 +31,12 @@ export class PriorityConfigsService {
|
||||
|
||||
async create(dto: CreatePriorityConfigDto): Promise<PriorityConfig> {
|
||||
this.validateCurrencyField(dto.type, dto.currency);
|
||||
await this.assertNoRangeCollision({
|
||||
type: dto.type,
|
||||
currency: dto.currency ?? null,
|
||||
minWagonCount: dto.minWagonCount,
|
||||
maxWagonCount: dto.maxWagonCount,
|
||||
});
|
||||
|
||||
const displayOrder = await this.displayOrder.resolveCreateOrder(PriorityConfig, 'displayOrder', {});
|
||||
|
||||
@@ -52,6 +58,13 @@ export class PriorityConfigsService {
|
||||
const type = dto.type ?? existing.type;
|
||||
const currency = dto.currency !== undefined ? dto.currency : existing.currency;
|
||||
this.validateCurrencyField(type, currency);
|
||||
await this.assertNoRangeCollision({
|
||||
type,
|
||||
currency: currency ?? null,
|
||||
minWagonCount: dto.minWagonCount ?? existing.minWagonCount,
|
||||
maxWagonCount: dto.maxWagonCount ?? existing.maxWagonCount,
|
||||
excludeId: id,
|
||||
});
|
||||
|
||||
const { ...patch } = dto;
|
||||
const updated = await this.repository.update(id, patch);
|
||||
@@ -59,6 +72,43 @@ export class PriorityConfigsService {
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* No two rules of the same type (and, for CURRENCY rules, the same currency)
|
||||
* may cover overlapping wagon-count ranges — a booking must match at most one
|
||||
* rule per type. Rejects an exact duplicate (1–5 vs 1–5) and any partial
|
||||
* overlap (1–5 vs 4–7). Ranges are inclusive on both ends.
|
||||
*/
|
||||
async assertNoRangeCollision(input: {
|
||||
type: 'WAGON' | 'CURRENCY' | 'CUSTOMS';
|
||||
currency?: string | null;
|
||||
minWagonCount: number;
|
||||
maxWagonCount: number;
|
||||
excludeId?: string;
|
||||
}): Promise<void> {
|
||||
if (input.minWagonCount > input.maxWagonCount) {
|
||||
throw new BadRequestException(
|
||||
'Min wagon count cannot be greater than max wagon count',
|
||||
);
|
||||
}
|
||||
const siblings = await this.repository.findAll({
|
||||
where: { type: input.type },
|
||||
});
|
||||
const clash = siblings.find(
|
||||
(s) =>
|
||||
s.id !== input.excludeId &&
|
||||
(input.type !== 'CURRENCY' || (s.currency ?? null) === (input.currency ?? null)) &&
|
||||
input.minWagonCount <= s.maxWagonCount &&
|
||||
input.maxWagonCount >= s.minWagonCount,
|
||||
);
|
||||
if (clash) {
|
||||
throw new BadRequestException(
|
||||
`Wagon range ${input.minWagonCount}–${input.maxWagonCount} overlaps existing rule ` +
|
||||
`"${clash.label}" (${clash.minWagonCount}–${clash.maxWagonCount}). ` +
|
||||
'Adjust the range so rules do not collide.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async remove(id: string): Promise<void> {
|
||||
await this.findById(id);
|
||||
await this.repository.softDelete(id);
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
import {
|
||||
NotificationAudience,
|
||||
NotificationType,
|
||||
} from '@edr/types';
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { NotificationInboxService } from '../../notification-inbox/notification-inbox.service';
|
||||
import { CreatePriorityConfigDto } from '../dto/create-priority-config.dto';
|
||||
import { SubmitPriorityRuleChangeDto } from '../dto/priority-rule-change-request.dto';
|
||||
import { UpdatePriorityConfigDto } from '../dto/update-priority-config.dto';
|
||||
import {
|
||||
PriorityRuleChangeRequest,
|
||||
PriorityRuleChangeStatus,
|
||||
} from '../entities/priority-rule-change-request.entity';
|
||||
import { PriorityConfigsService } from './priority-configs.service';
|
||||
|
||||
/** Backoffice rule-engine page — where both queue and rules live. */
|
||||
const RULES_LINK = '/dashboard/rules/priority-configs';
|
||||
|
||||
/**
|
||||
* Approval workflow for priority-rule changes. Nobody mutates priority configs
|
||||
* directly any more: a change is SUBMITTED here (validated up front so the
|
||||
* requester gets immediate feedback on range collisions), the team is
|
||||
* notified, and an approver later applies or rejects it. Applying re-runs the
|
||||
* full validation — the winning state is whatever is true at approval time.
|
||||
*/
|
||||
@Injectable()
|
||||
export class PriorityRuleChangeRequestsService {
|
||||
private readonly logger = new Logger(PriorityRuleChangeRequestsService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(PriorityRuleChangeRequest)
|
||||
private readonly repo: Repository<PriorityRuleChangeRequest>,
|
||||
private readonly configs: PriorityConfigsService,
|
||||
private readonly inbox: NotificationInboxService,
|
||||
) {}
|
||||
|
||||
async submit(
|
||||
dto: SubmitPriorityRuleChangeDto,
|
||||
userId?: string | null,
|
||||
): Promise<PriorityRuleChangeRequest> {
|
||||
const payload = await this.validateSubmission(dto);
|
||||
|
||||
const request = await this.repo.save(
|
||||
this.repo.create({
|
||||
action: dto.action,
|
||||
priorityConfigId: dto.priorityConfigId ?? null,
|
||||
payload,
|
||||
status: 'PENDING',
|
||||
requestedByUserId: userId ?? null,
|
||||
}),
|
||||
);
|
||||
|
||||
this.notifyTeam(
|
||||
'Priority rule change submitted',
|
||||
`A ${dto.action.toLowerCase()} of a priority rule was submitted and awaits approval.`,
|
||||
request,
|
||||
);
|
||||
return request;
|
||||
}
|
||||
|
||||
async list(status?: PriorityRuleChangeStatus): Promise<PriorityRuleChangeRequest[]> {
|
||||
return this.repo.find({
|
||||
where: status ? { status } : {},
|
||||
relations: { priorityConfig: true },
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
async approve(
|
||||
id: string,
|
||||
userId?: string | null,
|
||||
decisionNote?: string,
|
||||
): Promise<PriorityRuleChangeRequest> {
|
||||
const request = await this.findPending(id);
|
||||
|
||||
// Apply the change through the normal service so currency + range-collision
|
||||
// validation runs against the CURRENT rules; a stale request that now
|
||||
// collides fails here and stays PENDING for the approver to see the error.
|
||||
if (request.action === 'CREATE') {
|
||||
await this.configs.create(request.payload as unknown as CreatePriorityConfigDto);
|
||||
} else if (request.action === 'UPDATE') {
|
||||
await this.configs.update(
|
||||
this.requireTarget(request),
|
||||
request.payload as unknown as UpdatePriorityConfigDto,
|
||||
);
|
||||
} else {
|
||||
await this.configs.remove(this.requireTarget(request));
|
||||
}
|
||||
|
||||
request.status = 'APPROVED';
|
||||
request.decidedByUserId = userId ?? null;
|
||||
request.decidedAt = new Date();
|
||||
request.decisionNote = decisionNote ?? null;
|
||||
const saved = await this.repo.save(request);
|
||||
|
||||
this.notifyTeam(
|
||||
'Priority rule change approved',
|
||||
`The ${request.action.toLowerCase()} priority-rule change was approved and applied.` +
|
||||
(decisionNote ? ` Note: ${decisionNote}` : ''),
|
||||
saved,
|
||||
);
|
||||
return saved;
|
||||
}
|
||||
|
||||
async reject(
|
||||
id: string,
|
||||
userId?: string | null,
|
||||
decisionNote?: string,
|
||||
): Promise<PriorityRuleChangeRequest> {
|
||||
const request = await this.findPending(id);
|
||||
request.status = 'REJECTED';
|
||||
request.decidedByUserId = userId ?? null;
|
||||
request.decidedAt = new Date();
|
||||
request.decisionNote = decisionNote ?? null;
|
||||
const saved = await this.repo.save(request);
|
||||
|
||||
this.notifyTeam(
|
||||
'Priority rule change rejected',
|
||||
`The ${request.action.toLowerCase()} priority-rule change was rejected.` +
|
||||
(decisionNote ? ` Note: ${decisionNote}` : ''),
|
||||
saved,
|
||||
);
|
||||
return saved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a submission the way applying it would, so bad requests are
|
||||
* refused at the door — most importantly the wagon-range collision rule.
|
||||
* Returns the payload to persist.
|
||||
*/
|
||||
private async validateSubmission(
|
||||
dto: SubmitPriorityRuleChangeDto,
|
||||
): Promise<Record<string, unknown> | null> {
|
||||
if (dto.action === 'CREATE') {
|
||||
if (!dto.create) {
|
||||
throw new BadRequestException('CREATE requires the proposed rule in `create`');
|
||||
}
|
||||
await this.configs.assertNoRangeCollision({
|
||||
type: dto.create.type,
|
||||
currency: dto.create.currency ?? null,
|
||||
minWagonCount: dto.create.minWagonCount,
|
||||
maxWagonCount: dto.create.maxWagonCount,
|
||||
});
|
||||
return { ...dto.create };
|
||||
}
|
||||
|
||||
if (!dto.priorityConfigId) {
|
||||
throw new BadRequestException(`${dto.action} requires priorityConfigId`);
|
||||
}
|
||||
const existing = await this.configs.findById(dto.priorityConfigId);
|
||||
|
||||
if (dto.action === 'DELETE') return null;
|
||||
|
||||
if (!dto.update || Object.keys(dto.update).length === 0) {
|
||||
throw new BadRequestException('UPDATE requires the field changes in `update`');
|
||||
}
|
||||
await this.configs.assertNoRangeCollision({
|
||||
type: dto.update.type ?? existing.type,
|
||||
currency:
|
||||
dto.update.currency !== undefined ? dto.update.currency : existing.currency,
|
||||
minWagonCount: dto.update.minWagonCount ?? existing.minWagonCount,
|
||||
maxWagonCount: dto.update.maxWagonCount ?? existing.maxWagonCount,
|
||||
excludeId: existing.id,
|
||||
});
|
||||
return { ...dto.update };
|
||||
}
|
||||
|
||||
private async findPending(id: string): Promise<PriorityRuleChangeRequest> {
|
||||
const request = await this.repo.findOne({
|
||||
where: { id },
|
||||
relations: { priorityConfig: true },
|
||||
});
|
||||
if (!request) throw new NotFoundException(`Change request ${id} not found`);
|
||||
if (request.status !== 'PENDING') {
|
||||
throw new ConflictException(
|
||||
`Change request is already ${request.status.toLowerCase()}`,
|
||||
);
|
||||
}
|
||||
return request;
|
||||
}
|
||||
|
||||
private requireTarget(request: PriorityRuleChangeRequest): string {
|
||||
if (!request.priorityConfigId) {
|
||||
throw new BadRequestException(
|
||||
`${request.action} change request has no target rule`,
|
||||
);
|
||||
}
|
||||
return request.priorityConfigId;
|
||||
}
|
||||
|
||||
/**
|
||||
* In-app notification to the whole backoffice team (submission AND decision
|
||||
* both notify the team; the requester is staff, so they are included).
|
||||
* Fire-and-forget — a notification failure never blocks the workflow.
|
||||
*/
|
||||
private notifyTeam(
|
||||
title: string,
|
||||
body: string,
|
||||
request: PriorityRuleChangeRequest,
|
||||
): void {
|
||||
void this.inbox
|
||||
.notify({
|
||||
recipients: { allBackoffice: true },
|
||||
audience: NotificationAudience.BACKOFFICE,
|
||||
type: NotificationType.REQUEST_SUBMITTED,
|
||||
title,
|
||||
body,
|
||||
link: RULES_LINK,
|
||||
data: { priorityRuleChangeRequestId: request.id, action: request.action },
|
||||
})
|
||||
.catch((err) =>
|
||||
this.logger.warn(
|
||||
`Priority-rule notification failed: ${(err as Error).message}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,28 @@ export class AvailableDaysForCargoQueryDto {
|
||||
@IsString()
|
||||
cargoTypeCode?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid', description: 'Bulk cargo type id (preferred over code).' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
cargoTypeId?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'Container type ids as a JSON string array — enables the exact wagon-type compatibility gate (falls back to containerSize matching when absent).',
|
||||
})
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => {
|
||||
if (value == null || value === '') return undefined;
|
||||
if (typeof value !== 'string') return value;
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
})
|
||||
@IsArray()
|
||||
containerTypeIds?: string[];
|
||||
|
||||
@ApiPropertyOptional({ description: 'Total bulk weight in tons.' })
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => (value === '' || value == null ? undefined : Number(value)))
|
||||
|
||||
@@ -234,9 +234,11 @@ export class TrainSchedulingController {
|
||||
originYardId: query.originYardId,
|
||||
destinationYardId: query.destinationYardId,
|
||||
freightType: query.freightType,
|
||||
cargoTypeId: query.cargoTypeId,
|
||||
cargoTypeCode: query.cargoTypeCode,
|
||||
totalWeightTons: query.totalWeightTons,
|
||||
containers: query.containers,
|
||||
containerTypeIds: query.containerTypeIds,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1391,8 +1391,6 @@ export class TrainSchedulingService {
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
const trainSetId = schedule.trainSetId;
|
||||
|
||||
await this.releasePinnedWagonsForTrainSet(manager, trainSetId);
|
||||
|
||||
const deletedAllocationIds =
|
||||
await this.wagonBookingAllocationsRepository.deleteByTrainSetId(trainSetId, manager);
|
||||
|
||||
@@ -1528,7 +1526,6 @@ export class TrainSchedulingService {
|
||||
(sb) => sb.bookingId !== bookingId,
|
||||
);
|
||||
if (remainingBookings.length === 0) {
|
||||
await this.releasePinnedWagonsForTrainSet(manager, schedule.trainSetId);
|
||||
await this.wagonBookingAllocationsRepository.deleteByTrainSetId(
|
||||
schedule.trainSetId,
|
||||
manager,
|
||||
@@ -1768,7 +1765,18 @@ export class TrainSchedulingService {
|
||||
throw new BadRequestException('Cannot pin wagons on a dispatched or cancelled schedule');
|
||||
}
|
||||
|
||||
const slotIds = new Set((schedule.trainSet?.wagons ?? []).map((w) => w.id));
|
||||
const slots = schedule.trainSet?.wagons ?? [];
|
||||
const slotIds = new Set(slots.map((w) => w.id));
|
||||
const slotById = new Map(slots.map((w) => [w.id, w]));
|
||||
const builtTrainId = await this.builtTrainIdOfSchedule(scheduleId);
|
||||
// Occupancy is judged against THIS schedule's own slots only — a wagon
|
||||
// pinned on another schedule (e.g. the same train's July 17 run) stays
|
||||
// pinnable here.
|
||||
const slotIdByPhysicalId = new Map(
|
||||
slots
|
||||
.filter((w) => w.physicalWagonId)
|
||||
.map((w) => [w.physicalWagonId as string, w.id]),
|
||||
);
|
||||
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
for (const assignment of dto.assignments) {
|
||||
@@ -1784,29 +1792,61 @@ export class TrainSchedulingService {
|
||||
if (!physicalWagon) {
|
||||
throw new NotFoundException(`Wagon ${assignment.physicalWagonId} not found`);
|
||||
}
|
||||
if (
|
||||
physicalWagon.status !== WagonStatus.Available &&
|
||||
physicalWagon.currentTrainScheduleId !== scheduleId
|
||||
) {
|
||||
const occupyingSlotId = slotIdByPhysicalId.get(assignment.physicalWagonId);
|
||||
if (occupyingSlotId && occupyingSlotId !== assignment.trainSetWagonId) {
|
||||
const occupyingSlot = slotById.get(occupyingSlotId);
|
||||
throw new ConflictException(
|
||||
`Wagon ${physicalWagon.wagonNumber} is not available`,
|
||||
`Wagon ${physicalWagon.wagonNumber} is already pinned to slot #${occupyingSlot?.sequenceNo ?? '?'} of this schedule`,
|
||||
);
|
||||
}
|
||||
if (physicalWagon.currentYardId !== schedule.originStationId) {
|
||||
throw new ConflictException(
|
||||
`Wagon ${physicalWagon.wagonNumber} is at yard ${physicalWagon.currentYardId} but schedule originates from ${schedule.originStationId}`,
|
||||
);
|
||||
if (builtTrainId) {
|
||||
// Train-bound schedule: only the built train's own consist may be
|
||||
// pinned — wherever the wagons currently sit, they travel with the
|
||||
// train, so no yard/status gate applies.
|
||||
if (physicalWagon.trainId !== builtTrainId) {
|
||||
throw new ConflictException(
|
||||
`Wagon ${physicalWagon.wagonNumber} is not part of this schedule's train`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (physicalWagon.trainId) {
|
||||
throw new ConflictException(
|
||||
`Wagon ${physicalWagon.wagonNumber} is coupled to a built train and cannot be pinned as a loose wagon`,
|
||||
);
|
||||
}
|
||||
if (!this.isWagonPhysicallyUsable(physicalWagon)) {
|
||||
throw new ConflictException(
|
||||
`Wagon ${physicalWagon.wagonNumber} is not available (${physicalWagon.status})`,
|
||||
);
|
||||
}
|
||||
if (
|
||||
physicalWagon.currentTrainScheduleId &&
|
||||
physicalWagon.currentTrainScheduleId !== scheduleId
|
||||
) {
|
||||
throw new ConflictException(
|
||||
`Wagon ${physicalWagon.wagonNumber} is out on a dispatched train`,
|
||||
);
|
||||
}
|
||||
if (physicalWagon.currentYardId !== schedule.originStationId) {
|
||||
throw new ConflictException(
|
||||
`Wagon ${physicalWagon.wagonNumber} is at yard ${physicalWagon.currentYardId} but schedule originates from ${schedule.originStationId}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// The pin lives ONLY on the schedule's slot — the Wagon entity keeps
|
||||
// its status untouched so other schedules can still use the wagon.
|
||||
await manager.getRepository(TrainSetWagon).update(assignment.trainSetWagonId, {
|
||||
physicalWagonId: assignment.physicalWagonId,
|
||||
status: 'RESERVED',
|
||||
});
|
||||
await manager.getRepository(Wagon).update(assignment.physicalWagonId, {
|
||||
trainSetWagonId: assignment.trainSetWagonId,
|
||||
currentTrainScheduleId: scheduleId,
|
||||
status: WagonStatus.Assigned,
|
||||
});
|
||||
for (const [physicalId, slotId] of slotIdByPhysicalId) {
|
||||
if (slotId === assignment.trainSetWagonId) {
|
||||
slotIdByPhysicalId.delete(physicalId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
slotIdByPhysicalId.set(assignment.physicalWagonId, assignment.trainSetWagonId);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1860,6 +1900,22 @@ export class TrainSchedulingService {
|
||||
// at a time — block dispatch while any set locomotive is out on a dispatched train.
|
||||
const setLocomotiveIds = this.locomotivesOfTrainSet(schedule.trainSet).map((l) => l.id);
|
||||
await this.assertLocomotivesNotDispatchedElsewhere(setLocomotiveIds, scheduleId);
|
||||
// Same rule for wagons: many schedules may pin the same wagon, but it can
|
||||
// only be OUT on one dispatched train at a time.
|
||||
const pinnedPhysicalIds = (schedule.trainSet?.wagons ?? [])
|
||||
.map((slot) => slot.physicalWagonId)
|
||||
.filter((id): id is string => Boolean(id));
|
||||
if (pinnedPhysicalIds.length) {
|
||||
const rolling = await this.dataSource.getRepository(Wagon).find({
|
||||
where: { id: In(pinnedPhysicalIds), currentTrainScheduleId: Not(IsNull()) },
|
||||
});
|
||||
const busy = rolling.filter((w) => w.currentTrainScheduleId !== scheduleId);
|
||||
if (busy.length) {
|
||||
throw new ConflictException(
|
||||
`Cannot dispatch: wagon(s) ${busy.map((w) => w.wagonNumber).join(', ')} are still out on another dispatched train`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
@@ -3735,10 +3791,11 @@ export class TrainSchedulingService {
|
||||
originYardId: string,
|
||||
targetScheduleId?: string,
|
||||
): Promise<Array<{ wagonTypeId: string; wagonTypeCode: string; available: number }>> {
|
||||
const [wagons, wagonTypes, builtTrainId] = await Promise.all([
|
||||
const [wagons, wagonTypes, builtTrainId, pinnedToTargetIds] = await Promise.all([
|
||||
this.dataSource.getRepository(Wagon).find(),
|
||||
this.dataSource.getRepository(WagonType).find(),
|
||||
this.builtTrainIdOfSchedule(targetScheduleId),
|
||||
this.pinnedPhysicalWagonIdsForSchedule(targetScheduleId),
|
||||
]);
|
||||
const typeCodeById = new Map(wagonTypes.map((type) => [type.id, type.code]));
|
||||
const counts = new Map<string, { code: string; available: number }>();
|
||||
@@ -3750,10 +3807,20 @@ export class TrainSchedulingService {
|
||||
if (builtTrainId) {
|
||||
if (wagon.trainId !== builtTrainId) continue;
|
||||
} else {
|
||||
const pinnedOnTarget = targetScheduleId
|
||||
? wagon.currentTrainScheduleId === targetScheduleId
|
||||
: false;
|
||||
if (wagon.status !== WagonStatus.Available && !pinnedOnTarget) continue;
|
||||
// Schedule-scoped availability: pins held by OTHER schedules never
|
||||
// consume a wagon here — the same physical wagon may serve the July 17
|
||||
// and the July 20 run. A wagon is unusable only when it is coupled to a
|
||||
// built train's consist, physically blocked, or out on a dispatched
|
||||
// train right now.
|
||||
const pinnedOnTarget = pinnedToTargetIds.has(wagon.id);
|
||||
if (wagon.trainId) continue;
|
||||
if (!this.isWagonPhysicallyUsable(wagon) && !pinnedOnTarget) continue;
|
||||
if (
|
||||
wagon.currentTrainScheduleId &&
|
||||
wagon.currentTrainScheduleId !== targetScheduleId
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (wagon.currentYardId !== originYardId) continue;
|
||||
}
|
||||
|
||||
@@ -3812,22 +3879,59 @@ export class TrainSchedulingService {
|
||||
};
|
||||
}
|
||||
|
||||
private async releasePinnedWagonsForTrainSet(manager: EntityManager, trainSetId: string) {
|
||||
const slots = await manager.getRepository(TrainSetWagon).find({ where: { trainSetId } });
|
||||
const physicalIds = slots
|
||||
.map((slot) => slot.physicalWagonId)
|
||||
.filter((id): id is string => Boolean(id));
|
||||
if (!physicalIds.length) return;
|
||||
const wagons = await manager.getRepository(Wagon).find({ where: { id: In(physicalIds) } });
|
||||
for (const wagon of wagons) {
|
||||
await manager.getRepository(Wagon).update(wagon.id, {
|
||||
// Built-train wagons stay coupled to their train (ASSIGNED); loose
|
||||
// wagons return to the open AVAILABLE pool.
|
||||
status: wagon.trainId ? WagonStatus.Assigned : WagonStatus.Available,
|
||||
trainSetWagonId: null,
|
||||
currentTrainScheduleId: null,
|
||||
});
|
||||
}
|
||||
/**
|
||||
* A wagon in a blocked physical state can never be planned or pinned.
|
||||
* ASSIGNED no longer blocks: it only means the wagon is coupled to a built
|
||||
* train or stamped by a live run — schedule-level occupancy is tracked on
|
||||
* the schedule's own TrainSetWagon slots, never on the Wagon entity.
|
||||
*/
|
||||
private isWagonPhysicallyUsable(wagon: Wagon): boolean {
|
||||
return (
|
||||
wagon.status === WagonStatus.Available || wagon.status === WagonStatus.Assigned
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Physical wagons already pinned to THIS schedule's slots. Availability is
|
||||
* schedule-scoped: only a duplicate pin within the same schedule conflicts;
|
||||
* pins held by other schedules of the same train are irrelevant.
|
||||
*/
|
||||
private async pinnedPhysicalWagonIdsForSchedule(
|
||||
scheduleId: string | undefined,
|
||||
manager?: EntityManager,
|
||||
): Promise<Set<string>> {
|
||||
if (!scheduleId) return new Set();
|
||||
const runner = manager ?? this.dataSource;
|
||||
const rows: { physical_wagon_id: string }[] = await runner.query(
|
||||
`SELECT tsw.physical_wagon_id
|
||||
FROM freight.train_set_wagons tsw
|
||||
JOIN freight.train_schedules ts ON ts.train_set_id = tsw.train_set_id
|
||||
WHERE ts.id = $1
|
||||
AND ts.deleted_at IS NULL
|
||||
AND tsw.deleted_at IS NULL
|
||||
AND tsw.physical_wagon_id IS NOT NULL`,
|
||||
[scheduleId],
|
||||
);
|
||||
return new Set(rows.map((row) => row.physical_wagon_id));
|
||||
}
|
||||
|
||||
/**
|
||||
* Physical wagons pinned to any slot of a live (DRAFT/SCHEDULED/DISPATCHED)
|
||||
* schedule. Used to guard consist trims — the Wagon entity itself carries no
|
||||
* schedule-occupancy state anymore.
|
||||
*/
|
||||
private async wagonIdsPinnedToLiveSchedules(manager?: EntityManager): Promise<Set<string>> {
|
||||
const runner = manager ?? this.dataSource;
|
||||
const rows: { physical_wagon_id: string }[] = await runner.query(
|
||||
`SELECT DISTINCT tsw.physical_wagon_id
|
||||
FROM freight.train_set_wagons tsw
|
||||
JOIN freight.train_schedules ts ON ts.train_set_id = tsw.train_set_id
|
||||
WHERE ts.status IN ('DRAFT', 'SCHEDULED', 'DISPATCHED')
|
||||
AND ts.deleted_at IS NULL
|
||||
AND tsw.deleted_at IS NULL
|
||||
AND tsw.physical_wagon_id IS NOT NULL`,
|
||||
);
|
||||
return new Set(rows.map((row) => row.physical_wagon_id));
|
||||
}
|
||||
|
||||
private async autoPinWagonsForSchedule(
|
||||
@@ -3839,6 +3943,10 @@ export class TrainSchedulingService {
|
||||
const wagons = await manager.getRepository(Wagon).find();
|
||||
const wagonTypes = await manager.getRepository(WagonType).find();
|
||||
const builtTrainId = await this.builtTrainIdOfSchedule(scheduleId, manager);
|
||||
const pinnedToScheduleIds = await this.pinnedPhysicalWagonIdsForSchedule(
|
||||
scheduleId,
|
||||
manager,
|
||||
);
|
||||
const typeCodeById = new Map(wagonTypes.map((wt) => [wt.id, wt.code]));
|
||||
|
||||
const planSlots = [...slots]
|
||||
@@ -3857,6 +3965,7 @@ export class TrainSchedulingService {
|
||||
scheduleId,
|
||||
originYardId,
|
||||
builtTrainId,
|
||||
pinnedToScheduleIds,
|
||||
);
|
||||
if (unpinnable.length) {
|
||||
throw new BadRequestException({
|
||||
@@ -3874,18 +3983,17 @@ export class TrainSchedulingService {
|
||||
originYardId,
|
||||
assignedPhysicalIds,
|
||||
builtTrainId,
|
||||
pinnedToScheduleIds,
|
||||
);
|
||||
if (!physical) continue;
|
||||
|
||||
// Pin lives ONLY on the schedule's own slot — the Wagon entity is never
|
||||
// touched here, so the same physical wagon stays free for every other
|
||||
// schedule (it gets stamped at dispatch, when it physically leaves).
|
||||
await manager.getRepository(TrainSetWagon).update(slot.trainSetWagonId!, {
|
||||
physicalWagonId: physical.id,
|
||||
status: 'RESERVED',
|
||||
});
|
||||
await manager.getRepository(Wagon).update(physical.id, {
|
||||
trainSetWagonId: slot.trainSetWagonId,
|
||||
currentTrainScheduleId: scheduleId,
|
||||
status: WagonStatus.Assigned,
|
||||
});
|
||||
assignedPhysicalIds.add(physical.id);
|
||||
}
|
||||
}
|
||||
@@ -3898,9 +4006,10 @@ export class TrainSchedulingService {
|
||||
): Promise<string[]> {
|
||||
if (!wagonPlan.length) return [];
|
||||
|
||||
const [wagons, builtTrainId] = await Promise.all([
|
||||
const [wagons, builtTrainId, pinnedToScheduleIds] = await Promise.all([
|
||||
this.dataSource.getRepository(Wagon).find(),
|
||||
this.builtTrainIdOfSchedule(targetScheduleId),
|
||||
this.pinnedPhysicalWagonIdsForSchedule(targetScheduleId),
|
||||
]);
|
||||
return this.findUnpinnableWagonSlots(
|
||||
wagonPlan.map((slot) => ({
|
||||
@@ -3913,6 +4022,7 @@ export class TrainSchedulingService {
|
||||
targetScheduleId,
|
||||
originYardId,
|
||||
builtTrainId,
|
||||
pinnedToScheduleIds,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3927,6 +4037,7 @@ export class TrainSchedulingService {
|
||||
scheduleId: string | undefined,
|
||||
originYardId: string,
|
||||
builtTrainId: string | null = null,
|
||||
pinnedToScheduleIds: Set<string> = new Set(),
|
||||
): string[] {
|
||||
const violations: string[] = [];
|
||||
const assignedPhysicalIds = new Set<string>();
|
||||
@@ -3939,6 +4050,7 @@ export class TrainSchedulingService {
|
||||
originYardId,
|
||||
assignedPhysicalIds,
|
||||
builtTrainId,
|
||||
pinnedToScheduleIds,
|
||||
);
|
||||
if (!physical) {
|
||||
violations.push(
|
||||
@@ -3964,14 +4076,22 @@ export class TrainSchedulingService {
|
||||
originYardId: string,
|
||||
assignedPhysicalIds: Set<string>,
|
||||
builtTrainId: string | null = null,
|
||||
pinnedToScheduleIds: Set<string> = new Set(),
|
||||
): Wagon | undefined {
|
||||
const usable = (wagon: Wagon): boolean => {
|
||||
if (wagon.wagonTypeId !== slot.wagonTypeId) return false;
|
||||
if (assignedPhysicalIds.has(wagon.id)) return false;
|
||||
const pinnedOnSchedule = scheduleId
|
||||
? wagon.currentTrainScheduleId === scheduleId
|
||||
: false;
|
||||
return wagon.status === WagonStatus.Available || pinnedOnSchedule;
|
||||
// Loose pool never lends a wagon coupled to a built train's consist.
|
||||
if (wagon.trainId) return false;
|
||||
// Out on a dispatched train right now — physically gone.
|
||||
if (
|
||||
wagon.currentTrainScheduleId &&
|
||||
wagon.currentTrainScheduleId !== scheduleId
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const pinnedOnSchedule = pinnedToScheduleIds.has(wagon.id);
|
||||
return this.isWagonPhysicallyUsable(wagon) || pinnedOnSchedule;
|
||||
};
|
||||
// Train-bound schedule: ONLY the built train's own wagons may be pinned —
|
||||
// wherever they currently sit (they travel with the train), never a loose
|
||||
@@ -4746,6 +4866,7 @@ export class TrainSchedulingService {
|
||||
.filter((slot) => slot.physicalWagonId && (slot.allocations?.length ?? 0) > 0)
|
||||
.map((slot) => slot.physicalWagonId as string),
|
||||
);
|
||||
const pinnedToLiveIds = await this.wagonIdsPinnedToLiveSchedules();
|
||||
|
||||
const limits = minLocomotiveLimits(this.locomotivesOfTrainSet(schedule.trainSet));
|
||||
const maxPullWeightTons = roundTons(Number(limits?.maxPullWeightTons ?? 0));
|
||||
@@ -4802,8 +4923,8 @@ export class TrainSchedulingService {
|
||||
wagons: wagons.map((wagon) => ({
|
||||
...mapWagon(wagon),
|
||||
loaded: loadedWagonIds.has(wagon.id),
|
||||
// Free = not pinned to any run; only free wagons can be trimmed.
|
||||
removable: wagon.currentTrainScheduleId == null && !loadedWagonIds.has(wagon.id),
|
||||
// Free = not pinned to any live run's slot; only free wagons can be trimmed.
|
||||
removable: !pinnedToLiveIds.has(wagon.id) && !loadedWagonIds.has(wagon.id),
|
||||
})),
|
||||
addableWagons: addableWagons.map(mapWagon),
|
||||
adjustments: adjustments.map((log) => ({
|
||||
@@ -4882,13 +5003,14 @@ export class TrainSchedulingService {
|
||||
const consistById = new Map(consist.map((w) => [w.id, w]));
|
||||
|
||||
// --- validate removals: must be coupled and free (no cargo, no pin) ---
|
||||
const pinnedToLiveIds = await this.wagonIdsPinnedToLiveSchedules(manager);
|
||||
const removed: Wagon[] = [];
|
||||
for (const wagonId of removeWagonIds) {
|
||||
const wagon = consistById.get(wagonId);
|
||||
if (!wagon) {
|
||||
throw new NotFoundException(`Wagon ${wagonId} is not coupled to train ${train.code}`);
|
||||
}
|
||||
if (loadedWagonIds.has(wagon.id) || wagon.currentTrainScheduleId != null) {
|
||||
if (loadedWagonIds.has(wagon.id) || pinnedToLiveIds.has(wagon.id)) {
|
||||
throw new ConflictException(
|
||||
`Wagon ${wagon.wagonNumber} is loaded/pinned on a schedule and cannot be trimmed`,
|
||||
);
|
||||
@@ -5369,10 +5491,10 @@ export class TrainSchedulingService {
|
||||
/**
|
||||
* Cargo-aware day pool: the EAT days a customer may pick for this cargo. A day
|
||||
* is selectable when ≥1 OPEN schedule on the route that day still has remaining
|
||||
* train capacity (not fully allocated). Wagon availability is deliberately NOT
|
||||
* checked here: whether a matching wagon currently sits in the right yard is an
|
||||
* operational question staff resolve when they approve or reject the booking,
|
||||
* not something the customer can act on while choosing a date. Same
|
||||
* train capacity (not fully allocated) AND its wagon stock can physically carry
|
||||
* the selected cargo/container type (wagon-TYPE gate). Quantity is deliberately
|
||||
* NOT gated — a booking bigger than the free capacity is accepted and the batch
|
||||
* engine offers a partial split later. No counts are exposed: same
|
||||
* `{ days: string[] }` shape as getAvailableDays — the customer picks a DAY,
|
||||
* not a train.
|
||||
*/
|
||||
@@ -5380,9 +5502,11 @@ export class TrainSchedulingService {
|
||||
originYardId?: string;
|
||||
destinationYardId?: string;
|
||||
freightType: 'CONTAINER' | 'BULK';
|
||||
cargoTypeId?: string | null;
|
||||
cargoTypeCode?: string | null;
|
||||
totalWeightTons?: number;
|
||||
containers?: Array<{ containerSize: string; quantity: number }>;
|
||||
containerTypeIds?: string[];
|
||||
}): Promise<{ days: string[] }> {
|
||||
const schedules = await this.getBookableScheduleEntities(
|
||||
input.originYardId,
|
||||
@@ -5390,17 +5514,233 @@ export class TrainSchedulingService {
|
||||
);
|
||||
if (schedules.length === 0) return { days: [] };
|
||||
|
||||
const withCapacity = schedules.filter(
|
||||
(s) => Math.max(0, (s.maxWagons ?? 0) - (s.trainSet?.wagonCount ?? 0)) > 0,
|
||||
);
|
||||
const compatible = await this.filterCargoCompatibleSchedules(withCapacity, input);
|
||||
|
||||
const days = new Set<string>();
|
||||
for (const s of schedules) {
|
||||
const hasCapacity =
|
||||
Math.max(0, (s.maxWagons ?? 0) - (s.trainSet?.wagonCount ?? 0)) > 0;
|
||||
if (!hasCapacity) continue;
|
||||
for (const s of compatible) {
|
||||
if (s.scheduledDepartureDate)
|
||||
days.add(eatDay(new Date(s.scheduledDepartureDate)));
|
||||
}
|
||||
return { days: [...days].sort() };
|
||||
}
|
||||
|
||||
/**
|
||||
* Wagon-TYPE compatibility gate (customer booking): keep only the schedules
|
||||
* whose wagon stock can physically carry the selected cargo — every container
|
||||
* line (or the bulk cargo type) must map to at least one wagon type the
|
||||
* schedule's stock actually has. Stock = the built train's own consist, or the
|
||||
* origin yard's loose pool for schedules assembled from loose locomotives.
|
||||
* QUANTITY is deliberately ignored: an over-sized booking is allowed and gets
|
||||
* a partial split offer from the batch engine later.
|
||||
*/
|
||||
private async filterCargoCompatibleSchedules(
|
||||
schedules: TrainSchedule[],
|
||||
cargo: {
|
||||
freightType: 'CONTAINER' | 'BULK';
|
||||
cargoTypeId?: string | null;
|
||||
cargoTypeCode?: string | null;
|
||||
containers?: Array<{ containerSize: string; quantity: number }>;
|
||||
containerTypeIds?: string[];
|
||||
},
|
||||
): Promise<TrainSchedule[]> {
|
||||
if (!schedules.length) return schedules;
|
||||
const required = await this.requiredWagonTypeSets(cargo);
|
||||
// No cargo identity supplied — nothing to gate on (legacy callers).
|
||||
if (required === null) return schedules;
|
||||
|
||||
const stockByScheduleId = await this.scheduleWagonTypeStock(schedules);
|
||||
return schedules.filter((s) => {
|
||||
const stock = stockByScheduleId.get(s.id) ?? new Set<string>();
|
||||
return required.every((set) => {
|
||||
for (const typeId of set) if (stock.has(typeId)) return true;
|
||||
return false;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* One Set of allowed wagon-type ids per required cargo dimension: per
|
||||
* container line's type (or per container size when only sizes are known),
|
||||
* or a single set for the bulk cargo type. `null` = no cargo identity given,
|
||||
* skip gating. An EMPTY set means "nothing can carry this" (no wagon types
|
||||
* configured) — the gate then blocks every schedule, mirroring the hard
|
||||
* config violation scheduling raises for the same state.
|
||||
*/
|
||||
private async requiredWagonTypeSets(cargo: {
|
||||
freightType: 'CONTAINER' | 'BULK';
|
||||
cargoTypeId?: string | null;
|
||||
cargoTypeCode?: string | null;
|
||||
containers?: Array<{ containerSize: string; quantity: number }>;
|
||||
containerTypeIds?: string[];
|
||||
}): Promise<Set<string>[] | null> {
|
||||
if (cargo.freightType === 'CONTAINER') {
|
||||
const typeIds = [...new Set((cargo.containerTypeIds ?? []).filter(Boolean))];
|
||||
if (typeIds.length) {
|
||||
const rows: { container_type_id: string; wagon_type_id: string | null }[] =
|
||||
await this.dataSource.query(
|
||||
`SELECT ct.id AS container_type_id, wt.id AS wagon_type_id
|
||||
FROM freight.container_types ct
|
||||
LEFT JOIN freight.container_type_wagon_types ctwt ON ctwt.container_type_id = ct.id
|
||||
LEFT JOIN freight.wagon_types wt
|
||||
ON wt.id = ctwt.wagon_type_id AND wt.deleted_at IS NULL AND wt.is_active = true
|
||||
WHERE ct.id = ANY($1::uuid[]) AND ct.deleted_at IS NULL`,
|
||||
[typeIds],
|
||||
);
|
||||
const byType = new Map<string, Set<string>>(typeIds.map((id) => [id, new Set()]));
|
||||
for (const row of rows) {
|
||||
if (row.wagon_type_id) byType.get(row.container_type_id)?.add(row.wagon_type_id);
|
||||
}
|
||||
return [...byType.values()];
|
||||
}
|
||||
// Legacy callers only know sizes ("20ft"/"40ft"): a size is carriable when
|
||||
// ANY active container type of that size has a matching wagon type.
|
||||
const sizes = [
|
||||
...new Set(
|
||||
(cargo.containers ?? [])
|
||||
.map((line) => parseInt(String(line.containerSize), 10))
|
||||
.filter((n) => Number.isFinite(n) && n > 0),
|
||||
),
|
||||
];
|
||||
if (!sizes.length) return null;
|
||||
const rows: { size_ft: number; wagon_type_id: string | null }[] =
|
||||
await this.dataSource.query(
|
||||
`SELECT ct.size_ft, wt.id AS wagon_type_id
|
||||
FROM freight.container_types ct
|
||||
LEFT JOIN freight.container_type_wagon_types ctwt ON ctwt.container_type_id = ct.id
|
||||
LEFT JOIN freight.wagon_types wt
|
||||
ON wt.id = ctwt.wagon_type_id AND wt.deleted_at IS NULL AND wt.is_active = true
|
||||
WHERE ct.size_ft = ANY($1::int[]) AND ct.deleted_at IS NULL
|
||||
AND (ct.is_active IS DISTINCT FROM false)`,
|
||||
[sizes],
|
||||
);
|
||||
const bySize = new Map<number, Set<string>>(sizes.map((s) => [s, new Set()]));
|
||||
for (const row of rows) {
|
||||
if (row.wagon_type_id) bySize.get(Number(row.size_ft))?.add(row.wagon_type_id);
|
||||
}
|
||||
return [...bySize.values()];
|
||||
}
|
||||
|
||||
if (!cargo.cargoTypeId && !cargo.cargoTypeCode) return null;
|
||||
const rows: { wagon_type_id: string | null }[] = await this.dataSource.query(
|
||||
`SELECT wt.id AS wagon_type_id
|
||||
FROM freight.cargo_types c
|
||||
LEFT JOIN freight.cargo_type_wagon_types ctwt ON ctwt.cargo_type_id = c.id
|
||||
LEFT JOIN freight.wagon_types wt
|
||||
ON wt.id = ctwt.wagon_type_id AND wt.deleted_at IS NULL AND wt.is_active = true
|
||||
WHERE c.deleted_at IS NULL
|
||||
AND (($1::uuid IS NOT NULL AND c.id = $1::uuid) OR ($1::uuid IS NULL AND c.code = $2))`,
|
||||
[cargo.cargoTypeId ?? null, cargo.cargoTypeCode ?? null],
|
||||
);
|
||||
const set = new Set<string>();
|
||||
for (const row of rows) if (row.wagon_type_id) set.add(row.wagon_type_id);
|
||||
return [set];
|
||||
}
|
||||
|
||||
/**
|
||||
* Wagon-type ids each schedule's stock can offer: the built train's own
|
||||
* consist for train-bound schedules, the origin yard's loose usable pool
|
||||
* otherwise. Batched — two queries for the whole schedule list.
|
||||
*/
|
||||
private async scheduleWagonTypeStock(
|
||||
schedules: TrainSchedule[],
|
||||
): Promise<Map<string, Set<string>>> {
|
||||
const builtTrainIds = [
|
||||
...new Set(
|
||||
schedules
|
||||
.map((s) => s.trainSet?.trainId)
|
||||
.filter((id): id is string => Boolean(id)),
|
||||
),
|
||||
];
|
||||
const looseOriginYardIds = [
|
||||
...new Set(
|
||||
schedules
|
||||
.filter((s) => !s.trainSet?.trainId)
|
||||
.map((s) => s.originStationId)
|
||||
.filter(Boolean),
|
||||
),
|
||||
];
|
||||
|
||||
const [trainRows, yardRows] = await Promise.all([
|
||||
builtTrainIds.length
|
||||
? (this.dataSource.query(
|
||||
`SELECT train_id, wagon_type_id
|
||||
FROM freight.wagons
|
||||
WHERE train_id = ANY($1::uuid[]) AND deleted_at IS NULL
|
||||
GROUP BY train_id, wagon_type_id`,
|
||||
[builtTrainIds],
|
||||
) as Promise<{ train_id: string; wagon_type_id: string }[]>)
|
||||
: Promise.resolve([] as { train_id: string; wagon_type_id: string }[]),
|
||||
looseOriginYardIds.length
|
||||
? (this.dataSource.query(
|
||||
`SELECT current_yard_id, wagon_type_id
|
||||
FROM freight.wagons
|
||||
WHERE train_id IS NULL AND deleted_at IS NULL
|
||||
AND status IN ('AVAILABLE', 'ASSIGNED')
|
||||
AND current_yard_id = ANY($1::uuid[])
|
||||
GROUP BY current_yard_id, wagon_type_id`,
|
||||
[looseOriginYardIds],
|
||||
) as Promise<{ current_yard_id: string; wagon_type_id: string }[]>)
|
||||
: Promise.resolve([] as { current_yard_id: string; wagon_type_id: string }[]),
|
||||
]);
|
||||
|
||||
const byTrain = new Map<string, Set<string>>();
|
||||
for (const row of trainRows) {
|
||||
const set = byTrain.get(row.train_id) ?? new Set<string>();
|
||||
set.add(row.wagon_type_id);
|
||||
byTrain.set(row.train_id, set);
|
||||
}
|
||||
const byYard = new Map<string, Set<string>>();
|
||||
for (const row of yardRows) {
|
||||
const set = byYard.get(row.current_yard_id) ?? new Set<string>();
|
||||
set.add(row.wagon_type_id);
|
||||
byYard.set(row.current_yard_id, set);
|
||||
}
|
||||
|
||||
const result = new Map<string, Set<string>>();
|
||||
for (const s of schedules) {
|
||||
const trainId = s.trainSet?.trainId;
|
||||
result.set(
|
||||
s.id,
|
||||
trainId
|
||||
? byTrain.get(trainId) ?? new Set()
|
||||
: byYard.get(s.originStationId) ?? new Set(),
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Booking-time gate for a chosen day: does the route have an OPEN departure
|
||||
* that day at all, and can any of that day's departures physically carry the
|
||||
* cargo (wagon-TYPE only — quantity never blocks, oversized bookings get a
|
||||
* partial split offer instead).
|
||||
*/
|
||||
async checkDayCargoCompatibility(
|
||||
originYardId: string,
|
||||
destinationYardId: string,
|
||||
day: string,
|
||||
cargo: {
|
||||
freightType: 'CONTAINER' | 'BULK';
|
||||
cargoTypeId?: string | null;
|
||||
containerTypeIds?: string[];
|
||||
},
|
||||
): Promise<{ hasDeparture: boolean; hasCompatible: boolean }> {
|
||||
const schedules = await this.getBookableScheduleEntities(
|
||||
originYardId,
|
||||
destinationYardId,
|
||||
);
|
||||
const onDay = schedules.filter(
|
||||
(s) =>
|
||||
s.scheduledDepartureDate && eatDay(new Date(s.scheduledDepartureDate)) === day,
|
||||
);
|
||||
if (!onDay.length) return { hasDeparture: false, hasCompatible: false };
|
||||
const compatible = await this.filterCargoCompatibleSchedules(onDay, cargo);
|
||||
return { hasDeparture: true, hasCompatible: compatible.length > 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Ordered stop yards of a schedule's route: origin → milestones → destination,
|
||||
* de-duplicated. Falls back to the two-endpoint pseudo-route when the schedule
|
||||
@@ -5553,6 +5893,7 @@ export class TrainSchedulingService {
|
||||
status: schedule.status,
|
||||
freightType: this.resolveScheduleFreightType(schedule),
|
||||
trainNumber: schedule.trainNumber ?? null,
|
||||
maxWagons: schedule.maxWagons ?? null,
|
||||
direction: schedule.direction ?? null,
|
||||
requiresLoadingConfirmation,
|
||||
loadingConfirmed,
|
||||
|
||||
@@ -399,7 +399,7 @@ export class TrainBuilderService {
|
||||
if (!wagon || wagon.trainId !== train.id) {
|
||||
throw new NotFoundException(`Wagon ${wagonId} is not part of this train`);
|
||||
}
|
||||
if (wagon.currentTrainScheduleId) {
|
||||
if (await this.isWagonPinnedToLiveSchedule(manager, wagon.id)) {
|
||||
throw new ConflictException(
|
||||
`Wagon ${wagon.wagonNumber} is pinned to an active schedule and cannot be removed`,
|
||||
);
|
||||
@@ -426,7 +426,7 @@ export class TrainBuilderService {
|
||||
if (!wagon || wagon.trainId !== train.id) {
|
||||
throw new NotFoundException(`Wagon ${wagonId} is not part of this train`);
|
||||
}
|
||||
if (wagon.currentTrainScheduleId) {
|
||||
if (await this.isWagonPinnedToLiveSchedule(manager, wagon.id)) {
|
||||
throw new ConflictException(
|
||||
`Wagon ${wagon.wagonNumber} is pinned to an active schedule and cannot be removed`,
|
||||
);
|
||||
@@ -441,6 +441,29 @@ export class TrainBuilderService {
|
||||
return this.getComposition(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule occupancy lives on TrainSetWagon slots (per-schedule snapshot),
|
||||
* not on the Wagon entity — a wagon is busy when any live (DRAFT/SCHEDULED/
|
||||
* DISPATCHED) schedule has it pinned to one of its slots.
|
||||
*/
|
||||
private async isWagonPinnedToLiveSchedule(
|
||||
manager: EntityManager,
|
||||
wagonId: string,
|
||||
): Promise<boolean> {
|
||||
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 = $1
|
||||
AND ts.status IN ('DRAFT', 'SCHEDULED', 'DISPATCHED')
|
||||
AND ts.deleted_at IS NULL
|
||||
AND tsw.deleted_at IS NULL
|
||||
LIMIT 1`,
|
||||
[wagonId],
|
||||
);
|
||||
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) => {
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { ArrayMaxSize, ArrayMinSize, IsArray, IsUUID } from 'class-validator';
|
||||
|
||||
/**
|
||||
* OCC bulk accept-and-execute: the subset of PENDING request ids to execute
|
||||
* now. Requests not listed (or that cannot be executed) stay PENDING.
|
||||
*/
|
||||
export class BulkFulfillTransferRequestsDto {
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@ArrayMaxSize(200)
|
||||
@IsUUID('all', { each: true })
|
||||
requestIds!: string[];
|
||||
}
|
||||
@@ -1,10 +1,20 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsInt, IsOptional, IsString, IsUUID, Max, Min } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import {
|
||||
IsInt,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
Max,
|
||||
MaxLength,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
|
||||
/**
|
||||
* A count-only wagon-transfer request. The requester picks source yard, wagon
|
||||
* type, destination yard and HOW MANY — never the specific wagons; OCC hand-picks
|
||||
* those at fulfilment.
|
||||
* those at fulfilment. The quantity may not exceed the AVAILABLE wagons of that
|
||||
* type currently in the source yard, and a reason is mandatory.
|
||||
*/
|
||||
export class CreateTransferRequestDto {
|
||||
@IsUUID()
|
||||
@@ -21,6 +31,12 @@ export class CreateTransferRequestDto {
|
||||
@Max(1000)
|
||||
quantity!: number;
|
||||
|
||||
@ApiProperty({ description: 'Why the wagons are needed — shown on the OCC queue' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(2000)
|
||||
reason!: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Optional note for the fulfilling staff' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
|
||||
@@ -59,4 +59,11 @@ export class WagonTransferRequest extends BaseEntity {
|
||||
|
||||
@Column({ name: 'note', type: 'text', nullable: true })
|
||||
note?: string | null;
|
||||
|
||||
/**
|
||||
* Why the wagons are needed — required for every new request and shown on
|
||||
* the OCC queue. Nullable only for rows that predate the requirement.
|
||||
*/
|
||||
@Column({ name: 'reason', type: 'text', nullable: true })
|
||||
reason?: string | null;
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ export const WAGON_STATUSES = [
|
||||
WagonStatus.ImportReady,
|
||||
WagonStatus.ExportReady,
|
||||
WagonStatus.Maintenance,
|
||||
WagonStatus.Retired,
|
||||
WagonStatus.Detained,
|
||||
] as const;
|
||||
|
||||
export type WagonStatusType = (typeof WAGON_STATUSES)[number];
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
WagonTransferHistoryAll,
|
||||
WagonTransferRequest,
|
||||
} from '../../common/booking-guards';
|
||||
import { BulkFulfillTransferRequestsDto } from './dto/bulk-fulfill-transfer-requests.dto';
|
||||
import { CreateTransferRequestDto } from './dto/create-transfer-request.dto';
|
||||
import { FulfillTransferRequestDto } from './dto/fulfill-transfer-request.dto';
|
||||
import { WagonTransferRequestsService } from './wagon-transfer-requests.service';
|
||||
@@ -51,6 +52,22 @@ export class WagonTransferRequestsController {
|
||||
return this.service.listRequests(status);
|
||||
}
|
||||
|
||||
// NOTE: static routes (`history`, `bulk-fulfill`) MUST stay above `@Get(':id')`
|
||||
// — Express matches in declaration order, so they would otherwise be captured
|
||||
// by the `:id` param route (and rejected by ParseUUIDPipe).
|
||||
@Post('bulk-fulfill')
|
||||
@WagonTransferFulfill()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'OCC: accept-and-execute a subset of pending requests (auto-picks available wagons; the rest stay PENDING)',
|
||||
})
|
||||
bulkFulfill(
|
||||
@Body() dto: BulkFulfillTransferRequestsDto,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
return this.service.bulkFulfill(dto.requestIds, user?.id);
|
||||
}
|
||||
|
||||
// NOTE: the two `history` routes MUST stay above `@Get(':id')` — Express
|
||||
// matches in declaration order, so `/history` would otherwise be captured by
|
||||
// the `:id` param route (and rejected by ParseUUIDPipe).
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { WagonTransferRequestStatus } from '@edr/types';
|
||||
import { WagonStatus, WagonTransferRequestStatus } from '@edr/types';
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
@@ -48,7 +48,12 @@ export class WagonTransferRequestsService {
|
||||
private readonly wagonsService: WagonsService,
|
||||
) {}
|
||||
|
||||
/** Record a PENDING request. Count-only — no wagons are picked here. */
|
||||
/**
|
||||
* Record a PENDING request. Count-only — no wagons are picked here, but the
|
||||
* count is capped at the AVAILABLE wagons of that type currently sitting in
|
||||
* the source yard: staff may only ask for wagons that are actually there to
|
||||
* give. A reason is mandatory and is shown on the OCC queue.
|
||||
*/
|
||||
async createRequest(
|
||||
dto: CreateTransferRequestDto,
|
||||
userId?: string | null,
|
||||
@@ -58,6 +63,14 @@ export class WagonTransferRequestsService {
|
||||
'Source and destination yard must be different',
|
||||
);
|
||||
}
|
||||
const available = await this.countAvailable(dto.fromYardId, dto.wagonTypeId);
|
||||
if (available < dto.quantity) {
|
||||
throw new BadRequestException(
|
||||
available === 0
|
||||
? 'No available wagons of this type in the source yard'
|
||||
: `Only ${available} available wagon(s) of this type in the source yard — request at most ${available}`,
|
||||
);
|
||||
}
|
||||
const request = this.requestRepo.create({
|
||||
fromYardId: dto.fromYardId,
|
||||
toYardId: dto.toYardId,
|
||||
@@ -65,12 +78,24 @@ export class WagonTransferRequestsService {
|
||||
quantity: dto.quantity,
|
||||
status: WagonTransferRequestStatus.Pending,
|
||||
requestedByUserId: userId ?? null,
|
||||
reason: dto.reason,
|
||||
note: dto.note ?? null,
|
||||
});
|
||||
const saved = await this.requestRepo.save(request);
|
||||
return this.findById(saved.id);
|
||||
}
|
||||
|
||||
/** AVAILABLE wagons of `wagonTypeId` currently in `yardId`. */
|
||||
private countAvailable(yardId: string, wagonTypeId: string): Promise<number> {
|
||||
return this.wagonRepo.count({
|
||||
where: {
|
||||
currentYardId: yardId,
|
||||
wagonTypeId,
|
||||
status: WagonStatus.Available,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Requests, newest first, optionally filtered by status (OCC queue = PENDING). */
|
||||
async listRequests(
|
||||
status?: WagonTransferRequestStatus,
|
||||
@@ -136,6 +161,14 @@ export class WagonTransferRequestsService {
|
||||
.join(', ')}`,
|
||||
);
|
||||
}
|
||||
const notAvailable = wagons.filter((w) => w.status !== WagonStatus.Available);
|
||||
if (notAvailable.length) {
|
||||
throw new BadRequestException(
|
||||
`These wagons are not available: ${notAvailable
|
||||
.map((w) => w.wagonNumber)
|
||||
.join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Reuse the audited bulk-transfer path (writes wagon_movements ledger rows,
|
||||
// each stamped with this request's id so history can link them back).
|
||||
@@ -152,6 +185,71 @@ export class WagonTransferRequestsService {
|
||||
return this.findById(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* OCC accepts AND executes a subset of pending requests in one action. For
|
||||
* each selected request the system auto-picks the required number of
|
||||
* AVAILABLE wagons of the requested type from the source yard (lowest wagon
|
||||
* number first) and runs the audited transfer. A request that cannot be
|
||||
* executed — already decided, or not enough available wagons left after the
|
||||
* ones processed before it — is SKIPPED and simply stays PENDING, visible to
|
||||
* both teams; nothing is rolled back for the others.
|
||||
*/
|
||||
async bulkFulfill(
|
||||
requestIds: string[],
|
||||
userId?: string | null,
|
||||
): Promise<{
|
||||
fulfilled: WagonTransferRequest[];
|
||||
skipped: Array<{ id: string; reason: string }>;
|
||||
}> {
|
||||
const fulfilled: WagonTransferRequest[] = [];
|
||||
const skipped: Array<{ id: string; reason: string }> = [];
|
||||
|
||||
// Sequential on purpose: each executed transfer moves wagons out of the
|
||||
// source yard, and the next request's auto-pick must see that new state.
|
||||
for (const id of [...new Set(requestIds)]) {
|
||||
const request = await this.requestRepo.findOne({ where: { id } });
|
||||
if (!request) {
|
||||
skipped.push({ id, reason: 'Request not found' });
|
||||
continue;
|
||||
}
|
||||
if (request.status !== WagonTransferRequestStatus.Pending) {
|
||||
skipped.push({
|
||||
id,
|
||||
reason: `Already ${request.status.toLowerCase()}`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const wagons = await this.wagonRepo.find({
|
||||
where: {
|
||||
currentYardId: request.fromYardId,
|
||||
wagonTypeId: request.wagonTypeId,
|
||||
status: WagonStatus.Available,
|
||||
},
|
||||
order: { wagonNumber: 'ASC' },
|
||||
take: request.quantity,
|
||||
});
|
||||
if (wagons.length < request.quantity) {
|
||||
skipped.push({
|
||||
id,
|
||||
reason: `Only ${wagons.length} of ${request.quantity} wagon(s) available in the source yard — left pending`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
await this.wagonsService.bulkTransfer(
|
||||
{ wagonIds: wagons.map((w) => w.id), toYardId: request.toYardId },
|
||||
userId,
|
||||
{ transferRequestId: request.id },
|
||||
);
|
||||
request.status = WagonTransferRequestStatus.Fulfilled;
|
||||
request.fulfilledByUserId = userId ?? null;
|
||||
request.fulfilledAt = new Date();
|
||||
await this.requestRepo.save(request);
|
||||
fulfilled.push(await this.findById(id));
|
||||
}
|
||||
|
||||
return { fulfilled, skipped };
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-user transfer history: the requests a user filed OR fulfilled, plus the
|
||||
* individual wagons they physically moved (linked back to their request when
|
||||
|
||||
@@ -144,7 +144,7 @@ export class SchedulingReadFacade {
|
||||
`SELECT id, wagon_number AS "wagonNumber", status, train_id AS "trainId"
|
||||
FROM freight.wagons
|
||||
WHERE deleted_at IS NULL
|
||||
AND UPPER(status) NOT IN ('RETIRED', 'MAINTENANCE')
|
||||
AND UPPER(status) NOT IN ('DETAINED', 'MAINTENANCE')
|
||||
ORDER BY wagon_number ASC`,
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user