fix(warehouses): detention groups by canonical truck type

Join truck_types via vehicles.truck_type_id (normalized legacy
vehicle_type only as fallback) so type renames can't unmatch detention
rules and FK-less vehicles keep billing.
This commit is contained in:
Hagernesh
2026-07-23 13:40:14 +00:00
parent 227a561e89
commit cf8a2e928d
41 changed files with 1495 additions and 109 deletions

View File

@@ -29,6 +29,7 @@ import { ConsignmentsModule } from "./modules/consignments/consignments.module";
// import { TrainsModule } from "./modules/trains/trains.module";
import { LocomotivesModule } from "./modules/locomotives/locomotives.module";
import { TruckTypesModule } from "./modules/truck-types/truck-types.module";
import { WagonTypesModule } from "./modules/wagon-types/wagon-types.module";
import { TrainSetsModule } from "./modules/train-sets/train-sets.module";
import { TrainSchedulesModule } from "./modules/train-schedules/train-schedules.module";
@@ -158,6 +159,7 @@ import { LoggerMiddleware } from "./logger.middleware";
FilesModule,
ConsignmentsModule,
LocomotivesModule,
TruckTypesModule,
WagonTypesModule,
TrainSetsModule,
TrainSchedulesModule,

View File

@@ -8,6 +8,8 @@ type MileRecord = {
bookingContainers?: Array<{
units?: Array<{ vgmTons?: number | string | null }> | null;
}> | null;
/** Attached here: the train schedule the booking rides, for mile alignment. */
trainSchedule?: { trainNumber: string | null; departureDate: string | null } | null;
} | null;
};
@@ -36,6 +38,38 @@ export async function attachMileFinancials(
if (unitTons > 0) b.cargoTotalWeightVgm = Number(unitTons.toFixed(3));
}
// Train alignment: which schedule each booking rides (mile pickups/deliveries
// are planned against the train's departure).
const bookingIds = [...new Set(records.map((r) => r.bookingId).filter(Boolean))] as string[];
if (bookingIds.length) {
const schedules: Array<{
bookingId: string;
trainNumber: string | null;
departureDate: string | null;
}> = await dataSource.query(
`SELECT DISTINCT ON (tsb.booking_id)
tsb.booking_id AS "bookingId",
ts.train_number AS "trainNumber",
COALESCE(ts.actual_departure_at, ts.scheduled_departure_date)::text AS "departureDate"
FROM freight.train_schedule_bookings tsb
JOIN freight.train_schedules ts
ON ts.id = tsb.train_schedule_id AND ts.deleted_at IS NULL
WHERE tsb.booking_id = ANY($1::uuid[]) AND tsb.deleted_at IS NULL
ORDER BY tsb.booking_id, tsb.created_at DESC`,
[bookingIds],
);
const byBookingSchedule = new Map(schedules.map((s) => [s.bookingId, s]));
for (const r of records) {
const s = r.bookingId ? byBookingSchedule.get(r.bookingId) : undefined;
if (r.booking && s) {
r.booking.trainSchedule = {
trainNumber: s.trainNumber,
departureDate: s.departureDate,
};
}
}
}
const needAdvance = records.filter(
(r) => r.bookingId && !(Number(r.advancedPayment) > 0),
);

View File

@@ -0,0 +1,40 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Bulk tonnage at assignment time. First-mile trucks and export self-haul
* trucks carry a planned load (tonnes + optional item count) so bulk bookings
* draw down as vehicles are assigned — not only at the weighbridge.
*/
export class AddMileTonsQuantity2820000000000 implements MigrationInterface {
name = 'AddMileTonsQuantity2820000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.first_mile_vehicle_assignments ADD COLUMN IF NOT EXISTS tons numeric(14,3);`,
);
await queryRunner.query(
`ALTER TABLE freight.first_mile_vehicle_assignments ADD COLUMN IF NOT EXISTS quantity integer;`,
);
await queryRunner.query(
`ALTER TABLE freight.customer_truck_assignments ADD COLUMN IF NOT EXISTS planned_tons numeric(14,3);`,
);
await queryRunner.query(
`ALTER TABLE freight.customer_truck_assignments ADD COLUMN IF NOT EXISTS planned_quantity integer;`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.customer_truck_assignments DROP COLUMN IF EXISTS planned_quantity;`,
);
await queryRunner.query(
`ALTER TABLE freight.customer_truck_assignments DROP COLUMN IF EXISTS planned_tons;`,
);
await queryRunner.query(
`ALTER TABLE freight.first_mile_vehicle_assignments DROP COLUMN IF EXISTS quantity;`,
);
await queryRunner.query(
`ALTER TABLE freight.first_mile_vehicle_assignments DROP COLUMN IF EXISTS tons;`,
);
}
}

View File

@@ -0,0 +1,121 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Truck types become back-office data instead of a hardcoded `VehicleType` enum,
* so EDR can add a configuration without a code change.
*
* `vehicles.vehicle_type` is deliberately LEFT IN PLACE as a denormalised code.
* Truck-detention billing groups trucks with raw SQL over that column
* (`SELECT v.vehicle_type ... GROUP BY`, warehouse-fee.service.ts) and matches
* the result against `warehouse_fee_rules.vehicle_type`. Swapping it for the FK
* outright would silently drop detention charges, so the FK is additive and the
* service writes the type's code through on every save.
*
* Raw SQL, `freight.`-qualified, IF NOT EXISTS throughout — the TypeORM builder
* API resolves bare names against `public` and crash-loops boot.
*/
export class AddTruckTypes2840000000000 implements MigrationInterface {
name = "AddTruckTypes2840000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.truck_types (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
code varchar(32) NOT NULL,
name varchar(100) NOT NULL,
capacity_tons numeric(10,3),
has_trailer boolean NOT NULL DEFAULT false,
description text,
is_active boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz
)
`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS ux_truck_types_code
ON freight.truck_types (code)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS ix_truck_types_is_active
ON freight.truck_types (is_active)
`);
// Seed one row per legacy enum value so vehicles already carrying that code
// keep resolving, plus CASONI as the first rigid (no-trailer) configuration.
// has_trailer is true only for the articulated configurations.
await queryRunner.query(`
INSERT INTO freight.truck_types (code, name, has_trailer)
VALUES
('TRUCK', 'Truck', true),
('TRAILER', 'Trailer', true),
('TANKER', 'Tanker', true),
('FLATBED', 'Flatbed', true),
('VAN', 'Van', false),
('CAR', 'Car', false),
('BUS', 'Bus', false),
('CASONI', 'Casoni (rigid, no trailer)', false)
ON CONFLICT (code) DO NOTHING
`);
await queryRunner.query(`
ALTER TABLE freight.vehicles
ADD COLUMN IF NOT EXISTS truck_type_id uuid
`);
// Separate DO block: ADD CONSTRAINT has no IF NOT EXISTS in Postgres.
await queryRunner.query(`
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'fk_vehicles_truck_type'
) THEN
ALTER TABLE freight.vehicles
ADD CONSTRAINT fk_vehicles_truck_type
FOREIGN KEY (truck_type_id) REFERENCES freight.truck_types (id)
ON DELETE SET NULL;
END IF;
END $$
`);
// Backfill the FK from the code already stored on each vehicle.
await queryRunner.query(`
UPDATE freight.vehicles v
SET truck_type_id = t.id
FROM freight.truck_types t
WHERE v.truck_type_id IS NULL
AND upper(trim(v.vehicle_type)) = t.code
`);
// Truck-type codes are varchar(32); the fee-rule column they are matched
// against was varchar(20) and would truncate/reject longer codes.
await queryRunner.query(`
ALTER TABLE freight.warehouse_fee_rules
ALTER COLUMN vehicle_type TYPE varchar(32)
`);
// A VIN identifies exactly one vehicle worldwide. Partial index so the many
// existing rows without a VIN do not collide.
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS ux_vehicles_vin
ON freight.vehicles (vin)
WHERE vin IS NOT NULL
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX IF EXISTS freight.ux_vehicles_vin`);
await queryRunner.query(`
ALTER TABLE freight.vehicles
DROP CONSTRAINT IF EXISTS fk_vehicles_truck_type
`);
await queryRunner.query(`
ALTER TABLE freight.vehicles
DROP COLUMN IF EXISTS truck_type_id
`);
await queryRunner.query(`DROP TABLE IF EXISTS freight.truck_types`);
// warehouse_fee_rules.vehicle_type is left widened: narrowing it back would
// fail on any row that stored a code longer than 20 characters.
}
}

View File

