mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
@@ -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;`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { PartialType } from '@nestjs/mapped-types';
|
||||
|
||||
import { CreateTruckTypeDto } from './create-truck-type.dto';
|
||||
|
||||
export class UpdateTruckTypeDto extends PartialType(CreateTruckTypeDto) {}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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 } });
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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 }];
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: "",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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}`),
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -2,6 +2,7 @@ export type RuleEngineResourceSlug =
|
||||
| "cargo-types"
|
||||
| "container-types"
|
||||
| "wagon-types"
|
||||
| "truck-types"
|
||||
| "priority-configs"
|
||||
| "service-types"
|
||||
| "weight-limit-rules"
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
Reference in New Issue
Block a user