Merge branch 'dev' into freight/nati-2

This commit is contained in:
Nathnael
2026-08-24 14:02:03 +00:00
33 changed files with 1749 additions and 63 deletions

BIN
4_5767239985799371288.xlsx Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
INV-20260812-00005-QR.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

BIN
INV-20260812-00005.pdf Normal file

Binary file not shown.

File diff suppressed because it is too large Load Diff

View File

@@ -86,17 +86,16 @@ describe("toEimsInvoice", () => {
expect(doc.SellerDetails).toBe(seller); expect(doc.SellerDetails).toBe(seller);
}); });
it("maps the buyer from the company row and leaves unmodelled fields null", () => { it("maps the buyer from the company row and omits Id fields for a TIN-identified buyer", () => {
const doc = toEimsInvoice(invoice(), seller, context()); const doc = toEimsInvoice(invoice(), seller, context());
// MoR rule 7004 rejects an explicit IdType/IdNumber null — the keys must be absent.
expect(doc.BuyerDetails).toEqual({ expect(doc.BuyerDetails).toEqual({
// Resolved by the registration service before the counter was reserved; the mapper copies. // Resolved by the registration service before the counter was reserved; the mapper copies.
City: "31", City: "31",
Country: "70", Country: "70",
Email: "buyer@abc.et", Email: "buyer@abc.et",
HouseNumber: "NEW", HouseNumber: "NEW",
IdNumber: null,
IdType: null,
Tin: "0999930000", Tin: "0999930000",
LegalName: "ABC Trading PLC", LegalName: "ABC Trading PLC",
Phone: "0912345678", Phone: "0912345678",

View File

@@ -35,8 +35,9 @@ export interface EimsBuyerDetails {
City: string | null; City: string | null;
Email: string | null; Email: string | null;
HouseNumber: string | null; HouseNumber: string | null;
IdNumber: string | null; /** Omitted entirely for a TIN-identified buyer — MoR rule 7004 rejects an explicit null. */
IdType: string | null; IdNumber?: string;
IdType?: string;
Tin: string; Tin: string;
LegalName: string; LegalName: string;
Phone: string | null; Phone: string | null;
@@ -410,8 +411,8 @@ export function toEimsInvoice(
City: context.buyerGeo.City, City: context.buyerGeo.City,
Email: company.email ?? null, Email: company.email ?? null,
HouseNumber: company.houseNo ?? null, HouseNumber: company.houseNo ?? null,
IdNumber: context.buyerIdNumber ?? null, ...(context.buyerIdNumber != null ? { IdNumber: context.buyerIdNumber } : {}),
IdType: context.buyerIdType ?? null, ...(context.buyerIdType != null ? { IdType: context.buyerIdType } : {}),
Tin: company.tin, Tin: company.tin,
LegalName: company.name, LegalName: company.name,
Phone: company.phone ?? null, Phone: company.phone ?? null,

View File

@@ -26,9 +26,18 @@ import { Booking } from './entities/booking.entity';
import { assertBookingStatus } from './booking-status.util'; import { assertBookingStatus } from './booking-status.util';
import { ContainerValidationService } from './container-validation.service'; import { ContainerValidationService } from './container-validation.service';
/**
* One physical container over its VGM limit. Weight limits are per container,
* so an overloaded box is reported (and billed) on its own tons above the
* limit — a lighter box on the same line never absorbs them.
*/
export interface OverweightLine { export interface OverweightLine {
containerTypeCode: string; containerTypeCode: string;
/** Container number when known, else "<code> #2" — identifies the box. */
containerLabel: string;
/** This container's VGM, not the line total. */
totalVgmTons: number; totalVgmTons: number;
/** The per-container limit. */
maxAllowedTons: number; maxAllowedTons: number;
excessTons: number; excessTons: number;
} }
@@ -250,9 +259,10 @@ export class BookingPricingService {
clearanceBlocked.push(...clearance.blocked); clearanceBlocked.push(...clearance.blocked);
} }
// Overweight detail for the customer: map the engine's per-line results back // Overweight detail for the customer: one row per over-limit CONTAINER,
// to the booking's container lines (same order) for code + weights. maxAllowed // mapped back to the booking's container lines (same order) for the code and
// is derived from the line total minus the excess the engine computed. // the physical container numbers. maxAllowed is the per-container limit,
// recovered from that container's weight minus its own excess.
const overweightLines: OverweightLine[] = []; const overweightLines: OverweightLine[] = [];
const containerLines = (booking.bookingContainers ?? []).filter( const containerLines = (booking.bookingContainers ?? []).filter(
(bc) => bc.containerTypeId != null, (bc) => bc.containerTypeId != null,
@@ -261,8 +271,6 @@ export class BookingPricingService {
const wr = ruleResult.containerWeightResults[i]; const wr = ruleResult.containerWeightResults[i];
if (!wr?.isOverweight) continue; if (!wr?.isOverweight) continue;
const line = containerLines[i]; const line = containerLines[i];
const totalVgmTons = Number(line?.totalVgmTons ?? 0);
const excessTons = Number(wr.overweightExcessTons ?? 0);
let code = line?.containerSize ?? ''; let code = line?.containerSize ?? '';
if (line?.containerTypeId) { if (line?.containerTypeId) {
try { try {
@@ -271,12 +279,32 @@ export class BookingPricingService {
// fall back to the container size label // fall back to the container size label
} }
} }
overweightLines.push({ const numbers = (line?.units ?? [])
containerTypeCode: code, .slice()
totalVgmTons, .sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0))
maxAllowedTons: Math.max(0, totalVgmTons - excessTons), .map((u) => u.containerNumber);
excessTons, // Legacy weight results carry no per-unit detail (a line total only) —
}); // report the line as a single row, as before.
const units = wr.overweightUnits?.length
? wr.overweightUnits
: [
{
unitIndex: 0,
vgmTons: Number(line?.totalVgmTons ?? 0),
excessTons: Number(wr.overweightExcessTons ?? 0),
},
];
for (const u of units) {
overweightLines.push({
containerTypeCode: code,
containerLabel:
(u.unitIndex > 0 ? numbers[u.unitIndex - 1] : null) ||
(u.unitIndex > 0 ? `${code} #${u.unitIndex}` : code),
totalVgmTons: u.vgmTons,
maxAllowedTons: Math.max(0, u.vgmTons - u.excessTons),
excessTons: u.excessTons,
});
}
} }
return { return {
@@ -346,6 +374,13 @@ export class BookingPricingService {
quantity: qty, quantity: qty,
vgmPerUnitTons: vgm, vgmPerUnitTons: vgm,
totalVgmTons: qty * vgm, totalVgmTons: qty * vgm,
// Real per-box weights when the booking recorded them: weight
// limits are per container, so 22/18/20t is 2t over on the first
// box even though the line total fits a 3x20t allowance.
unitVgmTons: (bc.units ?? [])
.slice()
.sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0))
.map((u) => Number(u.vgmTons ?? 0)),
isReefer: ct.isReefer, isReefer: ct.isReefer,
// Per-container opt-ins — PER_CONTAINER surcharges bill these. // Per-container opt-ins — PER_CONTAINER surcharges bill these.
hazardousQuantity: Number(bc.hazardousQuantity ?? 0), hazardousQuantity: Number(bc.hazardousQuantity ?? 0),

View File

@@ -383,6 +383,9 @@ export class BookingWagonCancellationService {
status: 'CANCELLED', status: 'CANCELLED',
trainScheduleId: null, trainScheduleId: null,
requestedTrainScheduleId: null, requestedTrainScheduleId: null,
// A dead booking holds no shipment day — leaving it set lets the
// stranded-PAID day sweep pick the booking up and resurrect it.
scheduledDate: null,
}); });
await this.detachFromSchedule(b); await this.detachFromSchedule(b);
} }
@@ -568,6 +571,9 @@ export class BookingWagonCancellationService {
status: 'CANCELLED', status: 'CANCELLED',
trainScheduleId: null, trainScheduleId: null,
requestedTrainScheduleId: null, requestedTrainScheduleId: null,
// A dead booking holds no shipment day — leaving it set lets the
// stranded-PAID day sweep pick the booking up and resurrect it.
scheduledDate: null,
}); });
await this.detachFromSchedule(booking); await this.detachFromSchedule(booking);
this.notifyCustomer( this.notifyCustomer(

View File

@@ -27,11 +27,15 @@ export class PriceLineItemDto {
currency!: string; currency!: string;
} }
/** One physical container over its per-container VGM limit. */
export class OverweightLineDto { export class OverweightLineDto {
@ApiProperty() @ApiProperty()
containerTypeCode!: string; containerTypeCode!: string;
@ApiProperty() @ApiProperty({ description: 'Container number, or "<code> #2" when unnumbered' })
containerLabel!: string;
@ApiProperty({ description: "This container's VGM in tons" })
totalVgmTons!: number; totalVgmTons!: number;
@ApiProperty() @ApiProperty()

View File

@@ -2091,6 +2091,7 @@ export class ContractBookingService {
): Promise<{ ): Promise<{
overweightLines: Array<{ overweightLines: Array<{
containerTypeCode: string; containerTypeCode: string;
containerLabel: string;
totalVgmTons: number; totalVgmTons: number;
maxAllowedTons: number; maxAllowedTons: number;
excessTons: number; excessTons: number;
@@ -2200,6 +2201,12 @@ export class ContractBookingService {
: 0, : 0,
vgmPerUnitTons: line.units.length ? totalVgmTons / line.units.length : 0, vgmPerUnitTons: line.units.length ? totalVgmTons / line.units.length : 0,
totalVgmTons, 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)), wagonsRequired: Math.ceil(line.quantity * wagonsPerUnitForSize(ct.sizeFt)),
}), }),
), ),
@@ -2233,6 +2240,7 @@ export class ContractBookingService {
containerTypeId: ct.id, containerTypeId: ct.id,
quantity: line.quantity, quantity: line.quantity,
totalVgmTons, totalVgmTons,
unitVgmTons: (line.units ?? []).map((u) => Number(u.vgmTons ?? 0)),
})), })),
contract.tradeDirection, contract.tradeDirection,
); );
@@ -2312,7 +2320,12 @@ export class ContractBookingService {
(s, u) => s + Number(u.vgmTons ?? 0), (s, u) => s + Number(u.vgmTons ?? 0),
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, type ClearanceTrainState,
} from '@edr/types'; } from '@edr/types';
import { DataSource } from 'typeorm';
import { DropdownSettingsService } from '../dropdown-settings/dropdown-settings.service'; import { DropdownSettingsService } from '../dropdown-settings/dropdown-settings.service';
import { FileUploadSettingsService } from '../file-upload-settings/file-upload-settings.service'; import { FileUploadSettingsService } from '../file-upload-settings/file-upload-settings.service';
import { FilesService } from '../files/files.service'; import { FilesService } from '../files/files.service';
@@ -117,6 +119,18 @@ export interface ContractClearanceView {
linkedBookingReviewNote?: string | null; linkedBookingReviewNote?: string | null;
/** Shipment day the booking currently holds — the default when GL resubmits. */ /** Shipment day the booking currently holds — the default when GL resubmits. */
linkedBookingScheduledDate?: string | null; 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?: { dutyAdvice?: {
amount: number; amount: number;
currency: string; currency: string;
@@ -171,6 +185,7 @@ export class ContractClearanceService {
private readonly glOperationsService: GlOperationsService, private readonly glOperationsService: GlOperationsService,
private readonly notifier: ContractNotifierService, private readonly notifier: ContractNotifierService,
private readonly transitAgentsService: TransitAgentsService, private readonly transitAgentsService: TransitAgentsService,
private readonly dataSource: DataSource,
) {} ) {}
private isPhasedCustoms(contract: Contract): boolean { 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 — // 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 // otherwise the clearance page sees no linked booking at all and cannot show
// its status or the actions that depend on it. // 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.bookingsService.findById(cycle.bookingId)
: await this.contractsRepository.findLatestBookingForContract(contractId); : 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) { if (booking) {
linkedBookingId = booking.id ?? null; linkedBookingId = booking.id ?? null;
linkedBookingReference = booking.reference ?? 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 { return {
contractId, contractId,
status: contract.status, status: contract.status,
@@ -433,6 +500,7 @@ export class ContractClearanceService {
linkedBookingStatus, linkedBookingStatus,
linkedBookingReviewNote, linkedBookingReviewNote,
linkedBookingScheduledDate, linkedBookingScheduledDate,
linkedBookingCancellation,
dutyAdvice, dutyAdvice,
dutyDispute, dutyDispute,
transitAssignee, transitAssignee,

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; import { ApiPropertyOptional } from "@nestjs/swagger";
import { IsIn, IsNumber, IsOptional, IsString, Length } from "class-validator"; import { IsIn, IsNumber, IsOptional, IsString, Length } from "class-validator";
import { EIMS_MODE_OF_PAYMENT, EimsModeOfPayment } from "../eims-receipt.types"; import { EIMS_MODE_OF_PAYMENT, EimsModeOfPayment } from "../eims-receipt.types";
@@ -9,9 +9,15 @@ import { EIMS_MODE_OF_PAYMENT, EimsModeOfPayment } from "../eims-receipt.types";
* guessed (payment method, collector, provider references — none of it is modelled on `Invoice`). * guessed (payment method, collector, provider references — none of it is modelled on `Invoice`).
*/ */
export class RegisterSalesReceiptDto { export class RegisterSalesReceiptDto {
@ApiProperty({ enum: EIMS_MODE_OF_PAYMENT, description: "MoR's confirmed ModeOfPayment enum." }) @ApiPropertyOptional({
enum: EIMS_MODE_OF_PAYMENT,
description:
"MoR's confirmed ModeOfPayment enum. Optional when the invoice's recorded payment method " +
"maps unambiguously (CASH, CHEQUE, CPO, CARD, BANK_TRANSFER); otherwise required.",
})
@IsOptional()
@IsIn(EIMS_MODE_OF_PAYMENT) @IsIn(EIMS_MODE_OF_PAYMENT)
modeOfPayment!: EimsModeOfPayment; modeOfPayment?: EimsModeOfPayment;
@ApiPropertyOptional({ description: 'Defaults to "Payment received".' }) @ApiPropertyOptional({ description: 'Defaults to "Payment received".' })
@IsOptional() @IsOptional()

View File

@@ -116,6 +116,47 @@ describe("EimsReceiptService.registerSalesReceipt", () => {
expect(receipt.qr).toBe("iVBORw0KGgo..."); expect(receipt.qr).toBe("iVBORw0KGgo...");
}); });
it("derives mode/date/voucher/amount from the recorded manual payment", async () => {
const db = new FakeDb([
invoiceRow({
payments: [
{ amount: 4000, method: "CASH", reference: "CRV-000123", paidAt: "2026-08-20T09:00:00.000Z", metadata: null },
],
} as never),
]);
const postBearer = jest.fn().mockResolvedValue(okResponse());
await build(db, postBearer).registerSalesReceipt(INVOICE_ID, {} as never);
const request = postBearer.mock.calls[0][1];
expect(request.TransactionDetails.ModeOfPayment).toBe("CASH");
expect(request.ManualReceiptNumber).toBe("CRV-000123");
expect(request.ReceiptDate).toBe("2026-08-20T09:00:00.000Z");
expect(request.CollectedAmount).toBe(4000);
});
it("puts a gateway reference in TransactionNumber, never ManualReceiptNumber, and demands an explicit mode", async () => {
const db = new FakeDb([
invoiceRow({
payments: [
{ amount: 10000, method: "GATEWAY", reference: "txn-9f8e7d", paidAt: "2026-08-21T10:00:00.000Z", metadata: null },
],
} as never),
]);
const postBearer = jest.fn().mockResolvedValue(okResponse());
const service = build(db, postBearer);
// GATEWAY says nothing about the channel — deriving would guess a tax field.
await expect(service.registerSalesReceipt(INVOICE_ID, {} as never)).rejects.toThrow(
BadRequestException,
);
await service.registerSalesReceipt(INVOICE_ID, { modeOfPayment: "Card" } as never);
const request = postBearer.mock.calls[0][1];
expect(request.TransactionDetails.TransactionNumber).toBe("txn-9f8e7d");
expect(request.ManualReceiptNumber).not.toBe("txn-9f8e7d");
});
it("defaults PaymentCoverage to FULL when the invoice balance is 0, PARTIAL otherwise", async () => { it("defaults PaymentCoverage to FULL when the invoice balance is 0, PARTIAL otherwise", async () => {
const db = new FakeDb([invoiceRow({ balanceAmount: 500 })]); const db = new FakeDb([invoiceRow({ balanceAmount: 500 })]);
const postBearer = jest.fn().mockResolvedValue(okResponse()); const postBearer = jest.fn().mockResolvedValue(okResponse());

View File

@@ -17,6 +17,8 @@ import { EimsReceipt, EimsReceiptKind, EimsReceiptStatus } from "./entities/eims
import { RegisterSalesReceiptDto } from "./dto/register-sales-receipt.dto"; import { RegisterSalesReceiptDto } from "./dto/register-sales-receipt.dto";
import { RegisterWithholdingReceiptDto } from "./dto/register-withholding-receipt.dto"; import { RegisterWithholdingReceiptDto } from "./dto/register-withholding-receipt.dto";
import { import {
EIMS_MODE_OF_PAYMENT,
EimsModeOfPayment,
EimsReceiptResponse, EimsReceiptResponse,
EimsSalesReceiptRequest, EimsSalesReceiptRequest,
EimsWithholdReceiptRequest, EimsWithholdReceiptRequest,
@@ -41,8 +43,11 @@ const DETERMINISTIC_KINDS = new Set(["SCHEMA_VALIDATION", "RULE_VALIDATION", "AU
* double-submission guard the way `/v1/cancel` does ("IRN already Canceled."), so the same caution * double-submission guard the way `/v1/cancel` does ("IRN already Canceled."), so the same caution
* applies as an unacknowledged registration: a human must check the MoR portal first. * applies as an unacknowledged registration: a human must check the MoR portal first.
* *
* Several request fields have no confirmed source in this codebase (payment method, collector, * Sales receipts derive what the invoice's payment ledger actually records — amount, date,
* withholding rate/amount) and are never guessed — see the two DTOs. * finance's voucher number (ManualReceiptNumber), gateway transaction id, and the payment mode
* where the ledger method maps unambiguously to MoR's enum. Fields with no recorded source
* (collector, withholding rate/amount, mobile-money modes) are still asked of the caller, never
* guessed — see the two DTOs.
*/ */
@Injectable() @Injectable()
export class EimsReceiptService { export class EimsReceiptService {
@@ -68,7 +73,26 @@ export class EimsReceiptService {
const session = await this.auth.getSessionContext(); const session = await this.auth.getSessionContext();
const receiptNumber = this.generateReceiptNumber(invoice); const receiptNumber = this.generateReceiptNumber(invoice);
const collectedAmount = dto.collectedAmount ?? Number(invoice.paidAmount);
// The newest ledger entry is the payment this receipt vouches for. A gateway settlement's
// `reference` is the provider transaction id; a manual settlement's `reference` is finance's
// own voucher number (CRV) — that one belongs in ManualReceiptNumber so the registered
// receipt matches finance's books.
const lastPayment = invoice.payments?.length
? invoice.payments[invoice.payments.length - 1]
: null;
const isGateway = (lastPayment?.method ?? "").toUpperCase() === "GATEWAY";
const modeOfPayment = dto.modeOfPayment ?? deriveModeOfPayment(lastPayment?.method);
if (!modeOfPayment) {
throw new BadRequestException({
code: "EIMS_MODE_OF_PAYMENT_REQUIRED",
message:
`Recorded payment method "${lastPayment?.method ?? "none"}" has no unambiguous MoR ` +
`ModeOfPayment — pass modeOfPayment (one of ${EIMS_MODE_OF_PAYMENT.join(", ")}).`,
});
}
const collectedAmount =
dto.collectedAmount ?? (lastPayment ? lastPayment.amount : Number(invoice.paidAmount));
const balance = Number(invoice.balanceAmount); const balance = Number(invoice.balanceAmount);
const request: EimsSalesReceiptRequest = { const request: EimsSalesReceiptRequest = {
@@ -77,9 +101,9 @@ export class EimsReceiptService {
Reason: dto.reason ?? "Payment received", Reason: dto.reason ?? "Payment received",
// ISO-8601 UTC — the collection's saved example uses a "+03:00" offset instead; no schema // ISO-8601 UTC — the collection's saved example uses a "+03:00" offset instead; no schema
// error for this field was ever observed to confirm which form MoR actually requires. // error for this field was ever observed to confirm which form MoR actually requires.
ReceiptDate: new Date().toISOString(), ReceiptDate: lastPayment?.paidAt ?? new Date().toISOString(),
ReceiptCounter: String(Date.now()), ReceiptCounter: String(Date.now()),
ManualReceiptNumber: receiptNumber, ManualReceiptNumber: (!isGateway && lastPayment?.reference) || receiptNumber,
SourceSystemType: session.systemType, SourceSystemType: session.systemType,
SourceSystemNumber: session.systemNumber, SourceSystemNumber: session.systemNumber,
ReceiptCurrency: currency, ReceiptCurrency: currency,
@@ -97,7 +121,7 @@ export class EimsReceiptService {
}, },
], ],
TransactionDetails: { TransactionDetails: {
ModeOfPayment: dto.modeOfPayment, ModeOfPayment: modeOfPayment,
ChequeNumber: dto.chequeNumber ?? null, ChequeNumber: dto.chequeNumber ?? null,
CPONumber: dto.cpoNumber ?? null, CPONumber: dto.cpoNumber ?? null,
DocumentNumber: dto.documentNumber ?? null, DocumentNumber: dto.documentNumber ?? null,
@@ -105,7 +129,7 @@ export class EimsReceiptService {
PaymentServiceProvider: dto.paymentServiceProvider ?? null, PaymentServiceProvider: dto.paymentServiceProvider ?? null,
OtherPaymentServiceProviderName: dto.otherPaymentServiceProviderName ?? null, OtherPaymentServiceProviderName: dto.otherPaymentServiceProviderName ?? null,
AccountNumber: dto.accountNumber ?? null, AccountNumber: dto.accountNumber ?? null,
TransactionNumber: dto.transactionNumber ?? null, TransactionNumber: dto.transactionNumber ?? (isGateway ? (lastPayment?.reference ?? null) : null),
}, },
}; };
@@ -286,3 +310,17 @@ export class EimsReceiptService {
return `REC-${invoice.invoiceNumber}-${Date.now()}`; return `REC-${invoice.invoiceNumber}-${Date.now()}`;
} }
} }
/**
* Recorded ledger method → MoR ModeOfPayment, only where the mapping is unambiguous. Mobile-money
* methods (TELEBIRR, EBIRR, …) have no MoR enum slot, and "GATEWAY" says nothing about the real
* channel — those return undefined and the caller must supply modeOfPayment explicitly. Guessing
* a tax field is worse than asking.
*/
function deriveModeOfPayment(method: string | null | undefined): EimsModeOfPayment | undefined {
if (!method) return undefined;
const normalized = method.toUpperCase().replace(/-/g, "_");
const direct = EIMS_MODE_OF_PAYMENT.find((m) => m.toUpperCase().replace(/ /g, "_") === normalized);
if (direct) return direct;
return normalized === "BANK_TRANSFER" ? "Local Bank Transfer" : undefined;
}

View File

@@ -169,6 +169,101 @@ describe('RuleEngineService — overweight surcharge by trade direction', () =>
}); });
}); });
describe('RuleEngineService — overweight is per container, never pooled', () => {
const configuredOverweight: Rate = {
id: 'rate-ow',
rateType: 'OVERWEIGHT_PER_TON',
trigger: 'OVERWEIGHT',
rateValue: 10,
rateUnit: 'PER_TON',
currency: 'USD',
status: 'LIVE',
containerTypeId: null,
cargoTypeId: null,
} as Rate;
const makeService = (maxCapacityTons: number | null) =>
new RuleEngineService(
{ findById: jest.fn().mockResolvedValue(null) } as never,
{ findById: jest.fn().mockResolvedValue(null) } as never,
{
findActiveByContainerTypeId: jest
.fn()
.mockResolvedValue([{ id: 'wlr-20', maxVgmTons: 20, maxCapacityTons }]),
} as never,
{ findAllActive: jest.fn().mockResolvedValue([]) } as never,
{ findLiveRates: jest.fn().mockResolvedValue([configuredOverweight]) } as never,
{ findById: jest.fn().mockResolvedValue(null) } as never,
{} as never,
);
// The reported case: 3 × 20ft at 22 / 18 / 20 t against a 20 t limit. The
// line total (60 t) fits a pooled 3 × 20 t allowance, but the first box is
// 2 t over and must be billed for it.
const input = (unitVgmTons: number[]): BookingEvaluationInput => ({
serviceTypeId: 'svc-1',
paymentCurrency: 'USD',
tradeDirection: 'EXPORT',
isHazardous: false,
totalWagons: 2,
containers: [
{
containerTypeId: 'ct-20',
quantity: unitVgmTons.length,
vgmPerUnitTons: unitVgmTons.reduce((s, v) => s + v, 0) / unitVgmTons.length,
totalVgmTons: unitVgmTons.reduce((s, v) => s + v, 0),
unitVgmTons,
},
],
});
it('bills only the tons the heavy container is over, not the line total', async () => {
const result = await makeService(null).evaluate(input([22, 18, 20]));
const wr = result.containerWeightResults[0];
expect(wr.isOverweight).toBe(true);
expect(wr.overweightExcessTons).toBe(2);
expect(wr.overweightUnits).toEqual([{ unitIndex: 1, vgmTons: 22, excessTons: 2 }]);
const ow = result.appliedModifiers.filter((m) => m.surchargeCode === 'OVERWEIGHT_PER_TON');
expect(ow[0].calculatedAmount).toBe(20); // 2 t × 10 USD/t
});
it('sums the excess of every over-limit container', async () => {
const result = await makeService(null).evaluate(input([22, 18, 23]));
const wr = result.containerWeightResults[0];
expect(wr.overweightExcessTons).toBe(5);
expect(wr.overweightUnits?.map((u) => u.unitIndex)).toEqual([1, 3]);
});
it('is not overweight when no single container is over the limit', async () => {
const result = await makeService(null).evaluate(input([20, 18, 20]));
expect(result.containerWeightResults[0].isOverweight).toBe(false);
});
it('falls back to an even spread when a line carries no per-unit weights', async () => {
const result = await makeService(null).evaluate({
serviceTypeId: 'svc-1',
paymentCurrency: 'USD',
tradeDirection: 'EXPORT',
isHazardous: false,
totalWagons: 2,
containers: [
{ containerTypeId: 'ct-20', quantity: 3, vgmPerUnitTons: 21, totalVgmTons: 63 },
],
});
// 3 boxes at 21 t each → 1 t over on each.
expect(result.containerWeightResults[0].overweightExcessTons).toBe(3);
});
it('blocks on capacity per container, not on the pooled line total', async () => {
const violations = await makeService(30).capacityViolations(
[{ containerTypeId: 'ct-20', quantity: 3, totalVgmTons: 60, unitVgmTons: [35, 5, 20] }],
'EXPORT',
);
expect(violations).toHaveLength(1);
expect(violations[0]).toContain('#1');
});
});
describe('RuleEngineService — empty-container return per route + container type', () => { describe('RuleEngineService — empty-container return per route + container type', () => {
const returnRate20: Rate = { const returnRate20: Rate = {
id: 'rate-return-20', id: 'rate-return-20',

View File

@@ -33,6 +33,32 @@ import { GOVERNMENT_PRIORITY_BONUS } from './government-priority.constants';
// from multipart form-data) and a non-empty "false" string is truthy. // from multipart form-data) and a non-empty "false" string is truthy.
const truthy = (v: unknown): boolean => v === true || v === 'true'; const truthy = (v: unknown): boolean => v === true || v === 'true';
/** Tons carry 3 decimals in the schema; keep derived tonnage on that grid. */
const round3 = (n: number): number => Math.round(n * 1000) / 1000;
/**
* The VGM of every physical container on a line. Uses the per-unit weights the
* booking recorded; when a line has none (or fewer than its quantity — legacy
* rows only kept a line total), the remainder is spread evenly, which is the
* uniform load those bookings were entered as.
*/
export const unitWeights = (container: {
quantity: number;
totalVgmTons: number;
unitVgmTons?: number[];
}): number[] => {
const known = (container.unitVgmTons ?? [])
.slice(0, container.quantity)
.map((v) => Number(v ?? 0));
const missing = Math.max(0, Number(container.quantity || 0) - known.length);
if (missing === 0) return known;
const rest = Math.max(
0,
Number(container.totalVgmTons || 0) - known.reduce((s, v) => s + v, 0),
);
return [...known, ...Array<number>(missing).fill(rest / missing)];
};
export interface BookingContainerEvalInput { export interface BookingContainerEvalInput {
containerTypeId: string; containerTypeId: string;
quantity: number; quantity: number;
@@ -41,6 +67,15 @@ export interface BookingContainerEvalInput {
isReefer?: boolean; isReefer?: boolean;
isOverweight?: boolean; isOverweight?: boolean;
overweightExcessTons?: number | null; overweightExcessTons?: number | null;
/**
* VGM of each physical container on this line, when the booking carries
* per-unit weights. Weight limits are a per-container ceiling: 3x20ft at
* 22/18/20t against a 20t limit is 2t overweight on the first box, not
* zero because the line total happens to fit. Missing/short (legacy lines
* that only carry a line total) falls back to an even spread across
* `quantity`, which is what those bookings actually recorded.
*/
unitVgmTons?: number[];
/** /**
* How many individual containers on this line opted into each handling * How many individual containers on this line opted into each handling
* service. PER_CONTAINER surcharges bill these counts, not the line * service. PER_CONTAINER surcharges bill these counts, not the line
@@ -131,11 +166,22 @@ export interface AppliedCargoModifier {
billingUnit?: string; billingUnit?: string;
} }
/** One physical container that broke the per-container VGM limit. */
export interface OverweightUnit {
/** 1-based position of the container within its line. */
unitIndex: number;
vgmTons: number;
excessTons: number;
}
export interface ContainerWeightResult { export interface ContainerWeightResult {
containerTypeId: string; containerTypeId: string;
weightLimitRuleId: string | null; weightLimitRuleId: string | null;
isOverweight: boolean; isOverweight: boolean;
/** Sum of the per-container excesses on this line. */
overweightExcessTons: number | null; overweightExcessTons: number | null;
/** Which containers of the line are over, and by how much. */
overweightUnits?: OverweightUnit[];
} }
export interface RuleEvaluationResult { export interface RuleEvaluationResult {
@@ -221,22 +267,37 @@ export class RuleEngineService {
lineMaxVgmTons.push(rule ? Number(rule.maxVgmTons) : null); lineMaxVgmTons.push(rule ? Number(rule.maxVgmTons) : null);
let isOverweight = container.isOverweight ?? false; let isOverweight = container.isOverweight ?? false;
let excess = container.overweightExcessTons ?? null; let excess = container.overweightExcessTons ?? null;
let overweightUnits: OverweightUnit[] | undefined;
if (rule) { if (rule) {
const maxTotal = Number(rule.maxVgmTons) * container.quantity; const perUnitLimit = Number(rule.maxVgmTons);
const totalVgm = container.totalVgmTons; // Per-container, never pooled: an underloaded box does not absorb the
if (totalVgm > maxTotal) { // excess of an overloaded one — each container is billed on its own
// tons above the limit.
overweightUnits = unitWeights(container)
.map((vgmTons, i) => ({
unitIndex: i + 1,
vgmTons,
excessTons: round3(Math.max(0, vgmTons - perUnitLimit)),
}))
.filter((u) => u.excessTons > 0);
if (overweightUnits.length > 0) {
isOverweight = true; isOverweight = true;
excess = Math.max(0, totalVgm - maxTotal); excess = round3(
warnings.push( overweightUnits.reduce((sum, u) => sum + u.excessTons, 0),
`Container type ${container.containerTypeId} VGM ${totalVgm}t exceeds limit ${maxTotal}t`,
); );
for (const u of overweightUnits) {
warnings.push(
`Container type ${container.containerTypeId} #${u.unitIndex} VGM ${u.vgmTons}t exceeds the ${perUnitLimit}t limit by ${u.excessTons}t`,
);
}
} }
containerWeightResults.push({ containerWeightResults.push({
containerTypeId: container.containerTypeId, containerTypeId: container.containerTypeId,
weightLimitRuleId: rule.id, weightLimitRuleId: rule.id,
isOverweight, isOverweight,
overweightExcessTons: excess, overweightExcessTons: excess,
overweightUnits,
}); });
} else { } else {
containerWeightResults.push({ containerWeightResults.push({
@@ -719,6 +780,7 @@ export class RuleEngineService {
containerTypeId: string; containerTypeId: string;
quantity: number; quantity: number;
totalVgmTons: number; totalVgmTons: number;
unitVgmTons?: number[];
}>, }>,
tradeDirection: string, tradeDirection: string,
): Promise<string[]> { ): Promise<string[]> {
@@ -731,13 +793,17 @@ export class RuleEngineService {
const rule = rules[0]; const rule = rules[0];
if (!rule || rule.maxCapacityTons == null) continue; if (!rule || rule.maxCapacityTons == null) continue;
const perUnit = Number(rule.maxCapacityTons); const perUnit = Number(rule.maxCapacityTons);
const maxTotal = perUnit * container.quantity; const label = rule.containerType?.code ?? container.containerTypeId;
if (container.totalVgmTons > maxTotal) { // Capacity is a physical ceiling on one box, so it is checked per box for
const label = rule.containerType?.code ?? container.containerTypeId; // the same reason the VGM limit is — a light container cannot carry the
violations.push( // overload of a heavy one.
`${label} total weight ${container.totalVgmTons}t exceeds the maximum capacity of ${maxTotal}t (${perUnit}t per unit) — the booking cannot be created; reduce the cargo weight`, unitWeights(container).forEach((vgmTons, i) => {
); if (vgmTons > perUnit) {
} violations.push(
`${label} #${i + 1} weight ${round3(vgmTons)}t exceeds the maximum capacity of ${perUnit}t per container — the booking cannot be created; reduce the cargo weight`,
);
}
});
} }
return violations; return violations;
} }

View File

@@ -484,6 +484,13 @@ export class ShippingLineBookingCompletionService {
returnQuantity: 0, returnQuantity: 0,
vgmPerUnitTons: figures.vgmPerUnit, vgmPerUnitTons: figures.vgmPerUnit,
totalVgmTons: figures.totalVgm, totalVgmTons: figures.totalVgm,
// In-memory units so the probe prices the same per-container
// overweight the persisted booking will: the limit applies to each
// box, not to the line's pooled tonnage.
units: (line.units ?? []).map((u, idx) => ({
vgmTons: Number(u.vgmTons ?? 0),
sortOrder: idx,
})) as BookingContainer['units'],
wagonsRequired: Math.ceil( wagonsRequired: Math.ceil(
line.quantity * wagonsPerUnitForSize(containerType.sizeFt), line.quantity * wagonsPerUnitForSize(containerType.sizeFt),
), ),

View File

@@ -195,6 +195,20 @@ describe('BookingBatchService — PAID reconcile', () => {
expect(trainSchedulingService.tryAutoWagonAllocation).toHaveBeenCalledTimes(2); expect(trainSchedulingService.tryAutoWagonAllocation).toHaveBeenCalledTimes(2);
}); });
it('ensurePaidBookingAllocated never resurrects a CANCELLED booking that is still paymentStatus PAID', async () => {
dataSource.getRepository().findOne.mockResolvedValue({
...paidBooking,
status: 'CANCELLED',
trainScheduleId: null,
} as unknown as Booking);
await service.ensurePaidBookingAllocated(bookingId);
expect(trainScheduleBookingsRepository.createMany).not.toHaveBeenCalled();
expect(trainSchedulingService.tryAutoWagonAllocation).not.toHaveBeenCalled();
expect(dataSource.getRepository().update).not.toHaveBeenCalled();
});
it('ensurePaidBookingAllocated holds a wagon-short booking out of the train', async () => { it('ensurePaidBookingAllocated holds a wagon-short booking out of the train', async () => {
trainSchedulingService.previewPaidBookingWagonShortage.mockResolvedValue({ trainSchedulingService.previewPaidBookingWagonShortage.mockResolvedValue({
wagonTypeCodes: 'NW6', wagonTypeCodes: 'NW6',

View File

@@ -571,6 +571,12 @@ export class BookingBatchService implements OnModuleInit {
relations: { company: true }, relations: { company: true },
}); });
if (!booking) return; if (!booking) return;
// A dead booking keeps payment_status = 'PAID' (it was paid before it died),
// so every rescue path below would happily re-place and re-allocate it —
// that is how a cancelled consolidation-lapse booking came back onto its
// train 30s after being cancelled. Never resurrect a dead booking.
if (["CANCELLED", "EXPIRED", "REJECTED", "COMPLETED"].includes(booking.status))
return;
if (!booking.trainScheduleId) { if (!booking.trainScheduleId) {
// A paid booking with no train is money taken and nothing boarding. The // A paid booking with no train is money taken and nothing boarding. The
// hold was expired before the payment landed (webhook lag beat the // hold was expired before the payment landed (webhook lag beat the

View File

@@ -64,7 +64,9 @@ async function main(): Promise<void> {
const args = process.argv.slice(2); const args = process.argv.slice(2);
const sheetFlag = args.indexOf("--sheet"); const sheetFlag = args.indexOf("--sheet");
const sheetName = sheetFlag >= 0 ? args[sheetFlag + 1] : DEFAULT_SHEET; const sheetName = sheetFlag >= 0 ? args[sheetFlag + 1] : DEFAULT_SHEET;
const workbookPath = args.find((arg, i) => !arg.startsWith("--") && i !== sheetFlag + 1); const workbookPath = args.find(
(arg, i) => !arg.startsWith("--") && (sheetFlag < 0 || i !== sheetFlag + 1),
);
if (!workbookPath) { if (!workbookPath) {
throw new Error( throw new Error(

View File

@@ -2410,9 +2410,9 @@ export default function GlCreateBookingForm() {
<Stack gap={6}> <Stack gap={6}>
{overweightLines.map((line, i) => ( {overweightLines.map((line, i) => (
<Text key={i} fz="sm" c="#9A5B00"> <Text key={i} fz="sm" c="#9A5B00">
{line.containerTypeCode}: {line.totalVgmTons}t exceeds {line.containerLabel || line.containerTypeCode}:{" "}
limit {line.maxAllowedTons}t (+{line.excessTons}t {line.totalVgmTons}t exceeds limit {line.maxAllowedTons}t
overweight) (+{line.excessTons}t overweight)
</Text> </Text>
))} ))}
<Text fz="xs" c="#9A5B00" mt={2}> <Text fz="xs" c="#9A5B00" mt={2}>

View File

@@ -1,6 +1,6 @@
import { directionLabel } from "@/lib/utils"; import { directionLabel } from "@/lib/utils";
import { useMemo } from "react"; import { useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query"; import { useMutation, useQuery } from "@tanstack/react-query";
import { useLocation, useParams } from "react-router-dom"; import { useLocation, useParams } from "react-router-dom";
import { import {
Alert, Alert,
@@ -14,6 +14,7 @@ import {
Stack, Stack,
Text, Text,
} from "@mantine/core"; } from "@mantine/core";
import { DatePickerInput } from "@mantine/dates";
import { import {
AlertCircle, AlertCircle,
ArrowRight, ArrowRight,
@@ -22,9 +23,16 @@ import {
PackageCheck, PackageCheck,
RefreshCw, RefreshCw,
ShieldCheck, ShieldCheck,
XCircle,
} from "lucide-react"; } from "lucide-react";
import toast from "react-hot-toast";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { api } from "@/auth/http";
import { extractErrorMessage } from "@/utils/errorExtractor";
import { formatMoney } from "@/lib/format";
import { toDayString } from "@/hooks/useListControls";
import { useAuth } from "@/auth/useAuth"; import { useAuth } from "@/auth/useAuth";
import { import {
FREIGHT_PERMS, FREIGHT_PERMS,
@@ -146,6 +154,32 @@ export default function ContractClearanceDetailPage() {
!isDjiboutiGl(user); !isDjiboutiGl(user);
const canResubmitBooking = bookingNeedsChanges && isGlBookingOwner; const canResubmitBooking = bookingNeedsChanges && isGlBookingOwner;
const canRebook = bookingExpired && isGlBookingOwner; const canRebook = bookingExpired && isGlBookingOwner;
// The linked booking was CANCELLED with an open wagon-cancellation ledger row
// (consolidation partner lapsed unpaid, staff cut). FEE_PENDING: the customer
// must settle the cancellation-fee invoice first; CREDIT_AVAILABLE: the paid
// freight is credit and GL rebooks it from here (new booking under this
// contract, marked PAID — it re-enters consolidation pairing if odd 20ft).
const cancellation =
clearance?.linkedBookingStatus === "CANCELLED"
? clearance?.linkedBookingCancellation
: null;
const canRebookCredit =
cancellation?.status === "CREDIT_AVAILABLE" &&
hasPermission(user, FREIGHT_PERMS.bookings.wagonCancellationRebook);
const [creditRebookDate, setCreditRebookDate] = useState<Date | null>(null);
const creditRebook = useMutation({
mutationFn: () =>
api.post(`/bookings/wagon-cancellations/${cancellation!.id}/rebook`, {
scheduledDate: toDayString(creditRebookDate!),
}),
onSuccess: () => {
toast.success("Booking recreated from the credit and marked paid.");
void refetch();
void refetchContract();
},
onError: (err) =>
toast.error(extractErrorMessage(err, "Rebook failed")),
});
// Rebook completes the SAME expired booking (it already carries the price and // Rebook completes the SAME expired booking (it already carries the price and
// cargo from its first completion) via the /complete endpoint's EXPIRED // cargo from its first completion) via the /complete endpoint's EXPIRED
// branch — routing it through create-booking instead would create a // branch — routing it through create-booking instead would create a
@@ -252,7 +286,18 @@ export default function ContractClearanceDetailPage() {
Customs Customs
</Badge> </Badge>
) : null} ) : null}
{bookingExpired ? ( {cancellation ? (
<Badge
variant="light"
color="red"
radius="sm"
leftSection={<XCircle size={13} />}
>
{cancellation.status === "FEE_PENDING"
? "Cancelled — fee pending"
: "Cancelled — rebook credit"}
</Badge>
) : bookingExpired ? (
<Badge <Badge
variant="light" variant="light"
color="orange" color="orange"
@@ -308,7 +353,59 @@ export default function ContractClearanceDetailPage() {
it can actually create the booking without checking the schedule board. */} it can actually create the booking without checking the schedule board. */}
{id ? <GlUpcomingWindowsSection contractId={id} /> : null} {id ? <GlUpcomingWindowsSection contractId={id} /> : null}
{bookingExpired ? ( {cancellation ? (
<Alert
color="red"
radius="md"
icon={<XCircle size={16} />}
title={`Booking ${clearance.linkedBookingReference ?? ""} cancelled — consolidation partner not paid`}
>
<Stack gap="sm" align="flex-start">
{cancellation.status === "FEE_PENDING" ? (
<Text size="sm">
The booking shared a wagon with a partner booking that was
never paid, so it could not board and was cancelled. Its paid
freight ({formatMoney(cancellation.creditAmount, cancellation.creditCurrency)}) is held as
rebooking credit. The customer must first pay the cancellation
fee from the portal Payments tab once it settles, rebook the
shipment here.
</Text>
) : (
<>
<Text size="sm">
The cancellation fee is settled. Pick the new shipment day
and rebook the booking is recreated under this contract
from the {formatMoney(cancellation.creditAmount, cancellation.creditCurrency)} credit and
marked paid (no new freight charge).
</Text>
{canRebookCredit ? (
<Group gap="sm" align="flex-end">
<DatePickerInput
label="New shipment day"
placeholder="Pick a day"
value={creditRebookDate}
onChange={(v) => setCreditRebookDate(v ? new Date(v) : null)}
minDate={new Date()}
w={220}
/>
<Button
color="grape"
radius="md"
size="sm"
leftSection={<RefreshCw size={15} />}
loading={creditRebook.isPending}
disabled={!creditRebookDate}
onClick={() => creditRebook.mutate()}
>
Rebook for customer
</Button>
</Group>
) : null}
</>
)}
</Stack>
</Alert>
) : bookingExpired ? (
<Alert <Alert
color="orange" color="orange"
radius="md" radius="md"

View File

@@ -56,6 +56,9 @@ export interface ShipmentPriceLine {
export interface ShipmentValidation { export interface ShipmentValidation {
overweightLines: Array<{ overweightLines: Array<{
containerTypeCode: string; containerTypeCode: string;
/** Container number, or "<code> #2" when unnumbered. */
containerLabel: string;
/** This container's VGM — the limit is per container, never pooled. */
totalVgmTons: number; totalVgmTons: number;
maxAllowedTons: number; maxAllowedTons: number;
excessTons: number; excessTons: number;

View File

@@ -1024,8 +1024,9 @@ function PriceConfirmModal({
<Stack gap={6}> <Stack gap={6}>
{overweightLines.map((line, i) => ( {overweightLines.map((line, i) => (
<Text key={i} fz="sm" c="#9A5B00"> <Text key={i} fz="sm" c="#9A5B00">
{line.containerTypeCode}: {line.totalVgmTons}t exceeds limit{" "} {line.containerLabel || line.containerTypeCode}:{" "}
{line.maxAllowedTons}t (+{line.excessTons}t overweight) {line.totalVgmTons}t exceeds limit {line.maxAllowedTons}t (+
{line.excessTons}t overweight)
</Text> </Text>
))} ))}
<Text fz="xs" c="#9A5B00" mt={2}> <Text fz="xs" c="#9A5B00" mt={2}>

View File

@@ -644,8 +644,9 @@ function PriceConfirmModal({
<Stack gap={6}> <Stack gap={6}>
{overweightLines.map((line, i) => ( {overweightLines.map((line, i) => (
<Text key={i} fz="sm" c="#9A5B00"> <Text key={i} fz="sm" c="#9A5B00">
{line.containerTypeCode}: {line.totalVgmTons}t exceeds limit{" "} {line.containerLabel || line.containerTypeCode}:{" "}
{line.maxAllowedTons}t (+{line.excessTons}t overweight) {line.totalVgmTons}t exceeds limit {line.maxAllowedTons}t (+
{line.excessTons}t overweight)
</Text> </Text>
))} ))}
<Text fz="xs" c="#9A5B00" mt={2}> <Text fz="xs" c="#9A5B00" mt={2}>

View File

@@ -33,9 +33,12 @@ export interface SubmitContractResponse {
message?: string; message?: string;
} }
/** A container line whose total VGM exceeds the weight-limit rule. */ /** One physical container whose VGM exceeds the weight-limit rule. */
export interface OverweightLine { export interface OverweightLine {
containerTypeCode: string; containerTypeCode: string;
/** Container number, or "<code> #2" when unnumbered. */
containerLabel: string;
/** This container's VGM — the limit is per container, never pooled. */
totalVgmTons: number; totalVgmTons: number;
maxAllowedTons: number; maxAllowedTons: number;
excessTons: number; excessTons: number;

View File

@@ -70,6 +70,9 @@ export interface ShippingLinePriceQuote {
/** Containers over their type's weight limit — a surcharge, not a block. */ /** Containers over their type's weight limit — a surcharge, not a block. */
overweightLines: { overweightLines: {
containerTypeCode: string; containerTypeCode: string;
/** Container number, or "<code> #2" when unnumbered. */
containerLabel: string;
/** This container's VGM — the limit is per container, never pooled. */
totalVgmTons: number; totalVgmTons: number;
maxAllowedTons: number; maxAllowedTons: number;
excessTons: number; excessTons: number;

Binary file not shown.

View File

@@ -511,6 +511,18 @@ export interface ContractClearanceView {
linkedBookingReviewNote?: string | null; linkedBookingReviewNote?: string | null;
/** Shipment day the booking holds; the default when GL resubmits it. */ /** Shipment day the booking holds; the default when GL resubmits it. */
linkedBookingScheduledDate?: string | null; 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;
/** /**
* Pre-declaration handshake with GL Djibouti: who handles the shipment in * Pre-declaration handshake with GL Djibouti: who handles the shipment in
* transit. `name` stays null until Djibouti answers, and GL Ethiopia cannot * transit. `name` stays null until Djibouti answers, and GL Ethiopia cannot