mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 01:48:12 +00:00
changes
This commit is contained in:
@@ -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();
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ type MileRecord = {
|
||||
bookingContainers?: Array<{
|
||||
units?: Array<{ vgmTons?: number | string | null }> | null;
|
||||
}> | null;
|
||||
/** Attached here: the train schedule the booking rides, for mile alignment. */
|
||||
trainSchedule?: { trainNumber: string | null; departureDate: string | null } | null;
|
||||
} | null;
|
||||
};
|
||||
|
||||
@@ -36,6 +38,38 @@ export async function attachMileFinancials(
|
||||
if (unitTons > 0) b.cargoTotalWeightVgm = Number(unitTons.toFixed(3));
|
||||
}
|
||||
|
||||
// Train alignment: which schedule each booking rides (mile pickups/deliveries
|
||||
// are planned against the train's departure).
|
||||
const bookingIds = [...new Set(records.map((r) => r.bookingId).filter(Boolean))] as string[];
|
||||
if (bookingIds.length) {
|
||||
const schedules: Array<{
|
||||
bookingId: string;
|
||||
trainNumber: string | null;
|
||||
departureDate: string | null;
|
||||
}> = await dataSource.query(
|
||||
`SELECT DISTINCT ON (tsb.booking_id)
|
||||
tsb.booking_id AS "bookingId",
|
||||
ts.train_number AS "trainNumber",
|
||||
COALESCE(ts.actual_departure_at, ts.scheduled_departure_date)::text AS "departureDate"
|
||||
FROM freight.train_schedule_bookings tsb
|
||||
JOIN freight.train_schedules ts
|
||||
ON ts.id = tsb.train_schedule_id AND ts.deleted_at IS NULL
|
||||
WHERE tsb.booking_id = ANY($1::uuid[]) AND tsb.deleted_at IS NULL
|
||||
ORDER BY tsb.booking_id, tsb.created_at DESC`,
|
||||
[bookingIds],
|
||||
);
|
||||
const byBookingSchedule = new Map(schedules.map((s) => [s.bookingId, s]));
|
||||
for (const r of records) {
|
||||
const s = r.bookingId ? byBookingSchedule.get(r.bookingId) : undefined;
|
||||
if (r.booking && s) {
|
||||
r.booking.trainSchedule = {
|
||||
trainNumber: s.trainNumber,
|
||||
departureDate: s.departureDate,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const needAdvance = records.filter(
|
||||
(r) => r.bookingId && !(Number(r.advancedPayment) > 0),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,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
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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;`);
|
||||
}
|
||||
}
|
||||
@@ -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;`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Bulk tonnage at assignment time. First-mile trucks and export self-haul
|
||||
* trucks carry a planned load (tonnes + optional item count) so bulk bookings
|
||||
* draw down as vehicles are assigned — not only at the weighbridge.
|
||||
*/
|
||||
export class AddMileTonsQuantity2820000000000 implements MigrationInterface {
|
||||
name = 'AddMileTonsQuantity2820000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.first_mile_vehicle_assignments ADD COLUMN IF NOT EXISTS tons numeric(14,3);`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.first_mile_vehicle_assignments ADD COLUMN IF NOT EXISTS quantity integer;`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.customer_truck_assignments ADD COLUMN IF NOT EXISTS planned_tons numeric(14,3);`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.customer_truck_assignments ADD COLUMN IF NOT EXISTS planned_quantity integer;`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.customer_truck_assignments DROP COLUMN IF EXISTS planned_quantity;`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.customer_truck_assignments DROP COLUMN IF EXISTS planned_tons;`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.first_mile_vehicle_assignments DROP COLUMN IF EXISTS quantity;`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.first_mile_vehicle_assignments DROP COLUMN IF EXISTS tons;`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Truck types become back-office data instead of a hardcoded `VehicleType` enum,
|
||||
* so EDR can add a configuration without a code change.
|
||||
*
|
||||
* `vehicles.vehicle_type` is deliberately LEFT IN PLACE as a denormalised code.
|
||||
* Truck-detention billing groups trucks with raw SQL over that column
|
||||
* (`SELECT v.vehicle_type ... GROUP BY`, warehouse-fee.service.ts) and matches
|
||||
* the result against `warehouse_fee_rules.vehicle_type`. Swapping it for the FK
|
||||
* outright would silently drop detention charges, so the FK is additive and the
|
||||
* service writes the type's code through on every save.
|
||||
*
|
||||
* Raw SQL, `freight.`-qualified, IF NOT EXISTS throughout — the TypeORM builder
|
||||
* API resolves bare names against `public` and crash-loops boot.
|
||||
*/
|
||||
export class AddTruckTypes2840000000000 implements MigrationInterface {
|
||||
name = "AddTruckTypes2840000000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.truck_types (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
code varchar(32) NOT NULL,
|
||||
name varchar(100) NOT NULL,
|
||||
capacity_tons numeric(10,3),
|
||||
has_trailer boolean NOT NULL DEFAULT false,
|
||||
description text,
|
||||
is_active boolean NOT NULL DEFAULT true,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
deleted_at timestamptz
|
||||
)
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS ux_truck_types_code
|
||||
ON freight.truck_types (code)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS ix_truck_types_is_active
|
||||
ON freight.truck_types (is_active)
|
||||
`);
|
||||
|
||||
// Seed one row per legacy enum value so vehicles already carrying that code
|
||||
// keep resolving, plus CASONI as the first rigid (no-trailer) configuration.
|
||||
// has_trailer is true only for the articulated configurations.
|
||||
await queryRunner.query(`
|
||||
INSERT INTO freight.truck_types (code, name, has_trailer)
|
||||
VALUES
|
||||
('TRUCK', 'Truck', true),
|
||||
('TRAILER', 'Trailer', true),
|
||||
('TANKER', 'Tanker', true),
|
||||
('FLATBED', 'Flatbed', true),
|
||||
('VAN', 'Van', false),
|
||||
('CAR', 'Car', false),
|
||||
('BUS', 'Bus', false),
|
||||
('CASONI', 'Casoni (rigid, no trailer)', false)
|
||||
ON CONFLICT (code) DO NOTHING
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.vehicles
|
||||
ADD COLUMN IF NOT EXISTS truck_type_id uuid
|
||||
`);
|
||||
|
||||
// Separate DO block: ADD CONSTRAINT has no IF NOT EXISTS in Postgres.
|
||||
await queryRunner.query(`
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'fk_vehicles_truck_type'
|
||||
) THEN
|
||||
ALTER TABLE freight.vehicles
|
||||
ADD CONSTRAINT fk_vehicles_truck_type
|
||||
FOREIGN KEY (truck_type_id) REFERENCES freight.truck_types (id)
|
||||
ON DELETE SET NULL;
|
||||
END IF;
|
||||
END $$
|
||||
`);
|
||||
|
||||
// Backfill the FK from the code already stored on each vehicle.
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.vehicles v
|
||||
SET truck_type_id = t.id
|
||||
FROM freight.truck_types t
|
||||
WHERE v.truck_type_id IS NULL
|
||||
AND upper(trim(v.vehicle_type)) = t.code
|
||||
`);
|
||||
|
||||
// Truck-type codes are varchar(32); the fee-rule column they are matched
|
||||
// against was varchar(20) and would truncate/reject longer codes.
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.warehouse_fee_rules
|
||||
ALTER COLUMN vehicle_type TYPE varchar(32)
|
||||
`);
|
||||
|
||||
// A VIN identifies exactly one vehicle worldwide. Partial index so the many
|
||||
// existing rows without a VIN do not collide.
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS ux_vehicles_vin
|
||||
ON freight.vehicles (vin)
|
||||
WHERE vin IS NOT NULL
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS freight.ux_vehicles_vin`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.vehicles
|
||||
DROP CONSTRAINT IF EXISTS fk_vehicles_truck_type
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.vehicles
|
||||
DROP COLUMN IF EXISTS truck_type_id
|
||||
`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.truck_types`);
|
||||
// warehouse_fee_rules.vehicle_type is left widened: narrowing it back would
|
||||
// fail on any row that stored a code longer than 20 characters.
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>`;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}>;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { IsArray, IsOptional, IsString, IsUUID, ValidateNested } from 'class-validator';
|
||||
import { IsArray, IsNumber, IsOptional, IsString, IsUUID, Min, ValidateNested } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
export class FirstMileVehicleInput {
|
||||
@@ -8,6 +8,18 @@ export class FirstMileVehicleInput {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
containerNumber?: string;
|
||||
|
||||
/** Bulk: tonnage this truck hauls. */
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
tons?: number;
|
||||
|
||||
/** Bulk: optional item/piece count. */
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
quantity?: number;
|
||||
}
|
||||
|
||||
/** Replace the full set of vehicles (with their container numbers) on a pickup. */
|
||||
|
||||
@@ -36,4 +36,12 @@ export class FirstMileVehicleAssignment extends BaseEntity {
|
||||
/** Actual distance driven by this truck (km), entered per vehicle. */
|
||||
@Column({ name: 'distance_km', type: 'numeric', precision: 10, scale: 2, nullable: true })
|
||||
distanceKm?: number | null;
|
||||
|
||||
/** Bulk: tonnage this truck hauls — assigned tonnage draws down the booking total. */
|
||||
@Column({ name: 'tons', type: 'numeric', precision: 14, scale: 3, nullable: true })
|
||||
tons?: number | null;
|
||||
|
||||
/** Bulk: optional item/piece count on this truck. */
|
||||
@Column({ name: 'quantity', type: 'integer', nullable: true })
|
||||
quantity?: number | null;
|
||||
}
|
||||
|
||||
@@ -532,17 +532,47 @@ export class FirstMileService {
|
||||
*/
|
||||
async setVehicles(
|
||||
id: string,
|
||||
inputs: Array<{ vehicleId: string; containerNumber?: string | null }>,
|
||||
inputs: Array<{
|
||||
vehicleId: string;
|
||||
containerNumber?: string | null;
|
||||
tons?: number | null;
|
||||
quantity?: number | null;
|
||||
}>,
|
||||
): Promise<FirstMile> {
|
||||
const existing = await this.findById(id);
|
||||
// Dedupe by vehicleId, keeping the container number; preserve order.
|
||||
const desiredMap = new Map<string, string | null>();
|
||||
// Dedupe by vehicleId, keeping the load details; preserve order.
|
||||
const desiredMap = new Map<
|
||||
string,
|
||||
{ containerNumber: string | null; tons: number | null; quantity: number | null }
|
||||
>();
|
||||
for (const inp of inputs) {
|
||||
if (inp.vehicleId) desiredMap.set(inp.vehicleId, inp.containerNumber ?? null);
|
||||
if (inp.vehicleId) {
|
||||
desiredMap.set(inp.vehicleId, {
|
||||
containerNumber: inp.containerNumber ?? null,
|
||||
tons: inp.tons ?? null,
|
||||
quantity: inp.quantity ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
const desired = [...desiredMap.keys()];
|
||||
const desiredSet = new Set(desired);
|
||||
|
||||
// Bulk drawdown: assigned tonnage may not exceed what the booking declares.
|
||||
const totalTons = [...desiredMap.values()].reduce((s, v) => s + (Number(v.tons) || 0), 0);
|
||||
if (totalTons > 0 && existing.bookingId) {
|
||||
const [b]: Array<{ vgm: string | null }> = await this.dataSource.query(
|
||||
`SELECT cargo_total_weight_vgm AS vgm FROM freight.bookings
|
||||
WHERE id = $1 AND deleted_at IS NULL`,
|
||||
[existing.bookingId],
|
||||
);
|
||||
const declared = Number(b?.vgm ?? 0);
|
||||
if (declared > 0 && totalTons > declared + 0.001) {
|
||||
throw new BadRequestException(
|
||||
`Assigned tonnage (${totalTons} t) exceeds the booking's declared ${declared} t`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const manager = this.dataSource.manager;
|
||||
const current = await manager.find(FirstMileVehicleAssignment, {
|
||||
where: { firstMileId: id },
|
||||
@@ -555,12 +585,16 @@ export class FirstMileService {
|
||||
)];
|
||||
const added = desired.filter((v) => !junctionSet.has(v));
|
||||
const removed = releaseIds.filter((v) => !desiredSet.has(v));
|
||||
// Vehicles that stay but whose container number changed.
|
||||
const changed = current.filter(
|
||||
(a) =>
|
||||
desiredMap.has(a.vehicleId) &&
|
||||
(a.containerNumber ?? null) !== (desiredMap.get(a.vehicleId) ?? null),
|
||||
);
|
||||
// Vehicles that stay but whose load details changed.
|
||||
const changed = current.filter((a) => {
|
||||
const want = desiredMap.get(a.vehicleId);
|
||||
if (!want) return false;
|
||||
return (
|
||||
(a.containerNumber ?? null) !== want.containerNumber ||
|
||||
(a.tons == null ? null : Number(a.tons)) !== want.tons ||
|
||||
(a.quantity ?? null) !== want.quantity
|
||||
);
|
||||
});
|
||||
|
||||
await this.dataSource.transaction(async (tx) => {
|
||||
if (removed.length) {
|
||||
@@ -570,17 +604,25 @@ export class FirstMileService {
|
||||
});
|
||||
}
|
||||
for (const vehicleId of added) {
|
||||
const want = desiredMap.get(vehicleId);
|
||||
await tx.insert(FirstMileVehicleAssignment, {
|
||||
firstMileId: id,
|
||||
vehicleId,
|
||||
containerNumber: desiredMap.get(vehicleId) ?? null,
|
||||
containerNumber: want?.containerNumber ?? null,
|
||||
tons: want?.tons ?? null,
|
||||
quantity: want?.quantity ?? null,
|
||||
});
|
||||
}
|
||||
for (const row of changed) {
|
||||
const want = desiredMap.get(row.vehicleId);
|
||||
await tx.update(
|
||||
FirstMileVehicleAssignment,
|
||||
{ firstMileId: id, vehicleId: row.vehicleId },
|
||||
{ containerNumber: desiredMap.get(row.vehicleId) ?? null },
|
||||
{
|
||||
containerNumber: want?.containerNumber ?? null,
|
||||
tons: want?.tons ?? null,
|
||||
quantity: want?.quantity ?? null,
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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' })
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsBoolean, IsNumber, IsOptional, IsString, MaxLength, Min } from 'class-validator';
|
||||
|
||||
const toNumber = ({ value }: { value: unknown }) =>
|
||||
value === '' || value == null ? value : Number(value);
|
||||
|
||||
const toBoolean = ({ value }: { value: unknown }) => {
|
||||
if (typeof value === 'boolean') return value;
|
||||
if (value === 'true') return true;
|
||||
if (value === 'false') return false;
|
||||
return value;
|
||||
};
|
||||
|
||||
export class CreateTruckTypeDto {
|
||||
@ApiProperty({ maxLength: 32, example: 'CASONI' })
|
||||
@IsString()
|
||||
@MaxLength(32)
|
||||
code!: string;
|
||||
|
||||
@ApiProperty({ maxLength: 100, example: 'Casoni (rigid, no trailer)' })
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
name!: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Payload capacity in metric tons — pre-fills a vehicle registered against this type',
|
||||
example: 30,
|
||||
})
|
||||
@IsOptional()
|
||||
@Transform(toNumber)
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
capacityTons?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Whether this configuration pulls a trailer. False (e.g. Casoni) forbids a trailer plate.',
|
||||
default: false,
|
||||
})
|
||||
@IsOptional()
|
||||
@Transform(toBoolean)
|
||||
@IsBoolean()
|
||||
hasTrailer?: boolean;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
|
||||
@ApiPropertyOptional({ default: true })
|
||||
@IsOptional()
|
||||
@Transform(toBoolean)
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { PartialType } from '@nestjs/mapped-types';
|
||||
|
||||
import { CreateTruckTypeDto } from './create-truck-type.dto';
|
||||
|
||||
export class UpdateTruckTypeDto extends PartialType(CreateTruckTypeDto) {}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index } from 'typeorm';
|
||||
|
||||
/**
|
||||
* A truck configuration EDR registers vehicles against — back-office managed so
|
||||
* new configurations arrive without a code change.
|
||||
*
|
||||
* Two fields drive vehicle registration:
|
||||
* - `capacityTons` pre-fills a vehicle's capacity (capacity belongs to the type,
|
||||
* not to each individual truck).
|
||||
* - `hasTrailer` decides whether a trailer plate applies at all. A rigid truck
|
||||
* (e.g. Casoni) has none, and registering one with a trailer plate is rejected.
|
||||
*/
|
||||
@Entity({ schema: 'freight', name: 'truck_types' })
|
||||
@Index(['code'])
|
||||
@Index(['isActive'])
|
||||
export class TruckType extends BaseEntity {
|
||||
/**
|
||||
* Matching key, upper-case. Denormalised onto `vehicles.vehicle_type`, which
|
||||
* truck-detention billing groups and matches fee rules by — so a code change
|
||||
* here is a billing-visible change.
|
||||
*/
|
||||
@Column({ name: 'code', type: 'varchar', length: 32, unique: true })
|
||||
code!: string;
|
||||
|
||||
@Column({ name: 'name', type: 'varchar', length: 100 })
|
||||
name!: string;
|
||||
|
||||
@Column({ name: 'capacity_tons', type: 'numeric', precision: 10, scale: 3, nullable: true })
|
||||
capacityTons?: number | null;
|
||||
|
||||
@Column({ name: 'has_trailer', type: 'boolean', default: false })
|
||||
hasTrailer!: boolean;
|
||||
|
||||
@Column({ name: 'description', type: 'text', nullable: true })
|
||||
description?: string | null;
|
||||
|
||||
@Column({ name: 'is_active', type: 'boolean', default: true })
|
||||
isActive!: boolean;
|
||||
}
|
||||
@@ -0,0 +1,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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { TruckType } from './entities/truck-type.entity';
|
||||
import { TruckTypesController } from './truck-types.controller';
|
||||
import { TruckTypesRepository } from './truck-types.repository';
|
||||
import { TruckTypesService } from './truck-types.service';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([TruckType])],
|
||||
controllers: [TruckTypesController],
|
||||
providers: [TruckTypesRepository, TruckTypesService],
|
||||
exports: [TruckTypesRepository, TruckTypesService],
|
||||
})
|
||||
export class TruckTypesModule {}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { BaseRepository } from '@edr/api-common';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { TruckType } from './entities/truck-type.entity';
|
||||
|
||||
@Injectable()
|
||||
export class TruckTypesRepository extends BaseRepository<TruckType> {
|
||||
constructor(
|
||||
@InjectRepository(TruckType)
|
||||
repository: Repository<TruckType>,
|
||||
) {
|
||||
super(repository);
|
||||
}
|
||||
|
||||
findByCode(code: string): Promise<TruckType | null> {
|
||||
return this.repository.findOne({ where: { code } });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { FindOptionsOrder } from 'typeorm';
|
||||
|
||||
import { CreateTruckTypeDto } from './dto/create-truck-type.dto';
|
||||
import { UpdateTruckTypeDto } from './dto/update-truck-type.dto';
|
||||
import { TruckType } from './entities/truck-type.entity';
|
||||
import { TruckTypesRepository } from './truck-types.repository';
|
||||
|
||||
type TruckTypeListFilter = {
|
||||
isActive?: boolean;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
sortBy?: string;
|
||||
sortOrder?: string;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class TruckTypesService {
|
||||
constructor(private readonly truckTypesRepository: TruckTypesRepository) {}
|
||||
|
||||
async findAll(filter: TruckTypeListFilter = {}): Promise<{
|
||||
data: TruckType[];
|
||||
meta: { total: number; page: number; pageSize: number; totalPages: number };
|
||||
}> {
|
||||
const page = filter.page ?? 1;
|
||||
const pageSize = filter.pageSize ?? 500;
|
||||
const sortBy = ['code', 'name', 'capacityTons', 'hasTrailer', 'isActive'].includes(
|
||||
filter.sortBy ?? '',
|
||||
)
|
||||
? (filter.sortBy as keyof TruckType)
|
||||
: 'code';
|
||||
const sortOrder = filter.sortOrder?.toUpperCase() === 'DESC' ? 'DESC' : 'ASC';
|
||||
|
||||
const [data, total] = await this.truckTypesRepository.findAndCount({
|
||||
where: filter.isActive === undefined ? {} : { isActive: filter.isActive },
|
||||
order: { [sortBy]: sortOrder } as FindOptionsOrder<TruckType>,
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
});
|
||||
|
||||
return {
|
||||
data,
|
||||
meta: {
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
totalPages: Math.max(1, Math.ceil(total / pageSize)),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<TruckType> {
|
||||
const truckType = await this.truckTypesRepository.findById(id);
|
||||
|
||||
if (!truckType) {
|
||||
throw new NotFoundException(`Truck type ${id} not found`);
|
||||
}
|
||||
|
||||
return truckType;
|
||||
}
|
||||
|
||||
async findByCode(code: string): Promise<TruckType> {
|
||||
const truckType = await this.truckTypesRepository.findByCode(code);
|
||||
if (!truckType) {
|
||||
throw new NotFoundException(`Truck type ${code} not found`);
|
||||
}
|
||||
return truckType;
|
||||
}
|
||||
|
||||
async create(dto: CreateTruckTypeDto): Promise<TruckType> {
|
||||
const code = dto.code.trim().toUpperCase();
|
||||
const existing = await this.truckTypesRepository.findByCode(code);
|
||||
|
||||
if (existing) {
|
||||
throw new ConflictException(`Truck type code "${code}" already exists`);
|
||||
}
|
||||
|
||||
return this.truckTypesRepository.create({
|
||||
code,
|
||||
name: dto.name.trim(),
|
||||
capacityTons: dto.capacityTons ?? null,
|
||||
hasTrailer: dto.hasTrailer ?? false,
|
||||
description: dto.description?.trim() ?? null,
|
||||
isActive: dto.isActive ?? true,
|
||||
});
|
||||
}
|
||||
|
||||
async update(id: string, dto: UpdateTruckTypeDto): Promise<TruckType> {
|
||||
const truckType = await this.findById(id);
|
||||
const nextCode = dto.code?.trim().toUpperCase();
|
||||
|
||||
if (nextCode && nextCode !== truckType.code) {
|
||||
const existing = await this.truckTypesRepository.findByCode(nextCode);
|
||||
if (existing) {
|
||||
throw new ConflictException(`Truck type code "${nextCode}" already exists`);
|
||||
}
|
||||
}
|
||||
|
||||
const updated = await this.truckTypesRepository.update(id, {
|
||||
...dto,
|
||||
...(nextCode ? { code: nextCode } : {}),
|
||||
...(dto.name ? { name: dto.name.trim() } : {}),
|
||||
});
|
||||
|
||||
if (!updated) {
|
||||
throw new NotFoundException(`Truck type ${id} not found`);
|
||||
}
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
async remove(id: string): Promise<void> {
|
||||
await this.findById(id);
|
||||
await this.truckTypesRepository.softDelete(id);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,11 @@
|
||||
import { IsString, IsEnum, IsNumber, IsOptional, IsUUID, Matches } from 'class-validator';
|
||||
import { Transform } from 'class-transformer';
|
||||
import { VehicleType, FuelType, VehicleStatus, VehicleAvailability } from '../entities/vehicle.entity';
|
||||
import {
|
||||
FuelType,
|
||||
VehicleAvailability,
|
||||
VehicleOwnership,
|
||||
VehicleStatus,
|
||||
} from '../entities/vehicle.entity';
|
||||
|
||||
/**
|
||||
* A vehicle plate is two or three letters, a hyphen, then two to six digits —
|
||||
@@ -28,8 +33,9 @@ export class CreateVehicleDto {
|
||||
@IsString()
|
||||
plateNumber!: string;
|
||||
|
||||
@IsEnum(VehicleType)
|
||||
vehicleType!: VehicleType;
|
||||
/** Truck configuration from `freight.truck_types` — drives capacity and whether a trailer plate applies. */
|
||||
@IsUUID()
|
||||
truckTypeId!: string;
|
||||
|
||||
@IsString()
|
||||
manufacturer!: string;
|
||||
@@ -43,8 +49,18 @@ export class CreateVehicleDto {
|
||||
@IsEnum(FuelType)
|
||||
fuelType!: FuelType;
|
||||
|
||||
/** Defaults to the truck type's capacity when omitted. */
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
capacity!: number;
|
||||
capacity?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
vin?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(VehicleOwnership)
|
||||
ownership?: VehicleOwnership;
|
||||
|
||||
@IsEnum(VehicleStatus)
|
||||
status!: VehicleStatus;
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
import { Entity, Column } from 'typeorm';
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
|
||||
/**
|
||||
* Legacy classification. Truck configurations are now back-office data in
|
||||
* `freight.truck_types` — register a vehicle with `truckTypeId`, not this.
|
||||
*
|
||||
* The `vehicle_type` COLUMN survives as a denormalised copy of the truck type's
|
||||
* code because truck-detention billing groups by it in raw SQL and matches it
|
||||
* against `warehouse_fee_rules.vehicle_type`. The service writes it through on
|
||||
* every save; nothing should set it by hand.
|
||||
*
|
||||
* @deprecated use `truckTypeId` / `freight.truck_types`
|
||||
*/
|
||||
export enum VehicleType {
|
||||
TRUCK = 'TRUCK',
|
||||
VAN = 'VAN',
|
||||
@@ -11,6 +22,12 @@ export enum VehicleType {
|
||||
FLATBED = 'FLATBED',
|
||||
}
|
||||
|
||||
/** Who supplies the truck. Supplier selection is deferred until EDR commits to outsourcing. */
|
||||
export enum VehicleOwnership {
|
||||
OWNED = 'OWNED',
|
||||
OUTSOURCED = 'OUTSOURCED',
|
||||
}
|
||||
|
||||
export enum FuelType {
|
||||
PETROL = 'PETROL',
|
||||
DIESEL = 'DIESEL',
|
||||
@@ -47,8 +64,12 @@ export class Vehicle extends BaseEntity {
|
||||
@Column({ name: 'registration_number', unique: true, nullable: true })
|
||||
registrationNumber?: string;
|
||||
|
||||
/** Denormalised `truck_types.code` — written through by the service, never set by hand. */
|
||||
@Column({ name: 'vehicle_type', type: 'varchar', nullable: true })
|
||||
vehicleType?: VehicleType;
|
||||
vehicleType?: string;
|
||||
|
||||
@Column({ name: 'truck_type_id', type: 'uuid', nullable: true })
|
||||
truckTypeId?: string | null;
|
||||
|
||||
@Column({ nullable: true })
|
||||
manufacturer?: string;
|
||||
@@ -101,7 +122,7 @@ export class Vehicle extends BaseEntity {
|
||||
@Column({ name: 'vin', type: 'varchar', nullable: true })
|
||||
vin?: string;
|
||||
|
||||
/** Owned | Leased | Rented */
|
||||
/** OWNED | OUTSOURCED — see {@link VehicleOwnership}. */
|
||||
@Column({ name: 'ownership', type: 'varchar', nullable: true })
|
||||
ownership?: string;
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ describe('VehiclesService driver assignment guard', () => {
|
||||
new VehiclesService(
|
||||
{ findOne, create: jest.fn((x) => x), save: jest.fn(async (x) => x) } as any,
|
||||
{ record: jest.fn() } as any,
|
||||
{ findById: jest.fn(async () => ({ code: 'TRUCK', name: 'Truck', hasTrailer: true })) } as any,
|
||||
);
|
||||
|
||||
it('rejects create when the driver is on another truck', async () => {
|
||||
@@ -18,7 +19,7 @@ describe('VehiclesService driver assignment guard', () => {
|
||||
const findOne = jest.fn().mockResolvedValueOnce(null).mockResolvedValueOnce(otherTruck);
|
||||
const svc = makeService(findOne);
|
||||
await expect(
|
||||
svc.create({ plateNumber: '3-22222', vehicleType: 'TRUCK', assignedDriverId: 'd1' } as any),
|
||||
svc.create({ plateNumber: '3-22222', truckTypeId: 'tt1', assignedDriverId: 'd1' } as any),
|
||||
).rejects.toThrow(ConflictException);
|
||||
});
|
||||
|
||||
|
||||
@@ -3,9 +3,10 @@ import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { Vehicle } from './entities/vehicle.entity';
|
||||
import { VehiclesService } from './vehicles.service';
|
||||
import { VehiclesController } from './vehicles.controller';
|
||||
import { TruckTypesModule } from '../truck-types/truck-types.module';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Vehicle])],
|
||||
imports: [TypeOrmModule.forFeature([Vehicle]), TruckTypesModule],
|
||||
providers: [VehiclesService],
|
||||
controllers: [VehiclesController],
|
||||
exports: [VehiclesService],
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Not, Repository } from 'typeorm';
|
||||
import { CreateVehicleDto } from './dto/create-vehicle.dto';
|
||||
import { UpdateVehicleDto } from './dto/update-vehicle.dto';
|
||||
import { Vehicle, VehicleAvailability, VehicleStatus } from './entities/vehicle.entity';
|
||||
import { TruckType } from '../truck-types/entities/truck-type.entity';
|
||||
import { TruckTypesService } from '../truck-types/truck-types.service';
|
||||
import { FirstMile, FirstMileStatus } from '../first-mile/entities/first-mile.entity';
|
||||
import { FirstMileContainerAllocation } from '../first-mile/entities/first-mile-container-allocation.entity';
|
||||
import { LastMile, LastMileStatus } from '../last-mile/entities/last-mile.entity';
|
||||
@@ -18,8 +25,26 @@ export class VehiclesService {
|
||||
@InjectRepository(Vehicle)
|
||||
private readonly vehicleRepo: Repository<Vehicle>,
|
||||
private readonly history: FleetHistoryService,
|
||||
private readonly truckTypes: TruckTypesService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* A trailer plate only exists on a configuration that pulls a trailer — a
|
||||
* rigid truck (Casoni) has none. Checked against the RESULTING record, not
|
||||
* just the patch, so switching an articulated truck to a rigid type cannot
|
||||
* leave its old trailer plate stranded on the row.
|
||||
*/
|
||||
private assertTrailerPlateAllowed(
|
||||
truckType: TruckType,
|
||||
trailerPlateNo?: string | null,
|
||||
): void {
|
||||
if (!truckType.hasTrailer && trailerPlateNo) {
|
||||
throw new BadRequestException(
|
||||
`${truckType.name} has no trailer — remove the trailer plate number`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A driver holds one truck at a time — reassignment requires detaching them
|
||||
* from their current truck first.
|
||||
@@ -54,10 +79,17 @@ export class VehiclesService {
|
||||
await this.assertDriverUnassigned(dto.assignedDriverId);
|
||||
}
|
||||
|
||||
const registrationNumber = `REG-${dto.vehicleType}-${Date.now()}`;
|
||||
const truckType = await this.truckTypes.findById(dto.truckTypeId);
|
||||
this.assertTrailerPlateAllowed(truckType, dto.trailerPlateNo);
|
||||
|
||||
const registrationNumber = `REG-${truckType.code}-${Date.now()}`;
|
||||
const vehicle = this.vehicleRepo.create({
|
||||
...dto,
|
||||
registrationNumber,
|
||||
// Denormalised for truck-detention billing, which groups on this column.
|
||||
vehicleType: truckType.code,
|
||||
// Capacity belongs to the type; an explicit value still wins for one-offs.
|
||||
capacity: dto.capacity ?? truckType.capacityTons ?? undefined,
|
||||
});
|
||||
|
||||
const saved = await this.vehicleRepo.save(vehicle);
|
||||
@@ -148,6 +180,17 @@ export class VehiclesService {
|
||||
await this.assertDriverUnassigned(dto.assignedDriverId, id);
|
||||
}
|
||||
|
||||
// Re-resolve the truck type whenever the type OR the trailer plate moves —
|
||||
// either edit can produce a rigid truck holding a trailer plate.
|
||||
const nextTruckTypeId = dto.truckTypeId ?? vehicle.truckTypeId;
|
||||
let nextTruckType: TruckType | null = null;
|
||||
if (nextTruckTypeId && (dto.truckTypeId !== undefined || dto.trailerPlateNo !== undefined)) {
|
||||
nextTruckType = await this.truckTypes.findById(nextTruckTypeId);
|
||||
const nextTrailerPlate =
|
||||
dto.trailerPlateNo !== undefined ? dto.trailerPlateNo : vehicle.trailerPlateNo;
|
||||
this.assertTrailerPlateAllowed(nextTruckType, nextTrailerPlate);
|
||||
}
|
||||
|
||||
const prev = {
|
||||
assignedDriverId: vehicle.assignedDriverId,
|
||||
assignedDriverName: vehicle.assignedDriverName,
|
||||
@@ -156,6 +199,11 @@ export class VehiclesService {
|
||||
};
|
||||
|
||||
Object.assign(vehicle, dto);
|
||||
// After the patch is applied, so the denormalised billing code always
|
||||
// reflects the type the vehicle actually ends up on.
|
||||
if (nextTruckType) {
|
||||
vehicle.vehicleType = nextTruckType.code;
|
||||
}
|
||||
const saved = await this.vehicleRepo.save(vehicle);
|
||||
|
||||
// Driver (re)assignment — emit an unassign for the old driver and/or an
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
|
||||
import { VehiclesService } from './vehicles.service';
|
||||
|
||||
// A trailer plate only exists on a configuration that pulls a trailer. A rigid
|
||||
// truck (Casoni) has none, so registering or editing one into a trailer plate
|
||||
// must be refused server-side — the form hiding the field is not enforcement.
|
||||
describe('VehiclesService trailer plate guard', () => {
|
||||
const CASONI = { code: 'CASONI', name: 'Casoni (rigid, no trailer)', hasTrailer: false, capacityTons: 30 };
|
||||
const ARTIC = { code: 'TRUCK', name: 'Truck', hasTrailer: true, capacityTons: 40 };
|
||||
|
||||
const makeService = (findOne: jest.Mock, truckType: unknown) => {
|
||||
const save = jest.fn(async (x) => x);
|
||||
const svc = new VehiclesService(
|
||||
{ findOne, create: jest.fn((x) => x), save } as any,
|
||||
{ record: jest.fn() } as any,
|
||||
{ findById: jest.fn(async () => truckType) } as any,
|
||||
);
|
||||
return { svc, save };
|
||||
};
|
||||
|
||||
it('rejects creating a rigid truck that carries a trailer plate', async () => {
|
||||
const findOne = jest.fn().mockResolvedValueOnce(null); // plate is free
|
||||
const { svc } = makeService(findOne, CASONI);
|
||||
await expect(
|
||||
svc.create({ plateNumber: 'ET-9875', truckTypeId: 'tt-casoni', trailerPlateNo: 'ET-1234' } as any),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('accepts a rigid truck with no trailer plate, and takes capacity from the type', async () => {
|
||||
const findOne = jest.fn().mockResolvedValueOnce(null);
|
||||
const { svc } = makeService(findOne, CASONI);
|
||||
const saved = await svc.create({ plateNumber: 'ET-9875', truckTypeId: 'tt-casoni' } as any);
|
||||
expect(saved.capacity).toBe(30);
|
||||
// Denormalised code is what truck-detention billing groups on.
|
||||
expect(saved.vehicleType).toBe('CASONI');
|
||||
});
|
||||
|
||||
it('keeps an explicit capacity over the type default', async () => {
|
||||
const findOne = jest.fn().mockResolvedValueOnce(null);
|
||||
const { svc } = makeService(findOne, CASONI);
|
||||
const saved = await svc.create({
|
||||
plateNumber: 'ET-9875',
|
||||
truckTypeId: 'tt-casoni',
|
||||
capacity: 25,
|
||||
} as any);
|
||||
expect(saved.capacity).toBe(25);
|
||||
});
|
||||
|
||||
it('allows a trailer plate on an articulated type', async () => {
|
||||
const findOne = jest.fn().mockResolvedValueOnce(null);
|
||||
const { svc } = makeService(findOne, ARTIC);
|
||||
await expect(
|
||||
svc.create({ plateNumber: 'ET-9875', truckTypeId: 'tt-truck', trailerPlateNo: 'ET-1234' } as any),
|
||||
).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
// The regression that motivated validating the RESULT rather than the patch:
|
||||
// switching type alone leaves the stored trailer plate behind.
|
||||
it('rejects switching an existing truck to a rigid type while its trailer plate stands', async () => {
|
||||
const findOne = jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ id: 'v1', plateNumber: 'ET-9875', trailerPlateNo: 'ET-1234' });
|
||||
const { svc } = makeService(findOne, CASONI);
|
||||
await expect(svc.update('v1', { truckTypeId: 'tt-casoni' } as any)).rejects.toThrow(
|
||||
BadRequestException,
|
||||
);
|
||||
});
|
||||
|
||||
it('allows the switch when the trailer plate is cleared in the same edit', async () => {
|
||||
const findOne = jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ id: 'v1', plateNumber: 'ET-9875', trailerPlateNo: 'ET-1234' });
|
||||
const { svc } = makeService(findOne, CASONI);
|
||||
const saved = await svc.update('v1', {
|
||||
truckTypeId: 'tt-casoni',
|
||||
trailerPlateNo: null,
|
||||
} as any);
|
||||
expect(saved.vehicleType).toBe('CASONI');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 }];
|
||||
|
||||
@@ -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) {}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {}
|
||||
|
||||
|
||||
@@ -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})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
]),
|
||||
|
||||
Reference in New Issue
Block a user