mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 03:05:42 +00:00
Customer Truck Assignment and Portal Delivary Approval
This commit is contained in:
@@ -1,20 +1,26 @@
|
||||
import { forwardRef, Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import { OnEvent } from '@nestjs/event-emitter';
|
||||
import { Freight } from '@edr/types';
|
||||
import { DataSource } from 'typeorm';
|
||||
import {
|
||||
BadRequestException,
|
||||
forwardRef,
|
||||
Inject,
|
||||
Injectable,
|
||||
Logger,
|
||||
} from "@nestjs/common";
|
||||
import { OnEvent } from "@nestjs/event-emitter";
|
||||
import { Freight } from "@edr/types";
|
||||
import { DataSource, EntityManager } from "typeorm";
|
||||
|
||||
import {
|
||||
BillingService,
|
||||
GenerateInvoiceInput,
|
||||
InvoiceEventPayload,
|
||||
InvoiceLineInput,
|
||||
} from '../billing/billing.service';
|
||||
import { Invoice } from '../billing/entities/invoice.entity';
|
||||
import { FirstMileService } from '../first-mile/first-mile.service';
|
||||
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
|
||||
import { PriceLineItemDto } from './dto/generate-price-response.dto';
|
||||
import { BookingsRepository } from './bookings.repository';
|
||||
import { Booking } from './entities/booking.entity';
|
||||
} from "../billing/billing.service";
|
||||
import { Invoice } from "../billing/entities/invoice.entity";
|
||||
import { FirstMileService } from "../first-mile/first-mile.service";
|
||||
import { BookingBatchService } from "../train-scheduling/booking-batch.service";
|
||||
import { PriceLineItemDto } from "./dto/generate-price-response.dto";
|
||||
import { BookingsRepository } from "./bookings.repository";
|
||||
import { Booking } from "./entities/booking.entity";
|
||||
|
||||
/** Snapshot written onto `booking.pricingBreakdown` by the pricing service. */
|
||||
interface StoredPricingBreakdown {
|
||||
@@ -23,6 +29,12 @@ interface StoredPricingBreakdown {
|
||||
currency?: string;
|
||||
}
|
||||
|
||||
export interface InvoiceOptions {
|
||||
dueDate?: Date;
|
||||
invoiceType?: string;
|
||||
invoiceStatus?: Freight.InvoiceStatus;
|
||||
}
|
||||
|
||||
/** Round to 2 decimals, avoiding binary float drift. */
|
||||
const round2 = (n: number): number => Math.round(n * 100) / 100;
|
||||
|
||||
@@ -52,32 +64,28 @@ export class BookingInvoiceService {
|
||||
* Ensure the booking has its invoice, generating one from the snapshotted
|
||||
* pricing breakdown if absent. Called when a booking reaches a billable state.
|
||||
* Idempotent — returns the existing open invoice instead of a duplicate.
|
||||
* Returns `null` (and logs) when the booking is not billable: no company to
|
||||
* bill (e.g. government bookings whose `companyId` is null, which the invoices
|
||||
* FK requires), or no priced amount.
|
||||
* Throws `BadRequestException` when the booking is not billable: no company
|
||||
* to bill (e.g. government bookings whose `companyId` is null, which the
|
||||
* invoices FK requires), or no priced amount.
|
||||
*/
|
||||
async ensureInvoiceForBooking(booking: Booking): Promise<Invoice | null> {
|
||||
async ensureInvoiceForBooking(
|
||||
booking: Booking,
|
||||
invoiceOptions: InvoiceOptions = {},
|
||||
): Promise<Invoice> {
|
||||
const existing = await this.billing.findPayable(
|
||||
Freight.InvoiceSource.Booking,
|
||||
booking.id,
|
||||
Freight.InvoiceType.Prepaid,
|
||||
"PREPAID",
|
||||
);
|
||||
if (existing) return existing;
|
||||
|
||||
if (!booking.companyId) {
|
||||
this.logger.warn(
|
||||
`Skipping invoice for booking ${booking.reference} (${booking.id}): no company to bill.`,
|
||||
throw new BadRequestException(
|
||||
`Cannot generate invoice for booking ${booking.reference} (${booking.id}): no company to bill.`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const input = this.buildInput(booking);
|
||||
if (!input) {
|
||||
this.logger.warn(
|
||||
`Skipping invoice for booking ${booking.reference} (${booking.id}): no priced amount.`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
const input = this.buildInput(booking, invoiceOptions);
|
||||
|
||||
return this.billing.generateInvoice(input);
|
||||
}
|
||||
@@ -87,10 +95,10 @@ export class BookingInvoiceService {
|
||||
* reactions live here (not in the payment process): each invoice type advances
|
||||
* the booking its own way. Only PREPAID exists today.
|
||||
*/
|
||||
@OnEvent('booking.invoice.paid')
|
||||
@OnEvent("booking.invoice.paid")
|
||||
async onBookingInvoicePaid(payload: InvoiceEventPayload): Promise<void> {
|
||||
switch (payload.type) {
|
||||
case Freight.InvoiceType.Prepaid:
|
||||
case "PREPAID":
|
||||
await this.advanceBookingOnPayment(payload.sourceId);
|
||||
break;
|
||||
default:
|
||||
@@ -100,6 +108,14 @@ export class BookingInvoiceService {
|
||||
}
|
||||
}
|
||||
|
||||
updateStatus(
|
||||
invoiceId: string,
|
||||
status: Freight.InvoiceStatus,
|
||||
manager?: EntityManager,
|
||||
): Promise<void> {
|
||||
return this.billing.updateStatus(invoiceId, status, manager);
|
||||
}
|
||||
|
||||
/**
|
||||
* Advance a booking once its prepaid invoice settles — the domain side-effect
|
||||
* of payment, relocated out of the payment service: the booking becomes PAID
|
||||
@@ -114,16 +130,18 @@ export class BookingInvoiceService {
|
||||
private async advanceBookingOnPayment(bookingId: string): Promise<void> {
|
||||
const booking = await this.bookingsRepository.findById(bookingId);
|
||||
if (!booking) {
|
||||
this.logger.warn(`Cannot advance unknown booking ${bookingId} on payment.`);
|
||||
this.logger.warn(
|
||||
`Cannot advance unknown booking ${bookingId} on payment.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (booking.paymentStatus === 'PAID') return;
|
||||
if (booking.paymentStatus === "PAID") return;
|
||||
|
||||
await this.dataSource.transaction(async (mg) => {
|
||||
await mg.update(
|
||||
Booking,
|
||||
{ id: bookingId },
|
||||
{ paymentStatus: 'PAID', status: 'PAID' },
|
||||
{ paymentStatus: "PAID", status: "PAID" },
|
||||
);
|
||||
await this.firstMile.acceptBooking(bookingId);
|
||||
});
|
||||
@@ -138,9 +156,13 @@ export class BookingInvoiceService {
|
||||
}
|
||||
|
||||
/** Map a booking's pricing snapshot into a generic invoice request. */
|
||||
private buildInput(booking: Booking): GenerateInvoiceInput | null {
|
||||
const breakdown = (booking.pricingBreakdown ?? {}) as StoredPricingBreakdown;
|
||||
const currency = breakdown.currency ?? booking.paymentCurrency ?? 'ETB';
|
||||
private buildInput(
|
||||
booking: Booking,
|
||||
invoiceOptions: InvoiceOptions = {},
|
||||
): GenerateInvoiceInput {
|
||||
const breakdown = (booking.pricingBreakdown ??
|
||||
{}) as StoredPricingBreakdown;
|
||||
const currency = breakdown.currency ?? booking.paymentCurrency ?? "ETB";
|
||||
|
||||
const lines: InvoiceLineInput[] = (breakdown.lineItems ?? []).map((l) => ({
|
||||
chargeType: l.code,
|
||||
@@ -155,10 +177,14 @@ export class BookingInvoiceService {
|
||||
// Fall back to a single freight line when no breakdown was snapshotted.
|
||||
if (lines.length === 0) {
|
||||
const amount = Number(booking.totalAmount);
|
||||
if (!Number.isFinite(amount) || amount <= 0) return null;
|
||||
if (!Number.isFinite(amount) || amount <= 0) {
|
||||
throw new BadRequestException(
|
||||
`Cannot generate invoice for booking ${booking.reference} (${booking.id}): no priced amount.`,
|
||||
);
|
||||
}
|
||||
lines.push({
|
||||
chargeType: 'FREIGHT',
|
||||
description: 'Rail freight',
|
||||
chargeType: "FREIGHT",
|
||||
description: "Rail freight",
|
||||
quantity: 1,
|
||||
unitRate: amount,
|
||||
amount,
|
||||
@@ -166,7 +192,9 @@ export class BookingInvoiceService {
|
||||
});
|
||||
}
|
||||
|
||||
const subtotal = round2(lines.reduce((sum, l) => sum + Number(l.amount), 0));
|
||||
const subtotal = round2(
|
||||
lines.reduce((sum, l) => sum + Number(l.amount), 0),
|
||||
);
|
||||
let totalAmount = subtotal;
|
||||
|
||||
// Honor a staff price override: bill the adjusted total, recording the delta
|
||||
@@ -176,8 +204,8 @@ export class BookingInvoiceService {
|
||||
const delta = round2(Number(adjusted) - subtotal);
|
||||
if (delta !== 0) {
|
||||
lines.push({
|
||||
chargeType: 'ADJUSTMENT',
|
||||
description: 'Staff price adjustment',
|
||||
chargeType: "ADJUSTMENT",
|
||||
description: "Staff price adjustment",
|
||||
quantity: 1,
|
||||
unitRate: delta,
|
||||
amount: delta,
|
||||
@@ -190,12 +218,14 @@ export class BookingInvoiceService {
|
||||
return {
|
||||
source: Freight.InvoiceSource.Booking,
|
||||
sourceId: booking.id,
|
||||
type: Freight.InvoiceType.Prepaid,
|
||||
companyId: booking.companyId,
|
||||
companyProfileId: booking.companyProfileId,
|
||||
currency,
|
||||
lines,
|
||||
totalAmount,
|
||||
dueAt: invoiceOptions.dueDate,
|
||||
type: invoiceOptions.invoiceType ?? "PREPAID",
|
||||
status: invoiceOptions.invoiceStatus ?? Freight.InvoiceStatus.Draft,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,180 +0,0 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
HttpStatus,
|
||||
Post,
|
||||
Query,
|
||||
Res,
|
||||
} from "@nestjs/common";
|
||||
import {
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
ApiQuery,
|
||||
ApiOkResponse,
|
||||
ApiProduces,
|
||||
} from "@nestjs/swagger";
|
||||
import { Response } from "express";
|
||||
import { Public } from "@edr/api-common";
|
||||
import { Freight } from "@edr/types";
|
||||
|
||||
import { BillingService } from "../billing/billing.service";
|
||||
import {
|
||||
InitiatePaymentDto,
|
||||
InitiateResponseDto,
|
||||
PaymentMethodTypeEnum,
|
||||
PaymentPlatformDto,
|
||||
} from "../payment/payments.dto";
|
||||
|
||||
/**
|
||||
* Booking-payment entrypoints. This is the ONE place that knows a payment is for a
|
||||
* booking — it maps the request to {@link Freight.InvoiceSource.Booking} and hands
|
||||
* off to billing, which resolves the invoice/amount and drives the gateway. Billing
|
||||
* and payment stay source-agnostic; the booking knowledge lives here, in the domain.
|
||||
* Routes are unchanged (`/payments/*`) so the portal is unaffected.
|
||||
*/
|
||||
@ApiTags("Payment")
|
||||
@Controller("payments")
|
||||
export class BookingPaymentController {
|
||||
constructor(private readonly billing: BillingService) { }
|
||||
|
||||
@Post("initiate")
|
||||
@ApiOperation({
|
||||
summary: "Initiate payment for a freight booking",
|
||||
description: "Charges the booking's open invoice through the payment gateway.",
|
||||
})
|
||||
@ApiOkResponse({ type: InitiateResponseDto })
|
||||
initiate(@Body() dto: InitiatePaymentDto): Promise<InitiateResponseDto> {
|
||||
return this.billing.payInvoice(Freight.InvoiceSource.Booking, dto.bookingId, {
|
||||
method: dto.method,
|
||||
platform: dto.platform,
|
||||
payerAccount: dto.payerAccount,
|
||||
returnUrl: dto.returnUrl,
|
||||
failureUrl: dto.failureUrl,
|
||||
});
|
||||
}
|
||||
|
||||
@Get("checkout")
|
||||
@Public()
|
||||
@ApiOperation({
|
||||
summary: "Browser checkout redirect",
|
||||
description:
|
||||
"Charges the booking's invoice and returns an HTML page that auto-redirects to the provider checkout URL. Open directly in a browser tab.",
|
||||
})
|
||||
@ApiQuery({ name: "bookingId", required: true })
|
||||
@ApiQuery({ name: "method", enum: PaymentMethodTypeEnum, required: true })
|
||||
@ApiQuery({ name: "platform", enum: ["web", "mobile"], required: false })
|
||||
@ApiProduces("text/html")
|
||||
async checkout(
|
||||
@Query("bookingId") bookingId: string,
|
||||
@Query("method") method: PaymentMethodTypeEnum,
|
||||
@Query("platform") platform: PaymentPlatformDto = "web",
|
||||
@Res() res: Response,
|
||||
) {
|
||||
if (!bookingId) {
|
||||
return res
|
||||
.status(HttpStatus.BAD_REQUEST)
|
||||
.type("html")
|
||||
.send(this.buildErrorHtml("Missing required query parameter: bookingId"));
|
||||
}
|
||||
if (!method || !Object.values(PaymentMethodTypeEnum).includes(method)) {
|
||||
return res
|
||||
.status(HttpStatus.BAD_REQUEST)
|
||||
.type("html")
|
||||
.send(this.buildErrorHtml("Missing or invalid query parameter: method"));
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await this.billing.payInvoice(
|
||||
Freight.InvoiceSource.Booking,
|
||||
bookingId,
|
||||
{ method, platform },
|
||||
);
|
||||
const url =
|
||||
result.clientAction?.type === "REDIRECT" ? result.clientAction.url : undefined;
|
||||
|
||||
if (url) {
|
||||
return res.status(HttpStatus.OK).type("html").send(this.buildRedirectHtml(url));
|
||||
}
|
||||
return res
|
||||
.status(HttpStatus.OK)
|
||||
.type("html")
|
||||
.send(this.buildStatusHtml(result.status, result.intentId));
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : "An unexpected error occurred";
|
||||
return res.status(HttpStatus.OK).type("html").send(this.buildErrorHtml(message));
|
||||
}
|
||||
}
|
||||
|
||||
private buildRedirectHtml(url: string): string {
|
||||
const escaped = url.replace(/\"/g, """);
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta http-equiv="refresh" content="0;url=${escaped}">
|
||||
<title>Redirecting to payment…</title>
|
||||
<style>
|
||||
body { font-family: sans-serif; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; background: #f5f5f5; }
|
||||
.card { background: #fff; border-radius: 8px; padding: 40px; text-align: center; box-shadow: 0 2px 8px rgba(0,0,0,.1); max-width: 400px; }
|
||||
.spinner { width: 40px; height: 40px; border: 4px solid #e0e0e0; border-top-color: #1a73e8; border-radius: 50%; animation: spin .8s linear infinite; margin: 0 auto 20px; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
p { color: #555; margin: 0 0 16px; }
|
||||
a { color: #1a73e8; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div class="spinner"></div>
|
||||
<p>Redirecting to payment provider…</p>
|
||||
<p><a href="${escaped}">Click here if you are not redirected</a></p>
|
||||
</div>
|
||||
<script>window.location.href = "${escaped}";</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
private buildStatusHtml(status: string, intentId: string): string {
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Payment status</title>
|
||||
<style>
|
||||
body { font-family: sans-serif; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; background: #f5f5f5; }
|
||||
.card { background: #fff; border-radius: 8px; padding: 40px; text-align: center; box-shadow: 0 2px 8px rgba(0,0,0,.1); max-width: 400px; }
|
||||
.status { font-size: 1.1rem; font-weight: 600; color: #333; margin-bottom: 8px; }
|
||||
small { color: #888; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div class="status">${status}</div>
|
||||
<small>Intent: ${intentId}</small>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
private buildErrorHtml(message: string): string {
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Payment error</title>
|
||||
<style>
|
||||
body { font-family: sans-serif; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; background: #f5f5f5; }
|
||||
.card { background: #fff; border-radius: 8px; padding: 40px; text-align: center; box-shadow: 0 2px 8px rgba(0,0,0,.1); max-width: 400px; }
|
||||
.error { color: #d32f2f; font-weight: 600; margin-bottom: 8px; }
|
||||
p { color: #555; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div class="error">Payment could not be initiated</div>
|
||||
<p>${message}</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Freight } from '@edr/types';
|
||||
import { BookingsRepository } from './bookings.repository';
|
||||
import { Booking } from './entities/booking.entity';
|
||||
import { assertBookingStatus } from './booking-status.util';
|
||||
import { InAppPaymentReceiptDto } from './dto/pay-booking.dto';
|
||||
import { BillingService } from '../billing/billing.service';
|
||||
import { PaymentMethodTypeEnum } from '../payment/payments.dto';
|
||||
export interface InAppPaymentReceipt extends InAppPaymentReceiptDto { }
|
||||
|
||||
@Injectable()
|
||||
export class BookingPaymentService {
|
||||
constructor(
|
||||
private readonly bookingsRepository: BookingsRepository,
|
||||
private readonly billing: BillingService,
|
||||
) { }
|
||||
|
||||
/**
|
||||
* Start payment for a booking. The booking never touches the payment gateway
|
||||
* directly — it charges its invoice through billing, which resolves the amount
|
||||
* and drives the provider. Returns the provider redirect URL (empty when none).
|
||||
*/
|
||||
async pay(bookingId: string): Promise<{ redirectUrl: string }> {
|
||||
const booking = await this.requireBooking(bookingId);
|
||||
assertBookingStatus(booking, ['FULLY_EXECUTED', 'SELECTED_FOR_BATCH', 'AWAITING_PAYMENT', '']);
|
||||
|
||||
const resp = await this.billing.payInvoice(Freight.InvoiceSource.Booking, bookingId, {
|
||||
method: PaymentMethodTypeEnum.TELEBIRR,
|
||||
platform: 'web',
|
||||
});
|
||||
|
||||
const action = resp.clientAction as { type?: string; url?: string } | undefined;
|
||||
return {
|
||||
redirectUrl: action?.type === 'REDIRECT' ? (action.url ?? '') : '',
|
||||
};
|
||||
}
|
||||
|
||||
private async requireBooking(id: string): Promise<Booking> {
|
||||
const booking = await this.bookingsRepository.findById(id);
|
||||
if (!booking) throw new NotFoundException(`Booking ${id} not found`);
|
||||
return booking;
|
||||
}
|
||||
}
|
||||
@@ -58,7 +58,6 @@ export function buildCargoTypeTree(
|
||||
id: child.id,
|
||||
name: child.cargoTypeName,
|
||||
code: child.code,
|
||||
show_free_text_box: child.showFreeTextBox,
|
||||
unit_of_measure: child.unitOfMeasure ?? null,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -35,6 +35,8 @@ describe('BookingTransitionService — acceptIntake validity window', () => {
|
||||
{} as never, // fileUploadSettingsService
|
||||
{} as never, // bookingBatchService
|
||||
bookingsService as never,
|
||||
{ isPhasedGeneralCustomsBooking: () => false } as never,
|
||||
{} as never,
|
||||
);
|
||||
return { service, bookingsRepository, ruleEngineService };
|
||||
}
|
||||
|
||||
@@ -46,6 +46,8 @@ describe('BookingTransitionService — finalizeClearance gate', () => {
|
||||
fileUploadSettingsService as never,
|
||||
{} as never, // bookingBatchService
|
||||
bookingsService as never,
|
||||
{ isPhasedGeneralCustomsBooking: () => false } as never,
|
||||
{} as never,
|
||||
);
|
||||
return { service, bookingsRepository };
|
||||
}
|
||||
@@ -128,6 +130,8 @@ describe('BookingTransitionService — finalizeClearance customs output gate', (
|
||||
fileUploadSettingsService as never,
|
||||
{} as never,
|
||||
bookingsService as never,
|
||||
{ isPhasedGeneralCustomsBooking: () => false } as never,
|
||||
{} as never,
|
||||
);
|
||||
return { service, bookingsRepository };
|
||||
}
|
||||
@@ -196,6 +200,8 @@ describe('BookingTransitionService — submitClearanceDocuments required-fields
|
||||
fileUploadSettingsService as never,
|
||||
{} as never,
|
||||
bookingsService as never,
|
||||
{ isPhasedGeneralCustomsBooking: () => false } as never,
|
||||
{} as never,
|
||||
);
|
||||
return { service, bookingsRepository, filesService };
|
||||
}
|
||||
|
||||
@@ -38,6 +38,8 @@ describe('BookingTransitionService — operation review', () => {
|
||||
{} as never, // fileUploadSettingsService
|
||||
bookingBatchService as never,
|
||||
bookingsService as never,
|
||||
{ isPhasedGeneralCustomsBooking: () => false } as never,
|
||||
{} as never,
|
||||
);
|
||||
return { service, bookingsRepository, bookingBatchService };
|
||||
}
|
||||
|
||||
@@ -4,8 +4,8 @@ import {
|
||||
Inject,
|
||||
Injectable,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
||||
} 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';
|
||||
@@ -15,7 +15,6 @@ import { RuleEngineService } from '../rule-engine/rule-engine.service';
|
||||
import { FilesService } from '../files/files.service';
|
||||
import { FileUploadSettingsService } from '../file-upload-settings/file-upload-settings.service';
|
||||
import { BookingContractService } from './booking-contract.service';
|
||||
import { BookingInvoiceService } from './booking-invoice.service';
|
||||
import { BookingPricingService } from './booking-pricing.service';
|
||||
import { BookingsRepository } from './bookings.repository';
|
||||
import { assertBookingStatus } from './booking-status.util';
|
||||
@@ -25,32 +24,47 @@ import { SubmitBookingResponseDto } from './dto/submit-booking-response.dto';
|
||||
import { PriceLineItemDto } from './dto/generate-price-response.dto';
|
||||
import { Booking } from './entities/booking.entity';
|
||||
import { BookingsService } from './bookings.service';
|
||||
import { BookingClearanceService } from '../contracts/booking-clearance.service';
|
||||
import { ClearanceWorkflowService } from '../contracts/clearance-workflow.service';
|
||||
import { ContractDocPhase } from '@edr/types';
|
||||
|
||||
|
||||
import { Freight } from "@edr/types";
|
||||
import { BookingInvoiceService } from "./booking-invoice.service";
|
||||
|
||||
@Injectable()
|
||||
export class BookingTransitionService {
|
||||
private readonly logger = new Logger(BookingTransitionService.name);
|
||||
|
||||
constructor(
|
||||
private readonly bookingsRepository: BookingsRepository,
|
||||
private readonly ruleEngineService: RuleEngineService,
|
||||
private readonly pricingService: BookingPricingService,
|
||||
private readonly contractService: BookingContractService,
|
||||
private readonly invoiceService: BookingInvoiceService,
|
||||
private readonly filesService: FilesService,
|
||||
private readonly fileUploadSettingsService: FileUploadSettingsService,
|
||||
@Inject(forwardRef(() => BookingBatchService))
|
||||
private readonly bookingBatchService: BookingBatchService,
|
||||
@Inject(forwardRef(() => BookingsService))
|
||||
private readonly bookingsService: BookingsService,
|
||||
@Inject(forwardRef(() => BookingClearanceService))
|
||||
private readonly bookingClearanceService: BookingClearanceService,
|
||||
@Inject(forwardRef(() => ClearanceWorkflowService))
|
||||
private readonly workflowService: ClearanceWorkflowService,
|
||||
private readonly invoiceService: BookingInvoiceService,
|
||||
|
||||
) {}
|
||||
|
||||
private isPhasedGeneralCustoms(booking: Booking): boolean {
|
||||
return this.bookingClearanceService.isPhasedGeneralCustomsBooking(booking);
|
||||
}
|
||||
|
||||
async submit(bookingId: string): Promise<SubmitBookingResponseDto> {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
assertBookingStatus(booking, ['DRAFT', 'CHANGES_REQUESTED']);
|
||||
assertBookingStatus(booking, ["DRAFT", "CHANGES_REQUESTED"]);
|
||||
|
||||
if (Number(booking.totalAmount) <= 0) {
|
||||
throw new BadRequestException(
|
||||
'Generate a price before submitting (POST /bookings/:id/generate-price)',
|
||||
"Generate a price before submitting (POST /bookings/:id/generate-price)",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -69,7 +83,8 @@ export class BookingTransitionService {
|
||||
totalAmount?: number;
|
||||
} | null;
|
||||
const unchanged = this.pricingService.pricesMatch(stored, computed);
|
||||
const priorityScore = await this.pricingService.computeSubmitPriorityScore(booking);
|
||||
const priorityScore =
|
||||
await this.pricingService.computeSubmitPriorityScore(booking);
|
||||
|
||||
if (unchanged) {
|
||||
await this.pricingService.createPricingSnapshots(
|
||||
@@ -79,7 +94,7 @@ export class BookingTransitionService {
|
||||
);
|
||||
|
||||
const updated = await this.bookingsRepository.update(bookingId, {
|
||||
status: 'SUBMITTED',
|
||||
status: "SUBMITTED",
|
||||
priorityScore,
|
||||
} as never);
|
||||
|
||||
@@ -109,7 +124,7 @@ export class BookingTransitionService {
|
||||
currency: computed.currency,
|
||||
generatedAt: new Date().toISOString(),
|
||||
},
|
||||
status: 'PRICE_CHANGED_PENDING_CONFIRM',
|
||||
status: "PRICE_CHANGED_PENDING_CONFIRM",
|
||||
} as never);
|
||||
|
||||
const updatedBooking = await this.bookingsService.findById(bookingId);
|
||||
@@ -121,16 +136,17 @@ export class BookingTransitionService {
|
||||
totalAmount: computed.totalAmount,
|
||||
currency: computed.currency,
|
||||
lineItems: computed.lineItems,
|
||||
message: 'Price has changed since preview. Confirm to submit with the updated price.',
|
||||
message:
|
||||
"Price has changed since preview. Confirm to submit with the updated price.",
|
||||
};
|
||||
}
|
||||
|
||||
async confirmSubmit(bookingId: string): Promise<SubmitBookingResponseDto> {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
assertBookingStatus(booking, ['PRICE_CHANGED_PENDING_CONFIRM']);
|
||||
assertBookingStatus(booking, ["PRICE_CHANGED_PENDING_CONFIRM"]);
|
||||
|
||||
if (Number(booking.totalAmount) <= 0) {
|
||||
throw new BadRequestException('No price to confirm');
|
||||
throw new BadRequestException("No price to confirm");
|
||||
}
|
||||
|
||||
const computed = await this.pricingService.computePriceForBooking(booking);
|
||||
@@ -149,9 +165,10 @@ export class BookingTransitionService {
|
||||
computed.appliedModifiers,
|
||||
);
|
||||
|
||||
const priorityScore = await this.pricingService.computeSubmitPriorityScore(booking);
|
||||
const priorityScore =
|
||||
await this.pricingService.computeSubmitPriorityScore(booking);
|
||||
const updated = await this.bookingsRepository.update(bookingId, {
|
||||
status: 'SUBMITTED',
|
||||
status: "SUBMITTED",
|
||||
priorityScore,
|
||||
totalAmount: computed.totalAmount,
|
||||
pricingBreakdown: {
|
||||
@@ -173,7 +190,7 @@ export class BookingTransitionService {
|
||||
totalAmount: Number(finalBooking.totalAmount),
|
||||
currency: finalBooking.paymentCurrency,
|
||||
lineItems: computed.lineItems,
|
||||
message: 'Booking submitted with confirmed price.',
|
||||
message: "Booking submitted with confirmed price.",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -183,17 +200,17 @@ export class BookingTransitionService {
|
||||
actorId: string,
|
||||
): Promise<Booking> {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
assertBookingStatus(booking, ['SUBMITTED']);
|
||||
assertBookingStatus(booking, ["SUBMITTED"]);
|
||||
|
||||
await this.bookingsRepository.createReviewNote(
|
||||
bookingId,
|
||||
note,
|
||||
'CHANGES_REQUESTED',
|
||||
"CHANGES_REQUESTED",
|
||||
actorId,
|
||||
);
|
||||
|
||||
const updated = await this.bookingsRepository.update(bookingId, {
|
||||
status: 'CHANGES_REQUESTED',
|
||||
status: "CHANGES_REQUESTED",
|
||||
} as never);
|
||||
return this.bookingsService.findById(updated!.id);
|
||||
}
|
||||
@@ -203,7 +220,7 @@ export class BookingTransitionService {
|
||||
if ((booking.approvalSteps?.length ?? 0) > 0) return;
|
||||
|
||||
await this.ruleEngineService.instantiateApprovalSteps(booking.id, {
|
||||
freightType: booking.freightType as 'CONTAINER' | 'BULK',
|
||||
freightType: booking.freightType as "CONTAINER" | "BULK",
|
||||
cargoTypeId: booking.cargoTypeId,
|
||||
});
|
||||
}
|
||||
@@ -217,14 +234,14 @@ export class BookingTransitionService {
|
||||
// Only SUBMITTED bookings are acceptable. A booking that still needs
|
||||
// consolidation sits in PENDING_CONSOLIDATION (resolved at submit time) and
|
||||
// is therefore never offered for accept until a partner moves it to SUBMITTED.
|
||||
assertBookingStatus(booking, ['SUBMITTED']);
|
||||
assertBookingStatus(booking, ["SUBMITTED"]);
|
||||
|
||||
// The backoffice must define how long the accepted contract stays valid.
|
||||
// Without a window the contract has no end date and cannot be relied on, so
|
||||
// accept is blocked until a positive number of days is supplied.
|
||||
if (!Number.isInteger(validityDays) || validityDays < 1) {
|
||||
throw new BadRequestException(
|
||||
'A contract validity (in days) is required to accept this booking.',
|
||||
"A contract validity (in days) is required to accept this booking.",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -234,12 +251,12 @@ export class BookingTransitionService {
|
||||
validUntil.setDate(validUntil.getDate() + validityDays);
|
||||
|
||||
await this.ruleEngineService.instantiateApprovalSteps(bookingId, {
|
||||
freightType: booking.freightType as 'CONTAINER' | 'BULK',
|
||||
freightType: booking.freightType as "CONTAINER" | "BULK",
|
||||
cargoTypeId: booking.cargoTypeId,
|
||||
});
|
||||
|
||||
const updated = await this.bookingsRepository.update(bookingId, {
|
||||
status: 'PENDING_APPROVAL',
|
||||
status: "PENDING_APPROVAL",
|
||||
approvedByStaffId: actorId,
|
||||
approvedByStaffAt: validFrom,
|
||||
contractValidityDays: validityDays,
|
||||
@@ -255,17 +272,17 @@ export class BookingTransitionService {
|
||||
actorId: string,
|
||||
): Promise<Booking> {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
assertBookingStatus(booking, ['SUBMITTED', 'PENDING_APPROVAL']);
|
||||
assertBookingStatus(booking, ["SUBMITTED", "PENDING_APPROVAL"]);
|
||||
|
||||
await this.bookingsRepository.createReviewNote(
|
||||
bookingId,
|
||||
reason,
|
||||
'REJECTION',
|
||||
"REJECTION",
|
||||
actorId,
|
||||
);
|
||||
|
||||
const updated = await this.bookingsRepository.update(bookingId, {
|
||||
status: 'REJECTED',
|
||||
status: "REJECTED",
|
||||
} as never);
|
||||
return this.bookingsService.findById(updated!.id);
|
||||
}
|
||||
@@ -283,8 +300,8 @@ export class BookingTransitionService {
|
||||
|
||||
let booking = await this.bookingsService.findById(bookingId);
|
||||
assertBookingStatus(booking, [
|
||||
'PENDING_APPROVAL',
|
||||
'APPROVED_PENDING_SIGNATURE',
|
||||
"PENDING_APPROVAL",
|
||||
"APPROVED_PENDING_SIGNATURE",
|
||||
]);
|
||||
|
||||
if ((booking.approvalSteps?.length ?? 0) === 0) {
|
||||
@@ -296,14 +313,17 @@ export class BookingTransitionService {
|
||||
bookingId,
|
||||
stepId,
|
||||
);
|
||||
if (!step || step.status !== 'PENDING') {
|
||||
throw new BadRequestException('Approval step not found or already actioned');
|
||||
if (!step || step.status !== "PENDING") {
|
||||
throw new BadRequestException(
|
||||
"Approval step not found or already actioned",
|
||||
);
|
||||
}
|
||||
|
||||
const next = await this.bookingsRepository.findNextPendingApprovalStep(bookingId);
|
||||
const next =
|
||||
await this.bookingsRepository.findNextPendingApprovalStep(bookingId);
|
||||
if (!next || next.id !== step.id) {
|
||||
throw new BadRequestException(
|
||||
'Approval steps must be completed in order',
|
||||
"Approval steps must be completed in order",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -315,29 +335,36 @@ export class BookingTransitionService {
|
||||
|
||||
const blocksRole = step.blocksRole;
|
||||
if (blocksRole && blocksRole === requiredRole) {
|
||||
throw new BadRequestException(`Role ${requiredRole} is blocked for this step`);
|
||||
throw new BadRequestException(
|
||||
`Role ${requiredRole} is blocked for this step`,
|
||||
);
|
||||
}
|
||||
|
||||
await this.bookingsRepository.completeApprovalStep(step.id, actorId, 'APPROVED');
|
||||
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';
|
||||
if (requiredRole === "LINE_STAFF") {
|
||||
updates.status = "APPROVED_PENDING_SIGNATURE";
|
||||
updates.approvedByStaffId = actorId;
|
||||
updates.approvedByStaffAt = now;
|
||||
} else if (requiredRole === 'DIRECTOR') {
|
||||
} else if (requiredRole === "DIRECTOR") {
|
||||
updates.signedByDirectorId = actorId;
|
||||
updates.signedByDirectorAt = now;
|
||||
} else if (requiredRole === 'CEO') {
|
||||
} else if (requiredRole === "CEO") {
|
||||
updates.signedByCeoId = actorId;
|
||||
updates.signedByCeoAt = now;
|
||||
}
|
||||
|
||||
const allDone = await this.bookingsRepository.allApprovalStepsComplete(bookingId);
|
||||
const allDone =
|
||||
await this.bookingsRepository.allApprovalStepsComplete(bookingId);
|
||||
if (allDone) {
|
||||
updates.status = 'APPROVED';
|
||||
updates.status = "APPROVED";
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length > 0) {
|
||||
@@ -359,90 +386,64 @@ export class BookingTransitionService {
|
||||
reason: string,
|
||||
): Promise<Booking> {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
assertBookingStatus(booking, ['PENDING_APPROVAL', 'APPROVED_PENDING_SIGNATURE']);
|
||||
assertBookingStatus(booking, [
|
||||
"PENDING_APPROVAL",
|
||||
"APPROVED_PENDING_SIGNATURE",
|
||||
]);
|
||||
|
||||
const step = await this.bookingsRepository.findApprovalStepById(
|
||||
bookingId,
|
||||
stepId,
|
||||
);
|
||||
if (!step) throw new BadRequestException('Approval step not found');
|
||||
if (!step) throw new BadRequestException("Approval step not found");
|
||||
|
||||
await this.bookingsRepository.completeApprovalStep(
|
||||
step.id,
|
||||
actorId,
|
||||
'REJECTED',
|
||||
"REJECTED",
|
||||
reason,
|
||||
);
|
||||
|
||||
await this.bookingsRepository.createReviewNote(
|
||||
bookingId,
|
||||
reason,
|
||||
'REJECTION',
|
||||
"REJECTION",
|
||||
actorId,
|
||||
);
|
||||
|
||||
const updated = await this.bookingsRepository.update(bookingId, {
|
||||
status: 'REJECTED',
|
||||
status: "REJECTED",
|
||||
} as never);
|
||||
return this.bookingsService.findById(updated!.id);
|
||||
}
|
||||
|
||||
async customerSign(bookingId: string): Promise<Booking> {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
assertBookingStatus(booking, ['CONTRACT_READY']);
|
||||
assertBookingStatus(booking, ["CONTRACT_READY"]);
|
||||
|
||||
const updated = await this.bookingsRepository.update(bookingId, {
|
||||
status: 'SIGNED_CUSTOMER',
|
||||
status: "SIGNED_CUSTOMER",
|
||||
customerSignedAt: new Date(),
|
||||
} as never);
|
||||
return this.bookingsService.findById(updated!.id);
|
||||
}
|
||||
|
||||
async marketingApprove(bookingId: string, actorId: string): Promise<Booking> {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
assertBookingStatus(booking, ['SIGNED_CUSTOMER']);
|
||||
|
||||
const updated = await this.bookingsRepository.update(bookingId, {
|
||||
status: 'FULLY_EXECUTED',
|
||||
fullyExecutedAt: new Date(),
|
||||
marketingApprovedById: actorId,
|
||||
marketingApprovedAt: new Date(),
|
||||
lockedAt: new Date(),
|
||||
} as never);
|
||||
|
||||
const executed = await this.bookingsService.findById(updated!.id);
|
||||
|
||||
// Billable state reached — generate the invoice payment will settle.
|
||||
// Non-blocking: a billing hiccup must not undo the execution.
|
||||
await this.invoiceService
|
||||
.ensureInvoiceForBooking(executed)
|
||||
.catch((err) =>
|
||||
this.logger.error(
|
||||
`Failed to generate invoice for booking ${executed.reference}: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
),
|
||||
);
|
||||
|
||||
return executed;
|
||||
}
|
||||
|
||||
async startTransit(bookingId: string): Promise<Booking> {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
assertBookingStatus(booking, ['PAID']);
|
||||
assertBookingStatus(booking, ["PAID"]);
|
||||
|
||||
const updated = await this.bookingsRepository.update(bookingId, {
|
||||
status: 'IN_TRANSIT',
|
||||
status: "IN_TRANSIT",
|
||||
} as never);
|
||||
return this.bookingsService.findById(updated!.id);
|
||||
}
|
||||
|
||||
async complete(bookingId: string): Promise<Booking> {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
assertBookingStatus(booking, ['IN_TRANSIT']);
|
||||
assertBookingStatus(booking, ["IN_TRANSIT"]);
|
||||
|
||||
const updated = await this.bookingsRepository.update(bookingId, {
|
||||
status: 'COMPLETED',
|
||||
status: "COMPLETED",
|
||||
endDate: new Date(),
|
||||
} as never);
|
||||
return this.bookingsService.findById(updated!.id);
|
||||
@@ -451,22 +452,23 @@ export class BookingTransitionService {
|
||||
async cancel(bookingId: string, reason: string): Promise<Booking> {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
assertBookingStatus(booking, [
|
||||
'DRAFT',
|
||||
'SUBMITTED',
|
||||
'PRICE_CHANGED_PENDING_CONFIRM',
|
||||
'CHANGES_REQUESTED',
|
||||
'PENDING_APPROVAL',
|
||||
'CONTRACT_READY',
|
||||
"DRAFT",
|
||||
"SUBMITTED",
|
||||
"PRICE_CHANGED_PENDING_CONFIRM",
|
||||
"CHANGES_REQUESTED",
|
||||
"PENDING_APPROVAL",
|
||||
"CONTRACT_READY",
|
||||
"OPERATION_REQUEST_PENDING",
|
||||
]);
|
||||
|
||||
await this.bookingsRepository.createReviewNote(
|
||||
bookingId,
|
||||
reason,
|
||||
'REJECTION',
|
||||
"REJECTION",
|
||||
);
|
||||
|
||||
const updated = await this.bookingsRepository.update(bookingId, {
|
||||
status: 'CANCELLED',
|
||||
status: "CANCELLED",
|
||||
} as never);
|
||||
return this.bookingsService.findById(updated!.id);
|
||||
}
|
||||
@@ -479,20 +481,20 @@ export class BookingTransitionService {
|
||||
async reject(bookingId: string, reason?: string): Promise<Booking> {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
assertBookingStatus(booking, [
|
||||
'DRAFT',
|
||||
'SUBMITTED',
|
||||
'PRICE_CHANGED_PENDING_CONFIRM',
|
||||
'PENDING_CONSOLIDATION',
|
||||
"DRAFT",
|
||||
"SUBMITTED",
|
||||
"PRICE_CHANGED_PENDING_CONFIRM",
|
||||
"PENDING_CONSOLIDATION",
|
||||
]);
|
||||
|
||||
await this.bookingsRepository.createReviewNote(
|
||||
bookingId,
|
||||
reason?.trim() || 'Customer rejected the price estimate.',
|
||||
'REJECTION',
|
||||
reason?.trim() || "Customer rejected the price estimate.",
|
||||
"REJECTION",
|
||||
);
|
||||
|
||||
const updated = await this.bookingsRepository.update(bookingId, {
|
||||
status: 'REJECTED',
|
||||
status: "REJECTED",
|
||||
} as never);
|
||||
return this.bookingsService.findById(updated!.id);
|
||||
}
|
||||
@@ -513,32 +515,44 @@ export class BookingTransitionService {
|
||||
fileKey: string;
|
||||
label: string;
|
||||
required: boolean;
|
||||
uploadedBy: 'customer' | 'gl';
|
||||
uploadedBy: "customer" | "gl";
|
||||
settingCode: string;
|
||||
file: { id: string; name: string; url: string } | null;
|
||||
reviewStatus: 'PENDING' | 'APPROVED' | 'QUERIED' | null;
|
||||
reviewStatus: "PENDING" | "APPROVED" | "QUERIED" | null;
|
||||
note: string | null;
|
||||
}>;
|
||||
allApproved: boolean;
|
||||
phase?: string | null;
|
||||
milestones?: unknown[];
|
||||
nextAction?: unknown;
|
||||
dutyRequired?: boolean | null;
|
||||
roHold?: boolean;
|
||||
roHoldReason?: string | null;
|
||||
vesselDepartureDate?: string | null;
|
||||
operationReady?: boolean;
|
||||
}> {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
if (this.isPhasedGeneralCustoms(booking)) {
|
||||
return this.bookingClearanceService.getClearanceView(bookingId);
|
||||
}
|
||||
const { inputCode, outputCode, includesCustoms } =
|
||||
clearanceCodesForBooking(booking);
|
||||
|
||||
const files = await this.filesService.findByResource(bookingId, 'bookings');
|
||||
const files = await this.filesService.findByResource(bookingId, "bookings");
|
||||
const fileByCode = new Map(files.map((f) => [f.code, f]));
|
||||
const reviews = await this.bookingsRepository.findDocumentReviews(bookingId);
|
||||
const reviews =
|
||||
await this.bookingsRepository.findDocumentReviews(bookingId);
|
||||
const reviewByKey = new Map(
|
||||
reviews.map((r) => [`${r.settingCode}:${r.fileKey}`, r]),
|
||||
);
|
||||
|
||||
const documents: Awaited<
|
||||
ReturnType<BookingTransitionService['getClearanceView']>
|
||||
>['documents'] = [];
|
||||
ReturnType<BookingTransitionService["getClearanceView"]>
|
||||
>["documents"] = [];
|
||||
|
||||
const pushSetting = async (
|
||||
code: string | null,
|
||||
uploadedBy: 'customer' | 'gl',
|
||||
uploadedBy: "customer" | "gl",
|
||||
) => {
|
||||
if (!code) return;
|
||||
let setting;
|
||||
@@ -556,28 +570,26 @@ export class BookingTransitionService {
|
||||
required: field.isRequired,
|
||||
uploadedBy,
|
||||
settingCode: code,
|
||||
file: file
|
||||
? { id: file.id, name: file.name, url: file.url }
|
||||
: null,
|
||||
file: file ? { id: file.id, name: file.name, url: file.url } : null,
|
||||
reviewStatus: review?.status ?? null,
|
||||
note: review?.note ?? null,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
await pushSetting(inputCode, 'customer');
|
||||
await pushSetting(outputCode, 'gl');
|
||||
await pushSetting(inputCode, "customer");
|
||||
await pushSetting(outputCode, "gl");
|
||||
|
||||
// Ad-hoc / unknown documents (code custom_*) appear alongside the seeded set.
|
||||
for (const f of files) {
|
||||
if (!f.code?.startsWith('custom_')) continue;
|
||||
if (!f.code?.startsWith("custom_")) continue;
|
||||
const review = reviewByKey.get(`custom:${f.code}`) ?? null;
|
||||
documents.push({
|
||||
fileKey: f.code,
|
||||
label: f.name,
|
||||
required: false,
|
||||
uploadedBy: 'customer',
|
||||
settingCode: 'custom',
|
||||
uploadedBy: "customer",
|
||||
settingCode: "custom",
|
||||
file: { id: f.id, name: f.name, url: f.url },
|
||||
reviewStatus: review?.status ?? null,
|
||||
note: review?.note ?? null,
|
||||
@@ -611,13 +623,15 @@ export class BookingTransitionService {
|
||||
}
|
||||
const required = (setting.fields ?? []).filter((f) => f.isRequired);
|
||||
if (required.length === 0) return true;
|
||||
const reviews = await this.bookingsRepository.findDocumentReviews(booking.id);
|
||||
const reviews = await this.bookingsRepository.findDocumentReviews(
|
||||
booking.id,
|
||||
);
|
||||
return required.every((field) =>
|
||||
reviews.some(
|
||||
(r) =>
|
||||
r.settingCode === inputCode &&
|
||||
r.fileKey === field.fileKey &&
|
||||
r.status === 'APPROVED',
|
||||
r.status === "APPROVED",
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -632,33 +646,38 @@ export class BookingTransitionService {
|
||||
files: Express.Multer.File[],
|
||||
): Promise<Booking> {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
assertBookingStatus(booking, ['AWAITING_DOCUMENTS', 'DOCUMENTS_UNDER_REVIEW']);
|
||||
assertBookingStatus(booking, [
|
||||
"AWAITING_DOCUMENTS",
|
||||
"DOCUMENTS_UNDER_REVIEW",
|
||||
]);
|
||||
const { inputCode } = clearanceCodesForBooking(booking);
|
||||
if (!inputCode) {
|
||||
throw new BadRequestException('This booking has no document-clearance step');
|
||||
throw new BadRequestException(
|
||||
"This booking has no document-clearance step",
|
||||
);
|
||||
}
|
||||
if (files.length === 0) {
|
||||
throw new BadRequestException('No documents uploaded');
|
||||
throw new BadRequestException("No documents uploaded");
|
||||
}
|
||||
|
||||
// First submission (nothing in review yet): every required input field must
|
||||
// be provided. Once review has started (DOCUMENTS_UNDER_REVIEW) the customer
|
||||
// is only fixing queried/pending docs, so the already-uploaded required docs
|
||||
// stay in place and we don't re-gate on the full required set.
|
||||
if (booking.status === 'AWAITING_DOCUMENTS') {
|
||||
if (booking.status === "AWAITING_DOCUMENTS") {
|
||||
await this.assertRequiredInputsPresent(bookingId, inputCode, files);
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
const record = await this.filesService.upsertByCode({
|
||||
resourceId: bookingId,
|
||||
resource: 'bookings',
|
||||
resource: "bookings",
|
||||
code: file.fieldname,
|
||||
file,
|
||||
});
|
||||
// Ad-hoc docs (custom_*) are not part of the required gate; still tracked.
|
||||
const settingCode = file.fieldname.startsWith('custom_')
|
||||
? 'custom'
|
||||
const settingCode = file.fieldname.startsWith("custom_")
|
||||
? "custom"
|
||||
: inputCode;
|
||||
await this.bookingsRepository.upsertDocumentReviewPending({
|
||||
bookingId,
|
||||
@@ -669,8 +688,20 @@ export class BookingTransitionService {
|
||||
}
|
||||
|
||||
await this.bookingsRepository.update(bookingId, {
|
||||
status: 'DOCUMENTS_UNDER_REVIEW',
|
||||
status: "DOCUMENTS_UNDER_REVIEW",
|
||||
} as never);
|
||||
|
||||
if (this.isPhasedGeneralCustoms(booking)) {
|
||||
await this.workflowService.onCustomerDocsUploadedForBooking(
|
||||
bookingId,
|
||||
booking.tradeDirection ?? 'IMPORT',
|
||||
);
|
||||
await this.workflowService.onDocumentReviewReopenedForBooking(bookingId);
|
||||
await this.bookingsRepository.update(bookingId, {
|
||||
clearanceCurrentPhase: ContractDocPhase.GlEtReview,
|
||||
} as never);
|
||||
}
|
||||
|
||||
return this.bookingsService.findById(bookingId);
|
||||
}
|
||||
|
||||
@@ -694,7 +725,10 @@ export class BookingTransitionService {
|
||||
const required = (setting.fields ?? []).filter((f) => f.isRequired);
|
||||
if (required.length === 0) return;
|
||||
|
||||
const existing = await this.filesService.findByResource(bookingId, 'bookings');
|
||||
const existing = await this.filesService.findByResource(
|
||||
bookingId,
|
||||
"bookings",
|
||||
);
|
||||
const presentKeys = new Set<string>([
|
||||
...existing.map((f) => f.code),
|
||||
...files.map((f) => f.fieldname),
|
||||
@@ -702,7 +736,7 @@ export class BookingTransitionService {
|
||||
|
||||
const missing = required.filter((f) => !presentKeys.has(f.fileKey));
|
||||
if (missing.length > 0) {
|
||||
const labels = missing.map((f) => f.fileLabel).join(', ');
|
||||
const labels = missing.map((f) => f.fileLabel).join(", ");
|
||||
throw new BadRequestException(
|
||||
`Please upload all required documents before submitting: ${labels}`,
|
||||
);
|
||||
@@ -713,22 +747,36 @@ export class BookingTransitionService {
|
||||
async reviewDocument(
|
||||
bookingId: string,
|
||||
fileKey: string,
|
||||
status: 'APPROVED' | 'QUERIED',
|
||||
status: "APPROVED" | "QUERIED",
|
||||
staffId: string,
|
||||
note?: string,
|
||||
): Promise<Booking> {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
assertBookingStatus(booking, ['DOCUMENTS_UNDER_REVIEW']);
|
||||
assertBookingStatus(booking, ["DOCUMENTS_UNDER_REVIEW"]);
|
||||
const { inputCode, outputCode } = clearanceCodesForBooking(booking);
|
||||
|
||||
const existing = await this.bookingsRepository.findDocumentReviews(bookingId);
|
||||
const existing =
|
||||
await this.bookingsRepository.findDocumentReviews(bookingId);
|
||||
const match = existing.find((r) => r.fileKey === fileKey);
|
||||
const settingCode =
|
||||
match?.settingCode ??
|
||||
(fileKey.startsWith('custom_') ? 'custom' : (inputCode ?? outputCode ?? 'custom'));
|
||||
(fileKey.startsWith("custom_")
|
||||
? "custom"
|
||||
: (inputCode ?? outputCode ?? "custom"));
|
||||
|
||||
if (status === 'QUERIED' && !note?.trim()) {
|
||||
throw new BadRequestException('A note is required when querying a document');
|
||||
if (status === "QUERIED" && !note?.trim()) {
|
||||
throw new BadRequestException(
|
||||
"A note is required when querying a document",
|
||||
);
|
||||
}
|
||||
if (
|
||||
status === 'QUERIED' &&
|
||||
this.isPhasedGeneralCustoms(booking) &&
|
||||
booking.preClearanceFinalizedAt
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
'Customer documents cannot be queried after pre-clearance is finalized.',
|
||||
);
|
||||
}
|
||||
|
||||
await this.bookingsRepository.setDocumentReviewStatus(
|
||||
@@ -739,15 +787,37 @@ export class BookingTransitionService {
|
||||
staffId,
|
||||
note,
|
||||
);
|
||||
if (status === 'QUERIED') {
|
||||
if (status === "QUERIED") {
|
||||
await this.bookingsRepository.createReviewNote(
|
||||
bookingId,
|
||||
`Document "${fileKey}" queried: ${note}`,
|
||||
'CHANGES_REQUESTED',
|
||||
"CHANGES_REQUESTED",
|
||||
staffId,
|
||||
);
|
||||
if (this.isPhasedGeneralCustoms(booking)) {
|
||||
await this.workflowService.onDocumentReviewReopenedForBooking(bookingId);
|
||||
await this.bookingsRepository.update(bookingId, {
|
||||
clearanceCurrentPhase: ContractDocPhase.GlEtReview,
|
||||
} as never);
|
||||
}
|
||||
}
|
||||
return this.bookingsService.findById(bookingId);
|
||||
|
||||
const updated = await this.bookingsService.findById(bookingId);
|
||||
if (this.isPhasedGeneralCustoms(updated)) {
|
||||
const allApproved = await this.isClearanceFullyApproved(updated);
|
||||
if (allApproved) {
|
||||
await this.workflowService.onAllDocsApprovedForBooking(bookingId);
|
||||
const phase =
|
||||
updated.tradeDirection === 'EXPORT'
|
||||
? ContractDocPhase.GlDjCollection
|
||||
: ContractDocPhase.GlEtOutput;
|
||||
await this.bookingsRepository.update(bookingId, {
|
||||
clearanceCurrentPhase: phase,
|
||||
} as never);
|
||||
}
|
||||
}
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
/** GL uploads the customs output documents (IM4/IM5/EX3/etc.). */
|
||||
@@ -756,18 +826,20 @@ export class BookingTransitionService {
|
||||
files: Express.Multer.File[],
|
||||
): Promise<Booking> {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
assertBookingStatus(booking, ['DOCUMENTS_UNDER_REVIEW']);
|
||||
assertBookingStatus(booking, ["DOCUMENTS_UNDER_REVIEW"]);
|
||||
const { outputCode } = clearanceCodesForBooking(booking);
|
||||
if (!outputCode) {
|
||||
throw new BadRequestException('This booking has no customs output documents');
|
||||
throw new BadRequestException(
|
||||
"This booking has no customs output documents",
|
||||
);
|
||||
}
|
||||
if (files.length === 0) {
|
||||
throw new BadRequestException('No documents uploaded');
|
||||
throw new BadRequestException("No documents uploaded");
|
||||
}
|
||||
for (const file of files) {
|
||||
await this.filesService.upsertByCode({
|
||||
resourceId: bookingId,
|
||||
resource: 'bookings',
|
||||
resource: "bookings",
|
||||
code: file.fieldname,
|
||||
file,
|
||||
});
|
||||
@@ -781,19 +853,28 @@ export class BookingTransitionService {
|
||||
*/
|
||||
async finalizeClearance(bookingId: string): Promise<Booking> {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
if (this.isPhasedGeneralCustoms(booking)) {
|
||||
throw new BadRequestException(
|
||||
'General customs bookings use phased clearance — complete milestones via the phased actions instead of finalize.',
|
||||
);
|
||||
}
|
||||
assertBookingStatus(booking, ['DOCUMENTS_UNDER_REVIEW']);
|
||||
|
||||
const approved = await this.isClearanceFullyApproved(booking);
|
||||
if (!approved) {
|
||||
throw new BadRequestException(
|
||||
'All required documents must be approved before clearance can be finalized',
|
||||
"All required documents must be approved before clearance can be finalized",
|
||||
);
|
||||
}
|
||||
|
||||
const { outputCode } = clearanceCodesForBooking(booking);
|
||||
if (outputCode) {
|
||||
const setting = await this.fileUploadSettingsService.getByCode(outputCode);
|
||||
const files = await this.filesService.findByResource(bookingId, 'bookings');
|
||||
const setting =
|
||||
await this.fileUploadSettingsService.getByCode(outputCode);
|
||||
const files = await this.filesService.findByResource(
|
||||
bookingId,
|
||||
"bookings",
|
||||
);
|
||||
const uploaded = new Set(files.map((f) => f.code));
|
||||
const missing = (setting.fields ?? []).filter(
|
||||
(f) => f.isRequired && !uploaded.has(f.fileKey),
|
||||
@@ -802,13 +883,13 @@ export class BookingTransitionService {
|
||||
throw new BadRequestException(
|
||||
`Upload all required customs output documents first: ${missing
|
||||
.map((m) => m.fileLabel)
|
||||
.join(', ')}`,
|
||||
.join(", ")}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await this.bookingsRepository.update(bookingId, {
|
||||
status: 'CLEARANCE_READY',
|
||||
status: "CLEARANCE_READY",
|
||||
} as never);
|
||||
return this.bookingsService.findById(bookingId);
|
||||
}
|
||||
@@ -827,11 +908,14 @@ export class BookingTransitionService {
|
||||
scheduledDate: string,
|
||||
): Promise<Booking> {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
assertBookingStatus(booking, ['CLEARANCE_READY', 'OPERATION_CHANGES_REQUESTED']);
|
||||
assertBookingStatus(booking, [
|
||||
"CLEARANCE_READY",
|
||||
"OPERATION_CHANGES_REQUESTED",
|
||||
]);
|
||||
|
||||
const date = new Date(scheduledDate);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
throw new BadRequestException('A valid schedule date is required');
|
||||
throw new BadRequestException("A valid schedule date is required");
|
||||
}
|
||||
|
||||
// The binding shipment day must have at least one OPEN departure on the
|
||||
@@ -844,12 +928,12 @@ export class BookingTransitionService {
|
||||
);
|
||||
if (!hasDeparture) {
|
||||
throw new BadRequestException(
|
||||
'No departures available on the selected day for this route',
|
||||
"No departures available on the selected day for this route",
|
||||
);
|
||||
}
|
||||
|
||||
await this.bookingsRepository.update(bookingId, {
|
||||
status: 'OPERATION_REQUEST_PENDING',
|
||||
status: "OPERATION_REQUEST_PENDING",
|
||||
scheduledDate: date,
|
||||
} as never);
|
||||
return this.bookingsService.findById(bookingId);
|
||||
@@ -865,27 +949,27 @@ export class BookingTransitionService {
|
||||
*/
|
||||
async reviewOperationRequest(
|
||||
bookingId: string,
|
||||
decision: 'ACCEPT' | 'REQUEST_CHANGES',
|
||||
decision: "ACCEPT" | "REQUEST_CHANGES",
|
||||
actorId: string,
|
||||
options: { note?: string } = {},
|
||||
): Promise<Booking> {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
assertBookingStatus(booking, ['OPERATION_REQUEST_PENDING']);
|
||||
assertBookingStatus(booking, ["OPERATION_REQUEST_PENDING"]);
|
||||
|
||||
if (decision === 'REQUEST_CHANGES') {
|
||||
if (decision === "REQUEST_CHANGES") {
|
||||
if (!options.note?.trim()) {
|
||||
throw new BadRequestException(
|
||||
'A note is required when requesting changes',
|
||||
"A note is required when requesting changes",
|
||||
);
|
||||
}
|
||||
await this.bookingsRepository.createReviewNote(
|
||||
bookingId,
|
||||
options.note,
|
||||
'CHANGES_REQUESTED',
|
||||
"CHANGES_REQUESTED",
|
||||
actorId,
|
||||
);
|
||||
await this.bookingsRepository.update(bookingId, {
|
||||
status: 'OPERATION_CHANGES_REQUESTED',
|
||||
status: "OPERATION_CHANGES_REQUESTED",
|
||||
} as never);
|
||||
return this.bookingsService.findById(bookingId);
|
||||
}
|
||||
@@ -907,9 +991,17 @@ export class BookingTransitionService {
|
||||
private async acceptOperationRequest(booking: Booking): Promise<Booking> {
|
||||
const now = new Date();
|
||||
|
||||
const invoice = await this.invoiceService.ensureInvoiceForBooking(booking);
|
||||
this.logger.log(
|
||||
`Generated invoice ${invoice.invoiceNumber} (${invoice.id}) for ${booking.reference}:${booking.id}`,
|
||||
);
|
||||
await this.invoiceService.updateStatus(
|
||||
invoice.id,
|
||||
Freight.InvoiceStatus.Pending,
|
||||
);
|
||||
if (isRoadService(booking.serviceType)) {
|
||||
await this.bookingsRepository.update(booking.id, {
|
||||
status: 'ROAD_DISPATCH_PENDING',
|
||||
status: "ROAD_DISPATCH_PENDING",
|
||||
fullyExecutedAt: now,
|
||||
lockedAt: booking.lockedAt ?? now,
|
||||
} as never);
|
||||
@@ -917,7 +1009,7 @@ export class BookingTransitionService {
|
||||
}
|
||||
|
||||
await this.bookingsRepository.update(booking.id, {
|
||||
status: 'FULLY_EXECUTED',
|
||||
status: "FULLY_EXECUTED",
|
||||
fullyExecutedAt: now,
|
||||
lockedAt: booking.lockedAt ?? now,
|
||||
} as never);
|
||||
@@ -932,21 +1024,23 @@ export class BookingTransitionService {
|
||||
return this.bookingsService.findById(booking.id);
|
||||
}
|
||||
|
||||
async enrichBookingResponse(booking: Booking): Promise<Booking & {
|
||||
latestChangeRequestNote?: string | null;
|
||||
contractSummary?: string | null;
|
||||
nextStep: BookingNextStep | null;
|
||||
}> {
|
||||
async enrichBookingResponse(booking: Booking): Promise<
|
||||
Booking & {
|
||||
latestChangeRequestNote?: string | null;
|
||||
contractSummary?: string | null;
|
||||
nextStep: BookingNextStep | null;
|
||||
}
|
||||
> {
|
||||
const note = await this.bookingsRepository.findLatestReviewNote(
|
||||
booking.id,
|
||||
'CHANGES_REQUESTED',
|
||||
"CHANGES_REQUESTED",
|
||||
);
|
||||
const summary =
|
||||
booking.contractSummary ??
|
||||
this.contractService.buildContractSummary(booking);
|
||||
const nextPending =
|
||||
booking.status === 'PENDING_APPROVAL' ||
|
||||
booking.status === 'APPROVED_PENDING_SIGNATURE'
|
||||
booking.status === "PENDING_APPROVAL" ||
|
||||
booking.status === "APPROVED_PENDING_SIGNATURE"
|
||||
? await this.bookingsRepository.findNextPendingApprovalStep(booking.id)
|
||||
: null;
|
||||
const nextStep = computeNextStep(booking, nextPending);
|
||||
@@ -957,4 +1051,4 @@ export class BookingTransitionService {
|
||||
nextStep,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
Request,
|
||||
Res,
|
||||
UnauthorizedException,
|
||||
UploadedFile,
|
||||
UploadedFiles,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
@@ -19,7 +20,7 @@ import { CurrentUser } from '@edr/api-common';
|
||||
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
||||
import { BookingStaff } from '../../common/booking-guards';
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
import { AnyFilesInterceptor } from '@nestjs/platform-express';
|
||||
import { AnyFilesInterceptor, FileInterceptor } from '@nestjs/platform-express';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiBody,
|
||||
@@ -27,12 +28,17 @@ import {
|
||||
ApiOkResponse,
|
||||
ApiOperation,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
import type { Response } from 'express';
|
||||
} from "@nestjs/swagger";
|
||||
import type { Response } from "express";
|
||||
|
||||
import { BookingContractService } from './booking-contract.service';
|
||||
import { BookingPricingService } from './booking-pricing.service';
|
||||
import { BookingTransitionService } from './booking-transition.service';
|
||||
import { BookingClearanceService } from '../contracts/booking-clearance.service';
|
||||
import {
|
||||
AdviseContractDutyDto,
|
||||
RoAmendmentDto,
|
||||
} from '../contracts/dto/phased-clearance.dto';
|
||||
import { BookingReferenceDataService } from './booking-reference-data.service';
|
||||
import { BookingsService } from './bookings.service';
|
||||
import { BookingReferenceDataDto } from './dto/booking-reference-data.dto';
|
||||
@@ -60,11 +66,14 @@ import { UpdateBookingDto } from './dto/update-booking.dto';
|
||||
import {
|
||||
type AuthUserPayload,
|
||||
resolveAuthUserId,
|
||||
} from '../../common/resolve-auth-user-id';
|
||||
import { assertFreightPermission, hasFreightPermission } from '../../common/freight-permission.util';
|
||||
} from "../../common/resolve-auth-user-id";
|
||||
import {
|
||||
assertFreightPermission,
|
||||
hasFreightPermission,
|
||||
} from "../../common/freight-permission.util";
|
||||
|
||||
@ApiTags('bookings')
|
||||
@Controller('bookings')
|
||||
@ApiTags("bookings")
|
||||
@Controller("bookings")
|
||||
@ApiBearerAuth()
|
||||
export class BookingsController {
|
||||
constructor(
|
||||
@@ -73,12 +82,13 @@ export class BookingsController {
|
||||
private readonly pricingService: BookingPricingService,
|
||||
private readonly transitionService: BookingTransitionService,
|
||||
private readonly contractService: BookingContractService,
|
||||
private readonly bookingClearanceService: BookingClearanceService,
|
||||
) {}
|
||||
|
||||
@Post()
|
||||
@UseInterceptors(AnyFilesInterceptor())
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiOperation({ summary: 'Create a new freight booking (DRAFT)' })
|
||||
@ApiConsumes("multipart/form-data")
|
||||
@ApiOperation({ summary: "Create a new freight booking (DRAFT)" })
|
||||
@ApiBody({ type: CreateBookingDto })
|
||||
async create(
|
||||
@Body() dto: CreateBookingDto,
|
||||
@@ -88,15 +98,24 @@ export class BookingsController {
|
||||
if (dto.isGovernment) {
|
||||
assertFreightPermission(user, FREIGHT_PERMS.bookings.staffAccept);
|
||||
}
|
||||
const result = await this.bookingsService.create(dto, files ?? [], user?.id);
|
||||
const result = await this.bookingsService.create(
|
||||
dto,
|
||||
files ?? [],
|
||||
user?.id,
|
||||
);
|
||||
|
||||
// Staff-created commercial bookings skip the draft stage: auto generate-price + submit.
|
||||
const isStaff = hasFreightPermission(user, FREIGHT_PERMS.bookings.staffAccept);
|
||||
const isStaff = hasFreightPermission(
|
||||
user,
|
||||
FREIGHT_PERMS.bookings.staffAccept,
|
||||
);
|
||||
if (isStaff && !dto.isGovernment) {
|
||||
try {
|
||||
await this.pricingService.generatePrice(result.booking.id);
|
||||
await this.transitionService.submit(result.booking.id);
|
||||
const submitted = await this.bookingsService.findById(result.booking.id);
|
||||
const submitted = await this.bookingsService.findById(
|
||||
result.booking.id,
|
||||
);
|
||||
return { booking: submitted, warnings: result.warnings };
|
||||
} catch {
|
||||
// If auto-pricing/submit fails, fall back to the DRAFT so staff can finish manually.
|
||||
@@ -106,16 +125,16 @@ export class BookingsController {
|
||||
return result;
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@Patch(":id")
|
||||
@UseInterceptors(AnyFilesInterceptor())
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiConsumes("multipart/form-data")
|
||||
@ApiOperation({
|
||||
summary: 'Update booking',
|
||||
description: 'Allowed when status is DRAFT or CHANGES_REQUESTED.',
|
||||
summary: "Update booking",
|
||||
description: "Allowed when status is DRAFT or CHANGES_REQUESTED.",
|
||||
})
|
||||
@ApiBody({ type: UpdateBookingDto })
|
||||
update(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() dto: UpdateBookingDto,
|
||||
@UploadedFiles() files: Express.Multer.File[],
|
||||
) {
|
||||
@@ -123,7 +142,7 @@ export class BookingsController {
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List freight bookings (paginated)' })
|
||||
@ApiOperation({ summary: "List freight bookings (paginated)" })
|
||||
async findAll(
|
||||
@Query() filter: FilterBookingDto,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
@@ -140,7 +159,7 @@ export class BookingsController {
|
||||
return this.bookingsService.findClearanceQueue(filter);
|
||||
}
|
||||
const userId = user?.id;
|
||||
if (!userId) throw new UnauthorizedException('Authentication required');
|
||||
if (!userId) throw new UnauthorizedException("Authentication required");
|
||||
const companyId =
|
||||
await this.bookingsService.resolveCustomerCompanyId(userId);
|
||||
// No linked company yet → no bookings to show (avoids leaking all bookings).
|
||||
@@ -166,27 +185,29 @@ export class BookingsController {
|
||||
return this.bookingsService.findAll(filter, companyId);
|
||||
}
|
||||
|
||||
@Get('by-company/:companyId/customer-view')
|
||||
@ApiOperation({ summary: 'List bookings for a company (customer-view shape, backoffice)' })
|
||||
@Get("by-company/:companyId/customer-view")
|
||||
@ApiOperation({
|
||||
summary: "List bookings for a company (customer-view shape, backoffice)",
|
||||
})
|
||||
findByCompanyCustomerView(
|
||||
@Param('companyId', ParseUUIDPipe) companyId: string,
|
||||
@Param("companyId", ParseUUIDPipe) companyId: string,
|
||||
) {
|
||||
return this.bookingsService.findCustomerBookings(companyId);
|
||||
}
|
||||
|
||||
@Get('list-summary')
|
||||
@ApiOperation({ summary: 'Booking list metrics and tab counts (backoffice)' })
|
||||
@Get("list-summary")
|
||||
@ApiOperation({ summary: "Booking list metrics and tab counts (backoffice)" })
|
||||
@ApiOkResponse({ type: BookingListSummaryDto })
|
||||
findListSummary(@Query() filter: FilterBookingDto) {
|
||||
return this.bookingsService.getListSummary(filter);
|
||||
}
|
||||
|
||||
@Get('my')
|
||||
@Get("my")
|
||||
@ApiOperation({
|
||||
summary: "List the current customer's bookings ready for payment",
|
||||
description:
|
||||
'Bookings owned by the authenticated user\'s company that are payable ' +
|
||||
'(FULLY_EXECUTED, SELECTED_FOR_BATCH, AWAITING_PAYMENT) and not yet PAID.',
|
||||
"Bookings owned by the authenticated user's company that are payable " +
|
||||
"(FULLY_EXECUTED, SELECTED_FOR_BATCH, AWAITING_PAYMENT) and not yet PAID.",
|
||||
})
|
||||
findMyPayable(
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
@@ -195,32 +216,32 @@ export class BookingsController {
|
||||
return this.bookingsService.findMyPayable(resolveAuthUserId(user), filter);
|
||||
}
|
||||
|
||||
@Get('queues/:queue')
|
||||
@Get("queues/:queue")
|
||||
@ApiOperation({
|
||||
summary: 'List bookings for a dashboard queue',
|
||||
description: 'Queues: intake, approval, signatures, marketing, finance',
|
||||
summary: "List bookings for a dashboard queue",
|
||||
description: "Queues: intake, approval, signatures, marketing, finance",
|
||||
})
|
||||
findQueue(
|
||||
@Param('queue') queue: string,
|
||||
@Param("queue") queue: string,
|
||||
@Query() filter: FilterBookingDto,
|
||||
@Query('excludeBulk') excludeBulk?: string,
|
||||
@Query("excludeBulk") excludeBulk?: string,
|
||||
) {
|
||||
return this.bookingsService.findQueue(queue, filter, {
|
||||
excludeBulk: excludeBulk === 'true',
|
||||
excludeBulk: excludeBulk === "true",
|
||||
});
|
||||
}
|
||||
|
||||
@Get('reference-data')
|
||||
@ApiOperation({ summary: 'Booking form catalog' })
|
||||
@Get("reference-data")
|
||||
@ApiOperation({ summary: "Booking form catalog" })
|
||||
@ApiOkResponse({ type: BookingReferenceDataDto })
|
||||
getReferenceData(): Promise<BookingReferenceDataDto> {
|
||||
return this.bookingReferenceDataService.getReferenceData();
|
||||
}
|
||||
|
||||
@Get('by-reference/:reference')
|
||||
@ApiOperation({ summary: 'Get booking by reference' })
|
||||
@Get("by-reference/:reference")
|
||||
@ApiOperation({ summary: "Get booking by reference" })
|
||||
async findByReference(
|
||||
@Param('reference') reference: string,
|
||||
@Param("reference") reference: string,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
const booking = await this.bookingsService.findByReference(reference);
|
||||
@@ -234,10 +255,10 @@ export class BookingsController {
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get booking by ID' })
|
||||
@Get(":id")
|
||||
@ApiOperation({ summary: "Get booking by ID" })
|
||||
async findOne(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
const booking = await this.bookingsService.findById(id);
|
||||
@@ -290,13 +311,13 @@ export class BookingsController {
|
||||
|
||||
@Get(':id/tracking')
|
||||
@ApiOperation({
|
||||
summary: 'Shipment tracking timeline for a booking',
|
||||
summary: "Shipment tracking timeline for a booking",
|
||||
description:
|
||||
"Returns the booking's consignment (once dispatched) and its ordered " +
|
||||
'tracking events. Scoped to the customer\'s own company.',
|
||||
"tracking events. Scoped to the customer's own company.",
|
||||
})
|
||||
async findTracking(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
const booking = await this.bookingsService.findById(id);
|
||||
@@ -310,66 +331,66 @@ export class BookingsController {
|
||||
return this.bookingsService.getBookingTracking(id);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@Delete(":id")
|
||||
@HttpCode(204)
|
||||
@ApiOperation({ summary: 'Soft-delete DRAFT booking' })
|
||||
remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||
@ApiOperation({ summary: "Soft-delete DRAFT booking" })
|
||||
remove(@Param("id", ParseUUIDPipe) id: string) {
|
||||
return this.bookingsService.remove(id);
|
||||
}
|
||||
|
||||
@Post(':id/documents')
|
||||
@Post(":id/documents")
|
||||
@UseInterceptors(AnyFilesInterceptor())
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiOperation({ summary: 'Upload documents for a booking (DRAFT only)' })
|
||||
@ApiConsumes("multipart/form-data")
|
||||
@ApiOperation({ summary: "Upload documents for a booking (DRAFT only)" })
|
||||
async uploadDocuments(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@UploadedFiles() files: Express.Multer.File[],
|
||||
) {
|
||||
const booking = await this.bookingsService.uploadDocuments(id, files ?? []);
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(':id/generate-price')
|
||||
@Post(":id/generate-price")
|
||||
@ApiOperation({
|
||||
summary: 'Generate price preview (DRAFT or CHANGES_REQUESTED)',
|
||||
summary: "Generate price preview (DRAFT or CHANGES_REQUESTED)",
|
||||
description:
|
||||
'Computes and stores a price preview on the booking. Does not create rate snapshots.',
|
||||
"Computes and stores a price preview on the booking. Does not create rate snapshots.",
|
||||
})
|
||||
@ApiOkResponse({ type: GeneratePriceResponseDto })
|
||||
generatePrice(@Param('id', ParseUUIDPipe) id: string) {
|
||||
generatePrice(@Param("id", ParseUUIDPipe) id: string) {
|
||||
return this.pricingService.generatePrice(id);
|
||||
}
|
||||
|
||||
@Post(':id/submit')
|
||||
@Post(":id/submit")
|
||||
@ApiOperation({
|
||||
summary: 'Customer submit booking',
|
||||
summary: "Customer submit booking",
|
||||
description:
|
||||
'Recomputes price against live rates. If unchanged, creates rate snapshots and submits. If changed, updates the booking price and returns priceChanged=true for confirmation.',
|
||||
"Recomputes price against live rates. If unchanged, creates rate snapshots and submits. If changed, updates the booking price and returns priceChanged=true for confirmation.",
|
||||
})
|
||||
@ApiOkResponse({ type: SubmitBookingResponseDto })
|
||||
submit(@Param('id', ParseUUIDPipe) id: string) {
|
||||
submit(@Param("id", ParseUUIDPipe) id: string) {
|
||||
return this.transitionService.submit(id);
|
||||
}
|
||||
|
||||
@Post(':id/confirm-submit')
|
||||
@Post(":id/confirm-submit")
|
||||
@ApiOperation({
|
||||
summary: 'Confirm submit after price change',
|
||||
summary: "Confirm submit after price change",
|
||||
description:
|
||||
'Creates rate snapshots for the updated booking price and moves the booking to SUBMITTED.',
|
||||
"Creates rate snapshots for the updated booking price and moves the booking to SUBMITTED.",
|
||||
})
|
||||
@ApiOkResponse({ type: SubmitBookingResponseDto })
|
||||
confirmSubmit(@Param('id', ParseUUIDPipe) id: string) {
|
||||
confirmSubmit(@Param("id", ParseUUIDPipe) id: string) {
|
||||
return this.transitionService.confirmSubmit(id);
|
||||
}
|
||||
|
||||
@Post(':id/reject')
|
||||
@Post(":id/reject")
|
||||
@ApiOperation({
|
||||
summary: 'Customer reject price estimate',
|
||||
summary: "Customer reject price estimate",
|
||||
description:
|
||||
'Customer rejects the priced booking at the confirm step. The booking becomes REJECTED (terminal); the customer must create a new booking.',
|
||||
"Customer rejects the priced booking at the confirm step. The booking becomes REJECTED (terminal); the customer must create a new booking.",
|
||||
})
|
||||
async reject(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() dto: RejectBookingDto,
|
||||
) {
|
||||
const booking = await this.transitionService.reject(id, dto.reason);
|
||||
@@ -378,22 +399,37 @@ export class BookingsController {
|
||||
|
||||
// ── Document clearance (post counter-sign) ────────────────────────────────
|
||||
|
||||
@Get('clearance/et-queue')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
|
||||
@ApiOperation({ summary: 'GL ET queue — general customs bookings awaiting ET action' })
|
||||
getBookingEtClearanceQueue() {
|
||||
return this.bookingClearanceService.etQueue();
|
||||
}
|
||||
|
||||
@Get('clearance/dj-queue')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
|
||||
@ApiOperation({ summary: 'GL DJ queue — general customs bookings awaiting DJ action' })
|
||||
getBookingDjClearanceQueue() {
|
||||
return this.bookingClearanceService.djQueue();
|
||||
}
|
||||
|
||||
@Get(':id/clearance')
|
||||
@ApiOperation({
|
||||
summary: 'Document-clearance grid (required docs + upload + GL review status)',
|
||||
summary:
|
||||
"Document-clearance grid (required docs + upload + GL review status)",
|
||||
})
|
||||
getClearance(@Param('id', ParseUUIDPipe) id: string) {
|
||||
getClearance(@Param("id", ParseUUIDPipe) id: string) {
|
||||
return this.transitionService.getClearanceView(id);
|
||||
}
|
||||
|
||||
@Post(':id/clearance/documents')
|
||||
@Post(":id/clearance/documents")
|
||||
@UseInterceptors(AnyFilesInterceptor())
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiConsumes("multipart/form-data")
|
||||
@ApiOperation({
|
||||
summary: 'Customer uploads clearance documents (fieldname = document key)',
|
||||
summary: "Customer uploads clearance documents (fieldname = document key)",
|
||||
})
|
||||
async submitClearanceDocuments(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@UploadedFiles() files: Express.Multer.File[],
|
||||
) {
|
||||
const booking = await this.transitionService.submitClearanceDocuments(
|
||||
@@ -403,14 +439,14 @@ export class BookingsController {
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(':id/clearance/proceed')
|
||||
@Post(":id/clearance/proceed")
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Customer requests operation with a schedule day ' +
|
||||
'(CLEARANCE_READY | OPERATION_CHANGES_REQUESTED → OPERATION_REQUEST_PENDING)',
|
||||
"Customer requests operation with a schedule day " +
|
||||
"(CLEARANCE_READY | OPERATION_CHANGES_REQUESTED → OPERATION_REQUEST_PENDING)",
|
||||
})
|
||||
async proceedToOperation(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() dto: RequestOperationDto,
|
||||
) {
|
||||
const booking = await this.transitionService.requestOperation(
|
||||
@@ -420,15 +456,15 @@ export class BookingsController {
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(':id/operation/review')
|
||||
@Post(":id/operation/review")
|
||||
@BookingStaff(FREIGHT_PERMS.bookings.operations)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Operations reviews an operation request: ACCEPT (→ batch pool), ' +
|
||||
'REQUEST_CHANGES (→ back to customer), or ADJUST_PRICE (→ customer re-confirm)',
|
||||
"Operations reviews an operation request: ACCEPT (→ batch pool), " +
|
||||
"REQUEST_CHANGES (→ back to customer), or ADJUST_PRICE (→ customer re-confirm)",
|
||||
})
|
||||
async reviewOperationRequest(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() dto: OperationReviewDto,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
@@ -441,11 +477,13 @@ export class BookingsController {
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(':id/clearance/review')
|
||||
@Post(":id/clearance/review")
|
||||
@BookingStaff(FREIGHT_PERMS.bookings.reviewDocuments)
|
||||
@ApiOperation({ summary: 'GL reviews a clearance document (Approve | Query)' })
|
||||
@ApiOperation({
|
||||
summary: "GL reviews a clearance document (Approve | Query)",
|
||||
})
|
||||
async reviewClearanceDocument(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() dto: ReviewDocumentDto,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
@@ -459,13 +497,13 @@ export class BookingsController {
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(':id/clearance/output-documents')
|
||||
@Post(":id/clearance/output-documents")
|
||||
@BookingStaff(FREIGHT_PERMS.bookings.uploadClearanceOutput)
|
||||
@UseInterceptors(AnyFilesInterceptor())
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiOperation({ summary: 'GL uploads customs output documents (IM4/EX3/…)' })
|
||||
@ApiConsumes("multipart/form-data")
|
||||
@ApiOperation({ summary: "GL uploads customs output documents (IM4/EX3/…)" })
|
||||
async uploadClearanceOutput(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@UploadedFiles() files: Express.Multer.File[],
|
||||
) {
|
||||
const booking = await this.transitionService.uploadClearanceOutputDocuments(
|
||||
@@ -475,21 +513,176 @@ export class BookingsController {
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(':id/clearance/finalize')
|
||||
@Post(":id/clearance/finalize")
|
||||
@BookingStaff(FREIGHT_PERMS.bookings.finalizeClearance)
|
||||
@ApiOperation({
|
||||
summary: 'GL finalizes clearance (requires 100% approved) → CLEARANCE_READY',
|
||||
summary:
|
||||
"GL finalizes clearance (requires 100% approved) → CLEARANCE_READY",
|
||||
})
|
||||
async finalizeClearance(@Param('id', ParseUUIDPipe) id: string) {
|
||||
async finalizeClearance(@Param("id", ParseUUIDPipe) id: string) {
|
||||
const booking = await this.transitionService.finalizeClearance(id);
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(':id/clearance/declaration')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
|
||||
@UseInterceptors(AnyFilesInterceptor())
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiOperation({ summary: 'GL ET uploads customs declaration on booking (GENERAL customs)' })
|
||||
async uploadBookingDeclaration(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@UploadedFiles() files: Express.Multer.File[],
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
const booking = await this.bookingClearanceService.uploadDeclaration(
|
||||
id,
|
||||
files ?? [],
|
||||
resolveAuthUserId(user),
|
||||
);
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(':id/clearance/duty')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDutyAdvise)
|
||||
@UseInterceptors(FileInterceptor('attachment'))
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiOperation({ summary: 'GL ET sets duty/tax on booking with notice attachment' })
|
||||
async adviseBookingDuty(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body('dutyRequired') dutyRequiredRaw: string,
|
||||
@Body('amount') amountRaw: string | undefined,
|
||||
@Body('currency') currency: string | undefined,
|
||||
@Body('declarationSerial') declarationSerial: string | undefined,
|
||||
@UploadedFile() attachment: Express.Multer.File | undefined,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
const dutyRequired = dutyRequiredRaw === 'true' || dutyRequiredRaw === '1';
|
||||
const dto: AdviseContractDutyDto = {
|
||||
dutyRequired,
|
||||
amount:
|
||||
amountRaw != null && amountRaw !== '' ? Number(amountRaw) : undefined,
|
||||
currency: currency ?? 'ETB',
|
||||
declarationSerial,
|
||||
};
|
||||
const booking = await this.bookingClearanceService.adviseDuty(
|
||||
id,
|
||||
dto,
|
||||
resolveAuthUserId(user),
|
||||
attachment,
|
||||
);
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(':id/clearance/finalize-pre-clearance')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
|
||||
@ApiOperation({ summary: 'GL ET finalizes import pre-clearance on booking' })
|
||||
async finalizeBookingPreClearance(@Param('id', ParseUUIDPipe) id: string) {
|
||||
const booking = await this.bookingClearanceService.finalizePreClearance(id);
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(':id/clearance/duty-slip')
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiOperation({ summary: 'Customer uploads duty/tax payment slip on booking' })
|
||||
async uploadBookingDutySlip(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
) {
|
||||
const booking = await this.bookingClearanceService.uploadDutySlip(id, file);
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(':id/clearance/transit-permit')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
|
||||
@UseInterceptors(AnyFilesInterceptor())
|
||||
@ApiConsumes('multipart/form-data')
|
||||
async uploadBookingTransitPermit(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@UploadedFiles() files: Express.Multer.File[],
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
const booking = await this.bookingClearanceService.uploadTransitPermit(
|
||||
id,
|
||||
files ?? [],
|
||||
resolveAuthUserId(user),
|
||||
);
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(':id/clearance/delivery-order')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
@ApiConsumes('multipart/form-data')
|
||||
async uploadBookingDeliveryOrder(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
const booking = await this.bookingClearanceService.uploadDeliveryOrder(
|
||||
id,
|
||||
file,
|
||||
resolveAuthUserId(user),
|
||||
);
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(':id/clearance/release-order')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
@ApiConsumes('multipart/form-data')
|
||||
async uploadBookingReleaseOrder(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
@Body('vesselDepartureDate') vesselDepartureDate: string,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
const result = await this.bookingClearanceService.uploadReleaseOrder(
|
||||
id,
|
||||
file,
|
||||
vesselDepartureDate,
|
||||
resolveAuthUserId(user),
|
||||
);
|
||||
return {
|
||||
...this.transitionService.enrichBookingResponse(result.booking),
|
||||
hold: result.hold,
|
||||
holdReason: result.holdReason,
|
||||
};
|
||||
}
|
||||
|
||||
@Post(':id/clearance/ro-amendment')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
|
||||
async requestBookingRoAmendment(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: RoAmendmentDto,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
const booking = await this.bookingClearanceService.requestRoAmendment(
|
||||
id,
|
||||
dto.note,
|
||||
resolveAuthUserId(user),
|
||||
);
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(':id/clearance/export-release')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
|
||||
async confirmBookingExportRelease(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
const booking = await this.bookingClearanceService.confirmExportRelease(
|
||||
id,
|
||||
resolveAuthUserId(user),
|
||||
);
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(':id/staff/request-changes')
|
||||
@BookingStaff(FREIGHT_PERMS.bookings.requestChanges)
|
||||
@ApiOperation({ summary: 'Staff return booking for customer updates' })
|
||||
@ApiOperation({ summary: "Staff return booking for customer updates" })
|
||||
async requestChanges(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() dto: RequestChangesDto,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
@@ -501,14 +694,14 @@ export class BookingsController {
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(':id/staff/accept')
|
||||
@Post(":id/staff/accept")
|
||||
@BookingStaff(FREIGHT_PERMS.bookings.staffAccept)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Staff accept intake → set contract validity window + start approval chain',
|
||||
"Staff accept intake → set contract validity window + start approval chain",
|
||||
})
|
||||
async acceptIntake(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() dto: AcceptIntakeDto,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
@@ -520,11 +713,11 @@ export class BookingsController {
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(':id/staff/reject')
|
||||
@Post(":id/staff/reject")
|
||||
@BookingStaff(FREIGHT_PERMS.bookings.reject)
|
||||
@ApiOperation({ summary: 'Staff final reject' })
|
||||
@ApiOperation({ summary: "Staff final reject" })
|
||||
async staffReject(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() dto: StaffRejectDto,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
@@ -536,11 +729,13 @@ export class BookingsController {
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(':id/government-expedite')
|
||||
@Post(":id/government-expedite")
|
||||
@BookingStaff(FREIGHT_PERMS.bookings.staffAccept)
|
||||
@ApiOperation({ summary: 'Expedite government booking to PAID / ELIGIBLE for scheduling' })
|
||||
@ApiOperation({
|
||||
summary: "Expedite government booking to PAID / ELIGIBLE for scheduling",
|
||||
})
|
||||
async governmentExpedite(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
const booking = await this.bookingsService.governmentExpedite(
|
||||
@@ -550,16 +745,16 @@ export class BookingsController {
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(':id/approval-steps/:stepId/approve')
|
||||
@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' })
|
||||
@ApiOperation({ summary: "Approve one approval step in sequence" })
|
||||
async approveStep(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param('stepId', ParseUUIDPipe) stepId: string,
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Param("stepId", ParseUUIDPipe) stepId: string,
|
||||
@Body() dto: ApproveStepDto,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
@@ -573,12 +768,12 @@ export class BookingsController {
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(':id/approval-steps/:stepId/reject')
|
||||
@Post(":id/approval-steps/:stepId/reject")
|
||||
@BookingStaff(FREIGHT_PERMS.bookings.rejectApproval)
|
||||
@ApiOperation({ summary: 'Reject at approval step' })
|
||||
@ApiOperation({ summary: "Reject at approval step" })
|
||||
async rejectStep(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param('stepId', ParseUUIDPipe) stepId: string,
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Param("stepId", ParseUUIDPipe) stepId: string,
|
||||
@Body() dto: RejectStepDto,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
@@ -591,53 +786,53 @@ export class BookingsController {
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(':id/contract/generate')
|
||||
@Post(":id/contract/generate")
|
||||
@BookingStaff(FREIGHT_PERMS.bookings.generateContract)
|
||||
@ApiOperation({ summary: 'Generate contract PDF from template' })
|
||||
async generateContract(@Param('id', ParseUUIDPipe) id: string) {
|
||||
@ApiOperation({ summary: "Generate contract PDF from template" })
|
||||
async generateContract(@Param("id", ParseUUIDPipe) id: string) {
|
||||
const booking = await this.contractService.generateContract(id);
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Get(':id/contract/view')
|
||||
@Get(":id/contract/view")
|
||||
@ApiOkResponse({ type: ContractViewDto })
|
||||
@ApiOperation({ summary: 'Contract HTML view for portal and backoffice' })
|
||||
@ApiOperation({ summary: "Contract HTML view for portal and backoffice" })
|
||||
getContractView(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Request() req: { user?: { id?: string; sub?: string } },
|
||||
) {
|
||||
const userId = req.user?.id ?? req.user?.sub;
|
||||
return this.contractService.getContractView(id, userId);
|
||||
}
|
||||
|
||||
@Get(':id/contract/document')
|
||||
@ApiOperation({ summary: 'Download contract PDF' })
|
||||
@Get(":id/contract/document")
|
||||
@ApiOperation({ summary: "Download contract PDF" })
|
||||
async downloadContractDocument(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Res() res: Response,
|
||||
): Promise<void> {
|
||||
const { stream, record } = await this.contractService.streamContract(id);
|
||||
res.setHeader('Content-Type', record.mimeType ?? 'application/pdf');
|
||||
res.setHeader("Content-Type", record.mimeType ?? "application/pdf");
|
||||
res.setHeader(
|
||||
'Content-Disposition',
|
||||
"Content-Disposition",
|
||||
`attachment; filename="${record.name}"`,
|
||||
);
|
||||
stream.pipe(res);
|
||||
}
|
||||
|
||||
@Get(':id/contract')
|
||||
@ApiOperation({ summary: 'Download contract file (alias)' })
|
||||
@Get(":id/contract")
|
||||
@ApiOperation({ summary: "Download contract file (alias)" })
|
||||
async downloadContract(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Res() res: Response,
|
||||
): Promise<void> {
|
||||
return this.downloadContractDocument(id, res);
|
||||
}
|
||||
|
||||
@Post(':id/contract/sign')
|
||||
@ApiOperation({ summary: 'Apply digital signature (customer or staff)' })
|
||||
@Post(":id/contract/sign")
|
||||
@ApiOperation({ summary: "Apply digital signature (customer or staff)" })
|
||||
async signContract(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() dto: SignContractDto,
|
||||
@Request() req: { user?: { id?: string; sub?: string }; ip?: string },
|
||||
) {
|
||||
@@ -649,28 +844,28 @@ export class BookingsController {
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Get(':id/contract/signatures')
|
||||
@ApiOperation({ summary: 'List contract signatures' })
|
||||
getContractSignatures(@Param('id', ParseUUIDPipe) id: string) {
|
||||
@Get(":id/contract/signatures")
|
||||
@ApiOperation({ summary: "List contract signatures" })
|
||||
getContractSignatures(@Param("id", ParseUUIDPipe) id: string) {
|
||||
return this.contractService.getSignatures(id);
|
||||
}
|
||||
|
||||
@Get(':id/summary')
|
||||
@ApiOperation({ summary: 'Contract summary string for dashboard' })
|
||||
getSummary(@Param('id', ParseUUIDPipe) id: string) {
|
||||
@Get(":id/summary")
|
||||
@ApiOperation({ summary: "Contract summary string for dashboard" })
|
||||
getSummary(@Param("id", ParseUUIDPipe) id: string) {
|
||||
return this.contractService.getSummary(id);
|
||||
}
|
||||
|
||||
@Post(':id/customer/sign')
|
||||
@Post(":id/customer/sign")
|
||||
@ApiOperation({
|
||||
summary: 'Customer digital signature (deprecated — use POST contract/sign)',
|
||||
summary: "Customer digital signature (deprecated — use POST contract/sign)",
|
||||
})
|
||||
async customerSign(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() dto: SignContractDto,
|
||||
@Request() req: { user?: { id?: string; sub?: string }; ip?: string },
|
||||
) {
|
||||
const payload: SignContractDto = { ...dto, role: 'CUSTOMER' };
|
||||
const payload: SignContractDto = { ...dto, role: "CUSTOMER" };
|
||||
const booking = await this.contractService.signContract(id, payload, {
|
||||
signerUserId: req.user?.id ?? req.user?.sub,
|
||||
ipAddress: req.ip,
|
||||
@@ -678,20 +873,21 @@ export class BookingsController {
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(':id/marketing/approve')
|
||||
@Post(":id/marketing/approve")
|
||||
@BookingStaff(FREIGHT_PERMS.bookings.signStaff)
|
||||
@ApiOperation({
|
||||
summary: 'Staff contract signature and fully execute (use contract/sign STAFF preferred)',
|
||||
summary:
|
||||
"Staff contract signature and fully execute (use contract/sign STAFF preferred)",
|
||||
})
|
||||
async marketingApprove(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() dto: SignContractDto,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
@Request() req: { ip?: string },
|
||||
) {
|
||||
const payload: SignContractDto = {
|
||||
...dto,
|
||||
role: 'STAFF',
|
||||
role: "STAFF",
|
||||
};
|
||||
const booking = await this.contractService.signContract(id, payload, {
|
||||
signerUserId: resolveAuthUserId(user),
|
||||
@@ -700,48 +896,48 @@ export class BookingsController {
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(':id/operations/start-transit')
|
||||
@Post(":id/operations/start-transit")
|
||||
@BookingStaff(FREIGHT_PERMS.bookings.operations)
|
||||
@ApiOperation({ summary: 'Mark in transit' })
|
||||
async startTransit(@Param('id', ParseUUIDPipe) id: string) {
|
||||
@ApiOperation({ summary: "Mark in transit" })
|
||||
async startTransit(@Param("id", ParseUUIDPipe) id: string) {
|
||||
const booking = await this.transitionService.startTransit(id);
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(':id/operations/complete')
|
||||
@Post(":id/operations/complete")
|
||||
@BookingStaff(FREIGHT_PERMS.bookings.operations)
|
||||
@ApiOperation({ summary: 'Mark completed' })
|
||||
async complete(@Param('id', ParseUUIDPipe) id: string) {
|
||||
@ApiOperation({ summary: "Mark completed" })
|
||||
async complete(@Param("id", ParseUUIDPipe) id: string) {
|
||||
const booking = await this.transitionService.complete(id);
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(':id/cancel')
|
||||
@Post(":id/cancel")
|
||||
@BookingStaff(FREIGHT_PERMS.bookings.cancel)
|
||||
@ApiOperation({ summary: 'Cancel booking' })
|
||||
@ApiOperation({ summary: "Cancel booking" })
|
||||
async cancel(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() dto: CancelBookingDto,
|
||||
) {
|
||||
const booking = await this.transitionService.cancel(id, dto.reason);
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(':id/consolidation')
|
||||
@ApiOperation({ summary: 'Request freight consolidation' })
|
||||
requestConsolidation(@Param('id', ParseUUIDPipe) id: string) {
|
||||
@Post(":id/consolidation")
|
||||
@ApiOperation({ summary: "Request freight consolidation" })
|
||||
requestConsolidation(@Param("id", ParseUUIDPipe) id: string) {
|
||||
return this.bookingsService.requestConsolidation(id);
|
||||
}
|
||||
|
||||
@Delete(':id/consolidation')
|
||||
@ApiOperation({ summary: 'Remove consolidation pairing' })
|
||||
removeConsolidation(@Param('id', ParseUUIDPipe) id: string) {
|
||||
@Delete(":id/consolidation")
|
||||
@ApiOperation({ summary: "Remove consolidation pairing" })
|
||||
removeConsolidation(@Param("id", ParseUUIDPipe) id: string) {
|
||||
return this.bookingsService.removeConsolidation(id);
|
||||
}
|
||||
|
||||
@Get(':id/consolidation')
|
||||
@ApiOperation({ summary: 'Get consolidation details' })
|
||||
getConsolidationDetails(@Param('id', ParseUUIDPipe) id: string) {
|
||||
@Get(":id/consolidation")
|
||||
@ApiOperation({ summary: "Get consolidation details" })
|
||||
getConsolidationDetails(@Param("id", ParseUUIDPipe) id: string) {
|
||||
return this.bookingsService.getConsolidationDetails(id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Module, forwardRef } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { ExchangeModule, ExchangeOptions } from '@edr/api-common';
|
||||
import { Module, forwardRef } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { TypeOrmModule } from "@nestjs/typeorm";
|
||||
import { ExchangeModule, ExchangeOptions } from "@edr/api-common";
|
||||
|
||||
// import { CustomersModule } from '../customers/customers.module';
|
||||
import { CompaniesModule } from '../companies/companies.module';
|
||||
@@ -14,13 +14,13 @@ import { BillingModule } from '../billing/billing.module';
|
||||
import { FirstMileModule } from '../first-mile/first-mile.module';
|
||||
import { BookingContractService } from './booking-contract.service';
|
||||
import { BookingInvoiceService } from './booking-invoice.service';
|
||||
import { BookingPaymentController } from './booking-payment.controller';
|
||||
import { BookingPaymentService } from './booking-payment.service';
|
||||
// import { BookingPaymentController } from './booking-payment.controller';
|
||||
// import { BookingPaymentService } from './booking-payment.service';
|
||||
import { BookingPricingService } from './booking-pricing.service';
|
||||
import { BookingReferenceDataService } from './booking-reference-data.service';
|
||||
import { BookingTransitionService } from './booking-transition.service';
|
||||
import { BookingsController } from './bookings.controller';
|
||||
import { PayController } from './pay.controller';
|
||||
// import { PayController } from './pay.controller';
|
||||
import { BookingsRepository } from './bookings.repository';
|
||||
import { ConsolidationService } from './consolidation.service';
|
||||
import { BookingsService } from './bookings.service';
|
||||
@@ -32,13 +32,15 @@ import { BookingRateSnapshot } from './entities/booking-rate-snapshot.entity';
|
||||
import { BookingContractSignature } from './entities/booking-contract-signature.entity';
|
||||
import { BookingReviewNote } from './entities/booking-review-note.entity';
|
||||
import { Booking } from './entities/booking.entity';
|
||||
import { BookingContainerAllocation } from './entities/booking-container-allocation.entity';
|
||||
import { ContractPdfService } from '../../contracts/contract-pdf.service';
|
||||
import { ContractPricingScheduleBuilder } from '../../contracts/contract-pricing-schedule.builder';
|
||||
import { ContractRendererService } from '../../contracts/contract-renderer.service';
|
||||
import { ContractTemplateResolver } from '../../contracts/contract-template.resolver';
|
||||
import { ContractViewModelBuilder } from '../../contracts/contract-view-model.builder';
|
||||
import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module';
|
||||
import { ContractsModule } from '../contracts/contracts.module';
|
||||
import { BookingContainerAllocation } from "./entities/booking-container-allocation.entity";
|
||||
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -56,6 +58,8 @@ import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.modu
|
||||
BillingModule,
|
||||
forwardRef(() => FirstMileModule),
|
||||
forwardRef(() => TrainSchedulingModule),
|
||||
forwardRef(() => ContractsModule),
|
||||
forwardRef(() => ContractsModule),
|
||||
FilesModule,
|
||||
MinioModule,
|
||||
CompaniesModule,
|
||||
@@ -66,10 +70,10 @@ import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.modu
|
||||
ExchangeModule.forRootAsync({
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService): ExchangeOptions =>
|
||||
config.get<ExchangeOptions>('app.cbeExchange') ?? {},
|
||||
config.get<ExchangeOptions>("app.cbeExchange") ?? {},
|
||||
}),
|
||||
],
|
||||
controllers: [BookingsController, PayController, BookingPaymentController],
|
||||
controllers: [BookingsController],
|
||||
providers: [
|
||||
BookingsService,
|
||||
BookingsRepository,
|
||||
@@ -79,13 +83,17 @@ import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.modu
|
||||
BookingTransitionService,
|
||||
BookingContractService,
|
||||
BookingInvoiceService,
|
||||
BookingPaymentService,
|
||||
ContractTemplateResolver,
|
||||
ContractViewModelBuilder,
|
||||
ContractPricingScheduleBuilder,
|
||||
ContractRendererService,
|
||||
ContractPdfService,
|
||||
],
|
||||
exports: [BookingsService, BookingsRepository, BookingPricingService, BookingInvoiceService],
|
||||
exports: [
|
||||
BookingsService,
|
||||
BookingsRepository,
|
||||
BookingPricingService,
|
||||
BookingInvoiceService,
|
||||
],
|
||||
})
|
||||
export class BookingsModule {}
|
||||
export class BookingsModule { }
|
||||
|
||||
@@ -121,6 +121,8 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
containerTypeId: string;
|
||||
quantity: number;
|
||||
vgmPerUnitTons: number;
|
||||
hazardousQuantity?: number;
|
||||
reeferQuantity?: number;
|
||||
weightResult: ContainerWeightResult;
|
||||
}>,
|
||||
): Promise<BookingContainer[]> {
|
||||
@@ -133,11 +135,16 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
const wagonsPerUnit = ct ? Number(ct.wagonsPerUnit) : 1;
|
||||
const totalVgm = item.quantity * item.vgmPerUnitTons;
|
||||
const wagonsRequired = Math.ceil(item.quantity * wagonsPerUnit);
|
||||
// A per-line breakdown can never exceed the line's own quantity.
|
||||
const clamp = (v?: number) =>
|
||||
Math.max(0, Math.min(item.quantity, Math.floor(Number(v ?? 0)) || 0));
|
||||
|
||||
const row = containerRepo.create({
|
||||
bookingId,
|
||||
containerTypeId: item.containerTypeId,
|
||||
quantity: item.quantity,
|
||||
hazardousQuantity: clamp(item.hazardousQuantity),
|
||||
reeferQuantity: clamp(item.reeferQuantity),
|
||||
vgmPerUnitTons: item.vgmPerUnitTons,
|
||||
totalVgmTons: totalVgm,
|
||||
wagonsRequired,
|
||||
@@ -483,6 +490,15 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
} as never);
|
||||
}
|
||||
|
||||
/** Bookings in any of the given statuses (clearance queue helpers). */
|
||||
async findByStatuses(statuses: string[]): Promise<Booking[]> {
|
||||
if (!statuses.length) return [];
|
||||
return this.repository.find({
|
||||
where: { status: In(statuses) },
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
/** Queue listing with optional bulk exclusion for LINE_STAFF. */
|
||||
async findQueue(options: {
|
||||
status: string | string[];
|
||||
|
||||
@@ -69,6 +69,17 @@ const NEEDS_ACTION_STATUSES = [
|
||||
'APPROVED_PENDING_SIGNATURE',
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Clamp a bulk hazardous/reefer amount into 0..cargoAmount: it can never exceed
|
||||
* the total cargo it's a portion of, and is never negative.
|
||||
*/
|
||||
function clampToCargo(value: number | undefined, cargoAmount: number): number {
|
||||
const v = Number(value ?? 0);
|
||||
if (!Number.isFinite(v) || v <= 0) return 0;
|
||||
const cap = Number.isFinite(cargoAmount) && cargoAmount > 0 ? cargoAmount : 0;
|
||||
return Math.min(v, cap);
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class BookingsService {
|
||||
constructor(
|
||||
@@ -635,6 +646,16 @@ export class BookingsService {
|
||||
// the container type at pricing time, so the booking-level flag stays off
|
||||
// for container freight to avoid double-counting.
|
||||
isReefer: dto.freightType === 'BULK' ? (dto.isReefer ?? false) : false,
|
||||
// Bulk-only hazardous/reefer amount, clamped to the cargo amount. Container
|
||||
// freight tracks this per line, so these are 0 for CONTAINER.
|
||||
bulkHazardousQuantity:
|
||||
dto.freightType === 'BULK'
|
||||
? clampToCargo(dto.bulkHazardousQuantity, dto.cargoTotalWeightVgm)
|
||||
: 0,
|
||||
bulkReeferQuantity:
|
||||
dto.freightType === 'BULK'
|
||||
? clampToCargo(dto.bulkReeferQuantity, dto.cargoTotalWeightVgm)
|
||||
: 0,
|
||||
paymentCurrency: dto.paymentCurrency,
|
||||
pnrCode: dto.pnrCode,
|
||||
financialTerms: dto.financialTerms,
|
||||
@@ -657,6 +678,8 @@ export class BookingsService {
|
||||
containerTypeId: c.containerTypeId,
|
||||
quantity: c.quantity,
|
||||
vgmPerUnitTons: c.vgmPerUnitTons,
|
||||
hazardousQuantity: c.hazardousQuantity,
|
||||
reeferQuantity: c.reeferQuantity,
|
||||
weightResult: ruleResult.containerWeightResults[i],
|
||||
})),
|
||||
);
|
||||
@@ -794,6 +817,8 @@ export class BookingsService {
|
||||
containers,
|
||||
);
|
||||
|
||||
const cargoAmount =
|
||||
dto.cargoTotalWeightVgm ?? Number(existing.cargoTotalWeightVgm ?? 0);
|
||||
const updates: Record<string, unknown> = {
|
||||
...dto,
|
||||
freightType,
|
||||
@@ -804,6 +829,22 @@ export class BookingsService {
|
||||
freightType === 'BULK'
|
||||
? (dto.isReefer ?? existing.isReefer ?? false)
|
||||
: false,
|
||||
// Bulk-only hazardous/reefer amount, clamped to the cargo amount; 0 for
|
||||
// container freight (per-line on the containers instead).
|
||||
bulkHazardousQuantity:
|
||||
freightType === 'BULK'
|
||||
? clampToCargo(
|
||||
dto.bulkHazardousQuantity ?? Number(existing.bulkHazardousQuantity ?? 0),
|
||||
cargoAmount,
|
||||
)
|
||||
: 0,
|
||||
bulkReeferQuantity:
|
||||
freightType === 'BULK'
|
||||
? clampToCargo(
|
||||
dto.bulkReeferQuantity ?? Number(existing.bulkReeferQuantity ?? 0),
|
||||
cargoAmount,
|
||||
)
|
||||
: 0,
|
||||
priorityScore: ruleResult.priorityScore,
|
||||
tradeDirection,
|
||||
};
|
||||
@@ -850,6 +891,8 @@ export class BookingsService {
|
||||
containerTypeId: c.containerTypeId,
|
||||
quantity: c.quantity,
|
||||
vgmPerUnitTons: c.vgmPerUnitTons,
|
||||
hazardousQuantity: c.hazardousQuantity,
|
||||
reeferQuantity: c.reeferQuantity,
|
||||
weightResult: ruleResult.containerWeightResults[i],
|
||||
})),
|
||||
);
|
||||
|
||||
@@ -72,9 +72,6 @@ export class BookingReferenceCargoTypeChildDto {
|
||||
@ApiProperty({ example: 'BULK_COFFEE' })
|
||||
code!: string;
|
||||
|
||||
@ApiProperty()
|
||||
show_free_text_box!: boolean;
|
||||
|
||||
@ApiProperty({ enum: CargoUnitOfMeasure, nullable: true, required: false })
|
||||
unit_of_measure?: CargoUnitOfMeasure | null;
|
||||
}
|
||||
|
||||
@@ -52,6 +52,28 @@ export class CreateBookingContainerDto {
|
||||
@Min(0)
|
||||
@Transform(({ value }) => Number(value))
|
||||
vgmPerUnitTons!: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'How many of this line are hazardous (0..quantity)',
|
||||
minimum: 0,
|
||||
default: 0,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Transform(({ value }) => Number(value ?? 0))
|
||||
hazardousQuantity?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'How many of this line are refrigerated (0..quantity)',
|
||||
minimum: 0,
|
||||
default: 0,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Transform(({ value }) => Number(value ?? 0))
|
||||
reeferQuantity?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -320,6 +342,25 @@ export class CreateBookingDto {
|
||||
@Transform(({ value }) => value === 'true' || value === true)
|
||||
isReefer?: boolean;
|
||||
|
||||
/**
|
||||
* Bulk-only: how much of the cargo is hazardous / refrigerated, in the cargo's
|
||||
* unit of measure (tons for PER_TON, item count for PER_ITEM). Must not exceed
|
||||
* cargoTotalWeightVgm. Ignored for container freight (per-line on containers).
|
||||
*/
|
||||
@ApiPropertyOptional({ minimum: 0, default: 0 })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Transform(({ value }) => Number(value ?? 0))
|
||||
bulkHazardousQuantity?: number;
|
||||
|
||||
@ApiPropertyOptional({ minimum: 0, default: 0 })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Transform(({ value }) => Number(value ?? 0))
|
||||
bulkReeferQuantity?: number;
|
||||
|
||||
@ApiProperty({ enum: PAYMENT_CURRENCIES })
|
||||
@IsIn([...PAYMENT_CURRENCIES])
|
||||
paymentCurrency!: string;
|
||||
|
||||
@@ -340,6 +340,19 @@ export class Booking extends BaseEntity {
|
||||
@Column({ name: 'is_reefer', type: 'boolean', default: false })
|
||||
isReefer!: boolean;
|
||||
|
||||
/**
|
||||
* Bulk-only hazardous / reefer amount, in the cargo's own unit of measure
|
||||
* (tons for PER_TON commodities, item count for PER_ITEM) — i.e. how much of
|
||||
* `cargoTotalWeightVgm` is hazardous / refrigerated. 0 when none. Container
|
||||
* freight carries this per line on `booking_container` instead, so these stay
|
||||
* 0 for CONTAINER bookings. The booleans above remain the surcharge trigger.
|
||||
*/
|
||||
@Column({ name: 'bulk_hazardous_quantity', type: 'numeric', precision: 12, scale: 3, default: 0 })
|
||||
bulkHazardousQuantity!: number;
|
||||
|
||||
@Column({ name: 'bulk_reefer_quantity', type: 'numeric', precision: 12, scale: 3, default: 0 })
|
||||
bulkReeferQuantity!: number;
|
||||
|
||||
@Column({ name: 'payment_currency', type: 'varchar', length: 5 })
|
||||
paymentCurrency!: string;
|
||||
|
||||
@@ -454,6 +467,25 @@ export class Booking extends BaseEntity {
|
||||
@Column({ name: 'gl_station_yard_id', type: 'uuid', nullable: true })
|
||||
glStationYardId?: string | null;
|
||||
|
||||
/** Per-booking phased clearance (GENERAL + customs). */
|
||||
@Column({ name: 'clearance_current_phase', type: 'varchar', length: 40, nullable: true })
|
||||
clearanceCurrentPhase?: string | null;
|
||||
|
||||
@Column({ name: 'duty_required', type: 'boolean', nullable: true })
|
||||
dutyRequired?: boolean | null;
|
||||
|
||||
@Column({ name: 'vessel_departure_date', type: 'date', nullable: true })
|
||||
vesselDepartureDate?: string | null;
|
||||
|
||||
@Column({ name: 'ro_amendment_requested_at', type: 'timestamptz', nullable: true })
|
||||
roAmendmentRequestedAt?: Date | null;
|
||||
|
||||
@Column({ name: 'ro_hold_reason', type: 'text', nullable: true })
|
||||
roHoldReason?: string | null;
|
||||
|
||||
@Column({ name: 'pre_clearance_finalized_at', type: 'timestamptz', nullable: true })
|
||||
preClearanceFinalizedAt?: Date | null;
|
||||
|
||||
/** GL staff user bound to this shipment by the station manager. */
|
||||
@Column({ name: 'gl_assigned_staff_id', type: 'uuid', nullable: true })
|
||||
glAssignedStaffId?: string | null;
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
import { Controller, Param, ParseUUIDPipe, Post } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { BookingPaymentService } from './booking-payment.service';
|
||||
// import { BookingTransitionService } from './booking-transition.service';
|
||||
// import { InAppPaymentReceiptDto } from './dto/pay-booking.dto';
|
||||
// import { Booking } from './entities/booking.entity';
|
||||
// import { BookingNextStep } from './booking-next-step.util';
|
||||
|
||||
@ApiTags('payments')
|
||||
@ApiBearerAuth()
|
||||
@Controller('bookings')
|
||||
export class PayController {
|
||||
constructor(
|
||||
private readonly paymentService: BookingPaymentService,
|
||||
// private readonly transitionService: BookingTransitionService,
|
||||
) { }
|
||||
|
||||
@Post(':id/payment/pay')
|
||||
@ApiOperation({ summary: 'Complete in-app payment (mock)' })
|
||||
@ApiOkResponse({ description: 'Enriched booking with ephemeral payment receipt' })
|
||||
async pay(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return await this.paymentService.pay(id);
|
||||
// const abstract = await this.transitionService.enrichBookingResponse(booking);
|
||||
// return { ...abstract, paymentReceipt: receipt };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user