mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 01:48:12 +00:00
add company stamp upload functionality for contract signing
- Introduced StampUpload component for uploading company stamp images. - Integrated stamp upload in contract signing modal, supporting PNG and JPG formats. - Implemented validation for file type and size (max 5 MB). - Added visual feedback for drag-and-drop functionality. - Updated contract-related pages to handle duplicate contract alerts and pricing notices. - Enhanced contract expiry management with a nightly sweep service. - Added unit tests for new features and updated existing tests for contract handling.
This commit is contained in:
@@ -473,3 +473,124 @@ describe('BookingPricingService — customs clearance fee billed on the booking
|
||||
expect(result.lineItems.some((l) => l.code.startsWith('CUSTOMS_CLEARANCE'))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Bulk freight bills in the commodity's own unit: tonnage for a weighed
|
||||
* commodity (PER_TON), item count for a counted one (PER_ITEM). Both read the
|
||||
* booking's cargo amount; PER_WAGON bills the wagons the cargo occupies.
|
||||
*/
|
||||
describe('BookingPricingService — bulk base freight units', () => {
|
||||
const DJ = 'yard-dj-bulk';
|
||||
const DIRE_B = 'yard-dire-bulk';
|
||||
|
||||
const bulkRate = (overrides: Partial<Rate> = {}): Rate =>
|
||||
({
|
||||
id: 'rate-bulk',
|
||||
rateType: 'BULK_IMPORT',
|
||||
appliesTo: 'BULK',
|
||||
trigger: 'ALWAYS',
|
||||
currency: 'USD',
|
||||
rateValue: 200,
|
||||
rateUnit: 'PER_ITEM',
|
||||
status: 'LIVE',
|
||||
containerTypeId: null,
|
||||
cargoTypeId: null,
|
||||
tradeDirection: 'IMPORT',
|
||||
originYardId: DJ,
|
||||
destinationYardId: DIRE_B,
|
||||
...overrides,
|
||||
}) as Rate;
|
||||
|
||||
const makeService = (liveRates: Rate[], wagonCapacity?: number) =>
|
||||
new BookingPricingService(
|
||||
{
|
||||
calculateWagonCount: jest.fn().mockResolvedValue(0),
|
||||
findContractRateSnapshots: jest.fn().mockResolvedValue([]),
|
||||
} as never,
|
||||
{
|
||||
evaluate: jest.fn().mockResolvedValue({
|
||||
priorityScore: 0,
|
||||
appliedModifiers: [],
|
||||
containerWeightResults: [],
|
||||
warnings: [],
|
||||
hardBlocked: [],
|
||||
requiresDirectorApproval: false,
|
||||
}),
|
||||
} as never,
|
||||
{ findById: jest.fn() } as never,
|
||||
{ findLiveRates: jest.fn().mockResolvedValue(liveRates) } as never,
|
||||
{ getRate: jest.fn().mockResolvedValue(MOCK_CBE_RATE) } as never,
|
||||
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
|
||||
{
|
||||
findById: jest.fn().mockResolvedValue({
|
||||
wagonTypes: wagonCapacity !== undefined ? [{ capacityTons: wagonCapacity }] : [],
|
||||
}),
|
||||
} as never,
|
||||
);
|
||||
|
||||
// 12 machines, not 12 tonnes — a PER_ITEM commodity records its count here.
|
||||
const booking = (overrides: Record<string, unknown> = {}) =>
|
||||
({
|
||||
id: 'b-bulk',
|
||||
freightType: 'BULK',
|
||||
tradeDirection: 'IMPORT',
|
||||
paymentCurrency: 'USD',
|
||||
cargoTypeId: 'cargo-machinery',
|
||||
cargoTotalWeightVgm: 12,
|
||||
originYardId: DJ,
|
||||
destinationYardId: DIRE_B,
|
||||
bookingContainers: [],
|
||||
...overrides,
|
||||
}) as unknown as Booking;
|
||||
|
||||
it('bills a PER_ITEM rate on the item count', async () => {
|
||||
const result = await makeService([bulkRate()]).computePriceForBooking(booking());
|
||||
|
||||
const line = result.lineItems.find((l) => l.code === 'BULK_IMPORT');
|
||||
expect(line!.unit).toBe('PER_ITEM');
|
||||
expect(line!.quantity).toBe(12);
|
||||
expect(line!.amount).toBe(2400);
|
||||
});
|
||||
|
||||
it('bills a PER_TON rate on the tonnage', async () => {
|
||||
const result = await makeService([
|
||||
bulkRate({ rateUnit: 'PER_TON', rateValue: 35 }),
|
||||
]).computePriceForBooking(booking({ cargoTotalWeightVgm: 120 }));
|
||||
|
||||
const line = result.lineItems.find((l) => l.code === 'BULK_IMPORT');
|
||||
expect(line!.unit).toBe('PER_TON');
|
||||
expect(line!.amount).toBe(35 * 120);
|
||||
});
|
||||
|
||||
it('bills a PER_WAGON rate on the wagons the cargo occupies, not zero', async () => {
|
||||
const result = await makeService(
|
||||
[bulkRate({ rateUnit: 'PER_WAGON', rateValue: 500 })],
|
||||
60,
|
||||
).computePriceForBooking(booking({ cargoTotalWeightVgm: 120 }));
|
||||
|
||||
const line = result.lineItems.find((l) => l.code === 'BULK_IMPORT');
|
||||
expect(line!.unit).toBe('PER_WAGON');
|
||||
expect(line!.quantity).toBe(2); // 120 t ÷ 60 t per wagon
|
||||
expect(line!.amount).toBe(1000);
|
||||
});
|
||||
|
||||
it('prices off the rate scoped to the booking commodity, not another one', async () => {
|
||||
const result = await makeService([
|
||||
bulkRate({ id: 'rate-wheat', cargoTypeId: 'cargo-wheat', rateUnit: 'PER_TON', rateValue: 35 }),
|
||||
bulkRate({ id: 'rate-machinery', cargoTypeId: 'cargo-machinery', rateValue: 200 }),
|
||||
]).computePriceForBooking(booking());
|
||||
|
||||
const line = result.lineItems.find((l) => l.code === 'BULK_IMPORT');
|
||||
expect(line!.unit).toBe('PER_ITEM');
|
||||
expect(line!.amount).toBe(2400);
|
||||
});
|
||||
|
||||
it('hard-blocks when the leg only carries another commodity’s rate', async () => {
|
||||
const result = await makeService([
|
||||
bulkRate({ id: 'rate-wheat', cargoTypeId: 'cargo-wheat' }),
|
||||
]).computePriceForBooking(booking());
|
||||
|
||||
expect(result.lineItems.some((l) => l.code === 'BULK_IMPORT')).toBe(false);
|
||||
expect(result.hardBlocked.some((m) => m.includes('rate is configured'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import { CargoTypesService } from '../rule-engine/services/cargo-types.service';
|
||||
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
|
||||
import { RatesService } from '../rule-engine/services/rates.service';
|
||||
import { Rate } from '../rule-engine/entities/rate.entity';
|
||||
import { isBulkQuantityUnit } from '../rule-engine/entities/rate-unit.util';
|
||||
import { ContractRateSnapshot } from '../contracts/entities/contract-rate-snapshot.entity';
|
||||
import { ExchangeService } from '@edr/api-common';
|
||||
import {
|
||||
@@ -529,7 +530,13 @@ export class BookingPricingService {
|
||||
const usedRatesMap = new Map<string, Rate>();
|
||||
const warnings: string[] = [];
|
||||
const blocked: string[] = [];
|
||||
const wagonCount = await this.resolveWagonCount(booking);
|
||||
// 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(
|
||||
@@ -600,7 +607,10 @@ export class BookingPricingService {
|
||||
// container type above or stay unpriced with a warning — falling back to
|
||||
// a corridor rate of a DIFFERENT container type billed once (qty 1) is
|
||||
// how a 38-container booking was invoiced 40 USD instead of 1900.
|
||||
const fallback = liveRates.find(
|
||||
// Within the leg, the rate scoped to the booking's own commodity wins over
|
||||
// the commodity-wide catch-all — a per-item machinery rate must never
|
||||
// price a per-ton wheat booking (or the reverse).
|
||||
const onLeg = liveRates.filter(
|
||||
(r) =>
|
||||
r.rateType === rateType &&
|
||||
r.currency === 'USD' &&
|
||||
@@ -608,11 +618,17 @@ export class BookingPricingService {
|
||||
r.originYardId === booking.originYardId &&
|
||||
r.destinationYardId === booking.destinationYardId,
|
||||
);
|
||||
const fallback =
|
||||
(booking.cargoTypeId
|
||||
? onLeg.find((r) => r.cargoTypeId === booking.cargoTypeId)
|
||||
: undefined) ?? onLeg.find((r) => !r.cargoTypeId);
|
||||
if (fallback) {
|
||||
usedRatesMap.set(fallback.id, fallback);
|
||||
const bulkTons = Number(booking.cargoTotalWeightVgm ?? 0);
|
||||
// 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);
|
||||
const quantity =
|
||||
isBulk && fallback.rateUnit === 'PER_TON' ? Math.max(bulkTons, 0) : 1;
|
||||
isBulk && isBulkQuantityUnit(fallback.rateUnit) ? Math.max(bulkQuantity, 0) : 1;
|
||||
const unitUsd = Number(fallback.rateValue);
|
||||
// H15: bulk freight uses the frozen BULK_FREIGHT snapshot when present.
|
||||
const frozen = isBulk
|
||||
@@ -719,6 +735,7 @@ export class BookingPricingService {
|
||||
quantity = containerCount;
|
||||
break;
|
||||
case 'PER_TON':
|
||||
case 'PER_ITEM':
|
||||
quantity = bulkTons;
|
||||
break;
|
||||
case 'FLAT':
|
||||
@@ -804,6 +821,7 @@ export class BookingPricingService {
|
||||
return 1;
|
||||
case 'PER_CONTAINER':
|
||||
case 'PER_TON':
|
||||
case 'PER_ITEM':
|
||||
default:
|
||||
return quantity;
|
||||
}
|
||||
@@ -860,6 +878,7 @@ export class BookingPricingService {
|
||||
case 'PER_WAGON':
|
||||
return unitValue * wagonCount;
|
||||
case 'PER_TON':
|
||||
case 'PER_ITEM':
|
||||
return unitValue * quantity;
|
||||
case 'FLAT':
|
||||
return unitValue;
|
||||
@@ -1044,7 +1063,7 @@ export class BookingPricingService {
|
||||
const unit = frozen ? this.rateUnitFromSnapshot(frozen.unitOfMeasure) : live!.rateUnit;
|
||||
const unitAmount = frozen ? Number(frozen.unitPrice) : convert(Number(live!.rateValue));
|
||||
let billedQty = 1;
|
||||
if (unit === 'PER_TON') {
|
||||
if (isBulkQuantityUnit(unit)) {
|
||||
billedQty = Math.max(0, Number(booking.cargoTotalWeightVgm ?? 0));
|
||||
} else if (unit === 'PER_WAGON') {
|
||||
const wagons = await this.bulkWagonCount(booking);
|
||||
@@ -1081,6 +1100,8 @@ export class BookingPricingService {
|
||||
return 'PER_WAGON';
|
||||
case 'per_ton':
|
||||
return 'PER_TON';
|
||||
case 'per_item':
|
||||
return 'PER_ITEM';
|
||||
case 'per_container':
|
||||
return 'PER_CONTAINER';
|
||||
default:
|
||||
|
||||
Reference in New Issue
Block a user