resolve merge conflict

This commit is contained in:
Marshal
2026-07-09 03:39:06 +00:00
174 changed files with 8297 additions and 3001 deletions

View File

@@ -68,6 +68,8 @@ import { AddCustomerTruckDto } from './dto/add-customer-truck.dto';
import { DepartCustomerTruckDto } from './dto/depart-customer-truck.dto';
import { LoadCustomerTruckDto } from './dto/load-customer-truck.dto';
import { CustomerTruckService } from './customer-truck.service';
import { FirstMileService } from '../first-mile/first-mile.service';
import { LastMileService } from '../last-mile/last-mile.service';
import { GenerateGrnDto } from './dto/generate-grn.dto';
import { ContainerReceiptService } from './container-receipt.service';
import { SignContractDto } from './dto/sign-contract.dto';
@@ -81,6 +83,60 @@ import {
hasFreightPermission,
} from "../../common/freight-permission.util";
interface MileVehicleSummary {
plate: string | null;
code: string | null;
driverName: string | null;
containerNumber: string | null;
distanceKm: number | null;
}
interface MileLegSummary {
status: string;
exactKm: number | null;
remainingPayment: number | null;
currency: string;
invoiced: boolean;
vehicles: MileVehicleSummary[];
}
/** Trim a first/last-mile record down to a customer-safe operational summary. */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function summarizeMileLeg(rec?: Record<string, any>): MileLegSummary | null {
if (!rec) return null;
const num = (v: unknown) => (v == null ? null : Number(v));
const assignments: Array<Record<string, any>> = rec.vehicleAssignments ?? []; // eslint-disable-line @typescript-eslint/no-explicit-any
const currency =
rec.vehicle?.currency ??
assignments[0]?.vehicle?.currency ??
rec.booking?.paymentCurrency ??
'ETB';
const vehicles: MileVehicleSummary[] = assignments.map((a) => ({
plate: a.vehicle?.plateNumber ?? null,
code: a.vehicle?.code ?? null,
driverName: a.vehicle?.assignedDriverName ?? null,
containerNumber: a.containerNumber ?? null,
distanceKm: num(a.distanceKm),
}));
if (!vehicles.length && rec.vehicle) {
vehicles.push({
plate: rec.vehicle.plateNumber ?? null,
code: rec.vehicle.code ?? null,
driverName: rec.vehicle.assignedDriverName ?? null,
containerNumber: null,
distanceKm: num(rec.exactKm),
});
}
return {
status: rec.status ?? '',
exactKm: num(rec.exactKm),
remainingPayment: num(rec.remainingPayment),
currency,
invoiced: Boolean(rec.invoice),
vehicles,
};
}
@ApiTags("bookings")
@Controller("bookings")
@ApiBearerAuth()
@@ -94,6 +150,8 @@ export class BookingsController {
private readonly bookingClearanceService: BookingClearanceService,
private readonly customerTruckService: CustomerTruckService,
private readonly containerReceiptService: ContainerReceiptService,
private readonly firstMileService: FirstMileService,
private readonly lastMileService: LastMileService,
) {}
@Post()
@@ -290,6 +348,33 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking);
}
@Get(':id/mile-summary')
@ApiOperation({
summary: 'First/last-mile operational summary for a booking (customer-safe)',
})
async mileSummary(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() user: TCurrentUser,
) {
// Customers may only see their own booking's mile summary.
const booking = await this.bookingsService.findById(id);
if (
!hasFreightPermission(user, FREIGHT_PERMS.bookings.view) &&
!hasFreightPermission(user, FREIGHT_PERMS.bookings.clearanceView)
) {
await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking);
}
const [first, last] = await Promise.all([
this.firstMileService.findAll({ bookingId: id, pageSize: 1 }),
this.lastMileService.findAll({ bookingId: id, pageSize: 1 }),
]);
return {
firstMile: summarizeMileLeg(first.data[0]),
lastMile: summarizeMileLeg(last.data[0]),
};
}
@Post(':id/customer-truck-assignment')
@ApiOperation({ summary: 'Customer assigns external truck and driver for terminal pickup' })
async assignCustomerTruck(
@@ -350,6 +435,21 @@ export class BookingsController {
return this.customerTruckService.addTruck(id, dto);
}
@Patch(':id/customer-trucks/:assignmentId')
@ApiOperation({ summary: 'Edit a not-yet-arrived customer truck (plate/driver/type + containers)' })
async updateCustomerTruck(
@Param('id', ParseUUIDPipe) id: string,
@Param('assignmentId', ParseUUIDPipe) assignmentId: string,
@Body() dto: AddCustomerTruckDto,
@CurrentUser() user: TCurrentUser,
) {
const booking = await this.bookingsService.findById(id);
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking);
}
return this.customerTruckService.updateTruck(id, assignmentId, dto);
}
@Delete(':id/customer-trucks/:assignmentId')
@ApiOperation({ summary: 'Remove a not-yet-arrived customer truck from a booking' })
async removeCustomerTruck(

View File

@@ -12,6 +12,7 @@ import { FileUploadSettingsModule } from '../file-upload-settings/file-upload-se
import { SignaturesModule } from '../signatures/signatures.module';
import { BillingModule } from '../billing/billing.module';
import { FirstMileModule } from '../first-mile/first-mile.module';
import { LastMileModule } from '../last-mile/last-mile.module';
import { BookingContractService } from './booking-contract.service';
import { BookingInvoiceService } from './booking-invoice.service';
// import { BookingPaymentController } from './booking-payment.controller';
@@ -70,6 +71,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
NotificationsModule,
NotificationInboxModule,
forwardRef(() => FirstMileModule),
forwardRef(() => LastMileModule),
forwardRef(() => TrainSchedulingModule),
forwardRef(() => ContractsModule),
forwardRef(() => ContractsModule),

View File

@@ -1424,6 +1424,20 @@ export class BookingsService {
schedule?.status ?? null;
}
// A generated-but-unsigned SELF_HAUL handover means the customer must approve
// delivery from the portal (booking-based, one per booking). EDR last-mile
// handovers are per delivering truck and signed by the receiver at the door,
// so they never surface the portal "Approve delivery" action.
const [pendingHandover] = await this.dataSource.query(
`SELECT 1 FROM freight.booking_handovers
WHERE booking_id = $1 AND signed_at IS NULL AND deleted_at IS NULL
AND mile_type = 'SELF_HAUL'
LIMIT 1`,
[id],
);
(booking as Booking & { handoverAwaitingSignature?: boolean }).handoverAwaitingSignature =
Boolean(pendingHandover);
return booking;
}

