fix issue

This commit is contained in:
Marshal
2026-08-22 00:49:53 +00:00
parent bad418d79e
commit b6c1efa043
22 changed files with 1448 additions and 88 deletions

View File

@@ -36,6 +36,7 @@ import {
import { BookingsRepository } from './bookings.repository';
import {
RebookCancelledWagonsDto,
RebookContainerLineDto,
RequestWagonCancellationDto,
} from './dto/wagon-cancellation.dto';
import { Booking } from './entities/booking.entity';
@@ -45,8 +46,11 @@ import {
BookingWagonCancellation,
CancelledQuantities,
CancelledUnitSnapshot,
WAGON_CANCEL_FEE_INVOICE_TYPE,
} from './entities/booking-wagon-cancellation.entity';
export { WAGON_CANCEL_FEE_INVOICE_TYPE };
/**
* rates.rate_type of the cancellation fee — an existing rate-engine type
* (trigger CANCELLATION, never auto-applied to booking pricing). Staff
@@ -59,8 +63,6 @@ export const WAGON_CANCELLATION_FEE_RATE_TYPE = 'CANCELLATION_FEE';
/** `booking_container.container_size` is stored as "20ft"/"40ft" — `Number()` on it is NaN. */
const sizeFtOf = (size: string | number | null | undefined): number =>
parseInt(String(size ?? ''), 10);
/** invoices.type of the fee invoice — the settlement branch key in BookingInvoiceService. */
export const WAGON_CANCEL_FEE_INVOICE_TYPE = 'WAGON_CANCEL_FEE';
const round2 = (n: number): number => Math.round(n * 100) / 100;
const round3 = (n: number): number => Math.round(n * 1000) / 1000;
@@ -767,7 +769,7 @@ export class BookingWagonCancellationService {
);
}
const createDto = this.buildRebookDto(row, dto.scheduledDate);
const createDto = this.buildRebookDto(row, dto.scheduledDate, dto.containers);
// Same currency as the source booking — the credit is in it.
createDto.paymentCurrency = source.paymentCurrency ?? undefined;
const created = await this.contractBooking.createUnderContract(
@@ -1460,11 +1462,25 @@ export class BookingWagonCancellationService {
private buildRebookDto(
row: BookingWagonCancellation,
scheduledDate: string,
overrides?: RebookContainerLineDto[],
): CreateBookingUnderContractDto {
const dto: CreateBookingUnderContractDto = { scheduledDate };
const q = row.cancelledQuantities;
if (q.bySize && Object.keys(q.bySize).length) {
// Unit overrides may rename containers, change seals and VGM — but the
// cancelled sizes and quantities are the contract of the credit: a size
// not on the credit, or a wrong unit count, is rejected.
const overrideBySize = new Map(
(overrides ?? []).map((o) => [o.containerSize, o.units]),
);
for (const size of overrideBySize.keys()) {
if (!(size in q.bySize)) {
throw new BadRequestException(
`The credit has no ${size} containers — sizes and quantities must match the cancelled booking.`,
);
}
}
const units = q.units ?? [];
dto.containers = Object.entries(q.bySize).map(([size, quantity]) => {
const sized = units.filter((u) => u.containerSize === size);
@@ -1473,13 +1489,23 @@ export class BookingWagonCancellationService {
`Credit is missing unit snapshots for size ${size} (${sized.length}/${quantity}) — contact EDR support.`,
);
}
const replacement = overrideBySize.get(size);
if (replacement && replacement.length !== quantity) {
throw new BadRequestException(
`The credit covers exactly ${quantity} × ${size} — you entered ${replacement.length}. Quantities cannot change on a rebook.`,
);
}
return {
containerSize: size,
quantity,
units: sized.map((u) => ({
containerNumber: u.containerNumber,
sealNumber: u.sealNumber ?? undefined,
vgmTons: u.vgmTons,
// Hazardous/reefer flags always ride from the snapshot (the cargo is
// the same cargo); number/seal/VGM come from the override when given.
units: sized.map((u, i) => ({
containerNumber: replacement?.[i]?.containerNumber ?? u.containerNumber,
sealNumber: replacement
? (replacement[i]?.sealNumber ?? undefined)
: (u.sealNumber ?? undefined),
vgmTons: replacement?.[i]?.vgmTons ?? u.vgmTons,
isHazardous: u.isHazardous,
isReefer: u.isReefer,
})),

View File

@@ -52,6 +52,7 @@ import {
FreightType,
} from './entities/booking.entity';
import { Booking } from './entities/booking.entity';
import { BookingWagonCancellation } from './entities/booking-wagon-cancellation.entity';
import { BookingContainerAllocation } from './entities/booking-container-allocation.entity';
import { FileRecord } from '../files/entities/file.entity';
import { CustomerTruckAssignmentDto } from './dto/customer-truck-assignment.dto';
@@ -2262,16 +2263,25 @@ export class BookingsService {
return this.findById(booking.id);
}
/** Upload documents for a DRAFT booking. */
/**
* Upload documents for a DRAFT booking — or for a booking created by
* rebooking a wagon-cancellation credit, whose paperwork may have changed
* with the new containers (old documents stay; new ones ride alongside).
*/
async uploadDocuments(
id: string,
files: Express.Multer.File[],
): Promise<Booking> {
const booking = await this.findById(id);
if (booking.status !== 'DRAFT') {
throw new BadRequestException(
'Documents can only be uploaded for DRAFT bookings',
);
const rebooked = await this.dataSource
.getRepository(BookingWagonCancellation)
.findOne({ where: { rebookedBookingId: id } });
if (!rebooked) {
throw new BadRequestException(
'Documents can only be uploaded for DRAFT bookings',
);
}
}
await this.filesService.uploadMany(id, 'bookings', files);
return this.findById(id);

View File

@@ -67,10 +67,60 @@ export class RequestWagonCancellationDto {
reason?: string;
}
export class RebookUnitDto {
@ApiProperty({ description: 'Container number for the rebooked unit' })
@IsString()
@MaxLength(64)
containerNumber!: string;
@ApiPropertyOptional({ description: 'Seal number' })
@IsOptional()
@IsString()
@MaxLength(64)
sealNumber?: string;
@ApiPropertyOptional({ description: 'VGM (tons) of the unit' })
@IsOptional()
@IsNumber()
@Min(0)
vgmTons?: number;
}
export class RebookContainerLineDto {
@ApiProperty({ description: 'Container size as stored on the credit, e.g. "20ft"' })
@IsString()
containerSize!: string;
@ApiProperty({
description:
'The rebooked units for this size — count MUST equal the cancelled quantity',
type: [RebookUnitDto],
})
@IsArray()
@ArrayNotEmpty()
@ValidateNested({ each: true })
@Type(() => RebookUnitDto)
units!: RebookUnitDto[];
}
export class RebookCancelledWagonsDto {
@ApiProperty({ description: 'Shipment day the credit is rebooked onto (ISO date)' })
@IsDateString()
scheduledDate!: string;
@ApiPropertyOptional({
description:
'Optional unit overrides: container number / seal / VGM may change, but ' +
'sizes and quantities must match the cancelled booking exactly. Sizes ' +
'omitted here keep their original units.',
type: [RebookContainerLineDto],
})
@IsOptional()
@IsArray()
@ArrayNotEmpty()
@ValidateNested({ each: true })
@Type(() => RebookContainerLineDto)
containers?: RebookContainerLineDto[];
}
export class FilterWagonCancellationsDto {

View File

@@ -5,6 +5,9 @@ import { Invoice } from '../../billing/entities/invoice.entity';
import { Rate } from '../../rule-engine/entities/rate.entity';
import { Booking } from './booking.entity';
/** `invoices.type` of the wagon-cancellation fee invoice — the settlement branch key in BookingInvoiceService. */
export const WAGON_CANCEL_FEE_INVOICE_TYPE = 'WAGON_CANCEL_FEE';
export const WAGON_CANCELLATION_STATUSES = [
// Requested; fee invoice open; wagons still allocated to the customer.
'FEE_PENDING',