mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 06:35:42 +00:00
Merge branch 'dev' of github.com:Tria-plc/edr-platform into freight/hot_fix
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
export class LastMileContainerAllocationDto {
|
||||
containerId!: string;
|
||||
vehicleId!: string;
|
||||
}
|
||||
|
||||
export class AllocateLastMileContainersDto {
|
||||
allocations!: LastMileContainerAllocationDto[];
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
||||
import { LastMile } from './last-mile.entity';
|
||||
import { Vehicle } from '../../vehicles/entities/vehicle.entity';
|
||||
|
||||
@Entity({ schema: 'freight', name: 'last_mile_container_allocations' })
|
||||
@Index(['lastMileId'])
|
||||
@Index(['vehicleId'])
|
||||
export class LastMileContainerAllocation extends BaseEntity {
|
||||
@ManyToOne(() => LastMile, (lm) => lm.containerAllocations)
|
||||
@JoinColumn({ name: 'last_mile_id' })
|
||||
lastMile!: LastMile;
|
||||
|
||||
@Column('uuid', { name: 'last_mile_id' })
|
||||
lastMileId!: string;
|
||||
|
||||
@Column('uuid', { name: 'container_id' })
|
||||
containerId!: string;
|
||||
|
||||
@ManyToOne(() => Vehicle)
|
||||
@JoinColumn({ name: 'vehicle_id' })
|
||||
vehicle?: Vehicle | null;
|
||||
|
||||
@Column('uuid', { name: 'vehicle_id', nullable: true })
|
||||
vehicleId?: string | null;
|
||||
|
||||
@Column('text')
|
||||
containerType!: string;
|
||||
|
||||
@Column('integer', { default: 1 })
|
||||
quantity!: number;
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm';
|
||||
|
||||
import { Booking } from '../../bookings/entities/booking.entity';
|
||||
import { Vehicle } from '../../vehicles/entities/vehicle.entity';
|
||||
import { LastMileContainerAllocation } from './last-mile-container-allocation.entity';
|
||||
|
||||
export const LAST_MILE_STATUSES = [
|
||||
'PAYMENT_PENDING',
|
||||
@@ -50,4 +51,7 @@ export class LastMile extends BaseEntity {
|
||||
@ManyToOne(() => Vehicle, { nullable: true, eager: false })
|
||||
@JoinColumn({ name: 'vehicle_id' })
|
||||
vehicle?: Vehicle | null;
|
||||
|
||||
@OneToMany(() => LastMileContainerAllocation, (ca) => ca.lastMile)
|
||||
containerAllocations?: LastMileContainerAllocation[];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { OnEvent } from '@nestjs/event-emitter';
|
||||
import { Freight } from '@edr/types';
|
||||
|
||||
import {
|
||||
BillingService,
|
||||
GenerateInvoiceInput,
|
||||
InvoiceEventPayload,
|
||||
} from '../billing/billing.service';
|
||||
import { Invoice } from '../billing/entities/invoice.entity';
|
||||
import { LastMileRepository } from './last-mile.repository';
|
||||
import { LastMile } from './entities/last-mile.entity';
|
||||
|
||||
/**
|
||||
* Owns the last-mile ⇄ invoice mapping — the one place that knows how a last-mile
|
||||
* record turns into invoices, which type to use, and how it advances when paid.
|
||||
* Last-mile records are billable business entities for delivery fees, so they
|
||||
* generate their own invoices directly via {@link BillingService}. All last-mile-specific
|
||||
* type branching lives here, at the two points it belongs: invoice creation and
|
||||
* settlement (the paid handler).
|
||||
*/
|
||||
@Injectable()
|
||||
export class LastMileInvoiceService {
|
||||
private readonly logger = new Logger(LastMileInvoiceService.name);
|
||||
|
||||
constructor(
|
||||
private readonly billing: BillingService,
|
||||
private readonly lastMileRepo: LastMileRepository,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Ensure the last-mile record has its invoice, generating one from the
|
||||
* remainingPayment if absent. Called when a last-mile record reaches a
|
||||
* billable state. Idempotent — returns the existing open invoice instead
|
||||
* of a duplicate. Returns `null` (and logs) when the record is not billable:
|
||||
* no company to bill (invoices FK requires a companyId).
|
||||
*/
|
||||
async ensureInvoiceFor(record: LastMile): Promise<Invoice | null> {
|
||||
// Check if invoice already exists
|
||||
const existing = await this.billing.findPayable(
|
||||
'last_mile' as Freight.InvoiceSource,
|
||||
record.id,
|
||||
'DELIVERY_FEE',
|
||||
);
|
||||
if (existing) return existing;
|
||||
|
||||
// Can't bill without company
|
||||
const lm = record.booking ? record : (await this.lastMileRepo.findById(record.id, { relations: { booking: true } }));
|
||||
if (!lm) return null;
|
||||
if (!lm.booking?.companyId) {
|
||||
this.logger.warn(
|
||||
`Skipping invoice for last-mile record ${record.id}: no company to bill.`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Generate invoice with remainingPayment as totalAmount
|
||||
const input: GenerateInvoiceInput = {
|
||||
source: 'last_mile' as Freight.InvoiceSource,
|
||||
sourceId: record.id,
|
||||
type: 'DELIVERY_FEE',
|
||||
companyId: lm.booking!.companyId,
|
||||
companyProfileId: lm.booking!.companyProfileId || '',
|
||||
currency: 'ETB',
|
||||
lines: [
|
||||
{
|
||||
chargeType: 'DELIVERY',
|
||||
description: 'Last-mile delivery',
|
||||
quantity: 1,
|
||||
unitRate: record.remainingPayment || 0,
|
||||
amount: record.remainingPayment || 0,
|
||||
},
|
||||
],
|
||||
totalAmount: record.remainingPayment || 0,
|
||||
};
|
||||
|
||||
return this.billing.generateInvoice(input);
|
||||
}
|
||||
|
||||
/**
|
||||
* React to a last-mile invoice being paid — the settlement branch point.
|
||||
* Advances the last-mile record to mark post-payment as completed.
|
||||
*/
|
||||
@OnEvent('last_mile.invoice.paid')
|
||||
async onPaid(payload: InvoiceEventPayload): Promise<void> {
|
||||
if (payload.type === 'DELIVERY_FEE') {
|
||||
const record = await this.lastMileRepo.findById(payload.sourceId);
|
||||
if (record) {
|
||||
this.logger.log(`Last-mile invoice paid for record ${payload.sourceId}.`);
|
||||
} else {
|
||||
this.logger.warn(
|
||||
`Cannot mark last-mile record ${payload.sourceId} as paid: not found.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,15 +17,20 @@ import { TrainSchedulingManage, TrainSchedulingView } from '../../common/booking
|
||||
|
||||
import { CreateLastMileDto } from './dto/create-last-mile.dto';
|
||||
import { UpdateLastMileDto } from './dto/update-last-mile.dto';
|
||||
import { AllocateLastMileContainersDto } from './dto/allocate-containers.dto';
|
||||
import { LastMileStatus } from './entities/last-mile.entity';
|
||||
import { LastMileService } from './last-mile.service';
|
||||
import { LastMileInvoiceService } from './last-mile-invoice.service';
|
||||
|
||||
@ApiTags('last-mile')
|
||||
@ApiBearerAuth()
|
||||
@Controller('last-mile')
|
||||
@TrainSchedulingView()
|
||||
export class LastMileController {
|
||||
constructor(private readonly lastMileService: LastMileService) {}
|
||||
constructor(
|
||||
private readonly lastMileService: LastMileService,
|
||||
private readonly lastMileInvoiceService: LastMileInvoiceService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List last-mile legs' })
|
||||
@@ -72,8 +77,13 @@ export class LastMileController {
|
||||
@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);
|
||||
async update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateLastMileDto) {
|
||||
const record = await this.lastMileService.update(id, dto);
|
||||
// Auto-generate invoice if distance or payment was updated
|
||||
if (dto.exactKm !== undefined || dto.remainingPayment !== undefined) {
|
||||
await this.lastMileInvoiceService.ensureInvoiceFor(record);
|
||||
}
|
||||
return record;
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@@ -83,4 +93,14 @@ export class LastMileController {
|
||||
remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.lastMileService.remove(id);
|
||||
}
|
||||
|
||||
@Post(':id/allocate-containers')
|
||||
@TrainSchedulingManage()
|
||||
@ApiOperation({ summary: 'Allocate containers to vehicles' })
|
||||
async allocateContainers(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: AllocateLastMileContainersDto,
|
||||
) {
|
||||
return this.lastMileService.allocateContainers(id, dto.allocations);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,25 +1,29 @@
|
||||
import { Module, forwardRef } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { BillingModule } from '../billing/billing.module';
|
||||
import { BookingsModule } from '../bookings/bookings.module';
|
||||
import { DriversModule } from '../drivers/drivers.module';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
import { VehiclesModule } from '../vehicles/vehicles.module';
|
||||
import { LastMile } from './entities/last-mile.entity';
|
||||
import { LastMileContainerAllocation } from './entities/last-mile-container-allocation.entity';
|
||||
import { LastMileController } from './last-mile.controller';
|
||||
import { LastMileInvoiceService } from './last-mile-invoice.service';
|
||||
import { LastMileRepository } from './last-mile.repository';
|
||||
import { LastMileService } from './last-mile.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([LastMile]),
|
||||
TypeOrmModule.forFeature([LastMile, LastMileContainerAllocation]),
|
||||
BillingModule,
|
||||
forwardRef(() => BookingsModule),
|
||||
VehiclesModule,
|
||||
DriversModule,
|
||||
NotificationsModule,
|
||||
],
|
||||
controllers: [LastMileController],
|
||||
providers: [LastMileRepository, LastMileService],
|
||||
exports: [LastMileRepository, LastMileService],
|
||||
providers: [LastMileRepository, LastMileService, LastMileInvoiceService],
|
||||
exports: [LastMileRepository, LastMileService, LastMileInvoiceService],
|
||||
})
|
||||
export class LastMileModule {}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { FindOptionsWhere } from 'typeorm';
|
||||
import { DataSource, FindOptionsWhere } from 'typeorm';
|
||||
|
||||
import { BookingsRepository } from '../bookings/bookings.repository';
|
||||
import { DriversService } from '../drivers/drivers.service';
|
||||
@@ -8,6 +8,7 @@ import { VehiclesService } from '../vehicles/vehicles.service';
|
||||
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 { LastMileContainerAllocation } from './entities/last-mile-container-allocation.entity';
|
||||
import { LastMileRepository } from './last-mile.repository';
|
||||
|
||||
type LastMileListFilter = {
|
||||
@@ -37,6 +38,7 @@ export class LastMileService {
|
||||
private readonly vehiclesService: VehiclesService,
|
||||
private readonly driversService: DriversService,
|
||||
private readonly smsClient: SmsClientService,
|
||||
private readonly dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
async acceptBooking(bookingReference: string): Promise<LastMile | null> {
|
||||
@@ -206,4 +208,35 @@ export class LastMileService {
|
||||
await this.findById(id);
|
||||
await this.lastMileRepository.softDelete(id);
|
||||
}
|
||||
|
||||
async allocateContainers(
|
||||
lastMileId: string,
|
||||
allocations: Array<{ containerId: string; vehicleId: string }>,
|
||||
) {
|
||||
const lastMile = await this.findById(lastMileId);
|
||||
if (!lastMile) {
|
||||
throw new NotFoundException(`Last-mile record ${lastMileId} not found`);
|
||||
}
|
||||
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
for (const allocation of allocations) {
|
||||
await manager.delete(LastMileContainerAllocation, {
|
||||
lastMileId,
|
||||
containerId: allocation.containerId,
|
||||
});
|
||||
await manager.insert(LastMileContainerAllocation, {
|
||||
lastMileId,
|
||||
containerId: allocation.containerId,
|
||||
vehicleId: allocation.vehicleId,
|
||||
containerType: 'CONTAINER',
|
||||
quantity: 1,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
allocated: allocations.length,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user