mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 03:10:54 +00:00
341 lines
13 KiB
TypeScript
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')}`;
|
|
}
|
|
}
|