Files
edr-platform/apps/edr-freight-api/src/modules/warehouses/handover.service.ts

341 lines
13 KiB
TypeScript

import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { NotificationAudience, NotificationType } from '@edr/types';
import { DataSource, EntityManager, IsNull, Repository } from 'typeorm';
import { BookingHandover, HandoverMileType } from './entities/booking-handover.entity';
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
import { NotificationsService } from '../notifications/notifications.service';
import { sendCompanyChannels } from '../notifications/notify-company.util';
/**
* Import handover records. A booking has one handover per truck (single truck ⇒
* one, effectively per-booking; multiple trucks ⇒ one each). Timing by mile type:
* - SELF_HAUL: generated when the customer truck arrives, signed before it leaves.
* - EDR_LAST_MILE: generated when the EDR truck exits the warehouse (with its
* exit paper), signed by the customer in the portal per truck; once every
* handover is signed the delivery auto-completes (inventory / cargo /
* booking → delivered).
*/
@Injectable()
export class HandoverService {
private readonly logger = new Logger(HandoverService.name);
constructor(
private readonly dataSource: DataSource,
private readonly inbox: NotificationInboxService,
private readonly notifications: NotificationsService,
) {}
/** Tell the customer a handover is ready and needs their signature. */
private async notifySignNeeded(
bookingId: string,
reference: string,
opts: { mileType?: HandoverMileType; truckPlate?: string | null } = {},
): Promise<void> {
try {
const [b]: Array<{ companyId: string | null; reference: string }> = await this.dataSource.query(
`SELECT company_id AS "companyId", reference FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`,
[bookingId],
);
if (!b?.companyId) return;
const truck = opts.truckPlate ? ` (truck ${opts.truckPlate})` : '';
const body =
opts.mileType === 'EDR_LAST_MILE'
? `Your goods for booking ${b.reference} are on their way${truck}. Please review and sign handover ${reference} from the portal to confirm receipt of the delivery.`
: `Your import handover ${reference} for booking ${b.reference} is ready. Please review and sign it from the portal before the truck leaves.`;
await this.inbox.notify({
recipients: { companyId: b.companyId },
audience: NotificationAudience.PORTAL,
type: NotificationType.DOCUMENT_ACTION,
title: 'Handover — signature needed',
body,
link: `/bookings/${bookingId}`,
data: { bookingId, reference },
});
await sendCompanyChannels(this.dataSource, this.notifications, b.companyId, body);
} catch (err) {
this.logger.warn(`Failed to notify handover sign for ${bookingId}: ${(err as Error).message}`);
}
}
list(bookingId: string): Promise<BookingHandover[]> {
return this.dataSource.getRepository(BookingHandover).find({
where: { bookingId },
order: { generatedAt: 'ASC' },
});
}
/**
* Ask the customer to sign the booking's handover. Ensures a handover exists
* (creates a booking-level self-haul one if none yet), then fires the
* sign-needed notification (in-app + SMS + email). Idempotent to re-send.
*/
async requestSignature(
bookingId: string,
): Promise<{ notified: boolean; reference: string | null; alreadySigned: boolean }> {
const repo = this.dataSource.getRepository(BookingHandover);
const existing = await repo.find({ where: { bookingId }, order: { generatedAt: 'ASC' } });
if (existing.length === 0) {
// No handover yet (truck not arrived): create a booking-level one so the
// customer has something to sign. ensureForArrivedTruck notifies on create.
const created = await this.ensureForArrivedTruck(bookingId, {});
return { notified: true, reference: created.reference, alreadySigned: false };
}
const unsigned = existing.find((h) => !h.signedAt);
if (!unsigned) {
return { notified: false, reference: existing[0].reference, alreadySigned: true };
}
await this.notifySignNeeded(bookingId, unsigned.reference);
return { notified: true, reference: unsigned.reference, alreadySigned: false };
}
/**
* Self-haul: ensure a handover exists for a customer truck that just arrived.
* Idempotent — one per (booking, truck). Runs inside the caller's transaction
* when a manager is supplied.
*/
async ensureForArrivedTruck(
bookingId: string,
opts: { truckAssignmentId?: string | null; truckPlate?: string | null },
manager?: EntityManager,
): Promise<BookingHandover> {
const m = manager ?? this.dataSource.manager;
const repo = m.getRepository(BookingHandover);
const existing = await repo.findOne({
where: {
bookingId,
truckAssignmentId: opts.truckAssignmentId ?? IsNull(),
},
});
if (existing) return existing;
const reference = await this.generateReference(bookingId, m);
const saved = await repo.save(
repo.create({
bookingId,
truckAssignmentId: opts.truckAssignmentId ?? null,
truckPlate: opts.truckPlate ?? null,
mileType: 'SELF_HAUL',
reference,
generatedAt: new Date(),
}),
);
this.logger.log(`Handover ${reference} generated on arrival for booking ${bookingId}`);
void this.notifySignNeeded(bookingId, reference);
return saved;
}
/** Find an existing EDR handover by assignment, else by plate, else booking-level. */
private async findEdrHandover(
repo: Repository<BookingHandover>,
bookingId: string,
opts: { truckPlate?: string | null; edrAssignmentId?: string | null },
): Promise<BookingHandover | null> {
if (opts.edrAssignmentId) {
const byAssignment = await repo.findOne({
where: { bookingId, edrAssignmentId: opts.edrAssignmentId },
});
if (byAssignment) return byAssignment;
}
if (opts.truckPlate) {
return repo.findOne({
where: { bookingId, mileType: 'EDR_LAST_MILE', truckPlate: opts.truckPlate },
});
}
return repo.findOne({
where: {
bookingId,
mileType: 'EDR_LAST_MILE',
truckPlate: IsNull(),
edrAssignmentId: IsNull(),
},
});
}
/**
* EDR last-mile: generate the handover when the EDR truck exits the warehouse
* (alongside its exit paper) and ask the customer to sign it from the portal.
* One per truck (multiple trucks ⇒ one each) or booking-level when the truck
* cannot be resolved. Idempotent by (booking, assignment) / (booking, plate).
*/
async ensureForDepartedEdrTruck(
bookingId: string,
opts: { truckPlate?: string | null; edrAssignmentId?: string | null },
manager?: EntityManager,
): Promise<BookingHandover> {
const m = manager ?? this.dataSource.manager;
const repo = m.getRepository(BookingHandover);
const existing = await this.findEdrHandover(repo, bookingId, opts);
if (existing) return existing;
const reference = await this.generateReference(bookingId, m);
const saved = await repo.save(
repo.create({
bookingId,
edrAssignmentId: opts.edrAssignmentId ?? null,
truckPlate: opts.truckPlate ?? null,
mileType: 'EDR_LAST_MILE',
reference,
generatedAt: new Date(),
}),
);
this.logger.log(
`EDR handover ${reference} generated on truck exit for booking ${bookingId}` +
(opts.truckPlate ? ` (truck ${opts.truckPlate})` : ''),
);
void this.notifySignNeeded(bookingId, reference, {
mileType: 'EDR_LAST_MILE',
truckPlate: opts.truckPlate,
});
return saved;
}
/**
* EDR last-mile: ensure a handover exists at delivery and stamp delivered_at.
* Normally the handover was already generated on truck exit — this only fills
* the delivery timestamp; a handover is created here only for legacy flows
* where the exit was recorded before this feature existed.
*/
async ensureAtDelivery(
bookingId: string,
opts: { truckPlate?: string | null; edrAssignmentId?: string | null },
manager?: EntityManager,
): Promise<BookingHandover> {
const m = manager ?? this.dataSource.manager;
const repo = m.getRepository(BookingHandover);
const existing = await this.findEdrHandover(repo, bookingId, opts);
if (existing) {
if (!existing.deliveredAt) {
existing.deliveredAt = new Date();
await repo.save(existing);
}
return existing;
}
const reference = await this.generateReference(bookingId, m);
const saved = await repo.save(
repo.create({
bookingId,
edrAssignmentId: opts.edrAssignmentId ?? null,
truckPlate: opts.truckPlate ?? null,
mileType: 'EDR_LAST_MILE',
reference,
generatedAt: new Date(),
deliveredAt: new Date(),
}),
);
void this.notifySignNeeded(bookingId, reference, {
mileType: 'EDR_LAST_MILE',
truckPlate: opts.truckPlate,
});
return saved;
}
/** Re-send the sign notification for every unsigned handover on the booking. */
async notifyUnsignedForBooking(bookingId: string): Promise<void> {
const unsigned = await this.dataSource.getRepository(BookingHandover).find({
where: { bookingId, signedAt: IsNull() },
order: { generatedAt: 'ASC' },
});
for (const h of unsigned) {
await this.notifySignNeeded(bookingId, h.reference, {
mileType: h.mileType,
truckPlate: h.truckPlate,
});
}
}
/**
* Reminder loop: until a self-haul handover is signed, re-send the sign
* notification (in-app + SMS + email) every 5 minutes. One reminder per
* booking per tick, newest unsigned handover's reference. Stops the moment
* signForBooking() stamps signed_at.
*
* NB: runs in every API instance — keep a single instance in dev or the
* customer is reminded once per instance per tick.
*/
@Cron(CronExpression.EVERY_5_MINUTES, { name: 'handover-sign-reminder' })
async remindUnsignedHandovers(): Promise<void> {
try {
const rows: Array<{ bookingId: string; reference: string }> = await this.dataSource.query(
`SELECT DISTINCT ON (booking_id)
booking_id AS "bookingId", reference
FROM freight.booking_handovers
WHERE signed_at IS NULL
AND deleted_at IS NULL
AND mile_type = 'SELF_HAUL'
ORDER BY booking_id, generated_at DESC`,
);
if (!rows.length) return;
this.logger.log(`Handover sign reminder: ${rows.length} booking(s) still unsigned`);
for (const row of rows) {
await this.notifySignNeeded(row.bookingId, row.reference);
}
} catch (err) {
this.logger.warn(`Handover sign reminder tick failed: ${(err as Error).message}`);
}
}
/**
* Sign one handover (EDR last-mile: the customer signs per truck). Returns the
* fresh handover; idempotent — an already-signed handover is returned as-is.
*/
async sign(
handoverId: string,
userId?: string | null,
signerName?: string | null,
): Promise<BookingHandover> {
const repo = this.dataSource.getRepository(BookingHandover);
const handover = await repo.findOne({ where: { id: handoverId } });
if (!handover) {
throw new NotFoundException(`Handover ${handoverId} not found`);
}
if (handover.signedAt) return handover;
handover.signedAt = new Date();
handover.signedByUserId = userId ?? null;
handover.signerName = signerName?.trim() || null;
return repo.save(handover);
}
/** Sign all unsigned handovers on a booking (self-haul: before the truck leaves). */
async signForBooking(
bookingId: string,
userId?: string | null,
signerName?: string | null,
): Promise<void> {
await this.dataSource
.getRepository(BookingHandover)
.update(
{ bookingId, signedAt: IsNull() },
{
signedAt: new Date(),
signedByUserId: userId ?? null,
signerName: signerName?.trim() || null,
},
);
}
/** True when every handover on the booking is signed (and at least one exists). */
async isFullySigned(bookingId: string): Promise<boolean> {
const repo = this.dataSource.getRepository(BookingHandover);
const [total, unsigned] = await Promise.all([
repo.count({ where: { bookingId } }),
repo.count({ where: { bookingId, signedAt: IsNull() } }),
]);
return total > 0 && unsigned === 0;
}
private async generateReference(bookingId: string, manager: EntityManager): Promise<string> {
const [booking] = await manager.query(
`SELECT reference FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`,
[bookingId],
);
const ref = String(booking?.reference ?? bookingId).replace(/^BK-?/i, '');
const count = await manager.getRepository(BookingHandover).count({ where: { bookingId } });
return `HND-${ref}-${String(count + 1).padStart(2, '0')}`;
}
}