mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
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:
@@ -0,0 +1,24 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Break-bulk (PER_ITEM) cargo needs a physical items-fit per allowed wagon
|
||||
* type (e.g. cars → NW5: 4, NW7: 6): a wagon runs out of floor space before it
|
||||
* runs out of rated tonnage, so allocation must respect BOTH limits. Stored as
|
||||
* a jsonb map { [wagonTypeId]: itemsFit } on cargo_types — keys mirror the
|
||||
* cargo_type_wagon_types join rows, kept in sync by the cargo-types service.
|
||||
*/
|
||||
export class AddCargoTypeItemsPerWagonMap3120000000000 implements MigrationInterface {
|
||||
name = 'AddCargoTypeItemsPerWagonMap3120000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "freight"."cargo_types" ADD COLUMN IF NOT EXISTS "items_per_wagon_map" jsonb`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "freight"."cargo_types" DROP COLUMN IF EXISTS "items_per_wagon_map"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Rates created before their commodity's unit_of_measure was flipped kept the
|
||||
* old bulk-quantity unit, so bookings of a PER_ITEM commodity (e.g. Machinery)
|
||||
* quoted "per ton". PER_TON and PER_ITEM bill the same stored quantity — only
|
||||
* the name differs — so renaming is safe. Going forward the cargo-types
|
||||
* service syncs rates on every uom change; this backfills the drift.
|
||||
*/
|
||||
export class SyncBulkRateUnitsToCargoUom3140000000000 implements MigrationInterface {
|
||||
name = 'SyncBulkRateUnitsToCargoUom3140000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`UPDATE "freight"."rates" r
|
||||
SET "rate_unit" = 'PER_ITEM'
|
||||
FROM "freight"."cargo_types" ct
|
||||
WHERE ct."id" = r."cargo_type_id"
|
||||
AND ct."unit_of_measure" = 'PER_ITEM'
|
||||
AND r."rate_unit" = 'PER_TON'`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`UPDATE "freight"."rates" r
|
||||
SET "rate_unit" = 'PER_TON'
|
||||
FROM "freight"."cargo_types" ct
|
||||
WHERE ct."id" = r."cargo_type_id"
|
||||
AND ct."unit_of_measure" = 'PER_TON'
|
||||
AND r."rate_unit" = 'PER_ITEM'`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(): Promise<void> {
|
||||
// Irreversible rename-by-join: the pre-sync unit is not recorded. Both
|
||||
// units bill identically, so rolling back the code needs no data change.
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
containersPerWagonForSize,
|
||||
wagonsPerUnitForSize,
|
||||
} from '../rule-engine/container-type.util';
|
||||
import { bulkItemWagonsRequired } from '../train-scheduling/train-capacity.util';
|
||||
import { bulkItemWagonsForAllowedTypes } from '../train-scheduling/train-capacity.util';
|
||||
import { BookingsRepository } from './bookings.repository';
|
||||
import { wagonRemainder } from './consolidation.service';
|
||||
import { GeneratePriceResponseDto, PriceLineItemDto } from './dto/generate-price-response.dto';
|
||||
@@ -1188,8 +1188,9 @@ export class BookingPricingService {
|
||||
);
|
||||
if (!(capacity > 0)) return null;
|
||||
// Break-bulk (PER_ITEM): `tons` above is the item count; size by
|
||||
// indivisible items instead of pretending the count is tonnage.
|
||||
const byItems = bulkItemWagonsRequired(booking, capacity);
|
||||
// indivisible items instead of pretending the count is tonnage. Best
|
||||
// count across allowed wagon types, each capped by its items-fit.
|
||||
const byItems = bulkItemWagonsForAllowedTypes(booking, cargo, capacity);
|
||||
if (byItems > 0) return byItems;
|
||||
return Math.max(1, Math.ceil(tons / capacity));
|
||||
} catch {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -69,6 +69,7 @@ import {
|
||||
LocomotiveLimits,
|
||||
WagonTypeDimensions,
|
||||
bookingCargoTons,
|
||||
bulkItemsFitFor,
|
||||
bulkItemWagonsRequired,
|
||||
bookingGrossWeightTons,
|
||||
deriveTrainCapacityFromLocomotive,
|
||||
@@ -4060,7 +4061,13 @@ export class BookingBatchService implements OnModuleInit {
|
||||
|
||||
// Break-bulk (PER_ITEM): indivisible items can need more wagons than raw
|
||||
// tonnage suggests (floor items-per-wagon loses the fractional capacity).
|
||||
const byItems = bulkItemWagonsRequired(booking, capacityTons);
|
||||
// `dimsFor` resolved dims from the first allowed wagon type, so charge that
|
||||
// same type's configured items-fit alongside its capacity.
|
||||
const byItems = bulkItemWagonsRequired(
|
||||
booking,
|
||||
capacityTons,
|
||||
bulkItemsFitFor(booking.cargoType, booking.cargoType?.wagonTypes?.[0]?.id),
|
||||
);
|
||||
|
||||
return Math.max(DEFAULT_WAGONS_PER_BOOKING, stored, byLength, byWeight, byItems);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { bookingCargoTons, bulkItemWagonsRequired } from './train-capacity.util';
|
||||
import { bookingCargoTons, bulkItemWagonsForAllowedTypes } from './train-capacity.util';
|
||||
import type { Booking } from '../bookings/entities/booking.entity';
|
||||
import type { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
||||
import {
|
||||
@@ -53,8 +53,10 @@ export function wagonsRequiredForBooking(booking: Booking, bulkWagonCapacity?: n
|
||||
if (booking.freightType === 'BULK') {
|
||||
const capacity = bulkWagonCapacity && bulkWagonCapacity > 0 ? bulkWagonCapacity : 1;
|
||||
// Break-bulk (PER_ITEM) sizes by indivisible items; `cargoTotalWeightVgm`
|
||||
// holds the item count there, not tons.
|
||||
const byItems = bulkItemWagonsRequired(booking, capacity);
|
||||
// holds the item count there, not tons. No wagon type is fixed yet, so use
|
||||
// the best count across the cargo's allowed types (per-type items-fit
|
||||
// respected); falls back to `capacity` when the relation isn't loaded.
|
||||
const byItems = bulkItemWagonsForAllowedTypes(booking, booking.cargoType, capacity);
|
||||
if (byItems > 0) return byItems;
|
||||
const weight = Number(booking.cargoTotalWeightVgm ?? 0);
|
||||
return Math.max(1, Math.ceil(weight / capacity));
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
bookingCargoTons,
|
||||
bookingGrossWeightTons,
|
||||
bookingTrainLengthMeters,
|
||||
bulkItemWagonsForAllowedTypes,
|
||||
bulkItemWagonsRequired,
|
||||
consistUsage,
|
||||
consistViolations,
|
||||
@@ -76,6 +77,62 @@ describe('train-capacity.util', () => {
|
||||
expect(bulkItemWagonsRequired(breakBulk(0, 800), 69)).toBe(0);
|
||||
expect(bulkItemWagonsRequired(breakBulk(400, 0), 69)).toBe(0);
|
||||
});
|
||||
|
||||
describe('configured items-fit (floor space vs tonnage)', () => {
|
||||
it('weight binds: 50 cars × 20T on a 70T wagon that fits 4 → 3 per wagon → 17', () => {
|
||||
// floor(70/20) = 3 by tonnage < 4 by floor space.
|
||||
expect(bulkItemWagonsRequired(breakBulk(50, 1000), 70, 4)).toBe(17);
|
||||
});
|
||||
|
||||
it('floor space binds: 50 cars × 10T on a 70T wagon that fits 4 → 4 per wagon → 13', () => {
|
||||
// floor(70/10) = 7 by tonnage, but only 4 fit physically.
|
||||
expect(bulkItemWagonsRequired(breakBulk(50, 500), 70, 4)).toBe(13);
|
||||
});
|
||||
|
||||
it('ignores an absent/invalid fit (legacy cargo types): tonnage-only', () => {
|
||||
expect(bulkItemWagonsRequired(breakBulk(50, 1000), 70, null)).toBe(17);
|
||||
expect(bulkItemWagonsRequired(breakBulk(50, 1000), 70, 0)).toBe(17);
|
||||
// floor(70/10) = 7 per wagon → ceil(50/7) = 8 wagons.
|
||||
expect(bulkItemWagonsRequired(breakBulk(50, 500), 70)).toBe(8);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('bulkItemWagonsForAllowedTypes', () => {
|
||||
const breakBulk = (quantity: number, weightTons: number) => ({
|
||||
freightType: 'BULK',
|
||||
cargoTotalWeightVgm: quantity,
|
||||
bulkTotalWeightTons: weightTons,
|
||||
});
|
||||
|
||||
it('picks the fewest-wagon allowed type, each capped by its own fit', () => {
|
||||
const cargoType = {
|
||||
wagonTypes: [
|
||||
{ id: 'nw5', capacityTons: 70 },
|
||||
{ id: 'nw7', capacityTons: 80 },
|
||||
],
|
||||
itemsPerWagonMap: { nw5: 4, nw7: 6 },
|
||||
};
|
||||
// 50 cars × 20T: NW5 → min(4, floor(70/20)=3) = 3/wagon = 17 wagons;
|
||||
// NW7 → min(6, floor(80/20)=4) = 4/wagon = 13 wagons. Best = 13.
|
||||
expect(bulkItemWagonsForAllowedTypes(breakBulk(50, 1000), cargoType, 70)).toBe(13);
|
||||
});
|
||||
|
||||
it('equals the old max-capacity estimate when no fits are configured', () => {
|
||||
const cargoType = {
|
||||
wagonTypes: [
|
||||
{ id: 'a', capacityTons: 50 },
|
||||
{ id: 'b', capacityTons: 70 },
|
||||
],
|
||||
};
|
||||
// Tonnage-only best = biggest wagon: floor(70/20) = 3/wagon → 17.
|
||||
expect(bulkItemWagonsForAllowedTypes(breakBulk(50, 1000), cargoType, 1)).toBe(17);
|
||||
});
|
||||
|
||||
it('falls back to the given capacity when the relation is missing', () => {
|
||||
expect(bulkItemWagonsForAllowedTypes(breakBulk(50, 1000), null, 70)).toBe(17);
|
||||
expect(bulkItemWagonsForAllowedTypes(breakBulk(50, 1000), { wagonTypes: [] }, 70)).toBe(17);
|
||||
});
|
||||
});
|
||||
|
||||
describe('bookingCargoTons (break-bulk weight preference)', () => {
|
||||
|
||||
@@ -120,6 +120,12 @@ export function bookingCargoTons(booking: {
|
||||
* 400 items / 800T on 69T wagons → 2T per item → 34 items per wagon → 12 wagons.
|
||||
* Returns 0 when the booking is not item-counted (PER_TON bulk, containers) —
|
||||
* callers then fall back to the pooled-tonnage math.
|
||||
*
|
||||
* `itemsFit` is the wagon type's PHYSICAL item capacity (floor space — from
|
||||
* cargoType.itemsPerWagonMap). It binds independently of tonnage: a 70T wagon
|
||||
* that fits 4 cars takes 3 cars of 20T (weight binds) but only 4 cars of 10T
|
||||
* (floor binds, 30T of rated capacity ride empty). Absent/invalid fit falls
|
||||
* back to tonnage-only (legacy cargo types without a configured fit).
|
||||
*/
|
||||
export function bulkItemWagonsRequired(
|
||||
booking: {
|
||||
@@ -128,6 +134,7 @@ export function bulkItemWagonsRequired(
|
||||
bulkTotalWeightTons?: number | string | null;
|
||||
},
|
||||
capacityTons: number,
|
||||
itemsFit?: number | null,
|
||||
): number {
|
||||
if (booking.freightType !== 'BULK' || !(capacityTons > 0)) return 0;
|
||||
const quantity = num(booking.cargoTotalWeightVgm);
|
||||
@@ -136,10 +143,56 @@ export function bulkItemWagonsRequired(
|
||||
const perItemTons = totalWeightTons / quantity;
|
||||
// ponytail: an item heavier than a whole wagon still charges 1 wagon per
|
||||
// item; reject such bookings at creation time if the case turns real.
|
||||
const itemsPerWagon = Math.max(1, Math.floor(capacityTons / perItemTons));
|
||||
const byTonnage = Math.max(1, Math.floor(capacityTons / perItemTons));
|
||||
const byFloor = num(itemsFit) >= 1 ? Math.floor(num(itemsFit)) : Infinity;
|
||||
const itemsPerWagon = Math.min(byTonnage, byFloor);
|
||||
return Math.max(1, Math.ceil(quantity / itemsPerWagon));
|
||||
}
|
||||
|
||||
type ItemFitCargoType = {
|
||||
wagonTypes?: Array<{ id: string; capacityTons?: number | string | null }> | null;
|
||||
itemsPerWagonMap?: Record<string, number> | null;
|
||||
} | null;
|
||||
|
||||
/** Configured whole-items fit of one wagon type for a cargo type; null if unset. */
|
||||
export function bulkItemsFitFor(
|
||||
cargoType: ItemFitCargoType | undefined,
|
||||
wagonTypeId: string | null | undefined,
|
||||
): number | null {
|
||||
const fit = wagonTypeId ? Number(cargoType?.itemsPerWagonMap?.[wagonTypeId]) : NaN;
|
||||
return Number.isFinite(fit) && fit >= 1 ? fit : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Break-bulk wagon count when no single wagon type is fixed yet: the best
|
||||
* (fewest-wagon) count across the cargo type's allowed wagon types, each
|
||||
* respecting its own items-fit. With no fits configured this equals the old
|
||||
* max-capacity estimate; with no allowed types it degrades to
|
||||
* `fallbackCapacityTons` tonnage-only.
|
||||
*/
|
||||
export function bulkItemWagonsForAllowedTypes(
|
||||
booking: {
|
||||
freightType?: string | null;
|
||||
cargoTotalWeightVgm?: number | string | null;
|
||||
bulkTotalWeightTons?: number | string | null;
|
||||
},
|
||||
cargoType: ItemFitCargoType | undefined,
|
||||
fallbackCapacityTons: number,
|
||||
): number {
|
||||
const allowed = (cargoType?.wagonTypes ?? []).filter((wt) => num(wt.capacityTons) > 0);
|
||||
if (!allowed.length) return bulkItemWagonsRequired(booking, fallbackCapacityTons);
|
||||
let best = 0;
|
||||
for (const wagonType of allowed) {
|
||||
const wagons = bulkItemWagonsRequired(
|
||||
booking,
|
||||
num(wagonType.capacityTons),
|
||||
bulkItemsFitFor(cargoType, wagonType.id),
|
||||
);
|
||||
if (wagons > 0 && (best === 0 || wagons < best)) best = wagons;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/** Gross weight of one loaded wagon: it hauls itself plus its cargo. */
|
||||
export function grossWagonWeightTons(slot: Pick<ConsistSlot, 'tareWeightTons' | 'cargoTons'>): number {
|
||||
return num(slot.tareWeightTons) + num(slot.cargoTons);
|
||||
|
||||
@@ -3,7 +3,12 @@ import { AllocationLoadType } from '@edr/types';
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
import { containersPerWagonForSize, wagonsPerUnitForSize } from '../rule-engine/container-type.util';
|
||||
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
||||
import { bookingCargoTons, bulkItemWagonsRequired, consistViolations } from './train-capacity.util';
|
||||
import {
|
||||
bookingCargoTons,
|
||||
bulkItemsFitFor,
|
||||
bulkItemWagonsRequired,
|
||||
consistViolations,
|
||||
} from './train-capacity.util';
|
||||
|
||||
export const MAX_TRAIN_WEIGHT_TONS = 3500;
|
||||
export const MAX_TRAIN_LENGTH_METERS = 760;
|
||||
@@ -175,7 +180,11 @@ export function buildBulkWagonPlan(
|
||||
// Break-bulk (PER_ITEM) bookings size by indivisible items per booking —
|
||||
// their tonnage must NOT pool with PER_TON cargo (an item can't split
|
||||
// across wagons the way loose tonnage can).
|
||||
const itemSlotsByBooking = bookings.map((b) => bulkItemWagonsRequired(b, capacity));
|
||||
const itemSlotsByBooking = bookings.map((b) =>
|
||||
// The plan fixed THIS wagon type, so its configured items-fit binds — not
|
||||
// the best fit across the cargo's allowed types.
|
||||
bulkItemWagonsRequired(b, capacity, bulkItemsFitFor(b.cargoType, wagonType.id)),
|
||||
);
|
||||
const itemSlots = itemSlotsByBooking.reduce((sum, n) => sum + n, 0);
|
||||
const totalWeight = roundTons(
|
||||
bookings.reduce(
|
||||
|
||||
@@ -29,7 +29,6 @@ import {
|
||||
Upload,
|
||||
UserCheck,
|
||||
} from "lucide-react";
|
||||
import dayjs from "dayjs";
|
||||
import toast from "react-hot-toast";
|
||||
import type { Freight } from "@edr/types";
|
||||
import { isViewable } from "@edr/ui-common";
|
||||
@@ -299,7 +298,14 @@ function DocumentRow({
|
||||
<Text size="xs" c="dimmed" mt={4} truncate>
|
||||
{doc.file.name} · {formatBytes(doc.file.size)} ·{" "}
|
||||
{doc.uploadedByName ?? "Global Logistics"} ·{" "}
|
||||
{dayjs(doc.uploadedAt).format("D MMM YYYY, HH:mm")}
|
||||
{new Date(doc.uploadedAt).toLocaleString("en-GB", {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
})}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import "@edr/ui-common/display-timezone";
|
||||
|
||||
// The pin must hold on ANY machine timezone — these assertions are the bug:
|
||||
// before the patch they only passed on a PC already set to UTC+3.
|
||||
describe("display-timezone pin (EAT, UTC+3)", () => {
|
||||
const utcMidnight = new Date("2026-01-01T00:00:00Z");
|
||||
|
||||
it("formats Date.toLocale* in EAT regardless of machine timezone", () => {
|
||||
expect(utcMidnight.toLocaleTimeString("en-GB", { hour12: false })).toBe(
|
||||
"03:00:00",
|
||||
);
|
||||
// 22:00 UTC is already the NEXT day in EAT.
|
||||
expect(new Date("2026-01-01T22:00:00Z").toLocaleDateString("en-CA")).toBe(
|
||||
"2026-01-02",
|
||||
);
|
||||
});
|
||||
|
||||
it("formats Intl.DateTimeFormat in EAT and keeps instanceof/statics", () => {
|
||||
const fmt = new Intl.DateTimeFormat("en-GB", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
});
|
||||
expect(fmt.format(utcMidnight)).toBe("03:00");
|
||||
expect(fmt).toBeInstanceOf(Intl.DateTimeFormat);
|
||||
expect(Intl.DateTimeFormat.supportedLocalesOf(["en-GB"])).toContain(
|
||||
"en-GB",
|
||||
);
|
||||
});
|
||||
|
||||
it("respects an explicit timeZone option", () => {
|
||||
expect(
|
||||
utcMidnight.toLocaleTimeString("en-GB", {
|
||||
hour12: false,
|
||||
timeZone: "UTC",
|
||||
}),
|
||||
).toBe("00:00:00");
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,6 @@
|
||||
// Must stay the first import: pins all date/time display to EAT before any
|
||||
// module can create a formatter in the PC's local timezone.
|
||||
import "@edr/ui-common/display-timezone";
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { format } from "date-fns";
|
||||
import { Input } from "@/shared/common/ui/input";
|
||||
import {
|
||||
Select,
|
||||
@@ -219,7 +218,7 @@ const buildQuery = (): CollectionQueryDTO => {
|
||||
<TableCell>{log.message}</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{log.timestamp && !isNaN(new Date(log.timestamp).getTime())
|
||||
? format(new Date(log.timestamp), "yyyy-MM-dd HH:mm:ss")
|
||||
? new Date(log.timestamp).toLocaleString("sv-SE")
|
||||
: "N/A"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
@@ -58,10 +58,15 @@ interface CargoNode extends RuleEngineRecord {
|
||||
unitOfMeasure?: string | null;
|
||||
/** Wagon types that can carry this bulk cargo during scheduling; empty if unset. */
|
||||
wagonTypes?: { id: string; code?: string; name?: string }[];
|
||||
/** PER_ITEM only: whole items that physically fit one wagon, keyed by wagon-type id. */
|
||||
itemsPerWagonMap?: Record<string, number> | null;
|
||||
isActive?: boolean;
|
||||
displayOrder?: number;
|
||||
}
|
||||
|
||||
/** Form-value prefix for the per-wagon-type items-fit inputs (PER_ITEM cargo). */
|
||||
const ITEMS_FIT_PREFIX = "itemsFit__";
|
||||
|
||||
const str = (v: unknown): string => (v == null ? "" : String(v));
|
||||
const orderOf = (n: CargoNode): number => Number(n.displayOrder ?? 0);
|
||||
|
||||
@@ -131,15 +136,33 @@ const CargoTypesPage = () => {
|
||||
|
||||
// Wagon-type options for the "Wagon types" picker (bulk cargo → allowed list).
|
||||
const { data: wagonTypeOptions } = useWagonTypeOptions(canCreate || canUpdate);
|
||||
const formFields = useMemo<FormFieldDef[]>(
|
||||
() =>
|
||||
FORM_FIELDS.map((field) =>
|
||||
field.name === "wagonTypeIds"
|
||||
? { ...field, options: wagonTypeOptions ?? [] }
|
||||
: field,
|
||||
),
|
||||
[wagonTypeOptions],
|
||||
);
|
||||
const formFields = useMemo<FormFieldDef[]>(() => {
|
||||
const base = FORM_FIELDS.map((field) =>
|
||||
field.name === "wagonTypeIds"
|
||||
? { ...field, options: wagonTypeOptions ?? [] }
|
||||
: field,
|
||||
);
|
||||
// PER_ITEM cargo: one "items per wagon" input per SELECTED wagon type — how
|
||||
// many whole items physically fit that wagon (floor space binds before
|
||||
// tonnage). Shown only while the wagon type is picked; the API requires a
|
||||
// fit for every selected type on PER_ITEM cargo.
|
||||
const fitFields: FormFieldDef[] = (wagonTypeOptions ?? []).map((opt) => ({
|
||||
name: `${ITEMS_FIT_PREFIX}${opt.value}`,
|
||||
label: `Items per ${opt.label} wagon`,
|
||||
type: "number",
|
||||
required: true,
|
||||
placeholder: "e.g. 4",
|
||||
showIf: (values) =>
|
||||
values.unitOfMeasure === "PER_ITEM" &&
|
||||
Array.isArray(values.wagonTypeIds) &&
|
||||
(values.wagonTypeIds as string[]).includes(opt.value),
|
||||
getInitialValue: (record) =>
|
||||
(record as CargoNode).itemsPerWagonMap?.[opt.value],
|
||||
}));
|
||||
const wagonTypesAt = base.findIndex((field) => field.name === "wagonTypeIds");
|
||||
base.splice(wagonTypesAt + 1, 0, ...fitFields);
|
||||
return base;
|
||||
}, [wagonTypeOptions]);
|
||||
|
||||
const [search, setSearch] = useState("");
|
||||
const [formMode, setFormMode] = useState<FormMode | null>(null);
|
||||
@@ -203,7 +226,18 @@ const CargoTypesPage = () => {
|
||||
const countAtRoot = (childrenOf.get("__root__") ?? []).length;
|
||||
|
||||
const handleSubmit = (values: Record<string, unknown>) => {
|
||||
const payload: Record<string, unknown> = { ...values };
|
||||
// Fold the per-wagon-type fit inputs into the API's map shape. Null when
|
||||
// none are visible (not PER_ITEM) so an update clears stale fits.
|
||||
const payload: Record<string, unknown> = {};
|
||||
const itemsPerWagonMap: Record<string, number> = {};
|
||||
for (const [key, value] of Object.entries(values)) {
|
||||
if (key.startsWith(ITEMS_FIT_PREFIX)) {
|
||||
itemsPerWagonMap[key.slice(ITEMS_FIT_PREFIX.length)] = Number(value);
|
||||
} else {
|
||||
payload[key] = value;
|
||||
}
|
||||
}
|
||||
payload.itemsPerWagonMap = Object.keys(itemsPerWagonMap).length ? itemsPerWagonMap : null;
|
||||
// Add always attaches to the page we're on; edit keeps the node's parent.
|
||||
if (formMode?.kind === "create" && current) {
|
||||
payload.parentGroupId = current.id;
|
||||
|
||||
@@ -107,7 +107,14 @@ const ReminderList = () => {
|
||||
Remind {dayjs(reminder.remindAt).fromNow()}
|
||||
</p>
|
||||
<span className="text-[11px] text-gray-400">
|
||||
({dayjs(reminder.remindAt).format("MMM D, h:mm A")})
|
||||
(
|
||||
{new Date(reminder.remindAt).toLocaleString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Card, CardContent } from "@/shared/common/ui/card";
|
||||
import { Badge } from "@/shared/common/ui/badge";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/shared/common/ui/avatar";
|
||||
import { format } from "date-fns";
|
||||
import {
|
||||
Clock,
|
||||
User,
|
||||
@@ -101,8 +99,14 @@ export function ActivityCard({ activity }: { activity: ActivityCardProps }) {
|
||||
const iconBg = "bg-gray-100 dark:bg-gray-800";
|
||||
const whiteBg = "bg-white dark:bg-gray-900";
|
||||
|
||||
const formattedDate = format(new Date(activity.timestamp), "MMM d, yyyy");
|
||||
const formattedTime = format(new Date(activity.timestamp), "HH:mm:ss");
|
||||
const formattedDate = new Date(activity.timestamp).toLocaleDateString(
|
||||
"en-US",
|
||||
{ month: "short", day: "numeric", year: "numeric" },
|
||||
);
|
||||
const formattedTime = new Date(activity.timestamp).toLocaleTimeString(
|
||||
"en-GB",
|
||||
{ hour12: false },
|
||||
);
|
||||
|
||||
return (
|
||||
<Card
|
||||
|
||||
@@ -213,7 +213,6 @@ export default function AuditLogPageShared({
|
||||
const hoverSoft = "hover:bg-gray-100 dark:hover:bg-gray-800";
|
||||
const primaryBtn =
|
||||
"bg-gray-900 hover:bg-gray-800 dark:bg-gray-100 dark:hover:bg-gray-200 text-white dark:text-gray-900";
|
||||
const primaryIcon = "text-gray-900 dark:text-gray-100";
|
||||
|
||||
const getSeverityColor = (severity: string) => {
|
||||
switch (severity) {
|
||||
@@ -472,10 +471,14 @@ export default function AuditLogPageShared({
|
||||
textSubtle,
|
||||
)}>
|
||||
<span className="whitespace-nowrap">
|
||||
{format(new Date(log.timestamp), "MMM d, yyyy")}
|
||||
{new Date(log.timestamp).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
})}
|
||||
</span>
|
||||
<span className="whitespace-nowrap">
|
||||
{format(new Date(log.timestamp), "HH:mm:ss")}
|
||||
{new Date(log.timestamp).toLocaleTimeString("en-GB", { hour12: false })}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-2 min-w-0">
|
||||
<span className={cn(textSubtle)}>•</span>
|
||||
@@ -817,13 +820,17 @@ export default function AuditLogPageShared({
|
||||
<TableCell className="font-medium">
|
||||
<div className="flex flex-col">
|
||||
<span className={cn("text-sm", textStrong)}>
|
||||
{format(
|
||||
new Date(log.timestamp),
|
||||
"MMM d, yyyy",
|
||||
{new Date(log.timestamp).toLocaleDateString(
|
||||
"en-US",
|
||||
{
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
},
|
||||
)}
|
||||
</span>
|
||||
<span className={cn("text-xs", textSubtle)}>
|
||||
{format(new Date(log.timestamp), "HH:mm:ss")}
|
||||
{new Date(log.timestamp).toLocaleTimeString("en-GB", { hour12: false })}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Must stay the first import: pins all date/time display to EAT before any
|
||||
// module can create a formatter in the PC's local timezone.
|
||||
import "@edr/ui-common/display-timezone";
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Box, Group, Text } from "@mantine/core";
|
||||
import { format } from "date-fns";
|
||||
import { memo } from "react";
|
||||
import { STATUS_CONFIG, cv } from "../constants";
|
||||
|
||||
@@ -56,7 +55,10 @@ export const ActivityRow = memo(function ActivityRow({
|
||||
</Text>
|
||||
</Box>
|
||||
<Text fz={11} c="edr-muted" className="shrink-0">
|
||||
{format(new Date(booking.createdAt), "MMM d")}
|
||||
{new Date(booking.createdAt).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
|
||||
@@ -106,7 +106,8 @@ function Countdown({
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
})}{" "}
|
||||
EAT
|
||||
</Text>
|
||||
{onPay && (
|
||||
<Button
|
||||
|
||||
@@ -68,6 +68,7 @@ function formatPriceUnit(unit: string): string {
|
||||
const map: Record<string, string> = {
|
||||
PER_CONTAINER: "per container",
|
||||
PER_TON: "per ton",
|
||||
PER_ITEM: "per item",
|
||||
PER_WAGON: "per wagon",
|
||||
PER_KM: "per km",
|
||||
FLAT: "flat",
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
Text,
|
||||
Textarea,
|
||||
} from "@mantine/core";
|
||||
import { format } from "date-fns";
|
||||
import {
|
||||
Calendar,
|
||||
CheckCircle2,
|
||||
@@ -239,7 +238,12 @@ export function Step8Review({
|
||||
values.destinationYard;
|
||||
|
||||
const scheduleLabel = values.scheduledDate
|
||||
? format(new Date(values.scheduledDate), "EEEE, MMM d, yyyy")
|
||||
? new Date(values.scheduledDate).toLocaleDateString("en-US", {
|
||||
weekday: "long",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
})
|
||||
: "—";
|
||||
|
||||
const directionLabel = direction
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./display-timezone": "./src/lib/display-timezone.ts",
|
||||
"./styles.css": "./dist/index.css",
|
||||
"./theme.css": "./src/styles/theme.css"
|
||||
},
|
||||
|
||||
48
packages/ui-common/src/lib/display-timezone.ts
Normal file
48
packages/ui-common/src/lib/display-timezone.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Side-effect module: pins every Intl-based date/time display to East Africa
|
||||
* Time (UTC+3) so all users see the same wall-clock times no matter what
|
||||
* timezone their PC is set to. Import it FIRST in the app entry, before any
|
||||
* other app module, so no module-scope formatter is created unpatched:
|
||||
*
|
||||
* import "@edr/ui-common/display-timezone";
|
||||
*
|
||||
* Call sites that pass an explicit `timeZone` option keep it. date-fns and
|
||||
* dayjs `format()` do NOT go through Intl and stay PC-local — don't use them
|
||||
* to display API timestamps.
|
||||
*/
|
||||
export const DISPLAY_TIME_ZONE = "Africa/Addis_Ababa";
|
||||
|
||||
type Locales = string | string[] | undefined;
|
||||
type LocaleMethod = (
|
||||
this: Date,
|
||||
locales?: Locales,
|
||||
options?: Intl.DateTimeFormatOptions,
|
||||
) => string;
|
||||
|
||||
function pinned(original: LocaleMethod): LocaleMethod {
|
||||
return function (locales, options) {
|
||||
return original.call(this, locales, {
|
||||
...options,
|
||||
timeZone: options?.timeZone ?? DISPLAY_TIME_ZONE,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
Date.prototype.toLocaleString = pinned(Date.prototype.toLocaleString);
|
||||
Date.prototype.toLocaleDateString = pinned(Date.prototype.toLocaleDateString);
|
||||
Date.prototype.toLocaleTimeString = pinned(Date.prototype.toLocaleTimeString);
|
||||
|
||||
const OriginalDateTimeFormat = Intl.DateTimeFormat;
|
||||
function PinnedDateTimeFormat(
|
||||
locales?: Locales,
|
||||
options?: Intl.DateTimeFormatOptions,
|
||||
): Intl.DateTimeFormat {
|
||||
return new OriginalDateTimeFormat(locales, {
|
||||
...options,
|
||||
timeZone: options?.timeZone ?? DISPLAY_TIME_ZONE,
|
||||
});
|
||||
}
|
||||
// Keep `instanceof Intl.DateTimeFormat` and the static method working.
|
||||
PinnedDateTimeFormat.prototype = OriginalDateTimeFormat.prototype;
|
||||
PinnedDateTimeFormat.supportedLocalesOf = OriginalDateTimeFormat.supportedLocalesOf;
|
||||
Intl.DateTimeFormat = PinnedDateTimeFormat as unknown as typeof Intl.DateTimeFormat;
|
||||
Reference in New Issue
Block a user