Files
edr-platform/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.service.ts
Hagernesh bf8eadbc85 fix: portal cannot load last-mile confirmation request
LAST_MILE_REQUESTS URL constants were missing the /api prefix every other
endpoint in URLS.ts carries — all five calls (get, submit, contract
view/document/sign) 404'd against the deployed API, so the departure
notification's confirm link never loaded for the customer.

Also widen GET /last-mile-requests/:id from @BookingStaff to @MixedAudience
with the same ownership check submit()/sign() already use — the confirm
page calls this as its first request, before the customer has done
anything else, so it can't be staff-only.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 12:14:18 +00:00

451 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { Cron } from '@nestjs/schedule';
import { DataSource, FindOptionsWhere } from 'typeorm';
import { Freight, LastMileRequestStatus } from '@edr/types';
import {
LastMileCharge,
computeLastMileCharge,
lastMileShipmentShape,
} from '../../common/last-mile-charge.util';
import { estimateMileKm } from '../../common/mile-distance.util';
import { usesEdrMileService } from '../../common/mile-haulage.util';
import { RatesService } from '../rule-engine/services/rates.service';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { BookingsRepository } from '../bookings/bookings.repository';
import { BookingsService } from '../bookings/bookings.service';
import { Booking } from '../bookings/entities/booking.entity';
import { BillingService } from '../billing/billing.service';
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
import { NotificationAudience, NotificationPriority, NotificationType } from '@edr/types';
import { LastMileService } from '../last-mile/last-mile.service';
import { Vehicle, VehicleAvailability, VehicleStatus } from '../vehicles/entities/vehicle.entity';
import { LastMileRequest } from './entities/last-mile-request.entity';
import { LastMileRequestsRepository } from './last-mile-requests.repository';
type ListFilter = {
status?: LastMileRequestStatus;
bookingId?: string;
page?: number;
pageSize?: number;
};
/** Just what remind() needs off a departed schedule — deliberately not the full
* `TrainSchedule` entity so this module never has to import train-scheduling code. */
type DepartedSchedule = { id: string; trainNumber?: string | null };
@Injectable()
export class LastMileRequestsService {
private readonly logger = new Logger(LastMileRequestsService.name);
constructor(
private readonly requestsRepository: LastMileRequestsRepository,
private readonly bookingsRepository: BookingsRepository,
private readonly bookingsService: BookingsService,
private readonly lastMileService: LastMileService,
private readonly billing: BillingService,
private readonly notifications: NotificationInboxService,
private readonly ratesService: RatesService,
private readonly dataSource: DataSource,
) {}
/** Container numbers on the booking (upper-cased) — mirrors LastMileService's own helper. */
private async bookingContainerNumbers(bookingId: string): Promise<string[]> {
const rows: Array<{ containerNumber: string }> = await this.dataSource.query(
`SELECT bcu.container_number AS "containerNumber"
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 bcu.deleted_at IS NULL`,
[bookingId],
);
return rows.map((r) => r.containerNumber.trim().toUpperCase());
}
/**
* Poll for trains that have departed Djibouti and remind their eligible
* bookings. Deliberately a self-contained poller (raw SQL against
* `import_djibouti_operations`/`train_schedules`, no import of train-scheduling
* module code) rather than a hook inside `TrainSchedulingService.dispatchSchedule`
* — keeps this feature decoupled from that module entirely. `remindForDeparture`
* is idempotent per (bookingId, scheduleId), so re-scanning the same recent
* window on every tick is safe — a schedule already fully reminded is a no-op.
*/
@Cron('*/2 * * * *', { name: 'last-mile-request-departure-scan' })
async scanDepartedSchedules(): Promise<void> {
let schedules: DepartedSchedule[] = [];
try {
schedules = await this.dataSource.query(
`SELECT ts.id AS "id", ts.train_number AS "trainNumber"
FROM freight.import_djibouti_operations op
JOIN freight.train_schedules ts
ON ts.id = op.train_schedule_id AND ts.deleted_at IS NULL
WHERE op.deleted_at IS NULL
AND op.departed_from_djibouti_at IS NOT NULL
AND op.departed_from_djibouti_at > now() - interval '14 days'`,
);
} catch (err) {
this.logger.warn(`Failed to scan for departed schedules: ${(err as Error).message}`);
return;
}
for (const schedule of schedules) {
await this.remindForDeparture(schedule);
}
}
/**
* Fired for a train that has departed Djibouti (import direction). For every booking
* already loaded on this schedule that bought EDR last-mile, idempotently
* creates the AWAITING_CONFIRMATION request and reminds both the customer and
* the Truck & Machinery department. Fire-and-forget per booking — one bad
* booking must never block the rest of the departure notification.
*/
async remindForDeparture(schedule: DepartedSchedule): Promise<void> {
let bookingIds: string[] = [];
try {
const rows: Array<{ bookingId: string }> = await this.dataSource.query(
`SELECT booking_id AS "bookingId"
FROM freight.train_schedule_bookings
WHERE train_schedule_id = $1 AND loading_status = 'LOADED' AND deleted_at IS NULL`,
[schedule.id],
);
bookingIds = rows.map((r) => r.bookingId);
} catch (err) {
this.logger.warn(`Failed to load schedule bookings for ${schedule.id}: ${(err as Error).message}`);
return;
}
if (!bookingIds.length) return;
for (const bookingId of bookingIds) {
try {
const booking = await this.bookingsRepository.findById(bookingId);
if (!booking) continue;
if (
!usesEdrMileService({
tradeDirection: booking.tradeDirection,
firstMile: booking.firstMilePickupAddress ?? null,
lastMile: booking.lastMileDeliveryAddress ?? null,
})
) {
continue;
}
await this.remind(booking, schedule);
} catch (err) {
this.logger.warn(`Failed to remind booking ${bookingId} for schedule ${schedule.id}: ${(err as Error).message}`);
}
}
}
private async remind(booking: Booking, schedule: DepartedSchedule): Promise<void> {
const [existing] = await this.requestsRepository.findAll({
where: { bookingId: booking.id, trainScheduleId: schedule.id },
take: 1,
});
if (existing) return; // already reminded for this departure
const request = await this.requestsRepository.create({
bookingId: booking.id,
trainScheduleId: schedule.id,
status: LastMileRequestStatus.AwaitingConfirmation,
reminderSentAt: new Date(),
});
const trainLabel = schedule.trainNumber ? `train ${schedule.trainNumber}` : 'your train';
if (booking.companyId) {
void this.notifications.notify({
recipients: { companyId: booking.companyId },
audience: NotificationAudience.PORTAL,
type: NotificationType.SCHEDULE_UPDATE,
title: 'Confirm your last-mile delivery',
body: `${trainLabel} carrying booking ${booking.reference ?? booking.id} has departed Djibouti. Confirm which containers go via EDR last-mile.`,
link: `/bookings/${booking.id}/last-mile-confirm?requestId=${request.id}`,
data: { bookingId: booking.id, requestId: request.id },
priority: NotificationPriority.HIGH,
});
}
void this.notifications.notify({
recipients: { permissionKeys: [FREIGHT_PERMS.lastMile.requestReview] },
audience: NotificationAudience.BACKOFFICE,
type: NotificationType.SCHEDULE_UPDATE,
title: 'Last-mile confirmation expected',
body: `${trainLabel} carrying booking ${booking.reference ?? booking.id} has departed Djibouti — awaiting the customer's last-mile confirmation.`,
link: `/dashboard/operations/last-mile?tab=requests`,
data: { bookingId: booking.id, requestId: request.id },
priority: NotificationPriority.HIGH,
});
}
async findAll(filter: ListFilter = {}): Promise<{
data: LastMileRequest[];
meta: { total: number; page: number; pageSize: number; totalPages: number };
}> {
const page = filter.page ?? 1;
const pageSize = filter.pageSize ?? 50;
const where: FindOptionsWhere<LastMileRequest> = {};
if (filter.status) where.status = filter.status;
if (filter.bookingId) where.bookingId = filter.bookingId;
const [data, total] = await this.requestsRepository.findAndCount({
where,
relations: { booking: { company: true } },
order: { createdAt: 'DESC' },
skip: (page - 1) * pageSize,
take: pageSize,
});
return {
data,
meta: { total, page, pageSize, totalPages: Math.max(1, Math.ceil(total / pageSize)) },
};
}
/**
* `userId` is set only when a portal customer calls this directly (the
* confirm-page deep link from the departure notification, before they've
* submitted or signed anything) — staff and every internal caller pass
* nothing and skip the check, same convention as submit()/sign().
*/
async findById(id: string, userId?: string | null): Promise<LastMileRequest> {
const record = await this.requestsRepository.findById(id, {
relations: { booking: { company: true } },
});
if (!record) throw new NotFoundException(`Last-mile request ${id} not found`);
if (userId) {
const companyId = await this.bookingsService.resolveCustomerCompanyId(userId);
if (companyId && record.booking?.companyId && companyId !== record.booking.companyId) {
throw new BadRequestException('This request does not belong to your company');
}
}
return record;
}
/**
* Rule-based price estimate for the approval dialog: estimated km (yard GPS →
* delivery point, straight-line) × the LIVE last-mile rate rules against the
* containers the customer confirmed (or the booking's bulk tonnage). All
* nulls when km or rate coverage is missing — the dialog then behaves as
* before (manually typed advance).
*/
async priceEstimate(id: string): Promise<{
estimatedKm: number | null;
mode: LastMileCharge['mode'] | null;
currency: string | null;
total: number | null;
lines: Array<{ description: string; amount: number }>;
}> {
const request = await this.findById(id);
const estimatedKm = await estimateMileKm(this.dataSource, request.bookingId, 'LAST');
if (!estimatedKm) {
return { estimatedKm: null, mode: null, currency: null, total: null, lines: [] };
}
const shape = await lastMileShipmentShape(
this.dataSource,
request.bookingId,
request.requestedContainerNumbers ?? [],
);
const charge = computeLastMileCharge({
...shape,
km: estimatedKm,
liveRates: await this.ratesService.findLiveRatesDetailed(),
});
return {
estimatedKm,
mode: charge?.mode ?? null,
currency: charge?.currency ?? null,
total: charge?.total ?? null,
lines: (charge?.lines ?? []).map(({ description, amount }) => ({ description, amount })),
};
}
/** Free (ACTIVE + unassigned) truck count — informational only for the approval screen. */
async freeTruckCount(): Promise<number> {
return this.dataSource.manager.count(Vehicle, {
where: { status: VehicleStatus.ACTIVE, availability: VehicleAvailability.FREE },
});
}
async submit(
id: string,
userId: string | null,
containerNumbers: string[],
deliveryDate: string,
): Promise<LastMileRequest> {
const request = await this.findById(id);
if (request.status !== LastMileRequestStatus.AwaitingConfirmation) {
throw new BadRequestException(`Request is already ${request.status.toLowerCase()}`);
}
if (userId) {
const companyId = await this.bookingsService.resolveCustomerCompanyId(userId);
if (companyId && request.booking?.companyId && companyId !== request.booking.companyId) {
throw new BadRequestException('This request does not belong to your company');
}
}
const bookingNumbers = await this.bookingContainerNumbers(request.bookingId);
const selected = containerNumbers.map((n) => n.trim().toUpperCase());
const unknown = selected.filter((n) => !bookingNumbers.includes(n));
if (unknown.length) {
throw new BadRequestException(`Container(s) not on this booking: ${unknown.join(', ')}`);
}
await this.requestsRepository.update(id, {
requestedContainerNumbers: selected,
requestedDeliveryDate: deliveryDate,
status: LastMileRequestStatus.Submitted,
submittedByUserId: userId,
submittedAt: new Date(),
} as Partial<LastMileRequest>);
void this.notifications.notify({
recipients: { permissionKeys: [FREIGHT_PERMS.lastMile.requestReview] },
audience: NotificationAudience.BACKOFFICE,
type: NotificationType.REQUEST_SUBMITTED,
title: 'Last-mile request ready for review',
body: `Booking ${request.booking?.reference ?? request.bookingId} confirmed ${selected.length} container(s) for EDR last-mile.`,
link: `/dashboard/operations/last-mile?tab=requests`,
data: { bookingId: request.bookingId, requestId: request.id },
priority: NotificationPriority.NORMAL,
});
return this.findById(id);
}
async approve(
id: string,
staffId: string | null,
advanceOverride?: number | null,
): Promise<LastMileRequest> {
const request = await this.findById(id);
if (request.status !== LastMileRequestStatus.Submitted) {
throw new BadRequestException(`Only a submitted request can be approved (current status: ${request.status})`);
}
const booking = request.booking ?? (await this.bookingsRepository.findById(request.bookingId));
if (!booking) throw new NotFoundException(`Booking ${request.bookingId} not found`);
// The live last-mile rates are the authority on the advance (km × rate);
// the chief's typed amount is only an override — and the only path when no
// rate covers the job. Snapshotted so the contract and invoice stay immune
// to later rate edits.
const estimate = await this.priceEstimate(id);
const advanceAmount = advanceOverride ?? estimate.total;
if (!advanceAmount || advanceAmount <= 0) {
throw new BadRequestException(
'No live last-mile rate covers this job — enter the advance amount manually.',
);
}
// Idempotent per booking — reuses the record if one already exists.
const lastMile = await this.lastMileService.create({
bookingId: request.bookingId,
status: 'PAYMENT_PENDING',
advancedPayment: 0,
});
// No invoice yet: the advance is invoiced by LastMileContractService.sign()
// once the customer has signed the LM contract — doc first, then payment.
await this.requestsRepository.update(id, {
status: LastMileRequestStatus.Approved,
reviewedByStaffId: staffId,
reviewedAt: new Date(),
resultingLastMileId: lastMile.id,
approvedAdvanceAmount: advanceAmount,
contractSummary: { ...estimate, advanceAmount },
contractGeneratedAt: new Date(),
} as Partial<LastMileRequest>);
if (booking.companyId) {
void this.notifications.notify({
recipients: { companyId: booking.companyId },
audience: NotificationAudience.PORTAL,
type: NotificationType.CONTRACT_STATUS,
title: 'Last-mile contract ready — view and sign',
body: `Your last-mile request for booking ${booking.reference ?? booking.id} was approved. Review and sign the last-mile contract to receive your advance invoice.`,
link: `/bookings/${booking.id}/last-mile-contract?requestId=${id}`,
data: { bookingId: booking.id, requestId: id, lastMileId: lastMile.id },
priority: NotificationPriority.HIGH,
});
}
return this.findById(id);
}
/** The advance invoice, deferred from approve() until the LM contract is signed. */
async generateAdvanceInvoice(request: LastMileRequest): Promise<void> {
const booking = request.booking ?? (await this.bookingsRepository.findById(request.bookingId));
if (!booking) throw new NotFoundException(`Booking ${request.bookingId} not found`);
const advanceAmount = request.approvedAdvanceAmount;
if (!advanceAmount || !request.resultingLastMileId) {
throw new BadRequestException('Request has no approved advance to invoice');
}
await this.billing.generateInvoice({
// 'last_mile' (not the InvoiceSource.LastMile enum value "lastmile") to
// match the existing source string LastMileInvoiceService/LastMileService
// already query by (findBySourceIds/findPayable/attachInvoices).
source: 'last_mile' as Freight.InvoiceSource,
sourceId: request.resultingLastMileId,
type: 'LAST_MILE_ADVANCE',
companyId: booking.companyId,
companyProfileId: booking.companyProfileId || '',
// The advance is priced by the last-mile rate, so it bills in that
// rate's currency (birr for domestic trucking) — the booking's payment
// currency is only the fallback when the amount was a manual override.
currency: request.contractSummary?.currency || booking.paymentCurrency || 'ETB',
lines: [
{
chargeType: 'LAST_MILE_ADVANCE',
description: 'Last-mile delivery advance',
amount: advanceAmount,
},
],
totalAmount: advanceAmount,
});
if (booking.companyId) {
void this.notifications.notify({
recipients: { companyId: booking.companyId },
audience: NotificationAudience.PORTAL,
type: NotificationType.INVOICE_ISSUED,
title: 'Last-mile contract signed — payment due',
body: `Thank you for signing the last-mile contract for booking ${booking.reference ?? booking.id}. Pay the advance invoice to proceed.`,
link: '/billing/invoices',
data: { bookingId: booking.id, requestId: request.id, lastMileId: request.resultingLastMileId },
priority: NotificationPriority.HIGH,
});
}
}
async reject(id: string, staffId: string | null, reason: string): Promise<LastMileRequest> {
const request = await this.findById(id);
if (request.status !== LastMileRequestStatus.Submitted) {
throw new BadRequestException(`Only a submitted request can be rejected (current status: ${request.status})`);
}
const booking = request.booking ?? (await this.bookingsRepository.findById(request.bookingId));
await this.requestsRepository.update(id, {
status: LastMileRequestStatus.Rejected,
reviewedByStaffId: staffId,
reviewedAt: new Date(),
rejectionReason: reason,
} as Partial<LastMileRequest>);
if (booking?.companyId) {
void this.notifications.notify({
recipients: { companyId: booking.companyId },
audience: NotificationAudience.PORTAL,
type: NotificationType.BOOKING_STATUS,
title: 'Last-mile request rejected',
body: `Your last-mile request for booking ${booking.reference ?? booking.id} was rejected: ${reason}`,
link: `/bookings/${booking.id}`,
data: { bookingId: booking.id, requestId: id },
priority: NotificationPriority.HIGH,
});
}
return this.findById(id);
}
}