mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 17:38:12 +00:00
Merge branch 'dev' into freight/feat/fixes-v1
This commit is contained in:
@@ -198,11 +198,12 @@ export class ContractBookingService {
|
||||
// GENERAL without customs (Path A) ALSO clears per booking: the customer
|
||||
// uploads his own clearance proof on each booking and Operations reviews it
|
||||
// (legacy AWAITING_DOCUMENTS → DOCUMENTS_UNDER_REVIEW → CLEARANCE_READY →
|
||||
// requestOperation machine). DOMESTIC has no border, so no gate.
|
||||
// requestOperation machine). GENERAL intercity (DOMESTIC) follows the same
|
||||
// per-booking gate with the intercity document set — ops finalize then puts
|
||||
// the booking straight into the ride-along pool (FULLY_EXECUTED), since
|
||||
// intercity has no shipment-day request step.
|
||||
const generalSelfClear =
|
||||
contract.contractKind === 'GENERAL' &&
|
||||
!contract.customsClearingEnabled &&
|
||||
contract.tradeDirection !== 'DOMESTIC';
|
||||
contract.contractKind === 'GENERAL' && !contract.customsClearingEnabled;
|
||||
|
||||
// Intercity (DOMESTIC) bookings ride on a passing import/export train:
|
||||
// there is no window and no date — staff accept them onto a train at
|
||||
@@ -300,48 +301,63 @@ export class ContractBookingService {
|
||||
} as never),
|
||||
);
|
||||
|
||||
// Persist container lines + per-unit container numbers (container freight only).
|
||||
if (freightType === 'CONTAINER') {
|
||||
await this.persistContainers(booking.id, contract, dto);
|
||||
}
|
||||
|
||||
// Reload with containers to compute the total from contract unit rates × qty.
|
||||
const loaded = await this.bookingsRepository.findByIdWithFiles(booking.id);
|
||||
if (loaded) {
|
||||
// Everything between the insert and the priced update must be all-or-nothing:
|
||||
// a throw part-way (container persist, weight rules, pricing) would otherwise
|
||||
// leave a 0-price, container-less row in OPERATION_REQUEST_PENDING that
|
||||
// occupies the one-time contract's single active-booking slot until the
|
||||
// doc-review sweep expires it — and the clearance cycle still points at the
|
||||
// previous booking, so the hub keeps offering "Rebook" against a dead draft.
|
||||
try {
|
||||
// Persist container lines + per-unit container numbers (container freight only).
|
||||
if (freightType === 'CONTAINER') {
|
||||
await this.applyWeightResults(loaded);
|
||||
await this.persistContainers(booking.id, contract, dto);
|
||||
}
|
||||
const computed = await this.bookingPricingService.computePriceForBooking(loaded);
|
||||
// Reject a zero-price booking outright. A total of 0 means no contract rate
|
||||
// matched the route/container (or the rate is unset), so the booking is not
|
||||
// valid to ship or invoice. Roll back the just-inserted row + its lines so it
|
||||
// does NOT occupy the one-time contract's single active-booking slot — else
|
||||
// the customer's retry hits "already has an active booking" against a broken
|
||||
// draft. The customer must fix the contract's rates, then rebook.
|
||||
if (!(computed.totalAmount > 0)) {
|
||||
await this.bookingsRepository.deleteContainers(booking.id);
|
||||
await this.bookingsRepository.hardDelete(booking.id);
|
||||
throw new BadRequestException(
|
||||
'Booking price came out as 0 — no contract rate matches this ' +
|
||||
'route/cargo. Set the contract rate and try again.',
|
||||
);
|
||||
}
|
||||
await this.bookingsRepository.update(booking.id, {
|
||||
totalAmount: computed.totalAmount,
|
||||
priorityScore: computed.priorityScore,
|
||||
pricingBreakdown: {
|
||||
lineItems: computed.lineItems,
|
||||
|
||||
// Reload with containers to compute the total from contract unit rates × qty.
|
||||
const loaded = await this.bookingsRepository.findByIdWithFiles(booking.id);
|
||||
if (loaded) {
|
||||
if (freightType === 'CONTAINER') {
|
||||
await this.applyWeightResults(loaded);
|
||||
}
|
||||
const computed = await this.bookingPricingService.computePriceForBooking(loaded);
|
||||
// A partially-priced booking (e.g. 40ft has a rate, 20ft has none) has
|
||||
// a positive total, so the zero-price gate below misses it — enforce
|
||||
// the pricing hard blocks first. The catch below rolls everything back.
|
||||
if (computed.hardBlocked.length > 0) {
|
||||
throw new BadRequestException(computed.hardBlocked.join('; '));
|
||||
}
|
||||
// Reject a zero-price booking outright. A total of 0 means no contract rate
|
||||
// matched the route/container (or the rate is unset), so the booking is not
|
||||
// valid to ship or invoice. The catch below rolls back the row + its lines.
|
||||
if (!(computed.totalAmount > 0)) {
|
||||
throw new BadRequestException(
|
||||
'Booking price came out as 0 — no contract rate matches this ' +
|
||||
'route/cargo. Set the contract rate and try again.',
|
||||
);
|
||||
}
|
||||
await this.bookingsRepository.update(booking.id, {
|
||||
totalAmount: computed.totalAmount,
|
||||
currency: computed.currency,
|
||||
generatedAt: new Date().toISOString(),
|
||||
},
|
||||
} as never);
|
||||
await this.bookingPricingService.createPricingSnapshots(
|
||||
booking.id,
|
||||
computed.usedRates,
|
||||
computed.appliedModifiers,
|
||||
);
|
||||
warnings.push(...computed.warnings);
|
||||
priorityScore: computed.priorityScore,
|
||||
pricingBreakdown: {
|
||||
lineItems: computed.lineItems,
|
||||
totalAmount: computed.totalAmount,
|
||||
currency: computed.currency,
|
||||
generatedAt: new Date().toISOString(),
|
||||
},
|
||||
} as never);
|
||||
await this.bookingPricingService.createPricingSnapshots(
|
||||
booking.id,
|
||||
computed.usedRates,
|
||||
computed.appliedModifiers,
|
||||
);
|
||||
warnings.push(...computed.warnings);
|
||||
}
|
||||
} catch (err) {
|
||||
await this.bookingsRepository
|
||||
.deleteContainers(booking.id)
|
||||
.catch(() => undefined);
|
||||
await this.bookingsRepository.hardDelete(booking.id).catch(() => undefined);
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Wagon consolidation gate. A container drawdown whose lines leave a partial
|
||||
@@ -734,15 +750,20 @@ export class ContractBookingService {
|
||||
const computed = await this.bookingPricingService.computePriceForBooking(loaded);
|
||||
// A zero price means no contract rate matches — roll the cargo back so
|
||||
// the instance stays CLEARANCE_READY and can be completed again once
|
||||
// the contract rates are fixed (the clearance work is not lost).
|
||||
if (!(computed.totalAmount > 0)) {
|
||||
// the contract rates are fixed (the clearance work is not lost). A
|
||||
// pricing hard block (e.g. one of two container sizes has no rate)
|
||||
// rolls back the same way: a partially-priced total is positive but
|
||||
// the booking must not proceed.
|
||||
if (!(computed.totalAmount > 0) || computed.hardBlocked.length > 0) {
|
||||
await this.bookingsRepository.deleteContainers(booking.id);
|
||||
await this.bookingsRepository.update(booking.id, {
|
||||
cargoTotalWeightVgm: 0,
|
||||
} as never);
|
||||
throw new BadRequestException(
|
||||
'Booking price came out as 0 — no contract rate matches this ' +
|
||||
'route/cargo. Set the contract rate and try again.',
|
||||
computed.hardBlocked.length > 0
|
||||
? computed.hardBlocked.join('; ')
|
||||
: 'Booking price came out as 0 — no contract rate matches this ' +
|
||||
'route/cargo. Set the contract rate and try again.',
|
||||
);
|
||||
}
|
||||
await this.bookingsRepository.update(booking.id, {
|
||||
@@ -1599,17 +1620,23 @@ export class ContractBookingService {
|
||||
throw new BadRequestException('At least one container line is required.');
|
||||
}
|
||||
|
||||
const allowedSizes = new Set(
|
||||
// Size strings arrive in mixed formats ("20ft" from the contract scope,
|
||||
// bare "20" from the rebook seed) — compare numerically so format never
|
||||
// fails a size that IS in scope.
|
||||
const allowedSizesFt = new Set(
|
||||
(contract.cargoScope ?? [])
|
||||
.map((c) => c.containerSize)
|
||||
.filter((s): s is string => !!s),
|
||||
.map((c) => parseInt(c.containerSize ?? '', 10))
|
||||
.filter((n) => Number.isFinite(n)),
|
||||
);
|
||||
|
||||
const containerRepo = this.dataSource.getRepository(BookingContainer);
|
||||
const unitRepo = this.dataSource.getRepository(BookingContainerUnit);
|
||||
|
||||
for (const line of lines) {
|
||||
if (allowedSizes.size && !allowedSizes.has(line.containerSize)) {
|
||||
if (
|
||||
allowedSizesFt.size &&
|
||||
!allowedSizesFt.has(parseInt(line.containerSize, 10))
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
`Container size ${line.containerSize} is outside the contract scope.`,
|
||||
);
|
||||
@@ -1746,11 +1773,33 @@ export class ContractBookingService {
|
||||
}),
|
||||
);
|
||||
|
||||
// Same size-scope gate persistContainers enforces at create, surfaced as a
|
||||
// blocking preview error so the form can't confirm a size the contract does
|
||||
// not cover. Numeric compare — "20" and "20ft" are the same size.
|
||||
const allowedSizesFt = new Set(
|
||||
(contract.cargoScope ?? [])
|
||||
.map((c) => parseInt(c.containerSize ?? '', 10))
|
||||
.filter((n) => Number.isFinite(n)),
|
||||
);
|
||||
const scopeErrors = allowedSizesFt.size
|
||||
? [
|
||||
...new Set(
|
||||
lines
|
||||
.map((l) => l.containerSize)
|
||||
.filter((s) => !allowedSizesFt.has(parseInt(s, 10))),
|
||||
),
|
||||
].map((s) => `Container size ${s} is outside the contract scope.`)
|
||||
: [];
|
||||
|
||||
// The unsaved twin of the booking createUnderContract would write: same
|
||||
// denormalized contract fields, same container-line math. No id → the
|
||||
// pricing service derives wagon counts from the in-memory lines.
|
||||
const route = await this.resolveRoute(contract, dto.contractRouteId);
|
||||
const previewBooking = Object.assign(new Booking(), {
|
||||
// contractId makes the preview price off the contract's frozen rate
|
||||
// snapshots exactly like the persisted booking will — without it the
|
||||
// preview total is 0 on a leg with no live rate and the form blocks.
|
||||
contractId: contract.id,
|
||||
freightType: contract.freightType,
|
||||
tradeDirection: contract.tradeDirection,
|
||||
paymentCurrency: contract.paymentCurrency,
|
||||
@@ -1858,7 +1907,10 @@ export class ContractBookingService {
|
||||
overweightSurchargeAmount,
|
||||
currency: computed.currency,
|
||||
pairingErrors,
|
||||
capacityErrors,
|
||||
// Pricing hard blocks (missing rate for a container size / requested
|
||||
// service) ride the capacity-errors channel so the form hard-blocks in
|
||||
// the preview instead of failing at the create call.
|
||||
capacityErrors: [...scopeErrors, ...capacityErrors, ...computed.hardBlocked],
|
||||
containerClashErrors,
|
||||
spaceErrors,
|
||||
lineItems: computed.lineItems,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Contract } from './entities/contract.entity';
|
||||
import { INTERCITY_DOCUMENTS_SETTING_CODE } from '../bookings/clearance.util';
|
||||
|
||||
/**
|
||||
* Resolves which seeded clearance FileUploadSetting applies to a contract during
|
||||
@@ -29,13 +30,17 @@ function freightFor(freightType: string): Freight {
|
||||
* own (smaller) clearance proof set → `contract_clearance_selfclear_{op}_{freight}`,
|
||||
* reviewed by Operations rather than GL.
|
||||
*
|
||||
* DOMESTIC/intercity has no border, so no clearance gate applies on either path.
|
||||
* DOMESTIC/intercity has no border, but a ONE_TIME intercity contract still
|
||||
* collects the admin-configured intercity document set after both signatures
|
||||
* (ops-reviewed, like Path A). GENERAL intercity contracts skip the contract
|
||||
* gate and collect the same set per booking instead.
|
||||
*/
|
||||
export function contractClearanceSettingCode(
|
||||
tradeDirection: string,
|
||||
freightType: string,
|
||||
includesCustoms: boolean,
|
||||
): string | null {
|
||||
if (tradeDirection === 'DOMESTIC') return INTERCITY_DOCUMENTS_SETTING_CODE;
|
||||
const op = operationFor(tradeDirection);
|
||||
if (!op) return null;
|
||||
const freight = freightFor(freightType);
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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. */
|
||||
@@ -576,17 +577,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']);
|
||||
@@ -594,6 +602,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(
|
||||
@@ -616,6 +638,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,
|
||||
@@ -1035,8 +1118,8 @@ export class ContractTransitionService {
|
||||
};
|
||||
|
||||
// A clearance gate applies whenever a clearance doc set resolves — Path B
|
||||
// (customs) or Path A self-clearance (IMPORT/EXPORT without customs). DOMESTIC
|
||||
// resolves to null on both paths and skips straight to executed.
|
||||
// (customs), Path A self-clearance (IMPORT/EXPORT without customs), or the
|
||||
// intercity document set (DOMESTIC, ops-reviewed like Path A).
|
||||
const clearanceCode = contractClearanceSettingCode(
|
||||
contract.tradeDirection,
|
||||
contract.freightType,
|
||||
|
||||
@@ -442,7 +442,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,
|
||||
@@ -454,6 +457,7 @@ export class ContractsController {
|
||||
stepId,
|
||||
resolveAuthUserId(user),
|
||||
dto.reason,
|
||||
dto.returnToStepId,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -156,6 +156,7 @@ export class ContractsRepository extends BaseRepository<Contract> {
|
||||
// direct download. Loaded separately to keep pagination counts correct.
|
||||
await this.attachContractFiles(items);
|
||||
await this.attachClearancePhases(items);
|
||||
await this.attachRejectionNotes(items);
|
||||
|
||||
const totalPages = pageSize > 0 ? Math.ceil(total / pageSize) : 0;
|
||||
return {
|
||||
@@ -228,6 +229,31 @@ export class ContractsRepository extends BaseRepository<Contract> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach the latest REJECTION review-note body to each REJECTED contract so
|
||||
* list consumers (portal rows, backoffice queues) can show why without a
|
||||
* per-contract detail fetch. One query per page, like `attachContractFiles`.
|
||||
*/
|
||||
private async attachRejectionNotes(contracts: Contract[]): Promise<void> {
|
||||
const rejected = contracts.filter((c) => c.status === 'REJECTED');
|
||||
if (rejected.length === 0) return;
|
||||
const ids = rejected.map((c) => c.id);
|
||||
const rows: Array<{ contract_id: string; body: string }> =
|
||||
await this.dataSource.query(
|
||||
`SELECT DISTINCT ON (contract_id) contract_id, body
|
||||
FROM freight.contract_review_notes
|
||||
WHERE contract_id = ANY($1)
|
||||
AND note_type = 'REJECTION'
|
||||
AND deleted_at IS NULL
|
||||
ORDER BY contract_id, created_at DESC`,
|
||||
[ids],
|
||||
);
|
||||
const byContract = new Map(rows.map((r) => [r.contract_id, r.body]));
|
||||
for (const contract of rejected) {
|
||||
contract.latestRejectionNote = byContract.get(contract.id) ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
async getStatusCounts(): Promise<Record<string, number>> {
|
||||
const rows = await this.repository
|
||||
.createQueryBuilder('contract')
|
||||
@@ -368,6 +394,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({
|
||||
|
||||
@@ -636,6 +636,47 @@ export class ContractsService {
|
||||
}
|
||||
}
|
||||
|
||||
// Surface the rejection reason. The approval-step note is wiped on
|
||||
// send-back resets, so the review-note trail is the only durable source.
|
||||
if (contract.status === 'REJECTED') {
|
||||
try {
|
||||
const note = await this.contractsRepository.findLatestReviewNote(
|
||||
contract.id,
|
||||
'REJECTION',
|
||||
);
|
||||
contract.latestRejectionNote = note?.body ?? null;
|
||||
} catch {
|
||||
contract.latestRejectionNote = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Surface the send-back reason to the returned-to approver, but only while
|
||||
// it is still actionable: once any step acts after the send-back the note
|
||||
// is stale and stays out of the response (the trail keeps it in the DB).
|
||||
if (contract.status === 'PENDING_APPROVAL') {
|
||||
try {
|
||||
const note = await this.contractsRepository.findLatestReviewNote(
|
||||
contract.id,
|
||||
'STAFF_NOTE',
|
||||
);
|
||||
// Stale when any step acted after it (send-back resolved) or when the
|
||||
// chain itself is newer than the note (fresh cycle after a resubmit).
|
||||
const staleAfter = Math.max(
|
||||
0,
|
||||
...(contract.approvalSteps ?? []).flatMap((s) => [
|
||||
s.actedAt ? new Date(s.actedAt).getTime() : 0,
|
||||
s.createdAt ? new Date(s.createdAt).getTime() : 0,
|
||||
]),
|
||||
);
|
||||
contract.latestSendBackNote =
|
||||
note && new Date(note.createdAt).getTime() > staleAfter
|
||||
? note.body
|
||||
: null;
|
||||
} catch {
|
||||
contract.latestSendBackNote = null;
|
||||
}
|
||||
}
|
||||
|
||||
return contract;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -13,6 +13,7 @@ export const INCIDENT_TYPES = [
|
||||
'CONTAINER_OPENED',
|
||||
'CONTAINER_DAMAGED',
|
||||
'FLUID_LEAKING',
|
||||
'OTHER',
|
||||
] as const;
|
||||
export type IncidentType = (typeof INCIDENT_TYPES)[number];
|
||||
|
||||
|
||||
@@ -326,4 +326,19 @@ export class Contract extends BaseEntity {
|
||||
* asked them to fix. Lives in contract_review_notes, not a column here.
|
||||
*/
|
||||
latestChangeRequestNote?: string | null;
|
||||
|
||||
/**
|
||||
* Body of the most recent REJECTION review note, attached by
|
||||
* ContractsService.findById when status is REJECTED so both backoffice and
|
||||
* portal can show why. Lives in contract_review_notes, not a column here.
|
||||
*/
|
||||
latestRejectionNote?: string | null;
|
||||
|
||||
/**
|
||||
* Body of the most recent send-back STAFF_NOTE, attached by
|
||||
* ContractsService.findById while the contract is PENDING_APPROVAL and no
|
||||
* approval step has acted since the send-back. Lives in
|
||||
* contract_review_notes, not a column here.
|
||||
*/
|
||||
latestSendBackNote?: string | null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user