mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 21:08:12 +00:00
@@ -1,6 +1,7 @@
|
||||
import "reflect-metadata";
|
||||
import * as dotenv from "dotenv";
|
||||
dotenv.config();
|
||||
import { createRequire } from "node:module";
|
||||
import { NestFactory } from "@nestjs/core";
|
||||
import type { NestExpressApplication } from "@nestjs/platform-express";
|
||||
import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger";
|
||||
@@ -20,6 +21,62 @@ import { AppModule } from "./app.module";
|
||||
*/
|
||||
const JSON_BODY_LIMIT = '20mb';
|
||||
|
||||
/**
|
||||
* Static /etc/hosts-style overrides from `DNS_HOST_OVERRIDES`, formatted as
|
||||
* `host=ip,host2=ip2`. Some internal hosts (MinIO) resolve only inside the
|
||||
* deployment network, so dev machines get ENOTFOUND on every upload. Patching
|
||||
* `dns.lookup` keeps the real hostname on the wire — the IP is used for the
|
||||
* connection only — so TLS still validates against the certificate's CN.
|
||||
*
|
||||
* The module is loaded through `createRequire`, NOT `import * as dns`: an ESM
|
||||
* namespace object is frozen, so assigning to it is silently dropped and the
|
||||
* patch becomes a no-op. `require` returns the live module object every other
|
||||
* caller (minio's http agent included) reads `lookup` off.
|
||||
*/
|
||||
function applyDnsHostOverrides(): void {
|
||||
const raw = process.env.DNS_HOST_OVERRIDES?.trim();
|
||||
if (!raw) return;
|
||||
|
||||
const overrides = new Map<string, string>();
|
||||
for (const entry of raw.split(",")) {
|
||||
const [host, ip] = entry.split("=").map((part) => part?.trim());
|
||||
if (host && ip) overrides.set(host.toLowerCase(), ip);
|
||||
}
|
||||
if (overrides.size === 0) return;
|
||||
|
||||
const dns = createRequire(__filename)("node:dns") as typeof import("node:dns");
|
||||
const originalLookup = dns.lookup.bind(dns);
|
||||
// `dns.lookup` is overloaded (options optional, all/family variants); the
|
||||
// cast keeps that surface intact while we intercept only mapped hostnames.
|
||||
(dns as { lookup: unknown }).lookup = ((
|
||||
hostname: string,
|
||||
options: unknown,
|
||||
callback?: unknown,
|
||||
) => {
|
||||
const ip = overrides.get(hostname?.toLowerCase?.());
|
||||
if (!ip) return (originalLookup as Function)(hostname, options, callback);
|
||||
|
||||
const done = (typeof options === "function" ? options : callback) as (
|
||||
err: NodeJS.ErrnoException | null,
|
||||
address: string | { address: string; family: number }[],
|
||||
family?: number,
|
||||
) => void;
|
||||
const family = ip.includes(":") ? 6 : 4;
|
||||
const wantsAll =
|
||||
typeof options === "object" && options !== null && (options as { all?: boolean }).all;
|
||||
|
||||
process.nextTick(() =>
|
||||
wantsAll ? done(null, [{ address: ip, family }]) : done(null, ip, family),
|
||||
);
|
||||
}) as typeof dns.lookup;
|
||||
|
||||
console.log(
|
||||
`[DNS] Host overrides active: ${[...overrides].map(([h, ip]) => `${h}->${ip}`).join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
applyDnsHostOverrides();
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create<NestExpressApplication>(AppModule);
|
||||
|
||||
|
||||
@@ -1289,6 +1289,38 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
.getMany();
|
||||
}
|
||||
|
||||
/**
|
||||
* EXPIRED bookings on the day's corridor — the batch board's expired lane.
|
||||
* Expiry nulls train_schedule_id, so neither findAllBySchedule nor the
|
||||
* ready-pool query can ever see them.
|
||||
*/
|
||||
findExpiredByCorridorDay(
|
||||
corridorYardIds: string[],
|
||||
day: string,
|
||||
): Promise<Booking[]> {
|
||||
if (corridorYardIds.length === 0) return Promise.resolve([]);
|
||||
return this.repository
|
||||
.createQueryBuilder('booking')
|
||||
.leftJoinAndSelect('booking.company', 'company')
|
||||
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
|
||||
.leftJoinAndSelect('bookingContainer.containerType', 'containerType')
|
||||
.leftJoinAndSelect('booking.cargoType', 'cargoType')
|
||||
.leftJoin(TrainScheduleBooking, 'sb', 'sb.booking_id = booking.id')
|
||||
.where('booking.origin_yard_id IN (:...corridorYardIds)', { corridorYardIds })
|
||||
.andWhere('booking.destination_yard_id IN (:...corridorYardIds)', {
|
||||
corridorYardIds,
|
||||
})
|
||||
.andWhere(
|
||||
`DATE(booking.scheduled_date AT TIME ZONE 'Africa/Addis_Ababa') = :day`,
|
||||
{ day },
|
||||
)
|
||||
.andWhere('sb.id IS NULL')
|
||||
.andWhere(`booking.status = 'EXPIRED'`)
|
||||
.orderBy('booking.priority_score', 'DESC')
|
||||
.addOrderBy('booking.created_at', 'ASC')
|
||||
.getMany();
|
||||
}
|
||||
|
||||
/**
|
||||
* Commercial bookings on the day's corridor whose operation request was NOT
|
||||
* accepted by staff (still pending / changes / price-confirm) and are not yet
|
||||
|
||||
@@ -290,6 +290,7 @@ export class ContractBookingService {
|
||||
isHazardous: this.resolveShipmentHandlingFlag(contract, dto, 'hazardousQuantity'),
|
||||
isReefer: this.resolveShipmentHandlingFlag(contract, dto, 'reeferQuantity'),
|
||||
cargoTotalWeightVgm: this.resolveBulkTons(dto),
|
||||
bulkTotalWeightTons: this.resolveBulkWeightTons(dto),
|
||||
firstMilePickupAddress: contract.firstMilePickupAddress ?? null,
|
||||
firstMilePickupLat: contract.firstMilePickupLat ?? null,
|
||||
firstMilePickupLng: contract.firstMilePickupLng ?? null,
|
||||
@@ -773,6 +774,7 @@ export class ContractBookingService {
|
||||
cargoTypeId: this.resolveCargoTypeId(contract, dto),
|
||||
cargoFreeText: dto.cargoFreeText?.trim() || null,
|
||||
cargoTotalWeightVgm: this.resolveBulkTons(dto),
|
||||
bulkTotalWeightTons: this.resolveBulkWeightTons(dto),
|
||||
equipmentReturn: this.resolveShipmentEquipmentReturn(contract, dto),
|
||||
// Completion is where the cargo — and therefore the price — is fixed, so
|
||||
// it is also where the billing currency is chosen. A bare instance was
|
||||
@@ -1253,6 +1255,7 @@ export class ContractBookingService {
|
||||
}
|
||||
|
||||
probe.cargoTotalWeightVgm = this.resolveBulkTons(dto);
|
||||
probe.bulkTotalWeightTons = this.resolveBulkWeightTons(dto);
|
||||
const cargoTypeId = this.resolveCargoTypeId(contract, dto);
|
||||
probe.cargoTypeId = cargoTypeId;
|
||||
if (cargoTypeId) {
|
||||
@@ -1297,11 +1300,7 @@ export class ContractBookingService {
|
||||
return;
|
||||
}
|
||||
|
||||
const requested =
|
||||
(dto.bulkLines ?? []).reduce(
|
||||
(sum, b) => sum + (b.cargoWeightTons ?? b.itemCount ?? 0),
|
||||
0,
|
||||
) || this.resolveBulkTons(dto) || 0;
|
||||
const requested = this.resolveBulkTons(dto);
|
||||
const remaining = outstanding.bulk?.outstanding ?? 0;
|
||||
// 0.001 t tolerance absorbs the 3-decimal rounding applied to split weights.
|
||||
if (Math.abs(requested - remaining) > 0.001) {
|
||||
@@ -1344,8 +1343,10 @@ export class ContractBookingService {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// PER_ITEM contracts are capped in items, so the item count is the
|
||||
// consumption figure — tonnage is only wagon-sizing data.
|
||||
const requested =
|
||||
(lines.bulk?.cargoWeightTons ?? lines.bulk?.itemCount ?? 0) || 0;
|
||||
Number(lines.bulk?.itemCount ?? lines.bulk?.cargoWeightTons ?? 0) || 0;
|
||||
const cap = capacity.find((c) => c.cap != null);
|
||||
if (cap && cap.remaining != null && requested > cap.remaining) {
|
||||
throw new BadRequestException(
|
||||
@@ -1373,11 +1374,7 @@ export class ContractBookingService {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const requested =
|
||||
(dto.bulkLines ?? []).reduce(
|
||||
(sum, b) => sum + (b.cargoWeightTons ?? b.itemCount ?? 0),
|
||||
0,
|
||||
) || this.resolveBulkTons(dto) || 0;
|
||||
const requested = this.resolveBulkTons(dto);
|
||||
const cap = capacity.find((c) => c.cap != null);
|
||||
if (cap && cap.remaining != null && requested > cap.remaining) {
|
||||
throw new BadRequestException(
|
||||
@@ -1641,11 +1638,27 @@ export class ContractBookingService {
|
||||
private resolveBulkTons(dto: CreateBookingUnderContractDto): number {
|
||||
if (!dto.bulkLines?.length) return 0;
|
||||
return dto.bulkLines.reduce(
|
||||
(sum, l) => sum + Number(l.cargoWeightTons ?? l.itemCount ?? 0),
|
||||
(sum, l) => sum + Number(l.itemCount ?? l.cargoWeightTons ?? 0),
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Real tonnage of a PER_ITEM (break-bulk) booking, kept alongside the item
|
||||
* count `cargoTotalWeightVgm` holds. Both are needed: the item count prices
|
||||
* the booking, the tonnage sizes the wagons (`bulkItemWagonsRequired` derives
|
||||
* per-item weight from tonnage ÷ items). Null for PER_TON bulk, where
|
||||
* `cargoTotalWeightVgm` already IS the tonnage.
|
||||
*/
|
||||
private resolveBulkWeightTons(
|
||||
dto: CreateBookingUnderContractDto,
|
||||
): number | null {
|
||||
const lines = dto.bulkLines ?? [];
|
||||
if (!lines.some((l) => Number(l.itemCount) > 0)) return null;
|
||||
const tons = lines.reduce((sum, l) => sum + Number(l.cargoWeightTons ?? 0), 0);
|
||||
return tons > 0 ? tons : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-line handling counts. Each physical container carries its own hazardous
|
||||
* / reefer / return switch (entered next to its VGM), so the count is however
|
||||
@@ -1961,6 +1974,7 @@ export class ContractBookingService {
|
||||
originYardId: route?.originYardId ?? null,
|
||||
destinationYardId: route?.destinationYardId ?? null,
|
||||
cargoTotalWeightVgm: this.resolveBulkTons(dto),
|
||||
bulkTotalWeightTons: this.resolveBulkWeightTons(dto),
|
||||
firstMilePickupAddress: contract.firstMilePickupAddress ?? null,
|
||||
lastMileDeliveryAddress: contract.lastMileDeliveryAddress ?? null,
|
||||
bookingContainers: resolved.map(({ line, ct, totalVgmTons }) =>
|
||||
|
||||
@@ -1632,6 +1632,18 @@ export class BookingBatchService implements OnModuleInit {
|
||||
for (const b of candidates) {
|
||||
if (!pinnedIds.has(b.id)) bookings.push(b);
|
||||
}
|
||||
// Expiry frees the schedule pin (expire() nulls train_schedule_id), so
|
||||
// expired bookings match neither query above — merge them back so the
|
||||
// board keeps its expired lane. Display-only: boardState maps them to
|
||||
// EXPIRED, which every capacity meter already excludes.
|
||||
const expiredPool =
|
||||
await this.bookingsRepository.findExpiredByCorridorDay(
|
||||
stops,
|
||||
eatDay(s.scheduledDepartureDate),
|
||||
);
|
||||
for (const b of expiredPool) {
|
||||
if (!pinnedIds.has(b.id)) bookings.push(b);
|
||||
}
|
||||
} catch (err) {
|
||||
// The board must still render the pinned bookings.
|
||||
this.logger.warn(
|
||||
@@ -3131,6 +3143,14 @@ export class BookingBatchService implements OnModuleInit {
|
||||
async intercityCapacity(scheduleId: string): Promise<{
|
||||
budget: CorridorBudget;
|
||||
needFor: (booking: Booking) => Capacity;
|
||||
/**
|
||||
* Per-wagon-type split of `needFor(booking).wagons`, against THIS
|
||||
* schedule's own wagon stock — so the same booking reads differently on a
|
||||
* different train. Empty when the stock can't be resolved.
|
||||
*/
|
||||
breakdownFor: (
|
||||
booking: Booking,
|
||||
) => Array<{ wagonTypeId: string; code: string; wagons: number }>;
|
||||
} | null> {
|
||||
const schedule =
|
||||
await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||||
@@ -3145,7 +3165,24 @@ export class BookingBatchService implements OnModuleInit {
|
||||
// still accepts ride-alongs on its empty legs — that is the whole point
|
||||
// of the ride-along flow.
|
||||
const budget = await this.remainingBudget(schedule, limits, wagonDims);
|
||||
return { budget, needFor: (booking) => this.needFor(booking, wagonDims) };
|
||||
// Physical stock of THIS schedule's train (built consist, or the yard fleet
|
||||
// it will draw from) — what makes the breakdown train-specific.
|
||||
const stock = await this.trainSchedulingService.wagonStockForSchedule(
|
||||
schedule.id,
|
||||
schedule.originStationId,
|
||||
budget.stops,
|
||||
);
|
||||
return {
|
||||
budget,
|
||||
needFor: (booking) => this.needFor(booking, wagonDims),
|
||||
breakdownFor: (booking) =>
|
||||
this.wagonBreakdownFor(
|
||||
booking,
|
||||
wagonDims,
|
||||
stock.remainingByTypeId,
|
||||
stock.codesByTypeId,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -4094,6 +4131,78 @@ export class BookingBatchService implements OnModuleInit {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The wagon count of {@link wagonsFor}, split across the wagon TYPES this
|
||||
* particular train stocks — "3 × N35 + 1 × PW2" rather than a bare 4.
|
||||
*
|
||||
* `wagonsFor` sizes the booking on ONE representative type (the first the
|
||||
* cargo type allows), which is all the abstract budget needs. Staff placing a
|
||||
* ride-along need the physical picture: how many of each type this schedule
|
||||
* must actually give up. So each allowed type is sized on its OWN capacity and
|
||||
* items-fit, then filled greedily from the type with the largest per-wagon
|
||||
* take, bounded by what the schedule has left of it.
|
||||
*
|
||||
* Because the stock is per-schedule, the same booking breaks down differently
|
||||
* on a train stocking 60T N35s than on one stocking 40T PW2s. Returns [] when
|
||||
* the booking's types are unconfigured or the train stocks none of them — the
|
||||
* caller then shows the plain total.
|
||||
*/
|
||||
private wagonBreakdownFor(
|
||||
booking: Booking,
|
||||
wagonDims: WagonDims,
|
||||
stockByTypeId: Map<string, number>,
|
||||
codesByTypeId: Map<string, string>,
|
||||
): Array<{ wagonTypeId: string; code: string; wagons: number }> {
|
||||
const total = this.wagonsFor(booking, wagonDims);
|
||||
if (total <= 0) return [];
|
||||
|
||||
// Per-wagon take of each allowed type ON THIS TRAIN, largest first: a type
|
||||
// that swallows more of the booking per wagon needs fewer wagons.
|
||||
const options = this.allowedDimsWithTypes(booking, wagonDims)
|
||||
.filter((o) => o.wagonTypeId && (stockByTypeId.get(o.wagonTypeId) ?? 0) > 0)
|
||||
.map((o) => {
|
||||
const wagonTypeId = o.wagonTypeId as string;
|
||||
const wagonsIfAlone = Math.max(
|
||||
1,
|
||||
bulkItemWagonsRequired(
|
||||
booking,
|
||||
o.dims.capacityTons,
|
||||
bulkItemsFitFor(booking.cargoType, wagonTypeId),
|
||||
) ||
|
||||
(o.dims.capacityTons > 0
|
||||
? Math.ceil(bookingCargoTons(booking) / o.dims.capacityTons)
|
||||
: total),
|
||||
);
|
||||
return {
|
||||
wagonTypeId,
|
||||
code: codesByTypeId.get(wagonTypeId) ?? '—',
|
||||
available: stockByTypeId.get(wagonTypeId) ?? 0,
|
||||
// Share of the whole booking one wagon of this type carries.
|
||||
takePerWagon: 1 / wagonsIfAlone,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => b.takePerWagon - a.takePerWagon);
|
||||
if (!options.length) return [];
|
||||
|
||||
// Fill greedily by take, capped by stock; `remaining` is the fraction of the
|
||||
// booking still unplaced, so a wagon of any type covers `takePerWagon` of it.
|
||||
const out: Array<{ wagonTypeId: string; code: string; wagons: number }> = [];
|
||||
let remaining = 1;
|
||||
for (const option of options) {
|
||||
if (remaining <= 1e-9) break;
|
||||
const wagons = Math.min(
|
||||
option.available,
|
||||
Math.ceil(remaining / option.takePerWagon),
|
||||
);
|
||||
if (wagons <= 0) continue;
|
||||
out.push({ wagonTypeId: option.wagonTypeId, code: option.code, wagons });
|
||||
remaining -= wagons * option.takePerWagon;
|
||||
}
|
||||
// The train cannot hold the whole booking in the types it stocks — the
|
||||
// `fits` check already fails it; report only what it CAN take.
|
||||
return out;
|
||||
}
|
||||
|
||||
private fits(need: Capacity, budget: Capacity): boolean {
|
||||
return (
|
||||
need.wagons <= budget.wagons &&
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { BookingBatchService } from './booking-batch.service';
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
|
||||
/**
|
||||
* The intercity ride-along board shows WHICH wagon types a booking takes from
|
||||
* the train it is being placed on ("3 × N35 + 1 × PW2"), not just how many
|
||||
* wagons. Because the split is drawn against that schedule's own stock, the
|
||||
* same booking must read differently on a different train.
|
||||
*/
|
||||
describe('BookingBatchService — intercity wagon breakdown', () => {
|
||||
const N35 = 'wagon-type-n35';
|
||||
const PW2 = 'wagon-type-pw2';
|
||||
|
||||
const dims = (capacityTons: number) => ({
|
||||
capacityTons,
|
||||
lengthMeters: 14,
|
||||
tareWeightTons: 20,
|
||||
});
|
||||
|
||||
const wagonDims = {
|
||||
bulk: dims(60),
|
||||
container: dims(60),
|
||||
byWagonTypeId: new Map([
|
||||
[N35, dims(60)],
|
||||
[PW2, dims(20)],
|
||||
]),
|
||||
};
|
||||
|
||||
const codes = new Map([
|
||||
[N35, 'N35'],
|
||||
[PW2, 'PW2'],
|
||||
]);
|
||||
|
||||
/**
|
||||
* 400 break-bulk items weighing 800t — 2t per item. On a 60t N35 that is 30
|
||||
* items per wagon (14 wagons); on a 20t PW2, 10 items (40 wagons).
|
||||
*/
|
||||
const perItemBooking = {
|
||||
id: 'booking-1',
|
||||
freightType: 'BULK',
|
||||
cargoTotalWeightVgm: 400,
|
||||
bulkTotalWeightTons: 800,
|
||||
bookingContainers: [],
|
||||
cargoType: {
|
||||
wagonTypes: [{ id: N35 }, { id: PW2 }],
|
||||
itemsPerWagonMap: {},
|
||||
},
|
||||
} as unknown as Booking;
|
||||
|
||||
const service = Object.create(
|
||||
BookingBatchService.prototype,
|
||||
) as BookingBatchService;
|
||||
|
||||
const breakdown = (
|
||||
booking: Booking,
|
||||
stock: Map<string, number>,
|
||||
): Array<{ code: string; wagons: number }> =>
|
||||
(
|
||||
service as unknown as {
|
||||
wagonBreakdownFor: (
|
||||
b: Booking,
|
||||
d: typeof wagonDims,
|
||||
s: Map<string, number>,
|
||||
c: Map<string, string>,
|
||||
) => Array<{ code: string; wagons: number }>;
|
||||
}
|
||||
)
|
||||
.wagonBreakdownFor(booking, wagonDims, stock, codes)
|
||||
.map(({ code, wagons }) => ({ code, wagons }));
|
||||
|
||||
it('takes the highest-capacity type first when the train stocks plenty', () => {
|
||||
const rows = breakdown(perItemBooking, new Map([[N35, 50], [PW2, 50]]));
|
||||
expect(rows).toEqual([{ code: 'N35', wagons: 14 }]);
|
||||
});
|
||||
|
||||
it('falls back to the smaller type for the remainder when the big one runs short', () => {
|
||||
// Only 10 of the 14 N35s the booking wants — the rest rides PW2s. Ten N35s
|
||||
// carry 10/14 of the booking, leaving 4/14, which needs ceil(40 × 4/14) PW2s.
|
||||
const rows = breakdown(perItemBooking, new Map([[N35, 10], [PW2, 50]]));
|
||||
expect(rows[0]).toEqual({ code: 'N35', wagons: 10 });
|
||||
expect(rows[1].code).toBe('PW2');
|
||||
expect(rows[1].wagons).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('reads differently on a train that stocks only the small type', () => {
|
||||
const rows = breakdown(perItemBooking, new Map([[PW2, 60]]));
|
||||
expect(rows).toEqual([{ code: 'PW2', wagons: 40 }]);
|
||||
});
|
||||
|
||||
it('honours the configured items-per-wagon fit over raw tonnage', () => {
|
||||
// Floor space binds before weight: an N35 physically holds 20 of these
|
||||
// items even though 30 would fit by weight → 20 wagons, not 14.
|
||||
const floorBound = {
|
||||
...perItemBooking,
|
||||
cargoType: {
|
||||
wagonTypes: [{ id: N35 }],
|
||||
itemsPerWagonMap: { [N35]: 20 },
|
||||
},
|
||||
} as unknown as Booking;
|
||||
expect(breakdown(floorBound, new Map([[N35, 50]]))).toEqual([
|
||||
{ code: 'N35', wagons: 20 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns nothing when the train stocks none of the allowed types', () => {
|
||||
expect(breakdown(perItemBooking, new Map([['other-type', 30]]))).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -155,12 +155,17 @@ export class IntercityService {
|
||||
return {
|
||||
...this.mapBooking(booking, need),
|
||||
need,
|
||||
wagonBreakdown: capacity?.breakdownFor(booking) ?? [],
|
||||
fits: Boolean(need && capacity && leg && capacity.budget.fits(need, leg)),
|
||||
};
|
||||
}),
|
||||
accepted: accepted.map((booking) => {
|
||||
const need = capacity?.needFor(booking) ?? null;
|
||||
return { ...this.mapBooking(booking, need), need };
|
||||
return {
|
||||
...this.mapBooking(booking, need),
|
||||
need,
|
||||
wagonBreakdown: capacity?.breakdownFor(booking) ?? [],
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -200,7 +205,9 @@ export class IntercityService {
|
||||
where: { id: bookingId },
|
||||
relations: {
|
||||
bookingContainers: { containerType: true },
|
||||
cargoType: true,
|
||||
// wagonTypes drives the break-bulk items-per-wagon fit — the accept
|
||||
// check must size the booking exactly as the candidate list did.
|
||||
cargoType: { wagonTypes: true },
|
||||
},
|
||||
});
|
||||
if (!booking) {
|
||||
@@ -326,6 +333,10 @@ export class IntercityService {
|
||||
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
|
||||
.leftJoinAndSelect('bookingContainer.containerType', 'containerType')
|
||||
.leftJoinAndSelect('booking.cargoType', 'cargoType')
|
||||
// The allowed wagon-type list is what sizes a break-bulk (PER_ITEM)
|
||||
// booking: without it `bulkItemsFitFor` reads no items-per-wagon fit and
|
||||
// the wagon count silently degrades to tonnage-only.
|
||||
.leftJoinAndSelect('cargoType.wagonTypes', 'cargoWagonType')
|
||||
.leftJoinAndSelect('booking.originYard', 'originYard')
|
||||
.leftJoinAndSelect('booking.destinationYard', 'destinationYard')
|
||||
.where(`booking.trade_direction = 'DOMESTIC'`)
|
||||
@@ -355,6 +366,10 @@ export class IntercityService {
|
||||
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
|
||||
.leftJoinAndSelect('bookingContainer.containerType', 'containerType')
|
||||
.leftJoinAndSelect('booking.cargoType', 'cargoType')
|
||||
// The allowed wagon-type list is what sizes a break-bulk (PER_ITEM)
|
||||
// booking: without it `bulkItemsFitFor` reads no items-per-wagon fit and
|
||||
// the wagon count silently degrades to tonnage-only.
|
||||
.leftJoinAndSelect('cargoType.wagonTypes', 'cargoWagonType')
|
||||
.leftJoinAndSelect('booking.originYard', 'originYard')
|
||||
.leftJoinAndSelect('booking.destinationYard', 'destinationYard')
|
||||
.where(`booking.trade_direction = 'DOMESTIC'`)
|
||||
|
||||
@@ -1435,4 +1435,44 @@ describe('TrainSchedulingService', () => {
|
||||
).rejects.toThrow(/free only 5/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('effectiveWagonsRequired', () => {
|
||||
const effective = (booking: unknown): number =>
|
||||
(service as never as { effectiveWagonsRequired(b: unknown): number })
|
||||
.effectiveWagonsRequired(booking);
|
||||
|
||||
// 20-item / 100T break-bulk on 70T wagons with a 4-items-per-wagon fit:
|
||||
// ceil(20/4) = 5 wagons.
|
||||
const perItemBooking = (wagonsRequired: number | null) => ({
|
||||
freightType: 'BULK',
|
||||
cargoTotalWeightVgm: 20,
|
||||
bulkTotalWeightTons: 100,
|
||||
wagonsRequired,
|
||||
cargoType: {
|
||||
wagonTypes: [{ id: 'wt-nw5', capacityTons: 70 }],
|
||||
itemsPerWagonMap: { 'wt-nw5': 4 },
|
||||
},
|
||||
});
|
||||
|
||||
it('overrides a stale too-small stamp with the item-aware recompute', () => {
|
||||
// Stamped 1 by old code that read the PER_ITEM count (20) as tons.
|
||||
expect(effective(perItemBooking(1))).toBe(5);
|
||||
});
|
||||
|
||||
it('keeps a stored stamp that is at least the recompute', () => {
|
||||
expect(effective(perItemBooking(7))).toBe(7);
|
||||
});
|
||||
|
||||
it('trusts the stamp when BULK cargo relations are not loaded', () => {
|
||||
expect(
|
||||
effective({
|
||||
freightType: 'BULK',
|
||||
cargoTotalWeightVgm: 20,
|
||||
bulkTotalWeightTons: 100,
|
||||
wagonsRequired: 5,
|
||||
cargoType: null,
|
||||
}),
|
||||
).toBe(5);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -136,6 +136,8 @@ import { deriveScheduleDirection } from './derive-schedule-direction.util';
|
||||
import { pickLowestFreeNumber, pickTrainNumberPool } from './train-number.util';
|
||||
import {
|
||||
bookingCargoTons,
|
||||
bulkItemsFitFor,
|
||||
bulkItemWagonsRequired,
|
||||
deriveTrainCapacityFromLocomotive,
|
||||
combinedLocomotiveLimits,
|
||||
trainSetLocomotiveLimits,
|
||||
@@ -7335,7 +7337,14 @@ export class TrainSchedulingService {
|
||||
const byLength = containerWagonsForLines(booking.bookingContainers ?? []);
|
||||
const byWeight =
|
||||
cargo > 0 && dims.capacityTons > 0 ? Math.ceil(cargo / dims.capacityTons) : 0;
|
||||
const wagons = Math.max(1, stored, byLength, byWeight);
|
||||
// Break-bulk (PER_ITEM): indivisible items occupy more wagons than raw
|
||||
// tonnage suggests — their tare must be pulled too (batch dimsFor parity).
|
||||
const byItems = bulkItemWagonsRequired(
|
||||
booking,
|
||||
dims.capacityTons,
|
||||
bulkItemsFitFor(booking.cargoType, wagonTypeId),
|
||||
);
|
||||
const wagons = Math.max(1, stored, byLength, byWeight, byItems);
|
||||
return roundTons(cargo + wagons * dims.tareWeightTons);
|
||||
}
|
||||
|
||||
@@ -7819,7 +7828,7 @@ export class TrainSchedulingService {
|
||||
*/
|
||||
private effectiveWagonsRequired(booking: Booking): number {
|
||||
const stored = Number(booking.wagonsRequired);
|
||||
if (stored > 0) return Math.ceil(stored);
|
||||
const storedCeil = stored > 0 ? Math.ceil(stored) : 0;
|
||||
const bulkCapacities = (booking.cargoType?.wagonTypes ?? [])
|
||||
.map((wt) => Number(wt.capacityTons))
|
||||
.filter((c) => c > 0);
|
||||
@@ -7827,7 +7836,15 @@ export class TrainSchedulingService {
|
||||
booking.freightType === 'BULK' && bulkCapacities.length
|
||||
? Math.max(...bulkCapacities)
|
||||
: undefined;
|
||||
return wagonsRequiredForBooking(booking, bulkCapacity);
|
||||
// BULK with no cargo relations loaded: recomputing would size against a
|
||||
// 1T capacity and read a PER_ITEM item count as tons — trust the stamp.
|
||||
if (booking.freightType === 'BULK' && bulkCapacity === undefined && storedCeil > 0) {
|
||||
return storedCeil;
|
||||
}
|
||||
// Stored is a candidate, never an early return (batch parity): rows
|
||||
// stamped while BULK sizing read the PER_ITEM item count as tons carry a
|
||||
// too-small footprint — a 20-item/100T booking was stamped 1 wagon.
|
||||
return Math.max(storedCeil, wagonsRequiredForBooking(booking, bulkCapacity));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -315,3 +315,131 @@ describe('planWagonsWithStock — leg-aware stock (intercity ride-along)', () =>
|
||||
expect(result.plan).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('planWagonsWithStock — break-bulk (PER_ITEM) item-aware packing', () => {
|
||||
const pw2: WagonType = {
|
||||
id: 'wt-pw2',
|
||||
code: 'PW2',
|
||||
capacityTons: 70,
|
||||
lengthMeters: 17,
|
||||
supportedLoadTypes: ['BULK'],
|
||||
isActive: true,
|
||||
supportsContainer: false,
|
||||
} as WagonType;
|
||||
const nw5: WagonType = {
|
||||
id: 'wt-nw5',
|
||||
code: 'NW5',
|
||||
capacityTons: 70,
|
||||
lengthMeters: 14,
|
||||
supportedLoadTypes: ['BULK'],
|
||||
isActive: true,
|
||||
supportsContainer: false,
|
||||
} as WagonType;
|
||||
|
||||
// 20 machinery items, 100T total (5T each). NW5 fits 4/wagon, PW2 fits 3.
|
||||
const machineryBooking = (): Booking =>
|
||||
({
|
||||
id: 'BULK-ITEMS',
|
||||
reference: 'BULK-ITEMS',
|
||||
freightType: 'BULK',
|
||||
cargoTypeId: 'ct-machinery',
|
||||
cargoTotalWeightVgm: 20,
|
||||
bulkTotalWeightTons: 100,
|
||||
cargoType: {
|
||||
id: 'ct-machinery',
|
||||
cargoTypeName: 'Machinery',
|
||||
itemsPerWagonMap: { 'wt-nw5': 4, 'wt-pw2': 3 },
|
||||
wagonTypes: [pw2, nw5],
|
||||
},
|
||||
}) as unknown as Booking;
|
||||
|
||||
const allowed = {
|
||||
byContainerTypeId: new Map<string, WagonType[]>(),
|
||||
byCargoTypeId: new Map([['ct-machinery', [pw2, nw5]]]),
|
||||
};
|
||||
|
||||
it('packs whole items per wagon by the items-fit map, not raw tonnage', () => {
|
||||
const result = planWagonsWithStock({
|
||||
bookings: [machineryBooking()],
|
||||
allowed,
|
||||
stock: {
|
||||
mode: 'YARD',
|
||||
remainingByTypeId: new Map([
|
||||
[pw2.id, 50],
|
||||
[nw5.id, 50],
|
||||
]),
|
||||
codesByTypeId: new Map([
|
||||
[pw2.id, pw2.code],
|
||||
[nw5.id, nw5.code],
|
||||
]),
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.deferred).toHaveLength(0);
|
||||
// Best fit: NW5 at 4 items/wagon → ceil(20/4) = 5 wagons, 20T each.
|
||||
expect(result.plan).toHaveLength(5);
|
||||
expect(result.plan.every((s) => s.wagonTypeCode === 'NW5')).toBe(true);
|
||||
expect(result.plan.map((s) => s.assignedWeightTons)).toEqual([20, 20, 20, 20, 20]);
|
||||
});
|
||||
|
||||
it('weight cap binds before items-fit when items are heavy', () => {
|
||||
// 14 items of 10T on 70T wagons with a 100-item floor fit → 7 items/wagon.
|
||||
const heavy = {
|
||||
...machineryBooking(),
|
||||
cargoTotalWeightVgm: 14,
|
||||
bulkTotalWeightTons: 140,
|
||||
cargoType: {
|
||||
id: 'ct-machinery',
|
||||
cargoTypeName: 'Machinery',
|
||||
itemsPerWagonMap: { 'wt-nw5': 100, 'wt-pw2': 100 },
|
||||
wagonTypes: [pw2, nw5],
|
||||
},
|
||||
} as unknown as Booking;
|
||||
const result = planWagonsWithStock({
|
||||
bookings: [heavy],
|
||||
allowed,
|
||||
stock: {
|
||||
mode: 'YARD',
|
||||
remainingByTypeId: new Map([
|
||||
[pw2.id, 50],
|
||||
[nw5.id, 50],
|
||||
]),
|
||||
codesByTypeId: new Map([
|
||||
[pw2.id, pw2.code],
|
||||
[nw5.id, nw5.code],
|
||||
]),
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.deferred).toHaveLength(0);
|
||||
expect(result.plan).toHaveLength(2);
|
||||
expect(result.plan.map((s) => s.assignedWeightTons)).toEqual([70, 70]);
|
||||
});
|
||||
|
||||
it('PER_TON bulk (no bulkTotalWeightTons) still packs by weight', () => {
|
||||
const loose = {
|
||||
...machineryBooking(),
|
||||
cargoTotalWeightVgm: 100,
|
||||
bulkTotalWeightTons: null,
|
||||
} as unknown as Booking;
|
||||
const result = planWagonsWithStock({
|
||||
bookings: [loose],
|
||||
allowed,
|
||||
stock: {
|
||||
mode: 'YARD',
|
||||
remainingByTypeId: new Map([
|
||||
[pw2.id, 50],
|
||||
[nw5.id, 50],
|
||||
]),
|
||||
codesByTypeId: new Map([
|
||||
[pw2.id, pw2.code],
|
||||
[nw5.id, nw5.code],
|
||||
]),
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.deferred).toHaveLength(0);
|
||||
expect(result.plan).toHaveLength(2);
|
||||
expect(result.plan.map((s) => s.assignedWeightTons)).toEqual([70, 30]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,11 @@ import { AllocationLoadType } from '@edr/types';
|
||||
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
||||
import {
|
||||
bookingCargoTons,
|
||||
bulkItemsFitFor,
|
||||
bulkItemWagonsForAllowedTypes,
|
||||
} from './train-capacity.util';
|
||||
import {
|
||||
sortBookingsForScheduling,
|
||||
type BookingWagonShortage,
|
||||
@@ -64,6 +69,12 @@ type OpenSlot = {
|
||||
/** Kind purity: a bulk wagon carries ONE cargo type at a time. */
|
||||
cargoTypeId: string | null;
|
||||
freeCapacityTons: number;
|
||||
/**
|
||||
* Whole-item slots left on this wagon (break-bulk PER_ITEM cargo only —
|
||||
* bounded by the cargo type's items-per-wagon fit and by tonnage). Undefined
|
||||
* for weight-only (PER_TON) bulk and container wagons.
|
||||
*/
|
||||
freeItems?: number;
|
||||
/**
|
||||
* Leg of the FIRST booking placed (`"from-to"` stop indexes). Containers
|
||||
* prefer a same-leg slot but may extend onto a different-leg one (span
|
||||
@@ -110,10 +121,19 @@ const shortageFor = (
|
||||
booking.freightType === 'BULK'
|
||||
? Math.max(
|
||||
1,
|
||||
Math.ceil(
|
||||
Number(booking.cargoTotalWeightVgm ?? 0) /
|
||||
Math.max(1, ...candidates.map((wt) => Number(wt.capacityTons))),
|
||||
),
|
||||
// Break-bulk (PER_ITEM) sizes by indivisible items (items-fit map
|
||||
// respected); PER_TON falls through to tonnage over the largest
|
||||
// candidate. bookingCargoTons, not raw VGM — for PER_ITEM that
|
||||
// column is the item count, not tons.
|
||||
bulkItemWagonsForAllowedTypes(
|
||||
booking,
|
||||
booking.cargoType,
|
||||
Math.max(1, ...candidates.map((wt) => Number(wt.capacityTons))),
|
||||
) ||
|
||||
Math.ceil(
|
||||
bookingCargoTons(booking) /
|
||||
Math.max(1, ...candidates.map((wt) => Number(wt.capacityTons))),
|
||||
),
|
||||
)
|
||||
: Math.max(1, containerWagonsForLines(booking.bookingContainers ?? []));
|
||||
const wagonsAvailable = candidates.reduce(
|
||||
@@ -347,18 +367,64 @@ export function planWagonsWithStock(params: {
|
||||
};
|
||||
}
|
||||
const allowedIds = new Set(candidates.map((wt) => wt.id));
|
||||
let remainingWeight = roundTons(Number(booking.cargoTotalWeightVgm ?? 0));
|
||||
// Break-bulk (PER_ITEM): `cargoTotalWeightVgm` is the ITEM COUNT and the
|
||||
// real tonnage lives in `bulkTotalWeightTons` — bookingCargoTons resolves
|
||||
// it either way. Items are indivisible, so a wagon takes whole items only,
|
||||
// bounded by tonnage AND by the cargo type's items-per-wagon fit.
|
||||
const quantity = Number(booking.cargoTotalWeightVgm ?? 0);
|
||||
const perItem =
|
||||
Number(booking.bulkTotalWeightTons ?? 0) > 0 && quantity > 0;
|
||||
let remainingWeight = roundTons(bookingCargoTons(booking));
|
||||
const perItemTons = perItem ? remainingWeight / quantity : 0;
|
||||
let remainingItems = perItem ? quantity : 0;
|
||||
|
||||
/** Whole items one wagon of this slot's type can still take. */
|
||||
const itemRoomOf = (open: OpenSlot): number =>
|
||||
Math.min(
|
||||
open.freeItems ?? Number.MAX_SAFE_INTEGER,
|
||||
perItemTons > 0 ? Math.floor(open.freeCapacityTons / perItemTons) : 0,
|
||||
);
|
||||
/** Fresh wagon's whole-item budget: items-fit map floor'd by tonnage. */
|
||||
const itemBudgetOf = (open: OpenSlot): number => {
|
||||
const fit = bulkItemsFitFor(booking.cargoType, open.slot.wagonTypeId);
|
||||
const byTonnage =
|
||||
perItemTons > 0
|
||||
? Math.max(1, Math.floor(Number(open.slot.capacityTons) / perItemTons))
|
||||
: 1;
|
||||
return Math.min(fit ?? Number.MAX_SAFE_INTEGER, byTonnage);
|
||||
};
|
||||
let placedAnywhere = false;
|
||||
|
||||
// Per-item: prefer the type carrying the most whole items per wagon.
|
||||
// openSlot's own capacity sort is stable, so this order breaks its ties.
|
||||
const itemBudgetOfType = (wt: WagonType): number =>
|
||||
Math.min(
|
||||
bulkItemsFitFor(booking.cargoType, wt.id) ?? Number.MAX_SAFE_INTEGER,
|
||||
perItemTons > 0
|
||||
? Math.max(1, Math.floor(Number(wt.capacityTons) / perItemTons))
|
||||
: 1,
|
||||
);
|
||||
const orderedCandidates = perItem
|
||||
? [...candidates].sort((a, b) => itemBudgetOfType(b) - itemBudgetOfType(a))
|
||||
: candidates;
|
||||
|
||||
// Top off wagons already carrying THIS cargo type before opening new ones.
|
||||
// ponytail: per-item cargo only shares wagons that were opened per-item
|
||||
// (freeItems tracked); mixing itemized and loose loads of one cargo type
|
||||
// on one wagon is not modeled — open a new wagon instead.
|
||||
for (const open of openSlots) {
|
||||
if (remainingWeight <= 0) break;
|
||||
if (perItem ? remainingItems <= 0 : remainingWeight <= 0) break;
|
||||
if (open.kind !== 'BULK') continue;
|
||||
if (open.legKey !== legKey) continue;
|
||||
if (open.cargoTypeId !== cargoTypeId) continue;
|
||||
if (!allowedIds.has(open.slot.wagonTypeId)) continue;
|
||||
if (open.freeCapacityTons <= 0) continue;
|
||||
const take = roundTons(Math.min(open.freeCapacityTons, remainingWeight));
|
||||
if (perItem !== (open.freeItems !== undefined)) continue;
|
||||
const takeItems = perItem ? Math.min(itemRoomOf(open), remainingItems) : 0;
|
||||
if (perItem && takeItems <= 0) continue;
|
||||
const take = perItem
|
||||
? roundTons(takeItems * perItemTons)
|
||||
: roundTons(Math.min(open.freeCapacityTons, remainingWeight));
|
||||
addAllocation(
|
||||
open.slot,
|
||||
booking.id,
|
||||
@@ -367,14 +433,39 @@ export function planWagonsWithStock(params: {
|
||||
AllocationLoadType.Bulk,
|
||||
);
|
||||
open.freeCapacityTons = roundTons(open.freeCapacityTons - take);
|
||||
if (perItem) {
|
||||
open.freeItems = (open.freeItems ?? 0) - takeItems;
|
||||
remainingItems -= takeItems;
|
||||
}
|
||||
remainingWeight = roundTons(remainingWeight - take);
|
||||
placedAnywhere = true;
|
||||
}
|
||||
|
||||
while (remainingWeight > 0 || !placedAnywhere) {
|
||||
const openedSlot = openSlot(candidates, 'BULK', cargoTypeId, leg);
|
||||
while ((perItem ? remainingItems > 0 : remainingWeight > 0) || !placedAnywhere) {
|
||||
// Per-item: openSlot's stock-depth tie-break would override the fit
|
||||
// preference, so hand it exactly the best in-stock type (full candidate
|
||||
// list only when none has stock, for the proper shortfall message).
|
||||
const inStockBest = perItem
|
||||
? orderedCandidates.find((wt) => availableFor(wt.id, leg) > 0)
|
||||
: undefined;
|
||||
const openedSlot = openSlot(
|
||||
inStockBest ? [inStockBest] : orderedCandidates,
|
||||
'BULK',
|
||||
cargoTypeId,
|
||||
leg,
|
||||
);
|
||||
if ('message' in openedSlot) return openedSlot;
|
||||
const take = roundTons(Math.min(openedSlot.freeCapacityTons, remainingWeight));
|
||||
let take: number;
|
||||
if (perItem) {
|
||||
// An item heavier than a whole wagon still charges 1 wagon per item
|
||||
// (creation-time validation owns rejecting that case).
|
||||
const takeItems = Math.max(1, Math.min(itemBudgetOf(openedSlot), remainingItems));
|
||||
take = roundTons(Math.min(takeItems * perItemTons, remainingWeight));
|
||||
openedSlot.freeItems = itemBudgetOf(openedSlot) - takeItems;
|
||||
remainingItems -= takeItems;
|
||||
} else {
|
||||
take = roundTons(Math.min(openedSlot.freeCapacityTons, remainingWeight));
|
||||
}
|
||||
addAllocation(
|
||||
openedSlot.slot,
|
||||
booking.id,
|
||||
@@ -399,6 +490,7 @@ export function planWagonsWithStock(params: {
|
||||
teuPerEdge: [...open.teuPerEdge],
|
||||
covered: { ...open.covered },
|
||||
freeCapacityTons: open.freeCapacityTons,
|
||||
freeItems: open.freeItems,
|
||||
assignedWeightTons: open.slot.assignedWeightTons,
|
||||
allocationCount: open.slot.allocations.length,
|
||||
allocationWeights: open.slot.allocations.map((a) => a.allocatedWeightTons),
|
||||
@@ -420,6 +512,7 @@ export function planWagonsWithStock(params: {
|
||||
open.teuPerEdge = [...snap.teuPerEdge];
|
||||
open.covered = { ...snap.covered };
|
||||
open.freeCapacityTons = snap.freeCapacityTons;
|
||||
open.freeItems = snap.freeItems;
|
||||
open.slot.assignedWeightTons = snap.assignedWeightTons;
|
||||
open.slot.allocations.length = snap.allocationCount;
|
||||
snap.allocationWeights.forEach((weight, allocationIndex) => {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Package } from "lucide-react";
|
||||
import { SimpleGrid, Divider, Box, Table, Text } from "@mantine/core";
|
||||
|
||||
import type { BookingDetail } from "@/types/booking";
|
||||
import { cargoTonsAndItems } from "@/utils/cargoWeight";
|
||||
|
||||
import { SectionCard } from "./SectionCard";
|
||||
import { MetricTile } from "./MetricTile";
|
||||
@@ -13,6 +14,7 @@ export interface BookingCargoCardProps {
|
||||
/** Cargo specs + container manifest table. */
|
||||
export function BookingCargoCard({ booking }: BookingCargoCardProps) {
|
||||
const containers = booking.bookingContainers ?? [];
|
||||
const { tons, items } = cargoTonsAndItems(booking);
|
||||
|
||||
return (
|
||||
<SectionCard icon={Package} title="Cargo specifications" accent="orange">
|
||||
@@ -21,7 +23,8 @@ export function BookingCargoCard({ booking }: BookingCargoCardProps) {
|
||||
label="Cargo type"
|
||||
value={booking.cargoType?.label ?? booking.freightType}
|
||||
/>
|
||||
<MetricTile label="Total VGM" value={`${booking.cargoTotalWeightVgm} tons`} />
|
||||
<MetricTile label="Total VGM" value={`${tons} tons`} />
|
||||
{items != null && <MetricTile label="Items" value={`${items}`} />}
|
||||
<MetricTile
|
||||
label="Hazardous"
|
||||
value={booking.isHazardous ? "Yes" : "No"}
|
||||
|
||||
@@ -3,6 +3,8 @@ import type { ReactNode } from "react";
|
||||
import { Hash, Package, Ship, Weight, Clock } from "lucide-react";
|
||||
import { Group, Stack, Text, Divider } from "@mantine/core";
|
||||
|
||||
import { cargoTonsAndItems } from "@/utils/cargoWeight";
|
||||
|
||||
import { SectionCard } from "./SectionCard";
|
||||
import { formatDate, type BookingDetailView } from "./booking-detail.styles";
|
||||
|
||||
@@ -38,7 +40,14 @@ export function BookingFactsCard({ booking }: BookingFactsCardProps) {
|
||||
{ icon: Hash, label: "PNR Code", value: booking.pnrCode || "—" },
|
||||
{ icon: Package, label: "Cargo Type", value: booking.cargoType?.label ?? "—" },
|
||||
{ icon: Ship, label: "Shipping Line", value: booking.shippingLine?.label ?? "—" },
|
||||
{ icon: Weight, label: "VGM Weight", value: `${booking.cargoTotalWeightVgm} tons` },
|
||||
{
|
||||
icon: Weight,
|
||||
label: "VGM Weight",
|
||||
value: (() => {
|
||||
const { tons, items } = cargoTonsAndItems(booking);
|
||||
return items != null ? `${tons} tons (${items} items)` : `${tons} tons`;
|
||||
})(),
|
||||
},
|
||||
{ icon: Clock, label: "Last Updated", value: formatDate(booking.updatedAt) },
|
||||
];
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
|
||||
import type { BookingDetail } from "@/types/booking";
|
||||
import { cargoTonsAndItems } from "@/utils/cargoWeight";
|
||||
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
|
||||
import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge";
|
||||
import { ContractReferenceLink } from "@/components/bookings/ContractReferenceLink";
|
||||
@@ -52,7 +53,7 @@ export function BookingRequestHero({
|
||||
(sum, c) => sum + Number(c.quantity ?? 0),
|
||||
0,
|
||||
);
|
||||
const weight = Number(booking.cargoTotalWeightVgm ?? 0);
|
||||
const { tons: weight, items: itemCount } = cargoTonsAndItems(booking);
|
||||
|
||||
return (
|
||||
<Paper
|
||||
@@ -162,7 +163,7 @@ export function BookingRequestHero({
|
||||
icon={Weight}
|
||||
label="Cargo weight"
|
||||
value={`${weight} T`}
|
||||
hint="VGM total"
|
||||
hint={itemCount != null ? `${itemCount} items` : "VGM total"}
|
||||
accent="blue"
|
||||
/>
|
||||
<HeroTile
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
@@ -665,6 +666,17 @@ export default function GlCreateBookingForm() {
|
||||
),
|
||||
);
|
||||
|
||||
// Drop one container row and shrink quantity to match — the inverse of
|
||||
// syncUnits growing the array when quantity goes up.
|
||||
const removeUnit = (lineIdx: number, unitIdx: number) =>
|
||||
setContainerLines((prev) =>
|
||||
prev.map((l, i) => {
|
||||
if (i !== lineIdx) return l;
|
||||
const units = l.units.filter((_, j) => j !== unitIdx);
|
||||
return withDerivedCounts({ ...l, quantity: String(units.length), units });
|
||||
}),
|
||||
);
|
||||
|
||||
// Same client-side validation as the customer portal shipment form
|
||||
// (new-shipment-form/schema.ts): ISO container numbers unique within the
|
||||
// shipment, positive VGM per unit, hazardous/reefer counts bounded by the
|
||||
@@ -1362,6 +1374,8 @@ export default function GlCreateBookingForm() {
|
||||
}
|
||||
onChange={(e) => {
|
||||
patchLine(lineIdx, { quantity: e.currentTarget.value });
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
syncUnits(lineIdx, Number(e.currentTarget.value || 0));
|
||||
}}
|
||||
radius={10}
|
||||
@@ -1495,6 +1509,14 @@ export default function GlCreateBookingForm() {
|
||||
/>
|
||||
</Box>
|
||||
))}
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
aria-label={`Remove container ${unitIdx + 1}`}
|
||||
onClick={() => removeUnit(lineIdx, unitIdx)}
|
||||
>
|
||||
<X size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
@@ -182,18 +182,18 @@ const FreightDashboardHeader = ({
|
||||
)}
|
||||
</Box>
|
||||
<Divider />
|
||||
<Menu.Item
|
||||
leftSection={<FileSignature size={15} />}
|
||||
onClick={() => navigate("/dashboard/profile#signature")}
|
||||
>
|
||||
Signature & Stamp
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<User size={15} />}
|
||||
onClick={() => navigate("/dashboard/profile")}
|
||||
>
|
||||
Profile
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<FileSignature size={15} />}
|
||||
onClick={() => navigate("/dashboard/profile#signature")}
|
||||
>
|
||||
My signature
|
||||
</Menu.Item>
|
||||
<Divider />
|
||||
<Menu.Item
|
||||
leftSection={<LogOut size={15} />}
|
||||
|
||||
@@ -79,7 +79,7 @@ export function MySignatureCard() {
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FileSignature className="size-4" />
|
||||
My signature
|
||||
Signature & Stamp
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
This signature can be reused to sign booking contracts.
|
||||
|
||||
@@ -38,6 +38,7 @@ import { formatRouteLabel } from "@/services/routes.service";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { trainSchedulingService } from "@/services/trainScheduling.service";
|
||||
import type { BookingDetail } from "@/types/booking";
|
||||
import { cargoTonsAndItems } from "@/utils/cargoWeight";
|
||||
import type {
|
||||
ContainerPlacement,
|
||||
FreightType,
|
||||
@@ -416,7 +417,7 @@ export function AllocateBookingWizard({
|
||||
const amount = Number(booking.totalAmount);
|
||||
const containers = booking.bookingContainers ?? [];
|
||||
const containerCount = containers.reduce((sum, c) => sum + Number(c.quantity ?? 0), 0);
|
||||
const weight = Number(booking.cargoTotalWeightVgm ?? 0);
|
||||
const { tons: weight, items: itemCount } = cargoTonsAndItems(booking);
|
||||
const holdCountdown = formatCountdown(booking.holdExpiresAt);
|
||||
|
||||
const containerComplete =
|
||||
@@ -945,7 +946,13 @@ export function AllocateBookingWizard({
|
||||
})}`}
|
||||
hint={booking.paymentStatus}
|
||||
/>
|
||||
<StatTile onDark icon={Weight} label="Cargo weight" value={`${weight} T`} hint="VGM total" />
|
||||
<StatTile
|
||||
onDark
|
||||
icon={Weight}
|
||||
label="Cargo weight"
|
||||
value={`${weight} T`}
|
||||
hint={itemCount != null ? `${itemCount} items` : "VGM total"}
|
||||
/>
|
||||
<StatTile
|
||||
onDark
|
||||
icon={ContainerIcon}
|
||||
|
||||
@@ -68,11 +68,27 @@ function CapacityBadges({ capacity }: { capacity: IntercityCapacity | null }) {
|
||||
);
|
||||
}
|
||||
|
||||
function NeedCells({ need }: { need: IntercityCapacity | null }) {
|
||||
function NeedCells({ row }: { row: IntercityBookingRow }) {
|
||||
const { need, wagonBreakdown } = row;
|
||||
if (!need) return <Table.Td colSpan={3}>—</Table.Td>;
|
||||
return (
|
||||
<>
|
||||
<Table.Td>{fmt(need.wagons)}</Table.Td>
|
||||
<Table.Td>
|
||||
{wagonBreakdown?.length ? (
|
||||
// Which wagon TYPES this train gives up, not just how many wagons —
|
||||
// a break-bulk booking's count depends on each type's capacity and
|
||||
// its configured items-per-wagon fit, so it differs per train.
|
||||
<Stack gap={2}>
|
||||
{wagonBreakdown.map((entry) => (
|
||||
<Text key={entry.wagonTypeId} size="sm">
|
||||
{entry.wagons} × {entry.code}
|
||||
</Text>
|
||||
))}
|
||||
</Stack>
|
||||
) : (
|
||||
fmt(need.wagons)
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>{fmt(need.weightTons)} t</Table.Td>
|
||||
<Table.Td>{fmt(need.lengthMeters)} m</Table.Td>
|
||||
</>
|
||||
@@ -285,7 +301,7 @@ export function IntercityRideAlongPanel({
|
||||
<Table.Td>
|
||||
<CorridorCell row={row} />
|
||||
</Table.Td>
|
||||
<NeedCells need={row.need} />
|
||||
<NeedCells row={row} />
|
||||
<Table.Td>
|
||||
{row.fits ? (
|
||||
<Badge size="sm" variant="light" color="teal">
|
||||
|
||||
@@ -12,6 +12,7 @@ import toast from "react-hot-toast";
|
||||
|
||||
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
||||
import { ContractSignaturePad } from "@/components/bookings/ContractSignaturePad";
|
||||
import { StampUpload } from "@/components/contracts/StampUpload";
|
||||
import { bookingSurface } from "@/components/bookings/booking-ui.styles";
|
||||
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
||||
import { invalidateBookingDetail } from "@/utils/queryInvalidation";
|
||||
@@ -40,6 +41,9 @@ export default function BookingContractPage() {
|
||||
const [signOpen, setSignOpen] = useState(false);
|
||||
const [signerName, setSignerName] = useState("");
|
||||
const [signatureData, setSignatureData] = useState<string | null>(null);
|
||||
// Company stamp: prefilled from the profile, or uploaded here when none is
|
||||
// saved yet.
|
||||
const [stampData, setStampData] = useState<string | null>(null);
|
||||
// When the user has a saved signature we offer it for approval first; they
|
||||
// can switch to drawing a fresh one.
|
||||
const [drawNew, setDrawNew] = useState(false);
|
||||
@@ -55,6 +59,7 @@ export default function BookingContractPage() {
|
||||
|
||||
const savedSignature = data?.savedSignature ?? null;
|
||||
const savedSignatureImage = savedSignature?.signatureImageUrl ?? null;
|
||||
const savedStampImage = savedSignature?.stampImageUrl ?? null;
|
||||
// Show the approval view only while a saved signature exists and the user
|
||||
// hasn't opted to draw a new one.
|
||||
const usingSaved = Boolean(savedSignatureImage) && !drawNew;
|
||||
@@ -98,6 +103,8 @@ export default function BookingContractPage() {
|
||||
// approve it; otherwise start with an empty pad.
|
||||
setSignerName(savedSignature?.signerDisplayName ?? "");
|
||||
setSignatureData(null);
|
||||
// Prefill with the reusable stamp saved on the profile; still replaceable.
|
||||
setStampData(savedStampImage);
|
||||
setDrawNew(false);
|
||||
setSignOpen(true);
|
||||
};
|
||||
@@ -106,10 +113,12 @@ export default function BookingContractPage() {
|
||||
if (!canSign || !signerName.trim()) return;
|
||||
// Approve the saved signature, or submit the freshly drawn one.
|
||||
const image = usingSaved ? savedSignatureImage : signatureData;
|
||||
if (!image) return;
|
||||
// The API rejects a STAFF signature without a stamp.
|
||||
if (!image || !stampData) return;
|
||||
signMutation.mutate({
|
||||
role: "STAFF",
|
||||
signatureImageBase64: image,
|
||||
stampImageBase64: stampData,
|
||||
signerDisplayName: signerName.trim(),
|
||||
consentText: "I agree to the terms of this contract.",
|
||||
});
|
||||
@@ -234,6 +243,15 @@ export default function BookingContractPage() {
|
||||
) : (
|
||||
<ContractSignaturePad onChange={setSignatureData} />
|
||||
)}
|
||||
<StampUpload
|
||||
value={stampData}
|
||||
onChange={setStampData}
|
||||
description={
|
||||
savedStampImage
|
||||
? "Your saved company stamp — replace it for this contract if needed."
|
||||
: "Required. Attach your official company stamp or seal."
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setSignOpen(false)}>
|
||||
@@ -243,6 +261,7 @@ export default function BookingContractPage() {
|
||||
disabled={
|
||||
signMutation.isPending ||
|
||||
(!usingSaved && !signatureData) ||
|
||||
!stampData ||
|
||||
!signerName.trim()
|
||||
}
|
||||
onClick={confirmSign}
|
||||
|
||||
@@ -92,6 +92,7 @@ export interface ContractView {
|
||||
savedSignature?: {
|
||||
signerDisplayName: string;
|
||||
signatureImageUrl?: string | null;
|
||||
stampImageUrl?: string | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
@@ -113,6 +114,8 @@ export interface ConsolidationDetails {
|
||||
export interface SignContractPayload {
|
||||
role: "CUSTOMER" | "STAFF";
|
||||
signatureImageBase64: string;
|
||||
/** Company stamp/seal image; the API requires one for CUSTOMER and STAFF. */
|
||||
stampImageBase64?: string;
|
||||
signerDisplayName: string;
|
||||
consentText?: string;
|
||||
}
|
||||
|
||||
@@ -171,6 +171,8 @@ export interface BookingDetail {
|
||||
/** What the containers carry / bulk commodity label — entered at booking time. */
|
||||
cargoFreeText?: string | null;
|
||||
cargoTotalWeightVgm: number;
|
||||
/** Break-bulk (PER_ITEM) only: real total tons — cargoTotalWeightVgm then holds the item count. */
|
||||
bulkTotalWeightTons?: number | null;
|
||||
isHazardous: boolean;
|
||||
consolidationPartnerId?: string | null;
|
||||
consolidationPartner?: BookingNamedRef & { reference?: string } | null;
|
||||
|
||||
@@ -959,6 +959,14 @@ export interface IntercityCapacity {
|
||||
lengthMeters: number | null;
|
||||
}
|
||||
|
||||
/** One wagon type this train must give up, and how many of it. */
|
||||
export interface IntercityWagonBreakdownEntry {
|
||||
wagonTypeId: string;
|
||||
/** Wagon-type code as marshalled, e.g. "N35" / "PW2". */
|
||||
code: string;
|
||||
wagons: number;
|
||||
}
|
||||
|
||||
export interface IntercityBookingRow {
|
||||
id: string;
|
||||
reference: string | null;
|
||||
@@ -973,6 +981,12 @@ export interface IntercityBookingRow {
|
||||
weightTons: number;
|
||||
paymentDeadline: string | null;
|
||||
need: IntercityCapacity | null;
|
||||
/**
|
||||
* `need.wagons` split across the wagon types THIS schedule stocks — the same
|
||||
* booking reads differently on a train of 60T wagons than on one of 40T.
|
||||
* Empty when the stock or the cargo type's wagon list is unresolved.
|
||||
*/
|
||||
wagonBreakdown?: IntercityWagonBreakdownEntry[];
|
||||
}
|
||||
|
||||
export interface IntercityCandidateRow extends IntercityBookingRow {
|
||||
|
||||
18
apps/edr-freight-web/backoffice/src/utils/cargoWeight.ts
Normal file
18
apps/edr-freight-web/backoffice/src/utils/cargoWeight.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Break-bulk (PER_ITEM) bulk bookings overload `cargoTotalWeightVgm` with the
|
||||
* ITEM COUNT; their real tonnage lives in `bulkTotalWeightTons`. Every other
|
||||
* booking stores tons in `cargoTotalWeightVgm` directly. Rendering the raw
|
||||
* VGM column showed a 20-item / 100T booking as "20 tons".
|
||||
*/
|
||||
export function cargoTonsAndItems(booking: {
|
||||
freightType?: string | null;
|
||||
cargoTotalWeightVgm?: number | string | null;
|
||||
bulkTotalWeightTons?: number | string | null;
|
||||
}): { tons: number; items: number | null } {
|
||||
const bulkTons = Number(booking.bulkTotalWeightTons ?? 0);
|
||||
const vgm = Number(booking.cargoTotalWeightVgm ?? 0);
|
||||
if (booking.freightType === "BULK" && bulkTons > 0) {
|
||||
return { tons: bulkTons, items: vgm > 0 ? vgm : null };
|
||||
}
|
||||
return { tons: vgm, items: null };
|
||||
}
|
||||
@@ -548,18 +548,18 @@ export function AppLayout({
|
||||
</>
|
||||
)}
|
||||
<Divider />
|
||||
<Menu.Item
|
||||
leftSection={<FileSignature size={15} />}
|
||||
onClick={() => navigate("/signature")}
|
||||
>
|
||||
Signature & Stamp
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<User size={15} />}
|
||||
onClick={() => navigate("/profile")}
|
||||
>
|
||||
Profile
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<FileSignature size={15} />}
|
||||
onClick={() => navigate("/signature")}
|
||||
>
|
||||
My signature
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<Settings size={15} />}
|
||||
onClick={() => navigate("/settings")}
|
||||
|
||||
@@ -69,7 +69,7 @@ export function MySignatureCard() {
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FileSignature className="size-5 text-primary" />
|
||||
My signature
|
||||
Signature & Stamp
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Reused to approve and sign booking contracts.
|
||||
|
||||
@@ -6,7 +6,7 @@ export default function MySignaturePage() {
|
||||
<div className="mx-auto flex max-w-md flex-col gap-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-black tracking-tight text-foreground">
|
||||
My signature
|
||||
Signature & Stamp
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Saved and reused to approve and sign booking contracts.
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { ContractSignaturePad } from "@/components/bookings/ContractSignaturePad";
|
||||
import { StampUpload } from "@/components/contracts/StampUpload";
|
||||
import {
|
||||
bookingsService,
|
||||
type SignContractPayload,
|
||||
@@ -25,6 +26,9 @@ export default function BookingContractPage() {
|
||||
const [signOpen, setSignOpen] = useState(false);
|
||||
const [signerName, setSignerName] = useState("");
|
||||
const [signatureData, setSignatureData] = useState<string | null>(null);
|
||||
// Company stamp: prefilled from the profile, or uploaded here when the
|
||||
// customer has never saved one.
|
||||
const [stampData, setStampData] = useState<string | null>(null);
|
||||
// When a saved signature exists we offer it for approval first; the customer
|
||||
// can switch to drawing a fresh one.
|
||||
const [drawNew, setDrawNew] = useState(false);
|
||||
@@ -37,12 +41,14 @@ export default function BookingContractPage() {
|
||||
|
||||
const savedSignature = data?.savedSignature ?? null;
|
||||
const savedSignatureImage = savedSignature?.signatureImageUrl ?? null;
|
||||
const savedStampImage = savedSignature?.stampImageUrl ?? null;
|
||||
const usingSaved = Boolean(savedSignatureImage) && !drawNew;
|
||||
|
||||
const openSign = () => {
|
||||
// Prefill from the saved signature so the customer only has to approve it.
|
||||
setSignerName(savedSignature?.signerDisplayName ?? "");
|
||||
setSignatureData(null);
|
||||
setStampData(savedStampImage);
|
||||
setDrawNew(false);
|
||||
setSignOpen(true);
|
||||
};
|
||||
@@ -51,10 +57,12 @@ export default function BookingContractPage() {
|
||||
if (!signerName.trim()) return;
|
||||
// Approve the saved signature, or submit the freshly drawn one.
|
||||
const image = usingSaved ? savedSignatureImage : signatureData;
|
||||
if (!image) return;
|
||||
// The API rejects a CUSTOMER signature without a stamp.
|
||||
if (!image || !stampData) return;
|
||||
signMutation.mutate({
|
||||
role: "CUSTOMER",
|
||||
signatureImageBase64: image,
|
||||
stampImageBase64: stampData,
|
||||
signerDisplayName: signerName.trim(),
|
||||
consentText: "I agree to the terms of this contract.",
|
||||
});
|
||||
@@ -191,6 +199,15 @@ export default function BookingContractPage() {
|
||||
) : (
|
||||
<ContractSignaturePad onChange={setSignatureData} />
|
||||
)}
|
||||
<StampUpload
|
||||
value={stampData}
|
||||
onChange={setStampData}
|
||||
description={
|
||||
savedStampImage
|
||||
? "Your saved company stamp — replace it for this contract if needed."
|
||||
: "Required. Attach your official company stamp or seal."
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-6 flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={() => setSignOpen(false)}>
|
||||
@@ -200,6 +217,7 @@ export default function BookingContractPage() {
|
||||
disabled={
|
||||
signMutation.isPending ||
|
||||
(!usingSaved && !signatureData) ||
|
||||
!stampData ||
|
||||
!signerName.trim()
|
||||
}
|
||||
onClick={confirmSign}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Group, Tabs } from "@mantine/core";
|
||||
import { CreditCard, FileText, LayoutGrid } from "lucide-react";
|
||||
import { Clock, CreditCard, FileText, LayoutGrid, Truck } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { useFileViewer } from "@/hooks/useFileViewer";
|
||||
@@ -184,15 +184,27 @@ export function ReadonlyBookingView({
|
||||
<Tabs
|
||||
defaultValue="overview"
|
||||
keepMounted={false}
|
||||
color="edr-green"
|
||||
styles={{
|
||||
list: { gap: 6, borderBottom: "1px solid #E6ECF2" },
|
||||
tab: { borderRadius: "10px 10px 0 0", fontWeight: 700, padding: "10px 16px" },
|
||||
tab: {
|
||||
borderRadius: "10px 10px 0 0",
|
||||
fontWeight: 700,
|
||||
padding: "10px 16px",
|
||||
transition: "background-color 120ms ease, color 120ms ease",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Tabs.List mb="lg">
|
||||
<Tabs.Tab value="overview" leftSection={<LayoutGrid size={15} />}>
|
||||
Overview
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="logistics" leftSection={<Truck size={15} />}>
|
||||
Logistics
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="activity" leftSection={<Clock size={15} />}>
|
||||
Activity
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="documents" leftSection={<FileText size={15} />}>
|
||||
Documents
|
||||
</Tabs.Tab>
|
||||
@@ -215,23 +227,9 @@ export function ReadonlyBookingView({
|
||||
|
||||
<ContainersCard booking={booking} />
|
||||
|
||||
<WarehouseLocationCard bookingId={booking.id} />
|
||||
|
||||
<ContractInfoCard booking={booking} />
|
||||
|
||||
<ShipmentTrackingCard bookingId={booking.id} />
|
||||
|
||||
{canAssignCustomerTruck && (
|
||||
<CustomerTruckAssignmentCard
|
||||
booking={booking}
|
||||
onAssigned={onBookingUpdated ?? (() => {})}
|
||||
/>
|
||||
)}
|
||||
<WarehousePaymentsSection bookingId={booking.id} />
|
||||
|
||||
<ActivityCard booking={booking} />
|
||||
|
||||
<MileSummaryCard booking={booking} />
|
||||
</>
|
||||
}
|
||||
right={
|
||||
@@ -256,6 +254,34 @@ export function ReadonlyBookingView({
|
||||
</div>
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="logistics">
|
||||
<div className="flex flex-col gap-6">
|
||||
<BodyGrid
|
||||
left={
|
||||
<>
|
||||
<WarehouseLocationCard bookingId={booking.id} />
|
||||
|
||||
{canAssignCustomerTruck && (
|
||||
<CustomerTruckAssignmentCard
|
||||
booking={booking}
|
||||
onAssigned={onBookingUpdated ?? (() => {})}
|
||||
/>
|
||||
)}
|
||||
|
||||
<MileSummaryCard booking={booking} />
|
||||
</>
|
||||
}
|
||||
right={<WarehousePaymentsSection bookingId={booking.id} />}
|
||||
/>
|
||||
</div>
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="activity">
|
||||
<div className="flex flex-col gap-6" style={{ maxWidth: 700 }}>
|
||||
<ActivityCard booking={booking} />
|
||||
</div>
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="documents">
|
||||
<DocumentsTab booking={booking} />
|
||||
</Tabs.Panel>
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import { Box, Group, Text } from "@mantine/core";
|
||||
import type { ReactNode } from "react";
|
||||
import { MapPin } from "lucide-react";
|
||||
import { type ReactNode, useState } from "react";
|
||||
|
||||
import { bookingStatusLabel } from "@/pages/bookings/booking-display";
|
||||
import { ShipmentTrackingModal } from "@/pages/bookings/tracking/ShipmentTrackingModal";
|
||||
|
||||
import type { BookingDetail } from "../booking-detail-types";
|
||||
import { fmtDate, isDraftLike, isNegative, serviceTypeLabel } from "../utils";
|
||||
import {
|
||||
fmtDate,
|
||||
isDraftLike,
|
||||
isNegative,
|
||||
serviceTypeLabel,
|
||||
yardLabel,
|
||||
} from "../utils";
|
||||
import { CardTitle, SectionCard } from "./layout";
|
||||
|
||||
type Row = { label: string; value: ReactNode; muted?: boolean };
|
||||
@@ -53,14 +61,39 @@ export function ScheduleCard({
|
||||
title: string;
|
||||
consignment?: boolean;
|
||||
}) {
|
||||
const [trackingOpen, setTrackingOpen] = useState(false);
|
||||
const service = serviceTypeLabel(booking);
|
||||
const equipmentReturn =
|
||||
booking.equipmentReturn === "WITH_RETURN" ? "With return" : "Without return";
|
||||
const assignedTrain: Row = {
|
||||
label: "Assigned train",
|
||||
value: booking.trainId ?? "Not yet assigned",
|
||||
muted: !booking.trainId,
|
||||
};
|
||||
const assignedTrain: Row = booking.trainScheduleId
|
||||
? {
|
||||
label: "Assigned train",
|
||||
value: (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTrackingOpen(true)}
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 5,
|
||||
border: "none",
|
||||
background: "none",
|
||||
padding: 0,
|
||||
cursor: "pointer",
|
||||
color: "#0A6F4D",
|
||||
fontWeight: 700,
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
<MapPin size={13} /> Track shipment
|
||||
</button>
|
||||
),
|
||||
}
|
||||
: {
|
||||
label: "Assigned train",
|
||||
value: "Not yet assigned",
|
||||
muted: true,
|
||||
};
|
||||
|
||||
const statusRow: Row = {
|
||||
label: "Status",
|
||||
@@ -115,6 +148,17 @@ export function ScheduleCard({
|
||||
</Group>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
{booking.trainScheduleId && (
|
||||
<ShipmentTrackingModal
|
||||
opened={trackingOpen}
|
||||
onClose={() => setTrackingOpen(false)}
|
||||
bookingId={booking.id}
|
||||
bookingReference={booking.reference}
|
||||
originLabel={yardLabel(booking.originYard)}
|
||||
destinationLabel={yardLabel(booking.destinationYard)}
|
||||
/>
|
||||
)}
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -69,10 +69,7 @@ export function ShipmentDetailsCard({ booking }: { booking: BookingDetail }) {
|
||||
],
|
||||
["Scheduled date", fmtDate(booking.scheduledDate)],
|
||||
],
|
||||
[
|
||||
["Shipping line", shippingLineLabel(booking)],
|
||||
["Assigned train", booking.trainId ?? "Not yet assigned"],
|
||||
],
|
||||
[["Shipping line", shippingLineLabel(booking)]],
|
||||
];
|
||||
|
||||
const badges: string[] = [];
|
||||
|
||||
@@ -83,6 +83,10 @@ export function totalVgmTons(b: BookingDetail): number {
|
||||
);
|
||||
if (sum > 0) return sum;
|
||||
}
|
||||
// Break-bulk (PER_ITEM): cargoTotalWeightVgm holds the ITEM COUNT — the
|
||||
// real tonnage lives in bulkTotalWeightTons.
|
||||
const bulkTons = Number(b.bulkTotalWeightTons || 0);
|
||||
if (bulkTons > 0) return bulkTons;
|
||||
return Number(b.cargoTotalWeightVgm || 0);
|
||||
}
|
||||
|
||||
|
||||
@@ -77,6 +77,7 @@ const STATUS_FILTERS = [
|
||||
key: "all",
|
||||
label: "All bookings",
|
||||
statuses: undefined as string | undefined,
|
||||
assignedToSchedule: undefined as "true" | "false" | undefined,
|
||||
},
|
||||
{
|
||||
key: "active",
|
||||
@@ -89,9 +90,16 @@ const STATUS_FILTERS = [
|
||||
key: "payment",
|
||||
label: "Awaiting payment",
|
||||
statuses:
|
||||
"SELECTED_FOR_BATCH,PNR_GENERATED,PAYMENT_VERIFICATION_IN_PROGRESS,EXPIRED",
|
||||
"SELECTED_FOR_BATCH,PNR_GENERATED,PAYMENT_VERIFICATION_IN_PROGRESS",
|
||||
},
|
||||
{ key: "transit", label: "In transit", statuses: "PAID,IN_TRANSIT,ARRIVED" },
|
||||
{
|
||||
key: "allocated",
|
||||
label: "Allocated to a train",
|
||||
statuses: undefined as string | undefined,
|
||||
assignedToSchedule: "true" as const,
|
||||
},
|
||||
{ key: "expired", label: "Expired", statuses: "EXPIRED" },
|
||||
{ key: "done", label: "Completed", statuses: "COMPLETED,DELIVERED" },
|
||||
{
|
||||
key: "closed",
|
||||
@@ -348,7 +356,10 @@ export default function BookingsListPage() {
|
||||
const [trackingBooking, setTrackingBooking] =
|
||||
useState<Freight.IBooking | null>(null);
|
||||
|
||||
const statuses = STATUS_FILTERS.find((t) => t.key === statusFilter)?.statuses;
|
||||
const activeFilter = STATUS_FILTERS.find((t) => t.key === statusFilter);
|
||||
const statuses = activeFilter?.statuses;
|
||||
const assignedToSchedule =
|
||||
"assignedToSchedule" in activeFilter! ? activeFilter.assignedToSchedule : undefined;
|
||||
const [sortBy, sortOrder] = sort.split(":") as [string, "ASC" | "DESC"];
|
||||
|
||||
const resetPage = () =>
|
||||
@@ -372,6 +383,7 @@ export default function BookingsListPage() {
|
||||
const filter: BookingListFilter = useMemo(
|
||||
() => ({
|
||||
statuses,
|
||||
assignedToSchedule,
|
||||
bookingType: typeFilter ?? undefined,
|
||||
freightType: freightFilter ?? undefined,
|
||||
createdFrom: createdFrom || undefined,
|
||||
@@ -386,6 +398,7 @@ export default function BookingsListPage() {
|
||||
}),
|
||||
[
|
||||
statuses,
|
||||
assignedToSchedule,
|
||||
typeFilter,
|
||||
freightFilter,
|
||||
createdFrom,
|
||||
@@ -423,6 +436,8 @@ export default function BookingsListPage() {
|
||||
draft: draftCount,
|
||||
done: doneCount,
|
||||
transit: undefined,
|
||||
allocated: undefined,
|
||||
expired: undefined,
|
||||
closed: undefined,
|
||||
};
|
||||
|
||||
@@ -557,7 +572,12 @@ export default function BookingsListPage() {
|
||||
size: 130,
|
||||
meta: hMeta,
|
||||
header: () => <ColHeader label="Payment" />,
|
||||
cell: ({ row }) => <PaymentBadge status={row.original.paymentStatus} />,
|
||||
cell: ({ row }) => (
|
||||
<PaymentBadge
|
||||
status={row.original.paymentStatus}
|
||||
bookingStatus={row.original.status}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "scheduling",
|
||||
|
||||
@@ -144,17 +144,31 @@ export function paymentStatusLabel(status?: string | null): string {
|
||||
return PAYMENT_LABELS[status] ?? titleCaseStatus(status);
|
||||
}
|
||||
|
||||
/** Payment status pill. */
|
||||
export function PaymentBadge({ status }: { status?: string | null }) {
|
||||
if (!status) return <Text fz={13} c="dimmed">—</Text>;
|
||||
/**
|
||||
* Payment status pill. Once the booking's own lifecycle status has moved past
|
||||
* payment (PAID or later — stage ≥ 3 in STATUS_CONFIG), payment is a settled
|
||||
* fact: show "Paid" even if a stale/lagging `paymentStatus` value says
|
||||
* otherwise, rather than surface a contradictory "Paid booking, pending
|
||||
* payment" row.
|
||||
*/
|
||||
export function PaymentBadge({
|
||||
status,
|
||||
bookingStatus,
|
||||
}: {
|
||||
status?: string | null;
|
||||
bookingStatus?: string | null;
|
||||
}) {
|
||||
const settled = bookingStatus ? (STATUS_CONFIG[bookingStatus]?.stage ?? 0) >= 3 : false;
|
||||
const effective = settled ? "PAID" : status;
|
||||
if (!effective) return <Text fz={13} c="dimmed">—</Text>;
|
||||
return (
|
||||
<Badge
|
||||
variant="light"
|
||||
radius="sm"
|
||||
color={PAYMENT_COLORS[status] ?? "gray"}
|
||||
color={PAYMENT_COLORS[effective] ?? "gray"}
|
||||
styles={{ root: { textTransform: "none", fontWeight: 600, letterSpacing: 0 } }}
|
||||
>
|
||||
{PAYMENT_LABELS[status] ?? titleCaseStatus(status)}
|
||||
{PAYMENT_LABELS[effective] ?? titleCaseStatus(effective)}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1316,7 +1316,10 @@ export default function ContractDetailPage() {
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<PaymentBadge status={booking.paymentStatus} />
|
||||
<PaymentBadge
|
||||
status={booking.paymentStatus}
|
||||
bookingStatus={booking.status}
|
||||
/>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<SchedulingCell booking={booking} />
|
||||
|
||||
@@ -11,6 +11,7 @@ import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
@@ -515,7 +516,9 @@ function NewShipmentBookingForm({
|
||||
routes={routes}
|
||||
completeBookingId={completeBookingId ?? null}
|
||||
/>
|
||||
<NotesSection form={form} />
|
||||
{/* Notes are captured when the booking is initiated — completing
|
||||
a bare booking does not re-ask for them. */}
|
||||
{!completeBookingId && <NotesSection form={form} />}
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
@@ -989,8 +992,9 @@ function ScheduleStep({
|
||||
?.cargoTypeCode ?? undefined,
|
||||
totalWeightTons: tons,
|
||||
};
|
||||
// itemCount is referenced so the query refreshes when a PER_ITEM cargo
|
||||
// amount changes (weight is the sizing input the backend uses).
|
||||
// Tonnage is the sizing input the day-feasibility endpoint takes, and
|
||||
// PER_ITEM cargo now captures it too — itemCount stays in the deps so the
|
||||
// query still refreshes when only the item count changes.
|
||||
}, [
|
||||
route,
|
||||
isContainer,
|
||||
@@ -1159,6 +1163,9 @@ function CargoStep({
|
||||
contract: Freight.IContract;
|
||||
}) {
|
||||
const isContainer = contract.freightType === "CONTAINER";
|
||||
// Break-bulk (PER_ITEM) cargo needs BOTH the item count (which prices it) and
|
||||
// the total tonnage (which sizes the wagons); PER_TON needs tonnage only.
|
||||
const isPerItem = bulkUnitOfMeasure(contract) === "PER_ITEM";
|
||||
// Sizes enabled by the contract scope.
|
||||
const sizes = useMemo(
|
||||
() =>
|
||||
@@ -1190,8 +1197,7 @@ function CargoStep({
|
||||
// still needs its container number.
|
||||
useEffect(() => {
|
||||
if (!remainderMode || isContainer) return;
|
||||
const field =
|
||||
bulkUnitOfMeasure(contract) === "PER_ITEM" ? "itemCount" : "cargoWeightTons";
|
||||
const field = isPerItem ? "itemCount" : "cargoWeightTons";
|
||||
if (!form.getValues(field)) {
|
||||
form.setValue(field, String(remainderLines[0].remaining), {
|
||||
shouldValidate: false,
|
||||
@@ -1475,6 +1481,27 @@ function CargoStep({
|
||||
<Stack gap={14}>
|
||||
{remainderNotice}
|
||||
<ContractCapacityNotice contractId={contract.id} isContainer={false} />
|
||||
{isPerItem && (
|
||||
<Controller
|
||||
name="itemCount"
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextInput
|
||||
{...field}
|
||||
type="number"
|
||||
onKeyDown={blockNegative}
|
||||
label="Number of items *"
|
||||
description="This contract is priced per item — the item count sets the price."
|
||||
placeholder="e.g. 500"
|
||||
min={0}
|
||||
step={1}
|
||||
error={fieldState.error?.message}
|
||||
radius={10}
|
||||
styles={fieldStyles}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<Controller
|
||||
name="cargoWeightTons"
|
||||
control={form.control}
|
||||
@@ -1483,7 +1510,12 @@ function CargoStep({
|
||||
{...field}
|
||||
type="number"
|
||||
onKeyDown={blockNegative}
|
||||
label="Quantity (tons)"
|
||||
label="Total weight (tons) *"
|
||||
description={
|
||||
isPerItem
|
||||
? "Combined weight of all the items — used to work out how many wagons the shipment needs."
|
||||
: undefined
|
||||
}
|
||||
placeholder="e.g. 1200"
|
||||
min={0}
|
||||
step={0.01}
|
||||
@@ -1493,24 +1525,6 @@ function CargoStep({
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="itemCount"
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextInput
|
||||
{...field}
|
||||
type="number"
|
||||
onKeyDown={blockNegative}
|
||||
label="Item count (if applicable)"
|
||||
placeholder="e.g. 500"
|
||||
min={0}
|
||||
step={1}
|
||||
error={fieldState.error?.message}
|
||||
radius={10}
|
||||
styles={fieldStyles}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{contract.isHazardous && (
|
||||
<Controller
|
||||
name="bulkHazardousQuantity"
|
||||
@@ -1694,6 +1708,18 @@ function ContainerLineEditor({
|
||||
syncHandlingCounts(next);
|
||||
};
|
||||
|
||||
// Drop one container row and shrink quantity to match — the inverse of
|
||||
// syncUnits growing the array when quantity goes up.
|
||||
const removeUnit = (unitIdx: number) => {
|
||||
const current = form.getValues(`containers.${index}.units`) ?? [];
|
||||
const next = current.filter((_, j) => j !== unitIdx);
|
||||
form.setValue(`containers.${index}.units`, next, { shouldValidate: false });
|
||||
form.setValue(`containers.${index}.quantity`, String(next.length), {
|
||||
shouldValidate: true,
|
||||
});
|
||||
syncHandlingCounts(next);
|
||||
};
|
||||
|
||||
/**
|
||||
* Line totals are a roll-up of the per-container switches — the count is
|
||||
* however many containers ticked each service. Kept in form state so the
|
||||
@@ -1781,8 +1807,10 @@ function ContainerLineEditor({
|
||||
styles={fieldStyles}
|
||||
onChange={(e) => {
|
||||
field.onChange(e.currentTarget.value);
|
||||
const qty = Number(e.currentTarget.value || 0);
|
||||
syncUnits(qty);
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
field.onBlur();
|
||||
syncUnits(Number(e.currentTarget.value || 0));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -1906,6 +1934,14 @@ function ContainerLineEditor({
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
aria-label={`Remove container ${u + 1}`}
|
||||
onClick={() => removeUnit(u)}
|
||||
>
|
||||
<X size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Controller, type UseFormReturn } from "react-hook-form";
|
||||
import { type UseFormReturn } from "react-hook-form";
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
@@ -7,13 +7,11 @@ import {
|
||||
Paper,
|
||||
Stack,
|
||||
Text,
|
||||
Textarea,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
CheckCircle2,
|
||||
Circle,
|
||||
ClipboardCheck,
|
||||
Coins,
|
||||
FileText,
|
||||
MapPin,
|
||||
Package,
|
||||
@@ -220,13 +218,13 @@ export function Step8Review({
|
||||
// not of the stored form flag — a stale draft flag must not misreport it.
|
||||
// Without bundling, the customer may still name their own clearing agent.
|
||||
const ownAgent = values.customsClearingAgent?.trim();
|
||||
const customsValue = isIntercity
|
||||
? "Not applicable — domestic transport"
|
||||
const customsTag: { label: string; color: string } = isIntercity
|
||||
? { label: "Not applicable · domestic", color: "gray" }
|
||||
: serviceType?.includesCustoms || values.customsClearingEnabled
|
||||
? "Included — Global Logistics"
|
||||
? { label: "EDR handles it · Global Logistics", color: "edr-green" }
|
||||
: ownAgent
|
||||
? `Own agent — ${ownAgent}`
|
||||
: "Not requested";
|
||||
? { label: `Own agent · ${ownAgent}`, color: "blue" }
|
||||
: { label: "Not requested", color: "gray" };
|
||||
|
||||
// Mirror the step-2 gating: imports never truck the first mile, exports never
|
||||
// truck the last mile, and a service that doesn't bundle a mile can't have it.
|
||||
@@ -351,27 +349,26 @@ export function Step8Review({
|
||||
label="Service"
|
||||
value={serviceType?.serviceName ?? "—"}
|
||||
/>
|
||||
<SummaryItem
|
||||
icon={<Coins size={18} />}
|
||||
label="Quotation currency"
|
||||
value={
|
||||
<>
|
||||
USD
|
||||
<Text fz="sm" c="dimmed" mt={4}>
|
||||
You choose the billing currency on each shipment.
|
||||
</Text>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<SummaryItem
|
||||
icon={<Route size={18} />}
|
||||
label="Route"
|
||||
value={`${originYardName} → ${destinationYardName}`}
|
||||
/>
|
||||
<SummaryItem
|
||||
icon={<MapPin size={18} />}
|
||||
label="Trade direction"
|
||||
value={directionLabel}
|
||||
value={
|
||||
<Group gap={6} wrap="nowrap" align="center">
|
||||
<Text fz={14} fw={600} c="#10202F" truncate>
|
||||
{originYardName} → {destinationYardName}
|
||||
</Text>
|
||||
<Badge
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
radius="sm"
|
||||
leftSection={<MapPin size={11} />}
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
{directionLabel}
|
||||
</Badge>
|
||||
</Group>
|
||||
}
|
||||
/>
|
||||
<SummaryItem
|
||||
icon={<Package size={18} />}
|
||||
@@ -420,7 +417,16 @@ export function Step8Review({
|
||||
<SummaryItem
|
||||
icon={<FileText size={18} />}
|
||||
label="Customs clearing"
|
||||
value={customsValue}
|
||||
value={
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color={customsTag.color}
|
||||
radius="sm"
|
||||
>
|
||||
{customsTag.label}
|
||||
</Badge>
|
||||
}
|
||||
/>
|
||||
{/* Step-3 toggles appear only when the customer selected them —
|
||||
an off toggle is left off the summary entirely. */}
|
||||
@@ -478,20 +484,6 @@ export function Step8Review({
|
||||
{documentsEditor}
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
<Controller
|
||||
name="notes"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<Textarea
|
||||
{...field}
|
||||
label="Additional notes"
|
||||
placeholder="Any special instructions for EDR operations…"
|
||||
rows={3}
|
||||
radius="md"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
{/* Right — sticky actions */}
|
||||
|
||||
@@ -221,6 +221,20 @@ export function createShipmentFormSchema(ctx: ShipmentValidationContext) {
|
||||
});
|
||||
}
|
||||
|
||||
// PER_ITEM cargo needs BOTH figures: the item count prices the booking,
|
||||
// the total weight sizes the wagons (per-item weight = tons ÷ items).
|
||||
// PER_TON needs only the tonnage, which `bulkCap` already covers.
|
||||
if (isPerItem) {
|
||||
const tons = Number(data.cargoWeightTons || 0);
|
||||
if (Number.isNaN(tons) || tons <= 0) {
|
||||
refineCtx.addIssue({
|
||||
code: "custom",
|
||||
path: ["cargoWeightTons"],
|
||||
message: "Enter the total weight in tons.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const boundBulkPortion = (
|
||||
on: boolean,
|
||||
raw: string,
|
||||
|
||||
@@ -148,3 +148,62 @@ describe("computeShipmentTotal — with_return surcharge", () => {
|
||||
expect(t.total).toBe(200 + 60);
|
||||
});
|
||||
});
|
||||
|
||||
// PER_ITEM (break-bulk) cargo captures BOTH an item count and a total tonnage:
|
||||
// the item count prices the booking, the tonnage sizes the wagons. Before this,
|
||||
// the estimate took `cargoWeightTons || itemCount` and so billed a per_item rate
|
||||
// against the tonnage as soon as both fields were filled.
|
||||
describe("computeShipmentTotal — PER_ITEM bulk", () => {
|
||||
const perItemContract = contract({
|
||||
freightType: "BULK",
|
||||
isHazardous: false,
|
||||
isReefer: false,
|
||||
equipmentReturn: "NO_RETURN",
|
||||
pricingBreakdown: {
|
||||
currency: "ETB",
|
||||
lineItems: [
|
||||
{ label: "Break-bulk freight", unit: "per_item", unitPrice: 50 },
|
||||
{
|
||||
label: "Customs clearance",
|
||||
unit: "per_item",
|
||||
unitPrice: 5,
|
||||
isClearance: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
} as unknown as Partial<Freight.IContract>);
|
||||
|
||||
it("bills a per_item rate on the item count, not the tonnage", () => {
|
||||
const t = computeShipmentTotal(
|
||||
perItemContract,
|
||||
values({ containers: [], itemCount: "400", cargoWeightTons: "800" }),
|
||||
);
|
||||
expect(line(t, "Break-bulk freight")).toMatchObject({
|
||||
quantity: 400,
|
||||
amount: 20000,
|
||||
});
|
||||
expect(line(t, "Customs clearance")).toMatchObject({
|
||||
quantity: 400,
|
||||
amount: 2000,
|
||||
});
|
||||
expect(t.total).toBe(22000);
|
||||
});
|
||||
|
||||
it("still bills a per_ton rate on the tonnage", () => {
|
||||
const perTon = contract({
|
||||
freightType: "BULK",
|
||||
isHazardous: false,
|
||||
isReefer: false,
|
||||
equipmentReturn: "NO_RETURN",
|
||||
pricingBreakdown: {
|
||||
currency: "ETB",
|
||||
lineItems: [{ label: "Bulk freight", unit: "per_ton", unitPrice: 10 }],
|
||||
},
|
||||
} as unknown as Partial<Freight.IContract>);
|
||||
const t = computeShipmentTotal(
|
||||
perTon,
|
||||
values({ containers: [], cargoWeightTons: "800", itemCount: "" }),
|
||||
);
|
||||
expect(line(t, "Bulk freight")).toMatchObject({ quantity: 800, amount: 8000 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,6 +15,18 @@ export interface ShipmentTotal {
|
||||
total: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Quantity a bulk rate bills, in ITS OWN unit. PER_ITEM cargo carries both
|
||||
* figures — the item count prices the booking, the tonnage sizes the wagons —
|
||||
* so a per_item rate must bill items even though tonnage is also filled in.
|
||||
* PER_TON cargo has no item count and falls back the other way for legacy rows.
|
||||
*/
|
||||
function bulkQtyForUnit(values: ShipmentFormValues, unit: string): number {
|
||||
return unit === "per_item"
|
||||
? Number(values.itemCount || 0)
|
||||
: Number(values.cargoWeightTons || values.itemCount || 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the booking total client-side from the contract's frozen unit rates ×
|
||||
* the quantities the customer enters (doc §9.2). This is an estimate shown in
|
||||
@@ -115,7 +127,6 @@ export function computeShipmentTotal(
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const qty = Number(values.cargoWeightTons || values.itemCount || 0);
|
||||
const rate =
|
||||
rateFor(
|
||||
(i) =>
|
||||
@@ -123,6 +134,7 @@ export function computeShipmentTotal(
|
||||
!i.isClearance &&
|
||||
!i.conditionalOn,
|
||||
) ?? items[0];
|
||||
const qty = rate ? bulkQtyForUnit(values, rate.unit) : 0;
|
||||
if (rate && qty > 0) {
|
||||
lines.push({
|
||||
label: rate.label,
|
||||
@@ -166,14 +178,14 @@ export function computeShipmentTotal(
|
||||
// real pricing.
|
||||
const lashing = items.find((i) => i.conditionalOn === "has_lashing");
|
||||
if (lashing && (lashing.unit === "per_ton" || lashing.unit === "per_item")) {
|
||||
const tons = Number(values.cargoWeightTons || values.itemCount || 0);
|
||||
if (tons > 0) {
|
||||
const qty = bulkQtyForUnit(values, lashing.unit);
|
||||
if (qty > 0) {
|
||||
lines.push({
|
||||
label: lashing.label,
|
||||
unitPrice: lashing.unitPrice,
|
||||
unit: lashing.unit,
|
||||
quantity: tons,
|
||||
amount: lashing.unitPrice * tons,
|
||||
quantity: qty,
|
||||
amount: lashing.unitPrice * qty,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -193,7 +205,7 @@ export function computeShipmentTotal(
|
||||
? Math.ceil(boxes * (cl.containerSize === "40ft" ? 1 : 0.5))
|
||||
: boxes;
|
||||
} else if (cl.unit === "per_ton" || cl.unit === "per_item") {
|
||||
qty = Number(values.cargoWeightTons || values.itemCount || 0);
|
||||
qty = bulkQtyForUnit(values, cl.unit);
|
||||
} else if (cl.unit === "flat") {
|
||||
qty = 1;
|
||||
}
|
||||
|
||||
@@ -172,6 +172,8 @@ export interface BookingListFilter {
|
||||
status?: string;
|
||||
/** Comma-separated statuses (overrides `status` when set). */
|
||||
statuses?: string;
|
||||
/** 'true' = has a train assigned (allocated), 'false' = not yet assigned. */
|
||||
assignedToSchedule?: "true" | "false";
|
||||
/** ONE_TIME or GENERAL_CONTRACT. */
|
||||
bookingType?: string;
|
||||
/** CONTAINER or BULK. */
|
||||
|
||||
Reference in New Issue
Block a user