mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
Merge pull request #1387 from Tria-plc/freight_feature/usermanagement
fix issue
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { Freight } from "@edr/types";
|
||||
|
||||
import { WAGON_CANCEL_FEE_INVOICE_TYPE } from "../bookings/entities/booking-wagon-cancellation.entity";
|
||||
import { BillingService } from "./billing.service";
|
||||
|
||||
/**
|
||||
@@ -1033,3 +1034,73 @@ describe("BillingService.document", () => {
|
||||
expect(render).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The pay-window guard belongs to the freight invoice. A wagon-cancellation fee
|
||||
* rides source=booking but is raised on an already-PAID booking, so it inherits
|
||||
* a deadline that has long passed — guarding it would make the fee permanently
|
||||
* unsettleable.
|
||||
*/
|
||||
describe("BillingService.confirmOfflinePayment pay-window guard", () => {
|
||||
const PAST = new Date(Date.now() - 86_400_000);
|
||||
|
||||
function makeService(invoiceType: string) {
|
||||
const invoice = {
|
||||
id: "inv-1",
|
||||
source: Freight.InvoiceSource.Booking,
|
||||
sourceId: "booking-1",
|
||||
type: invoiceType,
|
||||
currency: "ETB",
|
||||
status: Freight.InvoiceStatus.Issued,
|
||||
balanceAmount: 500,
|
||||
};
|
||||
const recordPayment = jest.fn().mockResolvedValue(invoice);
|
||||
const dataSource = {
|
||||
getRepository: () => ({
|
||||
findOne: async () => ({ id: "booking-1", paymentDeadline: PAST }),
|
||||
}),
|
||||
};
|
||||
const service = new BillingService(
|
||||
dataSource as never,
|
||||
{ findById: async () => invoice } as never,
|
||||
{} as never,
|
||||
makeEvents() as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{ upload: async () => ({ id: "file-1", name: "slip.pdf" }) } as never,
|
||||
{ get: () => undefined } as never,
|
||||
{ isEnabled: async () => true } as never,
|
||||
);
|
||||
(service as unknown as { recordPayment: unknown }).recordPayment =
|
||||
recordPayment;
|
||||
return { service, recordPayment };
|
||||
}
|
||||
|
||||
const slip = { originalname: "slip.pdf" } as never;
|
||||
|
||||
it("refuses a freight invoice once the pay window has closed", async () => {
|
||||
const { service } = makeService("PREPAID");
|
||||
await expect(
|
||||
service.confirmOfflinePayment("inv-1", slip, {}),
|
||||
).rejects.toThrow(/payment window has closed/i);
|
||||
});
|
||||
|
||||
it("settles a wagon-cancellation fee despite the closed window", async () => {
|
||||
const { service, recordPayment } = makeService(
|
||||
WAGON_CANCEL_FEE_INVOICE_TYPE,
|
||||
);
|
||||
await service.confirmOfflinePayment("inv-1", slip, {});
|
||||
expect(recordPayment).toHaveBeenCalledWith(
|
||||
"inv-1",
|
||||
expect.objectContaining({ amount: 500, method: "BANK_TRANSFER" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("still requires the bank slip for a cancellation fee", async () => {
|
||||
const { service } = makeService(WAGON_CANCEL_FEE_INVOICE_TYPE);
|
||||
await expect(
|
||||
service.confirmOfflinePayment("inv-1", undefined, {}),
|
||||
).rejects.toThrow(/slip file is required/i);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,6 +14,7 @@ import { DataSource, EntityManager, In, SelectQueryBuilder } from "typeorm";
|
||||
|
||||
import { Booking } from "../bookings/entities/booking.entity";
|
||||
import { AdditionalCharge } from "../bookings/entities/additional-charge.entity";
|
||||
import { WAGON_CANCEL_FEE_INVOICE_TYPE } from "../bookings/entities/booking-wagon-cancellation.entity";
|
||||
// Entity-only import (no module edge): portal reads resolve shipping-line
|
||||
// payers straight off the table.
|
||||
import { ShippingLineCompany } from "../shipping-lines/entities/shipping-line-company.entity";
|
||||
@@ -710,7 +711,14 @@ export class BillingService {
|
||||
throw new BadRequestException("The bank payment slip file is required.");
|
||||
}
|
||||
|
||||
if (invoice.source === "booking") {
|
||||
// The pay window belongs to the freight invoice. A wagon-cancellation fee
|
||||
// rides source=booking but is raised on an ALREADY-PAID booking, so it
|
||||
// inherits a deadline that has long passed — guarding it would make the fee
|
||||
// permanently unsettleable.
|
||||
if (
|
||||
invoice.source === "booking" &&
|
||||
invoice.type !== WAGON_CANCEL_FEE_INVOICE_TYPE
|
||||
) {
|
||||
const booking = await this.dataSource.getRepository(Booking).findOne({
|
||||
where: { id: invoice.sourceId },
|
||||
select: ["id", "paymentDeadline"],
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
import { BookingsRepository } from './bookings.repository';
|
||||
import {
|
||||
RebookCancelledWagonsDto,
|
||||
RebookContainerLineDto,
|
||||
RequestWagonCancellationDto,
|
||||
} from './dto/wagon-cancellation.dto';
|
||||
import { Booking } from './entities/booking.entity';
|
||||
@@ -45,8 +46,11 @@ import {
|
||||
BookingWagonCancellation,
|
||||
CancelledQuantities,
|
||||
CancelledUnitSnapshot,
|
||||
WAGON_CANCEL_FEE_INVOICE_TYPE,
|
||||
} from './entities/booking-wagon-cancellation.entity';
|
||||
|
||||
export { WAGON_CANCEL_FEE_INVOICE_TYPE };
|
||||
|
||||
/**
|
||||
* rates.rate_type of the cancellation fee — an existing rate-engine type
|
||||
* (trigger CANCELLATION, never auto-applied to booking pricing). Staff
|
||||
@@ -59,8 +63,6 @@ export const WAGON_CANCELLATION_FEE_RATE_TYPE = 'CANCELLATION_FEE';
|
||||
/** `booking_container.container_size` is stored as "20ft"/"40ft" — `Number()` on it is NaN. */
|
||||
const sizeFtOf = (size: string | number | null | undefined): number =>
|
||||
parseInt(String(size ?? ''), 10);
|
||||
/** invoices.type of the fee invoice — the settlement branch key in BookingInvoiceService. */
|
||||
export const WAGON_CANCEL_FEE_INVOICE_TYPE = 'WAGON_CANCEL_FEE';
|
||||
|
||||
const round2 = (n: number): number => Math.round(n * 100) / 100;
|
||||
const round3 = (n: number): number => Math.round(n * 1000) / 1000;
|
||||
@@ -767,7 +769,7 @@ export class BookingWagonCancellationService {
|
||||
);
|
||||
}
|
||||
|
||||
const createDto = this.buildRebookDto(row, dto.scheduledDate);
|
||||
const createDto = this.buildRebookDto(row, dto.scheduledDate, dto.containers);
|
||||
// Same currency as the source booking — the credit is in it.
|
||||
createDto.paymentCurrency = source.paymentCurrency ?? undefined;
|
||||
const created = await this.contractBooking.createUnderContract(
|
||||
@@ -1460,11 +1462,25 @@ export class BookingWagonCancellationService {
|
||||
private buildRebookDto(
|
||||
row: BookingWagonCancellation,
|
||||
scheduledDate: string,
|
||||
overrides?: RebookContainerLineDto[],
|
||||
): CreateBookingUnderContractDto {
|
||||
const dto: CreateBookingUnderContractDto = { scheduledDate };
|
||||
const q = row.cancelledQuantities;
|
||||
|
||||
if (q.bySize && Object.keys(q.bySize).length) {
|
||||
// Unit overrides may rename containers, change seals and VGM — but the
|
||||
// cancelled sizes and quantities are the contract of the credit: a size
|
||||
// not on the credit, or a wrong unit count, is rejected.
|
||||
const overrideBySize = new Map(
|
||||
(overrides ?? []).map((o) => [o.containerSize, o.units]),
|
||||
);
|
||||
for (const size of overrideBySize.keys()) {
|
||||
if (!(size in q.bySize)) {
|
||||
throw new BadRequestException(
|
||||
`The credit has no ${size} containers — sizes and quantities must match the cancelled booking.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const units = q.units ?? [];
|
||||
dto.containers = Object.entries(q.bySize).map(([size, quantity]) => {
|
||||
const sized = units.filter((u) => u.containerSize === size);
|
||||
@@ -1473,13 +1489,23 @@ export class BookingWagonCancellationService {
|
||||
`Credit is missing unit snapshots for size ${size} (${sized.length}/${quantity}) — contact EDR support.`,
|
||||
);
|
||||
}
|
||||
const replacement = overrideBySize.get(size);
|
||||
if (replacement && replacement.length !== quantity) {
|
||||
throw new BadRequestException(
|
||||
`The credit covers exactly ${quantity} × ${size} — you entered ${replacement.length}. Quantities cannot change on a rebook.`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
containerSize: size,
|
||||
quantity,
|
||||
units: sized.map((u) => ({
|
||||
containerNumber: u.containerNumber,
|
||||
sealNumber: u.sealNumber ?? undefined,
|
||||
vgmTons: u.vgmTons,
|
||||
// Hazardous/reefer flags always ride from the snapshot (the cargo is
|
||||
// the same cargo); number/seal/VGM come from the override when given.
|
||||
units: sized.map((u, i) => ({
|
||||
containerNumber: replacement?.[i]?.containerNumber ?? u.containerNumber,
|
||||
sealNumber: replacement
|
||||
? (replacement[i]?.sealNumber ?? undefined)
|
||||
: (u.sealNumber ?? undefined),
|
||||
vgmTons: replacement?.[i]?.vgmTons ?? u.vgmTons,
|
||||
isHazardous: u.isHazardous,
|
||||
isReefer: u.isReefer,
|
||||
})),
|
||||
|
||||
@@ -52,6 +52,7 @@ import {
|
||||
FreightType,
|
||||
} from './entities/booking.entity';
|
||||
import { Booking } from './entities/booking.entity';
|
||||
import { BookingWagonCancellation } from './entities/booking-wagon-cancellation.entity';
|
||||
import { BookingContainerAllocation } from './entities/booking-container-allocation.entity';
|
||||
import { FileRecord } from '../files/entities/file.entity';
|
||||
import { CustomerTruckAssignmentDto } from './dto/customer-truck-assignment.dto';
|
||||
@@ -2262,16 +2263,25 @@ export class BookingsService {
|
||||
return this.findById(booking.id);
|
||||
}
|
||||
|
||||
/** Upload documents for a DRAFT booking. */
|
||||
/**
|
||||
* Upload documents for a DRAFT booking — or for a booking created by
|
||||
* rebooking a wagon-cancellation credit, whose paperwork may have changed
|
||||
* with the new containers (old documents stay; new ones ride alongside).
|
||||
*/
|
||||
async uploadDocuments(
|
||||
id: string,
|
||||
files: Express.Multer.File[],
|
||||
): Promise<Booking> {
|
||||
const booking = await this.findById(id);
|
||||
if (booking.status !== 'DRAFT') {
|
||||
throw new BadRequestException(
|
||||
'Documents can only be uploaded for DRAFT bookings',
|
||||
);
|
||||
const rebooked = await this.dataSource
|
||||
.getRepository(BookingWagonCancellation)
|
||||
.findOne({ where: { rebookedBookingId: id } });
|
||||
if (!rebooked) {
|
||||
throw new BadRequestException(
|
||||
'Documents can only be uploaded for DRAFT bookings',
|
||||
);
|
||||
}
|
||||
}
|
||||
await this.filesService.uploadMany(id, 'bookings', files);
|
||||
return this.findById(id);
|
||||
|
||||
@@ -67,10 +67,60 @@ export class RequestWagonCancellationDto {
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export class RebookUnitDto {
|
||||
@ApiProperty({ description: 'Container number for the rebooked unit' })
|
||||
@IsString()
|
||||
@MaxLength(64)
|
||||
containerNumber!: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Seal number' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(64)
|
||||
sealNumber?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'VGM (tons) of the unit' })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
vgmTons?: number;
|
||||
}
|
||||
|
||||
export class RebookContainerLineDto {
|
||||
@ApiProperty({ description: 'Container size as stored on the credit, e.g. "20ft"' })
|
||||
@IsString()
|
||||
containerSize!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'The rebooked units for this size — count MUST equal the cancelled quantity',
|
||||
type: [RebookUnitDto],
|
||||
})
|
||||
@IsArray()
|
||||
@ArrayNotEmpty()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => RebookUnitDto)
|
||||
units!: RebookUnitDto[];
|
||||
}
|
||||
|
||||
export class RebookCancelledWagonsDto {
|
||||
@ApiProperty({ description: 'Shipment day the credit is rebooked onto (ISO date)' })
|
||||
@IsDateString()
|
||||
scheduledDate!: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'Optional unit overrides: container number / seal / VGM may change, but ' +
|
||||
'sizes and quantities must match the cancelled booking exactly. Sizes ' +
|
||||
'omitted here keep their original units.',
|
||||
type: [RebookContainerLineDto],
|
||||
})
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayNotEmpty()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => RebookContainerLineDto)
|
||||
containers?: RebookContainerLineDto[];
|
||||
}
|
||||
|
||||
export class FilterWagonCancellationsDto {
|
||||
|
||||
@@ -5,6 +5,9 @@ import { Invoice } from '../../billing/entities/invoice.entity';
|
||||
import { Rate } from '../../rule-engine/entities/rate.entity';
|
||||
import { Booking } from './booking.entity';
|
||||
|
||||
/** `invoices.type` of the wagon-cancellation fee invoice — the settlement branch key in BookingInvoiceService. */
|
||||
export const WAGON_CANCEL_FEE_INVOICE_TYPE = 'WAGON_CANCEL_FEE';
|
||||
|
||||
export const WAGON_CANCELLATION_STATUSES = [
|
||||
// Requested; fee invoice open; wagons still allocated to the customer.
|
||||
'FEE_PENDING',
|
||||
|
||||
@@ -8821,6 +8821,13 @@ export class TrainSchedulingService {
|
||||
allocatedWeightTons: roundTons(Number(allocation.allocatedWeightTons)),
|
||||
loadType: allocation.loadType ?? null,
|
||||
status: allocation.status,
|
||||
// THIS load's own corridor, not the wagon's union span.
|
||||
// A wagon reused across disjoint legs carries two loads
|
||||
// with different yards; without these the leg board can
|
||||
// only draw one merged bar and cannot say which load
|
||||
// rides which leg.
|
||||
originYardId: allocation.booking?.originYardId ?? null,
|
||||
destinationYardId: allocation.booking?.destinationYardId ?? null,
|
||||
containerItems: (containerItemsByAllocation.get(allocation.id) ?? []).map(
|
||||
(item) => ({
|
||||
id: item.id,
|
||||
|
||||
@@ -35,11 +35,34 @@ export type DeferredBookingRow = {
|
||||
shortage?: BookingWagonShortage | null;
|
||||
};
|
||||
|
||||
/** A booking the customer has already paid for. */
|
||||
const isPaid = (booking: Booking): boolean =>
|
||||
booking.paymentStatus === 'PAID' || booking.status === 'PAID';
|
||||
|
||||
/**
|
||||
* Seating order for the wagon planner.
|
||||
*
|
||||
* Government first, then PAID bookings, then priority score, then date.
|
||||
*
|
||||
* Payment ranks above priority score on purpose: money has changed hands and
|
||||
* the customer was promised space on THIS train. Without it the planner
|
||||
* seated an unpaid booking that merely arrived earlier and left a paid one
|
||||
* with no wagon — the reported S-2026-00045 case, where a paid 695T bulk
|
||||
* booking lost every wagon to unpaid container bookings and vanished from
|
||||
* the train with free PW2 still standing in the consist.
|
||||
*
|
||||
* This only decides who is seated FIRST when the train is oversubscribed. It
|
||||
* never invents capacity: an oversubscribed train still defers someone, and
|
||||
* that someone is now the party who has not paid.
|
||||
*/
|
||||
export function sortBookingsForScheduling(bookings: Booking[]): Booking[] {
|
||||
return [...bookings].sort((a, b) => {
|
||||
const govDiff = Number(Boolean(b.isGovernment)) - Number(Boolean(a.isGovernment));
|
||||
if (govDiff !== 0) return govDiff;
|
||||
|
||||
const paidDiff = Number(isPaid(b)) - Number(isPaid(a));
|
||||
if (paidDiff !== 0) return paidDiff;
|
||||
|
||||
const priorityDiff = (b.priorityScore ?? 0) - (a.priorityScore ?? 0);
|
||||
if (priorityDiff !== 0) return priorityDiff;
|
||||
|
||||
|
||||
@@ -154,10 +154,46 @@ const shortageFor = (
|
||||
),
|
||||
)
|
||||
: Math.max(1, containerWagonsForLines(booking.bookingContainers ?? []));
|
||||
const wagonsAvailable = candidates.reduce(
|
||||
(sum, wt) => sum + availableOf(wt.id),
|
||||
0,
|
||||
);
|
||||
|
||||
const freeByType = candidates.map((wt) => ({ wt, free: availableOf(wt.id) }));
|
||||
const wagonsAvailable = freeByType.reduce((sum, c) => sum + c.free, 0);
|
||||
|
||||
// PER_TON bulk: a bare wagon COUNT lies when the types carry different
|
||||
// tonnage for this cargo. 14 NW5 (30T) + 10 PW2 (20T) is "24 wagons free"
|
||||
// against a 24-wagon need, yet only 620T of the 695T booking fits — which
|
||||
// is how a deferral could read "needs 24, 24 available (short 1)". Size the
|
||||
// shortfall in the wagons the cargo's OWN caps require: how many more
|
||||
// wagons of the best remaining type would carry the leftover tonnage.
|
||||
const tons = bookingCargoTons(booking);
|
||||
const perItem =
|
||||
Number(booking.bulkTotalWeightTons ?? 0) > 0 &&
|
||||
Number(booking.cargoTotalWeightVgm ?? 0) > 0;
|
||||
if (booking.freightType === 'BULK' && !perItem && tons > 0) {
|
||||
let seatable = 0;
|
||||
let usedWagons = 0;
|
||||
for (const { wt, free } of freeByType) {
|
||||
const perWagon = bulkTonsPerWagon(booking.cargoType, wt.id, Number(wt.capacityTons));
|
||||
if (!(perWagon > 0) || free <= 0) continue;
|
||||
seatable += free * perWagon;
|
||||
usedWagons += free;
|
||||
}
|
||||
if (seatable < tons) {
|
||||
const bestPerWagon = Math.max(
|
||||
1,
|
||||
...candidates.map((wt) =>
|
||||
bulkTonsPerWagon(booking.cargoType, wt.id, Number(wt.capacityTons)),
|
||||
),
|
||||
);
|
||||
return {
|
||||
wagonTypeCodes: [...new Set(candidates.map((wt) => wt.code))].join('/'),
|
||||
wagonsNeeded,
|
||||
wagonsAvailable: usedWagons,
|
||||
// Wagons of the best type still missing to carry the leftover tonnage.
|
||||
wagonsShort: Math.max(1, Math.ceil((tons - seatable) / bestPerWagon)),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
wagonTypeCodes: [...new Set(candidates.map((wt) => wt.code))].join('/'),
|
||||
wagonsNeeded,
|
||||
|
||||
Reference in New Issue
Block a user