mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge pull request #968 from Tria-plc/freight_feature/usermanagement
Freight feature/usermanagement
This commit is contained in:
@@ -594,3 +594,126 @@ describe('BookingPricingService — bulk base freight units', () => {
|
||||
expect(result.hardBlocked.some((m) => m.includes('rate is configured'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* A PER_WAGON container rate bills the wagons the LINE occupies — two 20ft share
|
||||
* one wagon, a 40ft takes a whole one. Regression cases taken from real
|
||||
* bookings on Doraleh → Gelan, where the 20ft line was being charged for the
|
||||
* 40ft line's wagons as well.
|
||||
*/
|
||||
describe('BookingPricingService — PER_WAGON container freight', () => {
|
||||
const DJ = 'yard-dj-w';
|
||||
const ET = 'yard-et-w';
|
||||
|
||||
const perWagon20: Rate = {
|
||||
id: 'rate-20-wagon',
|
||||
rateType: 'CONTAINER_IMPORT',
|
||||
currency: 'USD',
|
||||
rateValue: 1690,
|
||||
rateUnit: 'PER_WAGON',
|
||||
status: 'LIVE',
|
||||
containerTypeId: 'ct-20',
|
||||
originYardId: DJ,
|
||||
destinationYardId: ET,
|
||||
} as Rate;
|
||||
|
||||
const perContainer40: Rate = {
|
||||
...perWagon20,
|
||||
id: 'rate-40-container',
|
||||
rateValue: 1676,
|
||||
rateUnit: 'PER_CONTAINER',
|
||||
containerTypeId: 'ct-40',
|
||||
} as Rate;
|
||||
|
||||
const makeService = () =>
|
||||
new BookingPricingService(
|
||||
{
|
||||
// Booking-wide aggregate — deliberately larger than any single line, so
|
||||
// a regression that reads it instead of the line's own wagons shows up.
|
||||
calculateWagonCount: jest.fn().mockResolvedValue(5),
|
||||
findContractRateSnapshots: jest.fn().mockResolvedValue([]),
|
||||
} as never,
|
||||
{
|
||||
evaluate: jest.fn().mockResolvedValue({
|
||||
priorityScore: 0,
|
||||
appliedModifiers: [],
|
||||
containerWeightResults: [],
|
||||
warnings: [],
|
||||
hardBlocked: [],
|
||||
requiresDirectorApproval: false,
|
||||
}),
|
||||
} as never,
|
||||
{
|
||||
findById: jest.fn(async (id: string) => ({
|
||||
id,
|
||||
sizeFt: id === 'ct-40' ? 40 : 20,
|
||||
isReefer: false,
|
||||
code: id === 'ct-40' ? 'C40' : 'C20',
|
||||
label: id === 'ct-40' ? 'C40' : 'C20',
|
||||
})),
|
||||
} as never,
|
||||
{ findLiveRates: jest.fn().mockResolvedValue([perWagon20, perContainer40]) } as never,
|
||||
{ getRate: jest.fn().mockResolvedValue(MOCK_CBE_RATE) } as never,
|
||||
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
|
||||
{ findById: jest.fn() } as never,
|
||||
);
|
||||
|
||||
const booking = (
|
||||
lines: Array<{ containerTypeId: string; quantity: number }>,
|
||||
) =>
|
||||
({
|
||||
id: 'b-wagon',
|
||||
freightType: 'CONTAINER',
|
||||
tradeDirection: 'IMPORT',
|
||||
paymentCurrency: 'USD',
|
||||
originYardId: DJ,
|
||||
destinationYardId: ET,
|
||||
bookingContainers: lines.map((l) => ({
|
||||
containerTypeId: l.containerTypeId,
|
||||
quantity: l.quantity,
|
||||
vgmPerUnitTons: 10,
|
||||
})),
|
||||
}) as unknown as Booking;
|
||||
|
||||
const price = async (
|
||||
lines: Array<{ containerTypeId: string; quantity: number }>,
|
||||
) => {
|
||||
const service = makeService();
|
||||
const result = await service.computePriceForBooking(booking(lines));
|
||||
return result.lineItems.filter((l) => l.code === 'CONTAINER_IMPORT');
|
||||
};
|
||||
|
||||
it('bills 2× 20ft as one wagon', async () => {
|
||||
const [line] = await price([{ containerTypeId: 'ct-20', quantity: 2 }]);
|
||||
expect(line.unit).toBe('PER_WAGON');
|
||||
expect(line.quantity).toBe(1);
|
||||
expect(line.amount).toBe(1690);
|
||||
});
|
||||
|
||||
it('bills 10× 20ft as five wagons', async () => {
|
||||
const [line] = await price([{ containerTypeId: 'ct-20', quantity: 10 }]);
|
||||
expect(line.quantity).toBe(5);
|
||||
expect(line.amount).toBe(5 * 1690);
|
||||
});
|
||||
|
||||
it('does not charge the 20ft line for the 40ft line’s wagons', async () => {
|
||||
const lines = await price([
|
||||
{ containerTypeId: 'ct-20', quantity: 4 },
|
||||
{ containerTypeId: 'ct-40', quantity: 1 },
|
||||
]);
|
||||
const twenty = lines.find((l) => l.description.startsWith('C20'))!;
|
||||
const forty = lines.find((l) => l.description.startsWith('C40'))!;
|
||||
// 4× 20ft = 2 wagons, NOT the booking-wide 3.
|
||||
expect(twenty.quantity).toBe(2);
|
||||
expect(twenty.amount).toBe(2 * 1690);
|
||||
// The 40ft line keeps billing per container.
|
||||
expect(forty.quantity).toBe(1);
|
||||
expect(forty.amount).toBe(1676);
|
||||
});
|
||||
|
||||
it('rounds an odd 20ft count up to a whole wagon', async () => {
|
||||
const [line] = await price([{ containerTypeId: 'ct-20', quantity: 5 }]);
|
||||
expect(line.quantity).toBe(3);
|
||||
expect(line.amount).toBe(3 * 1690);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -530,14 +530,6 @@ export class BookingPricingService {
|
||||
const usedRatesMap = new Map<string, Rate>();
|
||||
const warnings: string[] = [];
|
||||
const blocked: string[] = [];
|
||||
// Bulk bookings carry no container lines, so the container-based wagon
|
||||
// aggregate is 0 for them — a PER_WAGON bulk rate would bill nothing. Use
|
||||
// the tonnage-derived estimate instead (the eval input already carries it
|
||||
// for saved bookings; a preview derives it here).
|
||||
const wagonCount = isBulk
|
||||
? Number(evalInput.bulkWagons ?? 0) || (await this.bulkWagonCount(booking)) || 0
|
||||
: await this.resolveWagonCount(booking);
|
||||
|
||||
for (const container of evalInput.containers) {
|
||||
const rate = this.pickRate(
|
||||
liveRates,
|
||||
@@ -573,6 +565,10 @@ export class BookingPricingService {
|
||||
}
|
||||
|
||||
const rateUnit = rate?.rateUnit ?? 'PER_CONTAINER';
|
||||
// A PER_WAGON line bills the wagons THIS line occupies (two 20ft share
|
||||
// one), never the booking-wide count — otherwise a booking with a 20ft
|
||||
// and a 40ft line charges each line for the other's wagons too.
|
||||
const lineWagons = await this.lineWagonCount(container);
|
||||
let amount: number;
|
||||
let unitAmount: number;
|
||||
if (frozen) {
|
||||
@@ -581,11 +577,11 @@ export class BookingPricingService {
|
||||
rateUnit,
|
||||
unitAmount,
|
||||
container.quantity,
|
||||
wagonCount,
|
||||
lineWagons,
|
||||
);
|
||||
} else {
|
||||
const unitUsd = Number(rate!.rateValue);
|
||||
const usdAmount = this.amountForRate(rate!, container.quantity, wagonCount);
|
||||
const usdAmount = this.amountForRate(rate!, container.quantity, lineWagons);
|
||||
amount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount;
|
||||
unitAmount = isEtbBooking ? Math.round(unitUsd * usdToEtb) : unitUsd;
|
||||
}
|
||||
@@ -596,7 +592,7 @@ export class BookingPricingService {
|
||||
amount,
|
||||
unitAmount,
|
||||
unit: rateUnit,
|
||||
quantity: this.effectiveUnitQuantity(rateUnit, container.quantity, wagonCount),
|
||||
quantity: this.effectiveUnitQuantity(rateUnit, container.quantity, lineWagons),
|
||||
currency: paymentCurrency,
|
||||
});
|
||||
}
|
||||
@@ -625,6 +621,14 @@ export class BookingPricingService {
|
||||
: undefined) ?? onLeg.find((r) => !r.cargoTypeId);
|
||||
if (fallback) {
|
||||
usedRatesMap.set(fallback.id, fallback);
|
||||
// Bulk has no container lines to count wagons from, so a PER_WAGON bulk
|
||||
// rate bills the tonnage-derived estimate for the WHOLE booking (there
|
||||
// is only ever this one line).
|
||||
const wagonCount = isBulk
|
||||
? Number(evalInput.bulkWagons ?? 0) ||
|
||||
(await this.bulkWagonCount(booking)) ||
|
||||
0
|
||||
: await this.resolveWagonCount(booking);
|
||||
// Bulk quantity is stored in the commodity's own unit — tonnes for a
|
||||
// PER_TON commodity, item count for a PER_ITEM one.
|
||||
const bulkQuantity = Number(booking.cargoTotalWeightVgm ?? 0);
|
||||
@@ -783,6 +787,34 @@ export class BookingPricingService {
|
||||
return { lineItems: lines, usedRates: [...usedRatesMap.values()] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Wagons ONE container line occupies: two 20ft share a wagon, a 40ft takes a
|
||||
* whole one. This — not the booking-wide total — is what a PER_WAGON base
|
||||
* freight line bills, so a booking of 4×20ft + 1×40ft charges the 20ft line
|
||||
* for 2 wagons and the 40ft line for its own 1, instead of billing each line
|
||||
* for all 3.
|
||||
*/
|
||||
private async lineWagonCount(container: {
|
||||
containerTypeId: string;
|
||||
quantity: number;
|
||||
wagonsPerUnit?: number;
|
||||
}): Promise<number> {
|
||||
let perUnit = container.wagonsPerUnit;
|
||||
if (perUnit == null) {
|
||||
// Preview bookings build their eval input without the fraction — read it
|
||||
// off the container type instead of assuming one wagon per box.
|
||||
try {
|
||||
const ct = await this.containerTypesService.findById(
|
||||
container.containerTypeId,
|
||||
);
|
||||
perUnit = wagonsPerUnitForSize(Number(ct.sizeFt));
|
||||
} catch {
|
||||
perUnit = 1; // unknown type: never under-bill
|
||||
}
|
||||
}
|
||||
return Math.max(1, Math.ceil(container.quantity * perUnit));
|
||||
}
|
||||
|
||||
/**
|
||||
* Wagon count for PER_WAGON rates. A persisted booking uses the SQL aggregate;
|
||||
* an unsaved preview booking (no id) sums the wagonsRequired already computed
|
||||
|
||||
@@ -14,7 +14,8 @@ export interface IRatesRepository {
|
||||
findLiveRatesDetailed(): Promise<Rate[]>;
|
||||
findByPattern(pattern: {
|
||||
rateType: string;
|
||||
rateUnit: string;
|
||||
/** Omitted for singly-resolved rates — see the repository implementation. */
|
||||
rateUnit?: string;
|
||||
containerTypeId?: string | null;
|
||||
cargoTypeId?: string | null;
|
||||
tradeDirection?: string | null;
|
||||
|
||||
@@ -18,10 +18,17 @@ export class RatesRepository implements IRatesRepository {
|
||||
return this.repo.findOne({ where: { id } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Every LIVE rate, newest first. The ordering is load-bearing: pricing picks
|
||||
* the first match for a pattern, so without it Postgres heap order decided
|
||||
* which of two overlapping rates a booking was billed at. Newest-first also
|
||||
* means the most recent configuration wins where legacy overlaps still exist.
|
||||
*/
|
||||
findLiveRates(): Promise<Rate[]> {
|
||||
return this.repo
|
||||
.createQueryBuilder('rate')
|
||||
.where('rate.status = :status', { status: 'LIVE' })
|
||||
.orderBy('rate.created_at', 'DESC')
|
||||
.getMany();
|
||||
}
|
||||
|
||||
@@ -47,9 +54,21 @@ export class RatesRepository implements IRatesRepository {
|
||||
* insert so the admin gets a friendly error instead of a raw constraint fault.
|
||||
* NULL scope columns are matched with IS NULL, mirroring the COALESCE index.
|
||||
*/
|
||||
/**
|
||||
* The live/draft rate already covering a pricing pattern, if any.
|
||||
*
|
||||
* `rateUnit` is optional on purpose. Where pricing resolves ONE rate for a
|
||||
* lane (base freight, customs, lashing, empty return) the unit is not part of
|
||||
* the identity — a per-container and a per-wagon row for the same lane are
|
||||
* two answers to one question and the engine picks whichever came back first,
|
||||
* so the caller omits it and the second row is rejected. Additive surcharges
|
||||
* (hazard, reefer, demurrage…) are the opposite: the engine bills every
|
||||
* matching rate by its own unit, so one per freight shape is the design and
|
||||
* the caller passes the unit to keep them apart.
|
||||
*/
|
||||
findByPattern(pattern: {
|
||||
rateType: string;
|
||||
rateUnit: string;
|
||||
rateUnit?: string;
|
||||
containerTypeId?: string | null;
|
||||
cargoTypeId?: string | null;
|
||||
tradeDirection?: string | null;
|
||||
@@ -59,9 +78,12 @@ export class RatesRepository implements IRatesRepository {
|
||||
const qb = this.repo
|
||||
.createQueryBuilder('rate')
|
||||
.where('rate.rate_type = :rateType', { rateType: pattern.rateType })
|
||||
.andWhere('rate.rate_unit = :rateUnit', { rateUnit: pattern.rateUnit })
|
||||
.andWhere('rate.status <> :superseded', { superseded: 'SUPERSEDED' });
|
||||
|
||||
if (pattern.rateUnit) {
|
||||
qb.andWhere('rate.rate_unit = :rateUnit', { rateUnit: pattern.rateUnit });
|
||||
}
|
||||
|
||||
if (pattern.containerTypeId) {
|
||||
qb.andWhere('rate.container_type_id = :containerTypeId', { containerTypeId: pattern.containerTypeId });
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import { ConflictException } from '@nestjs/common';
|
||||
|
||||
import { RatesService } from './rates.service';
|
||||
import type { Rate } from '../entities/rate.entity';
|
||||
|
||||
/**
|
||||
* One rate per lane + scope, whatever the unit.
|
||||
*
|
||||
* Pricing resolves a single rate for a (type, container type, leg) and then
|
||||
* applies whatever unit it carries — it has no way to choose between a
|
||||
* per-container and a per-wagon row for the same 20ft lane, and used to bill
|
||||
* whichever the database happened to return first. So the unit is NOT part of a
|
||||
* rate's identity: changing how a lane is billed means editing its rate.
|
||||
*/
|
||||
describe('RatesService — one rate per pattern', () => {
|
||||
const DJ = '11111111-1111-4000-8000-000000000001';
|
||||
const ET = '11111111-1111-4000-8000-000000000002';
|
||||
const CT20 = '11111111-1111-4000-8000-000000000003';
|
||||
|
||||
const existing = (over: Partial<Rate> = {}): Rate =>
|
||||
({
|
||||
id: 'rate-existing',
|
||||
rateType: 'CONTAINER_IMPORT',
|
||||
rateUnit: 'PER_WAGON',
|
||||
rateValue: 1690,
|
||||
containerTypeId: CT20,
|
||||
cargoTypeId: null,
|
||||
tradeDirection: 'IMPORT',
|
||||
originYardId: DJ,
|
||||
destinationYardId: ET,
|
||||
status: 'LIVE',
|
||||
...over,
|
||||
}) as Rate;
|
||||
|
||||
const dto = {
|
||||
appliesTo: 'CONTAINER',
|
||||
trigger: 'ALWAYS',
|
||||
tradeDirection: 'IMPORT',
|
||||
containerTypeId: CT20,
|
||||
originYardId: DJ,
|
||||
destinationYardId: ET,
|
||||
rateValue: 845,
|
||||
rateUnit: 'PER_CONTAINER',
|
||||
};
|
||||
|
||||
let repository: { findByPattern: jest.Mock; create: jest.Mock };
|
||||
let service: RatesService;
|
||||
|
||||
beforeEach(() => {
|
||||
repository = {
|
||||
findByPattern: jest.fn().mockResolvedValue(null),
|
||||
create: jest.fn(async (r) => ({ id: 'rate-new', ...r })),
|
||||
};
|
||||
service = new RatesService(
|
||||
repository as never,
|
||||
{
|
||||
findById: jest.fn(async (id: string) => ({
|
||||
id,
|
||||
country: id === DJ ? 'Djibouti' : 'Ethiopia',
|
||||
label: id === DJ ? 'Doraleh' : 'Gelan',
|
||||
})),
|
||||
} as never,
|
||||
{ findById: jest.fn().mockResolvedValue(null) } as never,
|
||||
);
|
||||
});
|
||||
|
||||
it('refuses a second rate on the same lane that only differs by unit', async () => {
|
||||
repository.findByPattern.mockResolvedValue(existing());
|
||||
|
||||
await expect(service.create(dto as never, 'staff-1')).rejects.toBeInstanceOf(
|
||||
ConflictException,
|
||||
);
|
||||
expect(repository.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('looks the pattern up without the unit, so either order collides', async () => {
|
||||
await service.create(dto as never, 'staff-1');
|
||||
|
||||
const pattern = repository.findByPattern.mock.calls[0][0];
|
||||
expect(pattern).not.toHaveProperty('rateUnit');
|
||||
expect(pattern).toMatchObject({
|
||||
rateType: 'CONTAINER_IMPORT',
|
||||
containerTypeId: CT20,
|
||||
originYardId: DJ,
|
||||
destinationYardId: ET,
|
||||
});
|
||||
});
|
||||
|
||||
it('still allows the same unit on a different lane', async () => {
|
||||
await service.create(dto as never, 'staff-1');
|
||||
expect(repository.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
rateUnit: 'PER_CONTAINER',
|
||||
rateValue: 845,
|
||||
status: 'DRAFT',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Additive surcharges are billed per matching rate, each by its own unit, so
|
||||
* hazard is legitimately per-container for boxes AND per-ton for bulk. The
|
||||
* unit stays part of their identity or the second one could never be created.
|
||||
*/
|
||||
it('keeps the unit in the key for an additive surcharge', async () => {
|
||||
await service.create(
|
||||
{
|
||||
appliesTo: 'OTHER',
|
||||
trigger: 'HAZARDOUS',
|
||||
rateValue: 300,
|
||||
rateUnit: 'PER_CONTAINER',
|
||||
} as never,
|
||||
'staff-1',
|
||||
);
|
||||
|
||||
expect(repository.findByPattern.mock.calls[0][0]).toMatchObject({
|
||||
rateType: 'HAZARD_SURCHARGE',
|
||||
rateUnit: 'PER_CONTAINER',
|
||||
});
|
||||
});
|
||||
|
||||
it('treats lashing as singly resolved — one unit per direction', async () => {
|
||||
await service.create(
|
||||
{
|
||||
appliesTo: 'OTHER',
|
||||
trigger: 'LASHING',
|
||||
tradeDirection: 'IMPORT',
|
||||
rateValue: 40,
|
||||
rateUnit: 'PER_TON',
|
||||
} as never,
|
||||
'staff-1',
|
||||
);
|
||||
|
||||
expect(repository.findByPattern.mock.calls[0][0]).not.toHaveProperty(
|
||||
'rateUnit',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -132,6 +132,24 @@ export class RatesService {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when pricing resolves exactly ONE rate for this shape (base freight,
|
||||
* customs clearance, lashing, empty-container return — all `find()`-based
|
||||
* lookups). For those the unit is not part of the rate's identity: two rows
|
||||
* for the same lane differing only by unit are a duplicate the engine cannot
|
||||
* choose between.
|
||||
*
|
||||
* The additive surcharges are the opposite — the engine bills EVERY matching
|
||||
* rate by its own unit, which is how hazard can be per-container for boxes
|
||||
* and per-ton for bulk at the same time — so their unit stays part of the key.
|
||||
*/
|
||||
private resolvesSingleRate(
|
||||
appliesTo: Rate['appliesTo'],
|
||||
trigger: Rate['trigger'],
|
||||
): boolean {
|
||||
return this.isRouteScoped(appliesTo, trigger) || trigger === 'LASHING';
|
||||
}
|
||||
|
||||
/**
|
||||
* Which country each end of the leg must sit in, given what the rate is for.
|
||||
* The railway only sells three shapes: import lands at the Djibouti ports and
|
||||
@@ -326,10 +344,17 @@ export class RatesService {
|
||||
* Reject a second rate with the same identity pattern (rateType + scope). With
|
||||
* effective-date windows gone, two LIVE/DRAFT rates for the same pattern would
|
||||
* make pricing ambiguous — so we allow exactly one per pattern.
|
||||
*
|
||||
* The UNIT is not part of that identity. Pricing resolves one rate per lane +
|
||||
* scope and then applies whatever unit it carries; a per-container and a
|
||||
* per-wagon row for the same 20ft lane are two answers to one question, and
|
||||
* the engine silently picked one of them. Changing how a lane is billed means
|
||||
* editing its rate, not adding a second.
|
||||
*/
|
||||
private async assertNoDuplicatePattern(pattern: {
|
||||
rateType: string;
|
||||
rateUnit: string;
|
||||
/** Passed only for additive surcharges — see {@link resolvesSingleRate}. */
|
||||
rateUnit?: string;
|
||||
containerTypeId: string | null;
|
||||
cargoTypeId: string | null;
|
||||
tradeDirection: string | null;
|
||||
@@ -415,7 +440,7 @@ export class RatesService {
|
||||
|
||||
await this.assertNoDuplicatePattern({
|
||||
rateType,
|
||||
rateUnit,
|
||||
...(this.resolvesSingleRate(appliesTo, trigger) ? {} : { rateUnit }),
|
||||
containerTypeId,
|
||||
cargoTypeId,
|
||||
tradeDirection,
|
||||
@@ -600,7 +625,7 @@ export class RatesService {
|
||||
// Guard the pattern uniqueness for the new identity, ignoring this row.
|
||||
await this.assertNoDuplicatePattern({
|
||||
rateType,
|
||||
rateUnit,
|
||||
...(this.resolvesSingleRate(appliesTo, trigger) ? {} : { rateUnit }),
|
||||
containerTypeId: updates.containerTypeId,
|
||||
cargoTypeId: updates.cargoTypeId,
|
||||
tradeDirection: updates.tradeDirection,
|
||||
|
||||
@@ -72,12 +72,19 @@ export function computeGlShipmentTotal(
|
||||
(i) => i.containerSize === line.containerSize && !i.isClearance,
|
||||
);
|
||||
if (rate) {
|
||||
// A per-wagon rate bills the wagons this line occupies — two 20ft share
|
||||
// one — not the box count. Mirrors the API's base-freight line so the
|
||||
// quote matches the invoice.
|
||||
const billedQty =
|
||||
rate.unit === "per_wagon"
|
||||
? Math.ceil(qty * (line.containerSize === "40ft" ? 1 : 0.5))
|
||||
: qty;
|
||||
lines.push({
|
||||
label: rate.label,
|
||||
unitPrice: rate.unitPrice,
|
||||
unit: rate.unit,
|
||||
quantity: qty,
|
||||
amount: rate.unitPrice * qty,
|
||||
quantity: billedQty,
|
||||
amount: rate.unitPrice * billedQty,
|
||||
});
|
||||
}
|
||||
hazardTotalQty += line.hazardousQuantity;
|
||||
|
||||
@@ -116,8 +116,8 @@ const FreightDashboardHeader = ({
|
||||
</Group>
|
||||
|
||||
{/* Right: actions + avatar. The doc-review alarm leads the group — it
|
||||
only renders in the last half of a review phase that still has
|
||||
undecided requests, so it never competes for space otherwise. */}
|
||||
renders only during a review phase that still has undecided
|
||||
requests, so it never competes for space otherwise. */}
|
||||
<Group gap={10} wrap="nowrap" align="center">
|
||||
<DocReviewAlertButton />
|
||||
|
||||
|
||||
@@ -391,6 +391,12 @@ const RuleEngineFormDialog = ({
|
||||
// display order) is a non-negative magnitude — reject negatives outright
|
||||
// rather than letting a typed "-" reach the API.
|
||||
min={isNumber ? 0 : undefined}
|
||||
// Without an explicit step the browser assumes 1 and refuses to submit
|
||||
// anything fractional — which blocked decimal rates, tonnages and
|
||||
// distances. The API is the one that decides which of these are whole
|
||||
// numbers (@IsInt on points/sizes/order, @IsNumber on money, tons, km),
|
||||
// so let the field carry decimals and let a 400 catch the rest.
|
||||
step={isNumber ? "any" : undefined}
|
||||
disabled={field.disabled || computed !== undefined}
|
||||
value={String((computed !== undefined ? computed : values[field.name]) ?? "")}
|
||||
onChange={(e) => {
|
||||
|
||||
@@ -36,11 +36,12 @@ function formatRemaining(ms: number): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Header alarm for the document-review deadline. Appears only once the review
|
||||
* phase is half spent AND requests are still undecided — everything still
|
||||
* pending when the clock runs out is expired automatically, so this is the last
|
||||
* call to accept or reject. Clicking opens the booking requests already
|
||||
* filtered to those undecided import requests.
|
||||
* Header alarm for the document-review deadline. Runs for the whole review
|
||||
* phase — from the moment it opens until the clock runs out — whenever requests
|
||||
* are still undecided. Everything left pending at the deadline is expired
|
||||
* automatically, so staff get the full window to accept or reject rather than
|
||||
* only its back half. Clicking opens the booking requests already filtered to
|
||||
* those undecided requests.
|
||||
*/
|
||||
export default function DocReviewAlertButton() {
|
||||
const navigate = useNavigate();
|
||||
@@ -67,10 +68,12 @@ export default function DocReviewAlertButton() {
|
||||
}, [deadlineMs]);
|
||||
|
||||
if (!alert) return null;
|
||||
// Half the review phase has to be gone before staff are alarmed — a 30-minute
|
||||
// review warns with 15 minutes left.
|
||||
const halfMs = (Math.max(alert.docReviewMinutes, 1) * 60_000) / 2;
|
||||
if (remaining <= 0 || remaining > halfMs) return null;
|
||||
// Alarm for the WHOLE review phase, from the moment it opens: anything still
|
||||
// undecided when the clock runs out is expired automatically, so staff need
|
||||
// the full window to act, not the back half of it. The endpoint only returns
|
||||
// a schedule that is in DOC_REVIEW with undecided requests behind it, so the
|
||||
// remaining check just hides the pill once the deadline passes.
|
||||
if (remaining <= 0) return null;
|
||||
|
||||
const requestLabel = alert.pendingCount === 1 ? "request" : "requests";
|
||||
|
||||
@@ -85,7 +88,16 @@ export default function DocReviewAlertButton() {
|
||||
<UnstyledButton
|
||||
onClick={() => navigate(pendingRequestsHref(alert.tradeDirection))}
|
||||
aria-label={`${alert.pendingCount} import booking ${requestLabel} awaiting a decision — document review ends in ${formatRemaining(remaining)}`}
|
||||
className="group flex h-9 shrink-0 items-center gap-2 rounded-full border border-red-600/60 bg-red-600 pl-2.5 pr-2 text-white shadow-[0_2px_10px_rgba(220,38,38,0.35)] transition-transform hover:scale-[1.02] hover:bg-red-700"
|
||||
className="group flex h-9 shrink-0 items-center gap-2 rounded-full pl-2.5 pr-2 transition-transform hover:scale-[1.02]"
|
||||
// Inline, not Tailwind: Mantine's UnstyledButton resets the background
|
||||
// in unlayered CSS, which beats a `@layer utilities` class whatever its
|
||||
// specificity — `bg-red-600` alone renders the pill white.
|
||||
style={{
|
||||
background: "var(--mantine-color-red-6)",
|
||||
border: "1px solid var(--mantine-color-red-7)",
|
||||
color: "#fff",
|
||||
boxShadow: "0 2px 10px rgba(220, 38, 38, 0.35)",
|
||||
}}
|
||||
>
|
||||
{/* Live dot: a ping ring behind a solid core, so the pill reads as
|
||||
active without animating the whole chip. */}
|
||||
|
||||
@@ -112,6 +112,13 @@ const SORT_OPTIONS = [
|
||||
{ value: "contractValidUntil:DESC", label: "Expiring latest" },
|
||||
];
|
||||
|
||||
/** Every column is 120px wide and wraps its content instead of truncating. */
|
||||
const COLUMN_WIDTH = 120;
|
||||
const COLUMN_META = {
|
||||
headerClassName: "whitespace-normal break-words",
|
||||
cellClassName: "whitespace-normal break-words align-top",
|
||||
};
|
||||
|
||||
/** Local start-of-day → ISO, for inclusive "from" date filters. */
|
||||
function startOfDayIso(d: Date): string {
|
||||
const x = new Date(d);
|
||||
@@ -260,6 +267,8 @@ export default function ContractRequestsPage() {
|
||||
const columns: ColumnDef<ContractListRow>[] = [
|
||||
{
|
||||
id: "contract",
|
||||
size: COLUMN_WIDTH,
|
||||
meta: COLUMN_META,
|
||||
header: () => <span className={bookingTable.headerCell}>Customer</span>,
|
||||
cell: ({ row }) => {
|
||||
const c = row.original;
|
||||
@@ -269,10 +278,8 @@ export default function ContractRequestsPage() {
|
||||
<User className="size-4" strokeWidth={1.75} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium text-foreground">
|
||||
{c.customerLabel}
|
||||
</p>
|
||||
<p className="mt-0.5 flex items-center gap-1 truncate text-xs text-muted-foreground">
|
||||
<p className="font-medium text-foreground">{c.customerLabel}</p>
|
||||
<p className="mt-0.5 flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<FileText className="size-3 shrink-0 opacity-70" />
|
||||
{c.reference}
|
||||
</p>
|
||||
@@ -283,17 +290,17 @@ export default function ContractRequestsPage() {
|
||||
},
|
||||
{
|
||||
id: "route",
|
||||
size: COLUMN_WIDTH,
|
||||
meta: COLUMN_META,
|
||||
header: () => <span className={bookingTable.headerCell}>Route</span>,
|
||||
cell: ({ row }) => {
|
||||
const c = row.original;
|
||||
return (
|
||||
<div className="space-y-1 py-1">
|
||||
<div className="flex items-center gap-1.5 text-sm font-medium text-foreground">
|
||||
<span className="max-w-[8rem] truncate">{c.originLabel}</span>
|
||||
<span>{c.originLabel}</span>
|
||||
<ArrowRight className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="max-w-[8rem] truncate">
|
||||
{c.destinationLabel}
|
||||
</span>
|
||||
<span>{c.destinationLabel}</span>
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
<Badge
|
||||
@@ -315,8 +322,8 @@ export default function ContractRequestsPage() {
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
size: 200,
|
||||
minSize: 180,
|
||||
size: COLUMN_WIDTH,
|
||||
meta: COLUMN_META,
|
||||
header: () => <span className={bookingTable.headerCell}>Status</span>,
|
||||
cell: ({ row }) => (
|
||||
<div className="py-1">
|
||||
@@ -326,18 +333,18 @@ export default function ContractRequestsPage() {
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
meta: {
|
||||
headerClassName: "min-w-[11rem]",
|
||||
cellClassName: "min-w-[11rem]",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "approval",
|
||||
size: COLUMN_WIDTH,
|
||||
meta: COLUMN_META,
|
||||
header: () => <span className={bookingTable.headerCell}>Approval</span>,
|
||||
cell: ({ row }) => <ContractApprovalProgressCell row={row.original} />,
|
||||
},
|
||||
{
|
||||
id: "validity",
|
||||
size: COLUMN_WIDTH,
|
||||
meta: COLUMN_META,
|
||||
header: () => <span className={bookingTable.headerCell}>Validity</span>,
|
||||
cell: ({ row }) => {
|
||||
const c = row.original;
|
||||
@@ -362,6 +369,8 @@ export default function ContractRequestsPage() {
|
||||
},
|
||||
{
|
||||
id: "kind",
|
||||
size: COLUMN_WIDTH,
|
||||
meta: COLUMN_META,
|
||||
header: () => <span className={bookingTable.headerCell}>Kind</span>,
|
||||
cell: ({ row }) => {
|
||||
const isGeneral = row.original.contractKind === "GENERAL";
|
||||
@@ -383,6 +392,8 @@ export default function ContractRequestsPage() {
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
size: COLUMN_WIDTH,
|
||||
meta: COLUMN_META,
|
||||
header: () => <span className={bookingTable.headerCell}>Action</span>,
|
||||
cell: ({ row }) => {
|
||||
const action = getStaffRowAction(row.original);
|
||||
@@ -653,7 +664,9 @@ export default function ContractRequestsPage() {
|
||||
manualPagination: true,
|
||||
pageCount,
|
||||
}}
|
||||
containerClassName="border-0 shadow-none bg-transparent"
|
||||
// table-fixed makes the per-column 120px widths stick; without
|
||||
// it auto-layout re-widens columns once cells wrap.
|
||||
containerClassName="border-0 shadow-none bg-transparent [&_table]:table-fixed [&_table]:min-w-[840px]"
|
||||
footer={DataTableFooter}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
@@ -55,12 +55,19 @@ export function computeShipmentTotal(
|
||||
(i) => i.containerSize === line.containerSize && !i.isClearance,
|
||||
);
|
||||
if (rate) {
|
||||
// A per-wagon rate bills the wagons this line occupies — two 20ft share
|
||||
// one — not the box count. Mirrors the API's base-freight line so the
|
||||
// quote matches the invoice.
|
||||
const billedQty =
|
||||
rate.unit === "per_wagon"
|
||||
? Math.ceil(qty * (line.containerSize === "40ft" ? 1 : 0.5))
|
||||
: qty;
|
||||
lines.push({
|
||||
label: rate.label,
|
||||
unitPrice: rate.unitPrice,
|
||||
unit: rate.unit,
|
||||
quantity: qty,
|
||||
amount: rate.unitPrice * qty,
|
||||
quantity: billedQty,
|
||||
amount: rate.unitPrice * billedQty,
|
||||
});
|
||||
}
|
||||
hazardTotalQty += Number(line.hazardousQuantity || 0);
|
||||
|
||||
Reference in New Issue
Block a user