add items per wagon map to cargo types and sync bulk rate units

- Implemented  in the  to manage the physical item capacity for each wagon type.
- Added a new migration to create the  column in the  table.
- Introduced  method in  to update rate units when cargo type unit of measure changes.
- Updated booking calculations to consider items per wagon for break-bulk cargo.
- Refactored various components to utilize the new items fit logic and ensure consistent date formatting across the application.
- Added tests for the new display timezone functionality to ensure consistent date/time representation across different user settings.
This commit is contained in:
Marshal
2026-08-01 09:55:21 +00:00
parent a8788eb549
commit 0d8c63328a
28 changed files with 506 additions and 46 deletions

View File

@@ -1,6 +1,6 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { CargoUnitOfMeasure } from '@edr/types';
import { IsArray, IsBoolean, IsEnum, IsInt, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator';
import { IsArray, IsBoolean, IsEnum, IsInt, IsObject, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator';
export class CreateCargoTypeDto {
@ApiProperty({ description: 'Cargo type display name', maxLength: 255 })
@@ -32,6 +32,18 @@ export class CreateCargoTypeDto {
@IsUUID('4', { each: true })
wagonTypeIds?: string[];
@ApiPropertyOptional({
description:
'PER_ITEM cargo only: items that physically fit one wagon, keyed by wagon-type id ' +
'(e.g. { "<nw5-id>": 4, "<nw7-id>": 6 }). Required for every wagonTypeId when ' +
'unitOfMeasure is PER_ITEM.',
type: 'object',
additionalProperties: { type: 'integer', minimum: 1 },
})
@IsOptional()
@IsObject()
itemsPerWagonMap?: Record<string, number> | null;
@ApiPropertyOptional({ default: false })
@IsOptional()
@IsBoolean()

View File

@@ -50,6 +50,16 @@ export class CargoType extends BaseEntity {
})
wagonTypes?: WagonType[];
/**
* PER_ITEM (break-bulk) only: how many whole items physically fit each
* allowed wagon type, keyed by wagon-type id (e.g. cars → { NW5: 4, NW7: 6 }).
* Allocation loads min(this fit, floor(capacityTons / perItemTons)) per
* wagon — floor space and rated tonnage bind independently. Keys are kept a
* subset of the wagonTypes join rows by the cargo-types service.
*/
@Column({ name: 'items_per_wagon_map', type: 'jsonb', nullable: true })
itemsPerWagonMap?: Record<string, number> | null;
@Column({ name: 'requires_director_approval', type: 'boolean', default: false })
requiresDirectorApproval!: boolean;

View File

@@ -28,6 +28,13 @@ export interface IRatesRepository {
create(data: Partial<Rate>): Promise<Rate>;
update(id: string, data: Partial<Rate>): Promise<Rate | null>;
softDelete(id: string): Promise<void>;
/**
* Flip a commodity's PER_TON↔PER_ITEM rates to match its unit of measure.
* Both units bill the same stored quantity — only the name differs — so a
* uom change must rename the units or bookings keep quoting "per ton" for
* counted cargo. Returns the number of rates flipped.
*/
syncBulkQuantityUnit(cargoTypeId: string, unitOfMeasure: 'PER_TON' | 'PER_ITEM'): Promise<number>;
}
export const RATES_REPOSITORY = Symbol('RATES_REPOSITORY');

View File

@@ -177,4 +177,16 @@ export class RatesRepository implements IRatesRepository {
async softDelete(id: string): Promise<void> {
await this.repo.softDelete(id);
}
async syncBulkQuantityUnit(
cargoTypeId: string,
unitOfMeasure: 'PER_TON' | 'PER_ITEM',
): Promise<number> {
const from = unitOfMeasure === 'PER_ITEM' ? 'PER_TON' : 'PER_ITEM';
const result = await this.repo.update(
{ cargoTypeId, rateUnit: from },
{ rateUnit: unitOfMeasure },
);
return result.affected ?? 0;
}
}

View File

@@ -1,5 +1,11 @@
import { PaginatedResponse } from '@edr/types';
import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common';
import { CargoUnitOfMeasure, PaginatedResponse } from '@edr/types';
import {
BadRequestException,
ConflictException,
Inject,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { generateCode } from '../../../common/utils/generate-code.util';
import { CreateCargoTypeDto } from '../dto/create-cargo-type.dto';
import { ListCargoTypesQueryDto } from '../dto/list-rule-engine-query.dto';
@@ -11,6 +17,7 @@ import {
CARGO_TYPES_REPOSITORY,
ICargoTypesRepository,
} from '../interfaces/cargo-types.repository.interface';
import { IRatesRepository, RATES_REPOSITORY } from '../interfaces/rates.repository.interface';
import { DisplayOrderService } from './display-order.service';
@Injectable()
@@ -18,6 +25,8 @@ export class CargoTypesService {
constructor(
@Inject(CARGO_TYPES_REPOSITORY)
private readonly repository: ICargoTypesRepository,
@Inject(RATES_REPOSITORY)
private readonly ratesRepository: IRatesRepository,
private readonly displayOrder: DisplayOrderService,
) {}
@@ -38,6 +47,34 @@ export class CargoTypesService {
return this.repository.findByCode(code);
}
/**
* PER_ITEM (break-bulk) cargo must carry a whole-items-fit for EVERY allowed
* wagon type — allocation caps each wagon at min(fit, tonnage) and a missing
* fit would silently fall back to tonnage-only loading. Returns the map
* trimmed to the allowed ids (stale keys from a removed wagon type drop out);
* null when the cargo is not PER_ITEM or has no wagon types.
*/
private resolveItemsPerWagonMap(input: {
unitOfMeasure?: CargoUnitOfMeasure | null;
wagonTypeIds: string[];
itemsPerWagonMap?: Record<string, number> | null;
}): Record<string, number> | null {
if (input.unitOfMeasure !== CargoUnitOfMeasure.PerItem || !input.wagonTypeIds.length) {
return null;
}
const map: Record<string, number> = {};
for (const wagonTypeId of input.wagonTypeIds) {
const fit = Number(input.itemsPerWagonMap?.[wagonTypeId]);
if (!Number.isInteger(fit) || fit < 1) {
throw new BadRequestException(
`itemsPerWagonMap must define how many items fit wagon type ${wagonTypeId} (integer >= 1) for PER_ITEM cargo`,
);
}
map[wagonTypeId] = fit;
}
return map;
}
/** Create a new cargo type. */
async create(dto: CreateCargoTypeDto): Promise<CargoType> {
const code = generateCode(dto.cargoTypeName);
@@ -62,26 +99,58 @@ export class CargoTypesService {
unitOfMeasure: dto.unitOfMeasure ?? null,
// Join rows are written by the save (RESTRICT FK rejects unknown ids).
wagonTypes: (dto.wagonTypeIds ?? []).map((id) => ({ id }) as WagonType),
itemsPerWagonMap: this.resolveItemsPerWagonMap({
unitOfMeasure: dto.unitOfMeasure ?? null,
wagonTypeIds: dto.wagonTypeIds ?? [],
itemsPerWagonMap: dto.itemsPerWagonMap,
}),
displayOrder,
});
}
/** Update an existing cargo type. */
async update(id: string, dto: UpdateCargoTypeDto): Promise<CargoType> {
await this.findById(id);
const existing = await this.findById(id);
if (dto.parentGroupId) {
if (dto.parentGroupId === id) throw new ConflictException('A cargo type cannot be its own parent');
const parent = await this.repository.findById(dto.parentGroupId);
if (!parent) throw new NotFoundException(`Parent cargo type ${dto.parentGroupId} not found`);
}
const { wagonTypeIds, insertAfterId: _insertAfterId, ...columns } = dto;
const { wagonTypeIds, itemsPerWagonMap, insertAfterId: _insertAfterId, ...columns } = dto;
// Re-validate the fit map whenever anything it depends on moves — a partial
// update merges with the stored values so e.g. adding a wagon type without
// its fit still 400s. Untouched fields leave the stored map alone.
const touchesItemsFit =
wagonTypeIds !== undefined || itemsPerWagonMap !== undefined || dto.unitOfMeasure !== undefined;
const updated = await this.repository.update(id, {
...columns,
...(wagonTypeIds
? { wagonTypes: wagonTypeIds.map((wagonTypeId) => ({ id: wagonTypeId }) as WagonType) }
: {}),
...(touchesItemsFit
? {
itemsPerWagonMap: this.resolveItemsPerWagonMap({
unitOfMeasure:
dto.unitOfMeasure !== undefined ? dto.unitOfMeasure : existing.unitOfMeasure,
wagonTypeIds: wagonTypeIds ?? (existing.wagonTypes ?? []).map((wt) => wt.id),
itemsPerWagonMap:
itemsPerWagonMap !== undefined ? itemsPerWagonMap : existing.itemsPerWagonMap,
}),
}
: {}),
});
if (!updated) throw new NotFoundException(`Cargo type ${id} not found`);
// A uom flip renames how existing rates bill (PER_TON ↔ PER_ITEM name the
// same stored quantity) — sync them or bookings keep quoting "per ton" for
// counted cargo.
if (
dto.unitOfMeasure !== undefined &&
dto.unitOfMeasure !== existing.unitOfMeasure &&
(dto.unitOfMeasure === CargoUnitOfMeasure.PerTon ||
dto.unitOfMeasure === CargoUnitOfMeasure.PerItem)
) {
await this.ratesRepository.syncBulkQuantityUnit(id, dto.unitOfMeasure);
}
return updated;
}