mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
146 lines
5.8 KiB
TypeScript
146 lines
5.8 KiB
TypeScript
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<void> {
|
|
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<ReceivedUnitRow[]> {
|
|
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,
|
|
};
|
|
});
|
|
}
|
|
}
|