add yard distances management to rule engine

- Introduced new yard distances resource with CRUD operations.
- Created migration for yard distances table with necessary constraints.
- Implemented service and repository for yard distances handling.
- Added controller for API endpoints to manage yard distances.
- Updated rule engine configuration to include yard distances.
- Enhanced rule engine resource page to support yard distance selection.
- Updated contracts and train builder pages to handle new yard distance logic.
- Added error handling utility for better error message extraction.
This commit is contained in:
Marshal
2026-07-21 08:50:50 +00:00
parent 1647681840
commit 603537a20b
45 changed files with 1275 additions and 227 deletions

View File

@@ -143,6 +143,19 @@ export class ContractNotifierService {
this.inApp(c, 'Contract rejected', msg);
}
/**
* A later approver sent the contract back to an earlier stage of the chain.
* Staff-only: the customer is not involved in an internal send-back — their
* contract simply stays "under approval".
*/
sentBackToStep(c: Contract, targetRole: string, reason: string): void {
this.inAppStaff(
c,
'Contract returned in approval chain',
`Contract ${c.reference} was sent back to the ${targetRole} step. Reason: ${reason}`,
);
}
/** Staff requested changes before approval. */
changesRequested(c: Contract, note: string): void {
const msg =

View File

@@ -41,6 +41,7 @@ import {
ContractDocumentSnapshotInput,
} from './entities/contract.entity';
import { ContractSignerRole } from './entities/contract-signature.entity';
import { ContractApprovalStep } from './entities/contract-approval-step.entity';
import { SignContractDto } from './dto/sign-contract.dto';
/** The editable contract-document draft returned for the accept/edit dialog. */
@@ -550,17 +551,24 @@ export class ContractTransitionService {
/**
* Reject one approval step (line staff / director / CEO). The rejecting
* approver must supply a reason. A rejection is terminal: the whole contract
* moves to REJECTED and the customer must create a new one — there is no
* resubmit of the same contract. The reason is recorded both on the step and
* as a REJECTION review note so it is visible to the customer and the rest of
* the approval chain.
* approver must supply a reason, and picks where the rejection lands:
*
* - **To the customer** (`returnToStepId` omitted — the only option for the
* first approver): terminal. The whole contract moves to REJECTED with a
* REJECTION review note visible to the customer, who must resubmit.
* - **To an earlier approver** (`returnToStepId` = an already-APPROVED
* earlier step): internal send-back. That step and everything after it
* reset to PENDING and the chain re-runs from there; the contract stays
* PENDING_APPROVAL and the customer never sees it. E.g. the director can
* return a contract to line staff, who fix it and approve again, after
* which every later stage re-approves in order.
*/
async rejectStep(
contractId: string,
stepId: string,
actorId: string,
reason: string,
returnToStepId?: string,
): Promise<Contract> {
const contract = await this.contractsService.findById(contractId);
assertContractStatus(contract, ['PENDING_APPROVAL', 'APPROVED_PENDING_SIGNATURE']);
@@ -568,6 +576,20 @@ export class ContractTransitionService {
const step = await this.contractsRepository.findApprovalStepById(contractId, stepId);
if (!step) throw new BadRequestException('Approval step not found');
// Only the approver whose turn it is may reject — same ordering rule as
// approveStep. Without this, an already-actioned or future step could be
// "rejected" and wipe chain state it never owned.
const next = await this.contractsRepository.findNextPendingApprovalStep(contractId);
if (!next || next.id !== step.id) {
throw new BadRequestException(
'Only the current pending approval step can be rejected',
);
}
if (returnToStepId) {
return this.sendBackToStep(contract, step, actorId, reason, returnToStepId);
}
await this.contractsRepository.completeApprovalStep(step.id, actorId, 'REJECTED', reason);
await this.contractsRepository.createReviewNote(
@@ -590,6 +612,67 @@ export class ContractTransitionService {
return updated;
}
/**
* Internal send-back branch of rejectStep: return the contract to an earlier,
* already-approved stage of the chain instead of rejecting it outright.
* Deliberately NOT the terminal path: no clearance-fee expiry (the contract
* is still alive) and no customer-facing REJECTION note — the trail is a
* staff note plus a backoffice inbox ping.
*/
private async sendBackToStep(
contract: Contract,
rejectingStep: ContractApprovalStep,
actorId: string,
reason: string,
returnToStepId: string,
): Promise<Contract> {
const target = await this.contractsRepository.findApprovalStepById(
contract.id,
returnToStepId,
);
if (!target) throw new BadRequestException('Return-to approval step not found');
if (target.stepOrder >= rejectingStep.stepOrder) {
throw new BadRequestException(
'A rejection can only be returned to an EARLIER step in the chain — to reject to the customer, omit returnToStepId',
);
}
if (target.status !== 'APPROVED') {
throw new BadRequestException(
`Return-to step ${target.requiredRole} has not approved yet (status ${target.status})`,
);
}
// Staff-visible trail. Written before the reset so the reason survives the
// wipe of per-step notes.
await this.contractsRepository.createReviewNote(
contract.id,
`Returned to ${target.requiredRole} (step ${target.stepOrder}) by ${rejectingStep.requiredRole}: ${reason}`,
'STAFF_NOTE',
actorId,
'STAFF',
);
// Chain re-runs from the target stage: it and every later step (including
// the rejecting one) go back to PENDING. Legacy approved-by columns are
// left stale on purpose — approval steps are the source of truth and the
// columns get re-stamped on re-approval.
await this.contractsRepository.resetApprovalStepsFrom(
contract.id,
target.stepOrder,
);
// A send-back can only happen mid-chain, so the contract must remain (or
// return to) PENDING_APPROVAL — relevant when rejecting from
// APPROVED_PENDING_SIGNATURE.
await this.contractsRepository.update(contract.id, {
status: 'PENDING_APPROVAL',
} as never);
const updated = await this.contractsService.findById(contract.id);
this.notifier.sentBackToStep(updated, target.requiredRole, reason);
return updated;
}
/** Approve one approval step in sequence; → APPROVED when all complete. */
async approveStep(
contractId: string,

View File

@@ -441,7 +441,10 @@ export class ContractsController {
FREIGHT_PERMS.contracts.approveDirector,
FREIGHT_PERMS.contracts.approveCeo,
])
@ApiOperation({ summary: 'Reject one approval step (terminal → REJECTED)' })
@ApiOperation({
summary:
'Reject one approval step — to the customer (terminal → REJECTED) or, via returnToStepId, back to an earlier approver (chain re-runs from there)',
})
rejectStep(
@Param('id', ParseUUIDPipe) id: string,
@Param('stepId', ParseUUIDPipe) stepId: string,
@@ -453,6 +456,7 @@ export class ContractsController {
stepId,
resolveAuthUserId(user),
dto.reason,
dto.returnToStepId,
);
}

View File

@@ -368,6 +368,25 @@ export class ContractsRepository extends BaseRepository<Contract> {
});
}
/**
* Send-back reset: every step at or after `fromStepOrder` returns to PENDING
* with its actor/verdict cleared, so the chain re-runs from that stage. The
* send-back reason lives in the review-note trail, not on the wiped steps.
*/
async resetApprovalStepsFrom(
contractId: string,
fromStepOrder: number,
): Promise<void> {
await this.dataSource
.getRepository(ContractApprovalStep)
.createQueryBuilder()
.update()
.set({ status: 'PENDING', actedByStaffId: null, actedAt: null, note: null })
.where('contract_id = :contractId', { contractId })
.andWhere('step_order >= :fromStepOrder', { fromStepOrder })
.execute();
}
/** Check if all approval steps are approved. */
async allApprovalStepsComplete(contractId: string): Promise<boolean> {
const pending = await this.dataSource.getRepository(ContractApprovalStep).count({

View File

@@ -1,5 +1,5 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsOptional, IsString, MinLength } from 'class-validator';
import { IsOptional, IsString, IsUUID, MinLength } from 'class-validator';
export class ApproveStepDto {
@ApiProperty({ description: 'LINE_STAFF | DIRECTOR | CEO' })
@@ -26,6 +26,22 @@ export class RejectStepDto {
@IsString()
@MinLength(1)
reason!: string;
/**
* Where the rejection lands. Omitted → the customer: the contract goes to
* REJECTED and the customer must resubmit (unchanged legacy behaviour, and
* the only option for the first approver in the chain). Set to an EARLIER
* approved step's id → send-back: that step and everything after it reset to
* PENDING and the chain re-runs from there; the contract never leaves
* PENDING_APPROVAL and the customer is not involved.
*/
@ApiPropertyOptional({
description:
'Id of an earlier approval step to send the contract back to. Omit to reject to the customer.',
})
@IsOptional()
@IsUUID()
returnToStepId?: string;
}
export class CancelContractDto {