mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 02:30:55 +00:00
Accrual dashboard: - warehouse_accrual_acks table (migration 2140) + acknowledge/unacknowledge endpoints; dashboard rows carry acknowledged/snoozeUntil, acked items sink and are skipped by the alert cron. Row menu: mark reviewed / snooze 3d / 7d / un-acknowledge; acked rows dimmed with a "Reviewed" badge. - Fix zone weight occupancy: normalise inventory kg vs zone-capacity tonnes. Handover (rode along, shared files): - Require signer full name on delivery handover (signature optional); migration 2130 adds signer_name. Portal delivery/docs (rode along, shared files): - Approve-delivery name capture, booking-scoped GRN/release docs. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
223 lines
8.5 KiB
TypeScript
223 lines
8.5 KiB
TypeScript
import { Injectable, Logger } from '@nestjs/common';
|
|
import { Cron, CronExpression } from '@nestjs/schedule';
|
|
import { NotificationAudience, NotificationType } from '@edr/types';
|
|
import { DataSource, EntityManager, IsNull } from 'typeorm';
|
|
|
|
import { BookingHandover } 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 at delivery (after exit).
|
|
*/
|
|
@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): 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 body = `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;
|
|
}
|
|
|
|
/**
|
|
* EDR last-mile: generate a handover at delivery (after exit). One per EDR
|
|
* truck (by plate) or per booking. Idempotent by (booking, plate).
|
|
*/
|
|
async ensureAtDelivery(
|
|
bookingId: string,
|
|
opts: { truckPlate?: string | null; truckAssignmentId?: string | null },
|
|
manager?: EntityManager,
|
|
): Promise<BookingHandover> {
|
|
const m = manager ?? this.dataSource.manager;
|
|
const repo = m.getRepository(BookingHandover);
|
|
const existing = await repo.findOne({
|
|
where: {
|
|
bookingId,
|
|
truckPlate: opts.truckPlate ?? IsNull(),
|
|
truckAssignmentId: opts.truckAssignmentId ?? IsNull(),
|
|
},
|
|
});
|
|
if (existing) return existing;
|
|
|
|
const reference = await this.generateReference(bookingId, m);
|
|
return repo.save(
|
|
repo.create({
|
|
bookingId,
|
|
truckAssignmentId: opts.truckAssignmentId ?? null,
|
|
truckPlate: opts.truckPlate ?? null,
|
|
mileType: 'EDR_LAST_MILE',
|
|
reference,
|
|
generatedAt: new Date(),
|
|
deliveredAt: new Date(),
|
|
}),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 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 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')}`;
|
|
}
|
|
}
|