@@ -85,6 +85,24 @@ export class CustomerTruckService {
if (isBulk) {
const { totalTons, remainingTons } = await remainingBulkTons(this.dataSource, bookingId);
assertBulkTonnageRemains(totalTons, remainingTons);
// Assignment-time drawdown: planned tonnage across live trucks (weighed
// net once departed, planned before) may not exceed the declared total.
if (totalTons > 0) {
const [p]: Array<{ planned: string | null }> = await this.dataSource.query(
`SELECT SUM(COALESCE(a.net_weight_tons, a.planned_tons, 0)) AS planned
FROM freight.customer_truck_assignments a
WHERE a.booking_id = $1 AND a.deleted_at IS NULL`,
[bookingId],
);
const alreadyPlanned = Number(p?.planned ?? 0);
const requestedTons = Number(dto.plannedTons ?? 0);
if (requestedTons > 0 && alreadyPlanned + requestedTons > totalTons + 0.001) {
throw new BadRequestException(
`Planned tonnage exceeds the booking: ${alreadyPlanned} t already assigned of ${totalTons} t — at most ${Math.max(0, totalTons - alreadyPlanned)} t left for this truck`,
);
}
}
}
if (requested.length) {
@@ -108,6 +126,8 @@ export class CustomerTruckService {
plateNumber: dto.truckPlateNumber.trim().toUpperCase(),
driverName: dto.driverName.trim(),
truckType: dto.truckType.trim(),
plannedTons: isBulk ? (dto.plannedTons ?? null) : null,
plannedQuantity: isBulk ? (dto.plannedQuantity ?? null) : null,
}),
);
await manager.getRepository(CustomerTruckContainer).save(
@@ -186,23 +206,52 @@ export class CustomerTruckService {
throw new ConflictException('Cannot edit a truck that has already arrived');
}
const requested = (dto.containerNumbers ?? []).map((n) => n.trim().toUpperCase());
if (requested.length < 1) {
// Bulk trucks carry loose tonnage, not containers — planned tonnage is
// editable instead, capped by what the other trucks haven't claimed.
const isBulk = booking.freightType === 'BULK';
const requested = isBulk
? []
: (dto.containerNumbers ?? []).map((n) => n.trim().toUpperCase());
if (!isBulk && requested.length < 1) {
throw new BadRequestException('Select at least one container for this truck');
}
assertTruckLoad({
containers: requested,
bookingContainers: await this.bookingContainerNumbers(bookingId),
sizes: await bookingContainerSizes(this.dataSource, bookingId, requested),
// Exclude THIS truck's own containers so re-saving the same set is allowed.
assignedElsewhere: await this.assignedContainerNumbersExcept(bookingId, assignmentId),
});
if (!isBulk) {
assertTruckLoad({
containers: requested,
bookingContainers: await this.bookingContainerNumbers(bookingId),
sizes: await bookingContainerSizes(this.dataSource, bookingId, requested),
// Exclude THIS truck's own containers so re-saving the same set is allowed.
assignedElsewhere: await this.assignedContainerNumbersExcept(bookingId, assignmentId),
});
} else if (dto.plannedTons != null) {
const { totalTons } = await remainingBulkTons(this.dataSource, bookingId);
if (totalTons > 0) {
const [p]: Array<{ planned: string | null }> = await this.dataSource.query(
`SELECT SUM(COALESCE(a.net_weight_tons, a.planned_tons, 0)) AS planned
FROM freight.customer_truck_assignments a
WHERE a.booking_id = $1 AND a.deleted_at IS NULL AND a.id <> $2`,
[bookingId, assignmentId],
);
const others = Number(p?.planned ?? 0);
if (others + Number(dto.plannedTons) > totalTons + 0.001) {
throw new BadRequestException(
`Planned tonnage exceeds the booking: ${others} t on other trucks of ${totalTons} t — at most ${Math.max(0, totalTons - others)} t left for this truck`,
);
}
}
}
await this.dataSource.transaction(async (manager) => {
await manager.getRepository(CustomerTruckAssignment).update(assignmentId, {
plateNumber: dto.truckPlateNumber.trim().toUpperCase(),
driverName: dto.driverName.trim(),
truckType: dto.truckType.trim(),
...(isBulk
? {
plannedTons: dto.plannedTons ?? null,
plannedQuantity: dto.plannedQuantity ?? null,
}
: {}),
});
await manager.getRepository(CustomerTruckContainer).softDelete({ assignmentId });
await manager.getRepository(CustomerTruckContainer).save(

View File

@@ -4,10 +4,12 @@ import {
IsArray,
IsIn,
IsNotEmpty,
IsNumber,
IsOptional,
IsString,
Matches,
MaxLength,
Min,
} from 'class-validator';
import { CUSTOMER_TRUCK_TYPES } from './customer-truck-assignment.dto';
@@ -44,4 +46,16 @@ export class AddCustomerTruckDto {
message: 'each container number must match ISO container format, e.g. ABCD1234567',
})
containerNumbers?: string[];
/** Bulk: planned tonnage this truck hauls — draws down the booking total at assignment. */
@IsOptional()
@IsNumber()
@Min(0)
plannedTons?: number;
/** Bulk: optional item/piece count on this truck. */
@IsOptional()
@IsNumber()
@Min(0)
plannedQuantity?: number;
}

View File

@@ -51,6 +51,14 @@ export class CustomerTruckAssignment extends BaseEntity {
@Column({ name: 'net_weight_tons', type: 'numeric', precision: 14, scale: 3, nullable: true })
netWeightTons?: number | null;
/** Bulk: planned tonnage at assignment — draws down the booking before weigh-out. */
@Column({ name: 'planned_tons', type: 'numeric', precision: 14, scale: 3, nullable: true })
plannedTons?: number | null;
/** Bulk: optional item/piece count planned on this truck. */
@Column({ name: 'planned_quantity', type: 'integer', nullable: true })
plannedQuantity?: number | null;
@Column({ name: 'departed_at', type: 'timestamptz', nullable: true })
departedAt?: Date | null;

View File

@@ -1,4 +1,4 @@
import { IsArray, IsOptional, IsString, IsUUID, ValidateNested } from 'class-validator';
import { IsArray, IsNumber, IsOptional, IsString, IsUUID, Min, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';
export class FirstMileVehicleInput {
@@ -8,6 +8,18 @@ export class FirstMileVehicleInput {
@IsOptional()
@IsString()
containerNumber?: string;
/** Bulk: tonnage this truck hauls. */
@IsOptional()
@IsNumber()
@Min(0)
tons?: number;
/** Bulk: optional item/piece count. */
@IsOptional()
@IsNumber()
@Min(0)
quantity?: number;
}
/** Replace the full set of vehicles (with their container numbers) on a pickup. */

View File

@@ -36,4 +36,12 @@ export class FirstMileVehicleAssignment extends BaseEntity {
/** Actual distance driven by this truck (km), entered per vehicle. */
@Column({ name: 'distance_km', type: 'numeric', precision: 10, scale: 2, nullable: true })
distanceKm?: number | null;
/** Bulk: tonnage this truck hauls — assigned tonnage draws down the booking total. */
@Column({ name: 'tons', type: 'numeric', precision: 14, scale: 3, nullable: true })
tons?: number | null;
/** Bulk: optional item/piece count on this truck. */
@Column({ name: 'quantity', type: 'integer', nullable: true })
quantity?: number | null;
}

View File

@@ -532,17 +532,47 @@ export class FirstMileService {
*/
async setVehicles(
id: string,
inputs: Array<{ vehicleId: string; containerNumber?: string | null }>,
inputs: Array<{
vehicleId: string;
containerNumber?: string | null;
tons?: number | null;
quantity?: number | null;
}>,
): Promise<FirstMile> {
const existing = await this.findById(id);
// Dedupe by vehicleId, keeping the container number; preserve order.
const desiredMap = new Map<string, string | null>();
// Dedupe by vehicleId, keeping the load details; preserve order.
const desiredMap = new Map<
string,
{ containerNumber: string | null; tons: number | null; quantity: number | null }
>();
for (const inp of inputs) {
if (inp.vehicleId) desiredMap.set(inp.vehicleId, inp.containerNumber ?? null);
if (inp.vehicleId) {
desiredMap.set(inp.vehicleId, {
containerNumber: inp.containerNumber ?? null,
tons: inp.tons ?? null,
quantity: inp.quantity ?? null,
});
}
}
const desired = [...desiredMap.keys()];
const desiredSet = new Set(desired);
// Bulk drawdown: assigned tonnage may not exceed what the booking declares.
const totalTons = [...desiredMap.values()].reduce((s, v) => s + (Number(v.tons) || 0), 0);
if (totalTons > 0 && existing.bookingId) {
const [b]: Array<{ vgm: string | null }> = await this.dataSource.query(
`SELECT cargo_total_weight_vgm AS vgm FROM freight.bookings
WHERE id = $1 AND deleted_at IS NULL`,
[existing.bookingId],
);
const declared = Number(b?.vgm ?? 0);
if (declared > 0 && totalTons > declared + 0.001) {
throw new BadRequestException(
`Assigned tonnage (${totalTons} t) exceeds the booking's declared ${declared} t`,
);
}
}
const manager = this.dataSource.manager;
const current = await manager.find(FirstMileVehicleAssignment, {
where: { firstMileId: id },
@@ -555,12 +585,16 @@ export class FirstMileService {
)];
const added = desired.filter((v) => !junctionSet.has(v));
const removed = releaseIds.filter((v) => !desiredSet.has(v));
// Vehicles that stay but whose container number changed.
const changed = current.filter(
(a) =>
desiredMap.has(a.vehicleId) &&
(a.containerNumber ?? null) !== (desiredMap.get(a.vehicleId) ?? null),
);
// Vehicles that stay but whose load details changed.
const changed = current.filter((a) => {
const want = desiredMap.get(a.vehicleId);
if (!want) return false;
return (
(a.containerNumber ?? null) !== want.containerNumber ||
(a.tons == null ? null : Number(a.tons)) !== want.tons ||
(a.quantity ?? null) !== want.quantity
);
});
await this.dataSource.transaction(async (tx) => {
if (removed.length) {
@@ -570,17 +604,25 @@ export class FirstMileService {
});
}
for (const vehicleId of added) {
const want = desiredMap.get(vehicleId);
await tx.insert(FirstMileVehicleAssignment, {
firstMileId: id,
vehicleId,
containerNumber: desiredMap.get(vehicleId) ?? null,
containerNumber: want?.containerNumber ?? null,
tons: want?.tons ?? null,
quantity: want?.quantity ?? null,
});
}
for (const row of changed) {
const want = desiredMap.get(row.vehicleId);
await tx.update(
FirstMileVehicleAssignment,
{ firstMileId: id, vehicleId: row.vehicleId },
{ containerNumber: desiredMap.get(row.vehicleId) ?? null },
{
containerNumber: want?.containerNumber ?? null,
tons: want?.tons ?? null,
quantity: want?.quantity ?? null,
},
);
}
});

View File

@@ -0,0 +1,55 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsBoolean, IsNumber, IsOptional, IsString, MaxLength, Min } from 'class-validator';
const toNumber = ({ value }: { value: unknown }) =>
value === '' || value == null ? value : Number(value);
const toBoolean = ({ value }: { value: unknown }) => {
if (typeof value === 'boolean') return value;
if (value === 'true') return true;
if (value === 'false') return false;
return value;
};
export class CreateTruckTypeDto {
@ApiProperty({ maxLength: 32, example: 'CASONI' })
@IsString()
@MaxLength(32)
code!: string;
@ApiProperty({ maxLength: 100, example: 'Casoni (rigid, no trailer)' })
@IsString()
@MaxLength(100)
name!: string;
@ApiPropertyOptional({
description: 'Payload capacity in metric tons — pre-fills a vehicle registered against this type',
example: 30,
})
@IsOptional()
@Transform(toNumber)
@IsNumber()
@Min(0)
capacityTons?: number;
@ApiPropertyOptional({
description: 'Whether this configuration pulls a trailer. False (e.g. Casoni) forbids a trailer plate.',
default: false,
})
@IsOptional()
@Transform(toBoolean)
@IsBoolean()
hasTrailer?: boolean;
@ApiPropertyOptional()
@IsOptional()
@IsString()
description?: string;
@ApiPropertyOptional({ default: true })
@IsOptional()
@Transform(toBoolean)
@IsBoolean()
isActive?: boolean;
}

View File

@@ -0,0 +1,5 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateTruckTypeDto } from './create-truck-type.dto';
export class UpdateTruckTypeDto extends PartialType(CreateTruckTypeDto) {}

View File

@@ -0,0 +1,40 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index } from 'typeorm';
/**
* A truck configuration EDR registers vehicles against — back-office managed so
* new configurations arrive without a code change.
*
* Two fields drive vehicle registration:
* - `capacityTons` pre-fills a vehicle's capacity (capacity belongs to the type,
* not to each individual truck).
* - `hasTrailer` decides whether a trailer plate applies at all. A rigid truck
* (e.g. Casoni) has none, and registering one with a trailer plate is rejected.
*/
@Entity({ schema: 'freight', name: 'truck_types' })
@Index(['code'])
@Index(['isActive'])
export class TruckType extends BaseEntity {
/**
* Matching key, upper-case. Denormalised onto `vehicles.vehicle_type`, which
* truck-detention billing groups and matches fee rules by — so a code change
* here is a billing-visible change.
*/
@Column({ name: 'code', type: 'varchar', length: 32, unique: true })
code!: string;
@Column({ name: 'name', type: 'varchar', length: 100 })
name!: string;
@Column({ name: 'capacity_tons', type: 'numeric', precision: 10, scale: 3, nullable: true })
capacityTons?: number | null;
@Column({ name: 'has_trailer', type: 'boolean', default: false })
hasTrailer!: boolean;
@Column({ name: 'description', type: 'text', nullable: true })
description?: string | null;
@Column({ name: 'is_active', type: 'boolean', default: true })
isActive!: boolean;
}

View File

@@ -0,0 +1,74 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
ParseUUIDPipe,
Patch,
Post,
Query,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { RuleEngineManage, RuleEngineView } from '../../common/rule-engine-guards';
import { CreateTruckTypeDto } from './dto/create-truck-type.dto';
import { UpdateTruckTypeDto } from './dto/update-truck-type.dto';
import { TruckTypesService } from './truck-types.service';
@ApiTags('truck-types')
@Controller('truck-types')
@ApiBearerAuth()
export class TruckTypesController {
constructor(private readonly truckTypesService: TruckTypesService) {}
@Get()
@RuleEngineView('truck-types')
@ApiOperation({ summary: 'List truck types' })
findAll(@Query() query: Record<string, string | undefined>) {
return this.truckTypesService.findAll({
isActive:
query.isActive === 'all'
? undefined
: query.isActive !== undefined
? query.isActive === 'true'
: true,
page: query.page ? parseInt(query.page, 10) : undefined,
pageSize: query.pageSize ? parseInt(query.pageSize, 10) : undefined,
sortBy: query.sortBy,
sortOrder: query.sortOrder,
});
}
@Get(':id')
@RuleEngineView('truck-types')
@ApiOperation({ summary: 'Get a truck type by ID' })
findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.truckTypesService.findById(id);
}
@Post()
@RuleEngineManage('truck-types')
@ApiOperation({ summary: 'Create a truck type' })
create(@Body() dto: CreateTruckTypeDto) {
return this.truckTypesService.create(dto);
}
@Patch(':id')
@RuleEngineManage('truck-types')
@ApiOperation({ summary: 'Update a truck type' })
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateTruckTypeDto) {
return this.truckTypesService.update(id, dto);
}
@Delete(':id')
@RuleEngineManage('truck-types')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Soft-delete a truck type' })
remove(@Param('id', ParseUUIDPipe) id: string) {
return this.truckTypesService.remove(id);
}
}

View File

@@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { TruckType } from './entities/truck-type.entity';
import { TruckTypesController } from './truck-types.controller';
import { TruckTypesRepository } from './truck-types.repository';
import { TruckTypesService } from './truck-types.service';
@Module({
imports: [TypeOrmModule.forFeature([TruckType])],
controllers: [TruckTypesController],
providers: [TruckTypesRepository, TruckTypesService],
exports: [TruckTypesRepository, TruckTypesService],
})
export class TruckTypesModule {}

View File

@@ -0,0 +1,20 @@
import { BaseRepository } from '@edr/api-common';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { TruckType } from './entities/truck-type.entity';
@Injectable()
export class TruckTypesRepository extends BaseRepository<TruckType> {
constructor(
@InjectRepository(TruckType)
repository: Repository<TruckType>,
) {
super(repository);
}
findByCode(code: string): Promise<TruckType | null> {
return this.repository.findOne({ where: { code } });
}
}

View File

@@ -0,0 +1,116 @@
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';
import { FindOptionsOrder } from 'typeorm';
import { CreateTruckTypeDto } from './dto/create-truck-type.dto';
import { UpdateTruckTypeDto } from './dto/update-truck-type.dto';
import { TruckType } from './entities/truck-type.entity';
import { TruckTypesRepository } from './truck-types.repository';
type TruckTypeListFilter = {
isActive?: boolean;
page?: number;
pageSize?: number;
sortBy?: string;
sortOrder?: string;
};
@Injectable()
export class TruckTypesService {
constructor(private readonly truckTypesRepository: TruckTypesRepository) {}
async findAll(filter: TruckTypeListFilter = {}): Promise<{
data: TruckType[];
meta: { total: number; page: number; pageSize: number; totalPages: number };
}> {
const page = filter.page ?? 1;
const pageSize = filter.pageSize ?? 500;
const sortBy = ['code', 'name', 'capacityTons', 'hasTrailer', 'isActive'].includes(
filter.sortBy ?? '',
)
? (filter.sortBy as keyof TruckType)
: 'code';
const sortOrder = filter.sortOrder?.toUpperCase() === 'DESC' ? 'DESC' : 'ASC';
const [data, total] = await this.truckTypesRepository.findAndCount({
where: filter.isActive === undefined ? {} : { isActive: filter.isActive },
order: { [sortBy]: sortOrder } as FindOptionsOrder<TruckType>,
skip: (page - 1) * pageSize,
take: pageSize,
});
return {
data,
meta: {
total,
page,
pageSize,
totalPages: Math.max(1, Math.ceil(total / pageSize)),
},
};
}
async findById(id: string): Promise<TruckType> {
const truckType = await this.truckTypesRepository.findById(id);
if (!truckType) {
throw new NotFoundException(`Truck type ${id} not found`);
}
return truckType;
}
async findByCode(code: string): Promise<TruckType> {
const truckType = await this.truckTypesRepository.findByCode(code);
if (!truckType) {
throw new NotFoundException(`Truck type ${code} not found`);
}
return truckType;
}
async create(dto: CreateTruckTypeDto): Promise<TruckType> {
const code = dto.code.trim().toUpperCase();
const existing = await this.truckTypesRepository.findByCode(code);
if (existing) {
throw new ConflictException(`Truck type code "${code}" already exists`);
}
return this.truckTypesRepository.create({
code,
name: dto.name.trim(),
capacityTons: dto.capacityTons ?? null,
hasTrailer: dto.hasTrailer ?? false,
description: dto.description?.trim() ?? null,
isActive: dto.isActive ?? true,
});
}
async update(id: string, dto: UpdateTruckTypeDto): Promise<TruckType> {
const truckType = await this.findById(id);
const nextCode = dto.code?.trim().toUpperCase();
if (nextCode && nextCode !== truckType.code) {
const existing = await this.truckTypesRepository.findByCode(nextCode);
if (existing) {
throw new ConflictException(`Truck type code "${nextCode}" already exists`);
}
}
const updated = await this.truckTypesRepository.update(id, {
...dto,
...(nextCode ? { code: nextCode } : {}),
...(dto.name ? { name: dto.name.trim() } : {}),
});
if (!updated) {
throw new NotFoundException(`Truck type ${id} not found`);
}
return updated;
}
async remove(id: string): Promise<void> {
await this.findById(id);
await this.truckTypesRepository.softDelete(id);
}
}

View File

@@ -1,6 +1,11 @@
import { IsString, IsEnum, IsNumber, IsOptional, IsUUID, Matches } from 'class-validator';
import { Transform } from 'class-transformer';
import { VehicleType, FuelType, VehicleStatus, VehicleAvailability } from '../entities/vehicle.entity';
import {
FuelType,
VehicleAvailability,
VehicleOwnership,
VehicleStatus,
} from '../entities/vehicle.entity';
/**
* A vehicle plate is two or three letters, a hyphen, then two to six digits —
@@ -28,8 +33,9 @@ export class CreateVehicleDto {
@IsString()
plateNumber!: string;
@IsEnum(VehicleType)
vehicleType!: VehicleType;
/** Truck configuration from `freight.truck_types` — drives capacity and whether a trailer plate applies. */
@IsUUID()
truckTypeId!: string;
@IsString()
manufacturer!: string;
@@ -43,8 +49,18 @@ export class CreateVehicleDto {
@IsEnum(FuelType)
fuelType!: FuelType;
/** Defaults to the truck type's capacity when omitted. */
@IsOptional()
@IsNumber()
capacity!: number;
capacity?: number;
@IsOptional()
@IsString()
vin?: string;
@IsOptional()
@IsEnum(VehicleOwnership)
ownership?: VehicleOwnership;
@IsEnum(VehicleStatus)
status!: VehicleStatus;

View File

@@ -1,6 +1,17 @@
import { Entity, Column } from 'typeorm';
import { BaseEntity } from '@edr/api-common';
/**
* Legacy classification. Truck configurations are now back-office data in
* `freight.truck_types` — register a vehicle with `truckTypeId`, not this.
*
* The `vehicle_type` COLUMN survives as a denormalised copy of the truck type's
* code because truck-detention billing groups by it in raw SQL and matches it
* against `warehouse_fee_rules.vehicle_type`. The service writes it through on
* every save; nothing should set it by hand.
*
* @deprecated use `truckTypeId` / `freight.truck_types`
*/
export enum VehicleType {
TRUCK = 'TRUCK',
VAN = 'VAN',
@@ -11,6 +22,12 @@ export enum VehicleType {
FLATBED = 'FLATBED',
}
/** Who supplies the truck. Supplier selection is deferred until EDR commits to outsourcing. */
export enum VehicleOwnership {
OWNED = 'OWNED',
OUTSOURCED = 'OUTSOURCED',
}
export enum FuelType {
PETROL = 'PETROL',
DIESEL = 'DIESEL',
@@ -47,8 +64,12 @@ export class Vehicle extends BaseEntity {
@Column({ name: 'registration_number', unique: true, nullable: true })
registrationNumber?: string;
/** Denormalised `truck_types.code` — written through by the service, never set by hand. */
@Column({ name: 'vehicle_type', type: 'varchar', nullable: true })
vehicleType?: VehicleType;
vehicleType?: string;
@Column({ name: 'truck_type_id', type: 'uuid', nullable: true })
truckTypeId?: string | null;
@Column({ nullable: true })
manufacturer?: string;
@@ -101,7 +122,7 @@ export class Vehicle extends BaseEntity {
@Column({ name: 'vin', type: 'varchar', nullable: true })
vin?: string;
/** Owned | Leased | Rented */
/** OWNED | OUTSOURCED — see {@link VehicleOwnership}. */
@Column({ name: 'ownership', type: 'varchar', nullable: true })
ownership?: string;

View File

@@ -11,6 +11,7 @@ describe('VehiclesService driver assignment guard', () => {
new VehiclesService(
{ findOne, create: jest.fn((x) => x), save: jest.fn(async (x) => x) } as any,
{ record: jest.fn() } as any,
{ findById: jest.fn(async () => ({ code: 'TRUCK', name: 'Truck', hasTrailer: true })) } as any,
);
it('rejects create when the driver is on another truck', async () => {
@@ -18,7 +19,7 @@ describe('VehiclesService driver assignment guard', () => {
const findOne = jest.fn().mockResolvedValueOnce(null).mockResolvedValueOnce(otherTruck);
const svc = makeService(findOne);
await expect(
svc.create({ plateNumber: '3-22222', vehicleType: 'TRUCK', assignedDriverId: 'd1' } as any),
svc.create({ plateNumber: '3-22222', truckTypeId: 'tt1', assignedDriverId: 'd1' } as any),
).rejects.toThrow(ConflictException);
});

View File

@@ -3,9 +3,10 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { Vehicle } from './entities/vehicle.entity';
import { VehiclesService } from './vehicles.service';
import { VehiclesController } from './vehicles.controller';
import { TruckTypesModule } from '../truck-types/truck-types.module';
@Module({
imports: [TypeOrmModule.forFeature([Vehicle])],
imports: [TypeOrmModule.forFeature([Vehicle]), TruckTypesModule],
providers: [VehiclesService],
controllers: [VehiclesController],
exports: [VehiclesService],

View File

@@ -1,9 +1,16 @@
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Not, Repository } from 'typeorm';
import { CreateVehicleDto } from './dto/create-vehicle.dto';
import { UpdateVehicleDto } from './dto/update-vehicle.dto';
import { Vehicle, VehicleAvailability, VehicleStatus } from './entities/vehicle.entity';
import { TruckType } from '../truck-types/entities/truck-type.entity';
import { TruckTypesService } from '../truck-types/truck-types.service';
import { FirstMile, FirstMileStatus } from '../first-mile/entities/first-mile.entity';
import { FirstMileContainerAllocation } from '../first-mile/entities/first-mile-container-allocation.entity';
import { LastMile, LastMileStatus } from '../last-mile/entities/last-mile.entity';
@@ -18,8 +25,26 @@ export class VehiclesService {
@InjectRepository(Vehicle)
private readonly vehicleRepo: Repository<Vehicle>,
private readonly history: FleetHistoryService,
private readonly truckTypes: TruckTypesService,
) {}
/**
* A trailer plate only exists on a configuration that pulls a trailer — a
* rigid truck (Casoni) has none. Checked against the RESULTING record, not
* just the patch, so switching an articulated truck to a rigid type cannot
* leave its old trailer plate stranded on the row.
*/
private assertTrailerPlateAllowed(
truckType: TruckType,
trailerPlateNo?: string | null,
): void {
if (!truckType.hasTrailer && trailerPlateNo) {
throw new BadRequestException(
`${truckType.name} has no trailer — remove the trailer plate number`,
);
}
}
/**
* A driver holds one truck at a time — reassignment requires detaching them
* from their current truck first.
@@ -54,10 +79,17 @@ export class VehiclesService {
await this.assertDriverUnassigned(dto.assignedDriverId);
}
const registrationNumber = `REG-${dto.vehicleType}-${Date.now()}`;
const truckType = await this.truckTypes.findById(dto.truckTypeId);
this.assertTrailerPlateAllowed(truckType, dto.trailerPlateNo);
const registrationNumber = `REG-${truckType.code}-${Date.now()}`;
const vehicle = this.vehicleRepo.create({
...dto,
registrationNumber,
// Denormalised for truck-detention billing, which groups on this column.
vehicleType: truckType.code,
// Capacity belongs to the type; an explicit value still wins for one-offs.
capacity: dto.capacity ?? truckType.capacityTons ?? undefined,
});
const saved = await this.vehicleRepo.save(vehicle);
@@ -148,6 +180,17 @@ export class VehiclesService {
await this.assertDriverUnassigned(dto.assignedDriverId, id);
}
// Re-resolve the truck type whenever the type OR the trailer plate moves —
// either edit can produce a rigid truck holding a trailer plate.
const nextTruckTypeId = dto.truckTypeId ?? vehicle.truckTypeId;
let nextTruckType: TruckType | null = null;
if (nextTruckTypeId && (dto.truckTypeId !== undefined || dto.trailerPlateNo !== undefined)) {
nextTruckType = await this.truckTypes.findById(nextTruckTypeId);
const nextTrailerPlate =
dto.trailerPlateNo !== undefined ? dto.trailerPlateNo : vehicle.trailerPlateNo;
this.assertTrailerPlateAllowed(nextTruckType, nextTrailerPlate);
}
const prev = {
assignedDriverId: vehicle.assignedDriverId,
assignedDriverName: vehicle.assignedDriverName,
@@ -156,6 +199,11 @@ export class VehiclesService {
};
Object.assign(vehicle, dto);
// After the patch is applied, so the denormalised billing code always
// reflects the type the vehicle actually ends up on.
if (nextTruckType) {
vehicle.vehicleType = nextTruckType.code;
}
const saved = await this.vehicleRepo.save(vehicle);
// Driver (re)assignment — emit an unassign for the old driver and/or an

View File

@@ -0,0 +1,81 @@
import { BadRequestException } from '@nestjs/common';
import { VehiclesService } from './vehicles.service';
// A trailer plate only exists on a configuration that pulls a trailer. A rigid
// truck (Casoni) has none, so registering or editing one into a trailer plate
// must be refused server-side — the form hiding the field is not enforcement.
describe('VehiclesService trailer plate guard', () => {
const CASONI = { code: 'CASONI', name: 'Casoni (rigid, no trailer)', hasTrailer: false, capacityTons: 30 };
const ARTIC = { code: 'TRUCK', name: 'Truck', hasTrailer: true, capacityTons: 40 };
const makeService = (findOne: jest.Mock, truckType: unknown) => {
const save = jest.fn(async (x) => x);
const svc = new VehiclesService(
{ findOne, create: jest.fn((x) => x), save } as any,
{ record: jest.fn() } as any,
{ findById: jest.fn(async () => truckType) } as any,
);
return { svc, save };
};
it('rejects creating a rigid truck that carries a trailer plate', async () => {
const findOne = jest.fn().mockResolvedValueOnce(null); // plate is free
const { svc } = makeService(findOne, CASONI);
await expect(
svc.create({ plateNumber: 'ET-9875', truckTypeId: 'tt-casoni', trailerPlateNo: 'ET-1234' } as any),
).rejects.toThrow(BadRequestException);
});
it('accepts a rigid truck with no trailer plate, and takes capacity from the type', async () => {
const findOne = jest.fn().mockResolvedValueOnce(null);
const { svc } = makeService(findOne, CASONI);
const saved = await svc.create({ plateNumber: 'ET-9875', truckTypeId: 'tt-casoni' } as any);
expect(saved.capacity).toBe(30);
// Denormalised code is what truck-detention billing groups on.
expect(saved.vehicleType).toBe('CASONI');
});
it('keeps an explicit capacity over the type default', async () => {
const findOne = jest.fn().mockResolvedValueOnce(null);
const { svc } = makeService(findOne, CASONI);
const saved = await svc.create({
plateNumber: 'ET-9875',
truckTypeId: 'tt-casoni',
capacity: 25,
} as any);
expect(saved.capacity).toBe(25);
});
it('allows a trailer plate on an articulated type', async () => {
const findOne = jest.fn().mockResolvedValueOnce(null);
const { svc } = makeService(findOne, ARTIC);
await expect(
svc.create({ plateNumber: 'ET-9875', truckTypeId: 'tt-truck', trailerPlateNo: 'ET-1234' } as any),
).resolves.toBeDefined();
});
// The regression that motivated validating the RESULT rather than the patch:
// switching type alone leaves the stored trailer plate behind.
it('rejects switching an existing truck to a rigid type while its trailer plate stands', async () => {
const findOne = jest
.fn()
.mockResolvedValueOnce({ id: 'v1', plateNumber: 'ET-9875', trailerPlateNo: 'ET-1234' });
const { svc } = makeService(findOne, CASONI);
await expect(svc.update('v1', { truckTypeId: 'tt-casoni' } as any)).rejects.toThrow(
BadRequestException,
);
});
it('allows the switch when the trailer plate is cleared in the same edit', async () => {
const findOne = jest
.fn()
.mockResolvedValueOnce({ id: 'v1', plateNumber: 'ET-9875', trailerPlateNo: 'ET-1234' });
const { svc } = makeService(findOne, CASONI);
const saved = await svc.update('v1', {
truckTypeId: 'tt-casoni',
trailerPlateNo: null,
} as any);
expect(saved.vehicleType).toBe('CASONI');
});
});

View File

@@ -791,15 +791,22 @@ export class WarehouseFeeService {
};
}
// Group the leg's vehicles by type so each truck type is billed by its own
// matching rule (rates differ by truck type). Falls back to one untyped group.
// Group the leg's vehicles by CANONICAL truck type so each type is billed
// by its own matching rule (rates differ by truck type). The FK to
// truck_types is the source of truth — renaming a type's label no longer
// silently unmatches its rule; the normalized legacy vehicle_type code is
// only a fallback for vehicles without the FK (LEFT JOIN keeps them billed
// instead of dropping them). Falls back to one untyped group.
const groupRows: Array<{ vehicleType: string | null; truckCount: number | string }> =
await this.dataSource.query(
`SELECT v.vehicle_type AS "vehicleType", count(*)::int AS "truckCount"
`SELECT COALESCE(t.code, NULLIF(UPPER(TRIM(v.vehicle_type)), '')) AS "vehicleType",
count(*)::int AS "truckCount"
FROM freight.last_mile_vehicle_assignments va
JOIN freight.vehicles v ON v.id = va.vehicle_id AND v.deleted_at IS NULL
LEFT JOIN freight.truck_types t
ON t.id = v.truck_type_id AND t.deleted_at IS NULL
WHERE va.last_mile_id = $1 AND va.deleted_at IS NULL
GROUP BY v.vehicle_type`,
GROUP BY 1`,
[lastMileId],
);
const groups = groupRows.length ? groupRows : [{ vehicleType: null, truckCount: 1 }];

View File

@@ -11,6 +11,7 @@ export const RULE_ENGINE_RESOURCE_SLUGS = [
'cargo-types',
'container-types',
'wagon-types',
'truck-types',
'service-types',
'yards',
'shipping-lines',
@@ -97,6 +98,7 @@ const RULE_ENGINE_PERMISSION_IDS: Record<RuleEngineResourceSlug, { view: string;
'cargo-types': { view: 'b2000001-0001-4000-8000-000000000001', manage: 'b2000001-0001-4000-8000-000000000002' },
'container-types': { view: 'b2000001-0001-4000-8000-000000000003', manage: 'b2000001-0001-4000-8000-000000000004' },
'wagon-types': { view: 'b2000001-0001-4000-8000-000000000015', manage: 'b2000001-0001-4000-8000-000000000016' },
'truck-types': { view: 'b2000001-0001-4000-8000-00000000001a', manage: 'b2000001-0001-4000-8000-00000000001b' },
'service-types': { view: 'b2000001-0001-4000-8000-000000000005', manage: 'b2000001-0001-4000-8000-000000000006' },
yards: { view: 'b2000001-0001-4000-8000-000000000007', manage: 'b2000001-0001-4000-8000-000000000008' },
'shipping-lines': { view: 'b2000001-0001-4000-8000-000000000009', manage: 'b2000001-0001-4000-8000-00000000000a' },

View File

@@ -126,6 +126,28 @@ const FleetFormDialog = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, recordId]);
// Seed the `_`-prefixed scratch that `onOptionSelected` derives (e.g.
// _hasTrailer) for the value already on the record. Without this, editing a
// rigid truck would show a Trailer Plate field until the type is re-picked.
// Only scratch keys are written, so a stored one-off capacity is never
// clobbered by the type's default; re-deriving from the live value is
// idempotent, so this is safe to run again when the options finally load.
useEffect(() => {
if (!open) return;
setValues((current) => {
const scratch: Record<string, unknown> = {};
fields.forEach((field) => {
if (!field.onOptionSelected) return;
const selected = field.options?.find((o) => o.value === current[field.name]);
if (!selected) return;
Object.entries(field.onOptionSelected(selected, current)).forEach(([key, value]) => {
if (key.startsWith("_")) scratch[key] = value;
});
});
return Object.keys(scratch).length ? { ...current, ...scratch } : current;
});
}, [open, fields]);
// Receive the ?code&state relayed by the /callback popup, exchange it for
// the verified identity, and prefill the matching form fields.
useEffect(() => {
@@ -202,18 +224,52 @@ const FleetFormDialog = ({
const faydaVerified = values.faydaVerified === true;
/**
* Fields the current answers actually apply to — a rigid truck type (Casoni)
* has no trailer, so its plate field disappears. Honoured in three places, not
* just here: a hidden field must also skip validation (an invisible "required"
* error blocks submit with nothing to fix) and must submit an explicit null
* (so switching to a rigid type CLEARS the stored trailer plate rather than
* stranding it on the row).
*/
const visibleFields = useMemo(
() =>
fields.filter((field) => {
if (
field.hideWhen &&
field.hideWhen.equals.includes(String(values[field.hideWhen.field] ?? ""))
) {
return false;
}
if (
field.showWhen &&
!field.showWhen.equals.includes(String(values[field.showWhen.field] ?? ""))
) {
return false;
}
if (field.showIf && !field.showIf(values)) return false;
return true;
}),
[fields, values],
);
const hiddenFieldNames = useMemo(() => {
const visible = new Set(visibleFields.map((f) => f.name));
return fields.filter((f) => !visible.has(f.name)).map((f) => f.name);
}, [fields, visibleFields]);
const shortFields = useMemo(
() => fields.filter((f) => f.type !== "textarea"),
[fields],
() => visibleFields.filter((f) => f.type !== "textarea"),
[visibleFields],
);
const longFields = useMemo(
() => fields.filter((f) => f.type === "textarea"),
[fields],
() => visibleFields.filter((f) => f.type === "textarea"),
[visibleFields],
);
const validate = () => {
const next: Record<string, string> = {};
fields.forEach((field) => {
visibleFields.forEach((field) => {
const value = values[field.name];
const stringValue =
typeof value === "string" ? value.trim() : String(value ?? "");
@@ -295,9 +351,19 @@ const FleetFormDialog = ({
fields.forEach((field) => {
if (field.derivedValue) submitted[field.name] = field.derivedValue(values);
});
// A field the answers hid no longer applies to this record — send an explicit
// null so the column is unset, instead of leaving a stale value behind.
hiddenFieldNames.forEach((name) => {
submitted[name] = null;
});
const payload = Object.fromEntries(
Object.entries(submitted)
// `_`-prefixed keys are form-local scratch written by `onOptionSelected`
// (e.g. _hasTrailer, which drives visibility). The API validates with
// forbidNonWhitelisted, so an undeclared key would 400 the whole save.
.filter(([key]) => !key.startsWith("_"))
.map(([key, value]) => {
if (hiddenFieldNames.includes(key)) return [key, null];
if (value === FLEET_SELECT_NONE || value === "" || value == null)
return [key, clearableByName[key] ? null : undefined];
if (fieldTypeByName[key] === "number") {
@@ -371,7 +437,15 @@ const FleetFormDialog = ({
: String(value)
}
onChange={(next) =>
setValues((current) => ({ ...current, [field.name]: next ?? "" }))
setValues((current) => {
const patch = field.onOptionSelected
? field.onOptionSelected(
field.options?.find((o) => o.value === next),
current,
)
: {};
return { ...current, [field.name]: next ?? "", ...patch };
})
}
error={error}
searchable

View File

@@ -417,6 +417,9 @@ export const URL_CONSTANTS = {
WAGON_TYPES: "/wagon-types",
WAGON_TYPE_BY_ID: (id: string) => `/wagon-types/${id}`,
TRUCK_TYPES: "/truck-types",
TRUCK_TYPE_BY_ID: (id: string) => `/truck-types/${id}`,
PRIORITY_CONFIGS: "/priority-configs",
PRIORITY_CONFIG_BY_ID: (id: string) => `/priority-configs/${id}`,

View File

@@ -94,6 +94,9 @@ const FleetResourcePage = () => {
const { data: wagonTypes = [], isLoading: wagonTypesLoading } = useQuery(
api.wagonTypes.list.queryOptions(),
);
const { data: truckTypes = [], isLoading: truckTypesLoading } = useQuery(
api.truckTypes.list.queryOptions(),
);
const { data: containerTypes = [], isLoading: containerTypesLoading } = useQuery(
api.containerTypes.list.queryOptions({ staleTime: Infinity }),
);
@@ -169,17 +172,34 @@ const FleetResourcePage = () => {
(y) => ({ value: y.id, label: y.label ?? y.code ?? y.id }),
);
// Carries capacity + trailer configuration so picking a truck type can
// pre-fill the vehicle's capacity and drop the trailer plate on a rigid type.
const truckTypeOpts = (
truckTypes as Array<{
id: string;
code: string;
name?: string;
capacityTons?: number | null;
hasTrailer?: boolean;
}>
).map((t) => ({
value: t.id,
label: t.name ? `${t.name} (${t.code})` : t.code,
meta: { capacityTons: t.capacityTons, hasTrailer: t.hasTrailer },
}));
registerFleetOptionLabels("currentYardId", yardOpts);
return {
wagonTypes: wagonTypeOpts,
containerTypes: containerTypeOpts,
cargoTypes: [{ label: "None", value: FLEET_SELECT_NONE }, ...cargoTypeOpts],
truckTypes: truckTypeOpts,
wagons: [{ label: "Unassigned", value: FLEET_SELECT_NONE }, ...wagonOpts],
containers: containerOpts,
yards: yardOpts,
};
}, [wagonTypes, containerTypes, cargoTypes, wagons, containers, yards]);
}, [wagonTypes, containerTypes, cargoTypes, truckTypes, wagons, containers, yards]);
const listFilterSelects = useMemo(() => {
if (!config?.listFilters?.length) return null;
@@ -212,6 +232,7 @@ const FleetResourcePage = () => {
registerFleetOptionLabels("containerId", dynamicOptions.containers);
registerFleetOptionLabels("currentYardId", dynamicOptions.yards);
registerFleetOptionLabels("locationId", dynamicOptions.yards);
registerFleetOptionLabels("truckTypeId", dynamicOptions.truckTypes);
}, [dynamicOptions]);
const formFields = useMemo((): FleetFormFieldDef[] => {
@@ -227,6 +248,7 @@ const FleetResourcePage = () => {
wagonTypesLoading ||
containerTypesLoading ||
cargoTypesLoading ||
truckTypesLoading ||
wagonsLoading ||
containersLoading ||
yardsLoading;

View File

@@ -1,14 +1,18 @@
import { useMemo } from "react";
import { useMemo, useState } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
ActionIcon,
Badge,
Button,
Card,
Center,
Container,
Group,
Loader,
NumberInput,
Radio,
Select,
SimpleGrid,
Stack,
Table,
@@ -20,6 +24,7 @@ import {
import {
ArrowLeft,
Fuel,
Gauge,
History,
Route,
Truck,
@@ -28,7 +33,13 @@ import {
} from "lucide-react";
import { api } from "@/auth/http";
import { vehiclesService } from "@/services/vehicles.service";
import { api as apiClient2 } from "@/services/api";
import { useToast } from "@/hooks/use-toast";
import {
vehiclesService,
type SaveVehiclePayload,
type Vehicle,
} from "@/services/vehicles.service";
import { driversService } from "@/services/drivers.service";
import { fleetHistoryService } from "@/services/fleet-history.service";
@@ -132,6 +143,7 @@ const VehicleDetailPage = () => {
<Tabs.Tab value="history" leftSection={<History size={14} />}>History</Tabs.Tab>
<Tabs.Tab value="maintenance" leftSection={<Wrench size={14} />}>Maintenance</Tabs.Tab>
<Tabs.Tab value="fuel" leftSection={<Fuel size={14} />}>Fuel</Tabs.Tab>
<Tabs.Tab value="operations" leftSection={<Gauge size={14} />}>Operations</Tabs.Tab>
<Tabs.Tab value="mile" leftSection={<Route size={14} />}>First/Last mile</Tabs.Tab>
</Tabs.List>
@@ -142,6 +154,8 @@ const VehicleDetailPage = () => {
<InfoRow label="Code" value={vehicle.code ?? "—"} />
<InfoRow label="Registration" value={vehicle.registrationNumber ?? "—"} />
<InfoRow label="Type" value={vehicle.vehicleType ?? "—"} />
<InfoRow label="VIN" value={vehicle.vin ?? "—"} />
<InfoRow label="Ownership" value={vehicle.ownership ?? "—"} />
<InfoRow label="Manufacturer" value={vehicle.manufacturer ?? "—"} />
<InfoRow label="Model" value={vehicle.model ?? "—"} />
<InfoRow label="Year" value={vehicle.year ?? "—"} />
@@ -172,6 +186,10 @@ const VehicleDetailPage = () => {
<FuelTab vehicleId={id} />
</Tabs.Panel>
<Tabs.Panel value="operations" pt="lg">
<OperationsTab vehicle={vehicle} />
</Tabs.Panel>
<Tabs.Panel value="mile" pt="lg">
<MileTab vehicleId={id} />
</Tabs.Panel>
@@ -181,6 +199,103 @@ const VehicleDetailPage = () => {
);
};
/**
* Where a truck is and what it costs to run are per-trip operational facts, not
* part of registering the vehicle — so they are edited here rather than on the
* Add Vehicle form. `pricePerKm` is live billing input: first/last-mile charges
* are `distance × pricePerKm`.
*/
const OperationsTab = ({ vehicle }: { vehicle: Vehicle }) => {
const { toast } = useToast();
const queryClient = useQueryClient();
const [form, setForm] = useState({
locationId: vehicle.locationId ?? "",
estimatedDistanceKm: vehicle.estimatedDistanceKm ?? "",
actualDistanceKm: vehicle.actualDistanceKm ?? "",
pricePerKm: vehicle.pricePerKm ?? "",
currency: vehicle.currency ?? "ETB",
});
const { data: yards = [], isLoading: yardsLoading } = useQuery(
apiClient2.routes.yards.queryOptions(),
);
const save = useMutation({
mutationFn: () =>
vehiclesService.update(vehicle.id, {
locationId: form.locationId || null,
// Empty means "not recorded" — send null so the column is unset rather
// than coerced to 0, which would read as a real measurement.
estimatedDistanceKm: form.estimatedDistanceKm === "" ? null : Number(form.estimatedDistanceKm),
actualDistanceKm: form.actualDistanceKm === "" ? null : Number(form.actualDistanceKm),
pricePerKm: form.pricePerKm === "" ? null : Number(form.pricePerKm),
currency: form.currency || null,
} as Partial<SaveVehiclePayload> & { locationId?: string | null }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["vehicle", vehicle.id] });
toast({ title: "Operational details saved" });
},
onError: () =>
toast({ title: "Could not save operational details", variant: "destructive" }),
});
return (
<Card withBorder radius="md" padding="md">
<Stack gap="md">
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="sm">
<Select
label="Location"
placeholder={yardsLoading ? "Loading yards..." : "Not set"}
data={(yards as Array<{ id: string; label?: string; code?: string }>).map((y) => ({
value: y.id,
label: y.label ?? y.code ?? y.id,
}))}
value={form.locationId || null}
onChange={(v) => setForm((f) => ({ ...f, locationId: v ?? "" }))}
disabled={yardsLoading}
searchable
clearable
/>
<NumberInput
label="Price per KM"
description="Used for first/last-mile billing"
value={form.pricePerKm}
onChange={(v) => setForm((f) => ({ ...f, pricePerKm: v as number | "" }))}
min={0}
/>
<NumberInput
label="Estimated Distance (KM)"
value={form.estimatedDistanceKm}
onChange={(v) => setForm((f) => ({ ...f, estimatedDistanceKm: v as number | "" }))}
min={0}
/>
<NumberInput
label="Actual Distance (KM)"
value={form.actualDistanceKm}
onChange={(v) => setForm((f) => ({ ...f, actualDistanceKm: v as number | "" }))}
min={0}
/>
<Radio.Group
label="Currency"
value={form.currency}
onChange={(v) => setForm((f) => ({ ...f, currency: v }))}
>
<Group gap="lg" mt={6}>
<Radio value="ETB" label="ETB" />
<Radio value="USD" label="USD" />
</Group>
</Radio.Group>
</SimpleGrid>
<Group justify="flex-end">
<Button onClick={() => save.mutate()} loading={save.isPending}>
Save
</Button>
</Group>
</Stack>
</Card>
);
};
const DriverTab = ({
vehicleId,
driverId,

View File

@@ -30,10 +30,22 @@ export type FleetDynamicOptions =
| "wagonTypes"
| "containerTypes"
| "cargoTypes"
| "truckTypes"
| "wagons"
| "containers"
| "yards";
/**
* A dynamic select option that can carry the record it came from. Picking a
* truck type has to pull its capacity and trailer configuration into the form,
* which a bare {label, value} pair cannot express.
*/
export interface FleetSelectOption {
label: string;
value: string;
meta?: Record<string, unknown>;
}
export interface FleetResourceColumn {
id: string;
header: string;
@@ -45,6 +57,17 @@ export interface FleetResourceColumn {
export interface FleetFormFieldDef extends FormFieldDef {
dynamicOptions?: FleetDynamicOptions;
noneOption?: boolean;
/** Options carrying their source record, so `onOptionSelected` can read it. */
options?: FleetSelectOption[];
/**
* Patch merged into the form when this select changes — for values that are a
* property of the chosen option rather than typed per record (a vehicle's
* capacity comes from its truck type). Returns the fields to overwrite.
*/
onOptionSelected?: (
option: FleetSelectOption | undefined,
values: Record<string, unknown>,
) => Record<string, unknown>;
/**
* Read-only field whose value is computed from the other fields rather than
* typed. Rendered non-editable and recomputed on every change, so the stored

View File

@@ -10,6 +10,16 @@ const PLATE_PATTERN = {
message: "Use letters and numbers like ET-9875 or AA-8642",
};
/**
* Legacy static list. Truck configurations now live in `freight.truck_types`
* and are edited in the back office (Rule Engine → Truck Types), so the form
* loads them through `dynamicOptions: "truckTypes"` instead.
*
* Kept only for the driver "authorized vehicle types" multiselect, which stores
* free-text categories rather than truck-type ids.
*
* @deprecated prefer the managed truck types
*/
const VEHICLE_TYPE_OPTIONS = [
{ label: "Truck", value: "TRUCK" },
{ label: "Van", value: "VAN" },
@@ -20,6 +30,11 @@ const VEHICLE_TYPE_OPTIONS = [
{ label: "Flatbed", value: "FLATBED" },
];
const OWNERSHIP_OPTIONS = [
{ label: "Owned", value: "OWNED" },
{ label: "Outsourced", value: "OUTSOURCED" },
];
const FUEL_TYPE_OPTIONS = [
{ label: "Petrol", value: "PETROL" },
{ label: "Diesel", value: "DIESEL" },
@@ -39,11 +54,6 @@ const VEHICLE_AVAILABILITY_OPTIONS = [
{ label: "Busy", value: "BUSY" },
];
const CURRENCY_OPTIONS = [
{ label: "ETB", value: "ETB" },
{ label: "USD", value: "USD" },
];
export const vehiclesConfig: FleetResourceConfig = {
slug: "vehicles",
label: "Vehicles",
@@ -72,35 +82,65 @@ export const vehiclesConfig: FleetResourceConfig = {
options: VEHICLE_AVAILABILITY_OPTIONS,
},
],
searchKeys: ["plateNumber", "registrationNumber", "manufacturer", "model", "vehicleType", "status"],
searchKeys: ["plateNumber", "registrationNumber", "manufacturer", "model", "vehicleType", "vin", "status"],
columns: [
{ id: "code", header: "Code", accessorKey: "code", size: 90 },
{ id: "plateNumber", header: "Plate Number", accessorKey: "plateNumber", size: 120 },
{ id: "trailerPlateNo", header: "Trailer Plate No", accessorKey: "trailerPlateNo", size: 130 },
{ id: "manufacturer", header: "Manufacturer", accessorKey: "manufacturer", size: 120 },
{ id: "model", header: "Model", accessorKey: "model", size: 100 },
{ id: "vehicleType", header: "Type", accessorKey: "vehicleType", size: 75 },
{ id: "truckTypeId", header: "Truck Type", accessorKey: "truckTypeId", format: "entityLabel", size: 130 },
{ id: "capacity", header: "Capacity (tons)", accessorKey: "capacity", format: "number", size: 100 },
{ id: "locationId", header: "Location", accessorKey: "locationId", size: 140 },
{ id: "ownership", header: "Ownership", accessorKey: "ownership", size: 100 },
{ id: "status", header: "Status", accessorKey: "status", format: "statusBadge", size: 100 },
{ id: "availability", header: "Availability", accessorKey: "availability", format: "statusBadge", size: 100 },
],
// Registration captures what the vehicle IS. Location, distances and haulage
// pricing are per-trip operational data and live on the vehicle's Operations
// tab instead (VehicleDetailPage) — they are not part of registering a truck.
formFields: [
{ name: "code", label: "Code", type: "text" },
{ name: "plateNumber", label: "Power Plate No", type: "text", required: true, pattern: PLATE_PATTERN },
// { name: "powerPlateNo", label: "Power Plate No", type: "text" },
{ name: "trailerPlateNo", label: "Trailer Plate No", type: "text", pattern: PLATE_PATTERN },
{ name: "vehicleType", label: "Vehicle Type", type: "select", required: true, options: VEHICLE_TYPE_OPTIONS },
// The truck type decides whether a trailer exists at all: a rigid truck
// (Casoni) has none, so the field disappears and submits null. Mirrored
// server-side in VehiclesService — hiding a field is not enforcement.
{
name: "trailerPlateNo",
label: "Trailer Plate No",
type: "text",
pattern: PLATE_PATTERN,
showIf: (values) => values._hasTrailer !== false,
},
{
name: "truckTypeId",
label: "Truck Type",
type: "select",
required: true,
dynamicOptions: "truckTypes",
description: "Managed in Rule Engine → Truck Types",
// Capacity is a property of the type, not of each individual truck.
// `_hasTrailer` is form-local scratch (stripped before submit) that drives
// the trailer plate's visibility.
onOptionSelected: (option) => ({
_hasTrailer: option?.meta?.hasTrailer ?? true,
...(option?.meta?.capacityTons != null
? { capacity: option.meta.capacityTons }
: {}),
}),
},
{ name: "vin", label: "VIN", type: "text", description: "Vehicle Identification Number" },
{ name: "ownership", label: "Ownership", type: "radio", options: OWNERSHIP_OPTIONS },
{ name: "manufacturer", label: "Manufacturer", type: "text", required: true },
{ name: "model", label: "Model", type: "text", required: true },
{ name: "year", label: "Year", type: "number", required: true },
{ name: "fuelType", label: "Fuel Type", type: "select", required: true, options: FUEL_TYPE_OPTIONS },
{ name: "capacity", label: "Capacity", type: "number", required: true },
{ name: "locationId", label: "Location", type: "select", dynamicOptions: "yards" },
{ name: "estimatedDistanceKm", label: "Estimated Distance (KM)", type: "number" },
{ name: "actualDistanceKm", label: "Actual Distance (KM)", type: "number" },
{ name: "pricePerKm", label: "Price per KM", type: "number" },
{ name: "currency", label: "Currency", type: "radio", options: CURRENCY_OPTIONS },
{
name: "capacity",
label: "Capacity (tons)",
type: "number",
required: true,
description: "Pre-filled from the truck type — override only for a one-off",
},
{ name: "status", label: "Status", type: "select", required: true, options: VEHICLE_STATUS_OPTIONS },
{ name: "availability", label: "Availability", type: "select", required: true, options: VEHICLE_AVAILABILITY_OPTIONS },
{ name: "description", label: "Description", type: "textarea" },
@@ -108,19 +148,15 @@ export const vehiclesConfig: FleetResourceConfig = {
emptyValues: {
code: "03-ET",
plateNumber: "",
powerPlateNo: "",
trailerPlateNo: "",
vehicleType: "TRUCK",
truckTypeId: "",
vin: "",
ownership: "OWNED",
manufacturer: "",
model: "",
year: new Date().getFullYear(),
fuelType: "DIESEL",
capacity: 0,
locationId: null,
estimatedDistanceKm: "",
actualDistanceKm: "",
pricePerKm: "",
currency: "ETB",
status: "ACTIVE",
availability: "FREE",
description: "",

View File

@@ -229,6 +229,21 @@ const cargoDesc = (r: FirstMileRecord) => {
if (r.booking?.cargoTotalWeightVgm) parts.push(`${r.booking.cargoTotalWeightVgm} t`);
return parts.join(" · ") || "—";
};
const cargoTypeName = (r: FirstMileRecord) =>
r.booking?.cargoType?.cargoTypeName ??
r.booking?.cargoType?.label ??
r.booking?.cargoType?.name ??
r.booking?.cargoFreeText ??
"—";
const isBulkBooking = (r: FirstMileRecord) => r.booking?.freightType === "BULK";
const bookingTotalTons = (r: FirstMileRecord) => Number(r.booking?.cargoTotalWeightVgm) || 0;
const trainScheduleLabel = (r: FirstMileRecord) => {
const s = r.booking?.trainSchedule;
if (!s?.trainNumber && !s?.departureDate) return "—";
return [s.trainNumber, s.departureDate ? new Date(s.departureDate).toLocaleDateString() : null]
.filter(Boolean)
.join(" · ");
};
// First-mile destination is the origin yard (pickup → origin yard)
const destinationYardName = (r: FirstMileRecord) =>
r.booking?.originYard?.label ?? "—";
@@ -277,7 +292,9 @@ const BookingInfo = ({ record }: { record: FirstMileRecord }) => {
<InfoRow label="Service type" value={serviceTypeName(record)} />
{hasPickupAddress && <InfoRow label="Pickup location" value={pickupLocation(record)} />}
<InfoRow label="Destination (origin yard)" value={destinationYardName(record)} />
<InfoRow label="Cargo Type" value={cargoTypeName(record)} />
<InfoRow label="Cargo" value={cargoDesc(record)} />
<InfoRow label="Train Schedule" value={trainScheduleLabel(record)} />
<InfoRow label="Advanced Payment" value={formatPrice(record.advancedPayment, currencyOf(record))} />
<InfoRow label="Post Payment" value={formatPrice(record.remainingPayment, currencyOf(record))} />
<InfoRow label="Contact" value={contactPersonName(record)} />
@@ -496,10 +513,11 @@ const FirstMilePage = () => {
const [tripSlipVehicleId, setTripSlipVehicleId] = useState<string | null>(null);
const [tripSlipSelectOpen, setTripSlipSelectOpen] = useState(false);
const [activeId, setActiveId] = useState<string | null>(null);
// Multi-vehicle assign: one row per truck — vehicle + the container it carries.
// Multi-vehicle assign: one row per truck — vehicle + its load (container for
// container bookings; tonnes + optional item count for bulk).
const [vehicleRows, setVehicleRows] = useState<
Array<{ vehicleId: string | null; containerNumber: string }>
>([{ vehicleId: null, containerNumber: "" }]);
Array<{ vehicleId: string | null; containerNumber: string; tons: number | ""; quantity: number | "" }>
>([{ vehicleId: null, containerNumber: "", tons: "", quantity: "" }]);
const [acceptOpen, setAcceptOpen] = useState(false);
const [acceptStep, setAcceptStep] = useState<1 | 2>(1);
@@ -925,13 +943,19 @@ const FirstMilePage = () => {
? rec.vehicleAssignments.map((a, i) => ({
vehicleId: a.vehicleId,
containerNumber: a.containerNumber ?? nums[i] ?? "",
tons: (a.tons != null ? Number(a.tons) : "") as number | "",
quantity: (a.quantity != null ? Number(a.quantity) : "") as number | "",
}))
: rec?.vehicleId
? [{ vehicleId: rec.vehicleId, containerNumber: nums[0] ?? "" }]
: [{ vehicleId: null, containerNumber: nums[0] ?? "" }];
? [{ vehicleId: rec.vehicleId, containerNumber: nums[0] ?? "", tons: "" as const, quantity: "" as const }]
: [{ vehicleId: null, containerNumber: nums[0] ?? "", tons: "" as const, quantity: "" as const }];
setBulkMode(false);
setActiveId(resolved);
setVehicleRows(rows.length ? rows : [{ vehicleId: null, containerNumber: nums[0] ?? "" }]);
setVehicleRows(
rows.length
? rows
: [{ vehicleId: null, containerNumber: nums[0] ?? "", tons: "", quantity: "" }],
);
setAssignOpen(true);
};
@@ -944,7 +968,7 @@ const FirstMilePage = () => {
}
setBulkMode(true);
setActiveId(null);
setVehicleRows([{ vehicleId: null, containerNumber: "" }]);
setVehicleRows([{ vehicleId: null, containerNumber: "", tons: "", quantity: "" }]);
setAssignOpen(true);
};
@@ -952,15 +976,41 @@ const FirstMilePage = () => {
setAssignOpen(false);
setBulkMode(false);
setActiveId(null);
setVehicleRows([{ vehicleId: null, containerNumber: "" }]);
setVehicleRows([{ vehicleId: null, containerNumber: "", tons: "", quantity: "" }]);
};
const handleAssign = () => {
const bulkCargo = activeRecord != null && isBulkBooking(activeRecord);
if (bulkCargo && vehicleRows.some((r) => r.vehicleId && r.tons === "")) {
toast({
title: "Tonnes required",
description: "Enter the tonnage each truck hauls — bulk assignment draws down the booking total.",
variant: "destructive",
});
return;
}
if (bulkCargo) {
const total = bookingTotalTons(activeRecord);
const assigning = vehicleRows.reduce((s, r) => s + (r.vehicleId ? Number(r.tons) || 0 : 0), 0);
if (total > 0 && assigning > total + 0.001) {
toast({
title: "Over booking tonnage",
description: `Assigned ${assigning} t exceeds the booking's ${total} t.`,
variant: "destructive",
});
return;
}
}
const seen = new Set<string>();
const vehicles = vehicleRows
.filter((r): r is { vehicleId: string; containerNumber: string } => Boolean(r.vehicleId))
.filter((r): r is (typeof vehicleRows)[number] & { vehicleId: string } => Boolean(r.vehicleId))
.filter((r) => (seen.has(r.vehicleId) ? false : seen.add(r.vehicleId)))
.map((r) => ({ vehicleId: r.vehicleId, containerNumber: r.containerNumber.trim() || null }));
.map((r) => ({
vehicleId: r.vehicleId,
containerNumber: r.containerNumber.trim() || null,
tons: bulkCargo && r.tons !== "" ? Number(r.tons) : null,
quantity: bulkCargo && r.quantity !== "" ? Number(r.quantity) : null,
}));
const count = vehicles.length;
const targetIds = bulkMode
? selectedIds
@@ -1432,30 +1482,63 @@ const FirstMilePage = () => {
clearable
disabled={assignVehicleOptions.length === 0}
/>
<Select
style={{ flex: 1 }}
label={i === 0 ? "Container no." : undefined}
placeholder={containerOptions.length ? "Select container" : "No container numbers"}
data={[
...containerOptions.filter(
(n) =>
n === row.containerNumber ||
!vehicleRows.some((r, idx) => idx !== i && r.containerNumber === n),
),
// keep a manual/legacy value selectable even if not in the booking
...(row.containerNumber && !containerOptions.includes(row.containerNumber)
? [row.containerNumber]
: []),
]}
value={row.containerNumber || null}
onChange={(value) =>
setVehicleRows((prev) =>
prev.map((x, idx) => (idx === i ? { ...x, containerNumber: value ?? "" } : x)),
)
}
searchable
clearable
/>
{!bulkMode && activeRecord && isBulkBooking(activeRecord) ? (
<>
<NumberInput
style={{ flex: 0.8 }}
label={i === 0 ? "Tonnes" : undefined}
placeholder="t"
min={0}
value={row.tons}
onChange={(v) =>
setVehicleRows((prev) =>
prev.map((x, idx) =>
idx === i ? { ...x, tons: v === "" ? "" : Number(v) } : x,
),
)
}
/>
<NumberInput
style={{ flex: 0.8 }}
label={i === 0 ? "Items qty (pcs)" : undefined}
placeholder="optional"
min={0}
value={row.quantity}
onChange={(v) =>
setVehicleRows((prev) =>
prev.map((x, idx) =>
idx === i ? { ...x, quantity: v === "" ? "" : Number(v) } : x,
),
)
}
/>
</>
) : (
<Select
style={{ flex: 1 }}
label={i === 0 ? "Container no." : undefined}
placeholder={containerOptions.length ? "Select container" : "No container numbers"}
data={[
...containerOptions.filter(
(n) =>
n === row.containerNumber ||
!vehicleRows.some((r, idx) => idx !== i && r.containerNumber === n),
),
// keep a manual/legacy value selectable even if not in the booking
...(row.containerNumber && !containerOptions.includes(row.containerNumber)
? [row.containerNumber]
: []),
]}
value={row.containerNumber || null}
onChange={(value) =>
setVehicleRows((prev) =>
prev.map((x, idx) => (idx === i ? { ...x, containerNumber: value ?? "" } : x)),
)
}
searchable
clearable
/>
)}
{vehicleRows.length > 1 && (
<ActionIcon
variant="subtle"
@@ -1479,6 +1562,8 @@ const FirstMilePage = () => {
vehicleId: null,
containerNumber:
(activeRecord ? bookingContainerNumbers(activeRecord)[prev.length] : "") ?? "",
tons: "" as const,
quantity: "" as const,
},
])
}
@@ -1491,6 +1576,22 @@ const FirstMilePage = () => {
>
Add vehicle
</Button>
{!bulkMode && activeRecord && isBulkBooking(activeRecord) && (() => {
const total = bookingTotalTons(activeRecord);
const assigning = vehicleRows.reduce(
(s, r) => s + (r.vehicleId ? Number(r.tons) || 0 : 0),
0,
);
const remaining = Math.round((total - assigning) * 1000) / 1000;
return (
<Text size="sm" c={remaining < 0 ? "red" : "dimmed"}>
Bulk drawdown: {assigning} t of {total} t assigned {" "}
<Text span fw={600} c={remaining < 0 ? "red" : undefined}>
{remaining} t remaining
</Text>
</Text>
);
})()}
</Stack>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={closeAssign}>Cancel</Button>

View File

@@ -409,6 +409,43 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
{ name: "isActive", label: "Active", type: "boolean" },
],
},
{
slug: "truck-types",
label: "Truck Types",
category: "configuration",
subtitle:
"Configure the truck configurations vehicles are registered against — capacity and whether a trailer applies",
searchPlaceholder: "Search truck types by name or code...",
cardTitleKey: "name",
columns: [
codeColumn("code"),
{ id: "name", header: "Name", accessorKey: "name" },
{ id: "capacityTons", header: "Capacity (t)", accessorKey: "capacityTons", format: "number" },
{ id: "hasTrailer", header: "Has trailer", accessorKey: "hasTrailer" },
activeColumn,
],
formFields: [
{ name: "code", label: "Code", type: "text", required: true },
{ name: "name", label: "Name", type: "text", required: true },
{
name: "capacityTons",
label: "Capacity (tons)",
type: "number",
optional: true,
description: "Pre-fills the capacity of every vehicle registered on this type",
},
// Drives the trailer plate on vehicle registration: a rigid truck (Casoni)
// has none, so registering one with a trailer plate is rejected.
{
name: "hasTrailer",
label: "Pulls a trailer",
type: "boolean",
description: "Off for a rigid truck (e.g. Casoni) — its registration has no trailer plate",
},
{ name: "description", label: "Description", type: "textarea", optional: true },
{ name: "isActive", label: "Active", type: "boolean" },
],
},
{
slug: "yard-distances",
label: "Yard Distances",

View File

@@ -39,7 +39,6 @@ import {
FEE_RULE_BASIS_LABELS,
FEE_RULE_TYPES,
FEE_RULE_TYPE_LABELS,
VEHICLE_TYPES,
type AllocationRule,
type FeeRule,
type FeeRuleBasis,
@@ -393,6 +392,12 @@ function FeeRules() {
const { data: containerTypes = [], isLoading: containerTypesLoading } = useQuery(
api.containerTypes.list.queryOptions({ staleTime: Infinity }),
);
// Detention rules match on the truck-type CODE denormalised onto
// vehicles.vehicle_type, so the options come from the managed truck types
// rather than a hardcoded list that drifts the moment a type is added.
const { data: truckTypes = [], isLoading: truckTypesLoading } = useQuery(
api.truckTypes.list.queryOptions({ staleTime: Infinity }),
);
const create = useCreateFeeRule();
const update = useUpdateFeeRule();
const remove = useDeleteFeeRule();
@@ -712,8 +717,9 @@ function FeeRules() {
{isTruckDetention && (
<Select
label="Truck type"
placeholder="Any truck type"
data={VEHICLE_TYPES.map((v) => ({ value: v, label: v.charAt(0) + v.slice(1).toLowerCase() }))}
placeholder={truckTypesLoading ? 'Loading truck types...' : 'Any truck type'}
data={truckTypes.map((t) => ({ value: t.code, label: `${t.name} (${t.code})` }))}
disabled={truckTypesLoading}
value={form.vehicleType || null}
onChange={(value) => setForm((f) => ({ ...f, vehicleType: selectValue(value) }))}
clearable

View File

@@ -195,6 +195,7 @@ import {
type UsedTrainNumbers,
} from "./trainBuilder.service";
import { trainSchedulingService } from "./trainScheduling.service";
import { truckTypesService, type TruckType } from "./truck-types.service";
import { wagonTypesService, type WagonType } from "./wagon-types.service";
import {
wagonService,
@@ -2073,6 +2074,36 @@ export const api = {
),
},
truckTypes: {
list: endpoint<void, TruckType[]>("truck-types", "list", () =>
truckTypesService.getTruckTypes(),
),
create: endpoint<Partial<TruckType>, TruckType>(
"truck-types",
"create",
(payload) => truckTypesService.create(payload).then((r) => r.data),
undefined,
() => [["truck-types"]],
),
update: endpoint<{ id: string; data: Partial<TruckType> }, TruckType>(
"truck-types",
"update",
({ id, data }) => truckTypesService.update(id, data).then((r) => r.data),
undefined,
() => [["truck-types"]],
),
remove: endpoint<string, void>(
"truck-types",
"remove",
(id) => truckTypesService.delete(id).then(() => undefined),
undefined,
() => [["truck-types"]],
),
},
wagonTypes: {
list: endpoint<void, WagonType[]>("wagon-types", "list", () =>
wagonTypesService.getWagonTypes(),

View File

@@ -22,7 +22,10 @@ export interface FirstMileBooking {
serviceType?: { id: string; label?: string } | null;
originYard?: { id: string; label?: string } | null;
destinationYard?: { id: string; label?: string } | null;
cargoType?: { id: string; label?: string } | null;
cargoType?: { id: string; label?: string; cargoTypeName?: string; name?: string } | null;
freightType?: string | null;
/** Attached server-side: the train schedule this booking rides. */
trainSchedule?: { trainNumber: string | null; departureDate: string | null } | null;
/** Container lines — total container count drives how many trucks are needed. */
bookingContainers?: Array<{
id: string;
@@ -68,6 +71,8 @@ export interface FirstMileRecord {
vehicleId: string;
containerNumber?: string | null;
distanceKm?: number | null;
tons?: number | null;
quantity?: number | null;
vehicle?: FirstMileVehicle | null;
}>;
/** Present only when an invoice has actually been generated (not on distance). */
@@ -95,7 +100,12 @@ export const firstMileService = {
api.delete<void>(FM.BY_ID(id)),
setVehicles: (
id: string,
vehicles: Array<{ vehicleId: string; containerNumber?: string | null }>,
vehicles: Array<{
vehicleId: string;
containerNumber?: string | null;
tons?: number | null;
quantity?: number | null;
}>,
) => api.post<FirstMileRecord>(`${FM.BASE}/${id}/vehicles`, { vehicles }),
setDistances: (
id: string,

View File

@@ -85,6 +85,7 @@ const RESOURCE_BASE: Record<RuleEngineResourceSlug, string> = {
"cargo-types": URL_CONSTANTS.RULE_ENGINE.CARGO_TYPES,
"container-types": URL_CONSTANTS.RULE_ENGINE.CONTAINER_TYPES,
"wagon-types": URL_CONSTANTS.RULE_ENGINE.WAGON_TYPES,
"truck-types": URL_CONSTANTS.RULE_ENGINE.TRUCK_TYPES,
"priority-configs": URL_CONSTANTS.RULE_ENGINE.PRIORITY_CONFIGS,
"service-types": URL_CONSTANTS.RULE_ENGINE.SERVICE_TYPES,
"weight-limit-rules": URL_CONSTANTS.RULE_ENGINE.WEIGHT_LIMIT_RULES,
@@ -103,6 +104,8 @@ const byIdPath = (resource: RuleEngineResourceSlug, id: string): string => {
return URL_CONSTANTS.RULE_ENGINE.CONTAINER_TYPE_BY_ID(id);
case "wagon-types":
return URL_CONSTANTS.RULE_ENGINE.WAGON_TYPE_BY_ID(id);
case "truck-types":
return URL_CONSTANTS.RULE_ENGINE.TRUCK_TYPE_BY_ID(id);
case "priority-configs":
return URL_CONSTANTS.RULE_ENGINE.PRIORITY_CONFIG_BY_ID(id);
case "service-types":

View File

@@ -0,0 +1,30 @@
import { api } from "../auth/http";
type ListResponse<T> = T[] | { data: T[] };
export interface TruckType {
id: string;
code: string;
name: string;
/** Pre-fills a vehicle's capacity — capacity belongs to the type, not each truck. */
capacityTons: number | null;
/** False for a rigid truck (e.g. Casoni), which has no trailer plate at all. */
hasTrailer: boolean;
description?: string | null;
isActive: boolean;
}
const asList = <T>(payload: ListResponse<T>): T[] =>
Array.isArray(payload) ? payload : payload.data;
export const truckTypesService = {
async getTruckTypes() {
const response = await api.get<ListResponse<TruckType>>('/truck-types', {
params: { isActive: 'all', pageSize: 500 },
});
return asList(response.data);
},
create: (data: Partial<TruckType>) => api.post('/truck-types', data),
update: (id: string, data: Partial<TruckType>) => api.patch(`/truck-types/${id}`, data),
delete: (id: string) => api.delete(`/truck-types/${id}`),
};

View File

@@ -20,7 +20,14 @@ export interface Vehicle {
id: string;
plateNumber: string;
registrationNumber: string;
/** Denormalised truck-type code, written server-side. Register with `truckTypeId`. */
vehicleType: VehicleType;
/** Truck configuration from the managed truck types. */
truckTypeId?: string | null;
/** Vehicle Identification Number — unique across the fleet. */
vin?: string | null;
/** OWNED | OUTSOURCED. */
ownership?: string | null;
manufacturer: string;
model: string;
year: number;

View File

@@ -2,6 +2,7 @@ export type RuleEngineResourceSlug =
| "cargo-types"
| "container-types"
| "wagon-types"
| "truck-types"
| "priority-configs"
| "service-types"
| "weight-limit-rules"

View File

@@ -8,6 +8,7 @@ import {
Group,
Loader,
MultiSelect,
NumberInput,
Select,
SimpleGrid,
Stack,
@@ -79,6 +80,8 @@ export function CustomerTruckAssignmentCard({
const [driverName, setDriverName] = useState("");
const [truckType, setTruckType] = useState("");
const [containers, setContainers] = useState<string[]>([]);
const [plannedTons, setPlannedTons] = useState<number | "">("");
const [plannedQty, setPlannedQty] = useState<number | "">("");
const [editingId, setEditingId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [bulkModalOpen, setBulkModalOpen] = useState(false);
@@ -105,15 +108,23 @@ export function CustomerTruckAssignmentCard({
setDriverName("");
setTruckType("");
setContainers([]);
setPlannedTons("");
setPlannedQty("");
setEditingId(null);
setError(null);
};
const startEdit = (t: Freight.ICustomerTruck) => {
const planned = t as Freight.ICustomerTruck & {
plannedTons?: number | string | null;
plannedQuantity?: number | null;
};
setPlateNumber(t.plateNumber ?? "");
setDriverName(t.driverName ?? "");
setTruckType(t.truckType ?? "");
setContainers((t.containers ?? []).map((c) => c.containerNumber));
setPlannedTons(planned.plannedTons != null ? Number(planned.plannedTons) : "");
setPlannedQty(planned.plannedQuantity != null ? Number(planned.plannedQuantity) : "");
setEditingId(t.id);
setError(null);
};
@@ -129,6 +140,12 @@ export function CustomerTruckAssignmentCard({
driverName: driverName.trim(),
truckType: truckType.trim(),
containerNumbers: isBulk ? [] : containers,
...(isBulk
? {
plannedTons: plannedTons === "" ? undefined : Number(plannedTons),
plannedQuantity: plannedQty === "" ? undefined : Number(plannedQty),
}
: {}),
};
return editingId
? customerTrucksService.update(booking.id, editingId, payload)
@@ -170,6 +187,10 @@ export function CustomerTruckAssignmentCard({
setError("Select 1 or 2 container numbers for this truck.");
return;
}
if (isBulk && plannedTons === "") {
setError("Enter the tonnes this truck will haul.");
return;
}
setError(null);
addMutation.mutate();
};
@@ -324,6 +345,40 @@ export function CustomerTruckAssignmentCard({
nothingFoundMessage="No unassigned containers"
/>
)}
{isBulk && (
<NumberInput
label="Tonnes to load"
description={(() => {
const total = Number(booking.cargoTotalWeightVgm) || 0;
const assigned = trucks
.filter((t) => t.id !== editingId)
.reduce((s, t) => {
const x = t as Freight.ICustomerTruck & {
netWeightTons?: number | string | null;
plannedTons?: number | string | null;
};
return s + (Number(x.netWeightTons ?? x.plannedTons) || 0);
}, 0);
const remaining = Math.max(0, Math.round((total - assigned) * 1000) / 1000);
return total > 0
? `${assigned} t of ${total} t already on trucks · ${remaining} t remaining`
: "Tonnage this truck hauls";
})()}
required
min={0}
value={plannedTons}
onChange={(v) => setPlannedTons(v === "" ? "" : Number(v))}
/>
)}
{isBulk && (
<NumberInput
label="Items quantity (pcs)"
description="Optional piece count on this truck"
min={0}
value={plannedQty}
onChange={(v) => setPlannedQty(v === "" ? "" : Number(v))}
/>
)}
</SimpleGrid>
<Group justify="flex-end">
{editingId && (