mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 03:10:54 +00:00
Merge branch 'dev' of github.com:Tria-plc/edr-platform into freight_feature/usermanagement
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user