fix(warehouses): detention groups by canonical truck type

Join truck_types via vehicles.truck_type_id (normalized legacy
vehicle_type only as fallback) so type renames can't unmatch detention
rules and FK-less vehicles keep billing.
This commit is contained in:
Hagernesh
2026-07-23 13:40:14 +00:00
parent 227a561e89
commit cf8a2e928d
41 changed files with 1495 additions and 109 deletions

View File

@@ -1,4 +1,4 @@
import { IsArray, IsOptional, IsString, IsUUID, ValidateNested } from 'class-validator';
import { IsArray, IsNumber, IsOptional, IsString, IsUUID, Min, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';
export class FirstMileVehicleInput {
@@ -8,6 +8,18 @@ export class FirstMileVehicleInput {
@IsOptional()
@IsString()
containerNumber?: string;
/** Bulk: tonnage this truck hauls. */
@IsOptional()
@IsNumber()
@Min(0)
tons?: number;
/** Bulk: optional item/piece count. */
@IsOptional()
@IsNumber()
@Min(0)
quantity?: number;
}
/** Replace the full set of vehicles (with their container numbers) on a pickup. */

View File

@@ -36,4 +36,12 @@ export class FirstMileVehicleAssignment extends BaseEntity {
/** 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;
/** Bulk: tonnage this truck hauls — assigned tonnage draws down the booking total. */
@Column({ name: 'tons', type: 'numeric', precision: 14, scale: 3, nullable: true })
tons?: number | null;
/** Bulk: optional item/piece count on this truck. */
@Column({ name: 'quantity', type: 'integer', nullable: true })
quantity?: number | null;
}

View File

@@ -532,17 +532,47 @@ export class FirstMileService {
*/
async setVehicles(
id: string,
inputs: Array<{ vehicleId: string; containerNumber?: string | null }>,
inputs: Array<{
vehicleId: string;
containerNumber?: string | null;
tons?: number | null;
quantity?: number | null;
}>,
): Promise<FirstMile> {
const existing = await this.findById(id);
// Dedupe by vehicleId, keeping the container number; preserve order.
const desiredMap = new Map<string, string | null>();
// Dedupe by vehicleId, keeping the load details; preserve order.
const desiredMap = new Map<
string,
{ containerNumber: string | null; tons: number | null; quantity: number | null }
>();
for (const inp of inputs) {
if (inp.vehicleId) desiredMap.set(inp.vehicleId, inp.containerNumber ?? null);
if (inp.vehicleId) {
desiredMap.set(inp.vehicleId, {
containerNumber: inp.containerNumber ?? null,
tons: inp.tons ?? null,
quantity: inp.quantity ?? null,
});
}
}
const desired = [...desiredMap.keys()];
const desiredSet = new Set(desired);
// Bulk drawdown: assigned tonnage may not exceed what the booking declares.
const totalTons = [...desiredMap.values()].reduce((s, v) => s + (Number(v.tons) || 0), 0);
if (totalTons > 0 && existing.bookingId) {
const [b]: Array<{ vgm: string | null }> = await this.dataSource.query(
`SELECT cargo_total_weight_vgm AS vgm FROM freight.bookings
WHERE id = $1 AND deleted_at IS NULL`,
[existing.bookingId],
);
const declared = Number(b?.vgm ?? 0);
if (declared > 0 && totalTons > declared + 0.001) {
throw new BadRequestException(
`Assigned tonnage (${totalTons} t) exceeds the booking's declared ${declared} t`,
);
}
}
const manager = this.dataSource.manager;
const current = await manager.find(FirstMileVehicleAssignment, {
where: { firstMileId: id },
@@ -555,12 +585,16 @@ export class FirstMileService {
)];
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),
);
// Vehicles that stay but whose load details changed.
const changed = current.filter((a) => {
const want = desiredMap.get(a.vehicleId);
if (!want) return false;
return (
(a.containerNumber ?? null) !== want.containerNumber ||
(a.tons == null ? null : Number(a.tons)) !== want.tons ||
(a.quantity ?? null) !== want.quantity
);
});
await this.dataSource.transaction(async (tx) => {
if (removed.length) {
@@ -570,17 +604,25 @@ export class FirstMileService {
});
}
for (const vehicleId of added) {
const want = desiredMap.get(vehicleId);
await tx.insert(FirstMileVehicleAssignment, {
firstMileId: id,
vehicleId,
containerNumber: desiredMap.get(vehicleId) ?? null,
containerNumber: want?.containerNumber ?? null,
tons: want?.tons ?? null,
quantity: want?.quantity ?? null,
});
}
for (const row of changed) {
const want = desiredMap.get(row.vehicleId);
await tx.update(
FirstMileVehicleAssignment,
{ firstMileId: id, vehicleId: row.vehicleId },
{ containerNumber: desiredMap.get(row.vehicleId) ?? null },
{
containerNumber: want?.containerNumber ?? null,
tons: want?.tons ?? null,
quantity: want?.quantity ?? null,
},
);
}
});