mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 17:10:56 +00:00
fix schule issue and contianer type issue
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Wagon fraction one container occupies, derived from its size: 40ft = 1 wagon,
|
||||
* 20ft = 0.5 (two per wagon). Unknown size reads as a whole wagon so counts
|
||||
* never under-book.
|
||||
*/
|
||||
export function wagonsPerUnitForSize(sizeFt?: number | null): number {
|
||||
const size = Number(sizeFt);
|
||||
if (!Number.isFinite(size) || size <= 0) return 1;
|
||||
return size >= 40 ? 1 : 0.5;
|
||||
}
|
||||
|
||||
/** Containers that fit on one wagon for a given container size (inverse of the wagon fraction). */
|
||||
export function containersPerWagonForSize(sizeFt?: number | null): number {
|
||||
return Math.max(1, Math.round(1 / wagonsPerUnitForSize(sizeFt)));
|
||||
}
|
||||
@@ -29,8 +29,7 @@ export class PriorityConfigsController {
|
||||
@Get('next-range')
|
||||
@RuleEngineView('priority-configs')
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Where the next contiguous range for a type (and currency) must start, plus the type's ceiling",
|
||||
summary: 'Where the next contiguous range for a type (and currency) must start',
|
||||
})
|
||||
nextRange(
|
||||
@Query('type') type: 'WAGON' | 'CURRENCY' | 'CUSTOMS',
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsArray, IsBoolean, IsInt, IsNumber, IsOptional, IsString, IsUUID, Max, MaxLength, Min } from 'class-validator';
|
||||
import { IsArray, IsBoolean, IsInt, IsOptional, IsString, IsUUID, Max, MaxLength, Min } from 'class-validator';
|
||||
|
||||
export class CreateContainerTypeDto {
|
||||
@ApiProperty({ description: 'Customer-facing label, e.g. "20ft Dry Container"', maxLength: 100 })
|
||||
@@ -14,12 +13,6 @@ export class CreateContainerTypeDto {
|
||||
@Max(40)
|
||||
sizeFt!: number;
|
||||
|
||||
@ApiProperty({ description: 'Wagon fraction per container: 0.50 for 20ft, 1.00 for 40ft' })
|
||||
@IsNumber()
|
||||
@Min(0.01)
|
||||
@Transform(({ value }) => Number(value))
|
||||
wagonsPerUnit!: number;
|
||||
|
||||
@ApiPropertyOptional({ default: false, description: 'True if this is a reefer (refrigerated) container' })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
|
||||
@@ -16,9 +16,6 @@ export class ContainerType extends BaseEntity {
|
||||
@Column({ name: 'size_ft', type: 'smallint', nullable: true })
|
||||
sizeFt!: number;
|
||||
|
||||
@Column({ name: 'wagons_per_unit', type: 'numeric', precision: 4, scale: 2, nullable: true })
|
||||
wagonsPerUnit!: number;
|
||||
|
||||
@Column({ name: 'is_reefer', type: 'boolean', default: false, nullable: true })
|
||||
isReefer!: boolean;
|
||||
|
||||
|
||||
@@ -48,7 +48,6 @@ export class ContainerTypesService {
|
||||
code,
|
||||
label: dto.label,
|
||||
sizeFt: dto.sizeFt,
|
||||
wagonsPerUnit: dto.wagonsPerUnit,
|
||||
isReefer: dto.isReefer ?? false,
|
||||
isOpenTop: dto.isOpenTop ?? false,
|
||||
isActive: dto.isActive ?? true,
|
||||
|
||||
@@ -5,9 +5,8 @@ import { PriorityConfigsService } from './priority-configs.service';
|
||||
|
||||
/**
|
||||
* Contiguous-range rules for priority configs: per type (per currency for
|
||||
* CURRENCY), ranges run 1..cap with no gaps and no overlaps; the next range
|
||||
* must start at the lowest uncovered wagon count. Caps: WAGON 50,
|
||||
* CURRENCY 35, CUSTOMS 15.
|
||||
* CURRENCY), ranges run from 1 with no gaps and no overlaps; the next range
|
||||
* must start at the lowest uncovered wagon count. There is no upper ceiling.
|
||||
*/
|
||||
describe('PriorityConfigsService range validation', () => {
|
||||
const rule = (
|
||||
@@ -118,41 +117,47 @@ describe('PriorityConfigsService range validation', () => {
|
||||
).rejects.toThrow(/overlaps existing rule/);
|
||||
});
|
||||
|
||||
it('enforces the per-type ceilings (WAGON 50, CURRENCY 35, CUSTOMS 15)', async () => {
|
||||
it('imposes no upper ceiling on any type', async () => {
|
||||
await expect(
|
||||
attempt(serviceWith([]), { minWagonCount: 1, maxWagonCount: 51 }),
|
||||
).rejects.toThrow(/may not exceed 50/);
|
||||
attempt(serviceWith([]), { minWagonCount: 1, maxWagonCount: 5000 }),
|
||||
).resolves.toBeUndefined();
|
||||
await expect(
|
||||
attempt(serviceWith([]), {
|
||||
type: 'CURRENCY',
|
||||
currency: 'USD',
|
||||
minWagonCount: 1,
|
||||
maxWagonCount: 36,
|
||||
maxWagonCount: 5000,
|
||||
}),
|
||||
).rejects.toThrow(/may not exceed 35/);
|
||||
).resolves.toBeUndefined();
|
||||
await expect(
|
||||
attempt(serviceWith([]), {
|
||||
type: 'CUSTOMS',
|
||||
minWagonCount: 1,
|
||||
maxWagonCount: 16,
|
||||
maxWagonCount: 5000,
|
||||
}),
|
||||
).rejects.toThrow(/may not exceed 15/);
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects any new rule once the chain covers the full range', async () => {
|
||||
it('keeps extending the chain past the old caps', async () => {
|
||||
await expect(
|
||||
attempt(serviceWith([rule('WAGON', 1, 50)]), {
|
||||
minWagonCount: 51,
|
||||
maxWagonCount: 51,
|
||||
maxWagonCount: 120,
|
||||
}),
|
||||
).rejects.toThrow(/may not exceed 50/);
|
||||
).resolves.toBeUndefined();
|
||||
await expect(
|
||||
attempt(serviceWith([rule('CUSTOMS', 1, 15)]), {
|
||||
type: 'CUSTOMS',
|
||||
minWagonCount: 1,
|
||||
maxWagonCount: 1,
|
||||
minWagonCount: 16,
|
||||
maxWagonCount: 99,
|
||||
}),
|
||||
).rejects.toThrow(/already cover the full 1–15 range/);
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('still rejects a min greater than the max', async () => {
|
||||
await expect(
|
||||
attempt(serviceWith([]), { minWagonCount: 9, maxWagonCount: 4 }),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('tracks CURRENCY chains per currency — USD and ETB are independent', async () => {
|
||||
@@ -214,16 +219,13 @@ describe('PriorityConfigsService range validation', () => {
|
||||
|
||||
it('reports the next-range prefill for the form', async () => {
|
||||
const svc = serviceWith([rule('WAGON', 1, 5), rule('WAGON', 11, 20)]);
|
||||
await expect(svc.nextRange('WAGON')).resolves.toEqual({
|
||||
nextMin: 6,
|
||||
maxCap: 50,
|
||||
});
|
||||
await expect(svc.nextRange('WAGON')).resolves.toEqual({ nextMin: 6 });
|
||||
// Past the old CUSTOMS cap of 15 the chain simply continues.
|
||||
await expect(
|
||||
serviceWith([rule('CUSTOMS', 1, 15)]).nextRange('CUSTOMS'),
|
||||
).resolves.toEqual({ nextMin: null, maxCap: 15 });
|
||||
).resolves.toEqual({ nextMin: 16 });
|
||||
await expect(serviceWith([]).nextRange('CURRENCY', 'USD')).resolves.toEqual({
|
||||
nextMin: 1,
|
||||
maxCap: 35,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,28 +10,19 @@ import {
|
||||
} from '../interfaces/priority-configs.repository.interface';
|
||||
import { DisplayOrderService } from './display-order.service';
|
||||
|
||||
/** Hard ceiling of each type's wagon-count chain (1..cap, contiguous). */
|
||||
export const RANGE_CAPS: Record<'WAGON' | 'CURRENCY' | 'CUSTOMS', number> = {
|
||||
WAGON: 50,
|
||||
CURRENCY: 35,
|
||||
CUSTOMS: 15,
|
||||
};
|
||||
|
||||
/**
|
||||
* Lowest wagon count ≥ 1 not covered by any of `rules` — where the next range
|
||||
* must start. Null when the chain is already complete up to the type's cap.
|
||||
* must start. The chain is unbounded above, so there is always a next start.
|
||||
*/
|
||||
function nextRangeStart(
|
||||
rules: Pick<PriorityConfig, 'type' | 'minWagonCount' | 'maxWagonCount'>[],
|
||||
): number | null {
|
||||
const cap = rules.length ? RANGE_CAPS[rules[0].type] : null;
|
||||
): number {
|
||||
const sorted = [...rules].sort((a, b) => a.minWagonCount - b.minWagonCount);
|
||||
let next = 1;
|
||||
for (const r of sorted) {
|
||||
if (r.minWagonCount > next) break; // gap before this rule — fill it
|
||||
next = Math.max(next, r.maxWagonCount + 1);
|
||||
}
|
||||
if (cap != null && next > cap) return null;
|
||||
return next;
|
||||
}
|
||||
|
||||
@@ -102,8 +93,8 @@ export class PriorityConfigsService {
|
||||
* - ranges never overlap — a booking matches at most one rule per type;
|
||||
* - ranges are contiguous from 1: a new range must START at the lowest
|
||||
* wagon count not yet covered (after 1–5 the next is 6–…; deleting a
|
||||
* middle rule opens a gap and the next create must fill it first);
|
||||
* - each type has a hard ceiling: WAGON 50, CURRENCY 35, CUSTOMS 15.
|
||||
* middle rule opens a gap and the next create must fill it first).
|
||||
* There is no upper ceiling — max wagon count is unbounded.
|
||||
* Ranges are inclusive on both ends.
|
||||
*/
|
||||
async assertNoRangeCollision(input: {
|
||||
@@ -118,14 +109,6 @@ export class PriorityConfigsService {
|
||||
'Min wagon count cannot be greater than max wagon count',
|
||||
);
|
||||
}
|
||||
const cap = RANGE_CAPS[input.type];
|
||||
if (input.maxWagonCount > cap) {
|
||||
throw new BadRequestException(
|
||||
`${input.type} ranges may not exceed ${cap} — ` +
|
||||
`${input.minWagonCount}–${input.maxWagonCount} goes past the ceiling.`,
|
||||
);
|
||||
}
|
||||
|
||||
const siblings = (
|
||||
await this.repository.findAll({ where: { type: input.type } })
|
||||
).filter(
|
||||
@@ -142,12 +125,6 @@ export class PriorityConfigsService {
|
||||
const currentStart = input.excludeId
|
||||
? (await this.repository.findById(input.excludeId))?.minWagonCount ?? null
|
||||
: null;
|
||||
if (expectedStart == null && currentStart == null) {
|
||||
throw new BadRequestException(
|
||||
`${input.type} rules already cover the full 1–${cap} range — ` +
|
||||
'delete or shrink an existing rule first.',
|
||||
);
|
||||
}
|
||||
if (
|
||||
input.minWagonCount !== expectedStart &&
|
||||
input.minWagonCount !== currentStart
|
||||
@@ -174,21 +151,21 @@ export class PriorityConfigsService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the next range for a type/currency must start, and the type's
|
||||
* ceiling — feeds the create form so the min field is auto-filled and
|
||||
* locked. `nextMin` is null when the chain already covers 1..cap.
|
||||
* Where the next range for a type/currency must start — feeds the create
|
||||
* form so the min field is auto-filled and locked. Always a number: the
|
||||
* chain has no ceiling, so another range always fits.
|
||||
*/
|
||||
async nextRange(
|
||||
type: 'WAGON' | 'CURRENCY' | 'CUSTOMS',
|
||||
currency?: string | null,
|
||||
): Promise<{ nextMin: number | null; maxCap: number }> {
|
||||
): Promise<{ nextMin: number }> {
|
||||
const siblings = (
|
||||
await this.repository.findAll({ where: { type } })
|
||||
).filter(
|
||||
(s) =>
|
||||
type !== 'CURRENCY' || (s.currency ?? null) === (currency ?? null),
|
||||
);
|
||||
return { nextMin: nextRangeStart(siblings), maxCap: RANGE_CAPS[type] };
|
||||
return { nextMin: nextRangeStart(siblings) };
|
||||
}
|
||||
|
||||
async remove(id: string): Promise<void> {
|
||||
|
||||
Reference in New Issue
Block a user