mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 04:08:11 +00:00
Merge pull request #435 from Tria-plc/freight/feature/first_mile_invoice
Freight/feature/first mile invoice
This commit is contained in:
@@ -0,0 +1,26 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Container number carried by each vehicle on a last-mile delivery. Auto-filled
|
||||||
|
* from the booking's container number when present, else entered by the operator
|
||||||
|
* at assignment time.
|
||||||
|
*/
|
||||||
|
export class AddLastMileAssignmentContainerNumber1890000000009
|
||||||
|
implements MigrationInterface
|
||||||
|
{
|
||||||
|
name = "AddLastMileAssignmentContainerNumber1890000000009";
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.last_mile_vehicle_assignments
|
||||||
|
ADD COLUMN IF NOT EXISTS container_number varchar
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.last_mile_vehicle_assignments
|
||||||
|
DROP COLUMN IF EXISTS container_number
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-vehicle actual distance on a last-mile delivery. A booking served by
|
||||||
|
* several trucks records each truck's km; the record's total (last_mile.exact_km)
|
||||||
|
* is their sum and drives the invoice.
|
||||||
|
*/
|
||||||
|
export class AddLastMileAssignmentDistance1890000000010
|
||||||
|
implements MigrationInterface
|
||||||
|
{
|
||||||
|
name = "AddLastMileAssignmentDistance1890000000010";
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.last_mile_vehicle_assignments
|
||||||
|
ADD COLUMN IF NOT EXISTS distance_km numeric(10,2)
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.last_mile_vehicle_assignments
|
||||||
|
DROP COLUMN IF EXISTS distance_km
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -307,6 +307,16 @@ export class BillingService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Invoices for a batch of source records (e.g. many last-mile legs), so a
|
||||||
|
* list can show which records already have an invoice without N+1 queries. */
|
||||||
|
findBySourceIds(source: string, sourceIds: string[]): Promise<Invoice[]> {
|
||||||
|
if (!sourceIds.length) return Promise.resolve([]);
|
||||||
|
return this.invoices.findAll({
|
||||||
|
where: { source, sourceId: In(sourceIds) },
|
||||||
|
order: { createdAt: "DESC" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** Invoices for the signed-in customer; empty when they have no company. */
|
/** Invoices for the signed-in customer; empty when they have no company. */
|
||||||
async findForUser(
|
async findForUser(
|
||||||
userId: string,
|
userId: string,
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
export class FirstMileContainerAllocationDto {
|
|
||||||
containerId!: string;
|
|
||||||
vehicleId!: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class AllocateFirstMileContainersDto {
|
|
||||||
allocations!: FirstMileContainerAllocationDto[];
|
|
||||||
}
|
|
||||||
@@ -17,13 +17,9 @@ import { TrainSchedulingManage, TrainSchedulingView } from '../../common/booking
|
|||||||
|
|
||||||
import { CreateFirstMileDto } from './dto/create-first-mile.dto';
|
import { CreateFirstMileDto } from './dto/create-first-mile.dto';
|
||||||
import { UpdateFirstMileDto } from './dto/update-first-mile.dto';
|
import { UpdateFirstMileDto } from './dto/update-first-mile.dto';
|
||||||
import { AllocateFirstMileContainersDto } from './dto/allocate-containers.dto';
|
|
||||||
import { FirstMileStatus } from './entities/first-mile.entity';
|
import { FirstMileStatus } from './entities/first-mile.entity';
|
||||||
import { FirstMileService } from './first-mile.service';
|
import { FirstMileService } from './first-mile.service';
|
||||||
import { FirstMileInvoiceService } from './first-mile-invoice.service';
|
import { FirstMileInvoiceService } from './first-mile-invoice.service';
|
||||||
import { BillingService } from '../billing/billing.service';
|
|
||||||
import { BookingsService } from '../bookings/bookings.service';
|
|
||||||
import { Freight } from '@edr/types';
|
|
||||||
|
|
||||||
@ApiTags('first-mile')
|
@ApiTags('first-mile')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@@ -33,8 +29,6 @@ export class FirstMileController {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly firstMileService: FirstMileService,
|
private readonly firstMileService: FirstMileService,
|
||||||
private readonly firstMileInvoiceService: FirstMileInvoiceService,
|
private readonly firstMileInvoiceService: FirstMileInvoiceService,
|
||||||
private readonly billingService: BillingService,
|
|
||||||
private readonly bookingsService: BookingsService
|
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@@ -89,40 +83,17 @@ export class FirstMileController {
|
|||||||
@TrainSchedulingManage()
|
@TrainSchedulingManage()
|
||||||
@ApiOperation({ summary: 'Update a first-mile leg' })
|
@ApiOperation({ summary: 'Update a first-mile leg' })
|
||||||
async update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateFirstMileDto) {
|
async update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateFirstMileDto) {
|
||||||
const record = await this.firstMileService.update(id, dto);
|
// No invoice side-effects — invoices are generated only via the explicit
|
||||||
// Auto-generate invoice if distance or payment was updated
|
// POST :id/invoice endpoint (the "Generate Invoice" action).
|
||||||
const booking = await this.bookingsService.findById(record.bookingId);
|
return this.firstMileService.update(id, dto);
|
||||||
const currency = booking.paymentCurrency || "ETB";
|
}
|
||||||
if (dto.exactKm !== undefined || dto.exactKm != record.exactKm || dto.remainingPayment !== undefined || dto.remainingPayment !== record.remainingPayment) {
|
|
||||||
await this.billingService.generateInvoice({
|
|
||||||
source: Freight.InvoiceSource.FirstMile,
|
|
||||||
sourceId: record.id,
|
|
||||||
type: "FIRST_MILE",
|
|
||||||
companyId: booking.companyId,
|
|
||||||
companyProfileId: booking.companyProfileId,
|
|
||||||
currency,
|
|
||||||
|
|
||||||
lines: [
|
@Post(':id/invoice')
|
||||||
{
|
@TrainSchedulingManage()
|
||||||
chargeType: "FIRST_MILE",
|
@ApiOperation({ summary: 'Generate the first-mile delivery-fee invoice' })
|
||||||
description: "First Mile Transportation Service",
|
async generateInvoice(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
quantity: 1,
|
const record = await this.firstMileService.findById(id);
|
||||||
unitRate: record.remainingPayment,
|
return this.firstMileInvoiceService.ensureInvoiceFor(record);
|
||||||
amount: record.remainingPayment,
|
|
||||||
currency,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
|
|
||||||
subtotalAmount: record.remainingPayment,
|
|
||||||
taxAmount: 0, // Replace if VAT/tax applies
|
|
||||||
totalAmount: record.remainingPayment,
|
|
||||||
|
|
||||||
dueInDays: 7,
|
|
||||||
status: Freight.InvoiceStatus.Pending,
|
|
||||||
});
|
|
||||||
await this.firstMileInvoiceService.ensureInvoiceFor(record);
|
|
||||||
}
|
|
||||||
return record;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@@ -132,14 +103,4 @@ export class FirstMileController {
|
|||||||
remove(@Param('id', ParseUUIDPipe) id: string) {
|
remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
return this.firstMileService.remove(id);
|
return this.firstMileService.remove(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(':firstMileId/allocate-containers')
|
|
||||||
@TrainSchedulingManage()
|
|
||||||
@ApiOperation({ summary: 'Allocate containers to vehicles for a first-mile leg' })
|
|
||||||
allocateContainers(
|
|
||||||
@Param('firstMileId', ParseUUIDPipe) firstMileId: string,
|
|
||||||
@Body() dto: AllocateFirstMileContainersDto,
|
|
||||||
) {
|
|
||||||
return this.firstMileService.allocateContainers(firstMileId, dto.allocations);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||||
import { FindOptionsWhere, In, IsNull, Not } from 'typeorm';
|
import { FindOptionsWhere, IsNull, Not } from 'typeorm';
|
||||||
import { InjectDataSource } from '@nestjs/typeorm';
|
import { InjectDataSource } from '@nestjs/typeorm';
|
||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
import { VehicleAvailability } from '../vehicles/entities/vehicle.entity';
|
import { VehicleAvailability } from '../vehicles/entities/vehicle.entity';
|
||||||
@@ -13,7 +13,7 @@ import { FirstMile, FirstMileStatus } from "./entities/first-mile.entity";
|
|||||||
import { FirstMileContainerAllocation } from "./entities/first-mile-container-allocation.entity";
|
import { FirstMileContainerAllocation } from "./entities/first-mile-container-allocation.entity";
|
||||||
import { FirstMileRepository } from "./first-mile.repository";
|
import { FirstMileRepository } from "./first-mile.repository";
|
||||||
import { OnEvent } from "@nestjs/event-emitter";
|
import { OnEvent } from "@nestjs/event-emitter";
|
||||||
import { InvoiceEventPayload } from "../billing/billing.service";
|
import { BillingService, InvoiceEventPayload } from "../billing/billing.service";
|
||||||
import { FleetHistoryService } from "../fleet-history/fleet-history.service";
|
import { FleetHistoryService } from "../fleet-history/fleet-history.service";
|
||||||
import { FleetEventType } from "../fleet-history/entities/fleet-event.entity";
|
import { FleetEventType } from "../fleet-history/entities/fleet-event.entity";
|
||||||
|
|
||||||
@@ -46,8 +46,27 @@ export class FirstMileService {
|
|||||||
private readonly driversService: DriversService,
|
private readonly driversService: DriversService,
|
||||||
private readonly smsClient: SmsClientService,
|
private readonly smsClient: SmsClientService,
|
||||||
private readonly history: FleetHistoryService,
|
private readonly history: FleetHistoryService,
|
||||||
|
private readonly billing: BillingService,
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
|
/** Attach real invoice info so the UI shows an invoice link only when one
|
||||||
|
* exists — not merely because distance was entered. Batched (no N+1). */
|
||||||
|
private async attachInvoices(records: FirstMile[]): Promise<void> {
|
||||||
|
const invoices = await this.billing.findBySourceIds(
|
||||||
|
'first_mile',
|
||||||
|
records.map((r) => r.id),
|
||||||
|
);
|
||||||
|
const byId = new Map<string, { id: string; number: string; status: string }>();
|
||||||
|
for (const inv of invoices) {
|
||||||
|
if (!byId.has(inv.sourceId)) {
|
||||||
|
byId.set(inv.sourceId, { id: inv.id, number: inv.invoiceNumber, status: String(inv.status) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const r of records) {
|
||||||
|
(r as FirstMile & { invoice?: unknown }).invoice = byId.get(r.id) ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Resolve a vehicle's driver + human labels, for stamping mile events onto
|
/** Resolve a vehicle's driver + human labels, for stamping mile events onto
|
||||||
* the driver's timeline and naming the vehicle. Best-effort — never throws. */
|
* the driver's timeline and naming the vehicle. Best-effort — never throws. */
|
||||||
private async vehicleInfo(
|
private async vehicleInfo(
|
||||||
@@ -191,6 +210,8 @@ export class FirstMileService {
|
|||||||
take: pageSize,
|
take: pageSize,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await this.attachInvoices(data);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
data,
|
data,
|
||||||
meta: {
|
meta: {
|
||||||
@@ -236,6 +257,8 @@ export class FirstMileService {
|
|||||||
throw new NotFoundException(`First-mile record ${id} not found`);
|
throw new NotFoundException(`First-mile record ${id} not found`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await this.attachInvoices([record]);
|
||||||
|
|
||||||
return record;
|
return record;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -537,84 +560,18 @@ export class FirstMileService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async remove(id: string): Promise<void> {
|
async remove(id: string): Promise<void> {
|
||||||
await this.findById(id);
|
const existing = await this.findById(id);
|
||||||
|
|
||||||
|
// Can't delete once billed.
|
||||||
|
const invoices = await this.billing.findBySourceIds('first_mile', [id]);
|
||||||
|
if (invoices.length) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Cannot delete a first-mile leg after its invoice is generated',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
await this.firstMileRepository.softDelete(id);
|
await this.firstMileRepository.softDelete(id);
|
||||||
}
|
// Free the trucks it was holding (direct + container), unless still in use.
|
||||||
|
await this.releaseVehicles(existing);
|
||||||
async allocateContainers(
|
|
||||||
firstMileId: string,
|
|
||||||
allocations: Array<{ containerId: string; vehicleId: string }>,
|
|
||||||
) {
|
|
||||||
const firstMile = await this.findById(firstMileId);
|
|
||||||
if (!firstMile) {
|
|
||||||
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, {
|
|
||||||
firstMileId,
|
|
||||||
containerId: allocation.containerId,
|
|
||||||
});
|
|
||||||
await manager.insert(FirstMileContainerAllocation, {
|
|
||||||
firstMileId,
|
|
||||||
containerId: allocation.containerId,
|
|
||||||
vehicleId: allocation.vehicleId,
|
|
||||||
containerType: "CONTAINER",
|
|
||||||
quantity: 1,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
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)),
|
|
||||||
);
|
|
||||||
|
|
||||||
// History: one event per vehicle actually added or removed by this
|
|
||||||
// multi-car (re)allocation, so reassignments show on every timeline.
|
|
||||||
const prevSet = new Set(previousVehicleIds);
|
|
||||||
const bookingRef = await this.resolveBookingRef(firstMile);
|
|
||||||
for (const vehicleId of vehicleIds) {
|
|
||||||
if (prevSet.has(vehicleId)) continue;
|
|
||||||
const info = await this.vehicleInfo(vehicleId);
|
|
||||||
await this.history.record({
|
|
||||||
eventType: FleetEventType.MILE_VEHICLE_ASSIGNED,
|
|
||||||
vehicleId,
|
|
||||||
firstMileId,
|
|
||||||
driverId: info.driverId,
|
|
||||||
label: firstMile.status,
|
|
||||||
metadata: { mile: 'FIRST', bookingRef, vehiclePlate: info.plate, driverName: info.driverName },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
for (const vehicleId of previousVehicleIds) {
|
|
||||||
if (vehicleIds.has(vehicleId)) continue;
|
|
||||||
const info = await this.vehicleInfo(vehicleId);
|
|
||||||
await this.history.record({
|
|
||||||
eventType: FleetEventType.MILE_VEHICLE_RELEASED,
|
|
||||||
vehicleId,
|
|
||||||
firstMileId,
|
|
||||||
driverId: info.driverId,
|
|
||||||
metadata: { mile: 'FIRST', bookingRef, vehiclePlate: info.plate, driverName: info.driverName },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
allocated: allocations.length,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
import { IsArray, IsUUID, ValidateNested } from 'class-validator';
|
|
||||||
import { Type } from 'class-transformer';
|
|
||||||
|
|
||||||
export class LastMileContainerAllocationDto {
|
|
||||||
@IsUUID()
|
|
||||||
containerId!: string;
|
|
||||||
|
|
||||||
@IsUUID()
|
|
||||||
vehicleId!: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class AllocateLastMileContainersDto {
|
|
||||||
@IsArray()
|
|
||||||
@ValidateNested({ each: true })
|
|
||||||
@Type(() => LastMileContainerAllocationDto)
|
|
||||||
allocations!: LastMileContainerAllocationDto[];
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { IsArray, IsNumber, IsOptional, IsUUID, Min, ValidateNested } from 'class-validator';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
|
||||||
|
export class VehicleDistanceInput {
|
||||||
|
@IsUUID()
|
||||||
|
vehicleId!: string;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
distanceKm!: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Per-vehicle actual distances for a last-mile delivery (multi-truck). */
|
||||||
|
export class SetDistancesDto {
|
||||||
|
@IsArray()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => VehicleDistanceInput)
|
||||||
|
distances!: VehicleDistanceInput[];
|
||||||
|
|
||||||
|
/** Recomputed remaining payment (total km × rate), from the client. */
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
remainingPayment?: number;
|
||||||
|
}
|
||||||
@@ -1,8 +1,19 @@
|
|||||||
import { IsArray, IsUUID } from 'class-validator';
|
import { IsArray, IsOptional, IsString, IsUUID, ValidateNested } from 'class-validator';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
|
||||||
/** Replace the full set of vehicles assigned to a last-mile delivery. */
|
export class LastMileVehicleInput {
|
||||||
|
@IsUUID()
|
||||||
|
vehicleId!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
containerNumber?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Replace the full set of vehicles (with their container numbers) on a delivery. */
|
||||||
export class SetVehiclesDto {
|
export class SetVehiclesDto {
|
||||||
@IsArray()
|
@IsArray()
|
||||||
@IsUUID('4', { each: true })
|
@ValidateNested({ each: true })
|
||||||
vehicleIds!: string[];
|
@Type(() => LastMileVehicleInput)
|
||||||
|
vehicles!: LastMileVehicleInput[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export class LastMileContainerAllocation extends BaseEntity {
|
|||||||
@Column('uuid', { name: 'vehicle_id', nullable: true })
|
@Column('uuid', { name: 'vehicle_id', nullable: true })
|
||||||
vehicleId?: string | null;
|
vehicleId?: string | null;
|
||||||
|
|
||||||
@Column('text')
|
@Column('text', { name: 'container_type' })
|
||||||
containerType!: string;
|
containerType!: string;
|
||||||
|
|
||||||
@Column('integer', { default: 1 })
|
@Column('integer', { default: 1 })
|
||||||
|
|||||||
@@ -27,4 +27,13 @@ export class LastMileVehicleAssignment extends BaseEntity {
|
|||||||
@ManyToOne(() => Vehicle, { nullable: false, eager: false })
|
@ManyToOne(() => Vehicle, { nullable: false, eager: false })
|
||||||
@JoinColumn({ name: 'vehicle_id' })
|
@JoinColumn({ name: 'vehicle_id' })
|
||||||
vehicle?: Vehicle;
|
vehicle?: Vehicle;
|
||||||
|
|
||||||
|
/** Container this truck carries — auto-filled from the booking's container
|
||||||
|
* number when known, else entered manually at assignment time. */
|
||||||
|
@Column({ name: 'container_number', type: 'varchar', nullable: true })
|
||||||
|
containerNumber?: string | null;
|
||||||
|
|
||||||
|
/** 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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,14 +17,11 @@ import { TrainSchedulingManage, TrainSchedulingView } from '../../common/booking
|
|||||||
|
|
||||||
import { CreateLastMileDto } from './dto/create-last-mile.dto';
|
import { CreateLastMileDto } from './dto/create-last-mile.dto';
|
||||||
import { UpdateLastMileDto } from './dto/update-last-mile.dto';
|
import { UpdateLastMileDto } from './dto/update-last-mile.dto';
|
||||||
import { AllocateLastMileContainersDto } from './dto/allocate-containers.dto';
|
|
||||||
import { SetVehiclesDto } from './dto/set-vehicles.dto';
|
import { SetVehiclesDto } from './dto/set-vehicles.dto';
|
||||||
|
import { SetDistancesDto } from './dto/set-distances.dto';
|
||||||
import { LastMileStatus } from './entities/last-mile.entity';
|
import { LastMileStatus } from './entities/last-mile.entity';
|
||||||
import { LastMileService } from './last-mile.service';
|
import { LastMileService } from './last-mile.service';
|
||||||
import { LastMileInvoiceService } from './last-mile-invoice.service';
|
import { LastMileInvoiceService } from './last-mile-invoice.service';
|
||||||
import { Freight } from '@edr/types';
|
|
||||||
import { BillingService } from '../billing/billing.service';
|
|
||||||
import { BookingsService } from '../bookings/bookings.service';
|
|
||||||
|
|
||||||
@ApiTags('last-mile')
|
@ApiTags('last-mile')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@@ -34,8 +31,6 @@ export class LastMileController {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly lastMileService: LastMileService,
|
private readonly lastMileService: LastMileService,
|
||||||
private readonly lastMileInvoiceService: LastMileInvoiceService,
|
private readonly lastMileInvoiceService: LastMileInvoiceService,
|
||||||
private readonly billingService: BillingService,
|
|
||||||
private readonly bookingsService: BookingsService
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@@ -84,40 +79,9 @@ export class LastMileController {
|
|||||||
@TrainSchedulingManage()
|
@TrainSchedulingManage()
|
||||||
@ApiOperation({ summary: 'Update a last-mile leg' })
|
@ApiOperation({ summary: 'Update a last-mile leg' })
|
||||||
async update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateLastMileDto) {
|
async update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateLastMileDto) {
|
||||||
const record = await this.lastMileService.update(id, dto);
|
// No invoice side-effects here — invoices are generated only via the
|
||||||
// Auto-generate invoice if distance or payment was updated
|
// explicit POST :id/invoice endpoint (the "Generate Invoice" action).
|
||||||
const booking = await this.bookingsService.findById(record.bookingId);
|
return this.lastMileService.update(id, dto);
|
||||||
const currency = booking.paymentCurrency || "ETB";
|
|
||||||
if (dto.exactKm !== undefined || dto.exactKm != record.exactKm || dto.remainingPayment !== undefined || dto.remainingPayment !== record.remainingPayment) {
|
|
||||||
await this.billingService.generateInvoice({
|
|
||||||
source: Freight.InvoiceSource.LastMile,
|
|
||||||
sourceId: record.id,
|
|
||||||
type: "LAST_MILE",
|
|
||||||
companyId: booking.companyId,
|
|
||||||
companyProfileId: booking.companyProfileId,
|
|
||||||
currency,
|
|
||||||
|
|
||||||
lines: [
|
|
||||||
{
|
|
||||||
chargeType: "LAST_MILE",
|
|
||||||
description: "Last Mile Transportation Service",
|
|
||||||
quantity: 1,
|
|
||||||
unitRate: record.remainingPayment,
|
|
||||||
amount: record.remainingPayment,
|
|
||||||
currency,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
|
|
||||||
subtotalAmount: record.remainingPayment,
|
|
||||||
taxAmount: 0, // Replace if VAT/tax applies
|
|
||||||
totalAmount: record.remainingPayment,
|
|
||||||
|
|
||||||
dueInDays: 7,
|
|
||||||
status: Freight.InvoiceStatus.Pending,
|
|
||||||
});
|
|
||||||
await this.lastMileInvoiceService.ensureInvoiceFor(record);
|
|
||||||
}
|
|
||||||
return record;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@@ -128,15 +92,6 @@ export class LastMileController {
|
|||||||
return this.lastMileService.remove(id);
|
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post(':id/vehicles')
|
@Post(':id/vehicles')
|
||||||
@TrainSchedulingManage()
|
@TrainSchedulingManage()
|
||||||
@@ -145,6 +100,24 @@ export class LastMileController {
|
|||||||
@Param('id', ParseUUIDPipe) id: string,
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
@Body() dto: SetVehiclesDto,
|
@Body() dto: SetVehiclesDto,
|
||||||
) {
|
) {
|
||||||
return this.lastMileService.setVehicles(id, dto.vehicleIds);
|
return this.lastMileService.setVehicles(id, dto.vehicles);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/distances')
|
||||||
|
@TrainSchedulingManage()
|
||||||
|
@ApiOperation({ summary: 'Set per-vehicle actual distances (does not generate an invoice)' })
|
||||||
|
async setDistances(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body() dto: SetDistancesDto,
|
||||||
|
) {
|
||||||
|
return this.lastMileService.setDistances(id, dto.distances, dto.remainingPayment);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/invoice')
|
||||||
|
@TrainSchedulingManage()
|
||||||
|
@ApiOperation({ summary: 'Generate the delivery-fee invoice for a last-mile leg' })
|
||||||
|
async generateInvoice(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
const record = await this.lastMileService.findById(id);
|
||||||
|
return this.lastMileInvoiceService.ensureInvoiceFor(record);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { LastMile, LastMileStatus } from './entities/last-mile.entity';
|
|||||||
import { LastMileContainerAllocation } from './entities/last-mile-container-allocation.entity';
|
import { LastMileContainerAllocation } from './entities/last-mile-container-allocation.entity';
|
||||||
import { LastMileVehicleAssignment } from './entities/last-mile-vehicle-assignment.entity';
|
import { LastMileVehicleAssignment } from './entities/last-mile-vehicle-assignment.entity';
|
||||||
import { LastMileRepository } from './last-mile.repository';
|
import { LastMileRepository } from './last-mile.repository';
|
||||||
import { InvoiceEventPayload } from '../billing/billing.service';
|
import { BillingService, InvoiceEventPayload } from '../billing/billing.service';
|
||||||
import { OnEvent } from '@nestjs/event-emitter';
|
import { OnEvent } from '@nestjs/event-emitter';
|
||||||
import { FleetHistoryService } from '../fleet-history/fleet-history.service';
|
import { FleetHistoryService } from '../fleet-history/fleet-history.service';
|
||||||
import { FleetEventType } from '../fleet-history/entities/fleet-event.entity';
|
import { FleetEventType } from '../fleet-history/entities/fleet-event.entity';
|
||||||
@@ -46,8 +46,28 @@ export class LastMileService {
|
|||||||
private readonly smsClient: SmsClientService,
|
private readonly smsClient: SmsClientService,
|
||||||
private readonly dataSource: DataSource,
|
private readonly dataSource: DataSource,
|
||||||
private readonly history: FleetHistoryService,
|
private readonly history: FleetHistoryService,
|
||||||
|
private readonly billing: BillingService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
/** Attach real invoice info (number/status) to records so the UI can show an
|
||||||
|
* invoice link only when one actually exists — NOT merely because distance
|
||||||
|
* was entered. Batched to avoid N+1. */
|
||||||
|
private async attachInvoices(records: LastMile[]): Promise<void> {
|
||||||
|
const invoices = await this.billing.findBySourceIds(
|
||||||
|
'last_mile',
|
||||||
|
records.map((r) => r.id),
|
||||||
|
);
|
||||||
|
const byId = new Map<string, { id: string; number: string; status: string }>();
|
||||||
|
for (const inv of invoices) {
|
||||||
|
if (!byId.has(inv.sourceId)) {
|
||||||
|
byId.set(inv.sourceId, { id: inv.id, number: inv.invoiceNumber, status: String(inv.status) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const r of records) {
|
||||||
|
(r as LastMile & { invoice?: unknown }).invoice = byId.get(r.id) ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Resolve a vehicle's driver + human labels, for stamping mile events onto
|
/** Resolve a vehicle's driver + human labels, for stamping mile events onto
|
||||||
* the driver's timeline and naming the vehicle. Best-effort — never throws. */
|
* the driver's timeline and naming the vehicle. Best-effort — never throws. */
|
||||||
private async vehicleInfo(
|
private async vehicleInfo(
|
||||||
@@ -159,6 +179,8 @@ export class LastMileService {
|
|||||||
take: pageSize,
|
take: pageSize,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await this.attachInvoices(data);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
data,
|
data,
|
||||||
meta: {
|
meta: {
|
||||||
@@ -183,6 +205,8 @@ export class LastMileService {
|
|||||||
throw new NotFoundException(`Last-mile record ${id} not found`);
|
throw new NotFoundException(`Last-mile record ${id} not found`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await this.attachInvoices([record]);
|
||||||
|
|
||||||
return record;
|
return record;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -219,14 +243,16 @@ export class LastMileService {
|
|||||||
return record;
|
return record;
|
||||||
}
|
}
|
||||||
|
|
||||||
@OnEvent("lastmile.invoice.paid")
|
@OnEvent("last_mile.invoice.paid")
|
||||||
async onBookingInvoicePaid(payload: InvoiceEventPayload): Promise<void> {
|
async onBookingInvoicePaid(payload: InvoiceEventPayload): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await this.lastMileRepository.update(payload.sourceId, { paid: true } as any);
|
// Invoice paid → the delivery is complete. Route through update() so it
|
||||||
this.logger.log(`Marked last-mile record ${payload.sourceId} as paid (invoice ${payload.invoiceId})`);
|
// also frees the trucks + records history (same as "Mark Delivered").
|
||||||
|
await this.update(payload.sourceId, { status: 'DELIVERED', paid: true } as unknown as UpdateLastMileDto);
|
||||||
|
this.logger.log(`Last-mile ${payload.sourceId} marked DELIVERED on invoice ${payload.invoiceId} payment`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`Failed to update last-mile payment status for record ${payload.sourceId}: ${String(err)}`,
|
`Failed to deliver last-mile ${payload.sourceId} on payment: ${String(err)}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -339,20 +365,28 @@ export class LastMileService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Free every vehicle held by this record (direct assignment + container
|
* Free every vehicle held by this record — junction assignments, the legacy
|
||||||
* allocations), unless still in use by another active trip.
|
* direct vehicle, and container allocations — unless still used by another
|
||||||
|
* active trip.
|
||||||
*/
|
*/
|
||||||
private async releaseVehicles(record: LastMile): Promise<void> {
|
private async releaseVehicles(record: LastMile): Promise<void> {
|
||||||
const recordAllocations = await this.dataSource.manager.find(
|
const [assignments, recordAllocations] = await Promise.all([
|
||||||
LastMileContainerAllocation,
|
this.dataSource.manager.find(LastMileVehicleAssignment, {
|
||||||
{ where: { lastMileId: record.id } },
|
where: { lastMileId: record.id },
|
||||||
);
|
}),
|
||||||
const vehicleIds = recordAllocations
|
this.dataSource.manager.find(LastMileContainerAllocation, {
|
||||||
.map((a) => a.vehicleId)
|
where: { lastMileId: record.id },
|
||||||
.filter((id): id is string => Boolean(id));
|
}),
|
||||||
if (record.vehicleId) {
|
]);
|
||||||
vehicleIds.push(record.vehicleId);
|
const vehicleIds = [
|
||||||
}
|
...new Set(
|
||||||
|
[
|
||||||
|
...assignments.map((a) => a.vehicleId),
|
||||||
|
...recordAllocations.map((a) => a.vehicleId),
|
||||||
|
record.vehicleId ?? null,
|
||||||
|
].filter((id): id is string => Boolean(id)),
|
||||||
|
),
|
||||||
|
];
|
||||||
await this.vehiclesService.releaseIfUnused(vehicleIds);
|
await this.vehiclesService.releaseIfUnused(vehicleIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -362,19 +396,37 @@ export class LastMileService {
|
|||||||
* for each added/removed vehicle. The first vehicle is mirrored onto the legacy
|
* for each added/removed vehicle. The first vehicle is mirrored onto the legacy
|
||||||
* `vehicleId` column for back-compat with single-vehicle readers.
|
* `vehicleId` column for back-compat with single-vehicle readers.
|
||||||
*/
|
*/
|
||||||
async setVehicles(id: string, vehicleIds: string[]): Promise<LastMile> {
|
async setVehicles(
|
||||||
|
id: string,
|
||||||
|
inputs: Array<{ vehicleId: string; containerNumber?: string | null }>,
|
||||||
|
): Promise<LastMile> {
|
||||||
const existing = await this.findById(id);
|
const existing = await this.findById(id);
|
||||||
const desired = [...new Set(vehicleIds.filter(Boolean))];
|
// Dedupe by vehicleId, keeping the container number; preserve order.
|
||||||
|
const desiredMap = new Map<string, string | null>();
|
||||||
|
for (const inp of inputs) {
|
||||||
|
if (inp.vehicleId) desiredMap.set(inp.vehicleId, inp.containerNumber ?? null);
|
||||||
|
}
|
||||||
|
const desired = [...desiredMap.keys()];
|
||||||
|
const desiredSet = new Set(desired);
|
||||||
|
|
||||||
const manager = this.dataSource.manager;
|
const manager = this.dataSource.manager;
|
||||||
const current = await manager.find(LastMileVehicleAssignment, {
|
const current = await manager.find(LastMileVehicleAssignment, {
|
||||||
where: { lastMileId: id },
|
where: { lastMileId: id },
|
||||||
});
|
});
|
||||||
const currentIds = current.map((a) => a.vehicleId);
|
const junctionSet = new Set(current.map((a) => a.vehicleId));
|
||||||
const currentSet = new Set(currentIds);
|
// Fold the legacy vehicleId into the release set — a vehicle assigned via the
|
||||||
const desiredSet = new Set(desired);
|
// old single-vehicle path has no junction row but must still be freed.
|
||||||
const added = desired.filter((v) => !currentSet.has(v));
|
const releaseIds = [...new Set(
|
||||||
const removed = currentIds.filter((v) => !desiredSet.has(v));
|
current.map((a) => a.vehicleId).concat(existing.vehicleId ? [existing.vehicleId] : []),
|
||||||
|
)];
|
||||||
|
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),
|
||||||
|
);
|
||||||
|
|
||||||
await this.dataSource.transaction(async (tx) => {
|
await this.dataSource.transaction(async (tx) => {
|
||||||
if (removed.length) {
|
if (removed.length) {
|
||||||
@@ -384,7 +436,18 @@ export class LastMileService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
for (const vehicleId of added) {
|
for (const vehicleId of added) {
|
||||||
await tx.insert(LastMileVehicleAssignment, { lastMileId: id, vehicleId });
|
await tx.insert(LastMileVehicleAssignment, {
|
||||||
|
lastMileId: id,
|
||||||
|
vehicleId,
|
||||||
|
containerNumber: desiredMap.get(vehicleId) ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const row of changed) {
|
||||||
|
await tx.update(
|
||||||
|
LastMileVehicleAssignment,
|
||||||
|
{ lastMileId: id, vehicleId: row.vehicleId },
|
||||||
|
{ containerNumber: desiredMap.get(row.vehicleId) ?? null },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -420,6 +483,41 @@ export class LastMileService {
|
|||||||
return this.findById(id);
|
return this.findById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record each truck's actual distance. The delivery total (exact_km) is their
|
||||||
|
* sum and drives billing; `remainingPayment` (total km × rate) is recomputed
|
||||||
|
* client-side. Does NOT generate an invoice — that's a separate explicit step.
|
||||||
|
*/
|
||||||
|
async setDistances(
|
||||||
|
id: string,
|
||||||
|
distances: Array<{ vehicleId: string; distanceKm: number }>,
|
||||||
|
remainingPayment?: number,
|
||||||
|
): Promise<LastMile> {
|
||||||
|
await this.findById(id);
|
||||||
|
|
||||||
|
// Distances are locked once the invoice exists.
|
||||||
|
const invoices = await this.billing.findBySourceIds('last_mile', [id]);
|
||||||
|
if (invoices.length) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Distances cannot be changed after the invoice is generated',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const d of distances) {
|
||||||
|
await this.dataSource.manager.update(
|
||||||
|
LastMileVehicleAssignment,
|
||||||
|
{ lastMileId: id, vehicleId: d.vehicleId },
|
||||||
|
{ distanceKm: d.distanceKm },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const total = distances.reduce((s, d) => s + (Number(d.distanceKm) || 0), 0);
|
||||||
|
await this.lastMileRepository.update(id, {
|
||||||
|
exactKm: total,
|
||||||
|
...(remainingPayment != null ? { remainingPayment } : {}),
|
||||||
|
} as any);
|
||||||
|
return this.findById(id);
|
||||||
|
}
|
||||||
|
|
||||||
private async notifyDriverAssignment(vehicleId: string, record: LastMile): Promise<void> {
|
private async notifyDriverAssignment(vehicleId: string, record: LastMile): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const vehicle = await this.vehiclesService.findById(vehicleId);
|
const vehicle = await this.vehiclesService.findById(vehicleId);
|
||||||
@@ -460,93 +558,54 @@ export class LastMileService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async remove(id: string): Promise<void> {
|
async remove(id: string): Promise<void> {
|
||||||
await this.findById(id);
|
const existing = await this.findById(id);
|
||||||
await this.lastMileRepository.softDelete(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
async allocateContainers(
|
// Can't delete once billed.
|
||||||
lastMileId: string,
|
const invoices = await this.billing.findBySourceIds('last_mile', [id]);
|
||||||
allocations: Array<{ containerId: string; vehicleId: string }>,
|
if (invoices.length) {
|
||||||
) {
|
throw new BadRequestException(
|
||||||
const lastMile = await this.findById(lastMileId);
|
'Cannot delete a last-mile delivery after its invoice is generated',
|
||||||
if (!lastMile) {
|
);
|
||||||
throw new NotFoundException(`Last-mile record ${lastMileId} not found`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Capture the vehicles currently on these containers so a reallocation can
|
// Every vehicle this delivery holds — junction + legacy + container rows.
|
||||||
// be diffed into assigned/released history events below.
|
const assignments = await this.dataSource.manager.find(LastMileVehicleAssignment, {
|
||||||
const previousAllocations = await this.dataSource.manager.find(
|
where: { lastMileId: id },
|
||||||
LastMileContainerAllocation,
|
});
|
||||||
{
|
const allocations = await this.dataSource.manager.find(LastMileContainerAllocation, {
|
||||||
where: {
|
where: { lastMileId: id },
|
||||||
lastMileId,
|
});
|
||||||
containerId: In(allocations.map((a) => a.containerId)),
|
const vehicleIds = [
|
||||||
},
|
...new Set(
|
||||||
},
|
[
|
||||||
);
|
...assignments.map((a) => a.vehicleId),
|
||||||
const previousVehicleIds = previousAllocations
|
...allocations.map((a) => a.vehicleId),
|
||||||
.map((a) => a.vehicleId)
|
existing.vehicleId ?? null,
|
||||||
.filter((id): id is string => Boolean(id));
|
].filter((v): v is string => Boolean(v)),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
await this.dataSource.transaction(async (manager) => {
|
await this.lastMileRepository.softDelete(id);
|
||||||
for (const allocation of allocations) {
|
if (assignments.length) {
|
||||||
await manager.delete(LastMileContainerAllocation, {
|
await this.dataSource.manager.softDelete(LastMileVehicleAssignment, { lastMileId: id });
|
||||||
lastMileId,
|
}
|
||||||
containerId: allocation.containerId,
|
|
||||||
});
|
// Free every vehicle no longer held by another active trip (releaseIfUnused
|
||||||
await manager.insert(LastMileContainerAllocation, {
|
// ignores this now soft-deleted record) and audit the release.
|
||||||
lastMileId,
|
if (vehicleIds.length) {
|
||||||
containerId: allocation.containerId,
|
await this.vehiclesService.releaseIfUnused(vehicleIds);
|
||||||
vehicleId: allocation.vehicleId,
|
const bookingRef = await this.resolveBookingRef(existing);
|
||||||
containerType: 'CONTAINER',
|
for (const vehicleId of vehicleIds) {
|
||||||
quantity: 1,
|
const info = await this.vehicleInfo(vehicleId);
|
||||||
|
await this.history.record({
|
||||||
|
eventType: FleetEventType.MILE_VEHICLE_RELEASED,
|
||||||
|
vehicleId,
|
||||||
|
lastMileId: id,
|
||||||
|
driverId: info.driverId,
|
||||||
|
metadata: { mile: 'LAST', bookingRef, vehiclePlate: info.plate, driverName: info.driverName },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
// Keep vehicle availability in sync: newly-allocated cars go BUSY, cars no
|
|
||||||
// longer on any of these containers are freed if unused elsewhere.
|
|
||||||
const vehicleIds = new Set(allocations.map((a) => a.vehicleId));
|
|
||||||
await Promise.all(
|
|
||||||
[...vehicleIds].map((id) =>
|
|
||||||
this.vehiclesService.setAvailability(id, VehicleAvailability.BUSY),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
await this.vehiclesService.releaseIfUnused(
|
|
||||||
previousVehicleIds.filter((id) => !vehicleIds.has(id)),
|
|
||||||
);
|
|
||||||
|
|
||||||
// History: one event per vehicle actually added or removed by this
|
|
||||||
// multi-car (re)allocation, so reassignments show on every timeline.
|
|
||||||
const prevSet = new Set(previousVehicleIds);
|
|
||||||
const bookingRef = await this.resolveBookingRef(lastMile);
|
|
||||||
for (const vehicleId of vehicleIds) {
|
|
||||||
if (prevSet.has(vehicleId)) continue;
|
|
||||||
const info = await this.vehicleInfo(vehicleId);
|
|
||||||
await this.history.record({
|
|
||||||
eventType: FleetEventType.MILE_VEHICLE_ASSIGNED,
|
|
||||||
vehicleId,
|
|
||||||
lastMileId,
|
|
||||||
driverId: info.driverId,
|
|
||||||
label: lastMile.status,
|
|
||||||
metadata: { mile: 'LAST', bookingRef, vehiclePlate: info.plate, driverName: info.driverName },
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
for (const vehicleId of previousVehicleIds) {
|
|
||||||
if (vehicleIds.has(vehicleId)) continue;
|
|
||||||
const info = await this.vehicleInfo(vehicleId);
|
|
||||||
await this.history.record({
|
|
||||||
eventType: FleetEventType.MILE_VEHICLE_RELEASED,
|
|
||||||
vehicleId,
|
|
||||||
lastMileId,
|
|
||||||
driverId: info.driverId,
|
|
||||||
metadata: { mile: 'LAST', bookingRef, vehiclePlate: info.plate, driverName: info.driverName },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
allocated: allocations.length,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,164 +0,0 @@
|
|||||||
import { useState, useMemo } from "react";
|
|
||||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
|
||||||
import {
|
|
||||||
Box,
|
|
||||||
Button,
|
|
||||||
Group,
|
|
||||||
Loader,
|
|
||||||
Select,
|
|
||||||
Stack,
|
|
||||||
Table,
|
|
||||||
Text,
|
|
||||||
Alert,
|
|
||||||
} from "@mantine/core";
|
|
||||||
import { AlertCircle } from "lucide-react";
|
|
||||||
import toast from "react-hot-toast";
|
|
||||||
|
|
||||||
import { vehiclesService } from "@/services/vehicles.service";
|
|
||||||
|
|
||||||
export interface ContainerAllocationRow {
|
|
||||||
id: string;
|
|
||||||
type: string;
|
|
||||||
qty: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FirstMileContainerAllocationTableProps {
|
|
||||||
firstMileId: string;
|
|
||||||
containers: ContainerAllocationRow[];
|
|
||||||
onSave: (allocations: Array<{ containerId: string; vehicleId: string }>) => Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Manual container-to-vehicle allocation table for first-mile pickups.
|
|
||||||
* Displays containers with type/qty, vehicle dropdown per row, and save action.
|
|
||||||
*/
|
|
||||||
export function FirstMileContainerAllocationTable({
|
|
||||||
firstMileId,
|
|
||||||
containers,
|
|
||||||
onSave,
|
|
||||||
}: FirstMileContainerAllocationTableProps) {
|
|
||||||
const [allocations, setAllocations] = useState<Record<string, string | null>>(
|
|
||||||
() => containers.reduce((acc, c) => ({ ...acc, [c.id]: null }), {}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const { data: vehicles = [], isLoading: vehiclesLoading } = useQuery({
|
|
||||||
queryKey: ["vehicles", "free"],
|
|
||||||
queryFn: () => vehiclesService.getAll({ status: "ACTIVE", availability: "FREE" }),
|
|
||||||
});
|
|
||||||
|
|
||||||
const vehicleOptions = useMemo(
|
|
||||||
() =>
|
|
||||||
vehicles.map((v) => ({
|
|
||||||
value: v.id,
|
|
||||||
label: `${v.plateNumber} (${v.vehicleType})`,
|
|
||||||
description: `${v.model} · ${v.manufacturer}`,
|
|
||||||
})),
|
|
||||||
[vehicles],
|
|
||||||
);
|
|
||||||
|
|
||||||
const saveAllocation = useMutation({
|
|
||||||
mutationFn: async () => {
|
|
||||||
const mappings = containers
|
|
||||||
.filter((c) => allocations[c.id])
|
|
||||||
.map((c) => ({
|
|
||||||
containerId: c.id,
|
|
||||||
vehicleId: allocations[c.id]!,
|
|
||||||
}));
|
|
||||||
|
|
||||||
if (mappings.length === 0) {
|
|
||||||
throw new Error("No containers allocated to vehicles");
|
|
||||||
}
|
|
||||||
|
|
||||||
await onSave(mappings);
|
|
||||||
},
|
|
||||||
onSuccess: () => {
|
|
||||||
toast.success("Container allocations saved");
|
|
||||||
setAllocations(
|
|
||||||
containers.reduce((acc, c) => ({ ...acc, [c.id]: null }), {}),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
toast.error(
|
|
||||||
error instanceof Error ? error.message : "Failed to save allocations",
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const allocatedCount = Object.values(allocations).filter(Boolean).length;
|
|
||||||
const allAllocated = allocatedCount === containers.length;
|
|
||||||
|
|
||||||
if (vehiclesLoading) {
|
|
||||||
return (
|
|
||||||
<Box display="flex" justifyContent="center" p="xl">
|
|
||||||
<Loader size="sm" />
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack gap="md">
|
|
||||||
{vehicles.length === 0 && (
|
|
||||||
<Alert icon={<AlertCircle size={16} />} color="yellow">
|
|
||||||
No free vehicles available. Free up or add vehicles before allocating containers.
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Box style={{ overflowX: "auto" }}>
|
|
||||||
<Table verticalSpacing="md" horizontalSpacing="md" highlightOnHover>
|
|
||||||
<Table.Thead>
|
|
||||||
<Table.Tr>
|
|
||||||
<Table.Th>Container ID</Table.Th>
|
|
||||||
<Table.Th>Type</Table.Th>
|
|
||||||
<Table.Th>Qty</Table.Th>
|
|
||||||
<Table.Th>Assigned Vehicle</Table.Th>
|
|
||||||
</Table.Tr>
|
|
||||||
</Table.Thead>
|
|
||||||
<Table.Tbody>
|
|
||||||
{containers.map((container) => (
|
|
||||||
<Table.Tr key={container.id}>
|
|
||||||
<Table.Td>
|
|
||||||
<Text fw={600} size="sm">
|
|
||||||
{container.id}
|
|
||||||
</Text>
|
|
||||||
</Table.Td>
|
|
||||||
<Table.Td>{container.type}</Table.Td>
|
|
||||||
<Table.Td>{container.qty}</Table.Td>
|
|
||||||
<Table.Td>
|
|
||||||
<Select
|
|
||||||
placeholder="Select vehicle"
|
|
||||||
data={vehicleOptions}
|
|
||||||
value={allocations[container.id] ?? null}
|
|
||||||
onChange={(value) =>
|
|
||||||
setAllocations((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[container.id]: value,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
searchable
|
|
||||||
clearable
|
|
||||||
disabled={vehicles.length === 0}
|
|
||||||
style={{ minWidth: 200 }}
|
|
||||||
/>
|
|
||||||
</Table.Td>
|
|
||||||
</Table.Tr>
|
|
||||||
))}
|
|
||||||
</Table.Tbody>
|
|
||||||
</Table>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Group justify="space-between">
|
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
{allocatedCount} of {containers.length} containers allocated
|
|
||||||
</Text>
|
|
||||||
<Button
|
|
||||||
color="edr-green"
|
|
||||||
loading={saveAllocation.isPending}
|
|
||||||
disabled={allocatedCount === 0 || vehicles.length === 0}
|
|
||||||
onClick={() => saveAllocation.mutate()}
|
|
||||||
>
|
|
||||||
Save Allocations
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,186 +0,0 @@
|
|||||||
import { useState, useMemo } from "react";
|
|
||||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
|
||||||
import {
|
|
||||||
Box,
|
|
||||||
Button,
|
|
||||||
Group,
|
|
||||||
Loader,
|
|
||||||
Select,
|
|
||||||
Stack,
|
|
||||||
Table,
|
|
||||||
Text,
|
|
||||||
Alert,
|
|
||||||
} from "@mantine/core";
|
|
||||||
import { AlertCircle } from "lucide-react";
|
|
||||||
import toast from "react-hot-toast";
|
|
||||||
|
|
||||||
import { vehiclesService } from "@/services/vehicles.service";
|
|
||||||
|
|
||||||
export interface LastMileContainerRow {
|
|
||||||
id: string;
|
|
||||||
type: string;
|
|
||||||
qty: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One vehicle (with trailer) carries at most this many containers. */
|
|
||||||
const CONTAINERS_PER_VEHICLE = 2;
|
|
||||||
|
|
||||||
export interface LastMileContainerAllocationTableProps {
|
|
||||||
containers: LastMileContainerRow[];
|
|
||||||
onSave: (allocations: Array<{ containerId: string; vehicleId: string }>) => Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Manual container-to-vehicle allocation table for last-mile deliveries.
|
|
||||||
* Displays containers with type/qty, vehicle dropdown per row, and save action.
|
|
||||||
*/
|
|
||||||
export function LastMileContainerAllocationTable({
|
|
||||||
containers,
|
|
||||||
onSave,
|
|
||||||
}: LastMileContainerAllocationTableProps) {
|
|
||||||
const [allocations, setAllocations] = useState<Record<string, string | null>>(
|
|
||||||
() => containers.reduce((acc, c) => ({ ...acc, [c.id]: null }), {}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const { data: vehicles = [], isLoading: vehiclesLoading } = useQuery({
|
|
||||||
queryKey: ["vehicles", "free"],
|
|
||||||
queryFn: () =>
|
|
||||||
vehiclesService.getAll({ status: "ACTIVE", availability: "FREE" }).then((r) => r.data),
|
|
||||||
});
|
|
||||||
|
|
||||||
const vehicleOptions = useMemo(
|
|
||||||
() =>
|
|
||||||
vehicles.map((v) => ({
|
|
||||||
value: v.id,
|
|
||||||
label: `${v.plateNumber} (${v.vehicleType})`,
|
|
||||||
description: `${v.model} · ${v.manufacturer}`,
|
|
||||||
})),
|
|
||||||
[vehicles],
|
|
||||||
);
|
|
||||||
|
|
||||||
const saveAllocation = useMutation({
|
|
||||||
mutationFn: async () => {
|
|
||||||
const mappings = containers
|
|
||||||
.filter((c) => allocations[c.id])
|
|
||||||
.map((c) => ({
|
|
||||||
containerId: c.id,
|
|
||||||
vehicleId: allocations[c.id]!,
|
|
||||||
}));
|
|
||||||
|
|
||||||
if (mappings.length === 0) {
|
|
||||||
throw new Error("No containers allocated to vehicles");
|
|
||||||
}
|
|
||||||
|
|
||||||
await onSave(mappings);
|
|
||||||
},
|
|
||||||
onSuccess: () => {
|
|
||||||
toast.success("Container allocations saved");
|
|
||||||
setAllocations(
|
|
||||||
containers.reduce((acc, c) => ({ ...acc, [c.id]: null }), {}),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
toast.error(
|
|
||||||
error instanceof Error ? error.message : "Failed to save allocations",
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const allocatedCount = Object.values(allocations).filter(Boolean).length;
|
|
||||||
|
|
||||||
// Containers already loaded onto each vehicle, to enforce the 2-per-vehicle cap.
|
|
||||||
const loadByVehicle = useMemo(() => {
|
|
||||||
const map: Record<string, number> = {};
|
|
||||||
for (const c of containers) {
|
|
||||||
const v = allocations[c.id];
|
|
||||||
if (v) map[v] = (map[v] ?? 0) + (c.qty || 1);
|
|
||||||
}
|
|
||||||
return map;
|
|
||||||
}, [allocations, containers]);
|
|
||||||
|
|
||||||
/** Options for a given row: a vehicle is disabled if assigning this container
|
|
||||||
* to it would exceed its 2-container capacity. */
|
|
||||||
const optionsForRow = (row: LastMileContainerRow) =>
|
|
||||||
vehicleOptions.map((o) => {
|
|
||||||
const already = loadByVehicle[o.value] ?? 0;
|
|
||||||
const selfHere = allocations[row.id] === o.value ? row.qty || 1 : 0;
|
|
||||||
const over = already - selfHere + (row.qty || 1) > CONTAINERS_PER_VEHICLE;
|
|
||||||
return { ...o, disabled: over };
|
|
||||||
});
|
|
||||||
|
|
||||||
if (vehiclesLoading) {
|
|
||||||
return (
|
|
||||||
<Group justify="center" p="xl">
|
|
||||||
<Loader size="sm" />
|
|
||||||
</Group>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack gap="md">
|
|
||||||
{vehicles.length === 0 && (
|
|
||||||
<Alert icon={<AlertCircle size={16} />} color="yellow">
|
|
||||||
No free vehicles available. Free up or add vehicles before allocating containers.
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Box style={{ overflowX: "auto" }}>
|
|
||||||
<Table verticalSpacing="md" horizontalSpacing="md" highlightOnHover>
|
|
||||||
<Table.Thead>
|
|
||||||
<Table.Tr>
|
|
||||||
<Table.Th>Container ID</Table.Th>
|
|
||||||
<Table.Th>Type</Table.Th>
|
|
||||||
<Table.Th>Qty</Table.Th>
|
|
||||||
<Table.Th>Assigned Vehicle</Table.Th>
|
|
||||||
</Table.Tr>
|
|
||||||
</Table.Thead>
|
|
||||||
<Table.Tbody>
|
|
||||||
{containers.map((container) => (
|
|
||||||
<Table.Tr key={container.id}>
|
|
||||||
<Table.Td>
|
|
||||||
<Text fw={600} size="sm">
|
|
||||||
{container.id}
|
|
||||||
</Text>
|
|
||||||
</Table.Td>
|
|
||||||
<Table.Td>{container.type}</Table.Td>
|
|
||||||
<Table.Td>{container.qty}</Table.Td>
|
|
||||||
<Table.Td>
|
|
||||||
<Select
|
|
||||||
placeholder="Select vehicle"
|
|
||||||
data={optionsForRow(container)}
|
|
||||||
value={allocations[container.id] ?? null}
|
|
||||||
onChange={(value) =>
|
|
||||||
setAllocations((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[container.id]: value,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
searchable
|
|
||||||
clearable
|
|
||||||
disabled={vehicles.length === 0}
|
|
||||||
style={{ minWidth: 200 }}
|
|
||||||
/>
|
|
||||||
</Table.Td>
|
|
||||||
</Table.Tr>
|
|
||||||
))}
|
|
||||||
</Table.Tbody>
|
|
||||||
</Table>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Group justify="space-between">
|
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
{allocatedCount} of {containers.length} containers allocated · max{" "}
|
|
||||||
{CONTAINERS_PER_VEHICLE} per vehicle
|
|
||||||
</Text>
|
|
||||||
<Button
|
|
||||||
color="edr-green"
|
|
||||||
loading={saveAllocation.isPending}
|
|
||||||
disabled={allocatedCount === 0 || vehicles.length === 0}
|
|
||||||
onClick={() => saveAllocation.mutate()}
|
|
||||||
>
|
|
||||||
Save Allocations
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
Truck,
|
Truck,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
import type { ColumnDef } from "@edr/ui-common";
|
import type { ColumnDef } from "@edr/ui-common";
|
||||||
import { DataTable, DataTableFooter, usePagination } from "@edr/ui-common";
|
import { DataTable, DataTableFooter, usePagination } from "@edr/ui-common";
|
||||||
import {
|
import {
|
||||||
@@ -33,11 +34,9 @@ import {
|
|||||||
Text,
|
Text,
|
||||||
TextInput,
|
TextInput,
|
||||||
UnstyledButton,
|
UnstyledButton,
|
||||||
Alert,
|
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
|
|
||||||
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
|
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
|
||||||
import { FirstMileContainerAllocationTable } from "@/components/FirstMileContainerAllocationTable";
|
|
||||||
import { ReceiveInventoryModal } from "@/components/warehouses/ReceiveInventoryModal";
|
import { ReceiveInventoryModal } from "@/components/warehouses/ReceiveInventoryModal";
|
||||||
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
@@ -50,7 +49,6 @@ import {
|
|||||||
import { bookingsService } from "@/services/bookings.service";
|
import { bookingsService } from "@/services/bookings.service";
|
||||||
import { vehiclesService } from "@/services/vehicles.service";
|
import { vehiclesService } from "@/services/vehicles.service";
|
||||||
import { ratesService } from "@/services/rates.service";
|
import { ratesService } from "@/services/rates.service";
|
||||||
import { api } from "@/auth/http";
|
|
||||||
import type { BookingDetail } from "@/types/booking";
|
import type { BookingDetail } from "@/types/booking";
|
||||||
|
|
||||||
const formatPrice = (amount: number) =>
|
const formatPrice = (amount: number) =>
|
||||||
@@ -320,6 +318,7 @@ const buildTripSlipHtml = (record: FirstMileRecord) => {
|
|||||||
const FirstMilePage = () => {
|
const FirstMilePage = () => {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
|
const navigate = useNavigate();
|
||||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||||
|
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
@@ -343,13 +342,9 @@ const FirstMilePage = () => {
|
|||||||
|
|
||||||
const [distanceOpen, setDistanceOpen] = useState(false);
|
const [distanceOpen, setDistanceOpen] = useState(false);
|
||||||
const [distanceValue, setDistanceValue] = useState("");
|
const [distanceValue, setDistanceValue] = useState("");
|
||||||
const [invoiceOpen, setInvoiceOpen] = useState(false);
|
|
||||||
const [invoiceRecord, setInvoiceRecord] = useState<FirstMileRecord | null>(null);
|
|
||||||
const [warehouseReceiveOpen, setWarehouseReceiveOpen] = useState(false);
|
const [warehouseReceiveOpen, setWarehouseReceiveOpen] = useState(false);
|
||||||
const [warehouseReceiveRecord, setWarehouseReceiveRecord] = useState<FirstMileRecord | null>(null);
|
const [warehouseReceiveRecord, setWarehouseReceiveRecord] = useState<FirstMileRecord | null>(null);
|
||||||
|
|
||||||
const [containerAllocationOpen, setContainerAllocationOpen] = useState(false);
|
|
||||||
const [containerAllocationFirstMileId, setContainerAllocationFirstMileId] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const { data: listData, isLoading } = useQuery({
|
const { data: listData, isLoading } = useQuery({
|
||||||
queryKey: QUERY_KEYS.FIRST_MILE.list(),
|
queryKey: QUERY_KEYS.FIRST_MILE.list(),
|
||||||
@@ -438,6 +433,17 @@ const FirstMilePage = () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const generateInvoiceMutation = useMutation({
|
||||||
|
mutationFn: (id: string) => firstMileService.generateInvoice(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
void qc.invalidateQueries({ queryKey: QUERY_KEYS.FIRST_MILE.list() });
|
||||||
|
toast({ title: "Invoice generated" });
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast({ title: "Invoice generation failed", variant: "destructive" });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const acceptMutation = useMutation({
|
const acceptMutation = useMutation({
|
||||||
mutationFn: async ({ reference, vehicleId }: { reference: string; vehicleId: string | null }) => {
|
mutationFn: async ({ reference, vehicleId }: { reference: string; vehicleId: string | null }) => {
|
||||||
const res = await firstMileService.accept(reference);
|
const res = await firstMileService.accept(reference);
|
||||||
@@ -462,20 +468,6 @@ const FirstMilePage = () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const allocateMutation = useMutation({
|
|
||||||
mutationFn: (data) => api.post(`/first-mile/${containerAllocationFirstMileId}/allocate-containers`, data),
|
|
||||||
onSuccess: () => {
|
|
||||||
toast({ title: "Containers allocated" });
|
|
||||||
void qc.invalidateQueries({ queryKey: QUERY_KEYS.FIRST_MILE.byId(containerAllocationFirstMileId ?? "") });
|
|
||||||
void qc.invalidateQueries({ queryKey: ["vehicles"] });
|
|
||||||
setContainerAllocationOpen(false);
|
|
||||||
setContainerAllocationFirstMileId(null);
|
|
||||||
},
|
|
||||||
onError: () => {
|
|
||||||
toast({ title: "Allocation failed", variant: "destructive" });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const activeRecord = useMemo(
|
const activeRecord = useMemo(
|
||||||
() => records.find((r) => r.id === activeId) ?? null,
|
() => records.find((r) => r.id === activeId) ?? null,
|
||||||
[records, activeId],
|
[records, activeId],
|
||||||
@@ -545,15 +537,6 @@ const FirstMilePage = () => {
|
|||||||
setDistanceValue("");
|
setDistanceValue("");
|
||||||
};
|
};
|
||||||
|
|
||||||
const openInvoice = (record: FirstMileRecord) => {
|
|
||||||
setInvoiceRecord(record);
|
|
||||||
setInvoiceOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const closeInvoice = () => {
|
|
||||||
setInvoiceOpen(false);
|
|
||||||
setInvoiceRecord(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const openWarehouseReceive = (record: FirstMileRecord) => {
|
const openWarehouseReceive = (record: FirstMileRecord) => {
|
||||||
setWarehouseReceiveRecord(record);
|
setWarehouseReceiveRecord(record);
|
||||||
@@ -565,16 +548,6 @@ const FirstMilePage = () => {
|
|||||||
setWarehouseReceiveRecord(null);
|
setWarehouseReceiveRecord(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const openContainerAllocation = (firstMileId: string) => {
|
|
||||||
setContainerAllocationFirstMileId(firstMileId);
|
|
||||||
setContainerAllocationOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const closeContainerAllocation = () => {
|
|
||||||
setContainerAllocationOpen(false);
|
|
||||||
setContainerAllocationFirstMileId(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSaveDistance = () => {
|
const handleSaveDistance = () => {
|
||||||
const distance = parseFloat(distanceValue);
|
const distance = parseFloat(distanceValue);
|
||||||
if (!activeId || isNaN(distance) || distance < 0) {
|
if (!activeId || isNaN(distance) || distance < 0) {
|
||||||
@@ -779,35 +752,28 @@ const FirstMilePage = () => {
|
|||||||
header: "Invoice",
|
header: "Invoice",
|
||||||
meta: { headerClassName, cellClassName },
|
meta: { headerClassName, cellClassName },
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const hasDistance = row.original.exactKm != null && row.original.exactKm > 0;
|
// Only show once actually generated — not merely on distance.
|
||||||
const isPaid = (row.original as any).paid;
|
const invoice = row.original.invoice;
|
||||||
if (!hasDistance) {
|
if (!invoice) {
|
||||||
return <Text c="dimmed">—</Text>;
|
return <Text c="dimmed">—</Text>;
|
||||||
}
|
}
|
||||||
if (isPaid) {
|
const isPaid = (row.original as any).paid || invoice.status === "Paid";
|
||||||
return (
|
|
||||||
<Group gap="xs" wrap="nowrap">
|
|
||||||
<UnstyledButton
|
|
||||||
onClick={() => openInvoice(row.original)}
|
|
||||||
c="blue"
|
|
||||||
fw={500}
|
|
||||||
style={{ textDecoration: "underline", cursor: "pointer" }}
|
|
||||||
>
|
|
||||||
#345
|
|
||||||
</UnstyledButton>
|
|
||||||
<Badge color="green" variant="light" size="sm">Paid</Badge>
|
|
||||||
</Group>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
return (
|
||||||
<UnstyledButton
|
<Group gap="xs" wrap="nowrap">
|
||||||
onClick={() => openInvoice(row.original)}
|
<UnstyledButton
|
||||||
c="blue"
|
onClick={() =>
|
||||||
fw={500}
|
invoice.id
|
||||||
style={{ textDecoration: "underline", cursor: "pointer" }}
|
? navigate(`/dashboard/invoices/${invoice.id}`)
|
||||||
>
|
: toast({ title: "Invoice link unavailable", description: "Refresh after the API restart.", variant: "destructive" })
|
||||||
#345
|
}
|
||||||
</UnstyledButton>
|
c="blue"
|
||||||
|
fw={500}
|
||||||
|
style={{ textDecoration: "underline", cursor: "pointer" }}
|
||||||
|
>
|
||||||
|
{invoice.number}
|
||||||
|
</UnstyledButton>
|
||||||
|
{isPaid && <Badge color="green" variant="light" size="sm">Paid</Badge>}
|
||||||
|
</Group>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -881,16 +847,20 @@ const FirstMilePage = () => {
|
|||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
leftSection={<Ruler size={15} />}
|
leftSection={<Ruler size={15} />}
|
||||||
|
disabled={Boolean(row.original.invoice)}
|
||||||
onClick={() => openDistance(row.original.id)}
|
onClick={() => openDistance(row.original.id)}
|
||||||
>
|
>
|
||||||
Add distance
|
Add distance
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
leftSection={<Receipt size={15} />}
|
leftSection={<Receipt size={15} />}
|
||||||
disabled={!(row.original.exactKm != null && row.original.exactKm > 0)}
|
disabled={
|
||||||
onClick={() => openInvoice(row.original)}
|
!(row.original.exactKm != null && row.original.exactKm > 0) ||
|
||||||
|
Boolean(row.original.invoice)
|
||||||
|
}
|
||||||
|
onClick={() => generateInvoiceMutation.mutate(row.original.id)}
|
||||||
>
|
>
|
||||||
Generate Invoice
|
{row.original.invoice ? "Invoice generated" : "Generate Invoice"}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
{canPrint && (
|
{canPrint && (
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
@@ -906,6 +876,7 @@ const FirstMilePage = () => {
|
|||||||
<Menu.Item
|
<Menu.Item
|
||||||
leftSection={<Trash size={15} />}
|
leftSection={<Trash size={15} />}
|
||||||
color="red"
|
color="red"
|
||||||
|
disabled={Boolean(row.original.invoice)}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (confirm(`Delete first-mile record ${bookingRef(row.original)}?`)) {
|
if (confirm(`Delete first-mile record ${bookingRef(row.original)}?`)) {
|
||||||
deleteMutation.mutate(row.original.id);
|
deleteMutation.mutate(row.original.id);
|
||||||
@@ -1295,137 +1266,6 @@ const FirstMilePage = () => {
|
|||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
{/* Invoice modal */}
|
|
||||||
<Modal
|
|
||||||
opened={invoiceOpen}
|
|
||||||
onClose={closeInvoice}
|
|
||||||
title={<Text fw={600}>Invoice #345</Text>}
|
|
||||||
size="lg"
|
|
||||||
radius="lg"
|
|
||||||
centered
|
|
||||||
>
|
|
||||||
<Stack gap="md">
|
|
||||||
{invoiceRecord && (
|
|
||||||
<>
|
|
||||||
<Card withBorder padding="md" radius="md" bg="var(--mantine-color-gray-0)">
|
|
||||||
<Stack gap="sm">
|
|
||||||
<Group justify="space-between">
|
|
||||||
<Text fw={700}>EDR Freight</Text>
|
|
||||||
<Text fw={600} size="sm">Invoice #345</Text>
|
|
||||||
</Group>
|
|
||||||
<Divider />
|
|
||||||
<SimpleGrid cols={2} spacing="sm">
|
|
||||||
<InfoRow label="Booking" value={bookingRef(invoiceRecord)} />
|
|
||||||
<InfoRow label="Customer" value={customerName(invoiceRecord)} />
|
|
||||||
<InfoRow label="Pickup" value={pickupLocation(invoiceRecord)} />
|
|
||||||
<InfoRow label="Destination" value={destinationYardName(invoiceRecord)} />
|
|
||||||
<InfoRow label="Est. Distance" value={invoiceRecord.estimatedKm ? `${invoiceRecord.estimatedKm} km` : "—"} />
|
|
||||||
<InfoRow label="Actual Distance" value={invoiceRecord.exactKm ? `${invoiceRecord.exactKm} km` : "—"} />
|
|
||||||
<InfoRow label="Advanced Payment" value={formatPrice(invoiceRecord.advancedPayment)} />
|
|
||||||
<InfoRow label="Post Payment" value={formatPrice(invoiceRecord.remainingPayment)} />
|
|
||||||
</SimpleGrid>
|
|
||||||
<Divider />
|
|
||||||
<Group justify="flex-end">
|
|
||||||
<Stack gap={0} align="flex-end" style={{ minWidth: 200 }}>
|
|
||||||
<Group justify="space-between" w="100%">
|
|
||||||
<Text size="sm" c="dimmed">Post Payment</Text>
|
|
||||||
<Text size="sm">{formatPrice(invoiceRecord.remainingPayment)}</Text>
|
|
||||||
</Group>
|
|
||||||
<Group justify="space-between" w="100%">
|
|
||||||
<Text size="sm" c="dimmed">Advanced Payment</Text>
|
|
||||||
<Text size="sm">{formatPrice(invoiceRecord.advancedPayment)}</Text>
|
|
||||||
</Group>
|
|
||||||
<Divider my="xs" />
|
|
||||||
{(() => {
|
|
||||||
const postPayment = parseFloat(String(invoiceRecord.remainingPayment));
|
|
||||||
const advancedPayment = parseFloat(String(invoiceRecord.advancedPayment));
|
|
||||||
const difference = postPayment - advancedPayment;
|
|
||||||
|
|
||||||
if (difference > 0) {
|
|
||||||
return (
|
|
||||||
<Group justify="space-between" w="100%">
|
|
||||||
<Text size="sm" fw={600}>Remaining to Pay</Text>
|
|
||||||
<Text fw={700} c="orange">{formatPrice(difference)}</Text>
|
|
||||||
</Group>
|
|
||||||
);
|
|
||||||
} else if (difference < 0) {
|
|
||||||
return (
|
|
||||||
<Group justify="space-between" w="100%">
|
|
||||||
<Text size="sm" fw={600}>Refund</Text>
|
|
||||||
<Text fw={700} c="green">{formatPrice(Math.abs(difference))}</Text>
|
|
||||||
</Group>
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
return (
|
|
||||||
<Group justify="space-between" w="100%">
|
|
||||||
<Text size="sm" fw={600}>Status</Text>
|
|
||||||
<Text fw={700} c="blue">Settled</Text>
|
|
||||||
</Group>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
})()}
|
|
||||||
</Stack>
|
|
||||||
</Group>
|
|
||||||
</Stack>
|
|
||||||
</Card>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<Group justify="flex-end" gap="sm">
|
|
||||||
<Button variant="default" onClick={closeInvoice}>Close</Button>
|
|
||||||
</Group>
|
|
||||||
</Stack>
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
{/* Container Allocation modal */}
|
|
||||||
<Modal
|
|
||||||
opened={containerAllocationOpen}
|
|
||||||
onClose={closeContainerAllocation}
|
|
||||||
title={<Text fw={600}>Allocate Containers to Vehicles</Text>}
|
|
||||||
size="xl"
|
|
||||||
radius="lg"
|
|
||||||
centered
|
|
||||||
>
|
|
||||||
<Stack gap="md">
|
|
||||||
{activeRecord && (
|
|
||||||
<>
|
|
||||||
{/* Capacity guidance */}
|
|
||||||
{activeRecord.booking?.cargoType?.label === "BULK" ? (
|
|
||||||
<Alert color="blue" title="Bulk Cargo Allocation">
|
|
||||||
<Text size="sm">
|
|
||||||
Select multiple containers per vehicle based on capacity. Each vehicle can carry multiple containers if capacity allows.
|
|
||||||
</Text>
|
|
||||||
<Text size="xs" c="dimmed" mt="xs">
|
|
||||||
Capacity: TBD — TODO: add vehicle capacity_tons to vehicle API if missing
|
|
||||||
</Text>
|
|
||||||
</Alert>
|
|
||||||
) : (
|
|
||||||
<Alert color="blue">
|
|
||||||
<Text size="sm">
|
|
||||||
One vehicle per container. Each container will be assigned to a single vehicle.
|
|
||||||
</Text>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
<Divider />
|
|
||||||
|
|
||||||
{/* Container table */}
|
|
||||||
<FirstMileContainerAllocationTable
|
|
||||||
firstMileId={activeRecord.id}
|
|
||||||
containers={[
|
|
||||||
// TODO: Get containers from booking/first-mile data
|
|
||||||
// For now placeholder with TODO comment
|
|
||||||
]}
|
|
||||||
onSave={async (allocations) => {
|
|
||||||
await allocateMutation.mutateAsync(allocations);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<Group justify="flex-end" gap="sm">
|
|
||||||
<Button variant="default" onClick={closeContainerAllocation}>Close</Button>
|
|
||||||
</Group>
|
|
||||||
</Stack>
|
|
||||||
</Modal>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -45,6 +45,8 @@ export interface FirstMileRecord {
|
|||||||
vehicleId?: string | null;
|
vehicleId?: string | null;
|
||||||
booking?: FirstMileBooking | null;
|
booking?: FirstMileBooking | null;
|
||||||
vehicle?: FirstMileVehicle | null;
|
vehicle?: FirstMileVehicle | null;
|
||||||
|
/** Present only when an invoice has actually been generated (not on distance). */
|
||||||
|
invoice?: { id: string; number: string; status: string } | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
@@ -66,4 +68,6 @@ export const firstMileService = {
|
|||||||
api.post<FirstMileRecord>(FM.ACCEPT(bookingReference)),
|
api.post<FirstMileRecord>(FM.ACCEPT(bookingReference)),
|
||||||
remove: (id: string) =>
|
remove: (id: string) =>
|
||||||
api.delete<void>(FM.BY_ID(id)),
|
api.delete<void>(FM.BY_ID(id)),
|
||||||
|
generateInvoice: (id: string) =>
|
||||||
|
api.post<{ id: string; invoiceNumber?: string } | null>(`${FM.BASE}/${id}/invoice`),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -57,7 +57,15 @@ export interface LastMileRecord {
|
|||||||
booking?: LastMileBooking | null;
|
booking?: LastMileBooking | null;
|
||||||
vehicle?: LastMileVehicle | null;
|
vehicle?: LastMileVehicle | null;
|
||||||
/** Full set of vehicles serving this delivery (multi-truck). */
|
/** Full set of vehicles serving this delivery (multi-truck). */
|
||||||
vehicleAssignments?: Array<{ id: string; vehicleId: string; vehicle?: LastMileVehicle | null }>;
|
vehicleAssignments?: Array<{
|
||||||
|
id: string;
|
||||||
|
vehicleId: string;
|
||||||
|
containerNumber?: string | null;
|
||||||
|
distanceKm?: number | null;
|
||||||
|
vehicle?: LastMileVehicle | null;
|
||||||
|
}>;
|
||||||
|
/** Present only when an invoice has actually been generated (not on distance). */
|
||||||
|
invoice?: { id: string; number: string; status: string } | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
@@ -79,6 +87,15 @@ export const lastMileService = {
|
|||||||
api.post<LastMileRecord>(LM.ACCEPT(encodeURIComponent(bookingReference))),
|
api.post<LastMileRecord>(LM.ACCEPT(encodeURIComponent(bookingReference))),
|
||||||
remove: (id: string) =>
|
remove: (id: string) =>
|
||||||
api.delete<void>(LM.BY_ID(id)),
|
api.delete<void>(LM.BY_ID(id)),
|
||||||
setVehicles: (id: string, vehicleIds: string[]) =>
|
setVehicles: (
|
||||||
api.post<LastMileRecord>(`${LM.BASE}/${id}/vehicles`, { vehicleIds }),
|
id: string,
|
||||||
|
vehicles: Array<{ vehicleId: string; containerNumber?: string | null }>,
|
||||||
|
) => api.post<LastMileRecord>(`${LM.BASE}/${id}/vehicles`, { vehicles }),
|
||||||
|
setDistances: (
|
||||||
|
id: string,
|
||||||
|
distances: Array<{ vehicleId: string; distanceKm: number }>,
|
||||||
|
remainingPayment?: number,
|
||||||
|
) => api.post<LastMileRecord>(`${LM.BASE}/${id}/distances`, { distances, remainingPayment }),
|
||||||
|
generateInvoice: (id: string) =>
|
||||||
|
api.post<{ id: string; invoiceNumber?: string } | null>(`${LM.BASE}/${id}/invoice`),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -201,7 +201,7 @@ export interface BookingDetail {
|
|||||||
company?: BookingNamedRef & Partial<BookingCompany>;
|
company?: BookingNamedRef & Partial<BookingCompany>;
|
||||||
originYard?: BookingNamedRef;
|
originYard?: BookingNamedRef;
|
||||||
destinationYard?: BookingNamedRef;
|
destinationYard?: BookingNamedRef;
|
||||||
serviceType?: BookingNamedRef & { code?: string; priorityBonusPoints?: number; includesCustoms?: boolean };
|
serviceType?: BookingNamedRef & { code?: string; priorityBonusPoints?: number; includesCustoms?: boolean; includesFirstMile?: boolean; includesLastMile?: boolean };
|
||||||
cargoType?: BookingNamedRef;
|
cargoType?: BookingNamedRef;
|
||||||
shippingLine?: BookingNamedRef;
|
shippingLine?: BookingNamedRef;
|
||||||
bookingContainers?: BookingContainerLine[];
|
bookingContainers?: BookingContainerLine[];
|
||||||
|
|||||||
Reference in New Issue
Block a user