last mile

This commit is contained in:
natib21
2026-06-22 07:58:56 +00:00
parent bee47b231a
commit 6b1bbd8e59
9 changed files with 445 additions and 0 deletions

View File

@@ -0,0 +1,60 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsIn, IsNumber, IsOptional, IsUUID, Min } from 'class-validator';
import { LAST_MILE_STATUSES, LastMileStatus } from '../entities/last-mile.entity';
const toNumber = ({ value }: { value: unknown }) =>
value === '' || value == null ? undefined : Number(value);
export class CreateLastMileDto {
@ApiProperty({ description: 'Booking this last-mile leg belongs to (FK → bookings.id)' })
@IsUUID()
bookingId!: string;
@ApiPropertyOptional({
enum: LAST_MILE_STATUSES,
default: 'PAYMENT_PENDING',
})
@IsOptional()
@IsIn(LAST_MILE_STATUSES as unknown as string[])
status?: LastMileStatus;
@ApiPropertyOptional({ description: 'Amount already paid in advance', example: 4200 })
@IsOptional()
@Transform(toNumber)
@IsNumber()
@Min(0)
advancedPayment?: number;
@ApiPropertyOptional({ description: 'Outstanding balance to be collected', example: 1800 })
@IsOptional()
@Transform(toNumber)
@IsNumber()
@Min(0)
remainingPayment?: number;
@ApiPropertyOptional({ description: 'Planned distance for the leg, in km', example: 42.5 })
@IsOptional()
@Transform(toNumber)
@IsNumber()
@Min(0)
estimatedKm?: number;
@ApiPropertyOptional({ description: 'Actual distance travelled, in km', example: 44.1 })
@IsOptional()
@Transform(toNumber)
@IsNumber()
@Min(0)
exactKm?: number;
@ApiPropertyOptional({
type: String,
format: 'uuid',
description: 'Assigned vehicle (FK → vehicles.id). May be null until assigned.',
nullable: true,
})
@IsOptional()
@IsUUID()
vehicleId?: string | null;
}

View File

@@ -0,0 +1,5 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateLastMileDto } from './create-last-mile.dto';
export class UpdateLastMileDto extends PartialType(CreateLastMileDto) {}

View File

@@ -0,0 +1,49 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { Booking } from '../../bookings/entities/booking.entity';
import { Vehicle } from '../../vehicles/entities/vehicle.entity';
export const LAST_MILE_STATUSES = [
'PAYMENT_PENDING',
'READY_TO_TRANSIT',
'IN_TRANSIT',
'DELIVERED',
] as const;
export type LastMileStatus = (typeof LAST_MILE_STATUSES)[number];
@Entity({ name: 'last_mile', schema: 'freight' })
@Index(['bookingId'])
@Index(['status'])
@Index(['vehicleId'])
export class LastMile extends BaseEntity {
@Column({ name: 'booking_id', type: 'uuid' })
bookingId!: string;
@ManyToOne(() => Booking, { nullable: false, eager: false })
@JoinColumn({ name: 'booking_id' })
booking?: Booking;
@Column({ name: 'status', type: 'varchar', length: 30, default: 'PAYMENT_PENDING' })
status!: LastMileStatus;
@Column({ name: 'advanced_payment', type: 'numeric', precision: 14, scale: 2, default: 0 })
advancedPayment!: number;
@Column({ name: 'remaining_payment', type: 'numeric', precision: 14, scale: 2, default: 0 })
remainingPayment!: number;
@Column({ name: 'estimated_km', type: 'numeric', precision: 10, scale: 2, nullable: true })
estimatedKm?: number | null;
@Column({ name: 'exact_km', type: 'numeric', precision: 10, scale: 2, nullable: true })
exactKm?: number | null;
@Column({ name: 'vehicle_id', type: 'uuid', nullable: true })
vehicleId?: string | null;
@ManyToOne(() => Vehicle, { nullable: true, eager: false })
@JoinColumn({ name: 'vehicle_id' })
vehicle?: Vehicle | null;
}

View File

@@ -0,0 +1,79 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
ParseUUIDPipe,
Patch,
Post,
Query,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { TrainSchedulingManage, TrainSchedulingView } from '../../common/booking-guards';
import { CreateLastMileDto } from './dto/create-last-mile.dto';
import { UpdateLastMileDto } from './dto/update-last-mile.dto';
import { LastMileStatus } from './entities/last-mile.entity';
import { LastMileService } from './last-mile.service';
@ApiTags('last-mile')
@ApiBearerAuth()
@Controller('last-mile')
@TrainSchedulingView()
export class LastMileController {
constructor(private readonly lastMileService: LastMileService) {}
@Get()
@ApiOperation({ summary: 'List last-mile legs' })
findAll(
@Query('status') status?: string,
@Query('bookingId') bookingId?: string,
@Query('vehicleId') vehicleId?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
@Query('sortBy') sortBy?: string,
@Query('sortOrder') sortOrder?: 'ASC' | 'DESC',
) {
return this.lastMileService.findAll({
status: status as LastMileStatus | undefined,
bookingId,
vehicleId,
page: page ? parseInt(page, 10) : undefined,
pageSize: pageSize ? parseInt(pageSize, 10) : undefined,
sortBy,
sortOrder,
});
}
@Get(':id')
@ApiOperation({ summary: 'Get a last-mile leg by ID' })
findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.lastMileService.findById(id);
}
@Post()
@TrainSchedulingManage()
@ApiOperation({ summary: 'Create a last-mile leg' })
create(@Body() dto: CreateLastMileDto) {
return this.lastMileService.create(dto);
}
@Patch(':id')
@TrainSchedulingManage()
@ApiOperation({ summary: 'Update a last-mile leg' })
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateLastMileDto) {
return this.lastMileService.update(id, dto);
}
@Delete(':id')
@TrainSchedulingManage()
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Soft-delete a last-mile leg' })
remove(@Param('id', ParseUUIDPipe) id: string) {
return this.lastMileService.remove(id);
}
}

