fix issue -> consolidation

This commit is contained in:
Marshal
2026-08-24 12:24:23 +00:00
parent 979f024869
commit 2a107e8ba3
20 changed files with 475 additions and 39 deletions

View File

@@ -2091,6 +2091,7 @@ export class ContractBookingService {
): Promise<{
overweightLines: Array<{
containerTypeCode: string;
containerLabel: string;
totalVgmTons: number;
maxAllowedTons: number;
excessTons: number;
@@ -2200,6 +2201,12 @@ export class ContractBookingService {
: 0,
vgmPerUnitTons: line.units.length ? totalVgmTons / line.units.length : 0,
totalVgmTons,
// Per-box weights drive the overweight check — the limit is per
// container, so a heavy box is billed even when the line total fits.
units: (line.units ?? []).map((u, idx) => ({
vgmTons: Number(u.vgmTons ?? 0),
sortOrder: idx,
})) as BookingContainer['units'],
wagonsRequired: Math.ceil(line.quantity * wagonsPerUnitForSize(ct.sizeFt)),
}),
),
@@ -2233,6 +2240,7 @@ export class ContractBookingService {
containerTypeId: ct.id,
quantity: line.quantity,
totalVgmTons,
unitVgmTons: (line.units ?? []).map((u) => Number(u.vgmTons ?? 0)),
})),
contract.tradeDirection,
);
@@ -2312,7 +2320,12 @@ export class ContractBookingService {
(s, u) => s + Number(u.vgmTons ?? 0),
0,
);
return { containerTypeId: ct.id, quantity: line.quantity, totalVgmTons };
return {
containerTypeId: ct.id,
quantity: line.quantity,
totalVgmTons,
unitVgmTons: (line.units ?? []).map((u) => Number(u.vgmTons ?? 0)),
};
}),
);

View File

@@ -14,6 +14,8 @@ import {
type ClearanceTrainState,
} from '@edr/types';
import { DataSource } from 'typeorm';
import { DropdownSettingsService } from '../dropdown-settings/dropdown-settings.service';
import { FileUploadSettingsService } from '../file-upload-settings/file-upload-settings.service';
import { FilesService } from '../files/files.service';
@@ -117,6 +119,18 @@ export interface ContractClearanceView {
linkedBookingReviewNote?: string | null;
/** Shipment day the booking currently holds — the default when GL resubmits. */
linkedBookingScheduledDate?: string | null;
/**
* Open wagon-cancellation on a CANCELLED linked booking (consolidation
* partner lapsed, staff cut): FEE_PENDING = customer must pay the
* cancellation fee; CREDIT_AVAILABLE = fee settled, GL rebooks the credit.
*/
linkedBookingCancellation?: {
id: string;
status: string;
wagonsCancelled: number;
creditAmount: number;
creditCurrency: string;
} | null;
dutyAdvice?: {
amount: number;
currency: string;
@@ -171,6 +185,7 @@ export class ContractClearanceService {
private readonly glOperationsService: GlOperationsService,
private readonly notifier: ContractNotifierService,
private readonly transitAgentsService: TransitAgentsService,
private readonly dataSource: DataSource,
) {}
private isPhasedCustoms(contract: Contract): boolean {
@@ -369,9 +384,27 @@ export class ContractClearanceService {
// without a cycle row), so fall back to the contract's own live booking —
// otherwise the clearance page sees no linked booking at all and cannot show
// its status or the actions that depend on it.
const booking = cycle?.bookingId
let booking = cycle?.bookingId
? await this.bookingsService.findById(cycle.bookingId)
: await this.contractsRepository.findLatestBookingForContract(contractId);
// The fallback skips terminal bookings, but a CANCELLED one with an open
// wagon-cancellation still belongs on this page: the fee gate and the
// rebook-from-credit action live here. Surface the newest such booking.
if (!booking) {
const [open] = await this.dataSource.query<{ booking_id: string }[]>(
`SELECT c.booking_id
FROM freight.booking_wagon_cancellations c
JOIN freight.bookings b ON b.id = c.booking_id
WHERE b.contract_id = $1
AND b.status = 'CANCELLED'
AND c.status IN ('FEE_PENDING', 'CREDIT_AVAILABLE')
AND c.deleted_at IS NULL
ORDER BY c.created_at DESC
LIMIT 1`,
[contractId],
);
if (open) booking = await this.bookingsService.findById(open.booking_id);
}
if (booking) {
linkedBookingId = booking.id ?? null;
linkedBookingReference = booking.reference ?? null;
@@ -395,6 +428,40 @@ export class ContractClearanceService {
}
}
// A CANCELLED booking may carry an open wagon-cancellation (consolidation
// partner lapsed, staff cut): FEE_PENDING gates on the customer paying the
// cancellation fee; CREDIT_AVAILABLE lets GL rebook from the credit here.
let linkedBookingCancellation: {
id: string;
status: string;
wagonsCancelled: number;
creditAmount: number;
creditCurrency: string;
} | null = null;
if (booking && linkedBookingStatus === 'CANCELLED') {
const [row] = await this.dataSource.query<
{ id: string; status: string; wagons_cancelled: string; credit_amount: string }[]
>(
`SELECT id, status, wagons_cancelled, credit_amount
FROM freight.booking_wagon_cancellations
WHERE booking_id = $1
AND status IN ('FEE_PENDING', 'CREDIT_AVAILABLE')
AND deleted_at IS NULL
ORDER BY created_at DESC
LIMIT 1`,
[booking.id],
);
if (row) {
linkedBookingCancellation = {
id: row.id,
status: row.status,
wagonsCancelled: Number(row.wagons_cancelled),
creditAmount: Number(row.credit_amount),
creditCurrency: booking.paymentCurrency ?? 'ETB',
};
}
}
return {
contractId,
status: contract.status,
@@ -433,6 +500,7 @@ export class ContractClearanceService {
linkedBookingStatus,
linkedBookingReviewNote,
linkedBookingScheduledDate,
linkedBookingCancellation,
dutyAdvice,
dutyDispute,
transitAssignee,

View File

@@ -63,6 +63,7 @@ describe('ContractClearanceService — duty dispute', () => {
{} as never, // glOperationsService
notifier as never,
{} as never, // transitAgentsService
{} as never, // dataSource
);
build([
milestone('DUTY_TAXES_ADVISED', 'COMPLETED'),

View File

@@ -61,6 +61,7 @@ describe('ContractClearanceService — transit assignee', () => {
{} as never,
notifier as never,
transitAgentsService as never,
{} as never, // dataSource
);
});