mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 21:15:41 +00:00
New Transit Agents admin table (name, valid-from/to, active/suspended, validity badge) — DJ assign step now picks from it, active+valid only.
This commit is contained in:
@@ -282,6 +282,85 @@ export class GlOperationsService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Offload facts for a booking, read-only: what came off the train at its
|
||||
* destination (containers, wagons, tonnes) and where the goods went. Sourced
|
||||
* from the booking's warehouse-inventory row — written by the auto-unload
|
||||
* that runs on train arrival for both directions.
|
||||
*/
|
||||
async offloadState(
|
||||
bookingId: string,
|
||||
milestones: Array<{ milestoneCode: string; status: string; triggeredAt?: Date | null }>,
|
||||
): Promise<Freight.ClearanceOffloadState> {
|
||||
const [row]: Array<{
|
||||
destination: string | null;
|
||||
containers: number;
|
||||
wagons: number;
|
||||
bookedWeight: string | null;
|
||||
inventoryStatus: string | null;
|
||||
unloadedAt: Date | null;
|
||||
grnNumber: string | null;
|
||||
offloadedWeight: string | null;
|
||||
warehouse: string | null;
|
||||
warehouseYard: string | null;
|
||||
zone: string | null;
|
||||
}> = await this.dataSource.query(
|
||||
`SELECT COALESCE(dy.label, dy.code) AS "destination",
|
||||
(SELECT COUNT(*)::int
|
||||
FROM freight.booking_container bc
|
||||
JOIN freight.booking_container_units bcu
|
||||
ON bcu.booking_container_id = bc.id AND bcu.deleted_at IS NULL
|
||||
WHERE bc.booking_id = b.id AND bc.deleted_at IS NULL) AS "containers",
|
||||
(SELECT COUNT(*)::int
|
||||
FROM freight.wagon_booking_allocations wba
|
||||
WHERE wba.booking_id = b.id AND wba.deleted_at IS NULL) AS "wagons",
|
||||
b.cargo_total_weight_vgm AS "bookedWeight",
|
||||
inv.status AS "inventoryStatus",
|
||||
inv.unloaded_at AS "unloadedAt",
|
||||
inv.grn_number AS "grnNumber",
|
||||
inv.weight AS "offloadedWeight",
|
||||
wh.name AS "warehouse",
|
||||
wy.name AS "warehouseYard",
|
||||
wz.name AS "zone"
|
||||
FROM freight.bookings b
|
||||
LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT i.*
|
||||
FROM freight.warehouse_inventory i
|
||||
WHERE i.booking_id = b.id AND i.deleted_at IS NULL
|
||||
ORDER BY i.unloaded_at DESC NULLS LAST, i.created_at DESC
|
||||
LIMIT 1
|
||||
) inv ON TRUE
|
||||
LEFT JOIN freight.warehouses wh ON wh.id = inv.warehouse_id
|
||||
LEFT JOIN freight.warehouse_yards wy ON wy.id = inv.yard_id
|
||||
LEFT JOIN freight.warehouse_zones wz ON wz.id = inv.zone_id
|
||||
WHERE b.id = $1 AND b.deleted_at IS NULL`,
|
||||
[bookingId],
|
||||
);
|
||||
|
||||
const milestone = milestones.find((m) => m.milestoneCode === 'OFFLOADED');
|
||||
const offloadedAt =
|
||||
milestone?.status === 'COMPLETED' && milestone.triggeredAt
|
||||
? new Date(milestone.triggeredAt).toISOString()
|
||||
: (row?.unloadedAt ? new Date(row.unloadedAt).toISOString() : null);
|
||||
// The warehouse records the real offloaded tonnage; before it does, the
|
||||
// booked VGM is the best number we have.
|
||||
const weight = Number(row?.offloadedWeight ?? 0) || Number(row?.bookedWeight ?? 0);
|
||||
const location = [row?.warehouse, row?.warehouseYard, row?.zone].filter(Boolean).join(' › ');
|
||||
|
||||
return {
|
||||
offloaded: milestone?.status === 'COMPLETED' || Boolean(row?.unloadedAt),
|
||||
offloadedAt,
|
||||
destination: row?.destination ?? null,
|
||||
containers: row?.containers ?? 0,
|
||||
wagons: row?.wagons ?? 0,
|
||||
weightTons: weight || null,
|
||||
grnNumber: row?.grnNumber ?? null,
|
||||
location: location || null,
|
||||
inventoryStatus: row?.inventoryStatus ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* GL Djibouti uploads T1 transport documents (multi-file) once the gate pass
|
||||
* is secured on the train schedule (which itself follows wagon allocation).
|
||||
@@ -381,8 +460,9 @@ export class GlOperationsService {
|
||||
|
||||
/**
|
||||
* GL Djibouti raises the post-offload final invoice (export): manual amount +
|
||||
* attached invoice document. The customer pays offline and attaches a slip;
|
||||
* GL (ET or DJ) then confirms to settle it.
|
||||
* attached invoice document. It is issued as a DRAFT the customer must approve
|
||||
* first; only then do they pay offline and attach a slip, and GL (ET or DJ)
|
||||
* confirms to settle it.
|
||||
*/
|
||||
async createFinalInvoice(
|
||||
bookingId: string,
|
||||
@@ -445,7 +525,8 @@ export class GlOperationsService {
|
||||
amount: input.amount,
|
||||
},
|
||||
],
|
||||
status: Freight.InvoiceStatus.Issued,
|
||||
// DRAFT until the customer approves it — approveFinalInvoice issues it.
|
||||
status: Freight.InvoiceStatus.Draft,
|
||||
});
|
||||
|
||||
await this.filesService.upsertByCode({
|
||||
@@ -467,6 +548,40 @@ export class GlOperationsService {
|
||||
return summary;
|
||||
}
|
||||
|
||||
/**
|
||||
* Customer approves the drafted final invoice — issues it, which is what
|
||||
* unlocks the payment slip upload. Idempotent: approving twice is a no-op.
|
||||
*/
|
||||
async approveFinalInvoice(
|
||||
bookingId: string,
|
||||
userId?: string,
|
||||
): Promise<Freight.ClearanceFinalInvoiceSummary> {
|
||||
const booking = await this.getBooking(bookingId);
|
||||
const invoice = await this.billingService.findInvoice(
|
||||
Freight.InvoiceSource.Booking,
|
||||
bookingId,
|
||||
GL_FINAL_INVOICE_TYPE,
|
||||
);
|
||||
if (!invoice) {
|
||||
throw new BadRequestException('No final invoice has been raised for this shipment.');
|
||||
}
|
||||
if (
|
||||
invoice.status === Freight.InvoiceStatus.Cancelled ||
|
||||
invoice.status === Freight.InvoiceStatus.Expired
|
||||
) {
|
||||
throw new BadRequestException('The final invoice is no longer payable.');
|
||||
}
|
||||
if (invoice.status === Freight.InvoiceStatus.Draft) {
|
||||
await this.billingService.updateStatus(invoice.id, Freight.InvoiceStatus.Issued);
|
||||
this.notifier.finalInvoiceApprovedToStaff(booking);
|
||||
}
|
||||
|
||||
void userId;
|
||||
const summary = await this.finalInvoiceSummary(bookingId);
|
||||
if (!summary) throw new NotFoundException('Final invoice not found.');
|
||||
return summary;
|
||||
}
|
||||
|
||||
/** Customer attaches the payment slip for the final invoice. */
|
||||
async uploadFinalInvoiceSlip(
|
||||
bookingId: string,
|
||||
@@ -483,6 +598,11 @@ export class GlOperationsService {
|
||||
if (!invoice) {
|
||||
throw new BadRequestException('No final invoice has been issued for this shipment.');
|
||||
}
|
||||
if (invoice.status === Freight.InvoiceStatus.Draft) {
|
||||
throw new BadRequestException(
|
||||
'Approve the final invoice before attaching a payment slip.',
|
||||
);
|
||||
}
|
||||
if (invoice.status === Freight.InvoiceStatus.Paid) {
|
||||
throw new BadRequestException('The final invoice is already paid.');
|
||||
}
|
||||
@@ -688,6 +808,8 @@ export class GlOperationsService {
|
||||
description: line?.description ?? null,
|
||||
invoiceFile: toRef('final_invoice'),
|
||||
slipFile: toRef('final_invoice_slip'),
|
||||
// Issuing IS the customer approval (createFinalInvoice leaves it DRAFT).
|
||||
approvedAt: invoice.issuedAt ? new Date(invoice.issuedAt).toISOString() : null,
|
||||
confirmedAt: invoice.paidAt ? new Date(invoice.paidAt).toISOString() : null,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user