View File

@@ -42,21 +42,30 @@ export class CustomerTruckService {
const booking = await this.loadBookingGuard(bookingId);
this.assertSelfHaulPaid(booking);
const isExport = booking.tradeDirection === 'EXPORT';
const requested = (dto.containerNumbers ?? []).map((n) => n.trim().toUpperCase());
// EXPORT: the truck delivers 12 known containers. IMPORT: containers are
// not pre-specified — they are registered + weighed when the truck leaves.
if (isExport) {
if (requested.length < 1 || requested.length > 2) {
throw new BadRequestException('An export truck must carry 1 or 2 of the booking containers');
}
} else if (requested.length > 2) {
// Both import and export specify the containers each truck carries. Capacity
// is size-based: a 40ft container fills the truck (max 1); two 20ft containers
// fit (max 2), no size mixing. #trucks <= #containers follows naturally since
// each container is assigned to exactly one truck.
if (requested.length < 1) {
throw new BadRequestException('Select at least one container for this truck');
}
if (requested.length > 2) {
throw new BadRequestException('A truck carries at most 2 containers');
}
if (requested.length) {
const bookingNumbers = await this.bookingContainerNumbers(bookingId);
// Never assign more trucks than the booking has containers.
const existingTrucks = await this.dataSource
.getRepository(CustomerTruckAssignment)
.count({ where: { bookingId } });
if (existingTrucks + 1 > bookingNumbers.length) {
throw new BadRequestException(
`Cannot assign more trucks than containers — this booking has ${bookingNumbers.length} container(s) and ${existingTrucks} truck(s) already assigned.`,
);
}
for (const n of requested) {
if (!bookingNumbers.includes(n)) {
throw new BadRequestException(`Container ${n} is not one of this booking's containers`);
@@ -68,6 +77,13 @@ export class CustomerTruckService {
throw new ConflictException(`Container ${n} is already loaded onto another truck`);
}
}
// Size cap: a 40ft container fills the truck.
const sizes = await this.containerSizes(bookingId, requested);
if (sizes.some((s) => s.includes('40')) && requested.length > 1) {
throw new BadRequestException(
'A 40ft container fills the truck — assign only 1 container to this truck',
);
}
}
await this.dataSource.transaction(async (manager) => {
@@ -133,6 +149,76 @@ export class CustomerTruckService {
return this.listTrucks(bookingId);
}
/**
* Edit a truck assignment — plate/driver/type and the containers it carries.
* Allowed only until the truck has arrived (same guard as removal). Container
* rules mirror {@link addTruck}: 12 of the booking's containers, none already
* on another truck, and a 40ft container fills the truck (max 1).
*/
async updateTruck(
bookingId: string,
assignmentId: string,
dto: AddCustomerTruckDto,
): Promise<CustomerTruckAssignment[]> {
const booking = await this.loadBookingGuard(bookingId);
this.assertSelfHaulPaid(booking);
const assignment = await this.assignments.findByIdWithContainers(assignmentId);
if (!assignment || assignment.bookingId !== bookingId) {
throw new NotFoundException('Truck assignment not found for this booking');
}
if (assignment.arrivedAt) {
throw new ConflictException('Cannot edit a truck that has already arrived');
}
const requested = (dto.containerNumbers ?? []).map((n) => n.trim().toUpperCase());
if (requested.length < 1) {
throw new BadRequestException('Select at least one container for this truck');
}
if (requested.length > 2) {
throw new BadRequestException('A truck carries at most 2 containers');
}
const bookingNumbers = await this.bookingContainerNumbers(bookingId);
for (const n of requested) {
if (!bookingNumbers.includes(n)) {
throw new BadRequestException(`Container ${n} is not one of this booking's containers`);
}
}
// Exclude THIS truck's own containers so re-saving the same set is allowed.
const assignedElsewhere = await this.assignedContainerNumbersExcept(bookingId, assignmentId);
for (const n of requested) {
if (assignedElsewhere.includes(n)) {
throw new ConflictException(`Container ${n} is already loaded onto another truck`);
}
}
const sizes = await this.containerSizes(bookingId, requested);
if (sizes.some((s) => s.includes('40')) && requested.length > 1) {
throw new BadRequestException(
'A 40ft container fills the truck — assign only 1 container to this truck',
);
}
await this.dataSource.transaction(async (manager) => {
await manager.getRepository(CustomerTruckAssignment).update(assignmentId, {
plateNumber: dto.truckPlateNumber.trim().toUpperCase(),
driverName: dto.driverName.trim(),
truckType: dto.truckType.trim(),
});
await manager.getRepository(CustomerTruckContainer).softDelete({ assignmentId });
await manager.getRepository(CustomerTruckContainer).save(
requested.map((containerNumber) =>
manager.getRepository(CustomerTruckContainer).create({
assignmentId,
bookingId,
containerNumber,
}),
),
);
});
return this.listTrucks(bookingId);
}
/**
* Register an IMPORT self-haul truck leaving the port: the containers it
* actually loaded (replacing any provisional list) and its weighed gross.
@@ -226,6 +312,13 @@ export class CustomerTruckService {
if (assignment.departedAt) {
throw new ConflictException('This truck has already left — its load is locked');
}
// Containers can only be loaded after the truck has physically arrived at the
// warehouse (arrival weighing recorded). Assignment alone is just planning.
if (!assignment.arrivedAt) {
throw new BadRequestException(
'Record the truck arrival before loading — containers can only be loaded onto an arrived truck',
);
}
const requested = (dto.containerNumbers ?? []).map((n) => n.trim().toUpperCase());
if (!requested.length) {
@@ -244,30 +337,35 @@ export class CustomerTruckService {
}
}
const grossKg = await this.vgmKgForContainers(bookingId, requested);
const grossTons = await this.vgmTonsForContainers(bookingId, requested);
await this.dataSource.transaction(async (manager) => {
await manager.getRepository(CustomerTruckContainer).softDelete({ assignmentId });
// Operator loading the truck: stamp loaded_at so these containers move to
// the LOADED stage (customer assignment alone leaves loaded_at null).
const loadedAt = new Date();
await manager.getRepository(CustomerTruckContainer).save(
requested.map((containerNumber) =>
manager.getRepository(CustomerTruckContainer).create({
assignmentId,
bookingId,
containerNumber,
loadedAt,
}),
),
);
// Provisional gross from the loaded containers' VGM — overridden by the
// weighed gross on departure.
// Provisional gross (tonnes) from the loaded containers' VGM — overridden
// by the weighed gross on departure. (Column is *_kg but holds tonnes.)
await manager.getRepository(CustomerTruckAssignment).update(assignmentId, {
grossWeightKg: grossKg,
grossWeightKg: grossTons,
});
});
return this.listTrucks(bookingId);
}
private async vgmKgForContainers(bookingId: string, numbers: string[]): Promise<number> {
const [row]: Array<{ kg: string }> = await this.dataSource.query(
`SELECT COALESCE(SUM(bcu.vgm_tons), 0) * 1000 AS kg
/** Summed VGM (tonnes) of the given containers — provisional truck gross. */
private async vgmTonsForContainers(bookingId: string, numbers: string[]): Promise<number> {
const [row]: Array<{ tons: string }> = await this.dataSource.query(
`SELECT COALESCE(SUM(bcu.vgm_tons), 0) AS tons
FROM freight.booking_container_units bcu
JOIN freight.booking_container bc
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
@@ -276,7 +374,7 @@ export class CustomerTruckService {
AND bcu.deleted_at IS NULL`,
[bookingId, numbers],
);
return Number(row?.kg ?? 0);
return Number(row?.tons ?? 0);
}
/**
@@ -399,4 +497,20 @@ export class CustomerTruckService {
);
return rows.map((r) => r.containerNumber.trim().toUpperCase());
}
/** Contract container sizes (e.g. "20ft" / "40ft") for the given container numbers. */
private async containerSizes(bookingId: string, numbers: string[]): Promise<string[]> {
if (!numbers.length) return [];
const rows: Array<{ size: string | null }> = await this.dataSource.query(
`SELECT bc.container_size AS "size"
FROM freight.booking_container_units bcu
JOIN freight.booking_container bc
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
WHERE bc.booking_id = $1
AND UPPER(bcu.container_number) = ANY($2)
AND bcu.deleted_at IS NULL`,
[bookingId, numbers],
);
return rows.map((r) => (r.size ?? '').trim());
}
}

View File

@@ -23,4 +23,12 @@ export class CustomerTruckContainer extends BaseEntity {
@Column({ name: 'container_number', type: 'varchar', length: 64 })
containerNumber!: string;
/**
* When the container was actually loaded onto the truck by the operator.
* Null = customer-assigned (planned) but not yet loaded. Stage LOADED requires
* this to be set, so customer assignment alone does not mark a container loaded.
*/
@Column({ name: 'loaded_at', type: 'timestamptz', nullable: true })
loadedAt?: Date | null;
}