contrat nad booking modification

This commit is contained in:
Marshal
2026-07-20 12:24:58 +00:00
parent eb532399d9
commit b90afadfba
55 changed files with 1210 additions and 1373 deletions

View File

@@ -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;

View File

@@ -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');
});
});

View File

@@ -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}`,

View File

@@ -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" })

View File

@@ -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,

View File

@@ -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

View File

@@ -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;
}

View File

@@ -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[];