mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-09 04:48:18 +00:00
feat(freight-web): polish document clearance, general contracts list and detail UIs
This commit is contained in:
@@ -23,6 +23,13 @@ import { clearanceSettingCode } from './clearance.util';
|
||||
import { ContractViewDto } from './dto/contract-view.dto';
|
||||
import { SignContractDto } from './dto/sign-contract.dto';
|
||||
import { ContractSignerRole } from './entities/booking-contract-signature.entity';
|
||||
|
||||
/**
|
||||
* Default ordering window (months) for a general contract activated on
|
||||
* counter-sign. Mirrors GeneralContractService.DEFAULT_CONTRACT_PERIOD_MONTHS;
|
||||
* defined locally to avoid a circular module dependency on booking-orders.
|
||||
*/
|
||||
const DEFAULT_CONTRACT_PERIOD_MONTHS = 3;
|
||||
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
|
||||
import { SignaturesService } from '../signatures/signatures.service';
|
||||
|
||||
@@ -233,9 +240,23 @@ export class BookingContractService {
|
||||
includesCustoms,
|
||||
);
|
||||
|
||||
const isGeneralContract = booking.bookingType === 'GENERAL_CONTRACT';
|
||||
|
||||
if (role === 'CUSTOMER') {
|
||||
updates.status = 'SIGNED_CUSTOMER';
|
||||
updates.customerSignedAt = now;
|
||||
} else if (isGeneralContract) {
|
||||
// A general contract is NOT paid up front — each drawdown order is priced
|
||||
// and paid on its own. So on counter-sign it becomes ACTIVE directly and
|
||||
// opens its ordering window; orders spawn their own priced child bookings.
|
||||
const expiresAt = new Date(now);
|
||||
expiresAt.setMonth(expiresAt.getMonth() + DEFAULT_CONTRACT_PERIOD_MONTHS);
|
||||
updates.fullyExecutedAt = now;
|
||||
updates.marketingApprovedAt = now;
|
||||
updates.marketingApprovedById = options.signerUserId ?? null;
|
||||
updates.lockedAt = now;
|
||||
updates.status = 'CONTRACT_ACTIVE';
|
||||
updates.expiresAt = expiresAt;
|
||||
} else {
|
||||
updates.fullyExecutedAt = now;
|
||||
updates.marketingApprovedAt = now;
|
||||
|
||||
@@ -268,6 +268,10 @@ export class BookingPricingService {
|
||||
tradeDirection: booking.tradeDirection,
|
||||
// Coerce defensively in case the stored flag is a string ("true"/"false").
|
||||
isHazardous: booking.isHazardous === true || (booking.isHazardous as unknown) === 'true',
|
||||
// Booking-level reefer flag (set by contract drawdown orders that carry a
|
||||
// reefer quantity) applies the REEFER surcharge even for non-reefer
|
||||
// container types. ORed with per-container reefer in the engine.
|
||||
isReefer: booking.isReefer === true || (booking.isReefer as unknown) === 'true',
|
||||
isGovernment: booking.isGovernment,
|
||||
allowConsolidation,
|
||||
shippingLineId: booking.shippingLineId,
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { BookingTransitionService } from './booking-transition.service';
|
||||
|
||||
/**
|
||||
* Operation-request review for general-contract drawdown orders:
|
||||
* - ACCEPT a train order → FULLY_EXECUTED and enqueued into the batch pool.
|
||||
* - ACCEPT a road order → ROAD_DISPATCH_PENDING, NOT enqueued.
|
||||
* - REQUEST_CHANGES requires a note → OPERATION_CHANGES_REQUESTED.
|
||||
* - ADJUST_PRICE sets the adjusted total → OPERATION_PRICE_PENDING_CONFIRM.
|
||||
*/
|
||||
describe('BookingTransitionService — operation review', () => {
|
||||
function makeService(serviceTypeCode: string) {
|
||||
const booking = {
|
||||
id: 'b-1',
|
||||
status: 'OPERATION_REQUEST_PENDING',
|
||||
originYardId: 'o-1',
|
||||
destinationYardId: 'd-1',
|
||||
scheduledDate: new Date('2026-07-01T00:00:00.000Z'),
|
||||
serviceType: { code: serviceTypeCode },
|
||||
};
|
||||
const bookingsRepository = {
|
||||
update: jest.fn().mockResolvedValue({ id: 'b-1' }),
|
||||
createReviewNote: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const bookingsService = {
|
||||
findById: jest.fn().mockResolvedValue(booking),
|
||||
};
|
||||
const bookingBatchService = {
|
||||
enqueueRouteDayProcessing: jest.fn(),
|
||||
};
|
||||
|
||||
const service = new BookingTransitionService(
|
||||
bookingsRepository as never,
|
||||
{} as never, // ruleEngineService
|
||||
{} as never, // pricingService
|
||||
{} as never, // contractService
|
||||
{} as never, // filesService
|
||||
{} as never, // fileUploadSettingsService
|
||||
bookingBatchService as never,
|
||||
bookingsService as never,
|
||||
);
|
||||
return { service, bookingsRepository, bookingBatchService };
|
||||
}
|
||||
|
||||
it('ACCEPT of a train order → FULLY_EXECUTED and enqueues the batch pool', async () => {
|
||||
const { service, bookingsRepository, bookingBatchService } =
|
||||
makeService('RAIL_CONTAINER');
|
||||
await service.reviewOperationRequest('b-1', 'ACCEPT', 'staff-1');
|
||||
expect(bookingsRepository.update).toHaveBeenCalledWith(
|
||||
'b-1',
|
||||
expect.objectContaining({ status: 'FULLY_EXECUTED' }),
|
||||
);
|
||||
expect(bookingBatchService.enqueueRouteDayProcessing).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('ACCEPT of a road order → ROAD_DISPATCH_PENDING and does NOT enqueue', async () => {
|
||||
const { service, bookingsRepository, bookingBatchService } =
|
||||
makeService('ROAD_CONTAINER');
|
||||
await service.reviewOperationRequest('b-1', 'ACCEPT', 'staff-1');
|
||||
expect(bookingsRepository.update).toHaveBeenCalledWith(
|
||||
'b-1',
|
||||
expect.objectContaining({ status: 'ROAD_DISPATCH_PENDING' }),
|
||||
);
|
||||
expect(bookingBatchService.enqueueRouteDayProcessing).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('REQUEST_CHANGES requires a note → OPERATION_CHANGES_REQUESTED', async () => {
|
||||
const { service, bookingsRepository } = makeService('RAIL_CONTAINER');
|
||||
await expect(
|
||||
service.reviewOperationRequest('b-1', 'REQUEST_CHANGES', 'staff-1', {}),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
await service.reviewOperationRequest('b-1', 'REQUEST_CHANGES', 'staff-1', {
|
||||
note: 'Fix the schedule',
|
||||
});
|
||||
expect(bookingsRepository.update).toHaveBeenCalledWith(
|
||||
'b-1',
|
||||
expect.objectContaining({ status: 'OPERATION_CHANGES_REQUESTED' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('ADJUST_PRICE sets the adjusted total → OPERATION_PRICE_PENDING_CONFIRM', async () => {
|
||||
const { service, bookingsRepository } = makeService('RAIL_CONTAINER');
|
||||
await service.reviewOperationRequest('b-1', 'ADJUST_PRICE', 'staff-1', {
|
||||
amount: 1500,
|
||||
});
|
||||
expect(bookingsRepository.update).toHaveBeenCalledWith(
|
||||
'b-1',
|
||||
expect.objectContaining({
|
||||
adjustedTotalAmount: 1500,
|
||||
status: 'OPERATION_PRICE_PENDING_CONFIRM',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,7 @@ import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/curre
|
||||
import { assertCanApproveBookingStep } from '../../common/freight-permission.util';
|
||||
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
|
||||
import { eatDay } from '../train-scheduling/batch-window.util';
|
||||
import { isRoadService } from '../booking-orders/road.util';
|
||||
import { RuleEngineService } from '../rule-engine/rule-engine.service';
|
||||
import { FilesService } from '../files/files.service';
|
||||
import { FileUploadSettingsService } from '../file-upload-settings/file-upload-settings.service';
|
||||
@@ -879,12 +880,27 @@ export class BookingTransitionService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Move a reviewed operation request into the batch holding pool. The pool query
|
||||
* (findBatchPoolByRouteDay) keys on FULLY_EXECUTED + scheduled_date, so we set
|
||||
* those and kick the day-level fill immediately instead of waiting for cron.
|
||||
* Move a reviewed operation request forward after Marketing accepts.
|
||||
*
|
||||
* - Train services enter the batch holding pool: the pool query
|
||||
* (findBatchPoolByRouteDay) keys on FULLY_EXECUTED + scheduled_date, so we
|
||||
* set those and kick the day-level fill immediately instead of waiting for
|
||||
* cron.
|
||||
* - Road (truck) services skip the train batch entirely and wait for truck
|
||||
* dispatch at ROAD_DISPATCH_PENDING; they are billed by KM, not wagons.
|
||||
*/
|
||||
private async acceptOperationRequest(booking: Booking): Promise<Booking> {
|
||||
const now = new Date();
|
||||
|
||||
if (isRoadService(booking.serviceType)) {
|
||||
await this.bookingsRepository.update(booking.id, {
|
||||
status: 'ROAD_DISPATCH_PENDING',
|
||||
fullyExecutedAt: now,
|
||||
lockedAt: booking.lockedAt ?? now,
|
||||
} as never);
|
||||
return this.bookingsService.findById(booking.id);
|
||||
}
|
||||
|
||||
await this.bookingsRepository.update(booking.id, {
|
||||
status: 'FULLY_EXECUTED',
|
||||
fullyExecutedAt: now,
|
||||
|
||||
@@ -79,6 +79,6 @@ import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.modu
|
||||
ContractRendererService,
|
||||
ContractPdfService,
|
||||
],
|
||||
exports: [BookingsService, BookingsRepository],
|
||||
exports: [BookingsService, BookingsRepository, BookingPricingService],
|
||||
})
|
||||
export class BookingsModule {}
|
||||
|
||||
@@ -463,6 +463,7 @@ export class BookingsService {
|
||||
containerTypeId:
|
||||
dto.freightType === 'CONTAINER' ? (r.containerTypeId ?? null) : null,
|
||||
quantity: r.quantity,
|
||||
km: r.km ?? null,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -77,6 +77,18 @@ export class CreateContractRouteDto {
|
||||
@Min(0)
|
||||
@Transform(({ value }) => Number(value))
|
||||
quantity!: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Road distance (km) for this route; used to bill road orders.',
|
||||
minimum: 0,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Transform(({ value }) =>
|
||||
value === undefined || value === null || value === '' ? undefined : Number(value),
|
||||
)
|
||||
km?: number;
|
||||
}
|
||||
|
||||
export class CreateBookingDto {
|
||||
|
||||
@@ -47,6 +47,9 @@ export const BOOKING_STATUSES = [
|
||||
'AWAITING_DOCUMENTS',
|
||||
'DOCUMENTS_UNDER_REVIEW',
|
||||
'CLEARANCE_READY',
|
||||
// Road (truck) drawdown orders skip the train batch pool and wait here for
|
||||
// truck dispatch after Marketing accepts; billed by KM, not wagons.
|
||||
'ROAD_DISPATCH_PENDING',
|
||||
'OPERATION_REQUESTED',
|
||||
// Operations review gate: customer picks a schedule day and submits the
|
||||
// operation request; the operations team reviews capacity/docs/route before
|
||||
@@ -288,6 +291,15 @@ export class Booking extends BaseEntity {
|
||||
@Column({ name: 'is_hazardous', type: 'boolean', default: false })
|
||||
isHazardous!: boolean;
|
||||
|
||||
/**
|
||||
* Refrigerated cargo flag. For one-time bookings reefer is derived from the
|
||||
* container type; for general-contract drawdown orders the customer enters a
|
||||
* reefer quantity per order, which sets this flag on the spawned child so the
|
||||
* REEFER_SURCHARGE rate applies even when the container type is not a reefer.
|
||||
*/
|
||||
@Column({ name: 'is_reefer', type: 'boolean', default: false })
|
||||
isReefer!: boolean;
|
||||
|
||||
@Column({ name: 'payment_currency', type: 'varchar', length: 5 })
|
||||
paymentCurrency!: string;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user