mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 04:08:11 +00:00
Truck detention was timed once per delivery, so every truck on a multi-truck last mile was billed the same number of days regardless of when it actually arrived or was released. Each truck now carries its own detention clock (destination arrival → release) with its own rule match, chargeable days and amount; the modal records and displays the window per truck, and the summary shows the longest detention plus the combined total. Also corrected a false no detention rule matches warning that appeared whenever more than one truck was assigned.
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsArray, IsDateString, IsOptional, IsUUID, ValidateNested } from 'class-validator';
|
||||
|
||||
/**
|
||||
* One truck's detention window. Each truck reaches the destination and is
|
||||
* released at its own time, so detention days differ between trucks on the
|
||||
* same delivery. Null clears the value (falls back to the leg-level pair).
|
||||
*/
|
||||
export class TruckDetentionTimeInput {
|
||||
@IsUUID()
|
||||
vehicleId!: string;
|
||||
|
||||
/** Detention clock start — this truck reached the destination. */
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
destinationArrivedAt?: string | null;
|
||||
|
||||
/** Detention clock end — this truck was released/returned. Omit = still out. */
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
returnedAt?: string | null;
|
||||
}
|
||||
|
||||
export class SetDetentionTimesDto {
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => TruckDetentionTimeInput)
|
||||
trucks!: TruckDetentionTimeInput[];
|
||||
}
|
||||
@@ -51,6 +51,21 @@ export class LastMileVehicleAssignment extends BaseEntity {
|
||||
@Column({ name: 'departed_at', type: 'timestamptz', nullable: true })
|
||||
departedAt?: Date | null;
|
||||
|
||||
/**
|
||||
* Detention clock START for THIS truck: reached the delivery destination.
|
||||
* Distinct from `arrivedAt` (warehouse gate-in). Null falls back to the
|
||||
* leg-level `last_mile.arrived_at`.
|
||||
*/
|
||||
@Column({ name: 'destination_arrived_at', type: 'timestamptz', nullable: true })
|
||||
destinationArrivedAt?: Date | null;
|
||||
|
||||
/**
|
||||
* Detention clock END for THIS truck: released / returned by the customer.
|
||||
* Null (with no leg-level `delivered_at`) means still out — detention accrues.
|
||||
*/
|
||||
@Column({ name: 'returned_at', type: 'timestamptz', nullable: true })
|
||||
returnedAt?: Date | null;
|
||||
|
||||
/** Weighed gross on exit, in TONNES (not kg — see the migration note). */
|
||||
@Column({ name: 'gross_weight_tons', type: 'numeric', precision: 14, scale: 3, nullable: true })
|
||||
grossWeightTons?: number | null;
|
||||
|
||||
@@ -23,6 +23,7 @@ import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
import { CreateLastMileDto } from './dto/create-last-mile.dto';
|
||||
import { UpdateLastMileDto } from './dto/update-last-mile.dto';
|
||||
import { SetVehiclesDto } from './dto/set-vehicles.dto';
|
||||
import { SetDetentionTimesDto } from './dto/set-detention-times.dto';
|
||||
import { SetDistancesDto } from './dto/set-distances.dto';
|
||||
import { RecordProofOfDeliveryDto } from './dto/record-proof-of-delivery.dto';
|
||||
import { LastMileStatus } from './entities/last-mile.entity';
|
||||
@@ -131,6 +132,18 @@ export class LastMileController {
|
||||
return this.lastMileService.setDistances(id, dto.distances, dto.remainingPayment);
|
||||
}
|
||||
|
||||
@Post(':id/detention-times')
|
||||
@BookingStaff(FREIGHT_PERMS.lastMile.update)
|
||||
@ApiOperation({
|
||||
summary: 'Set each truck\'s own detention window (arrived at destination / returned)',
|
||||
})
|
||||
async setDetentionTimes(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: SetDetentionTimesDto,
|
||||
) {
|
||||
return this.lastMileService.setDetentionTimes(id, dto.trucks);
|
||||
}
|
||||
|
||||
@Post(':id/proof-of-delivery')
|
||||
@BookingStaff(FREIGHT_PERMS.lastMile.update)
|
||||
@UseInterceptors(AnyFilesInterceptor())
|
||||
|
||||
@@ -869,6 +869,46 @@ export class LastMileService {
|
||||
* sum and drives billing; `remainingPayment` (total km × rate) is recomputed
|
||||
* client-side. Does NOT generate an invoice — that's a separate explicit step.
|
||||
*/
|
||||
/**
|
||||
* Per-truck detention windows. Each truck reaches the destination and is
|
||||
* released at its own time, so every truck gets its own clock (and therefore
|
||||
* its own chargeable days). Locked once the detention invoice exists.
|
||||
*/
|
||||
async setDetentionTimes(
|
||||
id: string,
|
||||
trucks: Array<{
|
||||
vehicleId: string;
|
||||
destinationArrivedAt?: string | null;
|
||||
returnedAt?: string | null;
|
||||
}>,
|
||||
): Promise<LastMile> {
|
||||
await this.findById(id);
|
||||
|
||||
const invoices = await this.billing.findBySourceIds('last_mile', [id]);
|
||||
if (invoices.length) {
|
||||
throw new BadRequestException(
|
||||
'Detention times cannot be changed after the invoice is generated',
|
||||
);
|
||||
}
|
||||
|
||||
for (const t of trucks) {
|
||||
const start = t.destinationArrivedAt ? new Date(t.destinationArrivedAt) : null;
|
||||
const end = t.returnedAt ? new Date(t.returnedAt) : null;
|
||||
if (start && end && end.getTime() < start.getTime()) {
|
||||
throw new BadRequestException(
|
||||
'A truck cannot be returned before it arrived — check the detention times',
|
||||
);
|
||||
}
|
||||
await this.dataSource.manager.update(
|
||||
LastMileVehicleAssignment,
|
||||
{ lastMileId: id, vehicleId: t.vehicleId },
|
||||
{ destinationArrivedAt: start, returnedAt: end },
|
||||
);
|
||||
}
|
||||
|
||||
return this.findById(id);
|
||||
}
|
||||
|
||||
async setDistances(
|
||||
id: string,
|
||||
distances: Array<{ vehicleId: string; distanceKm: number }>,
|
||||
|
||||
Reference in New Issue
Block a user