feat(bookings): add contract validity window and customs clearing features

- Introduced contract validity days, valid from and valid until fields in the booking model.
- Updated booking acceptance logic to enforce validity window constraints.
- Added customs clearing agent and first/last mile pickup/delivery coordinates to the booking model.
- Implemented LocationPicker component for address selection with map integration using Leaflet.
- Enhanced booking review step to display customs clearing agent details.
- Updated API and DTOs to accommodate new booking fields.
- Added migration scripts for database schema changes.
- Implemented tests for booking acceptance and DTO transformations.
This commit is contained in:
Marshal
2026-06-24 00:21:01 +00:00
parent 07cd7dc111
commit 8ef7641048
31 changed files with 1251 additions and 226 deletions

View File

@@ -0,0 +1,84 @@
import { BadRequestException } from '@nestjs/common';
import { BookingTransitionService } from './booking-transition.service';
/**
* Focused tests for the contract validity window set at the accept step.
* The backoffice must supply a number of days; the window runs from the accept
* moment through accept + N days.
*/
describe('BookingTransitionService — acceptIntake validity window', () => {
const booking = {
id: 'b-1',
status: 'SUBMITTED',
freightType: 'CONTAINER',
cargoTypeId: null,
};
function makeService() {
const bookingsRepository = {
update: jest.fn().mockResolvedValue({ id: 'b-1' }),
};
const bookingsService = {
findById: jest.fn().mockResolvedValue(booking),
};
const ruleEngineService = {
instantiateApprovalSteps: jest.fn().mockResolvedValue([]),
};
const service = new BookingTransitionService(
bookingsRepository as never,
ruleEngineService as never,
{} as never, // pricingService
{} as never, // contractService
{} as never, // filesService
{} as never, // fileUploadSettingsService
bookingsService as never,
);
return { service, bookingsRepository, ruleEngineService };
}
it('rejects accept when validity days is missing or non-positive', async () => {
const { service } = makeService();
await expect(
service.acceptIntake('b-1', 'staff-1', 0),
).rejects.toBeInstanceOf(BadRequestException);
await expect(
service.acceptIntake('b-1', 'staff-1', -5),
).rejects.toBeInstanceOf(BadRequestException);
await expect(
service.acceptIntake('b-1', 'staff-1', 1.5),
).rejects.toBeInstanceOf(BadRequestException);
});
it('sets a validity window of validFrom..validFrom + N days', async () => {
const { service, bookingsRepository } = makeService();
await service.acceptIntake('b-1', 'staff-1', 10);
expect(bookingsRepository.update).toHaveBeenCalledTimes(1);
const [id, updates] = bookingsRepository.update.mock.calls[0];
expect(id).toBe('b-1');
expect(updates).toMatchObject({
status: 'PENDING_APPROVAL',
approvedByStaffId: 'staff-1',
contractValidityDays: 10,
});
const from = updates.contractValidFrom as Date;
const until = updates.contractValidUntil as Date;
const diffDays = Math.round(
(until.getTime() - from.getTime()) / (1000 * 60 * 60 * 24),
);
expect(diffDays).toBe(10);
// The accept timestamp and the validity start are the same moment.
expect((updates.approvedByStaffAt as Date).getTime()).toBe(from.getTime());
});
it('instantiates the approval chain when accepting', async () => {
const { service, ruleEngineService } = makeService();
await service.acceptIntake('b-1', 'staff-1', 30);
expect(ruleEngineService.instantiateApprovalSteps).toHaveBeenCalledWith(
'b-1',
expect.objectContaining({ freightType: 'CONTAINER' }),
);
});
});

View File

