mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 19:58:11 +00:00
Merge pull request #453 from Tria-plc/freight_feature/usermanagement
Freight feature/usermanagement
This commit is contained in:
@@ -0,0 +1,133 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace load-type string matching with a real wagon-type foreign key.
|
||||||
|
*
|
||||||
|
* Before this migration, train scheduling picked a wagon type by matching
|
||||||
|
* strings — a hardcoded cargo-code → wagon-code map for bulk (COFFEE→KW2, …)
|
||||||
|
* and a fixed NW5 default for every container. This adds `wagon_type_id` FKs on
|
||||||
|
* `cargo_types` and `container_types` so scheduling resolves the wagon type
|
||||||
|
* through the relation instead.
|
||||||
|
*
|
||||||
|
* The columns are NULLABLE: cargo grouping rows and container/legacy cargo that
|
||||||
|
* never ship in bulk have no wagon type, and forcing one onto them is
|
||||||
|
* meaningless. Scheduling enforces the requirement at run time (it throws when a
|
||||||
|
* scheduled bulk cargo type or a container type in the batch has no wagon type).
|
||||||
|
*
|
||||||
|
* Backfill reproduces the old hardcoded resolution one final time so existing
|
||||||
|
* bulk cargo + container rows are not left unset. After this, the runtime map is
|
||||||
|
* removed — the FK is the single source of truth.
|
||||||
|
*/
|
||||||
|
export class AddWagonTypeFkToCargoAndContainerTypes1940000000000
|
||||||
|
implements MigrationInterface
|
||||||
|
{
|
||||||
|
name = "AddWagonTypeFkToCargoAndContainerTypes1940000000000";
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
// ── Columns + FKs ────────────────────────────────────────────────────────
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.cargo_types
|
||||||
|
ADD COLUMN IF NOT EXISTS wagon_type_id uuid;
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.container_types
|
||||||
|
ADD COLUMN IF NOT EXISTS wagon_type_id uuid;
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.cargo_types
|
||||||
|
ADD CONSTRAINT fk_cargo_types_wagon_type
|
||||||
|
FOREIGN KEY (wagon_type_id)
|
||||||
|
REFERENCES freight.wagon_types(id)
|
||||||
|
ON DELETE RESTRICT;
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.container_types
|
||||||
|
ADD CONSTRAINT fk_container_types_wagon_type
|
||||||
|
FOREIGN KEY (wagon_type_id)
|
||||||
|
REFERENCES freight.wagon_types(id)
|
||||||
|
ON DELETE RESTRICT;
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_cargo_types_wagon_type_id
|
||||||
|
ON freight.cargo_types (wagon_type_id);
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_container_types_wagon_type_id
|
||||||
|
ON freight.container_types (wagon_type_id);
|
||||||
|
`);
|
||||||
|
|
||||||
|
// ── Backfill: old cargo-code → wagon-code map (one last time) ─────────────
|
||||||
|
// COFFEE/GRAIN/WHEAT/SORGHUM/CORN → KW2, FERTILIZER/SUGAR → PW2,
|
||||||
|
// COAL → KW3, STEEL/ORE → CW3. Unmapped bulk cargo → CW3 (old default).
|
||||||
|
const cargoCodeToWagon: Record<string, string> = {
|
||||||
|
COFFEE: "KW2",
|
||||||
|
GRAIN: "KW2",
|
||||||
|
WHEAT: "KW2",
|
||||||
|
SORGHUM: "KW2",
|
||||||
|
CORN: "KW2",
|
||||||
|
FERTILIZER: "PW2",
|
||||||
|
SUGAR: "PW2",
|
||||||
|
COAL: "KW3",
|
||||||
|
STEEL: "CW3",
|
||||||
|
ORE: "CW3",
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const [cargoCode, wagonCode] of Object.entries(cargoCodeToWagon)) {
|
||||||
|
await queryRunner.query(
|
||||||
|
`
|
||||||
|
UPDATE freight.cargo_types ct
|
||||||
|
SET wagon_type_id = wt.id
|
||||||
|
FROM freight.wagon_types wt
|
||||||
|
WHERE wt.code = $1
|
||||||
|
AND UPPER(TRIM(ct.code)) = $2
|
||||||
|
AND ct.wagon_type_id IS NULL;
|
||||||
|
`,
|
||||||
|
[wagonCode, cargoCode],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remaining bulk cargo (PER_TON) without a mapped code → default bulk wagon CW3.
|
||||||
|
await queryRunner.query(`
|
||||||
|
UPDATE freight.cargo_types ct
|
||||||
|
SET wagon_type_id = wt.id
|
||||||
|
FROM freight.wagon_types wt
|
||||||
|
WHERE wt.code = 'CW3'
|
||||||
|
AND ct.wagon_type_id IS NULL
|
||||||
|
AND ct.unit_of_measure = 'PER_TON';
|
||||||
|
`);
|
||||||
|
|
||||||
|
// All container types → the old container default wagon NW5.
|
||||||
|
await queryRunner.query(`
|
||||||
|
UPDATE freight.container_types ct
|
||||||
|
SET wagon_type_id = wt.id
|
||||||
|
FROM freight.wagon_types wt
|
||||||
|
WHERE wt.code = 'NW5'
|
||||||
|
AND ct.wagon_type_id IS NULL;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
DROP INDEX IF EXISTS freight.idx_container_types_wagon_type_id;
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
DROP INDEX IF EXISTS freight.idx_cargo_types_wagon_type_id;
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.container_types
|
||||||
|
DROP CONSTRAINT IF EXISTS fk_container_types_wagon_type;
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.cargo_types
|
||||||
|
DROP CONSTRAINT IF EXISTS fk_cargo_types_wagon_type;
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.container_types DROP COLUMN IF EXISTS wagon_type_id;
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.cargo_types DROP COLUMN IF EXISTS wagon_type_id;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1082,8 +1082,10 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
|||||||
destinationYard: true,
|
destinationYard: true,
|
||||||
// units carry the real per-container numbers entered at booking time —
|
// units carry the real per-container numbers entered at booking time —
|
||||||
// the wagon plan shows those instead of generated placeholders.
|
// the wagon plan shows those instead of generated placeholders.
|
||||||
bookingContainers: { containerType: true, units: true },
|
// containerType.wagonType + cargoType.wagonType drive wagon-type
|
||||||
cargoType: true,
|
// resolution during scheduling (FK, not the old load-type string map).
|
||||||
|
bookingContainers: { containerType: { wagonType: true }, units: true },
|
||||||
|
cargoType: { wagonType: true },
|
||||||
},
|
},
|
||||||
order: { priorityScore: 'DESC', createdAt: 'ASC' },
|
order: { priorityScore: 'DESC', createdAt: 'ASC' },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -21,6 +21,14 @@ export class CreateCargoTypeDto {
|
|||||||
@IsUUID()
|
@IsUUID()
|
||||||
parentGroupId?: string;
|
parentGroupId?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description:
|
||||||
|
'Wagon type used to carry this (bulk) cargo. Drives train scheduling wagon-type resolution; required for bulk commodities that are scheduled.',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID('4')
|
||||||
|
wagonTypeId?: string | null;
|
||||||
|
|
||||||
@ApiPropertyOptional({ default: false })
|
@ApiPropertyOptional({ default: false })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
|
|||||||
@@ -30,6 +30,14 @@ export class CreateContainerTypeDto {
|
|||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
isOpenTop?: boolean;
|
isOpenTop?: boolean;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description:
|
||||||
|
'Wagon type used to carry this container. Drives train scheduling wagon-type resolution; required when this container type is scheduled.',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID('4')
|
||||||
|
wagonTypeId?: string | null;
|
||||||
|
|
||||||
@ApiPropertyOptional({ default: true })
|
@ApiPropertyOptional({ default: true })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { BaseEntity } from '@edr/api-common';
|
import { BaseEntity } from '@edr/api-common';
|
||||||
import { CargoUnitOfMeasure } from '@edr/types';
|
import { CargoUnitOfMeasure } from '@edr/types';
|
||||||
import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm';
|
import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm';
|
||||||
|
import { WagonType } from '../../wagon-types/entities/wagon-type.entity';
|
||||||
|
|
||||||
@Entity({ schema: 'freight', name: 'cargo_types' })
|
@Entity({ schema: 'freight', name: 'cargo_types' })
|
||||||
@Index(['isActive'])
|
@Index(['isActive'])
|
||||||
@Index(['displayOrder'])
|
@Index(['displayOrder'])
|
||||||
@Index(['parentGroupId'])
|
@Index(['parentGroupId'])
|
||||||
|
@Index(['wagonTypeId'])
|
||||||
@Index(['code'])
|
@Index(['code'])
|
||||||
export class CargoType extends BaseEntity {
|
export class CargoType extends BaseEntity {
|
||||||
@Column({ name: 'code', type: 'varchar', length: 50, unique: true, default: '' })
|
@Column({ name: 'code', type: 'varchar', length: 50, unique: true, default: '' })
|
||||||
@@ -25,6 +27,19 @@ export class CargoType extends BaseEntity {
|
|||||||
@Column({ name: 'unit_of_measure', type: 'varchar', length: 16, nullable: true })
|
@Column({ name: 'unit_of_measure', type: 'varchar', length: 16, nullable: true })
|
||||||
unitOfMeasure?: CargoUnitOfMeasure | null;
|
unitOfMeasure?: CargoUnitOfMeasure | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wagon type that carries this (bulk) cargo. Replaces the former hardcoded
|
||||||
|
* cargo-code → wagon-code map: train scheduling resolves the bulk wagon type
|
||||||
|
* through this FK. Nullable — grouping rows and container/legacy cargo never
|
||||||
|
* carry it; scheduling throws if a scheduled bulk cargo type leaves it unset.
|
||||||
|
*/
|
||||||
|
@Column({ name: 'wagon_type_id', type: 'uuid', nullable: true })
|
||||||
|
wagonTypeId?: string | null;
|
||||||
|
|
||||||
|
@ManyToOne(() => WagonType, { nullable: true, onDelete: 'RESTRICT' })
|
||||||
|
@JoinColumn({ name: 'wagon_type_id' })
|
||||||
|
wagonType?: WagonType | null;
|
||||||
|
|
||||||
@Column({ name: 'requires_director_approval', type: 'boolean', default: false })
|
@Column({ name: 'requires_director_approval', type: 'boolean', default: false })
|
||||||
requiresDirectorApproval!: boolean;
|
requiresDirectorApproval!: boolean;
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { BaseEntity } from '@edr/api-common';
|
import { BaseEntity } from '@edr/api-common';
|
||||||
import { Column, Entity, Index, OneToMany } from 'typeorm';
|
import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm';
|
||||||
import { WeightLimitRule } from './weight-limit-rule.entity';
|
import { WeightLimitRule } from './weight-limit-rule.entity';
|
||||||
|
import { WagonType } from '../../wagon-types/entities/wagon-type.entity';
|
||||||
|
|
||||||
@Entity({ schema: 'freight', name: 'container_types' })
|
@Entity({ schema: 'freight', name: 'container_types' })
|
||||||
@Index(['code'])
|
@Index(['code'])
|
||||||
@Index(['isActive'])
|
@Index(['isActive'])
|
||||||
|
@Index(['wagonTypeId'])
|
||||||
export class ContainerType extends BaseEntity {
|
export class ContainerType extends BaseEntity {
|
||||||
@Column({ name: 'code', type: 'varchar', length: 20, unique: true })
|
@Column({ name: 'code', type: 'varchar', length: 20, unique: true })
|
||||||
code!: string;
|
code!: string;
|
||||||
@@ -24,6 +26,19 @@ export class ContainerType extends BaseEntity {
|
|||||||
@Column({ name: 'is_open_top', type: 'boolean', default: false, nullable: true })
|
@Column({ name: 'is_open_top', type: 'boolean', default: false, nullable: true })
|
||||||
isOpenTop!: boolean;
|
isOpenTop!: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wagon type that carries this container. Replaces the former hardcoded
|
||||||
|
* container wagon-code default (NW5): train scheduling resolves the container
|
||||||
|
* wagon type through this FK. Nullable; scheduling throws if a scheduled
|
||||||
|
* container type leaves it unset.
|
||||||
|
*/
|
||||||
|
@Column({ name: 'wagon_type_id', type: 'uuid', nullable: true })
|
||||||
|
wagonTypeId?: string | null;
|
||||||
|
|
||||||
|
@ManyToOne(() => WagonType, { nullable: true, onDelete: 'RESTRICT' })
|
||||||
|
@JoinColumn({ name: 'wagon_type_id' })
|
||||||
|
wagonType?: WagonType | null;
|
||||||
|
|
||||||
@Column({ name: 'is_active', type: 'boolean', default: true })
|
@Column({ name: 'is_active', type: 'boolean', default: true })
|
||||||
isActive!: boolean;
|
isActive!: boolean;
|
||||||
|
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ export class CargoTypesRepository implements ICargoTypesRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async update(id: string, data: Partial<CargoType>): Promise<CargoType | null> {
|
async update(id: string, data: Partial<CargoType>): Promise<CargoType | null> {
|
||||||
await this.repo.update(id, data);
|
await this.repo.update(id, data as never);
|
||||||
return this.findById(id);
|
return this.findById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ export class ContainerTypesRepository implements IContainerTypesRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async update(id: string, data: Partial<ContainerType>): Promise<ContainerType | null> {
|
async update(id: string, data: Partial<ContainerType>): Promise<ContainerType | null> {
|
||||||
await this.repo.update(id, data);
|
await this.repo.update(id, data as never);
|
||||||
return this.findById(id);
|
return this.findById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ export class RatesRepository implements IRatesRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async update(id: string, data: Partial<Rate>): Promise<Rate | null> {
|
async update(id: string, data: Partial<Rate>): Promise<Rate | null> {
|
||||||
await this.repo.update(id, data);
|
await this.repo.update(id, data as never);
|
||||||
return this.findById(id);
|
return this.findById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ export class WeightLimitRulesRepository implements IWeightLimitRulesRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async update(id: string, data: Partial<WeightLimitRule>): Promise<WeightLimitRule | null> {
|
async update(id: string, data: Partial<WeightLimitRule>): Promise<WeightLimitRule | null> {
|
||||||
await this.repo.update(id, data);
|
await this.repo.update(id, data as never);
|
||||||
return this.findById(id);
|
return this.findById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ export class CargoTypesService {
|
|||||||
requiresDirectorApproval: dto.requiresDirectorApproval ?? false,
|
requiresDirectorApproval: dto.requiresDirectorApproval ?? false,
|
||||||
isActive: dto.isActive ?? true,
|
isActive: dto.isActive ?? true,
|
||||||
unitOfMeasure: dto.unitOfMeasure ?? null,
|
unitOfMeasure: dto.unitOfMeasure ?? null,
|
||||||
|
wagonTypeId: dto.wagonTypeId ?? null,
|
||||||
displayOrder,
|
displayOrder,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ export class ContainerTypesService {
|
|||||||
isReefer: dto.isReefer ?? false,
|
isReefer: dto.isReefer ?? false,
|
||||||
isOpenTop: dto.isOpenTop ?? false,
|
isOpenTop: dto.isOpenTop ?? false,
|
||||||
isActive: dto.isActive ?? true,
|
isActive: dto.isActive ?? true,
|
||||||
|
wagonTypeId: dto.wagonTypeId ?? null,
|
||||||
displayOrder,
|
displayOrder,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { InjectDataSource } from '@nestjs/typeorm';
|
import { InjectDataSource } from '@nestjs/typeorm';
|
||||||
import { DataSource, EntityManager, In, Not } from 'typeorm';
|
import { DataSource, EntityManager, In, IsNull, Not } from 'typeorm';
|
||||||
|
|
||||||
import { BookingsRepository } from '../bookings/bookings.repository';
|
import { BookingsRepository } from '../bookings/bookings.repository';
|
||||||
import { Booking } from '../bookings/entities/booking.entity';
|
import { Booking } from '../bookings/entities/booking.entity';
|
||||||
@@ -38,6 +38,8 @@ import { WagonAllocationBulkLoadsRepository } from '../train-schedules/wagon-all
|
|||||||
import { WagonAllocationContainerItemsRepository } from '../train-schedules/wagon-allocation-container-items.repository';
|
import { WagonAllocationContainerItemsRepository } from '../train-schedules/wagon-allocation-container-items.repository';
|
||||||
import { WagonBookingAllocationsRepository } from '../train-schedules/wagon-booking-allocations.repository';
|
import { WagonBookingAllocationsRepository } from '../train-schedules/wagon-booking-allocations.repository';
|
||||||
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
||||||
|
import { CargoType } from '../rule-engine/entities/cargo-type.entity';
|
||||||
|
import { ContainerType } from '../rule-engine/entities/container-type.entity';
|
||||||
import { WagonTypesRepository } from '../wagon-types/wagon-types.repository';
|
import { WagonTypesRepository } from '../wagon-types/wagon-types.repository';
|
||||||
import { Wagon } from '../wagons/entities/wagon.entity';
|
import { Wagon } from '../wagons/entities/wagon.entity';
|
||||||
import { AssignBookingsDto } from './dto/assign-bookings.dto';
|
import { AssignBookingsDto } from './dto/assign-bookings.dto';
|
||||||
@@ -87,10 +89,6 @@ import {
|
|||||||
type ContainerPlacementInput,
|
type ContainerPlacementInput,
|
||||||
type WagonPlanSlot,
|
type WagonPlanSlot,
|
||||||
} from './wagon-plan.util';
|
} from './wagon-plan.util';
|
||||||
import {
|
|
||||||
getDefaultContainerWagonTypeCode,
|
|
||||||
pickBulkWagonType,
|
|
||||||
} from './wagon-type-resolver.util';
|
|
||||||
import { deriveScheduleDirection } from './derive-schedule-direction.util';
|
import { deriveScheduleDirection } from './derive-schedule-direction.util';
|
||||||
import { pickLowestFreeNumber, pickTrainNumberPool } from './train-number.util';
|
import { pickLowestFreeNumber, pickTrainNumberPool } from './train-number.util';
|
||||||
import {
|
import {
|
||||||
@@ -2725,28 +2723,98 @@ export class TrainSchedulingService {
|
|||||||
return violations;
|
return violations;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the wagon type for a batch through the cargo-type / container-type
|
||||||
|
* `wagon_type_id` FK (replaces the former load-type string matching). Throws
|
||||||
|
* when the relevant type has no wagon type configured — scheduling is blocked
|
||||||
|
* until an admin assigns one on the cargo-type / container-type config screen.
|
||||||
|
*/
|
||||||
private async resolveWagonType(
|
private async resolveWagonType(
|
||||||
freightType: 'CONTAINER' | 'BULK',
|
freightType: 'CONTAINER' | 'BULK',
|
||||||
bookingIds: string[],
|
bookingIds: string[],
|
||||||
): Promise<WagonType> {
|
): Promise<WagonType> {
|
||||||
|
const bookings = await this.bookingsRepository.findByIdsForScheduling(bookingIds);
|
||||||
|
|
||||||
if (freightType === 'CONTAINER') {
|
if (freightType === 'CONTAINER') {
|
||||||
const [wagonType] = await this.wagonTypesRepository.findAll({
|
// First container type present on the batch drives the container wagon
|
||||||
where: { code: getDefaultContainerWagonTypeCode(), isActive: true },
|
// type (matches the prior single-wagon-type-per-consist behavior).
|
||||||
});
|
const containerType = bookings
|
||||||
if (!wagonType) {
|
.flatMap((b) => b.bookingContainers ?? [])
|
||||||
throw new NotFoundException(`Wagon type ${getDefaultContainerWagonTypeCode()} not found`);
|
.map((line) => line.containerType)
|
||||||
|
.find((ct): ct is NonNullable<typeof ct> => Boolean(ct));
|
||||||
|
if (!containerType) {
|
||||||
|
throw new BadRequestException('No container type found on the container booking(s)');
|
||||||
}
|
}
|
||||||
|
const wagonType = await this.loadWagonTypeForType(
|
||||||
|
containerType.wagonTypeId ?? null,
|
||||||
|
`Container type "${containerType.label ?? containerType.code}"`,
|
||||||
|
);
|
||||||
return wagonType;
|
return wagonType;
|
||||||
}
|
}
|
||||||
|
|
||||||
const bookings = await this.bookingsRepository.findByIdsForScheduling(bookingIds);
|
const cargoType = bookings.map((b) => b.cargoType).find((ct) => Boolean(ct));
|
||||||
const cargoCode = bookings[0]?.cargoType?.code ?? null;
|
if (!cargoType) {
|
||||||
const wagonTypes = await this.wagonTypesRepository.findAll({ where: { isActive: true } });
|
throw new BadRequestException('No cargo type found on the bulk booking(s)');
|
||||||
const picked = pickBulkWagonType(wagonTypes, cargoCode);
|
|
||||||
if (!picked) {
|
|
||||||
throw new NotFoundException('No suitable bulk wagon type found');
|
|
||||||
}
|
}
|
||||||
return picked;
|
return this.loadWagonTypeForType(
|
||||||
|
cargoType.wagonTypeId ?? null,
|
||||||
|
`Cargo type "${cargoType.cargoTypeName ?? cargoType.code}"`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load an active wagon type by FK id, throwing a clear error when the id is
|
||||||
|
* unset (type not configured) or points at a missing/inactive wagon type.
|
||||||
|
*/
|
||||||
|
private async loadWagonTypeForType(
|
||||||
|
wagonTypeId: string | null,
|
||||||
|
typeLabel: string,
|
||||||
|
): Promise<WagonType> {
|
||||||
|
if (!wagonTypeId) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`${typeLabel} has no wagon type configured — set one on its configuration before scheduling.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const [wagonType] = await this.wagonTypesRepository.findAll({
|
||||||
|
where: { id: wagonTypeId, isActive: true },
|
||||||
|
});
|
||||||
|
if (!wagonType) {
|
||||||
|
throw new NotFoundException(
|
||||||
|
`${typeLabel} references wagon type ${wagonTypeId}, which was not found or is inactive.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return wagonType;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Soft wagon-type resolution for the customer-facing availability preview
|
||||||
|
* (getAvailableDaysForCargo). Reads the configured FK by cargo/container type;
|
||||||
|
* returns null (→ "no days") instead of throwing when nothing is configured,
|
||||||
|
* since this only estimates which days have wagons and creates no booking.
|
||||||
|
*/
|
||||||
|
private async resolveWagonTypeForPreview(
|
||||||
|
freightType: 'CONTAINER' | 'BULK',
|
||||||
|
cargoTypeCode: string | null,
|
||||||
|
): Promise<WagonType | null> {
|
||||||
|
if (freightType === 'BULK') {
|
||||||
|
if (!cargoTypeCode) return null;
|
||||||
|
const cargoType = await this.dataSource.getRepository(CargoType).findOne({
|
||||||
|
where: { code: cargoTypeCode },
|
||||||
|
relations: { wagonType: true },
|
||||||
|
});
|
||||||
|
return cargoType?.wagonType?.isActive ? cargoType.wagonType : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Container preview: the input carries no specific container type, so use the
|
||||||
|
// wagon type of the first configured (active) container type.
|
||||||
|
const containerType = await this.dataSource
|
||||||
|
.getRepository(ContainerType)
|
||||||
|
.findOne({
|
||||||
|
where: { isActive: true, wagonTypeId: Not(IsNull()) },
|
||||||
|
relations: { wagonType: true },
|
||||||
|
order: { displayOrder: 'ASC' },
|
||||||
|
});
|
||||||
|
return containerType?.wagonType?.isActive ? containerType.wagonType : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async persistTrainSetWagons(
|
private async persistTrainSetWagons(
|
||||||
@@ -3419,15 +3487,12 @@ export class TrainSchedulingService {
|
|||||||
);
|
);
|
||||||
if (schedules.length === 0) return { days: [] };
|
if (schedules.length === 0) return { days: [] };
|
||||||
|
|
||||||
const wagonTypes = await this.dataSource.getRepository(WagonType).find();
|
// Resolve the wagon type this cargo needs via the cargo/container-type FK.
|
||||||
|
// Soft (customer availability preview): no days if unresolved, never throws.
|
||||||
// Resolve the wagon type this cargo needs.
|
const requiredType = await this.resolveWagonTypeForPreview(
|
||||||
const requiredType =
|
input.freightType,
|
||||||
input.freightType === 'BULK'
|
input.cargoTypeCode ?? null,
|
||||||
? pickBulkWagonType(wagonTypes, input.cargoTypeCode)
|
);
|
||||||
: wagonTypes.find(
|
|
||||||
(wt) => wt.code === getDefaultContainerWagonTypeCode() && wt.isActive,
|
|
||||||
);
|
|
||||||
if (!requiredType) return { days: [] };
|
if (!requiredType) return { days: [] };
|
||||||
|
|
||||||
// How many wagons of that type the cargo needs.
|
// How many wagons of that type the cargo needs.
|
||||||
|
|||||||
@@ -1,49 +0,0 @@
|
|||||||
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
|
||||||
|
|
||||||
const CARGO_CODE_TO_WAGON_TYPE: Record<string, string> = {
|
|
||||||
COFFEE: 'KW2',
|
|
||||||
GRAIN: 'KW2',
|
|
||||||
WHEAT: 'KW2',
|
|
||||||
SORGHUM: 'KW2',
|
|
||||||
CORN: 'KW2',
|
|
||||||
FERTILIZER: 'PW2',
|
|
||||||
SUGAR: 'PW2',
|
|
||||||
COAL: 'KW3',
|
|
||||||
STEEL: 'CW3',
|
|
||||||
ORE: 'CW3',
|
|
||||||
};
|
|
||||||
|
|
||||||
const DEFAULT_BULK_WAGON_TYPE = 'CW3';
|
|
||||||
const DEFAULT_CONTAINER_WAGON_TYPE = 'NW5';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve wagon type code from cargo type code for bulk freight.
|
|
||||||
*/
|
|
||||||
export function resolveBulkWagonTypeCode(cargoTypeCode?: string | null): string {
|
|
||||||
if (!cargoTypeCode) return DEFAULT_BULK_WAGON_TYPE;
|
|
||||||
const normalized = cargoTypeCode.trim().toUpperCase();
|
|
||||||
return CARGO_CODE_TO_WAGON_TYPE[normalized] ?? DEFAULT_BULK_WAGON_TYPE;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pick the best matching wagon type entity for bulk cargo.
|
|
||||||
*/
|
|
||||||
export function pickBulkWagonType(
|
|
||||||
wagonTypes: WagonType[],
|
|
||||||
cargoTypeCode?: string | null,
|
|
||||||
): WagonType | undefined {
|
|
||||||
const preferredCode = resolveBulkWagonTypeCode(cargoTypeCode);
|
|
||||||
const direct = wagonTypes.find((wt) => wt.code === preferredCode && wt.isActive);
|
|
||||||
if (direct) return direct;
|
|
||||||
|
|
||||||
return wagonTypes.find(
|
|
||||||
(wt) =>
|
|
||||||
wt.isActive &&
|
|
||||||
!wt.supportsContainer &&
|
|
||||||
wt.code !== DEFAULT_CONTAINER_WAGON_TYPE,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getDefaultContainerWagonTypeCode(): string {
|
|
||||||
return DEFAULT_CONTAINER_WAGON_TYPE;
|
|
||||||
}
|
|
||||||
@@ -21,6 +21,8 @@ interface AuthEmployeePosition {
|
|||||||
isDelegate?: boolean;
|
isDelegate?: boolean;
|
||||||
parentPositionId?: string | null;
|
parentPositionId?: string | null;
|
||||||
permissions?: AuthPermission[];
|
permissions?: AuthPermission[];
|
||||||
|
/** Some IAM payloads nest the position record instead of flattening its key. */
|
||||||
|
position?: { id?: string; key?: string; name?: LocaleText };
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AuthEmployeeRecord {
|
interface AuthEmployeeRecord {
|
||||||
|
|||||||
@@ -174,6 +174,23 @@ export const useContainerTypeOptions = (
|
|||||||
buildContainerTypeSelectOptions(result.data ?? [], includeNone),
|
buildContainerTypeSelectOptions(result.data ?? [], includeNone),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Active wagon-type options for the cargo-type / container-type "Wagon type"
|
||||||
|
* picker. The FK the selection sets drives train-scheduling wagon resolution.
|
||||||
|
*/
|
||||||
|
export const useWagonTypeOptions = (enabled = true) =>
|
||||||
|
useQuery({
|
||||||
|
...api.wagonTypes.list.queryOptions(),
|
||||||
|
enabled,
|
||||||
|
select: (rows: { id: string; code: string; name: string; isActive?: boolean }[]) =>
|
||||||
|
rows
|
||||||
|
.filter((wt) => wt.isActive !== false)
|
||||||
|
.map((wt) => ({
|
||||||
|
label: wt.name ? `${wt.name} (${wt.code})` : wt.code,
|
||||||
|
value: wt.id,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
const LIVE_RATE_PAGE_SIZE = 500;
|
const LIVE_RATE_PAGE_SIZE = 500;
|
||||||
|
|
||||||
export const useLiveRateOptions = (enabled = true) =>
|
export const useLiveRateOptions = (enabled = true) =>
|
||||||
|
|||||||
@@ -73,15 +73,23 @@ export function getPermissionKeys(user: AuthUser | null | undefined): string[] {
|
|||||||
return [...keys];
|
return [...keys];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Position keys held by the user (e.g. "ethiopian_gl", "djibouti_gl"). */
|
/**
|
||||||
|
* Position keys held by the user (e.g. "ethiopian_gl", "djibouti_gl").
|
||||||
|
* Tolerates IAM payload shape variants: the key flat on the employee position,
|
||||||
|
* nested under `position.key`, or the GL modeled as a role instead.
|
||||||
|
*/
|
||||||
export function getPositionKeys(user: AuthUser | null | undefined): string[] {
|
export function getPositionKeys(user: AuthUser | null | undefined): string[] {
|
||||||
if (!user) return [];
|
if (!user) return [];
|
||||||
const keys = new Set<string>();
|
const keys = new Set<string>();
|
||||||
for (const emp of user.employee ?? []) {
|
for (const emp of user.employee ?? []) {
|
||||||
for (const pos of emp.positions ?? []) {
|
for (const pos of emp.positions ?? []) {
|
||||||
if (pos.key) keys.add(pos.key);
|
if (pos.key) keys.add(pos.key);
|
||||||
|
if (pos.position?.key) keys.add(pos.position.key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for (const role of user.roles ?? []) {
|
||||||
|
if (role.key) keys.add(role.key);
|
||||||
|
}
|
||||||
return [...keys];
|
return [...keys];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -596,21 +596,58 @@ export default function ContractRequestDetailPage() {
|
|||||||
No cargo scope lines.
|
No cargo scope lines.
|
||||||
</Text>
|
</Text>
|
||||||
) : (
|
) : (
|
||||||
<Stack gap="xs">
|
<Stack gap="sm">
|
||||||
{(contract.cargoScope ?? []).map((s) => (
|
{(contract.cargoScope ?? []).map((s) => {
|
||||||
<Group key={s.id} gap={8} wrap="nowrap">
|
const isContainer = Boolean(s.containerSize);
|
||||||
<BoxIcon
|
// Bulk lines carry their commodity detail (name + unit);
|
||||||
size={15}
|
// container lines carry the size (20ft / 40ft).
|
||||||
color="var(--mantine-color-edr-green-6)"
|
const title = isContainer
|
||||||
/>
|
? `${s.containerSize} container`
|
||||||
<Text size="sm">
|
: (s.cargoType?.cargoTypeName ??
|
||||||
{s.containerSize ??
|
s.cargoFreeText ??
|
||||||
s.cargoFreeText ??
|
s.cargoType?.code ??
|
||||||
s.cargoTypeId ??
|
"Bulk cargo");
|
||||||
"Cargo"}
|
// quantityCap unit: containers for a size line, else the
|
||||||
</Text>
|
// cargo type's unit of measure (tons / items / …), default tons.
|
||||||
</Group>
|
const capUnit = isContainer
|
||||||
))}
|
? "containers"
|
||||||
|
: (s.cargoType?.unitOfMeasure?.toLowerCase() ?? "tons");
|
||||||
|
return (
|
||||||
|
<Group key={s.id} gap={8} wrap="nowrap" align="flex-start">
|
||||||
|
<BoxIcon
|
||||||
|
size={15}
|
||||||
|
color="var(--mantine-color-edr-green-6)"
|
||||||
|
style={{ marginTop: 2, flexShrink: 0 }}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<Text size="sm" fw={500}>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
<Group gap={6} mt={2}>
|
||||||
|
<Badge
|
||||||
|
variant="light"
|
||||||
|
color={isContainer ? "blue" : "grape"}
|
||||||
|
radius="sm"
|
||||||
|
size="xs"
|
||||||
|
tt="uppercase"
|
||||||
|
>
|
||||||
|
{isContainer ? "Container" : "Bulk"}
|
||||||
|
</Badge>
|
||||||
|
{s.cargoType?.code ? (
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
Code: {s.cargoType.code}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{s.quantityCap != null
|
||||||
|
? `Cap: ${s.quantityCap} ${capUnit}`
|
||||||
|
: "Cap: uncapped"}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</Stack>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
useRuleEngineList,
|
useRuleEngineList,
|
||||||
useRuleEngineMutations,
|
useRuleEngineMutations,
|
||||||
|
useWagonTypeOptions,
|
||||||
} from "@/hooks/rule-engine/useRuleEngine";
|
} from "@/hooks/rule-engine/useRuleEngine";
|
||||||
import type { RuleEngineRecord } from "@/types/rule-engine";
|
import type { RuleEngineRecord } from "@/types/rule-engine";
|
||||||
|
|
||||||
@@ -53,6 +54,8 @@ interface CargoNode extends RuleEngineRecord {
|
|||||||
requiresDirectorApproval?: boolean;
|
requiresDirectorApproval?: boolean;
|
||||||
/** How this cargo is measured (PER_TON / PER_ITEM); null for groups/unset. */
|
/** How this cargo is measured (PER_TON / PER_ITEM); null for groups/unset. */
|
||||||
unitOfMeasure?: string | null;
|
unitOfMeasure?: string | null;
|
||||||
|
/** Wagon type FK used to carry this bulk cargo during scheduling; null if unset. */
|
||||||
|
wagonTypeId?: string | null;
|
||||||
isActive?: boolean;
|
isActive?: boolean;
|
||||||
displayOrder?: number;
|
displayOrder?: number;
|
||||||
}
|
}
|
||||||
@@ -78,6 +81,18 @@ const FORM_FIELDS: FormFieldDef[] = [
|
|||||||
{ label: "Per item (break-bulk)", value: "PER_ITEM" },
|
{ label: "Per item (break-bulk)", value: "PER_ITEM" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// Wagon type that carries this (bulk) commodity — drives train-scheduling
|
||||||
|
// wagon resolution. Optional: leave "None" for grouping categories and
|
||||||
|
// container/legacy cargo; set it on scheduled bulk commodities.
|
||||||
|
// Options injected at render from useWagonTypeOptions.
|
||||||
|
name: "wagonTypeId",
|
||||||
|
label: "Wagon type",
|
||||||
|
type: "select",
|
||||||
|
optional: true,
|
||||||
|
placeholder: "Select wagon type (bulk cargo)",
|
||||||
|
options: [{ label: "None", value: RULE_ENGINE_SELECT_NONE }],
|
||||||
|
},
|
||||||
{ name: "requiresDirectorApproval", label: "Requires director approval", type: "boolean" },
|
{ name: "requiresDirectorApproval", label: "Requires director approval", type: "boolean" },
|
||||||
{ name: "isActive", label: "Active", type: "boolean" },
|
{ name: "isActive", label: "Active", type: "boolean" },
|
||||||
];
|
];
|
||||||
@@ -104,6 +119,24 @@ const CargoTypesPage = () => {
|
|||||||
|
|
||||||
const { create, update, remove } = useRuleEngineMutations(CARGO_SLUG);
|
const { create, update, remove } = useRuleEngineMutations(CARGO_SLUG);
|
||||||
|
|
||||||
|
// Wagon-type options for the "Wagon type" picker (bulk cargo → wagon FK).
|
||||||
|
const { data: wagonTypeOptions } = useWagonTypeOptions(canManage);
|
||||||
|
const formFields = useMemo<FormFieldDef[]>(
|
||||||
|
() =>
|
||||||
|
FORM_FIELDS.map((field) =>
|
||||||
|
field.name === "wagonTypeId"
|
||||||
|
? {
|
||||||
|
...field,
|
||||||
|
options: [
|
||||||
|
{ label: "None", value: RULE_ENGINE_SELECT_NONE },
|
||||||
|
...(wagonTypeOptions ?? []),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: field,
|
||||||
|
),
|
||||||
|
[wagonTypeOptions],
|
||||||
|
);
|
||||||
|
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [formMode, setFormMode] = useState<FormMode | null>(null);
|
const [formMode, setFormMode] = useState<FormMode | null>(null);
|
||||||
const [deleteTarget, setDeleteTarget] = useState<CargoNode | null>(null);
|
const [deleteTarget, setDeleteTarget] = useState<CargoNode | null>(null);
|
||||||
@@ -349,7 +382,7 @@ const CargoTypesPage = () => {
|
|||||||
? "Create a top-level cargo category."
|
? "Create a top-level cargo category."
|
||||||
: "Create a cargo type inside this category. It's attached here automatically."
|
: "Create a cargo type inside this category. It's attached here automatically."
|
||||||
}
|
}
|
||||||
fields={FORM_FIELDS}
|
fields={formFields}
|
||||||
initialRecord={formMode?.kind === "edit" ? formMode.record : null}
|
initialRecord={formMode?.kind === "edit" ? formMode.record : null}
|
||||||
isSubmitting={create.isPending || update.isPending}
|
isSubmitting={create.isPending || update.isPending}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import {
|
|||||||
useCargoTypeParentOptions,
|
useCargoTypeParentOptions,
|
||||||
useContainerTypeOptions,
|
useContainerTypeOptions,
|
||||||
useLiveRateOptions,
|
useLiveRateOptions,
|
||||||
|
useWagonTypeOptions,
|
||||||
useRateWorkflow,
|
useRateWorkflow,
|
||||||
useRuleEngineList,
|
useRuleEngineList,
|
||||||
useRuleEngineMutations,
|
useRuleEngineMutations,
|
||||||
@@ -151,6 +152,9 @@ const RuleEngineResourcePage = () => {
|
|||||||
const usesLiveRateField = Boolean(
|
const usesLiveRateField = Boolean(
|
||||||
config?.formFields.some((f) => f.name === "rateId"),
|
config?.formFields.some((f) => f.name === "rateId"),
|
||||||
);
|
);
|
||||||
|
const usesWagonTypeField = Boolean(
|
||||||
|
config?.formFields.some((f) => f.name === "wagonTypeId"),
|
||||||
|
);
|
||||||
|
|
||||||
const { data: cargoParentOptions, isLoading: cargoParentOptionsLoading } =
|
const { data: cargoParentOptions, isLoading: cargoParentOptionsLoading } =
|
||||||
useCargoTypeParentOptions(editingId, config?.slug === "cargo-types");
|
useCargoTypeParentOptions(editingId, config?.slug === "cargo-types");
|
||||||
@@ -160,6 +164,8 @@ const RuleEngineResourcePage = () => {
|
|||||||
useContainerTypeOptions(config?.slug === "rates", usesContainerTypeField);
|
useContainerTypeOptions(config?.slug === "rates", usesContainerTypeField);
|
||||||
const { data: liveRateOptions, isLoading: liveRateOptionsLoading } =
|
const { data: liveRateOptions, isLoading: liveRateOptionsLoading } =
|
||||||
useLiveRateOptions(usesLiveRateField);
|
useLiveRateOptions(usesLiveRateField);
|
||||||
|
const { data: wagonTypeOptions, isLoading: wagonTypeOptionsLoading } =
|
||||||
|
useWagonTypeOptions(usesWagonTypeField);
|
||||||
|
|
||||||
const formFields = useMemo(() => {
|
const formFields = useMemo(() => {
|
||||||
if (!config) return [];
|
if (!config) return [];
|
||||||
@@ -193,9 +199,16 @@ const RuleEngineResourcePage = () => {
|
|||||||
options: liveRateOptions ?? [],
|
options: liveRateOptions ?? [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
if (field.name === "wagonTypeId") {
|
||||||
|
return {
|
||||||
|
...field,
|
||||||
|
type: "select" as const,
|
||||||
|
options: wagonTypeOptions ?? [],
|
||||||
|
};
|
||||||
|
}
|
||||||
return field;
|
return field;
|
||||||
});
|
});
|
||||||
}, [config, cargoParentOptions, cargoLeafOptions, containerTypeOptions, liveRateOptions]);
|
}, [config, cargoParentOptions, cargoLeafOptions, containerTypeOptions, liveRateOptions, wagonTypeOptions]);
|
||||||
|
|
||||||
const rows = data?.data ?? [];
|
const rows = data?.data ?? [];
|
||||||
const meta = data?.meta;
|
const meta = data?.meta;
|
||||||
@@ -502,7 +515,8 @@ const RuleEngineResourcePage = () => {
|
|||||||
(config.slug === "cargo-types" && cargoParentOptionsLoading) ||
|
(config.slug === "cargo-types" && cargoParentOptionsLoading) ||
|
||||||
(usesContainerTypeField && containerTypeOptionsLoading) ||
|
(usesContainerTypeField && containerTypeOptionsLoading) ||
|
||||||
(usesCargoTypeField && cargoLeafOptionsLoading) ||
|
(usesCargoTypeField && cargoLeafOptionsLoading) ||
|
||||||
(usesLiveRateField && liveRateOptionsLoading)
|
(usesLiveRateField && liveRateOptionsLoading) ||
|
||||||
|
(usesWagonTypeField && wagonTypeOptionsLoading)
|
||||||
}
|
}
|
||||||
positionOptions={!editing ? createPositionOptions : undefined}
|
positionOptions={!editing ? createPositionOptions : undefined}
|
||||||
positionLoading={createPositionLoading}
|
positionLoading={createPositionLoading}
|
||||||
|
|||||||
@@ -248,6 +248,14 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
|||||||
formFields: [
|
formFields: [
|
||||||
{ name: "label", label: "Label", type: "text", required: true },
|
{ name: "label", label: "Label", type: "text", required: true },
|
||||||
{ name: "sizeFt", label: "Size (ft)", type: "number", required: true },
|
{ name: "sizeFt", label: "Size (ft)", type: "number", required: true },
|
||||||
|
// Options injected at render from useWagonTypeOptions (RuleEngineResourcePage).
|
||||||
|
{
|
||||||
|
name: "wagonTypeId",
|
||||||
|
label: "Wagon type",
|
||||||
|
type: "select",
|
||||||
|
required: true,
|
||||||
|
description: "Wagon type used to carry this container during train scheduling.",
|
||||||
|
},
|
||||||
{ name: "isOpenTop", label: "Open top", type: "boolean" },
|
{ name: "isOpenTop", label: "Open top", type: "boolean" },
|
||||||
{ name: "isActive", label: "Active", type: "boolean" },
|
{ name: "isActive", label: "Active", type: "boolean" },
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ import {
|
|||||||
Group,
|
Group,
|
||||||
Loader,
|
Loader,
|
||||||
Paper,
|
Paper,
|
||||||
|
Progress,
|
||||||
|
RingProgress,
|
||||||
SimpleGrid,
|
SimpleGrid,
|
||||||
Stack,
|
Stack,
|
||||||
Tabs,
|
Tabs,
|
||||||
@@ -211,6 +213,17 @@ export default function ContractDetailPage() {
|
|||||||
});
|
});
|
||||||
const bookingWindowOpen = hasOpenWindow(bookingWindows);
|
const bookingWindowOpen = hasOpenWindow(bookingWindows);
|
||||||
|
|
||||||
|
// Draw-down capacity per cargo line (GENERAL contracts only). The backend
|
||||||
|
// excludes CANCELLED/REJECTED/EXPIRED bookings, so a shipment that never ships
|
||||||
|
// releases its share and the tracker fills back up. Refetched on window focus so
|
||||||
|
// it reflects newly created / cancelled shipments.
|
||||||
|
const { data: capacityLines = [] } = useQuery({
|
||||||
|
queryKey: ["contract-capacity", id],
|
||||||
|
queryFn: () => contractsService.getCapacity(id!),
|
||||||
|
enabled: !!id && contract?.contractKind === "GENERAL",
|
||||||
|
refetchOnWindowFocus: true,
|
||||||
|
});
|
||||||
|
|
||||||
const contractBookings = useMemo(
|
const contractBookings = useMemo(
|
||||||
() =>
|
() =>
|
||||||
(bookingsPage?.items ?? []).filter(
|
(bookingsPage?.items ?? []).filter(
|
||||||
@@ -882,6 +895,88 @@ export default function ContractDetailPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
|
|
||||||
|
{/* Draw-down capacity — GENERAL contracts with a per-line quantity cap.
|
||||||
|
Fills as shipments consume capacity; empties again when a shipment is
|
||||||
|
cancelled/rejected/expired (backend releases it). */}
|
||||||
|
{isGeneral && capacityLines.length > 0 && (
|
||||||
|
<Card
|
||||||
|
withBorder
|
||||||
|
radius="lg"
|
||||||
|
p="lg"
|
||||||
|
style={{ borderColor: BORDER, boxShadow: CARD_SHADOW }}
|
||||||
|
>
|
||||||
|
<SectionLabel mb="md">Contract capacity</SectionLabel>
|
||||||
|
<Stack gap="lg">
|
||||||
|
{capacityLines.map((line, i) => {
|
||||||
|
const cap = line.cap ?? 0;
|
||||||
|
const booked = line.booked ?? 0;
|
||||||
|
const remaining = line.remaining ?? Math.max(0, cap - booked);
|
||||||
|
const usedPct = cap > 0 ? Math.min(100, (booked / cap) * 100) : 0;
|
||||||
|
const remainingPct = cap > 0 ? Math.round((remaining / cap) * 100) : 0;
|
||||||
|
const unit = capacityUnitLabel(contract, line);
|
||||||
|
const label = isContainer
|
||||||
|
? `${line.containerSize ?? "Containers"}`
|
||||||
|
: (contract.cargoScope ?? []).find(
|
||||||
|
(s) => s.cargoTypeId === line.cargoTypeId,
|
||||||
|
)?.cargoType?.cargoTypeName ??
|
||||||
|
(contract.cargoScope ?? [])[0]?.cargoFreeText ??
|
||||||
|
"Bulk commodity";
|
||||||
|
return (
|
||||||
|
<Group
|
||||||
|
key={line.containerSize ?? line.cargoTypeId ?? i}
|
||||||
|
align="center"
|
||||||
|
wrap="nowrap"
|
||||||
|
gap="lg"
|
||||||
|
>
|
||||||
|
<RingProgress
|
||||||
|
size={72}
|
||||||
|
thickness={8}
|
||||||
|
roundCaps
|
||||||
|
sections={[
|
||||||
|
{
|
||||||
|
value: remainingPct,
|
||||||
|
color: remaining === 0 ? "red" : GREEN,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
label={
|
||||||
|
<Text ta="center" fz={13} fw={700} style={{ color: INK }}>
|
||||||
|
{remainingPct}%
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<Group justify="space-between" mb={6} wrap="nowrap">
|
||||||
|
<Group gap={8} wrap="nowrap">
|
||||||
|
{isContainer ? (
|
||||||
|
<Package size={16} color={MUTED} />
|
||||||
|
) : (
|
||||||
|
<Weight size={16} color={MUTED} />
|
||||||
|
)}
|
||||||
|
<Text fz={14} fw={600} style={{ color: INK }}>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<Text fz={13} c="dimmed">
|
||||||
|
{booked} / {cap} {unit} booked
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<Progress
|
||||||
|
value={usedPct}
|
||||||
|
size="md"
|
||||||
|
radius="xl"
|
||||||
|
color={remaining === 0 ? "red" : GREEN}
|
||||||
|
/>
|
||||||
|
<Text fz={12} c="dimmed" mt={6}>
|
||||||
|
{remaining} {unit} remaining
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Signatures */}
|
{/* Signatures */}
|
||||||
{(contract.signatures ?? []).length > 0 && (
|
{(contract.signatures ?? []).length > 0 && (
|
||||||
<Card
|
<Card
|
||||||
@@ -1351,6 +1446,22 @@ function SectionLabel({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unit noun for a capacity line: "containers" for CONTAINER freight, else the
|
||||||
|
* bulk cargo's unit of measure ("tons" for PER_TON, "items" for PER_ITEM).
|
||||||
|
*/
|
||||||
|
function capacityUnitLabel(
|
||||||
|
contract: Freight.IContract,
|
||||||
|
line: Freight.ContractCapacityLine,
|
||||||
|
): string {
|
||||||
|
if (contract.freightType === "CONTAINER") return "containers";
|
||||||
|
const scope =
|
||||||
|
(contract.cargoScope ?? []).find(
|
||||||
|
(s) => s.cargoTypeId === line.cargoTypeId,
|
||||||
|
) ?? (contract.cargoScope ?? [])[0];
|
||||||
|
return scope?.cargoType?.unitOfMeasure === "PER_ITEM" ? "items" : "tons";
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One document row in the Documents tab: the file's kind (passport, business
|
* One document row in the Documents tab: the file's kind (passport, business
|
||||||
* license, contract, …) derived from its `code` as the primary label, the
|
* license, contract, …) derived from its `code` as the primary label, the
|
||||||
|
|||||||
@@ -132,6 +132,13 @@ export interface IContractCargoScope {
|
|||||||
/** "20ft" | "40ft"; null for bulk. */
|
/** "20ft" | "40ft"; null for bulk. */
|
||||||
containerSize?: string | null;
|
containerSize?: string | null;
|
||||||
cargoTypeId?: string | null;
|
cargoTypeId?: string | null;
|
||||||
|
/** Bulk cargo type detail (name + unit), loaded on the contract detail. */
|
||||||
|
cargoType?: {
|
||||||
|
id: string;
|
||||||
|
code?: string | null;
|
||||||
|
cargoTypeName?: string | null;
|
||||||
|
unitOfMeasure?: string | null;
|
||||||
|
} | null;
|
||||||
cargoFreeText?: string | null;
|
cargoFreeText?: string | null;
|
||||||
/**
|
/**
|
||||||
* GENERAL contracts: total quantity bookable across all shipments on this line
|
* GENERAL contracts: total quantity bookable across all shipments on this line
|
||||||
|
|||||||
Reference in New Issue
Block a user