View File

@@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { LastMile } from './entities/last-mile.entity';
import { LastMileController } from './last-mile.controller';
import { LastMileRepository } from './last-mile.repository';
import { LastMileService } from './last-mile.service';
@Module({
imports: [TypeOrmModule.forFeature([LastMile])],
controllers: [LastMileController],
providers: [LastMileRepository, LastMileService],
exports: [LastMileRepository, LastMileService],
})
export class LastMileModule {}

View File

@@ -0,0 +1,16 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { BaseRepository } from '@edr/api-common';
import { LastMile } from './entities/last-mile.entity';
@Injectable()
export class LastMileRepository extends BaseRepository<LastMile> {
constructor(
@InjectRepository(LastMile)
repository: Repository<LastMile>,
) {
super(repository);
}
}

View File

@@ -0,0 +1,113 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { FindOptionsWhere } from 'typeorm';
import { CreateLastMileDto } from './dto/create-last-mile.dto';
import { UpdateLastMileDto } from './dto/update-last-mile.dto';
import { LastMile, LastMileStatus } from './entities/last-mile.entity';
import { LastMileRepository } from './last-mile.repository';
type LastMileListFilter = {
status?: LastMileStatus;
bookingId?: string;
vehicleId?: string;
page?: number;
pageSize?: number;
sortBy?: string;
sortOrder?: string;
};
const SORTABLE_FIELDS: (keyof LastMile)[] = [
'status',
'advancedPayment',
'remainingPayment',
'createdAt',
];
@Injectable()
export class LastMileService {
constructor(private readonly lastMileRepository: LastMileRepository) {}
async findAll(filter: LastMileListFilter = {}): Promise<{
data: LastMile[];
meta: { total: number; page: number; pageSize: number; totalPages: number };
}> {
const page = filter.page ?? 1;
const pageSize = filter.pageSize ?? 50;
const sortBy = SORTABLE_FIELDS.includes(filter.sortBy as keyof LastMile)
? (filter.sortBy as keyof LastMile)
: 'createdAt';
const sortOrder = filter.sortOrder?.toUpperCase() === 'ASC' ? 'ASC' : 'DESC';
const where: FindOptionsWhere<LastMile> = {};
if (filter.status) where.status = filter.status;
if (filter.bookingId) where.bookingId = filter.bookingId;
if (filter.vehicleId) where.vehicleId = filter.vehicleId;
const [data, total] = await this.lastMileRepository.findAndCount({
where,
relations: { booking: true, vehicle: true },
order: { [sortBy]: sortOrder },
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<LastMile> {
const record = await this.lastMileRepository.findById(id, {
relations: { booking: true, vehicle: true },
});
if (!record) {
throw new NotFoundException(`Last-mile record ${id} not found`);
}
return record;
}
async create(dto: CreateLastMileDto): Promise<LastMile> {
return this.lastMileRepository.create({
bookingId: dto.bookingId,
status: dto.status ?? 'PAYMENT_PENDING',
advancedPayment: dto.advancedPayment ?? 0,
remainingPayment: dto.remainingPayment ?? 0,
estimatedKm: dto.estimatedKm ?? null,
exactKm: dto.exactKm ?? null,
vehicleId: dto.vehicleId ?? null,
});
}
async update(id: string, dto: UpdateLastMileDto): Promise<LastMile> {
await this.findById(id);
const updated = await this.lastMileRepository.update(id, {
...(dto.bookingId !== undefined ? { bookingId: dto.bookingId } : {}),
...(dto.status !== undefined ? { status: dto.status } : {}),
...(dto.advancedPayment !== undefined ? { advancedPayment: dto.advancedPayment } : {}),
...(dto.remainingPayment !== undefined ? { remainingPayment: dto.remainingPayment } : {}),
...(dto.estimatedKm !== undefined ? { estimatedKm: dto.estimatedKm } : {}),
...(dto.exactKm !== undefined ? { exactKm: dto.exactKm } : {}),
...(dto.vehicleId !== undefined ? { vehicleId: dto.vehicleId } : {}),
});
if (!updated) {
throw new NotFoundException(`Last-mile record ${id} not found`);
}
return updated;
}
async remove(id: string): Promise<void> {
await this.findById(id);
await this.lastMileRepository.softDelete(id);
}
}