enhance contract review and editing experience

This commit is contained in:
Marshal
2026-06-30 15:16:27 +00:00
parent f51c015814
commit 708bd75d1f
31 changed files with 2044 additions and 693 deletions

View File

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

View File

@@ -66,6 +66,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(
@@ -505,6 +516,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,
@@ -527,6 +548,8 @@ export class BookingsService {
containerTypeId: c.containerTypeId,
quantity: c.quantity,
vgmPerUnitTons: c.vgmPerUnitTons,
hazardousQuantity: c.hazardousQuantity,
reeferQuantity: c.reeferQuantity,
weightResult: ruleResult.containerWeightResults[i],
})),
);
@@ -664,6 +687,8 @@ export class BookingsService {
containers,
);
const cargoAmount =
dto.cargoTotalWeightVgm ?? Number(existing.cargoTotalWeightVgm ?? 0);
const updates: Record<string, unknown> = {
...dto,
freightType,
@@ -674,6 +699,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,
};
@@ -720,6 +761,8 @@ export class BookingsService {
containerTypeId: c.containerTypeId,
quantity: c.quantity,
vgmPerUnitTons: c.vgmPerUnitTons,
hazardousQuantity: c.hazardousQuantity,
reeferQuantity: c.reeferQuantity,
weightResult: ruleResult.containerWeightResults[i],
})),
);

View File

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

View File

@@ -320,6 +320,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;