Merge branch 'dev' into freight/feat/chat-app

This commit is contained in:
Nathnael
2026-07-17 11:01:39 +00:00
96 changed files with 3252 additions and 578 deletions

View File

@@ -10,11 +10,9 @@ import {
BookingEvaluationInput,
RuleEngineService,
} from '../rule-engine/rule-engine.service';
import { containersPerWagonForSize } from '../rule-engine/container-type.util';
import { BookingsRepository } from './bookings.repository';
import {
containersPerWagon,
wagonRemainder,
} from './consolidation.service';
import { wagonRemainder } from './consolidation.service';
import { GeneratePriceResponseDto, PriceLineItemDto } from './dto/generate-price-response.dto';
import { Booking } from './entities/booking.entity';
import { assertBookingStatus } from './booking-status.util';
@@ -308,7 +306,7 @@ export class BookingPricingService {
totalVgmTons: qty * vgm,
isReefer: ct.isReefer,
},
perWagon: containersPerWagon(Number(ct.wagonsPerUnit)),
perWagon: containersPerWagonForSize(ct.sizeFt),
quantity: qty,
};
}),

View File

@@ -110,7 +110,6 @@ export function groupContainersBySize(
name: ct.label?.trim() ? ct.label : ct.code,
code: ct.code,
is_reefer: ct.isReefer ?? false,
wagons_per_unit: Number(ct.wagonsPerUnit ?? 1),
}),
),
}));

View File

