This commit is contained in:
Marshal
2026-07-25 06:05:20 +00:00
254 changed files with 15542 additions and 5375 deletions

View File

@@ -12,7 +12,7 @@ import {
ensurePostgresSchemas,
APPLICATION_SEARCH_PATH,
} from "./config/ensure-postgres-schemas";
import { IamModule, DataSeeder } from "@tria-plc/iamapi-common";
import { IamModule } from "@tria-plc/iamapi-common";
import { SharedAuthModule } from "@tria-plc/api-common/modules/auth/shared-auth.module";
import appConfig from "./config/app.config";
@@ -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";
@@ -77,8 +78,8 @@ import { ApprovedFirstLastMileDemoBookingsSeeder } from "./seed/approved-first-l
import { PaidImportExportMileDemoSeeder } from "./seed/paid-import-export-mile-demo.seeder";
//New Trains, Wagons, Container and Cargo management modules
import { TrainsModule } from "./modules/trains/trains.module";
import { VerifaydaModule } from './modules/verifayda/verifayda.module';
import { FleetHistoryModule } from './modules/fleet-history/fleet-history.module';
import { VerifaydaModule } from "./modules/verifayda/verifayda.module";
import { FleetHistoryModule } from "./modules/fleet-history/fleet-history.module";
import { WagonsModule } from "./modules/wagons/wagons.module";
import { ContainersModule } from "./modules/container-management/containers.module";
import { CargoesModule } from "./modules/cargoes/cargoes.module";
@@ -104,7 +105,13 @@ import { LoggerMiddleware } from "./logger.middleware";
imports: [
ConfigModule.forRoot({
isGlobal: true,
load: [appConfig, databaseConfig, telebirrConfig, rabbitmqConfig, faydaConfig],
load: [
appConfig,
databaseConfig,
telebirrConfig,
rabbitmqConfig,
faydaConfig,
],
}),
ScheduleModule.forRoot(),
EventEmitterModule.forRoot(),
@@ -152,6 +159,7 @@ import { LoggerMiddleware } from "./logger.middleware";
FilesModule,
ConsignmentsModule,
LocomotivesModule,
TruckTypesModule,
WagonTypesModule,
TrainSetsModule,
TrainSchedulesModule,
@@ -223,7 +231,7 @@ import { LoggerMiddleware } from "./logger.middleware";
})
export class AppModule implements OnApplicationBootstrap {
constructor(
private readonly seeder: DataSeeder,
// private readonly seeder: DataSeeder,
private readonly edrOrgSeeder: EdrOrgSeeder,
private readonly freightPositionsSeeder: FreightPositionsSeeder,
private readonly fileUploadSettingsSeeder: FileUploadSettingsSeeder,
@@ -258,7 +266,7 @@ export class AppModule implements OnApplicationBootstrap {
// freightPositionsSeeder → seeds Position + PositionPermission rows
// (depends on edrOrgSeeder, must run after)
await this.freightPermissionKeyMigrationSeeder.run();
await this.seeder.run();
// await this.seeder.run();
await this.edrOrgSeeder.run();
await this.freightPositionsSeeder.run();

View File

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

View File

@@ -0,0 +1,23 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Acquisitions describe WHAT was acquired (vehicle, parts, equipment…) — the
* vehicle link is optional and only for acquisitions that ARE a fleet vehicle.
*/
export class AddAcquisitionItemName2470000000000 implements MigrationInterface {
name = 'AddAcquisitionItemName2470000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.asset_acquisitions
ADD COLUMN IF NOT EXISTS item_name varchar(200)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.asset_acquisitions
DROP COLUMN IF EXISTS item_name
`);
}
}

View File

@@ -0,0 +1,22 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Dedup stamp for the km/date-due maintenance alert — without it the daily
* cron would re-notify every day a schedule stays due.
*/
export class AddMaintenanceDueNotifiedAt2480000000000 implements MigrationInterface {
name = 'AddMaintenanceDueNotifiedAt2480000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.maintenance_schedules
ADD COLUMN IF NOT EXISTS due_notified_at timestamptz NULL
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.maintenance_schedules DROP COLUMN IF EXISTS due_notified_at
`);
}
}

View File

@@ -0,0 +1,40 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* KM-based maintenance scheduling: per-vehicle service intervals (by km
* and/or days) driving the maintenance due engine. Raw schema-qualified SQL —
* the builder API resolved bare table names against the default schema and
* failed on boot ("Table maintenance_intervals does not exist").
*/
export class AddMaintenanceIntervals2800000000000 implements MigrationInterface {
name = 'AddMaintenanceIntervals2800000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.maintenance_intervals (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
vehicle_id uuid NOT NULL REFERENCES freight.vehicles(id) ON DELETE CASCADE,
maintenance_type varchar NOT NULL,
interval_km numeric(14,2),
interval_days integer,
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 INDEX IF NOT EXISTS "IDX_maintenance_intervals_vehicle_type"
ON freight.maintenance_intervals (vehicle_id, maintenance_type);
`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_maintenance_intervals_vehicle_type"
ON freight.maintenance_intervals (vehicle_id, maintenance_type);
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.maintenance_intervals;`);
}
}

View File

@@ -0,0 +1,22 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Persist the signer's saved-signature image on the handover record, so the
* signed handover document can render the actual signature (not just the
* typed name) — parity with the booking-contract signing flow.
*/
export class AddSignatureToHandover2800000000001 implements MigrationInterface {
name = 'AddSignatureToHandover2800000000001';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.booking_handovers ADD COLUMN IF NOT EXISTS signature_image_url text;`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.booking_handovers DROP COLUMN IF EXISTS signature_image_url;`,
);
}
}

View File

