mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 10:10:57 +00:00
contrat nad booking modification
This commit is contained in:
@@ -1,4 +1,3 @@
|
||||
import { BookingApprovalStep } from './entities/booking-approval-step.entity';
|
||||
import { Booking } from './entities/booking.entity';
|
||||
|
||||
export interface BookingNextStep {
|
||||
@@ -9,7 +8,11 @@ export interface BookingNextStep {
|
||||
|
||||
export function computeNextStep(
|
||||
booking: Pick<Booking, 'status' | 'paymentCurrency'>,
|
||||
nextPendingStep?: Pick<BookingApprovalStep, 'requiredRole' | 'stepOrder'> | null,
|
||||
/**
|
||||
* Retained for call-site compatibility — bookings no longer run an approval
|
||||
* chain, so this is always null. Approvals are a contract-only concern.
|
||||
*/
|
||||
nextPendingStep?: { requiredRole: string; stepOrder: number } | null,
|
||||
): BookingNextStep | null {
|
||||
const { status } = booking;
|
||||
|
||||
|
||||
@@ -22,14 +22,17 @@ describe('BookingTransitionService — acceptIntake validity window', () => {
|
||||
findById: jest.fn().mockResolvedValue(booking),
|
||||
};
|
||||
const ruleEngineService = {
|
||||
instantiateApprovalSteps: jest.fn().mockResolvedValue([]),
|
||||
assertNoHardBlocks: jest.fn(),
|
||||
};
|
||||
const contractService = {
|
||||
generateContract: jest.fn().mockResolvedValue({ id: 'b-1' }),
|
||||
};
|
||||
|
||||
const service = new BookingTransitionService(
|
||||
bookingsRepository as never,
|
||||
ruleEngineService as never,
|
||||
{} as never, // pricingService
|
||||
{} as never, // contractService
|
||||
contractService as never,
|
||||
{} as never, // filesService
|
||||
{} as never, // fileUploadSettingsService
|
||||
{} as never, // bookingBatchService
|
||||
@@ -57,7 +60,7 @@ describe('BookingTransitionService — acceptIntake validity window', () => {
|
||||
dutySlipUploadedToStaff: jest.fn(),
|
||||
} as never, // notifier
|
||||
);
|
||||
return { service, bookingsRepository, ruleEngineService };
|
||||
return { service, bookingsRepository, ruleEngineService, contractService };
|
||||
}
|
||||
|
||||
it('rejects accept when validity days is missing or non-positive', async () => {
|
||||
@@ -81,7 +84,7 @@ describe('BookingTransitionService — acceptIntake validity window', () => {
|
||||
const [id, updates] = bookingsRepository.update.mock.calls[0];
|
||||
expect(id).toBe('b-1');
|
||||
expect(updates).toMatchObject({
|
||||
status: 'PENDING_APPROVAL',
|
||||
status: 'APPROVED',
|
||||
approvedByStaffId: 'staff-1',
|
||||
contractValidityDays: 10,
|
||||
});
|
||||
@@ -96,12 +99,9 @@ describe('BookingTransitionService — acceptIntake validity window', () => {
|
||||
expect((updates.approvedByStaffAt as Date).getTime()).toBe(from.getTime());
|
||||
});
|
||||
|
||||
it('instantiates the approval chain when accepting', async () => {
|
||||
const { service, ruleEngineService } = makeService();
|
||||
it('approves outright and generates the contract (no approval chain)', async () => {
|
||||
const { service, contractService } = makeService();
|
||||
await service.acceptIntake('b-1', 'staff-1', 30);
|
||||
expect(ruleEngineService.instantiateApprovalSteps).toHaveBeenCalledWith(
|
||||
'b-1',
|
||||
expect.objectContaining({ freightType: 'CONTAINER' }),
|
||||
);
|
||||
expect(contractService.generateContract).toHaveBeenCalledWith('b-1');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,9 +7,7 @@ import {
|
||||
Logger,
|
||||
Optional,
|
||||
} from "@nestjs/common";
|
||||
import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type";
|
||||
|
||||
import { assertCanApproveBookingStep } from '../../common/freight-permission.util';
|
||||
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
|
||||
import { eatDay } from '../train-scheduling/batch-window.util';
|
||||
import { isRoadService } from './road.util';
|
||||
@@ -248,16 +246,6 @@ export class BookingTransitionService {
|
||||
return fresh;
|
||||
}
|
||||
|
||||
/** Auto-create booking approval steps from system rules when none exist yet. */
|
||||
private async ensureBookingApprovalSteps(booking: Booking): Promise<void> {
|
||||
if ((booking.approvalSteps?.length ?? 0) > 0) return;
|
||||
|
||||
await this.ruleEngineService.instantiateApprovalSteps(booking.id, {
|
||||
freightType: booking.freightType as "CONTAINER" | "BULK",
|
||||
cargoTypeId: booking.cargoTypeId,
|
||||
});
|
||||
}
|
||||
|
||||
async acceptIntake(
|
||||
bookingId: string,
|
||||
actorId: string,
|
||||
@@ -283,21 +271,33 @@ export class BookingTransitionService {
|
||||
const validUntil = new Date(validFrom);
|
||||
validUntil.setDate(validUntil.getDate() + validityDays);
|
||||
|
||||
await this.ruleEngineService.instantiateApprovalSteps(bookingId, {
|
||||
freightType: booking.freightType as "CONTAINER" | "BULK",
|
||||
cargoTypeId: booking.cargoTypeId,
|
||||
});
|
||||
|
||||
const updated = await this.bookingsRepository.update(bookingId, {
|
||||
status: "PENDING_APPROVAL",
|
||||
// Bookings no longer run a multi-step approval chain — accepting the intake
|
||||
// approves the booking outright and generates its contract. (The approval
|
||||
// chain is a contract-only concern now; see contract-transition.service.)
|
||||
await this.bookingsRepository.update(bookingId, {
|
||||
status: "APPROVED",
|
||||
approvedByStaffId: actorId,
|
||||
approvedByStaffAt: validFrom,
|
||||
contractValidityDays: validityDays,
|
||||
contractValidFrom: validFrom,
|
||||
contractValidUntil: validUntil,
|
||||
} as never);
|
||||
const fresh = await this.bookingsService.findById(updated!.id);
|
||||
|
||||
// Generating the contract is best-effort: the acceptance is already
|
||||
// committed, so a failure here must not roll it back. The booking stays
|
||||
// APPROVED and staff can retry generation from the booking page.
|
||||
try {
|
||||
await this.contractService.generateContract(bookingId);
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Contract generation failed after accepting booking ${bookingId}: ${err}. ` +
|
||||
`The booking is APPROVED — retry generation from the booking page.`,
|
||||
);
|
||||
}
|
||||
|
||||
const fresh = await this.bookingsService.findById(bookingId);
|
||||
this.notifier.accepted(fresh);
|
||||
this.notifier.approved(fresh);
|
||||
return fresh;
|
||||
}
|
||||
|
||||
@@ -324,140 +324,6 @@ export class BookingTransitionService {
|
||||
return fresh;
|
||||
}
|
||||
|
||||
async approveStep(
|
||||
bookingId: string,
|
||||
stepId: string,
|
||||
actorId: string,
|
||||
requiredRole: string,
|
||||
authUser?: TCurrentUser,
|
||||
): Promise<Booking> {
|
||||
if (authUser) {
|
||||
assertCanApproveBookingStep(authUser, requiredRole);
|
||||
}
|
||||
|
||||
let booking = await this.bookingsService.findById(bookingId);
|
||||
assertBookingStatus(booking, [
|
||||
"PENDING_APPROVAL",
|
||||
"APPROVED_PENDING_SIGNATURE",
|
||||
]);
|
||||
|
||||
if ((booking.approvalSteps?.length ?? 0) === 0) {
|
||||
await this.ensureBookingApprovalSteps(booking);
|
||||
booking = await this.bookingsService.findById(bookingId);
|
||||
}
|
||||
|
||||
const step = await this.bookingsRepository.findApprovalStepById(
|
||||
bookingId,
|
||||
stepId,
|
||||
);
|
||||
if (!step || step.status !== "PENDING") {
|
||||
throw new BadRequestException(
|
||||
"Approval step not found or already actioned",
|
||||
);
|
||||
}
|
||||
|
||||
const next =
|
||||
await this.bookingsRepository.findNextPendingApprovalStep(bookingId);
|
||||
if (!next || next.id !== step.id) {
|
||||
throw new BadRequestException(
|
||||
"Approval steps must be completed in order",
|
||||
);
|
||||
}
|
||||
|
||||
if (step.requiredRole !== requiredRole) {
|
||||
throw new BadRequestException(
|
||||
`Step requires role ${step.requiredRole}, not ${requiredRole}`,
|
||||
);
|
||||
}
|
||||
|
||||
const blocksRole = step.blocksRole;
|
||||
if (blocksRole && blocksRole === requiredRole) {
|
||||
throw new BadRequestException(
|
||||
`Role ${requiredRole} is blocked for this step`,
|
||||
);
|
||||
}
|
||||
|
||||
await this.bookingsRepository.completeApprovalStep(
|
||||
step.id,
|
||||
actorId,
|
||||
"APPROVED",
|
||||
);
|
||||
|
||||
const updates: Record<string, unknown> = {};
|
||||
const now = new Date();
|
||||
|
||||
if (requiredRole === "LINE_STAFF") {
|
||||
updates.status = "APPROVED_PENDING_SIGNATURE";
|
||||
updates.approvedByStaffId = actorId;
|
||||
updates.approvedByStaffAt = now;
|
||||
} else if (requiredRole === "DIRECTOR") {
|
||||
updates.signedByDirectorId = actorId;
|
||||
updates.signedByDirectorAt = now;
|
||||
} else if (requiredRole === "CEO") {
|
||||
updates.signedByCeoId = actorId;
|
||||
updates.signedByCeoAt = now;
|
||||
}
|
||||
|
||||
const allDone =
|
||||
await this.bookingsRepository.allApprovalStepsComplete(bookingId);
|
||||
if (allDone) {
|
||||
updates.status = "APPROVED";
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length > 0) {
|
||||
await this.bookingsRepository.update(bookingId, updates as never);
|
||||
}
|
||||
|
||||
if (allDone) {
|
||||
const generated = await this.contractService.generateContract(bookingId);
|
||||
const fresh = await this.bookingsService.findById(generated.id);
|
||||
this.notifier.approved(fresh);
|
||||
return fresh;
|
||||
}
|
||||
|
||||
return this.bookingsService.findById(bookingId);
|
||||
}
|
||||
|
||||
async rejectStep(
|
||||
bookingId: string,
|
||||
stepId: string,
|
||||
actorId: string,
|
||||
reason: string,
|
||||
): Promise<Booking> {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
assertBookingStatus(booking, [
|
||||
"PENDING_APPROVAL",
|
||||
"APPROVED_PENDING_SIGNATURE",
|
||||
]);
|
||||
|
||||
const step = await this.bookingsRepository.findApprovalStepById(
|
||||
bookingId,
|
||||
stepId,
|
||||
);
|
||||
if (!step) throw new BadRequestException("Approval step not found");
|
||||
|
||||
await this.bookingsRepository.completeApprovalStep(
|
||||
step.id,
|
||||
actorId,
|
||||
"REJECTED",
|
||||
reason,
|
||||
);
|
||||
|
||||
await this.bookingsRepository.createReviewNote(
|
||||
bookingId,
|
||||
reason,
|
||||
"REJECTION",
|
||||
actorId,
|
||||
);
|
||||
|
||||
const updated = await this.bookingsRepository.update(bookingId, {
|
||||
status: "REJECTED",
|
||||
} as never);
|
||||
const fresh = await this.bookingsService.findById(updated!.id);
|
||||
this.notifier.rejected(fresh, reason);
|
||||
return fresh;
|
||||
}
|
||||
|
||||
async customerSign(bookingId: string): Promise<Booking> {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
assertBookingStatus(booking, ["CONTRACT_READY"]);
|
||||
@@ -1298,12 +1164,9 @@ export class BookingTransitionService {
|
||||
}
|
||||
let nextStep: BookingNextStep | null = null;
|
||||
try {
|
||||
const nextPending =
|
||||
booking.status === "PENDING_APPROVAL" ||
|
||||
booking.status === "APPROVED_PENDING_SIGNATURE"
|
||||
? await this.bookingsRepository.findNextPendingApprovalStep(booking.id)
|
||||
: null;
|
||||
nextStep = computeNextStep(booking, nextPending);
|
||||
// Bookings no longer carry an approval chain, so there is never a pending
|
||||
// approval step to hint at.
|
||||
nextStep = computeNextStep(booking, null);
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`enrichBookingResponse: next-step lookup failed for ${booking.id}: ${(err as Error).message}`,
|
||||
|
||||
@@ -52,10 +52,8 @@ import { GeneratePriceResponseDto } from './dto/generate-price-response.dto';
|
||||
import { SubmitBookingResponseDto } from './dto/submit-booking-response.dto';
|
||||
import {
|
||||
AcceptIntakeDto,
|
||||
ApproveStepDto,
|
||||
CancelBookingDto,
|
||||
RejectBookingDto,
|
||||
RejectStepDto,
|
||||
RequestChangesDto,
|
||||
ReviewDocumentDto,
|
||||
RequestOperationDto,
|
||||
@@ -1023,47 +1021,6 @@ export class BookingsController {
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(":id/approval-steps/:stepId/approve")
|
||||
@BookingStaff([
|
||||
FREIGHT_PERMS.bookings.approveLineStaff,
|
||||
FREIGHT_PERMS.bookings.approveDirector,
|
||||
FREIGHT_PERMS.bookings.approveCeo,
|
||||
])
|
||||
@ApiOperation({ summary: "Approve one approval step in sequence" })
|
||||
async approveStep(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Param("stepId", ParseUUIDPipe) stepId: string,
|
||||
@Body() dto: ApproveStepDto,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
const booking = await this.transitionService.approveStep(
|
||||
id,
|
||||
stepId,
|
||||
resolveAuthUserId(user),
|
||||
dto.requiredRole,
|
||||
user,
|
||||
);
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(":id/approval-steps/:stepId/reject")
|
||||
@BookingStaff(FREIGHT_PERMS.bookings.rejectApproval)
|
||||
@ApiOperation({ summary: "Reject at approval step" })
|
||||
async rejectStep(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Param("stepId", ParseUUIDPipe) stepId: string,
|
||||
@Body() dto: RejectStepDto,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
const booking = await this.transitionService.rejectStep(
|
||||
id,
|
||||
stepId,
|
||||
resolveAuthUserId(user),
|
||||
dto.reason,
|
||||
);
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(":id/contract/generate")
|
||||
@BookingStaff(FREIGHT_PERMS.bookings.generateContract)
|
||||
@ApiOperation({ summary: "Generate contract PDF from template" })
|
||||
|
||||
@@ -30,7 +30,6 @@ import { BookingsRepository } from './bookings.repository';
|
||||
import { ConsolidationService } from './consolidation.service';
|
||||
import { ContainerValidationService } from './container-validation.service';
|
||||
import { BookingsService } from './bookings.service';
|
||||
import { BookingApprovalStep } from './entities/booking-approval-step.entity';
|
||||
import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity';
|
||||
import { BookingDocumentReview } from './entities/booking-document-review.entity';
|
||||
import { BookingContainer } from './entities/booking-container.entity';
|
||||
@@ -60,7 +59,6 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
|
||||
Booking,
|
||||
BookingContainer,
|
||||
BookingCargoModifier,
|
||||
BookingApprovalStep,
|
||||
BookingDocumentReview,
|
||||
BookingRateSnapshot,
|
||||
BookingReviewNote,
|
||||
|
||||
@@ -9,7 +9,6 @@ import { ContainerType } from '../rule-engine/entities/container-type.entity';
|
||||
import { Contract } from '../contracts/entities/contract.entity';
|
||||
import { ContractRateSnapshot } from '../contracts/entities/contract-rate-snapshot.entity';
|
||||
import { ContractRoute } from '../contracts/entities/contract-route.entity';
|
||||
import { BookingApprovalStep } from './entities/booking-approval-step.entity';
|
||||
import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity';
|
||||
import {
|
||||
BookingDocumentReview,
|
||||
@@ -114,7 +113,6 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
.leftJoinAndSelect('booking.originYard', 'oy')
|
||||
.leftJoinAndSelect('booking.destinationYard', 'dy')
|
||||
.leftJoinAndSelect('booking.shippingLine', 'sl')
|
||||
.leftJoinAndSelect('booking.approvalSteps', 'steps')
|
||||
.leftJoinAndSelect('booking.rateSnapshots', 'snapshots')
|
||||
.leftJoinAndSelect('booking.cargoModifiers', 'modifiers')
|
||||
.leftJoinAndSelect('booking.reviewNotes', 'reviewNotes')
|
||||
@@ -435,58 +433,6 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
await this.dataSource.getRepository(BookingContainer).delete({ bookingId });
|
||||
}
|
||||
|
||||
/** Lowest-order pending approval step (sequential enforcement). */
|
||||
async findNextPendingApprovalStep(
|
||||
bookingId: string,
|
||||
): Promise<BookingApprovalStep | null> {
|
||||
return this.dataSource.getRepository(BookingApprovalStep).findOne({
|
||||
where: { bookingId, status: 'PENDING' },
|
||||
order: { stepOrder: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
async findApprovalStepById(
|
||||
bookingId: string,
|
||||
stepId: string,
|
||||
): Promise<BookingApprovalStep | null> {
|
||||
return this.dataSource.getRepository(BookingApprovalStep).findOne({
|
||||
where: { bookingId, id: stepId },
|
||||
});
|
||||
}
|
||||
|
||||
/** Get pending approval step for a role (must match next in sequence). */
|
||||
async findPendingApprovalStep(
|
||||
bookingId: string,
|
||||
requiredRole: string,
|
||||
): Promise<BookingApprovalStep | null> {
|
||||
const next = await this.findNextPendingApprovalStep(bookingId);
|
||||
if (!next || next.requiredRole !== requiredRole) return null;
|
||||
return next;
|
||||
}
|
||||
|
||||
/** Mark an approval step complete. */
|
||||
async completeApprovalStep(
|
||||
stepId: string,
|
||||
actorId: string,
|
||||
status: 'APPROVED' | 'REJECTED',
|
||||
remarks?: string,
|
||||
): Promise<void> {
|
||||
await this.dataSource.getRepository(BookingApprovalStep).update(stepId, {
|
||||
status,
|
||||
actionedByStaffId: actorId,
|
||||
actionedAt: new Date(),
|
||||
remarks,
|
||||
});
|
||||
}
|
||||
|
||||
/** Check if all approval steps are approved. */
|
||||
async allApprovalStepsComplete(bookingId: string): Promise<boolean> {
|
||||
const pending = await this.dataSource.getRepository(BookingApprovalStep).count({
|
||||
where: { bookingId, status: 'PENDING' },
|
||||
});
|
||||
return pending === 0;
|
||||
}
|
||||
|
||||
// ── Clearance document reviews ────────────────────────────────────────────
|
||||
|
||||
findDocumentReviews(bookingId: string): Promise<BookingDocumentReview[]> {
|
||||
@@ -673,7 +619,6 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
.leftJoinAndSelect('booking.destinationYard', 'destinationYard')
|
||||
.leftJoinAndSelect('booking.cargoType', 'cargo')
|
||||
.leftJoinAndSelect('booking.serviceType', 'serviceType')
|
||||
.leftJoinAndSelect('booking.approvalSteps', 'approvalSteps')
|
||||
.where('booking.status IN (:...statuses)', { statuses });
|
||||
|
||||
if (options.excludeBulk) {
|
||||
@@ -722,7 +667,6 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
.leftJoinAndSelect('booking.originYard', 'originYard')
|
||||
.leftJoinAndSelect('booking.destinationYard', 'destinationYard')
|
||||
.leftJoinAndSelect('booking.serviceType', 'serviceType')
|
||||
.leftJoinAndSelect('booking.approvalSteps', 'approvalSteps')
|
||||
.leftJoinAndSelect('booking.consolidationPartner', 'consolidationPartner')
|
||||
// Contract reference for the list column + search (no entity relation on
|
||||
// Booking → contract, so join the entity by id and select just the
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
||||
import { ApprovalRule } from '../../rule-engine/entities/approval-rule.entity';
|
||||
import { Booking } from './booking.entity';
|
||||
|
||||
export const APPROVAL_STEP_STATUSES = ['PENDING', 'APPROVED', 'REJECTED', 'SKIPPED'] as const;
|
||||
export type ApprovalStepStatus = typeof APPROVAL_STEP_STATUSES[number];
|
||||
|
||||
@Entity({ schema: 'freight', name: 'booking_approval_step' })
|
||||
@Index(['bookingId'])
|
||||
@Index(['status'])
|
||||
@Index(['bookingId', 'stepOrder'])
|
||||
export class BookingApprovalStep extends BaseEntity {
|
||||
@Column({ name: 'booking_id', type: 'uuid' })
|
||||
bookingId!: string;
|
||||
|
||||
@ManyToOne(() => Booking, (b) => b.approvalSteps, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'booking_id' })
|
||||
booking?: Booking;
|
||||
|
||||
@Column({ name: 'approval_rule_id', type: 'uuid' })
|
||||
approvalRuleId!: string;
|
||||
|
||||
@ManyToOne(() => ApprovalRule)
|
||||
@JoinColumn({ name: 'approval_rule_id' })
|
||||
approvalRule?: ApprovalRule;
|
||||
|
||||
@Column({ name: 'step_order', type: 'smallint' })
|
||||
stepOrder!: number;
|
||||
|
||||
@Column({ name: 'required_role', type: 'varchar', length: 30 })
|
||||
requiredRole!: string;
|
||||
|
||||
@Column({ name: 'blocks_role', type: 'varchar', length: 30, nullable: true })
|
||||
blocksRole?: string | null;
|
||||
|
||||
@Column({ name: 'status', type: 'varchar', length: 20, default: 'PENDING' })
|
||||
status!: ApprovalStepStatus;
|
||||
|
||||
@Column({ name: 'actioned_by_staff_id', type: 'uuid', nullable: true })
|
||||
actionedByStaffId?: string | null;
|
||||
|
||||
@Column({ name: 'actioned_at', type: 'timestamptz', nullable: true })
|
||||
actionedAt?: Date | null;
|
||||
|
||||
@Column({ name: 'remarks', type: 'text', nullable: true })
|
||||
remarks?: string | null;
|
||||
}
|
||||
@@ -10,7 +10,6 @@ import { ShippingLine } from '../../rule-engine/entities/shipping-line.entity';
|
||||
import { Yard } from '../../rule-engine/entities/yard.entity';
|
||||
import { Train } from '../../trains/entities/train.entity';
|
||||
import { FileRecord } from '../../files/entities/file.entity';
|
||||
import { BookingApprovalStep } from './booking-approval-step.entity';
|
||||
import { BookingCargoModifier } from './booking-cargo-modifier.entity';
|
||||
import { BookingContainer } from './booking-container.entity';
|
||||
import { BookingContainerAllocation } from './booking-container-allocation.entity';
|
||||
@@ -557,8 +556,6 @@ export class Booking extends BaseEntity {
|
||||
@OneToMany(() => BookingCargoModifier, (m) => m.booking)
|
||||
cargoModifiers?: BookingCargoModifier[];
|
||||
|
||||
@OneToMany(() => BookingApprovalStep, (s) => s.booking)
|
||||
approvalSteps?: BookingApprovalStep[];
|
||||
|
||||
@OneToMany(() => BookingRateSnapshot, (s) => s.booking)
|
||||
rateSnapshots?: BookingRateSnapshot[];
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
import type {
|
||||
ContractDocumentArticle,
|
||||
ContractDocumentSnapshot,
|
||||
} from './entities/contract.entity';
|
||||
|
||||
/**
|
||||
* One recorded change between two document snapshots. Granularity is per
|
||||
* article: a body edit is reported as "the body changed", not as a text diff.
|
||||
*/
|
||||
export type ContractDocumentChange =
|
||||
| { kind: 'ARTICLE_ADDED'; articleId: string; title: string }
|
||||
| { kind: 'ARTICLE_REMOVED'; articleId: string; title: string }
|
||||
| {
|
||||
kind: 'ARTICLE_RENAMED';
|
||||
articleId: string;
|
||||
title: string;
|
||||
fromTitle: string;
|
||||
}
|
||||
| { kind: 'ARTICLE_BODY_CHANGED'; articleId: string; title: string }
|
||||
| {
|
||||
kind: 'ARTICLE_REORDERED';
|
||||
articleId: string;
|
||||
title: string;
|
||||
fromOrder: number;
|
||||
toOrder: number;
|
||||
}
|
||||
| { kind: 'DOCUMENT_TITLE_CHANGED'; title: string; fromTitle: string | null }
|
||||
| { kind: 'WHEREAS_CHANGED'; added: number; removed: number };
|
||||
|
||||
type SnapshotLike = Pick<
|
||||
ContractDocumentSnapshot,
|
||||
'documentTitle' | 'whereasClauses' | 'articles'
|
||||
> | null;
|
||||
|
||||
/** Match on id when present, else on normalized title (editors may omit ids). */
|
||||
function articleKey(article: ContractDocumentArticle): string {
|
||||
return article.id || `title:${article.title.trim().toLowerCase()}`;
|
||||
}
|
||||
|
||||
function indexArticles(
|
||||
articles: ContractDocumentArticle[] | undefined,
|
||||
): Map<string, ContractDocumentArticle> {
|
||||
const map = new Map<string, ContractDocumentArticle>();
|
||||
for (const article of articles ?? []) {
|
||||
map.set(articleKey(article), article);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare two document snapshots and describe what changed, article by article.
|
||||
* Returns an empty array when the snapshots are equivalent, so callers can skip
|
||||
* recording a no-op revision.
|
||||
*/
|
||||
export function diffSnapshots(
|
||||
before: SnapshotLike,
|
||||
after: SnapshotLike,
|
||||
): ContractDocumentChange[] {
|
||||
const changes: ContractDocumentChange[] = [];
|
||||
|
||||
const beforeTitle = before?.documentTitle ?? null;
|
||||
const afterTitle = after?.documentTitle ?? null;
|
||||
if (beforeTitle !== afterTitle && afterTitle !== null) {
|
||||
changes.push({
|
||||
kind: 'DOCUMENT_TITLE_CHANGED',
|
||||
title: afterTitle,
|
||||
fromTitle: beforeTitle,
|
||||
});
|
||||
}
|
||||
|
||||
const beforeWhereas = before?.whereasClauses ?? [];
|
||||
const afterWhereas = after?.whereasClauses ?? [];
|
||||
const beforeWhereasSet = new Set(beforeWhereas);
|
||||
const afterWhereasSet = new Set(afterWhereas);
|
||||
const whereasAdded = afterWhereas.filter((c) => !beforeWhereasSet.has(c)).length;
|
||||
const whereasRemoved = beforeWhereas.filter((c) => !afterWhereasSet.has(c)).length;
|
||||
if (whereasAdded > 0 || whereasRemoved > 0) {
|
||||
changes.push({
|
||||
kind: 'WHEREAS_CHANGED',
|
||||
added: whereasAdded,
|
||||
removed: whereasRemoved,
|
||||
});
|
||||
}
|
||||
|
||||
const beforeArticles = indexArticles(before?.articles);
|
||||
const afterArticles = indexArticles(after?.articles);
|
||||
|
||||
for (const [key, article] of afterArticles) {
|
||||
const previous = beforeArticles.get(key);
|
||||
if (!previous) {
|
||||
changes.push({
|
||||
kind: 'ARTICLE_ADDED',
|
||||
articleId: article.id,
|
||||
title: article.title,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (previous.title !== article.title) {
|
||||
changes.push({
|
||||
kind: 'ARTICLE_RENAMED',
|
||||
articleId: article.id,
|
||||
title: article.title,
|
||||
fromTitle: previous.title,
|
||||
});
|
||||
}
|
||||
if (previous.body !== article.body) {
|
||||
changes.push({
|
||||
kind: 'ARTICLE_BODY_CHANGED',
|
||||
articleId: article.id,
|
||||
title: article.title,
|
||||
});
|
||||
}
|
||||
if (previous.order !== article.order) {
|
||||
changes.push({
|
||||
kind: 'ARTICLE_REORDERED',
|
||||
articleId: article.id,
|
||||
title: article.title,
|
||||
fromOrder: previous.order,
|
||||
toOrder: article.order,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const [key, article] of beforeArticles) {
|
||||
if (afterArticles.has(key)) continue;
|
||||
changes.push({
|
||||
kind: 'ARTICLE_REMOVED',
|
||||
articleId: article.id,
|
||||
title: article.title,
|
||||
});
|
||||
}
|
||||
|
||||
return changes;
|
||||
}
|
||||
|
||||
/** Short human summary of a change set, e.g. "2 articles edited, 1 article added". */
|
||||
export function summarizeChanges(changes: ContractDocumentChange[]): string {
|
||||
if (changes.length === 0) return 'No changes';
|
||||
|
||||
const articleVerbs: Record<string, string> = {
|
||||
ARTICLE_ADDED: 'added',
|
||||
ARTICLE_REMOVED: 'removed',
|
||||
ARTICLE_RENAMED: 'renamed',
|
||||
ARTICLE_BODY_CHANGED: 'edited',
|
||||
ARTICLE_REORDERED: 'reordered',
|
||||
};
|
||||
|
||||
const counts = new Map<string, number>();
|
||||
const parts: string[] = [];
|
||||
|
||||
for (const change of changes) {
|
||||
const verb = articleVerbs[change.kind];
|
||||
if (verb) {
|
||||
counts.set(verb, (counts.get(verb) ?? 0) + 1);
|
||||
} else if (change.kind === 'DOCUMENT_TITLE_CHANGED') {
|
||||
parts.push('document title changed');
|
||||
} else if (change.kind === 'WHEREAS_CHANGED') {
|
||||
parts.push('recitals changed');
|
||||
}
|
||||
}
|
||||
|
||||
const articleParts = [...counts.entries()].map(
|
||||
([verb, count]) => `${count} article${count === 1 ? '' : 's'} ${verb}`,
|
||||
);
|
||||
|
||||
return [...articleParts, ...parts].join(', ');
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { diffSnapshots, summarizeChanges } from './contract-document-diff.util';
|
||||
import { ContractDocumentRevision } from './entities/contract-document-revision.entity';
|
||||
import type { ContractDocumentSnapshot } from './entities/contract.entity';
|
||||
|
||||
export interface RecordRevisionInput {
|
||||
contractId: string;
|
||||
before: ContractDocumentSnapshot | null;
|
||||
after: ContractDocumentSnapshot | null;
|
||||
actorId?: string | null;
|
||||
actorRole?: string | null;
|
||||
stepId?: string | null;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ContractDocumentHistoryService {
|
||||
private readonly logger = new Logger(ContractDocumentHistoryService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(ContractDocumentRevision)
|
||||
private readonly revisionRepo: Repository<ContractDocumentRevision>,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Append a revision describing what an edit changed. Best-effort: recording
|
||||
* history must never break the edit that triggered it, so failures are logged
|
||||
* and swallowed. A no-op edit records nothing.
|
||||
*/
|
||||
async record(input: RecordRevisionInput): Promise<void> {
|
||||
try {
|
||||
const changes = diffSnapshots(input.before, input.after);
|
||||
if (changes.length === 0) return;
|
||||
|
||||
await this.revisionRepo.save(
|
||||
this.revisionRepo.create({
|
||||
contractId: input.contractId,
|
||||
actorId: input.actorId ?? null,
|
||||
actorRole: input.actorRole ?? null,
|
||||
stepId: input.stepId ?? null,
|
||||
summary: summarizeChanges(changes),
|
||||
changes,
|
||||
}),
|
||||
);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Failed to record document revision for contract ${input.contractId}: ${String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Revision history for a contract, newest first. */
|
||||
list(contractId: string): Promise<ContractDocumentRevision[]> {
|
||||
return this.revisionRepo.find({
|
||||
where: { contractId },
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
ConflictException,
|
||||
Injectable,
|
||||
Logger,
|
||||
ServiceUnavailableException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import { DataSource } from 'typeorm';
|
||||
@@ -17,7 +18,8 @@ import { ContractPdfService } from '../../contracts/contract-pdf.service';
|
||||
import { ContractViewModel } from '../../contracts/contract-view-model.builder';
|
||||
import { MinioService } from '../minio/minio.service';
|
||||
import { FileRecord } from '../files/entities/file.entity';
|
||||
import { assertCanApproveBookingStep } from '../../common/freight-permission.util';
|
||||
import { assertCanApproveContractStep } from '../../common/freight-permission.util';
|
||||
import { ContractDocumentHistoryService } from './contract-document-history.service';
|
||||
import { ApprovalRulesService } from '../rule-engine/services/approval-rules.service';
|
||||
import { CargoTypesService } from '../rule-engine/services/cargo-types.service';
|
||||
import { DropdownSettingsService } from '../dropdown-settings/dropdown-settings.service';
|
||||
@@ -48,8 +50,12 @@ export interface ContractDocumentDraft {
|
||||
articles: ContractDocumentArticle[];
|
||||
code: string | null;
|
||||
name: string | null;
|
||||
/** True once the document may no longer be edited/regenerated. */
|
||||
/** True when THIS caller may not edit — the inverse of `editableByMe`. */
|
||||
locked: boolean;
|
||||
/** Whether the requesting user is the approver whose turn it is to edit. */
|
||||
editableByMe: boolean;
|
||||
/** Role holding editing rights right now, for "locked because…" messaging. */
|
||||
nextApproverRole: string | null;
|
||||
generatedAt: Date | null;
|
||||
status: string;
|
||||
}
|
||||
@@ -62,6 +68,28 @@ export interface ContractDocumentDraft {
|
||||
*/
|
||||
const CONTRACT_VALIDITY_PERIODS_CODE = 'contract_validity_periods';
|
||||
|
||||
/**
|
||||
* Approval chains are configured in IAM position types, so a step's role no
|
||||
* longer maps onto the contract's fixed approver columns. These sets keep those
|
||||
* legacy columns populated for the roles that still correspond to one — both the
|
||||
* original role strings on historical rows and the position types that replaced
|
||||
* them. Steps outside these sets are recorded only in `contract_approval_steps`,
|
||||
* which is the source of truth.
|
||||
*/
|
||||
const LEGACY_STAFF_ROLES = new Set([
|
||||
'LINE_STAFF',
|
||||
'employee',
|
||||
'teamLeader',
|
||||
'officeHead',
|
||||
'recordOfficer',
|
||||
]);
|
||||
const LEGACY_DIRECTOR_ROLES = new Set([
|
||||
'DIRECTOR',
|
||||
'director',
|
||||
'operation-director',
|
||||
]);
|
||||
const LEGACY_CEO_ROLES = new Set(['CEO', 'chief', 'deputy']);
|
||||
|
||||
/**
|
||||
* Mask a phone for display — keep the last 4 digits, star the rest
|
||||
* (`+251986680099` → `•••••••0099`). Used to tell the customer WHERE the signing
|
||||
@@ -87,6 +115,7 @@ export class ContractTransitionService {
|
||||
private readonly logger = new Logger(ContractTransitionService.name);
|
||||
|
||||
constructor(
|
||||
private readonly documentHistory: ContractDocumentHistoryService,
|
||||
private readonly contractsRepository: ContractsRepository,
|
||||
private readonly contractsService: ContractsService,
|
||||
private readonly pricingService: ContractPricingService,
|
||||
@@ -229,18 +258,22 @@ export class ContractTransitionService {
|
||||
*/
|
||||
async getContractDocumentDraft(
|
||||
contractId: string,
|
||||
user?: TCurrentUser | null,
|
||||
): Promise<ContractDocumentDraft> {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
const snapshot =
|
||||
(contract.documentSnapshot as ContractDocumentSnapshot | null) ??
|
||||
(await this.resolveDocumentSnapshot(contract));
|
||||
const editableByMe = await this.documentIsEditableBy(contract, user);
|
||||
return {
|
||||
documentTitle: snapshot?.documentTitle ?? null,
|
||||
whereasClauses: snapshot?.whereasClauses ?? [],
|
||||
articles: snapshot?.articles ?? [],
|
||||
code: snapshot?.code ?? null,
|
||||
name: snapshot?.name ?? null,
|
||||
locked: !this.documentIsEditable(contract),
|
||||
locked: !editableByMe,
|
||||
editableByMe,
|
||||
nextApproverRole: await this.nextApproverRole(contract),
|
||||
generatedAt: contract.contractGeneratedAt ?? null,
|
||||
status: contract.status,
|
||||
};
|
||||
@@ -255,10 +288,12 @@ export class ContractTransitionService {
|
||||
async updateContractDocument(
|
||||
contractId: string,
|
||||
input: ContractDocumentSnapshotInput,
|
||||
user?: TCurrentUser | null,
|
||||
actorId?: string,
|
||||
): Promise<Contract> {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
assertContractStatus(contract, ['PENDING_APPROVAL']);
|
||||
this.assertDocumentEditable(contract);
|
||||
await this.assertDocumentEditable(contract, user);
|
||||
|
||||
const current =
|
||||
(contract.documentSnapshot as ContractDocumentSnapshot | null) ??
|
||||
@@ -270,9 +305,25 @@ export class ContractTransitionService {
|
||||
whereasClauses: input.whereasClauses ?? current?.whereasClauses ?? [],
|
||||
articles: input.articles ?? current?.articles ?? [],
|
||||
};
|
||||
const next = this.normalizeSnapshot(merged);
|
||||
await this.contractsRepository.update(contractId, {
|
||||
documentSnapshot: this.normalizeSnapshot(merged),
|
||||
documentSnapshot: next,
|
||||
} as never);
|
||||
|
||||
// Audit the edit after it lands. Recording history must never break the
|
||||
// edit itself, so the history service swallows its own failures.
|
||||
const step = await this.contractsRepository.findNextPendingApprovalStep(
|
||||
contractId,
|
||||
);
|
||||
await this.documentHistory.record({
|
||||
contractId,
|
||||
before: current,
|
||||
after: next,
|
||||
actorId: actorId ?? null,
|
||||
actorRole: step?.requiredRole ?? null,
|
||||
stepId: step?.id ?? null,
|
||||
});
|
||||
|
||||
return this.contractsService.findById(contractId);
|
||||
}
|
||||
|
||||
@@ -334,23 +385,54 @@ export class ContractTransitionService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* The contract document stays editable for the whole approval chain, but only
|
||||
* by the approver whose turn it is: whoever can action the next pending step.
|
||||
* Approving therefore hands editing rights to the next approver in the chain.
|
||||
*
|
||||
* Edits never reset approvals already given — earlier approvers stay approved.
|
||||
*/
|
||||
private documentIsEditable(contract: Contract): boolean {
|
||||
private async documentIsEditableBy(
|
||||
contract: Contract,
|
||||
user?: TCurrentUser | null,
|
||||
): Promise<boolean> {
|
||||
if (contract.status === 'SUBMITTED') return true;
|
||||
if (contract.status !== 'PENDING_APPROVAL') return false;
|
||||
return !(contract.approvalSteps ?? []).some((s) => s.status !== 'PENDING');
|
||||
|
||||
const next = await this.contractsRepository.findNextPendingApprovalStep(
|
||||
contract.id,
|
||||
);
|
||||
if (!next) return false;
|
||||
if (!user) return false;
|
||||
|
||||
try {
|
||||
assertCanApproveContractStep(user, next.requiredRole);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
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.',
|
||||
);
|
||||
}
|
||||
/** The role that currently holds editing rights, for UI messaging. */
|
||||
private async nextApproverRole(contract: Contract): Promise<string | null> {
|
||||
if (contract.status !== 'PENDING_APPROVAL') return null;
|
||||
const next = await this.contractsRepository.findNextPendingApprovalStep(
|
||||
contract.id,
|
||||
);
|
||||
return next?.requiredRole ?? null;
|
||||
}
|
||||
|
||||
private async assertDocumentEditable(
|
||||
contract: Contract,
|
||||
user?: TCurrentUser | null,
|
||||
): Promise<void> {
|
||||
if (await this.documentIsEditableBy(contract, user)) return;
|
||||
|
||||
const role = await this.nextApproverRole(contract);
|
||||
throw new ConflictException(
|
||||
role
|
||||
? `The contract document can only be edited by the current approver (${role}).`
|
||||
: 'The contract document is locked — the contract has advanced beyond approval.',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -513,25 +595,11 @@ export class ContractTransitionService {
|
||||
contractId: string,
|
||||
stepId: string,
|
||||
actorId: string,
|
||||
requiredRole: string,
|
||||
authUser?: TCurrentUser,
|
||||
): Promise<Contract> {
|
||||
if (authUser) {
|
||||
assertCanApproveBookingStep(authUser, requiredRole);
|
||||
}
|
||||
|
||||
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');
|
||||
@@ -541,31 +609,34 @@ export class ContractTransitionService {
|
||||
if (!next || next.id !== step.id) {
|
||||
throw new BadRequestException('Approval steps must be completed in order');
|
||||
}
|
||||
if (step.requiredRole !== requiredRole) {
|
||||
throw new BadRequestException(
|
||||
`Step requires role ${step.requiredRole}, not ${requiredRole}`,
|
||||
);
|
||||
}
|
||||
if (step.blocksRole && step.blocksRole === requiredRole) {
|
||||
throw new BadRequestException(`Role ${requiredRole} is blocked for this step`);
|
||||
|
||||
// The role is the step's own — never the caller's claim about themselves.
|
||||
const requiredRole = step.requiredRole;
|
||||
if (authUser) {
|
||||
assertCanApproveContractStep(authUser, requiredRole);
|
||||
}
|
||||
|
||||
await this.contractsRepository.completeApprovalStep(step.id, actorId, 'APPROVED');
|
||||
|
||||
// Record who acted on this step, but DO NOT advance the contract status here —
|
||||
// approving one step (e.g. LINE_STAFF) must not finalize the chain while later
|
||||
// steps (e.g. DIRECTOR) are still pending. Status only moves to APPROVED once
|
||||
// every step in the chain is complete; until then the contract stays in
|
||||
// PENDING_APPROVAL so the next required role can act.
|
||||
// approving one step must not finalize the chain while later steps are still
|
||||
// pending. Status only moves to APPROVED once every step in the chain is
|
||||
// complete; until then the contract stays in PENDING_APPROVAL so the next
|
||||
// required approver can act.
|
||||
//
|
||||
// `contract_approval_steps` is the source of truth for who approved what — a
|
||||
// chain is an arbitrary sequence of position types and cannot be represented
|
||||
// by fixed columns. The legacy columns below are still stamped, best-effort,
|
||||
// for the three roles that map onto them so older readers keep working.
|
||||
const updates: Record<string, unknown> = {};
|
||||
const now = new Date();
|
||||
if (requiredRole === 'LINE_STAFF') {
|
||||
if (LEGACY_STAFF_ROLES.has(requiredRole)) {
|
||||
updates.approvedByStaffId = actorId;
|
||||
updates.approvedByStaffAt = now;
|
||||
} else if (requiredRole === 'DIRECTOR') {
|
||||
} else if (LEGACY_DIRECTOR_ROLES.has(requiredRole)) {
|
||||
updates.signedByDirectorId = actorId;
|
||||
updates.signedByDirectorAt = now;
|
||||
} else if (requiredRole === 'CEO') {
|
||||
} else if (LEGACY_CEO_ROLES.has(requiredRole)) {
|
||||
updates.signedByCeoId = actorId;
|
||||
updates.signedByCeoAt = now;
|
||||
}
|
||||
@@ -579,14 +650,19 @@ export class ContractTransitionService {
|
||||
const updated = await this.contractsService.findById(contractId);
|
||||
if (allDone) {
|
||||
this.notifier.approved(updated);
|
||||
// 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.
|
||||
// Final approval is what produces the contract PDF — until now there was
|
||||
// only a live preview. The approval steps are already committed, so a
|
||||
// render failure must not roll them back; surface it instead of swallowing
|
||||
// it, since an APPROVED contract with no document needs operator action.
|
||||
try {
|
||||
return await this.finalizeApprovedContract(contractId);
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Finalizing contract after final approval failed for ${updated.reference}: ${err}`,
|
||||
this.logger.error(
|
||||
`Contract PDF generation failed after final approval for ${updated.reference}: ${err}`,
|
||||
);
|
||||
throw new ServiceUnavailableException(
|
||||
'All approvals were recorded, but generating the contract PDF failed. ' +
|
||||
'Retry generation from the contract page.',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -594,24 +670,13 @@ export class ContractTransitionService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Retry path for a contract that finished approval but whose PDF failed to
|
||||
* render (Chromium unavailable, etc.). The normal flow generates the document
|
||||
* automatically on the final approval — there is no manual generate step
|
||||
* before that, only the live preview.
|
||||
*/
|
||||
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, {
|
||||
@@ -626,11 +691,17 @@ export class ContractTransitionService {
|
||||
* 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> {
|
||||
private async renderContractDocument(
|
||||
contract: Contract,
|
||||
options: { strict?: boolean } = {},
|
||||
): Promise<void> {
|
||||
const { view } = await this.documentViewModelBuilder.build(contract.id);
|
||||
try {
|
||||
await this.upsertContractPdf(contract.id, contract.reference, view);
|
||||
} catch (err) {
|
||||
// Strict callers (final approval) need to know the PDF is missing — it is
|
||||
// the artifact of the completed chain, not a cache that can refill later.
|
||||
if (options.strict) throw err;
|
||||
this.logger.warn(
|
||||
`Contract PDF deferred for ${contract.reference}: ${err}. It will render on view/download once Chromium is available.`,
|
||||
);
|
||||
@@ -642,15 +713,14 @@ export class ContractTransitionService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Every approval step landed → generate the contract PDF, then CONTRACT_READY.
|
||||
* This is the only point at which the document is produced: approvers review a
|
||||
* live preview, and the final approval is what turns it into a PDF. Renders
|
||||
* unconditionally so the file reflects every edit made during the chain.
|
||||
*/
|
||||
private async finalizeApprovedContract(contractId: string): Promise<Contract> {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
if (!contract.contractGeneratedAt) {
|
||||
await this.renderContractDocument(contract);
|
||||
}
|
||||
await this.renderContractDocument(contract, { strict: true });
|
||||
await this.contractsRepository.update(contractId, {
|
||||
status: 'CONTRACT_READY',
|
||||
} as never);
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
} from '@nestjs/swagger';
|
||||
|
||||
import { BookingStaff } from '../../common/booking-guards';
|
||||
import { ContractDocumentHistoryService } from './contract-document-history.service';
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
import {
|
||||
assertFreightPermission,
|
||||
@@ -60,7 +61,6 @@ 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,
|
||||
RejectStepDto,
|
||||
RequestChangesDto,
|
||||
@@ -90,6 +90,7 @@ import {
|
||||
@ApiBearerAuth()
|
||||
export class ContractsController {
|
||||
constructor(
|
||||
private readonly documentHistory: ContractDocumentHistoryService,
|
||||
private readonly contractsService: ContractsService,
|
||||
private readonly pricingService: ContractPricingService,
|
||||
private readonly transitionService: ContractTransitionService,
|
||||
@@ -352,8 +353,22 @@ export class ContractsController {
|
||||
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);
|
||||
getContractDocumentDraft(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
// Editability depends on WHO is asking — only the approver whose turn it is
|
||||
// may edit — so the caller is part of the draft lookup.
|
||||
return this.transitionService.getContractDocumentDraft(id, user);
|
||||
}
|
||||
|
||||
@Get(':id/document/revisions')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.view)
|
||||
@ApiOperation({
|
||||
summary: 'Audit trail of edits to this contract\'s document (newest first)',
|
||||
})
|
||||
getContractDocumentRevisions(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.documentHistory.list(id);
|
||||
}
|
||||
|
||||
@Put(':id/document/articles')
|
||||
@@ -365,8 +380,14 @@ export class ContractsController {
|
||||
updateContractDocument(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: UpdateContractDocumentDto,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
return this.transitionService.updateContractDocument(id, dto);
|
||||
return this.transitionService.updateContractDocument(
|
||||
id,
|
||||
dto,
|
||||
user,
|
||||
resolveAuthUserId(user),
|
||||
);
|
||||
}
|
||||
|
||||
@Post(':id/staff/request-changes')
|
||||
@@ -396,23 +417,20 @@ export class ContractsController {
|
||||
}
|
||||
|
||||
@Post(':id/approval-steps/:stepId/approve')
|
||||
@BookingStaff([
|
||||
FREIGHT_PERMS.contracts.approveLineStaff,
|
||||
FREIGHT_PERMS.contracts.approveDirector,
|
||||
FREIGHT_PERMS.contracts.approveCeo,
|
||||
])
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.view)
|
||||
@ApiOperation({ summary: 'Approve one approval step in sequence' })
|
||||
approveStep(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param('stepId', ParseUUIDPipe) stepId: string,
|
||||
@Body() dto: ApproveStepDto,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
// Whether this caller may approve depends on the step's own required role
|
||||
// (an IAM position type), so the service resolves the step and authorizes
|
||||
// against it — the client never declares its own role.
|
||||
return this.transitionService.approveStep(
|
||||
id,
|
||||
stepId,
|
||||
resolveAuthUserId(user),
|
||||
dto.requiredRole,
|
||||
user,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -41,6 +41,8 @@ import { ContractRateSnapshot } from './entities/contract-rate-snapshot.entity';
|
||||
import { ContractSignature } from './entities/contract-signature.entity';
|
||||
import { ContractApprovalStep } from './entities/contract-approval-step.entity';
|
||||
import { ContractReviewNote } from './entities/contract-review-note.entity';
|
||||
import { ContractDocumentRevision } from './entities/contract-document-revision.entity';
|
||||
import { ContractDocumentHistoryService } from './contract-document-history.service';
|
||||
import { ContractClearanceCycle } from './entities/contract-clearance-cycle.entity';
|
||||
import { ContractDocumentReview } from './entities/contract-document-review.entity';
|
||||
import { ClearanceMilestone } from './entities/clearance-milestone.entity';
|
||||
@@ -64,6 +66,7 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum
|
||||
ContractSignature,
|
||||
ContractApprovalStep,
|
||||
ContractReviewNote,
|
||||
ContractDocumentRevision,
|
||||
ContractClearanceCycle,
|
||||
ContractDocumentReview,
|
||||
ClearanceMilestone,
|
||||
@@ -107,6 +110,7 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum
|
||||
ClearanceFeeService,
|
||||
ContractNotifierService,
|
||||
ContractTransitionService,
|
||||
ContractDocumentHistoryService,
|
||||
ContractClearanceService,
|
||||
ClearanceWorkflowService,
|
||||
BookingClearanceService,
|
||||
|
||||
@@ -26,10 +26,10 @@ export class ContractApprovalStep extends BaseEntity {
|
||||
@Column({ name: 'step_order', type: 'smallint', default: 0 })
|
||||
stepOrder!: number;
|
||||
|
||||
@Column({ name: 'required_role', type: 'varchar', length: 40 })
|
||||
@Column({ name: 'required_role', type: 'varchar', length: 64 })
|
||||
requiredRole!: string;
|
||||
|
||||
@Column({ name: 'blocks_role', type: 'varchar', length: 40, nullable: true })
|
||||
@Column({ name: 'blocks_role', type: 'varchar', length: 64, nullable: true })
|
||||
blocksRole?: string | null;
|
||||
|
||||
@Column({ name: 'status', type: 'varchar', length: 20, default: 'PENDING' })
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
||||
import type { ContractDocumentChange } from '../contract-document-diff.util';
|
||||
import { Contract } from './contract.entity';
|
||||
|
||||
/**
|
||||
* Append-only audit of contract document edits. The document stays editable
|
||||
* through the whole approval chain, so this records who changed which article
|
||||
* and when — the contract itself only ever holds the current snapshot.
|
||||
*/
|
||||
@Entity({ schema: 'freight', name: 'contract_document_revisions' })
|
||||
@Index(['contractId'])
|
||||
export class ContractDocumentRevision extends BaseEntity {
|
||||
@Column({ name: 'contract_id', type: 'uuid' })
|
||||
contractId!: string;
|
||||
|
||||
@ManyToOne(() => Contract, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'contract_id' })
|
||||
contract?: Contract;
|
||||
|
||||
@Column({ name: 'actor_id', type: 'uuid', nullable: true })
|
||||
actorId?: string | null;
|
||||
|
||||
/** The approval step's required role at the time of the edit. */
|
||||
@Column({ name: 'actor_role', type: 'varchar', length: 64, nullable: true })
|
||||
actorRole?: string | null;
|
||||
|
||||
@Column({ name: 'step_id', type: 'uuid', nullable: true })
|
||||
stepId?: string | null;
|
||||
|
||||
@Column({ name: 'summary', type: 'varchar', length: 255, nullable: true })
|
||||
summary?: string | null;
|
||||
|
||||
@Column({ name: 'changes', type: 'jsonb', default: () => `'[]'::jsonb` })
|
||||
changes!: ContractDocumentChange[];
|
||||
}
|
||||
@@ -31,6 +31,15 @@ export class ApprovalRulesController {
|
||||
return this.service.findChain(flag === 'true');
|
||||
}
|
||||
|
||||
@Get('position-types')
|
||||
@RuleEngineView('approval-rules')
|
||||
@ApiOperation({
|
||||
summary: 'IAM position types to choose from when building an approval chain',
|
||||
})
|
||||
listPositionTypes() {
|
||||
return this.service.listPositionTypes();
|
||||
}
|
||||
|
||||
@Post('reorder')
|
||||
@RuleEngineManage('approval-rules')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsBoolean, IsInt, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator';
|
||||
|
||||
const ROLES = ['LINE_STAFF', 'DIRECTOR', 'CEO'] as const;
|
||||
|
||||
export class CreateApprovalRuleDto {
|
||||
@ApiProperty({ description: 'True = Director+CEO chain; False = LineStaff+Director chain' })
|
||||
@IsBoolean()
|
||||
@@ -19,9 +17,12 @@ export class CreateApprovalRuleDto {
|
||||
@IsUUID('4')
|
||||
insertAfterId?: string;
|
||||
|
||||
@ApiProperty({ enum: ROLES, description: 'Role required to action this step' })
|
||||
@ApiProperty({
|
||||
description:
|
||||
'IAM position-type key required to action this step (see GET /approval-rules/position-types)',
|
||||
})
|
||||
@IsString()
|
||||
@MaxLength(30)
|
||||
@MaxLength(64)
|
||||
requiredRole!: string;
|
||||
|
||||
@ApiProperty({ description: 'Label shown in UI, e.g. "Review & Approve"', maxLength: 50 })
|
||||
@@ -29,9 +30,11 @@ export class CreateApprovalRuleDto {
|
||||
@MaxLength(50)
|
||||
actionLabel!: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: ROLES, description: 'Role explicitly blocked from actioning this step' })
|
||||
@ApiPropertyOptional({
|
||||
description: 'IAM position-type key explicitly blocked from actioning this step',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(30)
|
||||
@MaxLength(64)
|
||||
blocksRole?: string;
|
||||
}
|
||||
|
||||
@@ -12,12 +12,12 @@ export class ApprovalRule extends BaseEntity {
|
||||
@Column({ name: 'step_order', type: 'smallint' })
|
||||
stepOrder!: number;
|
||||
|
||||
@Column({ name: 'required_role', type: 'varchar', length: 30 })
|
||||
@Column({ name: 'required_role', type: 'varchar', length: 64 })
|
||||
requiredRole!: string;
|
||||
|
||||
@Column({ name: 'action_label', type: 'varchar', length: 50 })
|
||||
actionLabel!: string;
|
||||
|
||||
@Column({ name: 'blocks_role', type: 'varchar', length: 30, nullable: true })
|
||||
@Column({ name: 'blocks_role', type: 'varchar', length: 64, nullable: true })
|
||||
blocksRole?: string | null;
|
||||
}
|
||||
|
||||
@@ -64,7 +64,6 @@ 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';
|
||||
import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.entity';
|
||||
@@ -87,7 +86,6 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.
|
||||
ApprovalRule,
|
||||
BookingContainer,
|
||||
BookingCargoModifier,
|
||||
BookingApprovalStep,
|
||||
BookingRateSnapshot,
|
||||
]),
|
||||
// Team notifications for the priority-rule approval workflow.
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Inject, Injectable, BadRequestException } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { BookingApprovalStep } from '../bookings/entities/booking-approval-step.entity';
|
||||
import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.entity';
|
||||
import { Rate, RateTrigger } from './entities/rate.entity';
|
||||
import {
|
||||
@@ -23,15 +22,10 @@ import {
|
||||
IRatesRepository,
|
||||
RATES_REPOSITORY,
|
||||
} from './interfaces/rates.repository.interface';
|
||||
import {
|
||||
IApprovalRulesRepository,
|
||||
APPROVAL_RULES_REPOSITORY,
|
||||
} from './interfaces/approval-rules.repository.interface';
|
||||
import {
|
||||
IShippingLinesRepository,
|
||||
SHIPPING_LINES_REPOSITORY,
|
||||
} from './interfaces/shipping-lines.repository.interface';
|
||||
import { DEFAULT_APPROVAL_RULE_ROWS } from './approval-rules.defaults';
|
||||
import { GOVERNMENT_PRIORITY_BONUS } from './government-priority.constants';
|
||||
|
||||
export interface BookingContainerEvalInput {
|
||||
@@ -124,8 +118,6 @@ export class RuleEngineService {
|
||||
private readonly priorityConfigsRepo: IPriorityConfigsRepository,
|
||||
@Inject(RATES_REPOSITORY)
|
||||
private readonly ratesRepo: IRatesRepository,
|
||||
@Inject(APPROVAL_RULES_REPOSITORY)
|
||||
private readonly approvalRulesRepo: IApprovalRulesRepository,
|
||||
@Inject(SHIPPING_LINES_REPOSITORY)
|
||||
private readonly shippingLinesRepo: IShippingLinesRepository,
|
||||
private readonly dataSource: DataSource,
|
||||
@@ -390,79 +382,6 @@ export class RuleEngineService {
|
||||
return violations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure ITMLS default approval chains exist (container + bulk). Idempotent.
|
||||
*/
|
||||
async ensureDefaultApprovalRules(): Promise<void> {
|
||||
for (const flag of [false, true] as const) {
|
||||
const existing = await this.approvalRulesRepo.findChainForCargo(flag);
|
||||
if (existing.length > 0) continue;
|
||||
|
||||
const rows = DEFAULT_APPROVAL_RULE_ROWS.filter(
|
||||
(r) => r.requiresDirectorApproval === flag,
|
||||
);
|
||||
for (const row of rows) {
|
||||
await this.approvalRulesRepo.create({
|
||||
requiresDirectorApproval: row.requiresDirectorApproval,
|
||||
stepOrder: row.stepOrder,
|
||||
requiredRole: row.requiredRole,
|
||||
actionLabel: row.actionLabel,
|
||||
blocksRole: row.blocksRole,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Instantiate booking_approval_step rows from approval_rules by freight type.
|
||||
*/
|
||||
async instantiateApprovalSteps(
|
||||
bookingId: string,
|
||||
options: {
|
||||
freightType: 'CONTAINER' | 'BULK';
|
||||
cargoTypeId?: string | null;
|
||||
},
|
||||
): Promise<BookingApprovalStep[]> {
|
||||
await this.ensureDefaultApprovalRules();
|
||||
|
||||
let requiresDirectorApproval = false;
|
||||
|
||||
if (options.cargoTypeId) {
|
||||
const cargoType = await this.cargoTypesRepo.findById(options.cargoTypeId);
|
||||
if (!cargoType) {
|
||||
throw new BadRequestException(`Cargo type ${options.cargoTypeId} not found`);
|
||||
}
|
||||
requiresDirectorApproval = cargoType.requiresDirectorApproval;
|
||||
}
|
||||
|
||||
const chain = await this.approvalRulesRepo.findChainForCargo(
|
||||
requiresDirectorApproval,
|
||||
);
|
||||
|
||||
if (chain.length === 0) {
|
||||
throw new BadRequestException(
|
||||
`Approval chain could not be loaded for requiresDirectorApproval=${requiresDirectorApproval}.`,
|
||||
);
|
||||
}
|
||||
|
||||
const stepRepo = this.dataSource.getRepository(BookingApprovalStep);
|
||||
const steps: BookingApprovalStep[] = [];
|
||||
|
||||
for (const rule of chain) {
|
||||
const step = stepRepo.create({
|
||||
bookingId,
|
||||
approvalRuleId: rule.id,
|
||||
stepOrder: rule.stepOrder,
|
||||
requiredRole: rule.requiredRole,
|
||||
blocksRole: rule.blocksRole ?? null,
|
||||
status: 'PENDING',
|
||||
});
|
||||
steps.push(await stepRepo.save(step));
|
||||
}
|
||||
|
||||
return steps;
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot only the rates used in a booking's final price.
|
||||
*/
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { PaginatedResponse } from '@edr/types';
|
||||
import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { CreateApprovalRuleDto } from '../dto/create-approval-rule.dto';
|
||||
import { ListApprovalRulesQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||
import { ReorderItemsDto } from '../dto/reorder-items.dto';
|
||||
@@ -17,6 +18,7 @@ export class ApprovalRulesService {
|
||||
@Inject(APPROVAL_RULES_REPOSITORY)
|
||||
private readonly repository: IApprovalRulesRepository,
|
||||
private readonly displayOrder: DisplayOrderService,
|
||||
private readonly dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
/** List approval rules — standard paginated envelope with server-side search. */
|
||||
@@ -29,6 +31,21 @@ export class ApprovalRulesService {
|
||||
return this.repository.findChainForCargo(requiresDirectorApproval);
|
||||
}
|
||||
|
||||
/**
|
||||
* IAM position types, for the approval-step role picker. A chain step names
|
||||
* the position type that must approve it, so this is the vocabulary an admin
|
||||
* builds chains from. Read straight from the shared `iam` schema — the same
|
||||
* pattern the freight API already uses for `iam.users`.
|
||||
*/
|
||||
async listPositionTypes(): Promise<Array<{ label: string; value: string }>> {
|
||||
const rows = await this.dataSource.query<
|
||||
Array<{ key: string; label: string }>
|
||||
>(`SELECT key, COALESCE(name->>'en', key) AS label
|
||||
FROM iam.position_types
|
||||
ORDER BY 2`);
|
||||
return rows.map((row) => ({ label: row.label, value: row.key }));
|
||||
}
|
||||
|
||||
/** Get an approval rule by ID. */
|
||||
async findById(id: string): Promise<ApprovalRule> {
|
||||
const entity = await this.repository.findById(id);
|
||||
|
||||
Reference in New Issue
Block a user