mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 00:10:57 +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:
@@ -0,0 +1,70 @@
|
||||
import { allowedRateUnits, isBulkQuantityUnit } from "./rate-unit.util";
|
||||
|
||||
/**
|
||||
* A bulk rate's weighting unit follows how its commodity is counted: wheat is
|
||||
* weighed (per ton), machinery is counted (per item). Per-wagon is offered
|
||||
* either way.
|
||||
*/
|
||||
describe("allowedRateUnits — bulk unit of measure", () => {
|
||||
it("offers per-ton for a weighed commodity", () => {
|
||||
expect(
|
||||
allowedRateUnits({
|
||||
appliesTo: "BULK",
|
||||
trigger: "ALWAYS",
|
||||
cargoUnitOfMeasure: "PER_TON",
|
||||
}),
|
||||
).toEqual(["PER_TON", "PER_WAGON"]);
|
||||
});
|
||||
|
||||
it("offers per-item for a counted commodity", () => {
|
||||
expect(
|
||||
allowedRateUnits({
|
||||
appliesTo: "BULK",
|
||||
trigger: "ALWAYS",
|
||||
cargoUnitOfMeasure: "PER_ITEM",
|
||||
}),
|
||||
).toEqual(["PER_ITEM", "PER_WAGON"]);
|
||||
});
|
||||
|
||||
it("falls back to per-ton when the rate is not scoped to a commodity", () => {
|
||||
expect(allowedRateUnits({ appliesTo: "BULK", trigger: "ALWAYS" })).toEqual([
|
||||
"PER_TON",
|
||||
"PER_WAGON",
|
||||
]);
|
||||
});
|
||||
|
||||
it("swaps the per-ton slot for counted commodities on every bulk-capable shape", () => {
|
||||
expect(
|
||||
allowedRateUnits({
|
||||
appliesTo: "OTHER",
|
||||
trigger: "CUSTOMS_CLEARANCE",
|
||||
cargoKind: "BULK",
|
||||
cargoUnitOfMeasure: "PER_ITEM",
|
||||
}),
|
||||
).toEqual(["PER_ITEM", "PER_WAGON"]);
|
||||
expect(
|
||||
allowedRateUnits({
|
||||
appliesTo: "INTERCITY",
|
||||
trigger: "ALWAYS",
|
||||
cargoUnitOfMeasure: "PER_ITEM",
|
||||
}),
|
||||
).toEqual(["PER_CONTAINER", "PER_ITEM", "PER_WAGON", "PER_KM"]);
|
||||
});
|
||||
|
||||
it("never offers per-item for overweight, which is always per excess ton", () => {
|
||||
expect(
|
||||
allowedRateUnits({
|
||||
appliesTo: "OTHER",
|
||||
trigger: "OVERWEIGHT",
|
||||
cargoUnitOfMeasure: "PER_TON",
|
||||
}),
|
||||
).toEqual(["PER_TON"]);
|
||||
});
|
||||
|
||||
it("treats per-ton and per-item as the same booking quantity", () => {
|
||||
expect(isBulkQuantityUnit("PER_TON")).toBe(true);
|
||||
expect(isBulkQuantityUnit("PER_ITEM")).toBe(true);
|
||||
expect(isBulkQuantityUnit("PER_WAGON")).toBe(false);
|
||||
expect(isBulkQuantityUnit("FLAT")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,17 @@
|
||||
import type { RateAppliesTo, RateTrigger, RateUnit } from './rate.entity';
|
||||
|
||||
/** How the bulk commodity a rate is scoped to is counted (cargo_types.unit_of_measure). */
|
||||
export type CargoUom = 'PER_TON' | 'PER_ITEM' | null | undefined;
|
||||
|
||||
/**
|
||||
* Units billed against a booking's bulk quantity. That quantity is recorded in
|
||||
* the commodity's own unit — tonnes for a PER_TON commodity, item count for a
|
||||
* PER_ITEM one — so both units scale off the same field and only differ in what
|
||||
* they are called.
|
||||
*/
|
||||
export const isBulkQuantityUnit = (unit: string): boolean =>
|
||||
unit === 'PER_TON' || unit === 'PER_ITEM';
|
||||
|
||||
/**
|
||||
* Which rate units make sense for a given rate shape. The weighting basis is
|
||||
* driven by the *type* of thing being billed — a container leg bills per
|
||||
@@ -8,6 +20,10 @@ import type { RateAppliesTo, RateTrigger, RateUnit } from './rate.entity';
|
||||
* ton. This keeps the rate table dynamic yet non-conflicting: the admin can
|
||||
* only pick a unit the pricing engine knows how to apply.
|
||||
*
|
||||
* A rate scoped to a break-bulk commodity (unit_of_measure = PER_ITEM) offers
|
||||
* PER_ITEM wherever a weighed commodity offers PER_TON — machinery is priced
|
||||
* per unit shipped, wheat per tonne. Per-wagon is offered either way.
|
||||
*
|
||||
* Returned lists are ordered with the most natural/default unit first.
|
||||
*/
|
||||
export function allowedRateUnits(input: {
|
||||
@@ -15,6 +31,19 @@ export function allowedRateUnits(input: {
|
||||
trigger: RateTrigger;
|
||||
/** CUSTOMS_CLEARANCE only: which cargo kind the fee covers. */
|
||||
cargoKind?: 'CONTAINER' | 'BULK' | null;
|
||||
/** Unit of measure of the bulk commodity the rate is scoped to, when any. */
|
||||
cargoUnitOfMeasure?: CargoUom;
|
||||
}): RateUnit[] {
|
||||
const units = unitsForShape(input);
|
||||
return input.cargoUnitOfMeasure === 'PER_ITEM'
|
||||
? units.map((u) => (u === 'PER_TON' ? 'PER_ITEM' : u))
|
||||
: units;
|
||||
}
|
||||
|
||||
function unitsForShape(input: {
|
||||
appliesTo: RateAppliesTo;
|
||||
trigger: RateTrigger;
|
||||
cargoKind?: 'CONTAINER' | 'BULK' | null;
|
||||
}): RateUnit[] {
|
||||
const { appliesTo, trigger } = input;
|
||||
|
||||
@@ -81,6 +110,7 @@ export function isRateUnitAllowed(input: {
|
||||
appliesTo: RateAppliesTo;
|
||||
trigger: RateTrigger;
|
||||
cargoKind?: 'CONTAINER' | 'BULK' | null;
|
||||
cargoUnitOfMeasure?: CargoUom;
|
||||
unit: RateUnit;
|
||||
}): boolean {
|
||||
return allowedRateUnits(input).includes(input.unit);
|
||||
|
||||
@@ -34,6 +34,9 @@ export type RateStatus = typeof RATE_STATUSES[number];
|
||||
export const RATE_UNITS = [
|
||||
'PER_WAGON',
|
||||
'PER_TON',
|
||||
// Break-bulk commodities are counted, not weighed (cargo_types.unit_of_measure
|
||||
// = PER_ITEM) — their rates bill per item off the same booking quantity field.
|
||||
'PER_ITEM',
|
||||
'PER_CONTAINER',
|
||||
'PER_KM',
|
||||
'PER_INVOICE',
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Inject, Injectable, BadRequestException } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.entity';
|
||||
import { Rate, RateTrigger } from './entities/rate.entity';
|
||||
import { isBulkQuantityUnit } from './entities/rate-unit.util';
|
||||
import {
|
||||
ICargoTypesRepository,
|
||||
CARGO_TYPES_REPOSITORY,
|
||||
@@ -377,6 +378,9 @@ export class RuleEngineService {
|
||||
let calculatedAmount: number;
|
||||
|
||||
switch (rate.rateUnit) {
|
||||
// PER_ITEM is PER_TON for a counted (break-bulk) commodity — the bulk
|
||||
// quantity is recorded in the commodity's own unit either way.
|
||||
case 'PER_ITEM':
|
||||
case 'PER_TON':
|
||||
// OVERWEIGHT bills the excess tons; every other PER_TON surcharge
|
||||
// (e.g. bulk reefer) bills the full bulk tonnage.
|
||||
@@ -608,7 +612,7 @@ export class RuleEngineService {
|
||||
if (!rate) return modifiers;
|
||||
|
||||
const billedQty =
|
||||
rate.rateUnit === 'PER_TON'
|
||||
isBulkQuantityUnit(rate.rateUnit)
|
||||
? Math.max(0, Number(input.bulkTons ?? 0))
|
||||
: rate.rateUnit === 'PER_WAGON'
|
||||
? Math.max(0, Number(input.bulkWagons ?? 0))
|
||||
|
||||
@@ -12,7 +12,11 @@ import { ListRatesQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||
import { UpdateRateDto } from '../dto/update-rate.dto';
|
||||
import { Rate } from '../entities/rate.entity';
|
||||
import { deriveRateType } from '../entities/rate-type.util';
|
||||
import { allowedRateUnits, isRateUnitAllowed } from '../entities/rate-unit.util';
|
||||
import { CargoUom, allowedRateUnits, isRateUnitAllowed } from '../entities/rate-unit.util';
|
||||
import {
|
||||
CARGO_TYPES_REPOSITORY,
|
||||
ICargoTypesRepository,
|
||||
} from '../interfaces/cargo-types.repository.interface';
|
||||
import { IRatesRepository, RATES_REPOSITORY } from '../interfaces/rates.repository.interface';
|
||||
import { IYardsRepository, YARDS_REPOSITORY } from '../interfaces/yards.repository.interface';
|
||||
|
||||
@@ -32,6 +36,8 @@ export class RatesService {
|
||||
private readonly repository: IRatesRepository,
|
||||
@Inject(YARDS_REPOSITORY)
|
||||
private readonly yardsRepository: IYardsRepository,
|
||||
@Inject(CARGO_TYPES_REPOSITORY)
|
||||
private readonly cargoTypesRepository: ICargoTypesRepository,
|
||||
) {}
|
||||
|
||||
/** List rates — standard paginated envelope with server-side search. */
|
||||
@@ -63,25 +69,37 @@ export class RatesService {
|
||||
* Normalise + validate the weighting unit for a rate shape. Overweight is
|
||||
* always billed per excess ton, so its unit is forced to PER_TON regardless
|
||||
* of what the client sent. Every other shape must pick a unit the pricing
|
||||
* engine can actually apply (see `allowedRateUnits`).
|
||||
* engine can actually apply (see `allowedRateUnits`) — for a rate scoped to a
|
||||
* bulk commodity that means the commodity's own unit of measure: a PER_ITEM
|
||||
* commodity bills per item where a weighed one bills per ton.
|
||||
*/
|
||||
private resolveRateUnit(
|
||||
private async resolveRateUnit(
|
||||
appliesTo: Rate['appliesTo'],
|
||||
trigger: Rate['trigger'],
|
||||
requestedUnit: Rate['rateUnit'] | undefined,
|
||||
cargoKind?: 'CONTAINER' | 'BULK' | null,
|
||||
): Rate['rateUnit'] {
|
||||
cargoTypeId?: string | null,
|
||||
): Promise<Rate['rateUnit']> {
|
||||
// Overweight is per-ton, full stop — the admin form hides the unit field
|
||||
// for it and omits rateUnit from the payload entirely.
|
||||
if (trigger === 'OVERWEIGHT') return 'PER_TON';
|
||||
|
||||
const allowed = allowedRateUnits({ appliesTo, trigger, cargoKind });
|
||||
const cargoUnitOfMeasure = await this.cargoUnitOfMeasure(cargoTypeId);
|
||||
const allowed = allowedRateUnits({ appliesTo, trigger, cargoKind, cargoUnitOfMeasure });
|
||||
if (!requestedUnit) {
|
||||
throw new BadRequestException(
|
||||
`Pick a rate unit for this rate. Allowed: ${allowed.join(', ')}.`,
|
||||
);
|
||||
}
|
||||
if (!isRateUnitAllowed({ appliesTo, trigger, cargoKind, unit: requestedUnit })) {
|
||||
if (
|
||||
!isRateUnitAllowed({
|
||||
appliesTo,
|
||||
trigger,
|
||||
cargoKind,
|
||||
cargoUnitOfMeasure,
|
||||
unit: requestedUnit,
|
||||
})
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
`Rate unit "${requestedUnit}" is not valid for this rate. Allowed: ${allowed.join(', ')}.`,
|
||||
);
|
||||
@@ -89,6 +107,13 @@ export class RatesService {
|
||||
return requestedUnit;
|
||||
}
|
||||
|
||||
/** Unit of measure of the bulk commodity a rate is scoped to; null when unscoped. */
|
||||
private async cargoUnitOfMeasure(cargoTypeId?: string | null): Promise<CargoUom> {
|
||||
if (!cargoTypeId) return null;
|
||||
const cargo = await this.cargoTypesRepository.findById(cargoTypeId);
|
||||
return cargo?.unitOfMeasure ?? null;
|
||||
}
|
||||
|
||||
/** Base rail freight is priced per leg; surcharges and truck legs are not. */
|
||||
private isBaseFreight(appliesTo: Rate['appliesTo'], trigger: Rate['trigger']): boolean {
|
||||
return trigger === 'ALWAYS' && BASE_FREIGHT_CATEGORIES.includes(appliesTo);
|
||||
@@ -380,11 +405,12 @@ export class RatesService {
|
||||
tradeDirection,
|
||||
isBulk: this.resolvesToBulk(appliesTo, intercityKind),
|
||||
});
|
||||
const rateUnit = this.resolveRateUnit(
|
||||
const rateUnit = await this.resolveRateUnit(
|
||||
appliesTo,
|
||||
trigger,
|
||||
dto.rateUnit as Rate['rateUnit'] | undefined,
|
||||
cargoKind,
|
||||
cargoTypeId,
|
||||
);
|
||||
|
||||
await this.assertNoDuplicatePattern({
|
||||
@@ -562,12 +588,19 @@ export class RatesService {
|
||||
// Re-validate the unit against the (possibly changed) shape; overweight is
|
||||
// forced to PER_TON.
|
||||
const requestedUnit = (dto.rateUnit as Rate['rateUnit']) ?? existing.rateUnit;
|
||||
updates.rateUnit = this.resolveRateUnit(appliesTo, trigger, requestedUnit, cargoKind);
|
||||
const rateUnit = await this.resolveRateUnit(
|
||||
appliesTo,
|
||||
trigger,
|
||||
requestedUnit,
|
||||
cargoKind,
|
||||
updates.cargoTypeId,
|
||||
);
|
||||
updates.rateUnit = rateUnit;
|
||||
|
||||
// Guard the pattern uniqueness for the new identity, ignoring this row.
|
||||
await this.assertNoDuplicatePattern({
|
||||
rateType,
|
||||
rateUnit: updates.rateUnit,
|
||||
rateUnit,
|
||||
containerTypeId: updates.containerTypeId,
|
||||
cargoTypeId: updates.cargoTypeId,
|
||||
tradeDirection: updates.tradeDirection,
|
||||
|
||||
Reference in New Issue
Block a user