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:
Marshal
2026-07-25 17:14:58 +00:00
parent 54b5882355
commit fde5e6de4b
68 changed files with 1858 additions and 289 deletions

View File

@@ -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);
});
});

View File

@@ -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);

View File

@@ -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',

View File

@@ -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))

View File

@@ -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,