import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { DataSource, EntityManager } from 'typeorm'; export interface ReceivedUnitRow { id: string; containerNumber: string; receivedToPort: boolean; receivedAt: string | null; grnNumber: string | null; } /** * Per-container receive + GRN tracking on booking_container_units. * * Containers arrive individually (on separate self-haul trucks), so each unit is * flipped `received_to_port` when its truck arrives (auto). Staff then confirm a * Goods Received Note over the received-but-un-GRN'd containers: one GRN covers a * batch, so if the whole booking arrives together every unit shares a single GRN * (per-booking GRN); if trucks arrive separately each batch gets its own GRN. */ @Injectable() export class ContainerReceiptService { constructor(private readonly dataSource: DataSource) {} /** * Auto-mark the containers loaded on an arrived truck as received into the * port. Idempotent — only flips units not already received. Runs inside the * caller's transaction when a manager is supplied. */ async markReceivedForAssignment( bookingId: string, assignmentId: string, manager?: EntityManager, ): Promise { const m = manager ?? this.dataSource.manager; await m.query( `UPDATE freight.booking_container_units bcu SET received_to_port = true, received_at = COALESCE(bcu.received_at, NOW()), updated_at = NOW() FROM freight.booking_containers bc, freight.customer_truck_containers ctc WHERE bc.id = bcu.booking_container_id AND bc.booking_id = $1 AND ctc.assignment_id = $2 AND ctc.deleted_at IS NULL AND ctc.container_number = bcu.container_number AND bcu.deleted_at IS NULL AND bcu.received_to_port = false`, [bookingId, assignmentId], ); } /** Received-into-port containers that have not yet been assigned a GRN. */ async listReceivedPendingGrn(bookingId: string): Promise { return this.dataSource.query( `SELECT bcu.id, bcu.container_number AS "containerNumber", bcu.received_to_port AS "receivedToPort", bcu.received_at AS "receivedAt", bcu.grn_number AS "grnNumber" FROM freight.booking_container_units bcu JOIN freight.booking_containers 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 AND bcu.received_to_port = true AND bcu.grn_number IS NULL ORDER BY bcu.received_at`, [bookingId], ); } /** * Confirm a GRN over the currently received-but-un-GRN'd containers (optionally * a subset by container number). Assigns one GRN number to the whole batch and * returns it with the covered containers. If the batch covers every container * on the booking it is effectively a per-booking GRN. */ async generateGrn( bookingId: string, containerNumbers?: string[], ): Promise<{ grnNumber: string; containerNumbers: string[]; perBooking: boolean }> { const [booking] = await this.dataSource.query( `SELECT reference FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`, [bookingId], ); if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`); return this.dataSource.transaction(async (manager) => { const wanted = containerNumbers?.map((n) => n.trim().toUpperCase()); const pending: ReceivedUnitRow[] = await manager.query( `SELECT bcu.id, bcu.container_number AS "containerNumber" FROM freight.booking_container_units bcu JOIN freight.booking_containers 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 AND bcu.received_to_port = true AND bcu.grn_number IS NULL ${wanted ? 'AND bcu.container_number = ANY($2::varchar[])' : ''}`, wanted ? [bookingId, wanted] : [bookingId], ); if (!pending.length) { throw new BadRequestException('No received containers are awaiting a GRN'); } // Batch sequence = number of GRNs already issued for this booking + 1. const [{ batches }]: Array<{ batches: string }> = await manager.query( `SELECT COUNT(DISTINCT bcu.grn_number) AS batches FROM freight.booking_container_units bcu JOIN freight.booking_containers bc ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL WHERE bc.booking_id = $1 AND bcu.grn_number IS NOT NULL AND bcu.deleted_at IS NULL`, [bookingId], ); const seq = Number(batches) + 1; const grnNumber = `GRN-${String(booking.reference).replace(/^BK-?/i, '')}-${String(seq).padStart(2, '0')}`; const ids = pending.map((p) => p.id); await manager.query( `UPDATE freight.booking_container_units SET grn_number = $1, updated_at = NOW() WHERE id = ANY($2::uuid[])`, [grnNumber, ids], ); // Per-booking when no container on the booking is left un-GRN'd. const [{ remaining }]: Array<{ remaining: string }> = await manager.query( `SELECT COUNT(*) AS remaining FROM freight.booking_container_units bcu JOIN freight.booking_containers 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 AND bcu.grn_number IS NULL`, [bookingId], ); return { grnNumber, containerNumbers: pending.map((p) => p.containerNumber), perBooking: Number(remaining) === 0 && seq === 1, }; }); } }