@@ -0,0 +1,47 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Named service items for KM-based maintenance ("oil change", "tires", …).
* The coarse maintenance_type enum (PREVENTIVE/…) allowed only one interval
* per type per vehicle, so oil and tire intervals could not coexist. Interval
* identity becomes (vehicle, maintenance_type, service_item); schedules carry
* the item so completion re-finds the right interval for auto-scheduling.
*/
export class AddMaintenanceServiceItem2810000000000 implements MigrationInterface {
name = 'AddMaintenanceServiceItem2810000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.maintenance_intervals ADD COLUMN IF NOT EXISTS service_item varchar(120);`,
);
await queryRunner.query(
`ALTER TABLE freight.maintenance_schedules ADD COLUMN IF NOT EXISTS service_item varchar(120);`,
);
// Re-key interval uniqueness on (vehicle, type, item). COALESCE folds the
// item-less legacy rows into one slot; soft-deleted rows are ignored.
await queryRunner.query(
`DROP INDEX IF EXISTS freight."UQ_maintenance_intervals_vehicle_type";`,
);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_maintenance_intervals_vehicle_type_item"
ON freight.maintenance_intervals (vehicle_id, maintenance_type, COALESCE(service_item, ''))
WHERE deleted_at IS NULL;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DROP INDEX IF EXISTS freight."UQ_maintenance_intervals_vehicle_type_item";`,
);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_maintenance_intervals_vehicle_type"
ON freight.maintenance_intervals (vehicle_id, maintenance_type);
`);
await queryRunner.query(
`ALTER TABLE freight.maintenance_schedules DROP COLUMN IF EXISTS service_item;`,
);
await queryRunner.query(
`ALTER TABLE freight.maintenance_intervals DROP COLUMN IF EXISTS service_item;`,
);
}
}

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
import { ApiProperty } from "@nestjs/swagger";
import { IsBoolean, IsEmail, IsObject, IsOptional, IsString, MinLength } from "class-validator";
import { Type } from "class-transformer";
import { IsBoolean, IsEmail, IsOptional, IsString, MinLength, ValidateNested } from "class-validator";
class CreateOrganizationUserNameDto {
@ApiProperty()
@@ -29,7 +30,8 @@ export class CreateOrganizationUserDto {
phoneNumber?: string;
@ApiProperty({ type: CreateOrganizationUserNameDto })
@IsObject()
@ValidateNested()
@Type(() => CreateOrganizationUserNameDto)
name!: CreateOrganizationUserNameDto;
@ApiProperty({ required: false, default: false })

View File

@@ -252,8 +252,11 @@ export class BookingsController {
return this.bookingsService.findAll(filter, companyId);
}
// Powers the customer-detail bookings tab, so `customers:view` reaches it too
// — otherwise a staffer granted only the customer permission gets a page whose
// tabs 403 individually.
@Get("by-company/:companyId/customer-view")
@BookingView()
@BookingStaff([FREIGHT_PERMS.customers.view, FREIGHT_PERMS.bookings.view])
@ApiOperation({
summary: "List bookings for a company (customer-view shape, backoffice)",
})
@@ -436,18 +439,26 @@ export class BookingsController {
}
@Get(':id/customer-truck-assignment/freight-order')
@ApiOperation({ summary: 'Download duplicate freight order copies for customer truck assignment' })
@ApiOperation({
summary:
'Download freight order copies. The 2 gate copies always print; ?copies=1,2,8 adds waybill-style copies (catalog indexes 1-8).',
})
async customerTruckFreightOrder(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() user: TCurrentUser,
@Res() res: Response,
@Query('copies') copies?: string,
) {
const booking = await this.bookingsService.findById(id);
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking);
}
const extraCopyIndexes = (copies ?? '')
.split(',')
.map((n) => Number(n.trim()))
.filter((n) => Number.isInteger(n) && n >= 1 && n <= 8);
const { filename, buffer } =
await this.bookingsService.customerTruckFreightOrderCopies(id);
await this.bookingsService.customerTruckFreightOrderCopies(id, extraCopyIndexes);
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
res.send(buffer);
@@ -480,6 +491,20 @@ export class BookingsController {
return this.customerTruckService.addTruck(id, dto);
}
@Post(':id/customer-trucks/bulk')
@ApiOperation({ summary: 'Bulk add customer trucks from array payload (Excel parsed)' })
async bulkAddCustomerTrucks(
@Param('id', ParseUUIDPipe) id: string,
@Body() payload: { trucks: AddCustomerTruckDto[] },
@CurrentUser() user: TCurrentUser,
) {
const booking = await this.bookingsService.findById(id);
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking);
}
return this.customerTruckService.addBulkTrucks(id, payload.trucks);
}
@Patch(':id/customer-trucks/:assignmentId')
@ApiOperation({ summary: 'Edit a not-yet-arrived customer truck (plate/driver/type + containers)' })
async updateCustomerTruck(

View File

@@ -142,8 +142,21 @@ export class BookingsService {
return this.findById(bookingId);
}
/** Selectable freight-order copies (rail-waybill style). Indexes 1-8. */
static readonly FREIGHT_ORDER_EXTRA_COPIES = [
'Original 1 (for Issuing Carrier)',
'Original 2 (for Consignee)',
'Original 3 (for Shipper)',
'Copy 4 (Delivery Receipt)',
'Copy 5 (Extra Copy)',
'Copy 6 (Extra Copy)',
'Copy 7 (Extra Copy)',
'Copy 8 (for Agent)',
] as const;
async customerTruckFreightOrderCopies(
bookingId: string,
extraCopyIndexes: number[] = [],
): Promise<{ filename: string; buffer: Buffer }> {
const booking = await this.findById(bookingId);
if (!booking.customerTruckAssignedAt) {
@@ -171,7 +184,12 @@ export class BookingsService {
[bookingId],
);
const html = this.buildCustomerTruckFreightOrderHtml(booking, trucks);
// The 2 gate copies are ALWAYS printed; the waybill-style copies are
// whatever the customer ticked (indexes into the fixed catalog).
const extraCopies = [...new Set(extraCopyIndexes)]
.map((i) => BookingsService.FREIGHT_ORDER_EXTRA_COPIES[i - 1])
.filter(Boolean);
const html = this.buildCustomerTruckFreightOrderHtml(booking, trucks, extraCopies);
// Chromium when available; otherwise the styled tabular fallback (never the
// generic text dump — the freight order is an outward-facing gate document).
const buffer = await this.pdfRender.htmlToPdfBuffer(html, {
@@ -268,6 +286,7 @@ export class BookingsService {
arrivedAt: string | null;
containers: string | null;
}>,
extraCopies: string[] = [],
): string {
const esc = (v: unknown) => this.escapeHtml(String(v ?? '-'));
const assignedAt = booking.customerTruckAssignedAt
@@ -386,6 +405,7 @@ export class BookingsService {
<body>
${copy('Copy 1: Port Operations Copy')}
${copy('Copy 2: Gate Security & Carrier Copy')}
${extraCopies.map((label) => copy(label)).join('')}
</body>
</html>`;
}

View File

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

View File

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

View File

@@ -0,0 +1,48 @@
import { IsString, IsNotEmpty, IsIn, IsArray, ArrayMaxSize, ArrayUnique, Matches, IsOptional } from 'class-validator';
import { CUSTOMER_TRUCK_TYPES } from './customer-truck-assignment.dto';
export class BulkCustomerTruckRow {
@IsString()
@IsNotEmpty()
truckPlateNumber!: string;
@IsString()
@IsNotEmpty()
driverName!: string;
@IsString()
@IsNotEmpty()
@IsIn(CUSTOMER_TRUCK_TYPES)
truckType!: string;
@IsOptional()
@IsArray()
@ArrayMaxSize(2)
@ArrayUnique()
@Matches(/^[A-Z]{4}\d{7}$/, {
each: true,
message: 'each container must be ISO format (e.g. ABCD1234567)',
})
containerNumbers?: (string | null)[];
}
export class BulkCustomerTrucksDto {
@IsArray()
@ArrayMaxSize(100)
trucks!: BulkCustomerTruckRow[];
}
export interface BulkTruckUploadResult {
success: number;
failed: number;
errors: Array<{
row: number;
truck: string;
reason: string;
}>;
created: Array<{
truckPlateNumber: string;
driverName: string;
containers: number;
}>;
}

View File

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

View File

@@ -11,13 +11,22 @@ import {
HttpCode,
HttpStatus,
UseInterceptors,
UseGuards,
UploadedFiles,
BadRequestException,
NotFoundException,
} from "@nestjs/common";
import { AnyFilesInterceptor } from "@nestjs/platform-express";
import { ApiOperation, ApiTags, ApiConsumes } from "@nestjs/swagger";
import { CurrentUser } from "@edr/api-common";
import { FreightAdmin } from "../../common/booking-guards";
import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type";
import { JwtGuard } from "@tria-plc/api-common/modules/auth/services/jwt.guard";
import { BookingStaff } from "../../common/booking-guards";
import {
assertFreightPermission,
hasFreightPermission,
} from "../../common/freight-permission.util";
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
import { FilesService } from "../files/files.service";
import { CompaniesService } from "./companies.service";
import { CreateCompanyDto } from "./dto/create-company.dto";
@@ -59,6 +68,23 @@ interface CurrentIamUser {
phoneNumber?: string;
}
/**
* Which permission a status write needs. Approving/reactivating is a different
* authority from suspending, but both arrive on the same route with the target
* in the BODY — a route-level guard can't tell them apart, so the handlers
* assert against this map instead.
*
* Keyed by string so it serves both `CompanyStatus` and `ProfileStatus`
* (a superset: it adds `rejected`).
*/
const STATUS_PERM: Record<string, string> = {
active: FREIGHT_PERMS.customers.verify,
pending: FREIGHT_PERMS.customers.verify,
rejected: FREIGHT_PERMS.customers.verify,
suspended: FREIGHT_PERMS.customers.deactivate,
blacklisted: FREIGHT_PERMS.customers.deactivate,
};
@ApiTags("Companies")
@Controller("companies")
export class CompaniesController {
@@ -410,7 +436,7 @@ export class CompaniesController {
// Used by backoffice
@Post()
@FreightAdmin()
@BookingStaff(FREIGHT_PERMS.customers.create)
@ApiOperation({
summary:
"Create a new company (customer, freight_forwarder, dj_freight_forwarder, transporter)",
@@ -421,12 +447,14 @@ export class CompaniesController {
}
@Get("stats")
@BookingStaff(FREIGHT_PERMS.customers.view)
@ApiOperation({ summary: "Company counts by status (KPI strip)" })
async getStats(): Promise<CompanyStatsResponseDto> {
return this.companiesService.getCompanyStats();
}
@Get()
@BookingStaff(FREIGHT_PERMS.customers.view)
@ApiOperation({ summary: "List companies (paginated, filterable)" })
async findAll(
@Query() query: ListCompaniesQueryDto,
@@ -436,6 +464,7 @@ export class CompaniesController {
}
@Get(":id")
@BookingStaff(FREIGHT_PERMS.customers.view)
@ApiOperation({ summary: "Get company by ID" })
async findById(
@Param("id", ParseUUIDPipe) id: string,
@@ -446,30 +475,77 @@ export class CompaniesController {
return dto;
}
/**
* Edits fields AND carries `status`, so it spans two authorities. The route
* guard is one-of (a status-only caller must get in); the asserts below are
* what actually authorize: touching `status` needs the permission
* {@link STATUS_PERM} maps it to, touching anything else needs
* `customers:update`. Both checks are required — without the second, a
* caller holding only `customers:deactivate` could rename the company.
*/
@Patch(":id")
@FreightAdmin()
@BookingStaff([
FREIGHT_PERMS.customers.update,
FREIGHT_PERMS.customers.verify,
FREIGHT_PERMS.customers.deactivate,
])
@ApiOperation({ summary: "Update a company" })
async update(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: UpdateCompanyDto,
@CurrentUser() user: TCurrentUser,
): Promise<ResponseCompanyDto> {
const { status, ...fields } = dto;
if (status) assertFreightPermission(user, STATUS_PERM[status]);
if (Object.keys(fields).length > 0) {
assertFreightPermission(user, FREIGHT_PERMS.customers.update);
}
const company = await this.companiesService.updateCompany(id, dto);
return new ResponseCompanyDto(company);
}
@Delete(":id")
@FreightAdmin()
@BookingStaff(FREIGHT_PERMS.customers.deactivate)
@ApiOperation({ summary: "Soft-delete a company" })
@HttpCode(HttpStatus.NO_CONTENT)
async remove(@Param("id", ParseUUIDPipe) id: string): Promise<void> {
await this.companiesService.deleteCompany(id);
}
/**
* Dual-audience: staff read any customer's documents, and the portal reads
* its OWN during onboarding (`companiesService.getDocuments`). So the route
* is authenticated-only and the split happens here — same shape as
* `GET /contracts/:id`. Gating it on a staff permission alone would 403 every
* customer on their own documents.
*
* The staff arm is one-of because two pages consume it: the customer detail
* page (`customers:view`) and the contract-request detail page, whose route
* is gated on `contracts:view` — a contract reviewer without the customer
* permission still needs the applicant's documents.
*/
@Get(":companyId/documents")
@UseGuards(JwtGuard)
@ApiOperation({ summary: "List documents uploaded for a company" })
async listDocuments(
@Param("companyId", ParseUUIDPipe) companyId: string,
@CurrentUser() user: TCurrentUser,
) {
const isStaff = [
FREIGHT_PERMS.customers.view,
FREIGHT_PERMS.contracts.view,
FREIGHT_PERMS.bookings.view,
].some((p) => hasFreightPermission(user, p));
if (!isStaff) {
const { company } = await this.companiesService.getCompanyInfoByUserId(
user.id,
);
// Hidden as NotFound rather than Forbidden so company ids can't be probed.
if (company.id !== companyId) {
throw new NotFoundException(`Company ${companyId} not found`);
}
}
const files = await this.filesService.findByResource(companyId, "companies");
return Promise.all(
files.map(async (f) => ({
@@ -490,7 +566,7 @@ export class CompaniesController {
}
@Post("documents/:fileId/request-change")
@FreightAdmin()
@BookingStaff(FREIGHT_PERMS.customers.verify)
@ApiOperation({
summary: "Ask the customer to correct one uploaded document",
description:
@@ -532,14 +608,23 @@ export class CompaniesController {
return this.companiesService.uploadCompanyDocuments(companyId, files, user.id);
}
/**
* Approve / reject / suspend / blacklist all arrive here with the target in
* the body, so authorization is per-status via {@link STATUS_PERM} rather
* than on the route (the guard is only the one-of gate).
*/
@Patch("company-profiles/:profileId/status")
@FreightAdmin()
@BookingStaff([
FREIGHT_PERMS.customers.verify,
FREIGHT_PERMS.customers.deactivate,
])
@ApiOperation({ summary: "Update a company profile's approval status" })
async updateCompanyProfileStatus(
@CurrentUser() user: CurrentIamUser,
@CurrentUser() user: TCurrentUser,
@Param("profileId", ParseUUIDPipe) profileId: string,
@Body() dto: UpdateCompanyProfileStatusDto,
): Promise<ResponseCompanyProfileDto> {
assertFreightPermission(user, STATUS_PERM[dto.status]);
const profile = await this.companiesService.setCompanyProfileStatus(
profileId,
dto.status,
@@ -550,7 +635,7 @@ export class CompaniesController {
}
@Get(":companyId/change-requests")
@FreightAdmin()
@BookingStaff(FREIGHT_PERMS.customers.view)
@ApiOperation({ summary: "List a company's profile change requests" })
async listChangeRequests(
@Param("companyId", ParseUUIDPipe) companyId: string,
@@ -560,7 +645,7 @@ export class CompaniesController {
}
@Post("change-requests/:id/approve")
@FreightAdmin()
@BookingStaff(FREIGHT_PERMS.customers.verify)
@ApiOperation({
summary: "Approve a pending profile change request (applies the changes)",
})
@@ -576,7 +661,7 @@ export class CompaniesController {
}
@Post("change-requests/:id/reject")
@FreightAdmin()
@BookingStaff(FREIGHT_PERMS.customers.verify)
@ApiOperation({
summary: "Reject a pending profile change request with a note",
})
@@ -594,7 +679,7 @@ export class CompaniesController {
}
@Post(":companyId/profiles")
@FreightAdmin()
@BookingStaff(FREIGHT_PERMS.customers.update)
@ApiOperation({ summary: "Add a profile (employee) to a company" })
async createProfile(
@Param("companyId", ParseUUIDPipe) companyId: string,
@@ -608,6 +693,7 @@ export class CompaniesController {
}
@Get(":companyId/profiles")
@BookingStaff(FREIGHT_PERMS.customers.view)
@ApiOperation({ summary: "List profiles for a company" })
async listProfiles(
@Param("companyId", ParseUUIDPipe) companyId: string,
@@ -618,6 +704,7 @@ export class CompaniesController {
}
@Get("profile/user/:userId")
@BookingStaff(FREIGHT_PERMS.customers.view)
@ApiOperation({ summary: "Get profile by IAM user ID" })
async findProfileByUser(
@Param("userId", ParseUUIDPipe) userId: string,

View File

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

View File

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

View File

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

View File

@@ -54,11 +54,4 @@ export class InterchangeDocumentsController {
dispute(@Param('id', ParseUUIDPipe) id: string, @Body() dto: DisputeInterchangeDocumentDto) {
return this.service.dispute(id, dto);
}
@Patch(':id/cancel')
@BookingStaff(FREIGHT_PERMS.interchangeDocuments.cancel)
@ApiOperation({ summary: 'Cancel a draft/generated interchange document' })
cancel(@Param('id', ParseUUIDPipe) id: string) {
return this.service.cancel(id);
}
}

View File

@@ -203,11 +203,12 @@ export class InterchangeDocumentsService {
async dispute(id: string, dto: DisputeInterchangeDocumentDto): Promise<InterchangeDocument> {
const document = await this.findOne(id);
// A dispute can only be raised on a live handover — a GENERATED or already
// ACKNOWLEDGED document. CANCELLED and already-DISPUTED are terminal here.
if (!['GENERATED', 'ACKNOWLEDGED'].includes(document.status)) {
// A dispute can only be raised BEFORE the handover is acknowledged — an
// acknowledged document is settled. DISPUTED itself is terminal and
// read-only: the registered dispute cannot be re-raised or overwritten.
if (document.status !== 'GENERATED') {
throw new BadRequestException(
`Interchange document in ${document.status} status cannot be disputed (must be GENERATED or ACKNOWLEDGED)`,
`Interchange document in ${document.status} status cannot be disputed (must be GENERATED — an acknowledged handover is settled, a registered dispute is read-only)`,
);
}
await this.dataSource.getRepository(InterchangeDocument).update(id, {
@@ -217,15 +218,6 @@ export class InterchangeDocumentsService {
return this.findOne(id);
}
async cancel(id: string): Promise<InterchangeDocument> {
const document = await this.findOne(id);
if (!['DRAFT', 'GENERATED'].includes(document.status)) {
throw new BadRequestException(`Interchange document ${document.status} cannot be cancelled`);
}
await this.dataSource.getRepository(InterchangeDocument).update(id, { status: 'CANCELLED' });
return this.findOne(id);
}
private async getScheduleSnapshot(scheduleId: string): Promise<ScheduleSnapshot> {
const [schedule] = await this.dataSource.query(
`SELECT ts.id,

View File

@@ -8,6 +8,11 @@ export class CreateMaintenanceScheduleDto {
@IsEnum(MaintenanceType)
maintenanceType!: MaintenanceType;
/** What is serviced — matched against the interval for auto-scheduling. */
@IsOptional()
@IsString()
serviceItem?: string;
@IsString()
description!: string;
@@ -81,7 +86,37 @@ export class UpdateMaintenanceScheduleDto {
@IsNumber()
actualCost?: number;
/** Odometer at completion — drives KM-based auto-scheduling of the next service. */
@IsOptional()
@IsNumber()
odometerReading?: number;
@IsOptional()
@IsString()
notes?: string;
}
export class UpsertMaintenanceIntervalDto {
@IsUUID()
vehicleId!: string;
@IsEnum(MaintenanceType)
maintenanceType!: MaintenanceType;
/** What is serviced — "oil change", "tires", … Distinguishes intervals of the same type. */
@IsOptional()
@IsString()
serviceItem?: string;
@IsOptional()
@IsNumber()
intervalKm?: number;
@IsOptional()
@IsNumber()
intervalDays?: number;
@IsOptional()
@IsString()
description?: string;
}

View File

@@ -0,0 +1,46 @@
import { BaseEntity } from '@edr/api-common';
import { Entity, Column, ManyToOne, JoinColumn, Index } from 'typeorm';
import { Vehicle } from '../../vehicles/entities/vehicle.entity';
import { MaintenanceType } from './maintenance-schedule.entity';
/**
* Maintenance interval configuration. Defines how often a vehicle needs a
* given service. Identity is (vehicle, maintenanceType, serviceItem) — a
* vehicle carries several intervals of the same coarse type with different
* items (oil every 10k km, tires every 50k km, both PREVENTIVE). Uniqueness
* is enforced by a COALESCE expression index in the migration (nullable
* service_item), not a TypeORM @Unique.
*/
@Entity({ name: 'maintenance_intervals', schema: 'freight' })
@Index(['vehicleId', 'maintenanceType'])
export class MaintenanceInterval extends BaseEntity {
@Column({ name: 'vehicle_id', type: 'uuid' })
vehicleId!: string;
@ManyToOne(() => Vehicle, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'vehicle_id' })
vehicle!: Vehicle;
@Column({ name: 'maintenance_type', type: 'varchar' })
maintenanceType!: MaintenanceType;
/** What is serviced — "oil change", "tires", … Null = generic for the type. */
@Column({ name: 'service_item', type: 'varchar', length: 120, nullable: true })
serviceItem?: string | null;
/** Maintenance interval in kilometers. E.g., 10000 for oil changes every 10k km. */
@Column({ name: 'interval_km', type: 'numeric', precision: 14, scale: 2, nullable: true })
intervalKm?: number | null;
/** Maintenance interval in days. E.g., 365 for annual inspection. */
@Column({ name: 'interval_days', type: 'integer', nullable: true })
intervalDays?: number | null;
/** Human-readable description. E.g., "Oil and filter change". */
@Column({ name: 'description', type: 'text', nullable: true })
description?: string | null;
/** Is this interval active? Can be disabled without deleting historical data. */
@Column({ name: 'is_active', type: 'boolean', default: true })
isActive!: boolean;
}

View File

@@ -30,6 +30,10 @@ export class MaintenanceSchedule extends BaseEntity {
@Column({ name: 'maintenance_type', type: 'varchar' })
maintenanceType!: MaintenanceType;
/** What is serviced — matches the interval's service_item for auto-scheduling. */
@Column({ name: 'service_item', type: 'varchar', length: 120, nullable: true })
serviceItem?: string | null;
@Column({ name: 'description' })
description!: string;
@@ -62,4 +66,8 @@ export class MaintenanceSchedule extends BaseEntity {
@Column({ name: 'next_due_date', type: 'timestamptz', nullable: true })
nextDueDate?: Date;
/** Stamped once the km/date-due alert has fired, so the daily check doesn't repeat it. */
@Column({ name: 'due_notified_at', type: 'timestamptz', nullable: true })
dueNotifiedAt?: Date;
}

View File

@@ -0,0 +1,99 @@
import { MaintenanceService } from './maintenance.service';
import { MaintenanceStatus } from './entities/maintenance-schedule.entity';
/**
* KM-based auto-scheduling: completing a maintenance with an odometer reading
* creates the next SCHEDULED item at completedKm + intervalKm, matched on the
* schedule's (type, serviceItem) interval. Re-completing must not duplicate.
*/
function makeService(opts: {
before: Record<string, unknown> | null;
after: Record<string, unknown> | null;
interval: Record<string, unknown> | null;
}) {
const saved: Array<Record<string, unknown>> = [];
const service = Object.create(MaintenanceService.prototype) as Record<string, unknown>;
service.scheduleRepository = {
findOneBy: jest
.fn()
.mockResolvedValueOnce(opts.before)
.mockResolvedValueOnce(opts.after),
update: jest.fn(),
create: jest.fn((v: Record<string, unknown>) => v),
save: jest.fn(async (v: Record<string, unknown>) => {
saved.push(v);
return v;
}),
};
service.intervalRepository = {
getByVehicleAndType: jest.fn().mockResolvedValue(opts.interval),
};
service.dataSource = {
getRepository: jest.fn().mockReturnValue({ update: jest.fn() }),
};
service.logger = { error: jest.fn() };
return { service: service as unknown as MaintenanceService, saved };
}
const base = {
id: 's-1',
vehicleId: 'v-1',
maintenanceType: 'PREVENTIVE',
serviceItem: 'oil change',
description: 'Oil and filter',
};
describe('MaintenanceService auto-next scheduling', () => {
it('completing at 50,000 km with a 10,000 km interval schedules the next at 60,000', async () => {
const { service, saved } = makeService({
before: { ...base, status: MaintenanceStatus.SCHEDULED },
after: { ...base, status: MaintenanceStatus.COMPLETED, odometerReading: 50000 },
interval: { serviceItem: 'oil change', intervalKm: '10000.00', intervalDays: null, description: 'Oil and filter' },
});
await service.updateMaintenanceSchedule('s-1', {
status: MaintenanceStatus.COMPLETED,
odometerReading: 50000,
});
expect(saved).toHaveLength(1);
expect(saved[0]).toMatchObject({
vehicleId: 'v-1',
serviceItem: 'oil change',
nextDueKm: 60000,
status: MaintenanceStatus.SCHEDULED,
});
});
it('re-completing an already COMPLETED schedule does not duplicate the next one', async () => {
const { service, saved } = makeService({
before: { ...base, status: MaintenanceStatus.COMPLETED, odometerReading: 50000 },
after: { ...base, status: MaintenanceStatus.COMPLETED, odometerReading: 50000 },
interval: { serviceItem: 'oil change', intervalKm: '10000.00', intervalDays: null },
});
await service.updateMaintenanceSchedule('s-1', {
status: MaintenanceStatus.COMPLETED,
odometerReading: 50000,
});
expect(saved).toHaveLength(0);
});
it('a km + days interval produces ONE next schedule carrying both thresholds', async () => {
const { service, saved } = makeService({
before: { ...base, status: MaintenanceStatus.IN_PROGRESS },
after: { ...base, status: MaintenanceStatus.COMPLETED, odometerReading: 20000 },
interval: { serviceItem: 'oil change', intervalKm: '10000.00', intervalDays: 180 },
});
await service.updateMaintenanceSchedule('s-1', {
status: MaintenanceStatus.COMPLETED,
odometerReading: 20000,
});
expect(saved).toHaveLength(1);
expect(saved[0].nextDueKm).toBe(30000);
expect(saved[0].nextDueDate).toBeInstanceOf(Date);
});
});

View File

@@ -0,0 +1,76 @@
import { NotificationAudience } from '@edr/types';
import { MaintenanceService } from './maintenance.service';
/**
* The daily due-alert: a SCHEDULED item that crossed its km or date threshold
* gets one BACKOFFICE notification, then is stamped so it isn't repeated.
*/
function makeService(due: Array<Record<string, unknown>>) {
const update = jest.fn();
const notify = jest.fn();
const service = Object.create(MaintenanceService.prototype) as Record<string, unknown>;
service.maintenanceRepository = { getUnnotifiedDue: jest.fn().mockResolvedValue(due) };
service.scheduleRepository = { update };
service.inbox = { notify };
service.logger = { error: jest.fn() };
return { service: service as unknown as MaintenanceService, update, notify };
}
describe('MaintenanceService.sendDueAlerts', () => {
it('reports the km reason when the km threshold was crossed', async () => {
const { service, notify, update } = makeService([
{
id: 'sched-1',
vehicleId: 'v-1',
plateNumber: 'ET-9875',
maintenanceType: 'PREVENTIVE',
description: 'Oil change',
nextDueKm: 50000,
nextDueDate: null,
currentKm: 50200,
},
]);
await service.sendDueAlerts();
expect(notify).toHaveBeenCalledWith(
expect.objectContaining({
audience: NotificationAudience.BACKOFFICE,
title: 'Maintenance due — ET-9875',
body: expect.stringContaining('driven 50200 km (due at 50000 km)'),
}),
);
expect(update).toHaveBeenCalledWith('sched-1', { dueNotifiedAt: expect.any(Date) });
});
it('reports the date reason when only the due date has passed', async () => {
const { service, notify } = makeService([
{
id: 'sched-2',
vehicleId: 'v-2',
plateNumber: 'AA-8642',
maintenanceType: 'INSPECTION',
description: 'Annual inspection',
nextDueKm: null,
nextDueDate: new Date('2026-01-01'),
currentKm: 1000,
},
]);
await service.sendDueAlerts();
expect(notify).toHaveBeenCalledWith(
expect.objectContaining({ body: expect.stringContaining('due 1/1/2026') }),
);
});
it('does nothing when nothing is due', async () => {
const { service, notify, update } = makeService([]);
await service.sendDueAlerts();
expect(notify).not.toHaveBeenCalled();
expect(update).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,83 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { BaseRepository } from '@edr/api-common';
import { IsNull, Repository } from 'typeorm';
import { MaintenanceInterval } from './entities/maintenance-interval.entity';
import { MaintenanceType } from './entities/maintenance-schedule.entity';
@Injectable()
export class MaintenanceIntervalRepository extends BaseRepository<MaintenanceInterval> {
constructor(
@InjectRepository(MaintenanceInterval)
private readonly intervalRepository: Repository<MaintenanceInterval>,
) {
super(intervalRepository);
}
/**
* Resolve the interval for a completed service. Prefers the exact
* (type, serviceItem) match; a completion without an item falls back to the
* type's item-less interval only, so "oil" completions never consume the
* "tires" interval.
*/
async getByVehicleAndType(
vehicleId: string,
maintenanceType: MaintenanceType,
serviceItem?: string | null,
): Promise<MaintenanceInterval | null> {
return this.intervalRepository.findOne({
where: {
vehicleId,
maintenanceType,
isActive: true,
serviceItem: serviceItem?.trim() ? serviceItem.trim() : IsNull(),
},
});
}
async getActiveIntervals(vehicleId: string): Promise<MaintenanceInterval[]> {
return this.intervalRepository.find({
where: { vehicleId, isActive: true },
order: { maintenanceType: 'ASC', serviceItem: 'ASC' },
});
}
async upsertInterval(
vehicleId: string,
maintenanceType: MaintenanceType,
serviceItem?: string | null,
intervalKm?: number | null,
intervalDays?: number | null,
description?: string | null,
): Promise<MaintenanceInterval> {
const item = serviceItem?.trim() || null;
const existing = await this.getByVehicleAndType(vehicleId, maintenanceType, item);
if (existing) {
await this.intervalRepository.update(existing.id, {
intervalKm: intervalKm ?? existing.intervalKm,
intervalDays: intervalDays ?? existing.intervalDays,
description: description ?? existing.description,
});
const updated = await this.intervalRepository.findOneBy({ id: existing.id });
return updated!;
}
return this.intervalRepository.save(
this.intervalRepository.create({
vehicleId,
maintenanceType,
serviceItem: item,
intervalKm,
intervalDays,
description,
isActive: true,
}),
);
}
/** Soft-disable: history keeps pointing at it, auto-scheduling stops. */
async deactivate(id: string): Promise<void> {
await this.intervalRepository.update(id, { isActive: false });
}
}

View File

@@ -4,7 +4,12 @@ import { BookingStaff } from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { MaintenanceService } from './maintenance.service';
import { MaintenanceDepthService } from './maintenance-depth.service';
import { CreateMaintenanceScheduleDto, CreateMaintenanceCostDto, UpdateMaintenanceScheduleDto } from './dto/create-maintenance.dto';
import {
CreateMaintenanceScheduleDto,
CreateMaintenanceCostDto,
UpdateMaintenanceScheduleDto,
UpsertMaintenanceIntervalDto,
} from './dto/create-maintenance.dto';
import {
CreateWorkOrderDto,
UpdateWorkOrderDto,
@@ -44,6 +49,34 @@ export class MaintenanceController {
return this.maintenanceService.updateMaintenanceSchedule(id, dto);
}
@Get('due-board')
@BookingStaff([FREIGHT_PERMS.maintenance.view, FREIGHT_PERMS.fleetDashboard.view])
@ApiOperation({ summary: 'Fleet-wide next-due maintenance board (by date and km)' })
async getDueBoard() {
return this.maintenanceService.getDueBoard();
}
@Post('intervals')
@BookingStaff(FREIGHT_PERMS.maintenance.create)
@ApiOperation({ summary: 'Define/adjust a service interval (e.g. oil change every 10,000 km)' })
async upsertInterval(@Body() dto: UpsertMaintenanceIntervalDto) {
return this.maintenanceService.upsertInterval(dto);
}
@Get('intervals/:vehicleId')
@BookingStaff(FREIGHT_PERMS.maintenance.view)
@ApiOperation({ summary: "A vehicle's active service intervals" })
async getIntervals(@Param('vehicleId') vehicleId: string) {
return this.maintenanceService.getIntervals(vehicleId);
}
@Delete('intervals/:id')
@BookingStaff(FREIGHT_PERMS.maintenance.delete)
@ApiOperation({ summary: 'Deactivate a service interval (stops auto-scheduling)' })
async deactivateInterval(@Param('id') id: string) {
return this.maintenanceService.deactivateInterval(id);
}
@Get('upcoming/:vehicleId')
@BookingStaff(FREIGHT_PERMS.maintenance.view)
@ApiOperation({ summary: 'Get upcoming maintenance' })

View File

@@ -2,25 +2,37 @@ import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { MaintenanceSchedule } from './entities/maintenance-schedule.entity';
import { MaintenanceCost } from './entities/maintenance-cost.entity';
import { MaintenanceInterval } from './entities/maintenance-interval.entity';
import { WorkOrder } from './entities/work-order.entity';
import { Part } from './entities/part.entity';
import { Warranty } from './entities/warranty.entity';
import { MaintenanceService } from './maintenance.service';
import { MaintenanceDepthService } from './maintenance-depth.service';
import { MaintenanceRepository } from './maintenance.repository';
import { MaintenanceIntervalRepository } from './maintenance-interval.repository';
import { WorkOrderRepository } from './work-order.repository';
import { PartRepository } from './part.repository';
import { WarrantyRepository } from './warranty.repository';
import { MaintenanceController } from './maintenance.controller';
import { NotificationInboxModule } from '../notification-inbox/notification-inbox.module';
@Module({
imports: [
TypeOrmModule.forFeature([MaintenanceSchedule, MaintenanceCost, WorkOrder, Part, Warranty]),
TypeOrmModule.forFeature([
MaintenanceSchedule,
MaintenanceCost,
MaintenanceInterval,
WorkOrder,
Part,
Warranty,
]),
NotificationInboxModule,
],
providers: [
MaintenanceService,
MaintenanceDepthService,
MaintenanceRepository,
MaintenanceIntervalRepository,
WorkOrderRepository,
PartRepository,
WarrantyRepository,

View File

@@ -47,4 +47,109 @@ export class MaintenanceRepository extends BaseRepository<MaintenanceSchedule> {
.getRawOne();
return result?.total || 0;
}
/**
* Fleet-wide "next due" board: one row per vehicle with a SCHEDULED
* maintenance item, driven by time AND km — whichever is soonest. Current km
* is the vehicle's latest fuel-up odometer reading (how mileage is actually
* captured today), falling back to vehicle.actual_distance_km when the
* vehicle has no fuel purchase on file yet.
*/
async getDueBoard(): Promise<
Array<{
scheduleId: string;
vehicleId: string;
plateNumber: string;
maintenanceType: string;
serviceItem: string | null;
description: string;
scheduledDate: Date;
nextDueDate: Date | null;
nextDueKm: number | null;
currentKm: number | null;
kmRemaining: number | null;
daysRemaining: number | null;
overdue: boolean;
}>
> {
// Every SCHEDULED item, not one per vehicle — a truck legitimately holds
// several (oil vs tires intervals differ).
return this.scheduleRepository.manager.query(`
SELECT
s.id AS "scheduleId",
s.vehicle_id AS "vehicleId",
v.plate_number AS "plateNumber",
s.maintenance_type AS "maintenanceType",
s.service_item AS "serviceItem",
s.description,
s.scheduled_date AS "scheduledDate",
s.next_due_date AS "nextDueDate",
s.next_due_km AS "nextDueKm",
COALESCE(fp.max_odometer, v.actual_distance_km) AS "currentKm",
CASE WHEN s.next_due_km IS NOT NULL
THEN s.next_due_km - COALESCE(fp.max_odometer, v.actual_distance_km, 0)
ELSE NULL END AS "kmRemaining",
CASE WHEN s.next_due_date IS NOT NULL
THEN EXTRACT(DAY FROM s.next_due_date - now())
ELSE NULL END AS "daysRemaining",
(
(s.next_due_date IS NOT NULL AND s.next_due_date <= now())
OR (s.next_due_km IS NOT NULL
AND COALESCE(fp.max_odometer, v.actual_distance_km, 0) >= s.next_due_km)
) AS overdue
FROM freight.maintenance_schedules s
JOIN freight.vehicles v ON v.id = s.vehicle_id AND v.deleted_at IS NULL
LEFT JOIN LATERAL (
SELECT MAX(odometer_reading) AS max_odometer
FROM freight.fuel_purchases fp2
WHERE fp2.vehicle_id = s.vehicle_id
) fp ON true
WHERE s.status = 'SCHEDULED' AND s.deleted_at IS NULL
ORDER BY s.vehicle_id, s.scheduled_date ASC
`);
}
/**
* SCHEDULED items that have crossed their km or date due-point and have not
* yet been notified. Backs the daily km/date maintenance alert.
*/
async getUnnotifiedDue(): Promise<
Array<{
id: string;
vehicleId: string;
plateNumber: string;
maintenanceType: string;
description: string;
nextDueKm: number | null;
nextDueDate: Date | null;
currentKm: number | null;
}>
> {
return this.scheduleRepository.manager.query(`
SELECT
s.id,
s.vehicle_id AS "vehicleId",
v.plate_number AS "plateNumber",
s.maintenance_type AS "maintenanceType",
s.description,
s.next_due_km AS "nextDueKm",
s.next_due_date AS "nextDueDate",
COALESCE(fp.max_odometer, v.actual_distance_km) AS "currentKm"
FROM freight.maintenance_schedules s
JOIN freight.vehicles v ON v.id = s.vehicle_id AND v.deleted_at IS NULL
LEFT JOIN LATERAL (
SELECT MAX(odometer_reading) AS max_odometer
FROM freight.fuel_purchases fp2
WHERE fp2.vehicle_id = s.vehicle_id
) fp ON true
WHERE s.status = 'SCHEDULED'
AND s.deleted_at IS NULL
AND s.due_notified_at IS NULL
AND (
(s.next_due_date IS NOT NULL AND s.next_due_date <= now())
OR (s.next_due_km IS NOT NULL
AND COALESCE(fp.max_odometer, v.actual_distance_km, 0) >= s.next_due_km)
)
`);
}
}

View File

@@ -1,16 +1,28 @@
import { Injectable } from '@nestjs/common';
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Cron, CronExpression } from '@nestjs/schedule';
import { NotificationAudience, NotificationType } from '@edr/types';
import { DataSource, Repository } from 'typeorm';
import { MaintenanceRepository } from './maintenance.repository';
import { MaintenanceSchedule, MaintenanceStatus } from './entities/maintenance-schedule.entity';
import { MaintenanceIntervalRepository } from './maintenance-interval.repository';
import { MaintenanceSchedule, MaintenanceStatus, MaintenanceType } from './entities/maintenance-schedule.entity';
import { MaintenanceCost } from './entities/maintenance-cost.entity';
import { Vehicle, VehicleAvailability, VehicleStatus } from '../vehicles/entities/vehicle.entity';
import { CreateMaintenanceScheduleDto, CreateMaintenanceCostDto, UpdateMaintenanceScheduleDto } from './dto/create-maintenance.dto';
import {
CreateMaintenanceScheduleDto,
CreateMaintenanceCostDto,
UpdateMaintenanceScheduleDto,
UpsertMaintenanceIntervalDto,
} from './dto/create-maintenance.dto';
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
@Injectable()
export class MaintenanceService {
private readonly logger = new Logger(MaintenanceService.name);
constructor(
private readonly maintenanceRepository: MaintenanceRepository,
private readonly intervalRepository: MaintenanceIntervalRepository,
@InjectRepository(MaintenanceSchedule)
private readonly scheduleRepository: Repository<MaintenanceSchedule>,
@InjectRepository(MaintenanceCost)
@@ -18,8 +30,44 @@ export class MaintenanceService {
// Vehicle isn't registered in this module's TypeOrmModule.forFeature, so we
// reach it through the global DataSource rather than @InjectRepository.
private readonly dataSource: DataSource,
private readonly inbox: NotificationInboxService,
) {}
/** Fleet-wide next-due board — see MaintenanceRepository.getDueBoard. */
async getDueBoard() {
return this.maintenanceRepository.getDueBoard();
}
/**
* Daily check: a vehicle's driven km (latest fuel-up odometer reading, since
* that's the only place mileage is actually recorded) or its due date has
* reached a SCHEDULED item's threshold → alert backoffice once.
*/
@Cron(CronExpression.EVERY_DAY_AT_7AM, { name: 'maintenance-due-alert' })
async sendDueAlerts(): Promise<void> {
try {
const due = await this.maintenanceRepository.getUnnotifiedDue();
for (const item of due) {
const reason =
item.nextDueKm != null && (item.currentKm ?? 0) >= item.nextDueKm
? `driven ${item.currentKm} km (due at ${item.nextDueKm} km)`
: `due ${new Date(item.nextDueDate as Date).toLocaleDateString()}`;
await this.inbox.notify({
recipients: { allBackoffice: true },
audience: NotificationAudience.BACKOFFICE,
type: NotificationType.GENERIC,
title: `Maintenance due — ${item.plateNumber}`,
body: `${item.plateNumber} (${item.maintenanceType}) is due for maintenance — ${reason}. ${item.description}`,
link: `/dashboard/maintenance?vehicleId=${item.vehicleId}`,
data: { vehicleId: item.vehicleId, scheduleId: item.id, action: 'MAINTENANCE_DUE' },
});
await this.scheduleRepository.update(item.id, { dueNotifiedAt: new Date() });
}
} catch (err) {
this.logger.error(`sendDueAlerts failed: ${(err as Error).message}`, (err as Error).stack);
}
}
/**
* Reflect a maintenance schedule's lifecycle on the target vehicle. A vehicle
* under maintenance is taken out of service (MAINTENANCE + BUSY); once the
@@ -64,6 +112,10 @@ export class MaintenanceService {
id: string,
dto: UpdateMaintenanceScheduleDto,
): Promise<MaintenanceSchedule> {
// Status BEFORE the write: completing an already-COMPLETED schedule again
// must not auto-create a second "next" schedule.
const before = await this.scheduleRepository.findOneBy({ id });
await this.scheduleRepository.update(id, {
...dto,
completedDate: dto.completedDate ? new Date(dto.completedDate) : undefined,
@@ -78,6 +130,15 @@ export class MaintenanceService {
) {
// Maintenance finished/aborted → vehicle back in service.
await this.setVehicleMaintenanceState(updated.vehicleId, false);
// First transition into COMPLETED with an odometer → auto-schedule next.
if (
dto.status === MaintenanceStatus.COMPLETED &&
before?.status !== MaintenanceStatus.COMPLETED &&
updated.odometerReading != null
) {
await this.scheduleNextMaintenance(updated);
}
} else if (dto.status === MaintenanceStatus.IN_PROGRESS) {
// Maintenance started → keep the vehicle out of service.
await this.setVehicleMaintenanceState(updated.vehicleId, true);
@@ -87,6 +148,83 @@ export class MaintenanceService {
return updated!;
}
/** Define/adjust how often a vehicle needs a service ("oil change every 10,000 km"). */
async upsertInterval(dto: UpsertMaintenanceIntervalDto) {
return this.intervalRepository.upsertInterval(
dto.vehicleId,
dto.maintenanceType,
dto.serviceItem ?? null,
dto.intervalKm ?? null,
dto.intervalDays ?? null,
dto.description ?? null,
);
}
async getIntervals(vehicleId: string) {
return this.intervalRepository.getActiveIntervals(vehicleId);
}
async deactivateInterval(id: string): Promise<{ id: string; deactivated: boolean }> {
await this.intervalRepository.deactivate(id);
return { id, deactivated: true };
}
/**
* Auto-schedule the next service after a completion: matched on the
* completed schedule's (type, serviceItem) interval; one SCHEDULED row
* carrying BOTH thresholds when the interval defines km and days —
* whichever is crossed first makes it due.
*/
private async scheduleNextMaintenance(completed: MaintenanceSchedule): Promise<void> {
try {
const interval = await this.intervalRepository.getByVehicleAndType(
completed.vehicleId,
completed.maintenanceType as MaintenanceType,
completed.serviceItem,
);
if (!interval) return; // No interval defined, skip auto-scheduling
const now = new Date();
const completedKm = Number(completed.odometerReading ?? 0);
const intervalKm = Number(interval.intervalKm ?? 0);
const intervalDays = Number(interval.intervalDays ?? 0);
if (intervalKm <= 0 && intervalDays <= 0) return;
const nextDueKm = intervalKm > 0 ? completedKm + intervalKm : undefined;
const nextDueDate =
intervalDays > 0
? new Date(now.getTime() + intervalDays * 24 * 60 * 60 * 1000)
: undefined;
const label = interval.serviceItem ? `${interval.serviceItem}: ` : '';
const due = [
nextDueKm != null ? `${nextDueKm} km` : null,
nextDueDate != null ? nextDueDate.toISOString().slice(0, 10) : null,
]
.filter(Boolean)
.join(' / ');
await this.scheduleRepository.save(
this.scheduleRepository.create({
vehicleId: completed.vehicleId,
maintenanceType: completed.maintenanceType,
serviceItem: completed.serviceItem ?? interval.serviceItem ?? null,
description: `${label}${interval.description || completed.description} (next due: ${due})`,
scheduledDate: now,
nextDueKm,
nextDueDate,
status: MaintenanceStatus.SCHEDULED,
}),
);
} catch (err) {
this.logger.error(
`Failed to schedule next maintenance for vehicle ${completed.vehicleId}: ${(err as Error).message}`,
(err as Error).stack,
);
}
}
async getUpcomingMaintenance(vehicleId: string) {
return this.maintenanceRepository.getUpcomingMaintenance(vehicleId);
}

View File

@@ -16,7 +16,8 @@ import {
} from "@nestjs/swagger";
import { Response } from "express";
import { Public } from "@edr/api-common";
import { BookingView } from "../../common/booking-guards";
import { BookingStaff, BookingView } from "../../common/booking-guards";
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
import { PaymentService } from "./payment.service";
import { IntentStatusDto } from "./payments.dto";
@@ -25,7 +26,9 @@ import { IntentStatusDto } from "./payments.dto";
export class PaymentController {
constructor(private readonly paymentService: PaymentService) { }
// Customer-detail payments tab — same one-of rule as the bookings tab.
@Get("by-company/:companyId/customer-view")
@BookingStaff([FREIGHT_PERMS.customers.view, FREIGHT_PERMS.payments.view])
@ApiOperation({ summary: "List payments for a company (customer-view shape, backoffice)" })
findByCompanyCustomerView(
@Param("companyId", ParseUUIDPipe) companyId: string,

View File

@@ -7,6 +7,7 @@ import {
IsOptional,
IsEnum,
IsBoolean,
MinLength,
} from 'class-validator';
import { VendorType } from '../entities/vendor.entity';
import { AcquisitionType, AcquisitionStatus } from '../entities/asset-acquisition.entity';
@@ -72,6 +73,11 @@ export class UpdateVendorDto {
}
export class CreateAcquisitionDto {
/** WHAT was acquired — required so an acquisition can't be saved empty. */
@IsString()
@MinLength(2)
itemName!: string;
@IsOptional()
@IsUUID()
vehicleId?: string;
@@ -120,6 +126,11 @@ export class CreateAcquisitionDto {
}
export class UpdateAcquisitionDto {
@IsOptional()
@IsString()
@MinLength(2)
itemName?: string;
@IsOptional()
@IsUUID()
vehicleId?: string;

View File

@@ -18,6 +18,12 @@ export enum AcquisitionStatus {
@Entity({ name: 'asset_acquisitions', schema: 'freight' })
@Index(['vehicleId', 'acquisitionDate'])
export class AssetAcquisition extends BaseEntity {
/** WHAT was acquired (vehicle, parts, equipment…) — the asset itself. */
@Column({ name: 'item_name', type: 'varchar', length: 200, nullable: true })
itemName?: string;
/** Optional link — only when the acquisition IS a fleet vehicle. Parts and
* general procurement stay unlinked so reports don't misattribute them. */
@Column({ name: 'vehicle_id', type: 'uuid', nullable: true })
vehicleId?: string;

View File

@@ -0,0 +1,50 @@
import { BadRequestException } from '@nestjs/common';
import { ProcurementService } from './procurement.service';
import { AcquisitionType } from './entities/asset-acquisition.entity';
// PURCHASE acquisitions must not carry lease terms; LEASE/RENTAL may.
describe('ProcurementService acquisition lease-field guard', () => {
const repo = {
createAcquisition: jest.fn(async (dto) => dto),
findAcquisitionById: jest.fn(async () => ({ acquisitionType: AcquisitionType.PURCHASE })),
updateAcquisition: jest.fn(async (_id, dto) => dto),
};
const svc = new ProcurementService(repo as never);
it('rejects a PURCHASE with lease dates', async () => {
await expect(
svc.createAcquisition({
itemName: 'Brake pads',
acquisitionType: AcquisitionType.PURCHASE,
acquisitionDate: '2026-07-22',
leaseStart: '2026-07-01',
} as never),
).rejects.toThrow(BadRequestException);
});
it('accepts a LEASE with lease dates and a plain PURCHASE', async () => {
await expect(
svc.createAcquisition({
itemName: 'Rented crane',
acquisitionType: AcquisitionType.LEASE,
acquisitionDate: '2026-07-22',
leaseStart: '2026-07-01',
leaseEnd: '2027-07-01',
} as never),
).resolves.toBeDefined();
await expect(
svc.createAcquisition({
itemName: 'Brake pads',
acquisitionType: AcquisitionType.PURCHASE,
acquisitionDate: '2026-07-22',
} as never),
).resolves.toBeDefined();
});
it('rejects adding lease terms to an acquisition that is a PURCHASE', async () => {
await expect(
svc.updateAcquisition('a1', { monthlyPayment: 500 } as never),
).rejects.toThrow(BadRequestException);
});
});

View File

@@ -1,7 +1,7 @@
import { Injectable } from '@nestjs/common';
import { BadRequestException, Injectable } from '@nestjs/common';
import { ProcurementRepository } from './procurement.repository';
import { Vendor } from './entities/vendor.entity';
import { AssetAcquisition } from './entities/asset-acquisition.entity';
import { AcquisitionType, AssetAcquisition } from './entities/asset-acquisition.entity';
import { AssetDisposal } from './entities/asset-disposal.entity';
import {
CreateVendorDto,
@@ -51,7 +51,23 @@ export class ProcurementService {
}
// ---- Acquisitions ----
/** Lease terms only make sense on LEASE / RENTAL — a PURCHASE must not carry them. */
private assertLeaseFieldsValid(dto: {
acquisitionType?: string;
leaseStart?: string;
leaseEnd?: string;
monthlyPayment?: number;
}): void {
if (dto.acquisitionType !== AcquisitionType.PURCHASE) return;
if (dto.leaseStart || dto.leaseEnd || dto.monthlyPayment != null) {
throw new BadRequestException(
'Lease start/end and monthly payment are not valid for a PURCHASE acquisition',
);
}
}
async createAcquisition(dto: CreateAcquisitionDto): Promise<AssetAcquisition> {
this.assertLeaseFieldsValid(dto);
return this.procurementRepository.createAcquisition(dto);
}
@@ -64,6 +80,20 @@ export class ProcurementService {
}
async updateAcquisition(id: string, dto: UpdateAcquisitionDto): Promise<AssetAcquisition | null> {
// Validate against the resulting record, not just the patch — switching an
// acquisition to PURCHASE must also shed any stored lease terms.
const existing = await this.procurementRepository.findAcquisitionById(id);
if (existing) {
const next = { ...existing, ...dto };
if (next.acquisitionType === AcquisitionType.PURCHASE) {
this.assertLeaseFieldsValid({
acquisitionType: next.acquisitionType,
leaseStart: dto.leaseStart,
leaseEnd: dto.leaseEnd,
monthlyPayment: dto.monthlyPayment,
});
}
}
return this.procurementRepository.updateAcquisition(id, dto);
}

View File

@@ -2831,13 +2831,12 @@ export class TrainSchedulingService {
return [
`<tr class="empty">
${wagonCells}
<td colspan="6">EMPTY — no cargo allocated</td>
<td colspan="4">EMPTY — no cargo allocated</td>
</tr>`,
];
}
return allocations.map((allocation) => {
const booking = allocation.booking ?? bookingById.get(allocation.bookingId);
const company = booking?.company as Record<string, unknown> | null | undefined;
const cargoType = (booking as unknown as { cargoType?: { name?: string; code?: string } } | undefined)?.cargoType;
const containerItems = allocation.containerItems ?? [];
const firstContainer = containerItems[0];
@@ -2846,8 +2845,6 @@ export class TrainSchedulingService {
const chassisNumbers = containerItems.map((item) => item.chassisNumber).filter(Boolean).join(', ');
return `<tr>
${wagonCells}
<td>${esc(company?.name ?? company?.legalName ?? company?.tradeName ?? booking?.companyId)}</td>
<td>${esc(booking?.companyId)}</td>
<td>${esc(cargoType?.name ?? cargoType?.code ?? allocation.loadType)}</td>
<td>${esc(containerNumbers || firstContainer?.containerNumber)}</td>
<td>${esc(chassisNumbers)}</td>
@@ -2929,8 +2926,6 @@ export class TrainSchedulingService {
<th class="num">Equated Length</th>
<th class="num">Tare Weight</th>
<th class="num">Load Capacity</th>
<th>Customer Name</th>
<th>Customer ID</th>
<th>Cargo Type</th>
<th>Container No</th>
<th>Chassis No</th>
@@ -2938,7 +2933,7 @@ export class TrainSchedulingService {
</tr>
</thead>
<tbody>
${rows || '<tr><td colspan="12">No wagons on this train set.</td></tr>'}
${rows || '<tr><td colspan="10">No wagons on this train set.</td></tr>'}
</tbody>
</table>

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,79 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
ParseUUIDPipe,
Patch,
Post,
Query,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import {
RuleEngineCreate,
RuleEngineDelete,
RuleEngineUpdate,
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()
@RuleEngineCreate('truck-types')
@ApiOperation({ summary: 'Create a truck type' })
create(@Body() dto: CreateTruckTypeDto) {
return this.truckTypesService.create(dto);
}
@Patch(':id')
@RuleEngineUpdate('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')
@RuleEngineDelete('truck-types')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Soft-delete a truck type' })
remove(@Param('id', ParseUUIDPipe) id: string) {
return this.truckTypesService.remove(id);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -52,4 +52,8 @@ export class BookingHandover extends BaseEntity {
/** EDR last-mile: when the goods were delivered to the customer. */
@Column({ name: 'delivered_at', type: 'timestamptz', nullable: true })
deliveredAt?: Date | null;
/** URL to the signer's saved signature image, if available at sign time. */
@Column({ name: 'signature_image_url', type: 'text', nullable: true })
signatureImageUrl?: string | null;
}

View File

@@ -287,6 +287,7 @@ export class HandoverService {
handoverId: string,
userId?: string | null,
signerName?: string | null,
signatureImageUrl?: string | null,
): Promise<BookingHandover> {
const repo = this.dataSource.getRepository(BookingHandover);
const handover = await repo.findOne({ where: { id: handoverId } });
@@ -297,6 +298,7 @@ export class HandoverService {
handover.signedAt = new Date();
handover.signedByUserId = userId ?? null;
handover.signerName = signerName?.trim() || null;
handover.signatureImageUrl = signatureImageUrl ?? null;
return repo.save(handover);
}
@@ -305,6 +307,7 @@ export class HandoverService {
bookingId: string,
userId?: string | null,
signerName?: string | null,
signatureImageUrl?: string | null,
): Promise<void> {
await this.dataSource
.getRepository(BookingHandover)
@@ -314,6 +317,7 @@ export class HandoverService {
signedAt: new Date(),
signedByUserId: userId ?? null,
signerName: signerName?.trim() || null,
signatureImageUrl: signatureImageUrl ?? null,
},
);
}

View File

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

View File

@@ -20,8 +20,13 @@ import { WarehouseInspectionService } from './warehouse-inspection.service';
@ApiTags('warehouse-inspection')
@ApiBearerAuth()
// Baseline read: inspection reports are opened from inventory screens too —
// either view permission grants reads; writes stack their own per route.
@Controller()
@BookingStaff(FREIGHT_PERMS.warehouseInspectionReports.view)
@BookingStaff([
FREIGHT_PERMS.warehouseInspectionReports.view,
FREIGHT_PERMS.warehouseInventory.view,
])
export class WarehouseInspectionController {
constructor(private readonly inspectionService: WarehouseInspectionService) {}

View File

@@ -5,7 +5,7 @@ import { CurrentUser } from '@edr/api-common';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import { actorLabel } from './current-actor.util';
import { BookingStaff } from '../../common/booking-guards';
import { BookingStaff, StaffReference } from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { BulkReceiveDto } from './dto/bulk-receive.dto';
import { BulkInspectDto } from './dto/bulk-inspect.dto';
@@ -456,6 +456,7 @@ export class WarehouseInventoryController {
}
@Get(':id/handover-document')
@StaffReference()
@ApiOperation({ summary: 'View import goods handover document PDF' })
async handoverDocument(@Param('id', ParseUUIDPipe) id: string, @Res() res: Response) {
const { filename, buffer } = await this.inventoryService.handoverDocument(id);
@@ -466,6 +467,7 @@ export class WarehouseInventoryController {
}
@Post('bookings/:bookingId/approve-delivery')
@StaffReference()
@ApiOperation({ summary: "Approve delivery — customer records their full name (signature optional)" })
approveDeliveryForBooking(
@Param('bookingId', ParseUUIDPipe) bookingId: string,
@@ -481,12 +483,14 @@ export class WarehouseInventoryController {
}
@Get('bookings/:bookingId/handovers')
@StaffReference()
@ApiOperation({ summary: 'Handover records for a booking (per-booking or per-truck)' })
bookingHandovers(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
return this.handoverService.list(bookingId);
}
@Post('handovers/:handoverId/sign')
@StaffReference()
@ApiOperation({ summary: 'Customer signs one handover (EDR last-mile: one signature per truck)' })
signHandover(
@Param('handoverId', ParseUUIDPipe) handoverId: string,
@@ -502,12 +506,14 @@ export class WarehouseInventoryController {
}
@Post('bookings/:bookingId/request-handover-signature')
@StaffReference()
@ApiOperation({ summary: 'Ask the customer to sign the handover (creates one if none, then notifies)' })
requestHandoverSignature(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
return this.handoverService.requestSignature(bookingId);
}
@Get('bookings/:bookingId/grn-document')
@StaffReference()
@ApiOperation({ summary: 'View GRN PDF for a booking (customer portal)' })
async bookingGrnDocument(@Param('bookingId', ParseUUIDPipe) bookingId: string, @Res() res: Response) {
const { filename, buffer } = await this.inventoryService.grnDocumentForBooking(bookingId);
@@ -518,6 +524,7 @@ export class WarehouseInventoryController {
}
@Get('bookings/:bookingId/release-document')
@StaffReference()
@ApiOperation({ summary: 'View gate-clearance / release-order PDF for a booking (customer portal)' })
async bookingReleaseDocument(@Param('bookingId', ParseUUIDPipe) bookingId: string, @Res() res: Response) {
const { filename, buffer } = await this.inventoryService.releaseDocumentForBooking(bookingId);
@@ -528,6 +535,7 @@ export class WarehouseInventoryController {
}
@Get('bookings/:bookingId/handover-document')
@StaffReference()
@ApiOperation({ summary: 'View import goods handover document PDF (resolved by booking; ?handoverId= for the per-truck variant)' })
async bookingHandoverDocument(
@Param('bookingId', ParseUUIDPipe) bookingId: string,
@@ -545,18 +553,21 @@ export class WarehouseInventoryController {
}
@Get('bookings/:bookingId/container-items')
@StaffReference()
@ApiOperation({ summary: 'Per-container/bulk items of a booking with lifecycle stage + refs' })
containerItems(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
return this.inventoryService.containerItems(bookingId);
}
@Get('bookings/:bookingId/container-weights')
@StaffReference()
@ApiOperation({ summary: "A booking's containers + VGM cargo weight (tonnes) for exit weighing" })
containerWeights(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
return this.inventoryService.bookingContainerWeights(bookingId);
}
@Get('bookings/:bookingId/location')
@StaffReference()
@ApiOperation({ summary: "Warehouse location of a booking's inventory (customer portal)" })
bookingLocation(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
return this.inventoryService.bookingLocation(bookingId);

View File

@@ -1578,6 +1578,14 @@ export class WarehouseInventoryService {
notes: `Bulk received (${dto.direction})`,
truckEntrance,
});
// Validate capacity before saving
const weight = Number(booking.weight) || 0;
const containerCount = booking.freightType === 'CONTAINER' ? containerQuantity : 0;
this.assertCapacity('Warehouse', warehouse, weight, 0, containerCount);
this.assertCapacity('Yard', yard, weight, 0, containerCount);
this.assertCapacity('Zone', zone, weight, 0, containerCount);
const saved = await manager.getRepository(WarehouseInventory).save(
manager.getRepository(WarehouseInventory).create({
warehouseId: dto.warehouseId,
@@ -1585,7 +1593,7 @@ export class WarehouseInventoryService {
zoneId: dto.zoneId,
bookingId,
quantity: booking.freightType === 'CONTAINER' ? containerQuantity : 1,
weight: Number(booking.weight) || 0,
weight,
grnNumber,
status: 'RECEIVED',
arrivedAt: now,
@@ -1593,6 +1601,9 @@ export class WarehouseInventoryService {
}),
);
// Update warehouse/yard/zone capacity counters
await this.applyCapacityDelta(manager, dto, weight, 0, containerCount);
// Receiving the booking flags every container unit as received into the
// port (self-haul export: the delivering truck's goods are now in) so
// staff can raise the per-container GRN over what's received.
@@ -2493,27 +2504,37 @@ export class WarehouseInventoryService {
});
if (result.unloadedCount > 0) {
let document = await this.interchangeDocuments.generateFromSchedule({
scheduleId,
direction: 'EXPORT',
handoverLocation: schedule.destinationName ?? 'Djibouti Port',
handoverFrom: 'EDR',
handoverTo: 'Djibouti Port Operator',
portOperatorName: 'Doraleh Multipurpose Port',
generatedBy: performedBy ?? 'EDR Operations',
remarks: 'Generated after export unloading at Djibouti Port; signed by EDR and Djibouti Port Operator.',
});
if (document.status !== 'ACKNOWLEDGED') {
document = await this.interchangeDocuments.acknowledge(document.id, {
acknowledgedBy: 'Djibouti Port Operator',
remarks: 'Auto acknowledged after Djibouti export unloading.',
// Best-effort: the unload is already committed — a paperwork failure must
// not fail the response (it did once: items unloaded, request 500'd, and
// the document only appeared after a manual retry days later). The doc
// backfills on any retry since already-unloaded items count as unloaded.
try {
let document = await this.interchangeDocuments.generateFromSchedule({
scheduleId,
direction: 'EXPORT',
handoverLocation: schedule.destinationName ?? 'Djibouti Port',
handoverFrom: 'EDR',
handoverTo: 'Djibouti Port Operator',
portOperatorName: 'Doraleh Multipurpose Port',
generatedBy: performedBy ?? 'EDR Operations',
remarks: 'Generated after export unloading at Djibouti Port; signed by EDR and Djibouti Port Operator.',
});
if (document.status !== 'ACKNOWLEDGED') {
document = await this.interchangeDocuments.acknowledge(document.id, {
acknowledgedBy: 'Djibouti Port Operator',
remarks: 'Auto acknowledged after Djibouti export unloading.',
});
}
result.interchangeDocument = {
id: document.id,
documentNo: document.documentNo,
status: document.status,
};
} catch (err) {
this.logger.warn(
`Export interchange document generation failed for schedule ${scheduleId}: ${(err as Error).message} — rerun the Djibouti unloading to regenerate it`,
);
}
result.interchangeDocument = {
id: document.id,
documentNo: document.documentNo,
status: document.status,
};
}
return result;
@@ -4076,10 +4097,11 @@ export class WarehouseInventoryService {
await this.invoices.assertClearanceAllowed(item.id);
const approvedAt = new Date();
const signatureImageUrl = signature?.signatureImageUrl ?? null;
const approval = {
approvedAt: approvedAt.toISOString(),
signerDisplayName: name,
signatureImageUrl: signature?.signatureImageUrl ?? null,
signatureImageUrl,
userId,
};
const existingNotes = this.stripCustomerDeliveryApproval(item.notes);
@@ -4103,7 +4125,7 @@ export class WarehouseInventoryService {
// Sign the structured handover record(s) for this booking (self-haul: before
// the truck leaves). Kept alongside the legacy approval note.
await this.handover.signForBooking(bookingId, userId, name);
await this.handover.signForBooking(bookingId, userId, name, signatureImageUrl);
return {
bookingId,
@@ -4138,6 +4160,8 @@ export class WarehouseInventoryService {
throw new BadRequestException('Please enter your full name to sign the handover');
}
const signature = await this.signatures.getForUser(userId).catch(() => null);
const [h]: Array<{
bookingId: string;
reference: string;
@@ -4170,7 +4194,7 @@ export class WarehouseInventoryService {
);
if (inv) await this.invoices.assertClearanceAllowed(inv.id);
const signed = await this.handover.sign(handoverId, userId, name);
const signed = await this.handover.sign(handoverId, userId, name, signature?.signatureImageUrl ?? null);
const allSigned = await this.handover.isFullySigned(h.bookingId);
if (inv) {

View File

@@ -5,7 +5,7 @@ import { CurrentUser } from '@edr/api-common';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import { actorLabel } from './current-actor.util';
import { BookingStaff } from '../../common/booking-guards';
import { BookingStaff, StaffReference } from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { PayInvoiceDto as GatewayPayInvoiceDto } from '../billing/dto/pay-invoice.dto';
import { GenerateInvoiceDto, PayInvoiceBodyDto } from './dto/invoice.dto';
@@ -43,6 +43,7 @@ export class WarehouseInvoiceController {
}
@Get('bookings/:id/warehouse-fee-invoices')
@StaffReference()
@ApiOperation({ summary: 'List warehouse fee invoices for a booking' })
listForBooking(@Param('id', ParseUUIDPipe) id: string) {
return this.invoiceService.listForBooking(id);
@@ -70,12 +71,14 @@ export class WarehouseInvoiceController {
}
@Get('warehouse-fee-invoices/:id')
@StaffReference()
@ApiOperation({ summary: 'Get a warehouse fee invoice with items + payment history' })
findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.invoiceService.findById(id);
}
@Get('warehouse-fee-invoices/:id/document')
@StaffReference()
@ApiOperation({ summary: 'Download sealed warehouse fee invoice PDF' })
async document(@Param('id', ParseUUIDPipe) id: string, @Res() res: Response) {
const { filename, buffer } = await this.invoiceService.document(id);
@@ -86,6 +89,7 @@ export class WarehouseInvoiceController {
}
@Get('warehouse-fee-invoices/:id/receipt')
@StaffReference()
@ApiOperation({ summary: 'Download sealed warehouse fee payment receipt PDF' })
async receipt(@Param('id', ParseUUIDPipe) id: string, @Res() res: Response) {
const { filename, buffer } = await this.invoiceService.receipt(id);
@@ -110,6 +114,7 @@ export class WarehouseInvoiceController {
}
@Post('warehouse-fee-invoices/:id/pay-online')
@StaffReference()
@ApiOperation({ summary: 'Initiate Telebirr/Waafi payment for a warehouse fee invoice' })
payOnline(@Param('id', ParseUUIDPipe) id: string, @Body() dto: GatewayPayInvoiceDto) {
return this.invoiceService.initiatePayment(id, dto);

View File

@@ -1,7 +1,7 @@
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { BookingStaff, StaffReference } from '../../common/booking-guards';
import { BookingStaff } from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { CreateWarehouseZoneDto } from './dto/create-warehouse-zone.dto';
import { UpdateWarehouseYardDto } from './dto/update-warehouse-yard.dto';
@@ -10,8 +10,8 @@ import { WarehouseZonesService } from './warehouse-zones.service';
@ApiTags('warehouse-yards')
@ApiBearerAuth()
// No class-level guard: the two reference GETs are open to any signed-in
// staff (StaffReference), every other route carries its own permission.
// No class-level guard: every route carries its own permission (reads accept
// yard-view OR inventory-view so inventory flows can populate yard pickers).
@Controller('warehouse-yards')
export class WarehouseYardsController {
constructor(
@@ -20,14 +20,14 @@ export class WarehouseYardsController {
) {}
@Get()
@StaffReference()
@BookingStaff([FREIGHT_PERMS.warehouseYards.view, FREIGHT_PERMS.warehouseInventory.view])
@ApiOperation({ summary: 'List all warehouse yards' })
findAll() {
return this.yardsService.findAll();
}
@Get(':id')
@StaffReference()
@BookingStaff([FREIGHT_PERMS.warehouseYards.view, FREIGHT_PERMS.warehouseInventory.view])
@ApiOperation({ summary: 'Get warehouse yard by ID' })
findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.yardsService.findById(id);

View File

@@ -1,4 +1,4 @@
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';
import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common';
import { CreateWarehouseYardDto } from './dto/create-warehouse-yard.dto';
import { UpdateWarehouseYardDto } from './dto/update-warehouse-yard.dto';
@@ -44,6 +44,7 @@ export class WarehouseYardsService {
// Ensure the parent warehouse exists.
await this.warehousesService.findById(warehouseId);
await this.assertCodeUnique(warehouseId, dto.code.trim());
await this.assertCapacityWithinWarehouse(warehouseId, dto.capacityWeight ?? null, dto.capacityContainers ?? null);
return this.yardsRepository.create({
warehouseId,
@@ -69,14 +70,22 @@ export class WarehouseYardsService {
await this.assertCodeUnique(existing.warehouseId, dto.code.trim(), id);
}
const newCapacityWeight = dto.capacityWeight ?? existing.capacityWeight ?? null;
const newCapacityContainers = dto.capacityContainers ?? existing.capacityContainers ?? null;
// Validate updated capacity doesn't exceed warehouse limits
if (newCapacityWeight !== (existing.capacityWeight ?? null) || newCapacityContainers !== (existing.capacityContainers ?? null)) {
await this.assertCapacityWithinWarehouse(existing.warehouseId, newCapacityWeight, newCapacityContainers, id);
}
const status = dto.status ?? existing.status;
const updated = await this.yardsRepository.update(id, {
name: dto.name?.trim() ?? existing.name,
code: dto.code?.trim() ?? existing.code,
type: dto.type ?? existing.type,
capacityWeight: dto.capacityWeight ?? existing.capacityWeight,
capacityContainers: dto.capacityContainers ?? existing.capacityContainers,
capacityWeight: newCapacityWeight,
capacityContainers: newCapacityContainers,
maxWeight: dto.maxWeight ?? existing.maxWeight,
maxVolume: dto.maxVolume ?? existing.maxVolume,
status,
@@ -97,4 +106,39 @@ export class WarehouseYardsService {
throw new ConflictException(`Yard code ${code} already exists in this warehouse`);
}
}
private async assertCapacityWithinWarehouse(
warehouseId: string,
newCapacityWeight: number | null,
newCapacityContainers: number | null,
excludeYardId?: string,
): Promise<void> {
const warehouse = await this.warehousesService.findById(warehouseId);
const yards = await this.findByWarehouse(warehouseId);
// Sum existing yard capacities, excluding the yard being updated if provided
const otherYards = excludeYardId ? yards.filter((y) => y.id !== excludeYardId) : yards;
const totalExistingWeight = otherYards.reduce((sum, y) => sum + (y.capacityWeight ?? 0), 0);
const totalExistingContainers = otherYards.reduce((sum, y) => sum + (y.capacityContainers ?? 0), 0);
// Check weight capacity
if (newCapacityWeight !== null && warehouse.capacityWeight != null) {
const totalWeight = totalExistingWeight + newCapacityWeight;
if (totalWeight > warehouse.capacityWeight) {
throw new BadRequestException(
`Total yard weight capacity (${totalWeight}t) exceeds warehouse limit (${warehouse.capacityWeight}t)`,
);
}
}
// Check container capacity
if (newCapacityContainers !== null && warehouse.capacityContainers != null) {
const totalContainers = totalExistingContainers + newCapacityContainers;
if (totalContainers > warehouse.capacityContainers) {
throw new BadRequestException(
`Total yard container capacity (${totalContainers}) exceeds warehouse limit (${warehouse.capacityContainers})`,
);
}
}
}
}

View File

@@ -8,8 +8,11 @@ import { WarehouseZonesService } from './warehouse-zones.service';
@ApiTags('warehouse-zones')
@ApiBearerAuth()
// Baseline read: zone reference data also serves inventory flows (allocation,
// receive/move pickers) — either view permission grants reads; writes stack
// their specific permission per route.
@Controller('warehouse-zones')
@BookingStaff(FREIGHT_PERMS.warehouseZones.view)
@BookingStaff([FREIGHT_PERMS.warehouseZones.view, FREIGHT_PERMS.warehouseInventory.view])
export class WarehouseZonesController {
constructor(private readonly zonesService: WarehouseZonesService) {}

View File

@@ -1,4 +1,4 @@
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';
import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common';
import { CreateWarehouseZoneDto } from './dto/create-warehouse-zone.dto';
import { UpdateWarehouseZoneDto } from './dto/update-warehouse-zone.dto';
@@ -43,6 +43,7 @@ export class WarehouseZonesService {
// Ensure the parent yard exists.
await this.yardsService.findById(yardId);
await this.assertCodeUnique(yardId, dto.code.trim());
await this.assertCapacityWithinYard(yardId, dto.capacityWeight ?? null, dto.capacityContainers ?? null);
return this.zonesRepository.create({
yardId,
@@ -68,14 +69,22 @@ export class WarehouseZonesService {
await this.assertCodeUnique(existing.yardId, dto.code.trim(), id);
}
const newCapacityWeight = dto.capacityWeight ?? existing.capacityWeight ?? null;
const newCapacityContainers = dto.capacityContainers ?? existing.capacityContainers ?? null;
// Validate updated capacity doesn't exceed yard limits
if (newCapacityWeight !== (existing.capacityWeight ?? null) || newCapacityContainers !== (existing.capacityContainers ?? null)) {
await this.assertCapacityWithinYard(existing.yardId, newCapacityWeight, newCapacityContainers, id);
}
const status = dto.status ?? existing.status;
const updated = await this.zonesRepository.update(id, {
name: dto.name?.trim() ?? existing.name,
code: dto.code?.trim() ?? existing.code,
type: dto.type ?? existing.type,
capacityWeight: dto.capacityWeight ?? existing.capacityWeight,
capacityContainers: dto.capacityContainers ?? existing.capacityContainers,
capacityWeight: newCapacityWeight,
capacityContainers: newCapacityContainers,
maxWeight: dto.maxWeight ?? existing.maxWeight,
maxVolume: dto.maxVolume ?? existing.maxVolume,
status,
@@ -96,4 +105,39 @@ export class WarehouseZonesService {
throw new ConflictException(`Zone code ${code} already exists in this yard`);
}
}
private async assertCapacityWithinYard(
yardId: string,
newCapacityWeight: number | null,
newCapacityContainers: number | null,
excludeZoneId?: string,
): Promise<void> {
const yard = await this.yardsService.findById(yardId);
const zones = await this.findByYard(yardId);
// Sum existing zone capacities, excluding the zone being updated if provided
const otherZones = excludeZoneId ? zones.filter((z) => z.id !== excludeZoneId) : zones;
const totalExistingWeight = otherZones.reduce((sum, z) => sum + (z.capacityWeight ?? 0), 0);
const totalExistingContainers = otherZones.reduce((sum, z) => sum + (z.capacityContainers ?? 0), 0);
// Check weight capacity
if (newCapacityWeight !== null && yard.capacityWeight != null) {
const totalWeight = totalExistingWeight + newCapacityWeight;
if (totalWeight > yard.capacityWeight) {
throw new BadRequestException(
`Total zone weight capacity (${totalWeight}t) exceeds yard limit (${yard.capacityWeight}t)`,
);
}
}
// Check container capacity
if (newCapacityContainers !== null && yard.capacityContainers != null) {
const totalContainers = totalExistingContainers + newCapacityContainers;
if (totalContainers > yard.capacityContainers) {
throw new BadRequestException(
`Total zone container capacity (${totalContainers}) exceeds yard limit (${yard.capacityContainers})`,
);
}
}
}
}

View File

@@ -13,8 +13,15 @@ import { WarehousesService } from './warehouses.service';
@ApiTags('warehouses')
@ApiBearerAuth()
// Baseline read: warehouse reference data is consumed by inventory/dashboard
// flows too, so any of the three view permissions grants reads. Writes stack
// their specific create/update permission per route on top.
@Controller('warehouses')
@BookingStaff(FREIGHT_PERMS.warehouses.view)
@BookingStaff([
FREIGHT_PERMS.warehouses.view,
FREIGHT_PERMS.warehouseInventory.view,
FREIGHT_PERMS.warehouseDashboard.view,
])
export class WarehousesController {
constructor(
private readonly warehousesService: WarehousesService,

View File

@@ -19,6 +19,9 @@ export const RULE_ENGINE_RESOURCE_SLUGS = [
'rates',
'approval-rules',
'yard-distances',
// Keep new slugs at the END: ruleEngineCrudId derives ids from list index,
// so a mid-list insert would shift ids already seeded for later slugs.
'truck-types',
] as const;
export type RuleEngineResourceSlug = (typeof RULE_ENGINE_RESOURCE_SLUGS)[number];
@@ -101,6 +104,7 @@ const RULE_ENGINE_VIEW_IDS: Record<RuleEngineResourceSlug, string> = {
'cargo-types': 'b2000001-0001-4000-8000-000000000001',
'container-types': 'b2000001-0001-4000-8000-000000000003',
'wagon-types': 'b2000001-0001-4000-8000-000000000015',
'truck-types': 'b2000001-0001-4000-8000-00000000001a',
'service-types': 'b2000001-0001-4000-8000-000000000005',
yards: 'b2000001-0001-4000-8000-000000000007',
'shipping-lines': 'b2000001-0001-4000-8000-000000000009',
@@ -123,7 +127,7 @@ const ruleEngineCrudId = (
const n =
RULE_ENGINE_RESOURCE_SLUGS.indexOf(slug) * 3 +
RULE_ENGINE_CRUD_ACTIONS.indexOf(action) +
1; // 1..33
1; // 1..36
return `b2000002-0001-4000-8000-${n.toString(16).padStart(12, '0')}`;
};
@@ -902,41 +906,41 @@ export const POSITION_PERMISSION_PRESETS = {
// permission catalog (all CRUD across bookings, contracts, scheduling,
// fleet, warehouse, mile, finance, settings, staff).
operationsChief: dedupe([...BOOKING_RULE_ENGINE_PERMISSION_KEYS]),
// Dispatcher: warehouse floor operations — receive/GRN, move, load/unload,
// inspect, dispatch, gate, release/deliver, interchange docs, fee invoices,
// plus truck dispatch on the mile legs and read-only operational context.
// Allocation & fee rules are VIEW-ONLY — never create/update/delete.
// Dispatcher: full CRUD on warehouse management (incl. import/export/intercity
// inventory flows) and fleet management, plus truck dispatch on the mile legs
// and operational context. The ONE carve-out: allocation & fee rules stay
// VIEW-ONLY — a dispatcher never creates/updates/deletes those rules.
dispatcher: dedupe([
// Warehouse management — full CRUD.
FREIGHT_PERMS.warehouseDashboard.view,
FREIGHT_PERMS.warehouses.view,
FREIGHT_PERMS.warehouseYards.view,
FREIGHT_PERMS.warehouseZones.view,
FREIGHT_PERMS.warehouseInventory.view,
FREIGHT_PERMS.warehouseInventory.receive,
FREIGHT_PERMS.warehouseInventory.move,
FREIGHT_PERMS.warehouseInventory.load,
FREIGHT_PERMS.warehouseInventory.unload,
FREIGHT_PERMS.warehouseInventory.dispatch,
FREIGHT_PERMS.warehouseInventory.gatePass,
FREIGHT_PERMS.warehouseInventory.release,
FREIGHT_PERMS.warehouseInventory.deliver,
FREIGHT_PERMS.warehouseInventory.inspect,
FREIGHT_PERMS.warehouseInspectionReports.view,
FREIGHT_PERMS.warehouseInspectionReports.create,
FREIGHT_PERMS.warehouseInspectionReports.update,
FREIGHT_PERMS.interchangeDocuments.view,
FREIGHT_PERMS.interchangeDocuments.generate,
FREIGHT_PERMS.interchangeDocuments.acknowledge,
FREIGHT_PERMS.warehouseFeeInvoices.view,
FREIGHT_PERMS.warehouseFeeInvoices.generate,
...Object.values(FREIGHT_PERMS.warehouses),
...Object.values(FREIGHT_PERMS.warehouseYards),
...Object.values(FREIGHT_PERMS.warehouseZones),
...Object.values(FREIGHT_PERMS.warehouseInventory),
...Object.values(FREIGHT_PERMS.warehouseInspectionReports),
...Object.values(FREIGHT_PERMS.interchangeDocuments),
...Object.values(FREIGHT_PERMS.warehouseFeeInvoices),
// View-only on the rules that govern allocation and fees.
FREIGHT_PERMS.warehouseAllocationRules.view,
FREIGHT_PERMS.warehouseFeeRules.view,
// Fleet management — full CRUD.
...Object.values(FREIGHT_PERMS.fleet),
FREIGHT_PERMS.fleetDashboard.view,
...Object.values(FREIGHT_PERMS.fleetReports),
...Object.values(FREIGHT_PERMS.vehicles),
...Object.values(FREIGHT_PERMS.drivers),
...Object.values(FREIGHT_PERMS.tracking),
...Object.values(FREIGHT_PERMS.fuel),
...Object.values(FREIGHT_PERMS.maintenance),
...Object.values(FREIGHT_PERMS.locomotives),
...Object.values(FREIGHT_PERMS.wagons),
...Object.values(FREIGHT_PERMS.trains),
...Object.values(FREIGHT_PERMS.routes),
...Object.values(FREIGHT_PERMS.containers),
...Object.values(FREIGHT_PERMS.cargoes),
// Truck dispatch on the EDR mile legs + operational context.
FREIGHT_PERMS.firstMile.view,
FREIGHT_PERMS.firstMile.assignVehicles,
FREIGHT_PERMS.lastMile.view,
FREIGHT_PERMS.lastMile.assignVehicles,
...Object.values(FREIGHT_PERMS.firstMile),
...Object.values(FREIGHT_PERMS.lastMile),
FREIGHT_PERMS.trainScheduling.view,
FREIGHT_PERMS.bookings.operations,
]),