Merge branch 'dev' of github.com:Tria-plc/edr-platform into freight_feature/usermanagement

This commit is contained in:
marshal
2026-07-03 11:28:55 +03:00
28 changed files with 585 additions and 166 deletions

View File

@@ -33,14 +33,14 @@ import { BookingContractSignature } from './entities/booking-contract-signature.
import { BookingReviewNote } from './entities/booking-review-note.entity';
import { Booking } from './entities/booking.entity';
import { ContractPdfService } from '../../contracts/contract-pdf.service';
import { ContractPricingScheduleBuilder } from '../../contracts/contract-pricing-schedule.builder';
import { ContractRendererService } from '../../contracts/contract-renderer.service';
import { ContractTemplateResolver } from '../../contracts/contract-template.resolver';
import { ContractViewModelBuilder } from '../../contracts/contract-view-model.builder';
import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module';
import { ContractsModule } from '../contracts/contracts.module';
import { BookingContainerAllocation } from "./entities/booking-container-allocation.entity";
import { ContractPricingScheduleBuilder } from "../../contracts/contract-pricing-schedule.builder";
import { ContractRendererService } from "../../contracts/contract-renderer.service";
import { ContractTemplateResolver } from "../../contracts/contract-template.resolver";
import { ContractViewModelBuilder } from "../../contracts/contract-view-model.builder";
import { TrainSchedulingModule } from "../train-scheduling/train-scheduling.module";
import { VehiclesModule } from "../vehicles/vehicles.module";
@Module({
imports: [
@@ -62,6 +62,7 @@ import { BookingContainerAllocation } from "./entities/booking-container-allocat
forwardRef(() => ContractsModule),
FilesModule,
MinioModule,
VehiclesModule,
CompaniesModule,
// CustomersModule,
RuleEngineModule,

View File

@@ -31,6 +31,8 @@ import { ServiceType } from '../rule-engine/entities/service-type.entity';
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
import { BookingsRepository } from './bookings.repository';
import { ConsolidationService } from './consolidation.service';
import { VehiclesService } from '../vehicles/vehicles.service';
import { VehicleAvailability } from '../vehicles/entities/vehicle.entity';
import { assertFreightShape } from './booking-freight.util';
import { CreateBookingContainerDto, CreateBookingDto } from './dto/create-booking.dto';
import { mapStatusCountsToTabs } from './booking-list-tabs.config';
@@ -94,6 +96,7 @@ export class BookingsService {
private readonly ruleEngineService: RuleEngineService,
private readonly containerTypesService: ContainerTypesService,
private readonly consolidationService: ConsolidationService,
private readonly vehiclesService: VehiclesService,
private readonly contractPdfService: ContractPdfService,
) {}
@@ -1520,6 +1523,16 @@ export class BookingsService {
throw new NotFoundException(`Booking ${bookingId} not found`);
}
const previousAllocations = await this.dataSource.manager.find(BookingContainerAllocation, {
where: {
bookingId,
containerId: In(allocations.map((a) => a.containerId)),
},
});
const previousVehicleIds = previousAllocations
.map((a) => a.vehicleId)
.filter((id): id is string => Boolean(id));
await this.dataSource.transaction(async (manager) => {
for (const allocation of allocations) {
await manager.delete(BookingContainerAllocation, {
@@ -1536,6 +1549,16 @@ export class BookingsService {
}
});
const vehicleIds = new Set(allocations.map((a) => a.vehicleId));
await Promise.all(
[...vehicleIds].map((vehicleId) =>
this.vehiclesService.setAvailability(vehicleId, VehicleAvailability.BUSY),
),
);
await this.vehiclesService.releaseIfUnused(
previousVehicleIds.filter((id) => !vehicleIds.has(id)),
);
return {
success: true,
allocated: allocations.length,

View File

@@ -48,12 +48,10 @@ export class DriversService {
const qb = this.driverRepo.createQueryBuilder('d');
if (query.search) {
const searchTerm = `%${query.search}%`;
qb.where('d.firstName ILIKE :search', { search: searchTerm })
.orWhere('d.lastName ILIKE :search', { search: searchTerm })
.orWhere('d.email ILIKE :search', { search: searchTerm })
.orWhere('d.licenseNumber ILIKE :search', { search: searchTerm })
.orWhere('d.phoneNumber ILIKE :search', { search: searchTerm });
qb.where(
'(d.firstName ILIKE :search OR d.lastName ILIKE :search OR d.email ILIKE :search OR d.licenseNumber ILIKE :search OR d.phoneNumber ILIKE :search)',
{ search: `%${query.search}%` },
);
}
if (query.status) {

View File

@@ -65,6 +65,12 @@ export class FirstMileController {
return this.firstMileService.findById(id);
}
@Get('acceptitem/:id')
@ApiOperation({ summary: 'Get a first-mile accep by ID' })
acceptItem(@Param('id', ParseUUIDPipe) id: string) {
return this.firstMileService.acceptBooking(id);
}
@Post('accept/:reference')
@TrainSchedulingManage()
@ApiOperation({ summary: 'Accept a paid booking and create a first-mile leg' })

View File

@@ -1,8 +1,8 @@
import { Injectable, Logger, NotFoundException } from "@nestjs/common";
import { FindOptionsWhere } from "typeorm";
import { InjectDataSource } from "@nestjs/typeorm";
import { DataSource } from "typeorm";
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { FindOptionsWhere, In } from 'typeorm';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { VehicleAvailability } from '../vehicles/entities/vehicle.entity';
import { BookingsRepository } from "../bookings/bookings.repository";
import { DriversService } from "../drivers/drivers.service";
import { SmsClientService } from "../notifications/sms-client.service";
@@ -95,7 +95,6 @@ export class FirstMileService {
if (booking.paymentStatus !== "PAID") {
return null;
}
if (!this.bookingRequestsFirstMile(booking)) {
return null;
}
@@ -198,7 +197,7 @@ export class FirstMileService {
return existing;
}
return this.firstMileRepository.create({
const record = await this.firstMileRepository.create({
bookingId: dto.bookingId,
status: dto.status ?? "READY_TO_TRANSIT",
advancedPayment: dto.advancedPayment ?? 0,
@@ -208,6 +207,12 @@ export class FirstMileService {
vehicleId: dto.vehicleId ?? null,
paid: (dto as any).paid ?? false,
});
if (dto.vehicleId) {
await this.vehiclesService.setAvailability(dto.vehicleId, VehicleAvailability.BUSY);
}
return record;
}
private async findByBookingId(bookingId: string): Promise<FirstMile | null> {
@@ -233,12 +238,10 @@ export class FirstMileService {
firstMilePickupAddress?: string | null;
serviceType?: { includesFirstMile?: boolean | null } | null;
}): boolean {
// Export bookings always need a first mile (pickup → origin yard); the
// pickup address is captured at assignment time, not required upfront.
return Boolean(
booking.tradeDirection === "EXPORT" ||
booking.firstMilePickupAddress?.trim() ||
booking.serviceType?.includesFirstMile,
booking.tradeDirection === 'EXPORT' &&
(booking.firstMilePickupAddress?.trim() ||
booking.serviceType?.includesFirstMile),
);
}
@@ -267,28 +270,62 @@ export class FirstMileService {
throw new NotFoundException(`First-mile record ${id} not found`);
}
// Keep vehicle statuses in sync: new vehicle goes BUSY, replaced one goes back to FREE
if (dto.vehicleId !== undefined && dto.vehicleId !== existing.vehicleId) {
if (dto.vehicleId) {
await this.vehiclesService.setAvailability(dto.vehicleId, VehicleAvailability.BUSY);
}
if (existing.vehicleId) {
await this.vehiclesService.releaseIfUnused([existing.vehicleId]);
}
}
// Notify assigned driver on every explicit vehicle assignment or reassignment
if (dto.vehicleId) {
void this.notifyDriverAssignment(dto.vehicleId, existing);
}
// Trip finished — release the vehicles it was holding
if (dto.status === 'RECEIVED_TO_PORT' && existing.status !== 'RECEIVED_TO_PORT') {
await this.releaseVehicles(updated);
}
return updated;
}
async updateStatus(id: string, status: FirstMileStatus): Promise<FirstMile> {
const existing = await this.findById(id);
const updated = await this.firstMileRepository.update(id, { status });
if (!updated) {
throw new NotFoundException(`First-mile record ${id} not found`);
}
if (status === 'RECEIVED_TO_PORT' && existing.status !== 'RECEIVED_TO_PORT') {
await this.releaseVehicles(updated);
}
return updated;
}
private async notifyDriverAssignment(
vehicleId: string,
record: FirstMile,
): Promise<void> {
/**
* Free every vehicle held by this record (direct assignment + container
* allocations), unless still in use by another active trip.
*/
private async releaseVehicles(record: FirstMile): Promise<void> {
const recordAllocations = await this.dataSource.manager.find(FirstMileContainerAllocation, {
where: { firstMileId: record.id },
});
const vehicleIds = recordAllocations
.map((a) => a.vehicleId)
.filter((id): id is string => Boolean(id));
if (record.vehicleId) {
vehicleIds.push(record.vehicleId);
}
await this.vehiclesService.releaseIfUnused(vehicleIds);
}
private async notifyDriverAssignment(vehicleId: string, record: FirstMile): Promise<void> {
try {
const vehicle = await this.vehiclesService.findById(vehicleId);
if (!vehicle.assignedDriverId) {
@@ -359,6 +396,16 @@ export class FirstMileService {
throw new NotFoundException(`First-mile record ${firstMileId} not found`);
}
const previousAllocations = await this.dataSource.manager.find(FirstMileContainerAllocation, {
where: {
firstMileId,
containerId: In(allocations.map((a) => a.containerId)),
},
});
const previousVehicleIds = previousAllocations
.map((a) => a.vehicleId)
.filter((id): id is string => Boolean(id));
await this.dataSource.transaction(async (manager) => {
for (const allocation of allocations) {
await manager.delete(FirstMileContainerAllocation, {
@@ -375,6 +422,14 @@ export class FirstMileService {
}
});
const vehicleIds = new Set(allocations.map((a) => a.vehicleId));
await Promise.all(
[...vehicleIds].map((vehicleId) => this.vehiclesService.setAvailability(vehicleId, VehicleAvailability.BUSY)),
);
await this.vehiclesService.releaseIfUnused(
previousVehicleIds.filter((id) => !vehicleIds.has(id)),
);
return {
success: true,
allocated: allocations.length,

View File

@@ -71,6 +71,7 @@ export class LastMileService {
return null;
}
return this.create({
bookingId: booking.id,
advancedPayment: 0,
@@ -131,12 +132,6 @@ export class LastMileService {
}
async create(dto: CreateLastMileDto): Promise<LastMile> {
const [existing] = await this.lastMileRepository.findAll({
where: { bookingId: dto.bookingId },
take: 1,
});
if (existing) return existing;
return this.lastMileRepository.create({
bookingId: dto.bookingId,
status: dto.status ?? 'READY_TO_TRANSIT',

View File

@@ -1,5 +1,5 @@
import { IsString, IsEnum, IsNumber, IsOptional, IsUUID } from 'class-validator';
import { VehicleType, FuelType, VehicleStatus } from '../entities/vehicle.entity';
import { VehicleType, FuelType, VehicleStatus, VehicleAvailability } from '../entities/vehicle.entity';
export class CreateVehicleDto {
@IsString()
@@ -26,6 +26,10 @@ export class CreateVehicleDto {
@IsEnum(VehicleStatus)
status!: VehicleStatus;
@IsOptional()
@IsEnum(VehicleAvailability)
availability?: VehicleAvailability;
@IsOptional()
@IsString()
description?: string;
@@ -57,4 +61,8 @@ export class CreateVehicleDto {
@IsOptional()
@IsNumber()
actualDistanceKm?: number;
@IsOptional()
@IsUUID()
locationId?: string;
}

View File

@@ -25,11 +25,25 @@ export enum VehicleStatus {
OUT_OF_SERVICE = 'OUT_OF_SERVICE',
}
export enum VehicleAvailability {
FREE = 'FREE',
BUSY = 'BUSY',
}
@Entity({ name: 'vehicles', schema: 'freight' })
export class Vehicle extends BaseEntity {
@Column({ nullable: true })
code?: string;
@Column({ name: 'plate_number', unique: true, nullable: true })
plateNumber?: string;
@Column({ name: 'power_plate_no', nullable: true })
powerPlateNo?: string;
@Column({ name: 'trailer_plate_no', nullable: true })
trailerPlateNo?: string;
@Column({ name: 'registration_number', unique: true, nullable: true })
registrationNumber?: string;
@@ -54,6 +68,9 @@ export class Vehicle extends BaseEntity {
@Column({ name: 'status', type: 'varchar', default: VehicleStatus.ACTIVE, nullable: true })
status?: VehicleStatus;
@Column({ name: 'availability', type: 'varchar', default: VehicleAvailability.FREE, nullable: true })
availability?: VehicleAvailability;
@Column({ type: 'text', nullable: true })
description?: string | null;
@@ -68,4 +85,7 @@ export class Vehicle extends BaseEntity {
@Column({ name: 'actual_distance_km', type: 'numeric', nullable: true })
actualDistanceKm?: number;
@Column({ name: 'location_id', type: 'uuid', nullable: true })
locationId?: string;
}

View File

@@ -34,6 +34,7 @@ export class VehiclesController {
findAll(
@Query('search') search?: string,
@Query('status') status?: string,
@Query('availability') availability?: string,
@Query('page') page?: string,
@Query('limit') limit?: string,
@Query('sortBy') sortBy?: string,
@@ -42,6 +43,7 @@ export class VehiclesController {
return this.vehiclesService.findAll({
search,
status: status as any,
availability: availability as any,
page: page ? parseInt(page) : undefined,
limit: limit ? parseInt(limit) : undefined,
sortBy,

View File

@@ -1,9 +1,14 @@
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Not, Repository } from 'typeorm';
import { CreateVehicleDto } from './dto/create-vehicle.dto';
import { UpdateVehicleDto } from './dto/update-vehicle.dto';
import { Vehicle, VehicleStatus } from './entities/vehicle.entity';
import { Vehicle, VehicleAvailability, VehicleStatus } from './entities/vehicle.entity';
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';
import { LastMileContainerAllocation } from '../last-mile/entities/last-mile-container-allocation.entity';
import { BookingContainerAllocation } from '../bookings/entities/booking-container-allocation.entity';
@Injectable()
export class VehiclesService {
@@ -35,6 +40,7 @@ export class VehiclesService {
async findAll(query: {
search?: string;
status?: VehicleStatus | string;
availability?: VehicleAvailability | string;
page?: number;
limit?: number;
sortBy?: string;
@@ -44,7 +50,7 @@ export class VehiclesService {
if (query.search) {
qb = qb.where(
'v.plateNumber ILIKE :search OR v.manufacturer ILIKE :search',
'(v.plateNumber ILIKE :search OR v.manufacturer ILIKE :search OR v.model ILIKE :search OR v.code ILIKE :search OR v.trailerPlateNo ILIKE :search)',
{ search: `%${query.search}%` },
);
}
@@ -53,6 +59,10 @@ export class VehiclesService {
qb = qb.andWhere('v.status = :status', { status: query.status });
}
if (query.availability) {
qb = qb.andWhere('v.availability = :availability', { availability: query.availability });
}
const sortBy = ['plateNumber', 'status', 'year', 'createdAt'].includes(
query.sortBy ?? '',
)
@@ -91,6 +101,48 @@ export class VehiclesService {
return this.vehicleRepo.save(vehicle);
}
async setAvailability(id: string, availability: VehicleAvailability): Promise<void> {
await this.vehicleRepo.update(id, { availability });
}
/**
* Set vehicles back to FREE, but only when no active (non-completed)
* first/last-mile record or container allocation still references them.
* First-mile trips ending in RECEIVED_TO_PORT and last-mile trips ending
* in DELIVERED no longer hold the vehicle.
*/
async releaseIfUnused(vehicleIds: string[]): Promise<void> {
const manager = this.vehicleRepo.manager;
for (const vehicleId of [...new Set(vehicleIds)]) {
const [fmRecords, lmRecords, fmAllocations, lmAllocations, bookingAllocations] = await Promise.all([
manager.count(FirstMile, {
where: { vehicleId, status: Not<FirstMileStatus>('RECEIVED_TO_PORT') },
}),
manager.count(LastMile, {
where: { vehicleId, status: Not<LastMileStatus>('DELIVERED') },
}),
manager
.createQueryBuilder(FirstMileContainerAllocation, 'alloc')
.innerJoin(FirstMile, 'fm', 'fm.id = alloc.firstMileId')
.where('alloc.vehicleId = :vehicleId', { vehicleId })
.andWhere('fm.status != :done', { done: 'RECEIVED_TO_PORT' })
.andWhere('fm.deletedAt IS NULL')
.getCount(),
manager
.createQueryBuilder(LastMileContainerAllocation, 'alloc')
.innerJoin(LastMile, 'lm', 'lm.id = alloc.lastMileId')
.where('alloc.vehicleId = :vehicleId', { vehicleId })
.andWhere('lm.status != :done', { done: 'DELIVERED' })
.andWhere('lm.deletedAt IS NULL')
.getCount(),
manager.count(BookingContainerAllocation, { where: { vehicleId } }),
]);
if (fmRecords + lmRecords + fmAllocations + lmAllocations + bookingAllocations === 0) {
await this.setAvailability(vehicleId, VehicleAvailability.FREE);
}
}
}
async remove(id: string): Promise<void> {
await this.findById(id);
await this.vehicleRepo.softDelete(id);