@@ -198,13 +198,31 @@ export class BookingTransitionService {
});
}
async acceptIntake(bookingId: string, actorId: string): Promise<Booking> {
async acceptIntake(
bookingId: string,
actorId: string,
validityDays: number,
): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
// 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']);
// 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.',
);
}
// Validity runs from the accept moment through accept + N days.
const validFrom = new Date();
const validUntil = new Date(validFrom);
validUntil.setDate(validUntil.getDate() + validityDays);
await this.ruleEngineService.instantiateApprovalSteps(bookingId, {
freightType: booking.freightType as 'CONTAINER' | 'BULK',
cargoTypeId: booking.cargoTypeId,
@@ -213,7 +231,10 @@ export class BookingTransitionService {
const updated = await this.bookingsRepository.update(bookingId, {
status: 'PENDING_APPROVAL',
approvedByStaffId: actorId,
approvedByStaffAt: new Date(),
approvedByStaffAt: validFrom,
contractValidityDays: validityDays,
contractValidFrom: validFrom,
contractValidUntil: validUntil,
} as never);
return this.bookingsService.findById(updated!.id);
}

View File

@@ -42,6 +42,7 @@ import { FilterBookingDto } from './dto/filter-booking.dto';
import { GeneratePriceResponseDto } from './dto/generate-price-response.dto';
import { SubmitBookingResponseDto } from './dto/submit-booking-response.dto';
import {
AcceptIntakeDto,
AdjustPriceDto,
ApproveStepDto,
CancelBookingDto,
@@ -428,14 +429,19 @@ export class BookingsController {
@Post(':id/staff/accept')
@BookingStaff(FREIGHT_PERMS.bookings.staffAccept)
@ApiOperation({ summary: 'Staff accept intake → start approval chain' })
@ApiOperation({
summary:
'Staff accept intake → set contract validity window + start approval chain',
})
async acceptIntake(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: AcceptIntakeDto,
@CurrentUser() user: AuthUserPayload,
) {
const booking = await this.transitionService.acceptIntake(
id,
resolveAuthUserId(user),
dto.validityDays,
);
return this.transitionService.enrichBookingResponse(booking);
}

View File

@@ -406,7 +406,13 @@ export class BookingsService {
previousContractId: dto.previousContractId,
serviceTypeId: dto.serviceTypeId,
firstMilePickupAddress: dto.firstMilePickupAddress,
firstMilePickupLat: dto.firstMilePickupLat ?? null,
firstMilePickupLng: dto.firstMilePickupLng ?? null,
lastMileDeliveryAddress: dto.lastMileDeliveryAddress,
lastMileDeliveryLat: dto.lastMileDeliveryLat ?? null,
lastMileDeliveryLng: dto.lastMileDeliveryLng ?? null,
customsClearingEnabled: dto.customsClearingEnabled ?? false,
customsClearingAgent: dto.customsClearingAgent ?? null,
equipmentReturn: dto.equipmentReturn,
originYardId: dto.originYardId,
destinationYardId: dto.destinationYardId,

View File

@@ -0,0 +1,47 @@
import 'reflect-metadata';
import { plainToInstance } from 'class-transformer';
import { CreateBookingDto } from './create-booking.dto';
/**
* Boolean flags arrive as STRINGS over multipart/form-data ("true" / "false").
* The global freight ValidationPipe runs with enableImplicitConversion = false,
* so only the explicit @Transform on each flag coerces it. This pins that the
* literal string "false" maps to boolean `false` — class-transformer's implicit
* boolean coercion would otherwise turn any non-empty string (including "false")
* into `true`, silently flagging non-hazardous bookings as hazardous.
*/
describe('CreateBookingDto — boolean coercion from multipart strings', () => {
// Mirror the production pipe: explicit transforms only, no implicit coercion.
const toDto = (plain: Record<string, unknown>) =>
plainToInstance(CreateBookingDto, plain, {
enableImplicitConversion: false,
}) as unknown as CreateBookingDto;
it('maps the string "false" to boolean false for every flag', () => {
const dto = toDto({
isHazardous: 'false',
isGovernment: 'false',
customsClearingEnabled: 'false',
});
expect(dto.isHazardous).toBe(false);
expect(dto.isGovernment).toBe(false);
expect(dto.customsClearingEnabled).toBe(false);
});
it('maps the string "true" to boolean true for every flag', () => {
const dto = toDto({
isHazardous: 'true',
isGovernment: 'true',
customsClearingEnabled: 'true',
});
expect(dto.isHazardous).toBe(true);
expect(dto.isGovernment).toBe(true);
expect(dto.customsClearingEnabled).toBe(true);
});
it('still coerces numeric form strings to numbers', () => {
const dto = toDto({ cargoTotalWeightVgm: '12.5' });
expect(dto.cargoTotalWeightVgm).toBe(12.5);
expect(typeof dto.cargoTotalWeightVgm).toBe('number');
});
});

View File

@@ -11,6 +11,8 @@ import {
IsOptional,
IsString,
IsUUID,
Max,
MaxLength,
Min,
MinLength,
Validate,
@@ -168,11 +170,55 @@ export class CreateBookingDto {
@IsString()
firstMilePickupAddress?: string;
@ApiPropertyOptional({ description: 'First-mile pickup latitude (-90..90)' })
@IsOptional()
@IsNumber()
@Min(-90)
@Max(90)
@Transform(({ value }) => (value == null || value === '' ? undefined : Number(value)))
firstMilePickupLat?: number;
@ApiPropertyOptional({ description: 'First-mile pickup longitude (-180..180)' })
@IsOptional()
@IsNumber()
@Min(-180)
@Max(180)
@Transform(({ value }) => (value == null || value === '' ? undefined : Number(value)))
firstMilePickupLng?: number;
@ApiPropertyOptional()
@IsOptional()
@IsString()
lastMileDeliveryAddress?: string;
@ApiPropertyOptional({ description: 'Last-mile delivery latitude (-90..90)' })
@IsOptional()
@IsNumber()
@Min(-90)
@Max(90)
@Transform(({ value }) => (value == null || value === '' ? undefined : Number(value)))
lastMileDeliveryLat?: number;
@ApiPropertyOptional({ description: 'Last-mile delivery longitude (-180..180)' })
@IsOptional()
@IsNumber()
@Min(-180)
@Max(180)
@Transform(({ value }) => (value == null || value === '' ? undefined : Number(value)))
lastMileDeliveryLng?: number;
@ApiPropertyOptional({ description: 'Whether EDR handles customs clearance' })
@IsOptional()
@IsBoolean()
@Transform(({ value }) => value === 'true' || value === true)
customsClearingEnabled?: boolean;
@ApiPropertyOptional({ maxLength: 200, description: 'Customs clearing agent name (when customs is enabled)' })
@IsOptional()
@IsString()
@MaxLength(200)
customsClearingAgent?: string;
@ApiProperty({ enum: EQUIPMENT_RETURNS })
@IsIn([...EQUIPMENT_RETURNS])
equipmentReturn!: string;

View File

@@ -1,5 +1,14 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsIn, IsNumber, IsOptional, IsString, Min, MinLength } from 'class-validator';
import {
IsIn,
IsInt,
IsNumber,
IsOptional,
IsString,
Max,
Min,
MinLength,
} from 'class-validator';
export class RequestChangesDto {
@ApiProperty({ description: 'Staff note explaining what the customer must fix' })
@@ -8,6 +17,21 @@ export class RequestChangesDto {
note!: string;
}
export class AcceptIntakeDto {
@ApiProperty({
description:
'How many days the contract stays valid, counted from the accept date. ' +
'The contract is valid from now through now + validityDays.',
minimum: 1,
maximum: 365,
example: 30,
})
@IsInt()
@Min(1)
@Max(365)
validityDays!: number;
}
export class StaffRejectDto {
@ApiProperty()
@IsString()

View File

@@ -177,6 +177,21 @@ export class Booking extends BaseEntity {
@Column({ name: 'adjustment_reason', type: 'text', nullable: true })
adjustmentReason?: string | null;
/**
* Contract validity window, set by the backoffice at the accept step. The
* staff enter a number of days; the contract is valid from contractValidFrom
* (the accept moment) through contractValidUntil (validFrom + N days). Outside
* this window the contract is expired and the booking cannot proceed.
*/
@Column({ name: 'contract_validity_days', type: 'int', nullable: true })
contractValidityDays?: number | null;
@Column({ name: 'contract_valid_from', type: 'timestamptz', nullable: true })
contractValidFrom?: Date | null;
@Column({ name: 'contract_valid_until', type: 'timestamptz', nullable: true })
contractValidUntil?: Date | null;
@Column({ name: 'payment_status', type: 'varchar', length: 20, default: 'PENDING' })
paymentStatus!: string;
@@ -200,9 +215,27 @@ export class Booking extends BaseEntity {
@Column({ name: 'first_mile_pickup_address', type: 'text', nullable: true })
firstMilePickupAddress?: string | null;
@Column({ name: 'first_mile_pickup_lat', type: 'numeric', precision: 10, scale: 7, nullable: true })
firstMilePickupLat?: number | null;
@Column({ name: 'first_mile_pickup_lng', type: 'numeric', precision: 10, scale: 7, nullable: true })
firstMilePickupLng?: number | null;
@Column({ name: 'last_mile_delivery_address', type: 'text', nullable: true })
lastMileDeliveryAddress?: string | null;
@Column({ name: 'last_mile_delivery_lat', type: 'numeric', precision: 10, scale: 7, nullable: true })
lastMileDeliveryLat?: number | null;
@Column({ name: 'last_mile_delivery_lng', type: 'numeric', precision: 10, scale: 7, nullable: true })
lastMileDeliveryLng?: number | null;
@Column({ name: 'customs_clearing_enabled', type: 'boolean', default: false })
customsClearingEnabled!: boolean;
@Column({ name: 'customs_clearing_agent', type: 'varchar', length: 200, nullable: true })
customsClearingAgent?: string | null;
@Column({ name: 'equipment_return', type: 'varchar', length: 20 })
equipmentReturn!: string;