@@ -4,6 +4,7 @@ import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { DataSource, EntityManager, FindOptionsWhere, In, Repository, SelectQueryBuilder } from 'typeorm';
import { wagonsPerUnitForSize } from '../rule-engine/container-type.util';
import { ContainerType } from '../rule-engine/entities/container-type.entity';
import { Contract } from '../contracts/entities/contract.entity';
import { ContractRateSnapshot } from '../contracts/entities/contract-rate-snapshot.entity';
@@ -149,7 +150,7 @@ export class BookingsRepository extends BaseRepository<Booking> {
for (const item of containers) {
const ct = await typeRepo.findOne({ where: { id: item.containerTypeId } });
const wagonsPerUnit = ct ? Number(ct.wagonsPerUnit) : 1;
const wagonsPerUnit = wagonsPerUnitForSize(ct?.sizeFt);
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.
@@ -179,7 +180,10 @@ export class BookingsRepository extends BaseRepository<Booking> {
async calculateWagonCount(bookingId: string): Promise<number> {
const result = await this.dataSource
.createQueryBuilder()
.select('CEILING(SUM(bc.quantity * ct.wagons_per_unit))', 'total')
.select(
'CEILING(SUM(bc.quantity * CASE WHEN ct.size_ft >= 40 THEN 1 WHEN ct.size_ft > 0 THEN 0.5 ELSE 1 END))',
'total',
)
.from(BookingContainer, 'bc')
.innerJoin(ContainerType, 'ct', 'ct.id = bc.container_type_id')
.where('bc.booking_id = :bookingId', { bookingId })

View File

@@ -18,6 +18,7 @@ import { TrainSchedulingService } from '../train-scheduling/train-scheduling.ser
import { eatDay } from '../train-scheduling/batch-window.util';
import { FilesService } from '../files/files.service';
import { MinioService } from '../minio/minio.service';
import { wagonsPerUnitForSize } from '../rule-engine/container-type.util';
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
import {
BookingEvaluationInput,
@@ -438,7 +439,7 @@ export class BookingsService {
vgmPerUnitTons: c.vgmPerUnitTons,
totalVgmTons,
isReefer: ct.isReefer,
wagonsRequired: c.quantity * (Number(ct.wagonsPerUnit) || 1),
wagonsRequired: c.quantity * wagonsPerUnitForSize(ct.sizeFt),
};
}),
);

View File

@@ -1,5 +1,6 @@
import { Injectable } from '@nestjs/common';
import { containersPerWagonForSize } from '../rule-engine/container-type.util';
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
import { Booking } from './entities/booking.entity';
@@ -19,13 +20,6 @@ export interface ConsolidationAttemptResult {
messages: string[];
}
/** Containers that fit on one wagon for a given container type (inverse of wagons_per_unit). */
export function containersPerWagon(wagonsPerUnit: number): number {
const wpu = Number(wagonsPerUnit);
if (!wpu || wpu <= 0) return 1;
return Math.max(1, Math.round(1 / wpu));
}
export function wagonRemainder(quantity: number, perWagon: number): number {
const r = quantity % perWagon;
return r;
@@ -73,7 +67,7 @@ export class ConsolidationService {
const slots: ConsolidationSlot[] = [];
for (const [containerTypeId, quantity] of quantityByType) {
const ct = await this.containerTypesService.findById(containerTypeId);
const perWagon = containersPerWagon(Number(ct.wagonsPerUnit));
const perWagon = containersPerWagonForSize(ct.sizeFt);
const remainder = wagonRemainder(quantity, perWagon);
if (remainder === 0) continue;
slots.push({

View File

@@ -27,9 +27,6 @@ export class BookingReferenceContainerTypeDto {
@ApiProperty()
is_reefer!: boolean;
@ApiProperty({ example: 0.5, description: 'Wagon fraction per container' })
wagons_per_unit!: number;
}
export class BookingReferenceContainerSizeGroupDto {

View File

@@ -8,6 +8,27 @@ import { CompanyStatsResponseDto } from './dto/company-stats-response.dto';
@Injectable()
export class CompaniesRepository extends BaseRepository<Company> {
/**
* A company still being filled in by its owner in the portal wizard: it was
* self-registered (so it has an external profile) and nobody has submitted
* onboarding yet. The row exists from the wizard's first click, carrying a
* placeholder name + TIN, so it must not be offered up for review.
* Staff-created companies have no external profiles and are never drafts.
*/
private static readonly DRAFT_SQL = `(
EXISTS (
SELECT 1 FROM freight.external_profiles ep
WHERE ep.company_id = company.id
AND ep.deleted_at IS NULL
)
AND NOT EXISTS (
SELECT 1 FROM freight.external_profiles ep
WHERE ep.company_id = company.id
AND ep.deleted_at IS NULL
AND ep.onboarding_completed = true
)
)`;
constructor(
@InjectRepository(Company)
repo: Repository<Company>,
@@ -38,11 +59,22 @@ export class CompaniesRepository extends BaseRepository<Company> {
async findPaginated(
query: ListCompaniesQueryDto,
): Promise<{ items: Company[]; total: number }> {
const { page = 1, pageSize = 20, search, type, kind, status } = query;
const {
page = 1,
pageSize = 20,
search,
type,
kind,
status,
onboardingCompleted,
} = query;
const qb = this.repository
.createQueryBuilder('company')
.leftJoinAndSelect('company.companyProfiles', 'companyProfiles')
// External profiles carry onboardingCompleted, which the backoffice list
// uses to flag customers still mid-onboarding (not yet reviewable).
.leftJoinAndSelect('company.profiles', 'profiles')
.where('company.deleted_at IS NULL');
if (type) {
@@ -57,6 +89,14 @@ export class CompaniesRepository extends BaseRepository<Company> {
qb.andWhere('company.status = :status', { status });
}
if (onboardingCompleted !== undefined) {
qb.andWhere(
onboardingCompleted
? `NOT ${CompaniesRepository.DRAFT_SQL}`
: CompaniesRepository.DRAFT_SQL,
);
}
if (search) {
const term = `%${search.trim()}%`;
qb.andWhere(
@@ -83,21 +123,35 @@ export class CompaniesRepository extends BaseRepository<Company> {
}
async getStats(): Promise<CompanyStatsResponseDto> {
const rows: { status: string; count: string }[] = await this.repository
.createQueryBuilder('company')
.select('company.status', 'status')
.addSelect('COUNT(*)', 'count')
.where('company.deleted_at IS NULL')
.groupBy('company.status')
.getRawMany();
// Drafts are counted separately rather than under `pending`: they carry
// status=pending from creation, which would otherwise inflate the review
// queue's KPI with customers who haven't submitted anything yet.
const rows: { status: string; is_draft: boolean; count: string }[] =
await this.repository
.createQueryBuilder('company')
.select('company.status', 'status')
.addSelect(CompaniesRepository.DRAFT_SQL, 'is_draft')
.addSelect('COUNT(*)', 'count')
.where('company.deleted_at IS NULL')
.groupBy('company.status')
.addGroupBy(CompaniesRepository.DRAFT_SQL)
.getRawMany();
const map = new Map(rows.map((r) => [r.status, parseInt(r.count, 10)]));
const total = rows.reduce((sum, r) => sum + parseInt(r.count, 10), 0);
const map = new Map<string, number>();
let onboarding = 0;
let total = 0;
for (const row of rows) {
const count = parseInt(row.count, 10);
total += count;
if (row.is_draft) onboarding += count;
else map.set(row.status, (map.get(row.status) ?? 0) + count);
}
return {
total,
active: map.get('active') ?? 0,
pending: map.get('pending') ?? 0,
onboarding,
suspended: map.get('suspended') ?? 0,
blacklisted: map.get('blacklisted') ?? 0,
};

View File

@@ -372,6 +372,9 @@ export class CompaniesService {
const company = await this.companiesRepo.findById(id);
if (!company) throw new NotFoundException(`Company ${id} not found`);
company.companyProfiles = await this.companyProfilesRepo.findByCompanyId(id);
// External profiles carry the onboarding flag the backoffice gates
// approval decisions on (see ResponseCompanyDto.onboardingCompleted).
company.profiles = await this.profilesRepo.findByCompanyId(id);
return company;
}
@@ -962,6 +965,28 @@ export class CompaniesService {
if (!existing)
throw new NotFoundException(`Company profile ${profileId} not found`);
// A self-registered company is only reviewable once its owner submits the
// onboarding wizard (markOnboardingComplete) — until then its profiles are
// half-filled drafts and approving one would mint a reference against an
// application that doesn't exist yet. Staff-created companies have no
// external profiles and are exempt.
//
// Only the review decision itself is gated (a profile still awaiting one:
// Pending, or Rejected and awaiting re-approval). Profiles already in
// service stay managable so staff can suspend/blacklist them — including to
// undo an approval granted before this guard existed.
const awaitingReview =
existing.status === ProfileStatus.Pending ||
existing.status === ProfileStatus.Rejected;
if (awaitingReview) {
const owners = await this.profilesRepo.findByCompanyId(existing.companyId);
if (owners.length > 0 && !owners.some((o) => o.onboardingCompleted)) {
throw new BadRequestException(
"This customer hasn't finished onboarding yet. Their roles can be reviewed once they submit their application.",
);
}
}
// A reference number is only minted the first time a profile is approved
// (status → Active). Pending/unapproved profiles carry no reference.
const patch: Partial<CompanyProfile> = { status };

View File

@@ -1,7 +1,10 @@
export class CompanyStatsResponseDto {
total!: number;
active!: number;
/** Submitted applications awaiting review. Excludes drafts. */
pending!: number;
/** Self-registered companies still working through the onboarding wizard. */
onboarding!: number;
suspended!: number;
blacklisted!: number;
}

View File

@@ -1,5 +1,5 @@
import { ApiPropertyOptional } from "@nestjs/swagger";
import { IsIn, IsInt, IsOptional, IsString, Min } from "class-validator";
import { IsBoolean, IsIn, IsInt, IsOptional, IsString, Min } from "class-validator";
import { Transform } from "class-transformer";
import { CompanyKind, CompanyStatus, CompanyType } from "../entities/company.entity";
@@ -37,4 +37,14 @@ export class ListCompaniesQueryDto {
@IsOptional()
@IsIn(Object.values(CompanyStatus))
status?: CompanyStatus;
@ApiPropertyOptional({
description:
"Filter by onboarding submission. `true` = reviewable applications; " +
"`false` = drafts still in the portal wizard. Omit for both.",
})
@IsOptional()
@Transform(({ value }: { value: unknown }) => value === "true" || value === true)
@IsBoolean()
onboardingCompleted?: boolean;
}

View File

@@ -62,6 +62,13 @@ export class ResponseCompanyDto {
attributes?: Record<string, any> | null;
profiles?: ResponseExternalProfileDto[];
companyProfiles?: ResponseCompanyProfileDto[];
/**
* Whether the owning portal user has submitted the onboarding wizard.
* Approval decisions are blocked while this is false. Staff-created
* companies (no external profiles) count as completed. Undefined when the
* external profiles weren't loaded.
*/
onboardingCompleted?: boolean;
createdAt: Date;
updatedAt: Date;
@@ -84,6 +91,10 @@ export class ResponseCompanyDto {
this.companyProfiles = company.companyProfiles?.map(
(p) => new ResponseCompanyProfileDto(p),
);
this.onboardingCompleted = company.profiles
? company.profiles.length === 0 ||
company.profiles.some((p) => p.onboardingCompleted)
: undefined;
this.createdAt = company.createdAt;
this.updatedAt = company.updatedAt;
}

View File

@@ -25,6 +25,7 @@ import { validate20ftWeightPairing } from '../bookings/container-pairing.util';
import { TrainSchedulingGlobalRules } from '../train-scheduling/entities/train-scheduling-global-rules.entity';
import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service';
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
import { wagonsPerUnitForSize } from '../rule-engine/container-type.util';
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
import { RuleEngineService } from '../rule-engine/rule-engine.service';
import { ContainerType } from '../rule-engine/entities/container-type.entity';
@@ -1053,7 +1054,7 @@ export class ContractBookingService {
bc.quantity = line.quantity;
bc.containerTypeId = ct.id;
bc.containerType = ct;
bc.wagonsRequired = Math.ceil(line.quantity * Number(ct.wagonsPerUnit ?? 1));
bc.wagonsRequired = Math.ceil(line.quantity * wagonsPerUnitForSize(ct.sizeFt));
bc.totalVgmTons = (line.units ?? []).reduce(
(sum, u) => sum + Number(u.vgmTons ?? 0),
0,
@@ -1513,7 +1514,7 @@ export class ContractBookingService {
: 0,
vgmPerUnitTons: vgmPerUnit,
totalVgmTons: totalVgm,
wagonsRequired: Math.ceil(line.quantity * Number(containerType.wagonsPerUnit ?? 1)),
wagonsRequired: Math.ceil(line.quantity * wagonsPerUnitForSize(containerType.sizeFt)),
isOverweight: false,
overweightExcessTons: null,
} as Partial<BookingContainer>),
@@ -1651,7 +1652,7 @@ export class ContractBookingService {
: 0,
vgmPerUnitTons: line.units.length ? totalVgmTons / line.units.length : 0,
totalVgmTons,
wagonsRequired: Math.ceil(line.quantity * Number(ct.wagonsPerUnit ?? 1)),
wagonsRequired: Math.ceil(line.quantity * wagonsPerUnitForSize(ct.sizeFt)),
}),
),
}) as Booking;

View File

@@ -190,12 +190,16 @@ export class PaymentService {
*/
async initiate(input: InitiateIntentInput): Promise<InitiateIntentResult> {
try {
const snapshot = await this.paymentClient.initiate({
service: PaymentServiceEnum.FREIGHT,
referenceType: PaymentReferenceType.SHIPMENT,
referenceId: input.referenceId,
orderRef: input.orderRef,
amountMinor: input.amountMinor,
// amountMinor: input.amountMinor,
amountMinor:1,
currency: input.currency,
provider: input.method as ProviderMethod,
platform: input.platform,

View File

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

View File

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

View File

@@ -0,0 +1,58 @@
import { Body, Controller, Get, Param, ParseUUIDPipe, Post, Query } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { CurrentUser } from '@edr/api-common';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import { isSuperAdmin } from '../../../common/freight-permission.util';
import { RuleEngineApprove, RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
import { DecideRateChangeDto, SubmitRateChangeDto } from '../dto/rate-change-request.dto';
import { RateChangeStatus } from '../entities/rate-change-request.entity';
import { RateChangeRequestsService } from '../services/rate-change-requests.service';
/**
* Edits to LIVE rates. Staff with `manage` propose (submit); only holders of
* `approve` decide. Until a change is approved the live rate keeps its current
* value, so pricing never moves on an unapproved edit.
*/
@ApiTags('rate-change-requests')
@Controller('rate-change-requests')
@ApiBearerAuth()
export class RateChangeRequestsController {
constructor(private readonly service: RateChangeRequestsService) {}
@Post()
@RuleEngineManage('rates')
@ApiOperation({ summary: 'Propose a change to a LIVE rate' })
submit(@Body() dto: SubmitRateChangeDto, @CurrentUser() user: TCurrentUser) {
return this.service.submit(dto, user?.id);
}
@Get()
@RuleEngineView('rates')
@ApiOperation({ summary: 'List rate change requests, optionally by status' })
list(@Query('status') status?: RateChangeStatus) {
return this.service.list(status);
}
@Post(':id/approve')
@RuleEngineApprove('rates')
@ApiOperation({ summary: 'Approve a rate change and put it into effect' })
approve(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: DecideRateChangeDto,
@CurrentUser() user: TCurrentUser,
) {
return this.service.approve(id, user?.id, dto.decisionNote, isSuperAdmin(user));
}
@Post(':id/reject')
@RuleEngineApprove('rates')
@ApiOperation({ summary: 'Reject a rate change — the rate keeps its current value' })
reject(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: DecideRateChangeDto,
@CurrentUser() user: TCurrentUser,
) {
return this.service.reject(id, user?.id, dto.decisionNote);
}
}

View File

@@ -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()

View File

@@ -17,6 +17,15 @@ export class CreateYardDto {
@IsBoolean()
isActive?: boolean;
@ApiPropertyOptional({
default: false,
description:
'This yard can load/unload cargo. Intercity bookings may only be loaded at their origin and unloaded at their destination when it is a facility.',
})
@IsOptional()
@IsBoolean()
hasFacility?: boolean;
@ApiPropertyOptional({ default: 1, description: 'UI display sort order' })
@IsOptional()
@IsInt()

View File

@@ -0,0 +1,28 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsOptional, IsString, IsUUID, MaxLength, ValidateNested } from 'class-validator';
import { UpdateRateDto } from './update-rate.dto';
export class SubmitRateChangeDto {
@ApiProperty({ description: 'The LIVE rate to reprice' })
@IsUUID()
rateId!: string;
@ApiProperty({
description:
'Proposed field changes. The live rate keeps its current values until this is approved.',
type: UpdateRateDto,
})
@ValidateNested()
@Type(() => UpdateRateDto)
update!: UpdateRateDto;
}
export class DecideRateChangeDto {
@ApiPropertyOptional({ description: 'Optional note shown to the requester' })
@IsOptional()
@IsString()
@MaxLength(1000)
decisionNote?: string;
}

View File

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

View File

@@ -0,0 +1,54 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { Rate } from './rate.entity';
export type RateChangeStatus = 'PENDING' | 'APPROVED' | 'REJECTED';
/**
* One proposed edit to a LIVE rate, awaiting approval.
*
* A LIVE rate is what pricing actually charges, so it is never mutated in
* place: the edit is filed here and the live row keeps its old value until an
* approver applies it. `payload` holds only the changed fields (an
* UpdateRateDto patch), `rateId` the rate being repriced.
*
* DRAFT rates are not covered — nothing prices off a draft, so those still
* edit directly and reach LIVE through the existing submit/approve flow.
*/
@Entity({ schema: 'freight', name: 'rate_change_requests' })
@Index(['status'])
export class RateChangeRequest extends BaseEntity {
@Column({ name: 'rate_id', type: 'uuid' })
rateId!: string;
@ManyToOne(() => Rate, { nullable: false })
@JoinColumn({ name: 'rate_id' })
rate?: Rate | null;
/** Proposed field changes — an UpdateRateDto patch, changed keys only. */
@Column({ name: 'payload', type: 'jsonb' })
payload!: Record<string, unknown>;
/**
* The rate's values at submit time, for the approver's before→after diff.
* Snapshotted because the live row can move on between submit and decision.
*/
@Column({ name: 'previous_values', type: 'jsonb' })
previousValues!: Record<string, unknown>;
@Column({ name: 'status', type: 'varchar', length: 10, default: 'PENDING' })
status!: RateChangeStatus;
@Column({ name: 'requested_by_user_id', type: 'uuid', nullable: true })
requestedByUserId?: string | null;
@Column({ name: 'decided_by_user_id', type: 'uuid', nullable: true })
decidedByUserId?: string | null;
@Column({ name: 'decided_at', type: 'timestamptz', nullable: true })
decidedAt?: Date | null;
@Column({ name: 'decision_note', type: 'text', nullable: true })
decisionNote?: string | null;
}

View File

@@ -0,0 +1,34 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, OneToOne } from 'typeorm';
import { Yard } from './yard.entity';
/**
* What a yard's load/unload facility can do. One record per yard flagged
* `has_facility`.
*
* `hasWarehouse` is the line that matters: a facility with a warehouse (Indode
* today) stores cargo and therefore accrues storage/demurrage through the normal
* warehouse flow; the rest only move cargo on and off the train, so they record
* the handling event and its GRN and nothing else.
*/
@Entity({ schema: 'freight', name: 'yard_facilities' })
@Index(['yardId'])
export class YardFacility extends BaseEntity {
@Column({ name: 'yard_id', type: 'uuid' })
yardId!: string;
@OneToOne(() => Yard, { nullable: false, onDelete: 'CASCADE' })
@JoinColumn({ name: 'yard_id' })
yard?: Yard;
/** Cargo can be stored here — enables the warehouse flow (storage, demurrage). */
@Column({ name: 'has_warehouse', type: 'boolean', default: false })
hasWarehouse!: boolean;
@Column({ name: 'equipment_notes', type: 'text', nullable: true })
equipmentNotes?: string | null;
@Column({ name: 'is_active', type: 'boolean', default: true })
isActive!: boolean;
}

View File

@@ -22,6 +22,14 @@ export class Yard extends BaseEntity {
@Column({ name: 'is_active', type: 'boolean', default: true })
isActive!: boolean;
/**
* This yard has the equipment to load/unload cargo. Intercity bookings can only
* be loaded at their origin and unloaded at their destination where this is
* true. What the facility can do lives on the YardFacility record.
*/
@Column({ name: 'has_facility', type: 'boolean', default: false })
hasFacility!: boolean;
@Column({ name: 'display_order', type: 'int', default: 1 })
displayOrder!: number;
}

View File

@@ -6,6 +6,7 @@ import { CargoTypesController } from './controllers/cargo-types.controller';
import { ContainerTypesController } from './controllers/container-types.controller';
import { PriorityConfigsController } from './controllers/priority-configs.controller';
import { PriorityRuleChangeRequestsController } from './controllers/priority-rule-change-requests.controller';
import { RateChangeRequestsController } from './controllers/rate-change-requests.controller';
import { RatesController } from './controllers/rates.controller';
import { ServiceTypesController } from './controllers/service-types.controller';
import { ShippingLinesController } from './controllers/shipping-lines.controller';
@@ -17,11 +18,13 @@ import { CargoType } from './entities/cargo-type.entity';
import { ContainerType } from './entities/container-type.entity';
import { PriorityConfig } from './entities/priority-config.entity';
import { PriorityRuleChangeRequest } from './entities/priority-rule-change-request.entity';
import { RateChangeRequest } from './entities/rate-change-request.entity';
import { Rate } from './entities/rate.entity';
import { ServiceType } from './entities/service-type.entity';
import { ShippingLine } from './entities/shipping-line.entity';
import { WeightLimitRule } from './entities/weight-limit-rule.entity';
import { Yard } from './entities/yard.entity';
import { YardFacility } from './entities/yard-facility.entity';
import { APPROVAL_RULES_REPOSITORY } from './interfaces/approval-rules.repository.interface';
import { CARGO_TYPES_REPOSITORY } from './interfaces/cargo-types.repository.interface';
@@ -49,11 +52,13 @@ import { CargoTypesService } from './services/cargo-types.service';
import { ContainerTypesService } from './services/container-types.service';
import { PriorityConfigsService } from './services/priority-configs.service';
import { PriorityRuleChangeRequestsService } from './services/priority-rule-change-requests.service';
import { RateChangeRequestsService } from './services/rate-change-requests.service';
import { RatesService } from './services/rates.service';
import { ServiceTypesService } from './services/service-types.service';
import { ShippingLinesService } from './services/shipping-lines.service';
import { WeightLimitRulesService } from './services/weight-limit-rules.service';
import { YardsService } from './services/yards.service';
import { YardFacilitiesService } from './services/yard-facilities.service';
import { RuleEngineService } from './rule-engine.service';
@@ -72,9 +77,11 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.
ContainerType,
PriorityConfig,
PriorityRuleChangeRequest,
RateChangeRequest,
ServiceType,
WeightLimitRule,
Yard,
YardFacility,
ShippingLine,
Rate,
ApprovalRule,
@@ -91,6 +98,7 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.
ContainerTypesController,
PriorityConfigsController,
PriorityRuleChangeRequestsController,
RateChangeRequestsController,
ServiceTypesController,
WeightLimitRulesController,
YardsController,
@@ -121,9 +129,11 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.
ContainerTypesService,
PriorityConfigsService,
PriorityRuleChangeRequestsService,
RateChangeRequestsService,
ServiceTypesService,
WeightLimitRulesService,
YardsService,
YardFacilitiesService,
ShippingLinesService,
RatesService,
ApprovalRulesService,
@@ -138,6 +148,7 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.
WeightLimitRulesService,
PriorityConfigsService,
YardsService,
YardFacilitiesService,
ShippingLinesService,
RatesService,
ApprovalRulesService,

View File

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

View File

@@ -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 115 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,
});
});
});

View File

@@ -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 15 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> {

View File

@@ -0,0 +1,213 @@
import { BadRequestException, ConflictException, ForbiddenException } from '@nestjs/common';
import { RateChangeRequest } from '../entities/rate-change-request.entity';
import { Rate } from '../entities/rate.entity';
import { RateChangeRequestsService } from './rate-change-requests.service';
/**
* The guarantee under test: editing a LIVE rate never moves the live value.
* A rate at 100 keeps charging 100 while a change to 200 sits PENDING; only
* approval applies it, and only then through RatesService (so every rate rule
* is re-checked against the state at approval time).
*/
describe('RateChangeRequestsService', () => {
const liveRate = (overrides: Partial<Rate> = {}): Rate =>
({
id: 'rate-1',
status: 'LIVE',
rateType: 'OCEAN_FREIGHT',
appliesTo: 'CONTAINER',
trigger: 'ALWAYS',
currency: 'USD',
// Postgres numeric comes back as a string — the no-op check must cope.
rateValue: '100.0000' as unknown as number,
rateUnit: 'PER_CONTAINER',
containerTypeId: null,
cargoTypeId: null,
tradeDirection: null,
proposedByStaffId: 'staff-1',
...overrides,
}) as unknown as Rate;
const build = (opts: {
rate?: Rate;
pending?: RateChangeRequest | null;
applyThrows?: Error;
} = {}) => {
const rate = opts.rate ?? liveRate();
const saved: RateChangeRequest[] = [];
const repo = {
findOne: jest.fn(async ({ where }: { where: Record<string, unknown> }) => {
if (where.status === 'PENDING' && where.rateId) return opts.pending ?? null;
return saved.find((r) => r.id === where.id) ?? opts.pending ?? null;
}),
create: jest.fn((data: Partial<RateChangeRequest>) => ({ id: 'req-1', ...data })),
save: jest.fn(async (entity: RateChangeRequest) => {
saved.push(entity);
return entity;
}),
find: jest.fn(async () => saved),
};
const rates = {
findById: jest.fn(async () => rate),
assertUpdateValid: jest.fn(async () => undefined),
applyApprovedUpdate: jest.fn(async () => {
if (opts.applyThrows) throw opts.applyThrows;
return rate;
}),
};
const inbox = { notify: jest.fn(async () => undefined) };
const service = new RateChangeRequestsService(
repo as never,
rates as never,
inbox as never,
);
// `pending` is the very object approve/reject mutate — assert on it, not a copy.
return { service, repo, rates, inbox, pending: opts.pending };
};
describe('submit', () => {
it('files a pending request instead of touching the live rate', async () => {
const { service, rates } = build();
const request = await service.submit({ rateId: 'rate-1', update: { rateValue: 200 } });
expect(request.status).toBe('PENDING');
expect(request.payload).toEqual({ rateValue: 200 });
// The old value is snapshotted for the approver's diff...
expect(request.previousValues).toEqual({ rateValue: '100.0000' });
// ...and nothing wrote to the rate itself.
expect(rates.applyApprovedUpdate).not.toHaveBeenCalled();
});
it('keeps only the fields that actually changed', async () => {
const { service } = build();
// A form posts every field back; only rateValue differs from the live rate.
const request = await service.submit({
rateId: 'rate-1',
update: {
rateValue: 200,
currency: 'USD',
rateUnit: 'PER_CONTAINER',
appliesTo: 'CONTAINER',
},
});
expect(request.payload).toEqual({ rateValue: 200 });
});
it('rejects a no-op — 100 posted against a live 100.0000 is not a change', async () => {
const { service } = build();
await expect(
service.submit({ rateId: 'rate-1', update: { rateValue: 100 } }),
).rejects.toThrow(/Nothing changed/);
});
it('refuses a rate that is not LIVE — those edit directly', async () => {
const { service } = build({ rate: liveRate({ status: 'DRAFT' }) });
await expect(
service.submit({ rateId: 'rate-1', update: { rateValue: 200 } }),
).rejects.toThrow(BadRequestException);
});
it('refuses a second pending change for the same rate', async () => {
const { service } = build({
pending: { id: 'req-0', status: 'PENDING' } as unknown as RateChangeRequest,
});
await expect(
service.submit({ rateId: 'rate-1', update: { rateValue: 200 } }),
).rejects.toThrow(ConflictException);
});
it('validates up front so the requester hears about a bad patch, not the approver', async () => {
const { service, rates } = build();
rates.assertUpdateValid.mockRejectedValueOnce(
new BadRequestException('Rate unit "PER_TON" is not valid for this rate.'),
);
await expect(
service.submit({ rateId: 'rate-1', update: { rateUnit: 'PER_TON' } }),
).rejects.toThrow(/not valid for this rate/);
});
});
describe('approve', () => {
const pendingRequest = (): RateChangeRequest =>
({
id: 'req-1',
rateId: 'rate-1',
payload: { rateValue: 200 },
previousValues: { rateValue: '100.0000' },
status: 'PENDING',
requestedByUserId: 'staff-1',
}) as unknown as RateChangeRequest;
it('applies the change through RatesService and marks it approved', async () => {
const { service, rates } = build({ pending: pendingRequest() });
const decided = await service.approve('req-1', 'approver-1', 'Agreed');
expect(rates.applyApprovedUpdate).toHaveBeenCalledWith('rate-1', { rateValue: 200 });
expect(decided.status).toBe('APPROVED');
expect(decided.decidedByUserId).toBe('approver-1');
expect(decided.decisionNote).toBe('Agreed');
});
it('blocks the requester from approving their own change', async () => {
const { service, rates } = build({ pending: pendingRequest() });
await expect(service.approve('req-1', 'staff-1')).rejects.toThrow(ForbiddenException);
expect(rates.applyApprovedUpdate).not.toHaveBeenCalled();
});
it('lets a super admin self-approve', async () => {
const { service } = build({ pending: pendingRequest() });
await expect(service.approve('req-1', 'staff-1', undefined, true)).resolves.toMatchObject({
status: 'APPROVED',
});
});
it('stays PENDING when applying now fails — never marks a change that did not land', async () => {
const { service, pending, repo } = build({
pending: pendingRequest(),
applyThrows: new ConflictException('A rate for this exact combination already exists.'),
});
await expect(service.approve('req-1', 'approver-1')).rejects.toThrow(/already exists/);
// Apply runs first, so a failure leaves the request untouched and re-decidable.
expect(pending!.status).toBe('PENDING');
expect(repo.save).not.toHaveBeenCalled();
});
it('refuses to decide an already-decided request', async () => {
const { service } = build({
pending: { ...pendingRequest(), status: 'APPROVED' } as unknown as RateChangeRequest,
});
await expect(service.approve('req-1', 'approver-1')).rejects.toThrow(ConflictException);
});
});
describe('reject', () => {
it('never touches the rate — it simply keeps its current value', async () => {
const { service, rates } = build({
pending: {
id: 'req-1',
rateId: 'rate-1',
payload: { rateValue: 200 },
previousValues: { rateValue: '100.0000' },
status: 'PENDING',
requestedByUserId: 'staff-1',
} as unknown as RateChangeRequest,
});
const decided = await service.reject('req-1', 'approver-1', 'Too steep');
expect(decided.status).toBe('REJECTED');
expect(decided.decisionNote).toBe('Too steep');
expect(rates.applyApprovedUpdate).not.toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,241 @@
import { NotificationAudience, NotificationType } from '@edr/types';
import {
BadRequestException,
ConflictException,
ForbiddenException,
Injectable,
Logger,
NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { NotificationInboxService } from '../../notification-inbox/notification-inbox.service';
import { SubmitRateChangeDto } from '../dto/rate-change-request.dto';
import { UpdateRateDto } from '../dto/update-rate.dto';
import {
RateChangeRequest,
RateChangeStatus,
} from '../entities/rate-change-request.entity';
import { Rate } from '../entities/rate.entity';
import { RatesService } from './rates.service';
/** Backoffice page where both the queue and the rates live. */
const RATES_LINK = '/dashboard/rules/rates';
/** Fields a change request may carry — anything else in the patch is ignored. */
const DIFFABLE_FIELDS = [
'rateValue',
'currency',
'rateUnit',
'appliesTo',
'trigger',
'tradeDirection',
'containerTypeId',
'cargoTypeId',
] as const;
/**
* Approval workflow for edits to LIVE rates.
*
* A LIVE rate is what pricing charges right now, so it is never edited in
* place. The edit is filed here as a PENDING request and the live row keeps
* its old value — a rate at 100 USD keeps quoting 100 while a change to 200
* waits. Approval replays the edit through RatesService, so every rule
* (unit validity, pattern uniqueness) is re-checked against whatever is true
* at approval time, not at submit time.
*/
@Injectable()
export class RateChangeRequestsService {
private readonly logger = new Logger(RateChangeRequestsService.name);
constructor(
@InjectRepository(RateChangeRequest)
private readonly repo: Repository<RateChangeRequest>,
private readonly rates: RatesService,
private readonly inbox: NotificationInboxService,
) {}
/**
* File an edit against a LIVE rate. Validated up front so the requester
* hears about a bad unit or a pattern clash immediately rather than the
* approver hitting it days later.
*/
async submit(dto: SubmitRateChangeDto, userId?: string | null): Promise<RateChangeRequest> {
const rate = await this.rates.findById(dto.rateId);
if (rate.status !== 'LIVE') {
throw new BadRequestException(
`Only LIVE rates go through approval — this rate is ${rate.status} and can be edited directly.`,
);
}
const payload = this.changedFieldsOnly(rate, dto.update);
if (Object.keys(payload).length === 0) {
throw new BadRequestException('Nothing changed — the proposed values match the live rate.');
}
// One pending edit per rate: two racing requests would both validate, then
// the second would silently overwrite the first on approval.
const inFlight = await this.repo.findOne({
where: { rateId: dto.rateId, status: 'PENDING' },
});
if (inFlight) {
throw new ConflictException(
'This rate already has a change awaiting approval. Have it approved or rejected first.',
);
}
await this.rates.assertUpdateValid(dto.rateId, payload as UpdateRateDto);
const request = await this.repo.save(
this.repo.create({
rateId: dto.rateId,
payload,
previousValues: this.snapshot(rate, payload),
status: 'PENDING',
requestedByUserId: userId ?? null,
}),
);
this.notifyTeam(
'Rate change submitted',
`A change to a LIVE rate was submitted and awaits approval. The current rate stays in effect until it is approved.`,
request,
);
return request;
}
async list(status?: RateChangeStatus): Promise<RateChangeRequest[]> {
return this.repo.find({
where: status ? { status } : {},
relations: { rate: true },
order: { createdAt: 'DESC' },
});
}
/**
* Approve and apply. The live mutation runs FIRST — if it now fails (someone
* created a clashing rate since submit), the request stays PENDING and the
* approver sees the real error instead of a request marked approved that
* never landed.
*/
async approve(
id: string,
userId?: string | null,
decisionNote?: string,
canSelfApprove = false,
): Promise<RateChangeRequest> {
const request = await this.findPending(id);
// Separation of duties: the requester cannot approve their own repricing —
// except super admins, who have full backoffice authority.
if (!canSelfApprove && userId && userId === request.requestedByUserId) {
throw new ForbiddenException('You cannot approve a rate change you submitted');
}
await this.rates.applyApprovedUpdate(request.rateId, request.payload as UpdateRateDto);
request.status = 'APPROVED';
request.decidedByUserId = userId ?? null;
request.decidedAt = new Date();
request.decisionNote = decisionNote ?? null;
const saved = await this.repo.save(request);
this.notifyTeam(
'Rate change approved',
`The rate change was approved and is now live.` +
(decisionNote ? ` Note: ${decisionNote}` : ''),
saved,
);
return saved;
}
/** Reject — the live rate is never touched, so it simply keeps its value. */
async reject(
id: string,
userId?: string | null,
decisionNote?: string,
): Promise<RateChangeRequest> {
const request = await this.findPending(id);
request.status = 'REJECTED';
request.decidedByUserId = userId ?? null;
request.decidedAt = new Date();
request.decisionNote = decisionNote ?? null;
const saved = await this.repo.save(request);
this.notifyTeam(
'Rate change rejected',
`The rate change was rejected — the rate keeps its current value.` +
(decisionNote ? ` Note: ${decisionNote}` : ''),
saved,
);
return saved;
}
/**
* Keep only fields the requester actually changed. A form posts every field
* back, so without this the diff would list untouched values as changes.
*/
private changedFieldsOnly(rate: Rate, update: UpdateRateDto): Record<string, unknown> {
const patch: Record<string, unknown> = {};
for (const field of DIFFABLE_FIELDS) {
const proposed = (update as Record<string, unknown>)[field];
if (proposed === undefined) continue;
if (this.sameValue(proposed, (rate as unknown as Record<string, unknown>)[field])) continue;
patch[field] = proposed;
}
return patch;
}
/** The live values the patch would overwrite — the "before" side of the diff. */
private snapshot(rate: Rate, payload: Record<string, unknown>): Record<string, unknown> {
const before: Record<string, unknown> = {};
for (const field of Object.keys(payload)) {
before[field] = (rate as unknown as Record<string, unknown>)[field] ?? null;
}
return before;
}
/**
* rateValue arrives as a string from Postgres `numeric` but as a number from
* the form, so 100 and "100.0000" must compare equal or every submit would
* look like a change.
*/
private sameValue(a: unknown, b: unknown): boolean {
if (a === b) return true;
if (a == null && b == null) return true;
if (a == null || b == null) return false;
const numA = Number(a);
const numB = Number(b);
if (!Number.isNaN(numA) && !Number.isNaN(numB) && a !== '' && b !== '') {
return numA === numB;
}
return String(a) === String(b);
}
private async findPending(id: string): Promise<RateChangeRequest> {
const request = await this.repo.findOne({ where: { id }, relations: { rate: true } });
if (!request) throw new NotFoundException(`Rate change request ${id} not found`);
if (request.status !== 'PENDING') {
throw new ConflictException(`Rate change request is already ${request.status.toLowerCase()}`);
}
return request;
}
/** Fire-and-forget — a notification failure never blocks the workflow. */
private notifyTeam(title: string, body: string, request: RateChangeRequest): void {
void this.inbox
.notify({
recipients: { allBackoffice: true },
audience: NotificationAudience.BACKOFFICE,
type: NotificationType.REQUEST_SUBMITTED,
title,
body,
link: RATES_LINK,
data: { rateChangeRequestId: request.id, rateId: request.rateId },
})
.catch((err) =>
this.logger.warn(`Rate-change notification failed: ${(err as Error).message}`),
);
}
}

View File

@@ -119,12 +119,62 @@ export class RatesService {
});
}
/** Update a DRAFT rate. */
/**
* Update a DRAFT rate in place. Nothing prices off a draft, so a direct edit
* is safe. A LIVE rate cannot take this path — see `applyApprovedUpdate`.
*/
async update(id: string, dto: UpdateRateDto): Promise<Rate> {
const existing = await this.findById(id);
if (existing.status !== 'DRAFT') {
throw new BadRequestException('Only DRAFT rates can be updated');
throw new BadRequestException(
existing.status === 'LIVE'
? 'A LIVE rate cannot be edited directly — file a rate change request so an approver can apply it.'
: 'Only DRAFT rates can be updated',
);
}
return this.applyUpdate(existing, dto);
}
/**
* Apply an approved change request to a LIVE rate. Same validation as a
* DRAFT edit — it just skips the DRAFT guard, because a LIVE rate reaching
* here has already been through approval. Only ever called by
* RateChangeRequestsService.approve.
*/
async applyApprovedUpdate(id: string, dto: UpdateRateDto): Promise<Rate> {
const existing = await this.findById(id);
if (existing.status !== 'LIVE') {
throw new BadRequestException(
`Rate change requests apply to LIVE rates only — this rate is ${existing.status}.`,
);
}
return this.applyUpdate(existing, dto);
}
/**
* Validate a proposed patch against a rate without writing anything — lets a
* change request be refused at submit time instead of surprising the
* approver. Throws exactly what applying it would throw.
*/
async assertUpdateValid(id: string, dto: UpdateRateDto): Promise<void> {
await this.buildUpdate(await this.findById(id), dto);
}
private async applyUpdate(existing: Rate, dto: UpdateRateDto): Promise<Rate> {
const updates = await this.buildUpdate(existing, dto);
const updated = await this.repository.update(existing.id, updates);
if (!updated) throw new NotFoundException(`Rate ${existing.id} not found`);
return updated;
}
/**
* The shared edit body: re-derives rateType, re-validates the unit against
* the (possibly changed) shape, and guards pattern uniqueness. Status is
* never touched — an approved edit to a LIVE rate stays LIVE. Pure apart
* from the uniqueness read, so it doubles as the dry-run validator.
*/
private async buildUpdate(existing: Rate, dto: UpdateRateDto): Promise<Partial<Rate>> {
const id = existing.id;
const updates: Partial<Rate> = {};
const appliesTo = (dto.appliesTo as Rate['appliesTo']) ?? existing.appliesTo;
@@ -179,9 +229,7 @@ export class RatesService {
updates.currency = dto.currency ?? existing.currency ?? 'USD';
if (dto.rateValue !== undefined) updates.rateValue = dto.rateValue;
const updated = await this.repository.update(id, updates);
if (!updated) throw new NotFoundException(`Rate ${id} not found`);
return updated;
return updates;
}
/** Submit a DRAFT rate for CEO approval. */

View File

@@ -0,0 +1,89 @@
import { Injectable } from '@nestjs/common';
import { DataSource } from 'typeorm';
/** A yard's load/unload capability, resolved for the handling flows. */
export interface YardFacilityInfo {
yardId: string;
yardCode: string | null;
yardLabel: string | null;
/** The yard can load/unload cargo at all. */
hasFacility: boolean;
/** The facility stores cargo — enables the warehouse flow (storage, demurrage). */
hasWarehouse: boolean;
}
/**
* Which yards can handle cargo, and how.
*
* A yard is a load/unload point when `yards.has_facility` is set; the matching
* `yard_facilities` record says whether it also stores cargo. Facilities without a
* warehouse move cargo on and off the train and nothing more — no storage, no
* demurrage. This is the single resolver the journey and handling flows use, so
* they can't drift on what a facility is.
*/
@Injectable()
export class YardFacilitiesService {
constructor(private readonly dataSource: DataSource) {}
/** Resolve a yard's handling capability. Null when the yard doesn't exist. */
async facilityForYard(yardId: string): Promise<YardFacilityInfo | null> {
const [row]: Array<{
yardId: string;
yardCode: string | null;
yardLabel: string | null;
hasFacility: boolean;
hasWarehouse: boolean | null;
}> = await this.dataSource.query(
`SELECT y.id AS "yardId",
y.code AS "yardCode",
y.label AS "yardLabel",
y.has_facility AS "hasFacility",
f.has_warehouse AS "hasWarehouse"
FROM freight.yards y
LEFT JOIN freight.yard_facilities f
ON f.yard_id = y.id AND f.deleted_at IS NULL AND f.is_active = true
WHERE y.id = $1 AND y.deleted_at IS NULL`,
[yardId],
);
if (!row) return null;
return {
yardId: row.yardId,
yardCode: row.yardCode,
yardLabel: row.yardLabel,
hasFacility: Boolean(row.hasFacility),
// No facility record means no warehouse, whatever the flag says.
hasWarehouse: Boolean(row.hasFacility) && Boolean(row.hasWarehouse),
};
}
/** Every yard that can load/unload, for pickers and the intercity queues. */
async listFacilityYards(): Promise<YardFacilityInfo[]> {
const rows: Array<{
yardId: string;
yardCode: string | null;
yardLabel: string | null;
hasFacility: boolean;
hasWarehouse: boolean | null;
}> = await this.dataSource.query(
`SELECT y.id AS "yardId",
y.code AS "yardCode",
y.label AS "yardLabel",
y.has_facility AS "hasFacility",
f.has_warehouse AS "hasWarehouse"
FROM freight.yards y
LEFT JOIN freight.yard_facilities f
ON f.yard_id = y.id AND f.deleted_at IS NULL AND f.is_active = true
WHERE y.deleted_at IS NULL
AND y.is_active = true
AND y.has_facility = true
ORDER BY y.display_order ASC, y.label ASC`,
);
return rows.map((r) => ({
yardId: r.yardId,
yardCode: r.yardCode,
yardLabel: r.yardLabel,
hasFacility: true,
hasWarehouse: Boolean(r.hasWarehouse),
}));
}
}

View File

@@ -45,6 +45,7 @@ export class YardsService {
label: dto.label,
country: dto.country,
isActive: dto.isActive ?? true,
hasFacility: dto.hasFacility ?? false,
displayOrder,
});
}

View File

@@ -887,7 +887,7 @@ describe('BookingBatchService — wagonsFor', () => {
freightType: 'CONTAINER',
cargoTotalWeightVgm: 210,
bookingContainers: [
{ quantity: 2, wagonsRequired: 2, containerType: { wagonsPerUnit: 1, sizeFt: 40 } },
{ quantity: 2, wagonsRequired: 2, containerType: { sizeFt: 40 } },
],
};
expect(service.wagonsFor(booking, dims)).toBe(3);
@@ -899,7 +899,7 @@ describe('BookingBatchService — wagonsFor', () => {
freightType: 'CONTAINER',
cargoTotalWeightVgm: 40,
bookingContainers: [
{ quantity: 4, wagonsRequired: 2, containerType: { wagonsPerUnit: 0.5, sizeFt: 20 } },
{ quantity: 4, wagonsRequired: 2, containerType: { sizeFt: 20 } },
],
};
expect(service.wagonsFor(booking, dims)).toBe(2);
@@ -939,7 +939,7 @@ describe('BookingBatchService — wagonsFor', () => {
{
quantity: 2,
wagonsRequired: 2,
containerType: { wagonsPerUnit: 1, sizeFt: 40, wagonTypes: [{ id: 'pw2-id' }] },
containerType: { sizeFt: 40, wagonTypes: [{ id: 'pw2-id' }] },
},
],
};
@@ -950,3 +950,106 @@ describe('BookingBatchService — wagonsFor', () => {
});
});
});
describe('BookingBatchService — built-train wagon capacity', () => {
// A schedule created from a built train is capped by its PHYSICAL consist:
// wagon count only. The locomotive here is deliberately tiny (1T / 1m) — the
// old weight/length math would call every one of these trains FULL, so any
// assertion below that says "not full" proves those axes are ignored.
const scheduleId = 'schedule-built';
const reservedBooking = (id: string) =>
({
id,
freightType: 'BULK',
cargoTotalWeightVgm: 50, // 1 wagon at the 60T default bulk payload
bookingContainers: [],
originYardId: 'yard-a',
destinationYardId: 'yard-b',
}) as unknown as Booking;
const buildService = (opts: {
physicalWagons: number;
reserved: Booking[];
maxWagons?: number;
}) => {
const schedule = {
id: scheduleId,
maxWagons: opts.maxWagons ?? 44, // stale locomotive-derived cap on purpose
bookingWindowStatus: 'OPEN',
originStationId: 'yard-a',
destinationStationId: 'yard-b',
routeId: null,
scheduleBookings: [],
trainSet: {
locomotive: {
maxPullWeightTons: 1,
maxTrainLengthMeters: 1,
overageToleranceTons: 0,
overageToleranceMeters: 0,
},
train: { id: 'train-built-1' },
},
};
const wagonRepo = { count: jest.fn().mockResolvedValue(opts.physicalWagons) };
const genericRepo = {
find: jest.fn().mockResolvedValue([]),
update: jest.fn().mockResolvedValue(undefined),
};
const dataSource = {
getRepository: jest.fn((entity: { name?: string }) =>
entity?.name === 'Wagon' ? wagonRepo : genericRepo,
),
transaction: jest.fn(),
};
const service = new BookingBatchService(
dataSource as never,
{
findReservedForSchedule: jest.fn().mockResolvedValue(opts.reserved),
} as never,
{
findByIdWithFullGraph: jest.fn().mockResolvedValue(schedule),
findById: jest.fn().mockResolvedValue(schedule),
} as never,
null as never,
null as never,
null as never,
null as never,
null as never,
{ emitPhase: jest.fn() } as never,
null as never,
);
return { service, wagonRepo };
};
it('is FULL when bookings hold every physical wagon, even with loco-derived slots free', async () => {
const { service } = buildService({
physicalWagons: 2,
reserved: [reservedBooking('b1'), reservedBooking('b2')],
maxWagons: 44, // stale: the old slot cap would say 42 slots remain
});
await expect(service.isScheduleFull(scheduleId)).resolves.toBe(true);
});
it('is NOT full while physical wagons remain, ignoring weight/length limits', async () => {
const { service } = buildService({
physicalWagons: 3,
reserved: [reservedBooking('b1'), reservedBooking('b2')],
});
// 1T pull cap would have been exhausted long ago under the old math.
await expect(service.isScheduleFull(scheduleId)).resolves.toBe(false);
});
it('reports over-allocation when the consist is trimmed below committed bookings', async () => {
const { service } = buildService({
physicalWagons: 1,
reserved: [reservedBooking('b1'), reservedBooking('b2')],
});
await expect(service.scheduleWagonUsage(scheduleId)).resolves.toEqual({
maxWagons: 1,
allocatedWagons: 2,
remainingSlots: 0,
overAllocatedBy: 1,
});
});
});

View File

@@ -68,6 +68,7 @@ import {
wagonTypeDimensionsFromEntity,
} from './train-capacity.util';
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
import { Wagon } from '../wagons/entities/wagon.entity';
import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service';
import { BookingSplitService } from './booking-split.service';
import { BookingWindowGateway } from './booking-window.gateway';
@@ -305,6 +306,9 @@ export class BookingBatchService implements OnModuleInit {
private readonly trainScheduleBookingsRepository: TrainScheduleBookingsRepository,
private readonly notifier: BookingNotifierService,
private readonly scheduler: SchedulerRegistry,
// forwardRef: TrainSchedulingService injects this service back (window
// refresh after adjust-consist), so the classes load in a cycle.
@Inject(forwardRef(() => TrainSchedulingService))
private readonly trainSchedulingService: TrainSchedulingService,
private readonly billing: BillingService,
private readonly bookingWindowGateway: BookingWindowGateway,
@@ -2915,7 +2919,7 @@ export class BookingBatchService implements OnModuleInit {
? Math.ceil(booking.wagonsRequired)
: 0;
// TEU-aware: two 20ft share one wagon (wagonsPerUnit = 0.5). The old fallback
// TEU-aware: two 20ft share one wagon (half a wagon each). The old fallback
// summed raw container QUANTITY, so 20×20ft counted as 20 wagons, not 10.
const byLength = containerWagonsForLines(booking.bookingContainers ?? []);
@@ -2993,17 +2997,20 @@ export class BookingBatchService implements OnModuleInit {
}
/**
* Keep schedule.max_wagons aligned with the train's boarding limit: the
* locomotive's length-derived slot count. The physical wagons currently in
* the train set do NOT cap this — bookings are admitted on length/weight
* alone and yard staff attach the wagons manually before departure.
* Keep schedule.max_wagons aligned with the train's boarding limit. A built
* train's limit is its physical consist — the wagon count staff marshalled
* (and may change via adjust-consist). Only schedules WITHOUT a built train
* fall back to the locomotive's length-derived slot count, where bookings
* are admitted on length/weight alone and yard staff attach the wagons
* manually before departure.
*/
private async syncScheduleMaxWagons(
schedule: TrainSchedule,
locomotive: Locomotive,
): Promise<void> {
const limits = await this.capacityLimits(locomotive);
const maxWagons = limits.base.wagons;
const physicalWagons = await this.builtTrainWagonCount(schedule);
const maxWagons =
physicalWagons ?? (await this.capacityLimits(locomotive)).base.wagons;
if ((schedule.maxWagons ?? 0) !== maxWagons) {
await this.dataSource
.getRepository(TrainSchedule)
@@ -3122,16 +3129,31 @@ export class BookingBatchService implements OnModuleInit {
* reserved bookings already use ON THEIR OWN LEGS. A booking riding only
* Dire→Djibouti leaves the Addis→Dire edges untouched.
*
* The wagon axis is the locomotive's length-derived slot count only — the
* physical wagons currently marshalled in the train set do NOT cap it.
* Bookings are admitted on length/weight capacity and yard staff attach
* the missing wagons manually before wagon assignment.
* Two capacity regimes, decided by the schedule's train:
* - Built train (Train Builder consist with physical wagons): the consist IS
* the capacity. Wagon slots = physical wagon count; weight and length are
* NOT re-checked here — the builder and adjust-consist already enforced the
* locomotive's pull/length limits when the consist was assembled.
* - No built train (legacy schedules): the locomotive's length-derived slot
* count plus its weight/length budgets, as before — yard staff attach the
* missing wagons manually before wagon assignment.
*/
private async remainingBudget(
schedule: TrainSchedule,
limits: TrainLimits,
wagonDims: WagonDims,
): Promise<CorridorBudget> {
const physicalWagons = await this.builtTrainWagonCount(schedule);
if (physicalWagons != null) {
limits = {
base: {
wagons: physicalWagons,
weightTons: Number.POSITIVE_INFINITY,
lengthMeters: Number.POSITIVE_INFINITY,
},
tolerance: { weightTons: 0, lengthMeters: 0 },
};
}
const stops = await this.stopsForSchedule(schedule);
const budget = new CorridorBudget(stops, limits.base, limits.tolerance);
const allocated = (schedule.scheduleBookings ?? [])
@@ -3149,6 +3171,23 @@ export class BookingBatchService implements OnModuleInit {
return budget;
}
/**
* Physical wagons marshalled in the schedule's built train, or null when the
* schedule has no built train (or the consist is still empty) and the legacy
* locomotive-derived capacity must apply. This count is what caps a built
* train's bookings: 50 wagons coupled → 50 wagon slots, no more.
*/
private async builtTrainWagonCount(
schedule: TrainSchedule,
): Promise<number | null> {
const trainId = schedule.trainSet?.train?.id;
if (!trainId) return null;
const count = await this.dataSource
.getRepository(Wagon)
.count({ where: { trainId } });
return count > 0 ? count : null;
}
/**
* Wagon slots still boardable somewhere on the corridor (most-open edge).
* ≤ 0 means no leg can take another booking. Slot axis ONLY — the train-wide
@@ -3219,11 +3258,14 @@ export class BookingBatchService implements OnModuleInit {
}
/**
* FULL on ANY capacity axis: out of wagon slots, or out of pull weight /
* train length for even one more loaded wagon. The old slot-only check let
* a weight-bound train (PW2: weight binds at 37 wagons = 3522.4T of
* 3500+90T, slots bind at 44) cycle its booking window forever instead of
* finalizing — 7 phantom slots kept it "not full" while nothing could board.
* Built train: FULL when every physical wagon slot is taken — the consist is
* the capacity, weight/length were settled at build time.
* No built train: FULL on ANY capacity axis — out of wagon slots, or out of
* pull weight / train length for even one more loaded wagon. The old
* slot-only check let a weight-bound train (PW2: weight binds at 37 wagons =
* 3522.4T of 3500+90T, slots bind at 44) cycle its booking window forever
* instead of finalizing — 7 phantom slots kept it "not full" while nothing
* could board.
*/
async isScheduleFull(scheduleId: string): Promise<boolean> {
const schedule =
@@ -3232,9 +3274,53 @@ export class BookingBatchService implements OnModuleInit {
return this.isTrainFull(schedule);
}
/**
* Wagon-slot usage snapshot for staff UIs (adjust-consist dialog): the
* schedule's slot capacity, how many slots allocated + reserved bookings
* already hold on the busiest edge, how many are still free on the most-open
* edge, and by how many slots the consist has been trimmed BELOW what is
* already committed (0 when nothing is over-allocated).
*/
async scheduleWagonUsage(scheduleId: string): Promise<{
maxWagons: number;
allocatedWagons: number;
remainingSlots: number;
overAllocatedBy: number;
} | null> {
const schedule =
await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule) return null;
const capacity =
(await this.builtTrainWagonCount(schedule)) ?? schedule.maxWagons ?? 0;
const wagonDims = await this.loadWagonDims();
const budget = await this.remainingBudget(
schedule,
{
base: {
wagons: capacity,
weightTons: Number.POSITIVE_INFINITY,
lengthMeters: Number.POSITIVE_INFINITY,
},
tolerance: { weightTons: 0, lengthMeters: 0 },
},
wagonDims,
);
const tightest = budget.remainingFor(budget.fullLeg()).wagons;
return {
maxWagons: capacity,
allocatedWagons: capacity - tightest,
remainingSlots: Math.max(0, budget.maxRemaining().wagons),
overAllocatedBy: Math.max(0, -tightest),
};
}
/** See {@link isScheduleFull} — same check for callers that already hold the full graph. */
private async isTrainFull(schedule: TrainSchedule): Promise<boolean> {
if ((await this.remainingWagons(schedule)) <= 0) return true;
// Built train: the physical consist is the only capacity axis. Weight and
// length were enforced when the consist was assembled (builder /
// adjust-consist), so a free wagon slot means the train genuinely has room.
if ((await this.builtTrainWagonCount(schedule)) != null) return false;
const locomotive = schedule.trainSet?.locomotive;
if (!locomotive) return false; // no weight/length limits to bind against
const wagonDims = await this.loadWagonDims();

View File

@@ -9,6 +9,8 @@ import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource, EntityManager, In } from 'typeorm';
import { Freight } from '@edr/types';
import { YardFacilitiesService } from '../rule-engine/services/yard-facilities.service';
import { FacilityHandlingService } from './facility-handling.service';
import { Booking } from '../bookings/entities/booking.entity';
import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service';
import { Yard } from '../rule-engine/entities/yard.entity';
@@ -43,6 +45,8 @@ export class BookingJourneyService {
constructor(
@InjectDataSource() private readonly dataSource: DataSource,
private readonly yardFacilities: YardFacilitiesService,
private readonly facilityHandling: FacilityHandlingService,
@Optional() private readonly milestoneService?: ClearanceMilestoneService,
) {}
@@ -63,6 +67,7 @@ export class BookingJourneyService {
);
}
await this.assertTrainAtYard(schedule, booking.originYardId, 'origin');
await this.assertYardCanHandleCargo(booking, booking.originYardId, 'origin');
const now = new Date();
await this.dataSource.transaction(async (manager) => {
@@ -72,6 +77,16 @@ export class BookingJourneyService {
loadedByUserId: userId ?? null,
} as never);
await this.setAllocationStatuses(manager, scheduleId, bookingId, 'LOADED');
// The facility handed the cargo over — raise its GRN. No-ops for yards
// without a facility (import/export terminals), which keep their own flow.
await this.facilityHandling.recordHandling(manager, {
booking,
yardId: booking.originYardId,
trainScheduleId: scheduleId,
eventType: 'LOAD',
performedBy: userId ?? null,
occurredAt: now,
});
});
// Customer tracking: cargo is on the train — loading milestones plus the
@@ -99,6 +114,7 @@ export class BookingJourneyService {
);
}
await this.assertTrainAtYard(schedule, booking.destinationYardId, 'destination');
await this.assertYardCanHandleCargo(booking, booking.destinationYardId, 'destination');
// Intercity has no clearance/delivery tail — unloading completes it. Import/
// export continue into clearance, keyed on the booking's own arrival.
@@ -112,6 +128,17 @@ export class BookingJourneyService {
} as never);
await this.setAllocationStatuses(manager, scheduleId, bookingId, 'DEPARTED');
await this.settleWagonsOnUnload(manager, schedule, booking, now, userId ?? null);
// The facility took the cargo off the train — raise its GRN. Where the
// facility also stores cargo (Indode), the event links the storage record
// that storage/demurrage accrue against.
await this.facilityHandling.recordHandling(manager, {
booking,
yardId: booking.destinationYardId,
trainScheduleId: scheduleId,
eventType: 'UNLOAD',
performedBy: userId ?? null,
occurredAt: now,
});
});
// Customer tracking: THIS booking arrived (train may still be rolling).
@@ -306,6 +333,33 @@ export class BookingJourneyService {
});
}
/**
* INTERCITY ONLY. Intercity cargo rides a passing train and is handled at the
* booking's own yards, so those yards need the equipment to do it — a train
* stopping somewhere is not the same as somewhere being able to load it.
*
* Import/export are untouched: their cargo is handled at the route's terminal
* ports, not at an arbitrary mid-corridor yard, and gating them here would
* block existing traffic.
*
* Lives here rather than in the controller so the checkpoint-driven
* autoUnloadAtYard path cannot route around it.
*/
private async assertYardCanHandleCargo(
booking: Booking,
yardId: string,
side: 'origin' | 'destination',
): Promise<void> {
if (booking.tradeDirection !== 'DOMESTIC') return;
const facility = await this.yardFacilities.facilityForYard(yardId);
if (!facility?.hasFacility) {
throw new BadRequestException(
`${facility?.yardLabel ?? 'This yard'} has no load/unload facility — an intercity booking cannot be ` +
`${side === 'origin' ? 'loaded at its origin' : 'unloaded at its destination'} here.`,
);
}
}
/**
* The train is "at" a yard when the latest recorded checkpoint is that yard,
* or — for a booking boarding at the train's own origin — when the train has

View File

@@ -3,20 +3,6 @@ import { Type } from 'class-transformer';
import { IsInt, IsNumber, IsOptional, Max, Min } from 'class-validator';
export class UpdateTrainSchedulingGlobalRulesDto {
@ApiPropertyOptional({ example: 760 })
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(1)
maxTrainLengthMeters?: number;
@ApiPropertyOptional({ example: 3500 })
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(1)
maxTrainWeightTons?: number;
@ApiPropertyOptional({ example: 53 })
@IsOptional()
@Type(() => Number)
@@ -24,20 +10,6 @@ export class UpdateTrainSchedulingGlobalRulesDto {
@Min(1)
maxWagonsPerTrain?: number;
@ApiPropertyOptional({ example: 30 })
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(0.001)
max20ftContainerWeightTons?: number;
@ApiPropertyOptional({ example: 10 })
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(0)
max20ftPairWeightDiffTons?: number;
@ApiPropertyOptional({ example: 3, description: 'Days before departure the import booking-window day falls on' })
@IsOptional()
@Type(() => Number)

View File

@@ -0,0 +1,56 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index } from 'typeorm';
export const FACILITY_HANDLING_EVENT_TYPES = ['LOAD', 'UNLOAD'] as const;
export type FacilityHandlingEventType = (typeof FACILITY_HANDLING_EVENT_TYPES)[number];
/**
* Cargo loaded onto or unloaded off a train at a yard's facility, and the GRN
* raised for it.
*
* This exists because warehouse_inventory can't do the job: its
* warehouse/yard/zone are NOT NULL, so a facility that only has equipment and no
* warehouse (Sebeta, Modjo, Adama, Dire Dawa) could never have a row there —
* yet it still hands cargo over and still needs a GRN.
*
* `inventoryId` links to the warehouse record when the facility does store cargo
* (Indode), which is what makes storage and demurrage accrue there and nowhere
* else.
*/
@Entity({ schema: 'freight', name: 'facility_handling_events' })
@Index(['bookingId'])
@Index(['yardId'])
export class FacilityHandlingEvent extends BaseEntity {
@Column({ name: 'booking_id', type: 'uuid' })
bookingId!: string;
/** The facility yard where the cargo was handled. */
@Column({ name: 'yard_id', type: 'uuid' })
yardId!: string;
/** The train the cargo came off / went onto. */
@Column({ name: 'train_schedule_id', type: 'uuid', nullable: true })
trainScheduleId?: string | null;
@Column({ name: 'event_type', type: 'varchar', length: 10 })
eventType!: FacilityHandlingEventType;
@Column({ name: 'grn_number', type: 'varchar', length: 60, nullable: true })
grnNumber?: string | null;
@Column({ name: 'quantity', type: 'numeric', precision: 14, scale: 3, nullable: true })
quantity?: number | null;
@Column({ name: 'weight_tons', type: 'numeric', precision: 14, scale: 3, nullable: true })
weightTons?: number | null;
/** Set only when the facility stores cargo (has_warehouse) — the storage record. */
@Column({ name: 'inventory_id', type: 'uuid', nullable: true })
inventoryId?: string | null;
@Column({ name: 'performed_by', type: 'varchar', length: 120, nullable: true })
performedBy?: string | null;
@Column({ name: 'occurred_at', type: 'timestamptz', default: () => 'now()' })
occurredAt!: Date;
}

View File

@@ -0,0 +1,97 @@
import { Injectable, Logger } from '@nestjs/common';
import { EntityManager } from 'typeorm';
import { generateGrnNumber } from '../../common/grn.util';
import { YardFacilitiesService } from '../rule-engine/services/yard-facilities.service';
import { Booking } from '../bookings/entities/booking.entity';
import {
FacilityHandlingEvent,
FacilityHandlingEventType,
} from './entities/facility-handling-event.entity';
/**
* Records cargo being loaded/unloaded at a yard's facility, and raises its GRN.
*
* Every facility raises a GRN — the goods changed hands, whether or not anyone
* stores them. What differs is what happens next: a facility with a warehouse
* (Indode) keeps the cargo, so it goes through the normal warehouse flow and
* accrues storage/demurrage; the rest only move it between train and truck, so
* the event and its GRN are the whole record.
*
* Best-effort by design: a failure here must not undo a load/unload that
* physically happened.
*/
@Injectable()
export class FacilityHandlingService {
private readonly logger = new Logger(FacilityHandlingService.name);
constructor(private readonly yardFacilities: YardFacilitiesService) {}
/**
* Write the handling event and mint its GRN. Returns the GRN, or null when the
* yard has no facility (nothing to record) or the write failed.
*/
async recordHandling(
manager: EntityManager,
input: {
booking: Booking;
yardId: string;
trainScheduleId?: string | null;
eventType: FacilityHandlingEventType;
performedBy?: string | null;
occurredAt?: Date;
},
): Promise<string | null> {
const { booking, yardId, eventType } = input;
try {
const facility = await this.yardFacilities.facilityForYard(yardId);
if (!facility?.hasFacility) return null;
const occurredAt = input.occurredAt ?? new Date();
const grnNumber = generateGrnNumber(
booking.tradeDirection ?? 'DOMESTIC',
booking.id,
occurredAt,
);
// Link the storage record when this facility keeps cargo — that link is
// what ties an Indode handover to its storage/demurrage.
let inventoryId: string | null = null;
if (facility.hasWarehouse) {
const [inv]: Array<{ id: string }> = await manager.query(
`SELECT id FROM freight.warehouse_inventory
WHERE booking_id = $1 AND deleted_at IS NULL
ORDER BY created_at DESC LIMIT 1`,
[booking.id],
);
inventoryId = inv?.id ?? null;
}
const repo = manager.getRepository(FacilityHandlingEvent);
await repo.save(
repo.create({
bookingId: booking.id,
yardId,
trainScheduleId: input.trainScheduleId ?? null,
eventType,
grnNumber,
weightTons: Number(booking.cargoTotalWeightVgm) || null,
inventoryId,
performedBy: input.performedBy ?? null,
occurredAt,
}),
);
this.logger.log(
`GRN ${grnNumber} raised on ${eventType} at ${facility.yardCode ?? yardId} for booking ${booking.reference ?? booking.id}`,
);
return grnNumber;
} catch (err) {
// The cargo moved regardless — never fail the journey over the paperwork.
this.logger.error(
`Facility ${eventType} record failed for booking ${booking.id} at yard ${yardId}: ${String(err)}`,
);
return null;
}
}
}

View File

@@ -56,7 +56,7 @@ export function wagonsRequiredForBooking(booking: Booking, bulkWagonCapacity?: n
}
// TEU-aware, ceiled once at the booking level (40ft = 1 wagon, two 20ft = 1
// wagon). Honors containerType.wagonsPerUnit; falls back to the line's stored
// wagon). Derived from containerType.sizeFt; falls back to the line's stored
// fraction. Ceiling per line would over-count split 20ft lines.
return Math.max(1, containerWagonsForLines(booking.bookingContainers ?? []));
}

View File

@@ -7,6 +7,8 @@ import { BookingsModule } from '../bookings/bookings.module';
import { Container } from '../container-management/entities/container.entity';
import { LocomotivesModule } from '../locomotives/locomotives.module';
import { RuleEngineModule } from '../rule-engine/rule-engine.module';
import { FacilityHandlingService } from './facility-handling.service';
import { FacilityHandlingEvent } from './entities/facility-handling-event.entity';
import { Locomotive } from '../locomotives/entities/locomotive.entity';
import { Route } from '../routes/entities/route.entity';
import { TrainSetLocomotive } from '../train-sets/entities/train-set-locomotive.entity';
@@ -41,6 +43,7 @@ import { ContractsModule } from '../contracts/contracts.module';
@Module({
imports: [
TypeOrmModule.forFeature([
FacilityHandlingEvent,
Locomotive,
WagonType,
TrainSet,
@@ -81,6 +84,7 @@ import { ContractsModule } from '../contracts/contracts.module';
BookingSplitService,
IntercityService,
BookingJourneyService,
FacilityHandlingService,
],
exports: [
TrainSchedulingService,

View File

@@ -12,6 +12,8 @@
import {
BadRequestException,
ConflictException,
forwardRef,
Inject,
Injectable,
Logger,
NotFoundException,
@@ -96,6 +98,7 @@ import { MaintenanceRescheduleDto } from './dto/maintenance-reschedule.dto';
import { type BookingWindowConfig } from './booking-window.config';
import { BookingWindowGateway } from './booking-window.gateway';
import { BookingNotifierService } from './booking-notifier.service';
import { BookingBatchService } from './booking-batch.service';
import {
computeFleetAvailability,
summarizeFleetWarnings,
@@ -318,6 +321,11 @@ export class TrainSchedulingService {
private readonly bookingNotifier: BookingNotifierService,
@Optional() private readonly milestoneService?: ClearanceMilestoneService,
private readonly configService?: ConfigService,
// forwardRef: BookingBatchService injects this service back; @Optional so
// existing specs that construct the service without it keep working.
@Optional()
@Inject(forwardRef(() => BookingBatchService))
private readonly bookingBatchService?: BookingBatchService,
) {}
/**
@@ -592,7 +600,24 @@ export class TrainSchedulingService {
}
async getTrainSchedulingGlobalRules() {
return this.loadGlobalRulesRow();
return this.toPublicGlobalRules(await this.loadGlobalRulesRow());
}
/**
* Train length/weight and 20ft weight caps are engine-internal (wagon
* planning still reads them off the row); they are no longer exposed or
* editable through the global-rules endpoints.
*/
private toPublicGlobalRules(row: TrainSchedulingGlobalRules | null) {
if (!row) return row;
const {
maxTrainLengthMeters: _len,
maxTrainWeightTons: _wt,
max20ftContainerWeightTons: _cw,
max20ftPairWeightDiffTons: _pd,
...pub
} = row;
return pub;
}
async updateTrainSchedulingGlobalRules(dto: UpdateTrainSchedulingGlobalRulesDto) {
@@ -600,15 +625,7 @@ export class TrainSchedulingService {
if (!row) {
throw new NotFoundException('Train scheduling global rules not configured');
}
if (dto.maxTrainLengthMeters != null) row.maxTrainLengthMeters = dto.maxTrainLengthMeters;
if (dto.maxTrainWeightTons != null) row.maxTrainWeightTons = dto.maxTrainWeightTons;
if (dto.maxWagonsPerTrain != null) row.maxWagonsPerTrain = dto.maxWagonsPerTrain;
if (dto.max20ftContainerWeightTons != null) {
row.max20ftContainerWeightTons = dto.max20ftContainerWeightTons;
}
if (dto.max20ftPairWeightDiffTons != null) {
row.max20ftPairWeightDiffTons = dto.max20ftPairWeightDiffTons;
}
if (dto.importWindowLeadDays != null) row.importWindowLeadDays = dto.importWindowLeadDays;
if (dto.exportBookingLeadHours != null) row.exportBookingLeadHours = dto.exportBookingLeadHours;
if (dto.windowOpenHour != null) row.windowOpenHour = dto.windowOpenHour;
@@ -646,7 +663,7 @@ export class TrainSchedulingService {
await this.restampPendingWindows();
}
return saved;
return this.toPublicGlobalRules(saved);
}
/**
@@ -5109,6 +5126,11 @@ export class TrainSchedulingService {
wagons.reduce((sum, w) => sum + Number(w.wagonType?.lengthMeters ?? 0), 0),
);
// Wagon-slot picture for the dialog: the consist IS the schedule's booking
// capacity, so trimming/coupling wagons moves the FULL line live.
const wagonUsage =
(await this.bookingBatchService?.scheduleWagonUsage(scheduleId)) ?? null;
const mapWagon = (wagon: Wagon) => ({
id: wagon.id,
wagonNumber: wagon.wagonNumber,
@@ -5147,6 +5169,12 @@ export class TrainSchedulingService {
grossTons: roundTons(cargoTons + consistTareTons),
consistLengthMeters,
},
scheduleCapacity: wagonUsage
? {
...wagonUsage,
bookingWindowStatus: schedule.bookingWindowStatus ?? null,
}
: null,
wagons: wagons.map((wagon) => ({
...mapWagon(wagon),
loaded: loadedWagonIds.has(wagon.id),
@@ -5339,7 +5367,37 @@ export class TrainSchedulingService {
);
});
return this.getScheduleConsist(scheduleId);
// The consist IS the schedule's booking capacity, so an edit moves the
// FULL line: freeing slots on a FULL schedule reopens its window, taking
// the last slot closes it. Staff may shrink below what is already
// committed — allowed, but reported back as a warning (never silently).
const warnings: string[] = [];
const wasFull = schedule.bookingWindowStatus === 'FULL';
const usage = await this.bookingBatchService?.scheduleWagonUsage(scheduleId);
if (usage) {
const nowFull = usage.remainingSlots <= 0;
if (usage.overAllocatedBy > 0) {
warnings.push(
`The consist now has ${usage.maxWagons} wagon slot(s) but bookings already hold ` +
`${usage.allocatedWagons}${usage.overAllocatedBy} wagon(s) over capacity. ` +
'Couple more wagons or free bookings before departure.',
);
}
if (wasFull && !nowFull) {
await this.bookingBatchService?.refreshWindowStatus(scheduleId);
warnings.push(
`This schedule was FULL — the consist change freed ${usage.remainingSlots} wagon slot(s), ` +
'so it is no longer FULL and can take bookings again.',
);
} else if (!wasFull && nowFull) {
await this.bookingBatchService?.setWindow(scheduleId, 'FULL');
warnings.push(
'Every wagon slot is now taken — the schedule is FULL and stops accepting bookings.',
);
}
}
return { ...(await this.getScheduleConsist(scheduleId)), warnings };
}
/**

View File

@@ -77,7 +77,6 @@ describe('planWagonsWithStock — shortage detail', () => {
fortyFooter.bookingContainers![0]!.containerType = {
code: '40GP',
sizeFt: 40,
wagonsPerUnit: 1,
} as never;
const result = planWagonsWithStock({
bookings: [fortyFooter],

View File

@@ -106,7 +106,7 @@ describe('wagon-plan.util', () => {
});
it('6×20ft containers = 3 wagon slots (2 per wagon)', () => {
// 20ft containers have wagonsPerUnit = 0.5, so 6 * 0.5 = 3 wagons
// 20ft containers take half a wagon each, so 6 * 0.5 = 3 wagons
const booking = makeContainerBooking('b6x20', [{ quantity: 6, wagonsRequired: 3 }]);
expect(sumWagonsRequired(booking)).toBe(3);
const plan = buildContainerWagonPlan([booking], nw5);
@@ -227,7 +227,7 @@ describe('containerWagonsForLines — TEU-aware, ceil booking total once', () =>
const line = (quantity: number, wagonsPerUnit: number, wagonsRequired?: number) => ({
quantity,
wagonsRequired: wagonsRequired ?? quantity * wagonsPerUnit,
containerType: { wagonsPerUnit, sizeFt: wagonsPerUnit >= 1 ? 40 : 20 },
containerType: { sizeFt: wagonsPerUnit >= 1 ? 40 : 20 },
});
it('20×20ft = 10 wagons (not 20)', () => {
@@ -266,7 +266,7 @@ describe('containerWagonsForLines — TEU-aware, ceil booking total once', () =>
expect(containerWagonsForLines([line(21, 1)])).toBe(21);
});
it('falls back to line wagonsRequired when containerType/wagonsPerUnit missing', () => {
it('falls back to line wagonsRequired when containerType/sizeFt missing', () => {
// No containerType relation loaded → use the stored (0.5-aware) fraction.
expect(
containerWagonsForLines([

View File

@@ -1,6 +1,7 @@
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 { consistViolations } from './train-capacity.util';
@@ -61,7 +62,6 @@ export type ContainerUnitRow = {
label: string;
grossWeightTons: number;
sizeFt?: number;
wagonsPerUnit?: number;
containersPerWagon?: number;
teuSlots?: number;
containerNumber?: string | null;
@@ -95,33 +95,28 @@ export function teuSlotsForSizeFt(sizeFt: number): number {
return sizeFt >= 40 ? 2 : 1;
}
export function containersPerWagonFromType(wagonsPerUnit: number): number {
const wpu = Number(wagonsPerUnit);
if (!wpu || wpu <= 0) return 1;
return Math.max(1, Math.round(1 / wpu));
}
type ContainerLine = {
quantity?: number | null;
wagonsRequired?: number | null;
containerType?: { wagonsPerUnit?: number | null; sizeFt?: number | null } | null;
containerType?: { sizeFt?: number | null } | null;
};
/**
* RAW (un-ceiled) wagon fraction one container line occupies: qty × wagonsPerUnit
* (40ft = 1, 20ft = 0.5). Two 20ft = 1.0, three 20ft = 1.5. Kept fractional so
* the BOOKING total is ceiled once — ceiling per line over-counts a booking that
* splits its 20ft units across several lines (3×20 + 3×20 = 3 wagons, not 4).
* RAW (un-ceiled) wagon fraction one container line occupies: qty × size-derived
* fraction (40ft = 1, 20ft = 0.5). Two 20ft = 1.0, three 20ft = 1.5. Kept
* fractional so the BOOKING total is ceiled once — ceiling per line over-counts a
* booking that splits its 20ft units across several lines (3×20 + 3×20 = 3
* wagons, not 4).
*/
function lineWagonsRaw(line: ContainerLine): number {
const qty = Number(line.quantity ?? 0);
if (qty <= 0) return 0;
const wpu = Number(line.containerType?.wagonsPerUnit);
if (Number.isFinite(wpu) && wpu > 0) {
return qty * wpu;
const sizeFt = Number(line.containerType?.sizeFt);
if (Number.isFinite(sizeFt) && sizeFt > 0) {
return qty * wagonsPerUnitForSize(sizeFt);
}
// No wagonsPerUnit on the type: fall back to the line's stored fraction, else
// treat the whole line as one wagon.
// No size on the type: fall back to the line's stored fraction, else treat
// the whole line as one wagon.
const stored = Number(line.wagonsRequired);
return Number.isFinite(stored) && stored > 0 ? stored : 1;
}
@@ -250,8 +245,7 @@ export function expandBookingContainerUnits(bookings: Booking[]): ContainerUnitR
const qty = Number(line.quantity ?? 0);
const code = line.containerType?.code ?? line.containerType?.label ?? 'Container';
const sizeFt = Number(line.containerType?.sizeFt ?? (code.includes('40') ? 40 : 20));
const wagonsPerUnit = Number(line.containerType?.wagonsPerUnit ?? (sizeFt >= 40 ? 1 : 0.5));
const perWagon = containersPerWagonFromType(wagonsPerUnit);
const perWagon = containersPerWagonForSize(sizeFt);
const teuSlots = teuSlotsForSizeFt(sizeFt);
// The REAL per-container numbers/weights entered at booking time. Unit i of
// the line maps to units[i] (sortOrder order); the line-level number is only
@@ -271,7 +265,6 @@ export function expandBookingContainerUnits(bookings: Booking[]): ContainerUnitR
label: `${booking.reference} · ${i + 1}/${qty} · ${code}`,
grossWeightTons: Number(unit?.vgmTons ?? line.vgmPerUnitTons),
sizeFt,
wagonsPerUnit,
containersPerWagon: perWagon,
teuSlots,
containerNumber:

View File

@@ -20,6 +20,16 @@ export class CreateWagonDto {
// Tare weight and payload capacity are not accepted here: they belong to the
// wagon type and are resolved through wagonTypeId.
/** EXPORT run number — odd, Ethiopia → Djibouti (e.g. 8001). */
@IsOptional()
@IsString()
exportTrainNumber?: string;
/** IMPORT run number — even, Djibouti → Ethiopia (e.g. 8002). */
@IsOptional()
@IsString()
importTrainNumber?: string;
@IsOptional()
@IsEnum(WagonStatus)
status?: WagonStatus;

View File

@@ -43,6 +43,14 @@ export class Wagon extends BaseEntity {
// Tare weight and payload capacity are properties of the wagon TYPE — read them
// through `wagonType`, never off the individual wagon.
/** EXPORT run number — odd, Ethiopia → Djibouti (e.g. 8001). Null until set. */
@Column({ name: 'export_train_number', type: 'varchar', length: 20, nullable: true })
exportTrainNumber!: string | null;
/** IMPORT run number — even, Djibouti → Ethiopia (e.g. 8002). Null until set. */
@Column({ name: 'import_train_number', type: 'varchar', length: 20, nullable: true })
importTrainNumber!: string | null;
@Column({ type: 'varchar', length: 20, default: WagonStatus.Available })
status!: WagonStatusType;

View File

@@ -38,6 +38,8 @@ export class WagonsService {
if (dto.trainId === undefined) wagon.trainId = null;
if (dto.sequenceNumber === undefined) wagon.sequenceNumber = null;
if (dto.currentYardId === undefined) wagon.currentYardId = null;
if (dto.exportTrainNumber === undefined) wagon.exportTrainNumber = null;
if (dto.importTrainNumber === undefined) wagon.importTrainNumber = null;
return this.wagonRepo.save(wagon);
}

View File

@@ -3,6 +3,7 @@ import { Cron, CronExpression } from '@nestjs/schedule';
import { Between, DataSource, EntityManager, FindManyOptions, ILike, LessThanOrEqual, MoreThanOrEqual } from 'typeorm';
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
import { generateGrnNumber } from '../../common/grn.util';
import { SCHEDULE_BOOKINGS_CTE } from '../../common/schedule-bookings.sql';
import { Booking } from '../bookings/entities/booking.entity';
import { Cargo } from '../cargoes/entities/cargoes.entity';
@@ -5073,10 +5074,9 @@ export class WarehouseInventoryService {
}
}
/** Shared with the facility handling flow — see common/grn.util.ts. */
private generateGrnNumber(direction: string, referenceId: string, date: Date): string {
const stamp = date.toISOString().slice(0, 10).replace(/-/g, '');
const suffix = referenceId.replace(/-/g, '').slice(0, 8).toUpperCase();
return `GRN-${direction.toUpperCase()}-${stamp}-${suffix}`;
return generateGrnNumber(direction, referenceId, date);
}
private async generateReleaseReference(item: WarehouseInventory): Promise<string> {