feat: implement shipping line bookings management

- Add ShippingLineBookingsPage for listing and managing shipping line bookings.
- Create ShippingLineDocumentsModal for document uploads related to bookings.
- Introduce ShippingLineInitiateModal for initiating new shipping line bookings.
- Implement booking document state management with booking-doc-state utility.
- Add shipping line bookings service for API interactions.
- Update index to export new components and services.
- Enhance types for freight to include shipping line credits.
This commit is contained in:
marshalyordanos
2026-08-13 15:54:40 +03:00
parent 9aae132dd4
commit 9fff469ffa
50 changed files with 4485 additions and 77 deletions

View File

@@ -75,6 +75,14 @@ export class CreateRateDto {
@IsUUID()
destinationYardId?: string;
@ApiPropertyOptional({
description:
'FK to shipping_line_companies.id — set to price this rate for one shipping line only. Omitted/null = the standard rate every customer pays. A line rate overrides the standard one for that line\'s bookings.',
})
@IsOptional()
@IsUUID()
shippingLineCompanyId?: string;
@ApiPropertyOptional({ enum: CURRENCIES })
@IsOptional()
@IsIn([...CURRENCIES])

View File

@@ -141,6 +141,22 @@ export class ListRatesQueryDto extends PaginationQueryDto {
@IsString()
@MaxLength(200)
trigger?: string;
@ApiPropertyOptional({
description: 'Filter to one shipping line\'s rates.',
})
@IsOptional()
@IsUUID()
shippingLineCompanyId?: string;
@ApiPropertyOptional({
description:
'true = only shipping-line rates (any line), false = only standard customer rates. Omitted = both. Powers the Shipping line tab.',
})
@IsOptional()
@Transform(toOptionalBoolean)
@IsBoolean()
isShippingLineRate?: boolean;
}
export class ListWeightLimitRulesQueryDto extends PaginationQueryDto {

View File

@@ -1,5 +1,6 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { ShippingLineCompany } from '../../shipping-lines/entities/shipping-line-company.entity';
import { CargoType } from './cargo-type.entity';
import { ContainerType } from './container-type.entity';
import { Yard } from './yard.entity';
@@ -108,6 +109,7 @@ export type RateTrigger = typeof RATE_TRIGGERS[number];
@Index(['trigger'])
@Index(['originYardId'])
@Index(['destinationYardId'])
@Index(['shippingLineCompanyId'])
export class Rate extends BaseEntity {
@Column({ name: 'rate_type', type: 'varchar', length: 50 })
rateType!: RateType;
@@ -155,6 +157,23 @@ export class Rate extends BaseEntity {
@JoinColumn({ name: 'destination_yard_id' })
destinationYard?: Yard | null;
/**
* The shipping line this rate belongs to, or NULL for the standard rate every
* customer pays. A booking owned by a shipping line prices exclusively off
* that line's rates — the standard rate is NOT a fallback, so a missing line
* rate hard-blocks the booking rather than quietly billing the customer price.
*
* Points at `shipping_line_companies` (the portal account that books capacity),
* not `shipping_lines` (carrier reference data behind the SHIPPING_LINE
* trigger). The two are unrelated despite the similar names.
*/
@Column({ name: 'shipping_line_company_id', type: 'uuid', nullable: true })
shippingLineCompanyId?: string | null;
@ManyToOne(() => ShippingLineCompany, { nullable: true, eager: false })
@JoinColumn({ name: 'shipping_line_company_id' })
shippingLineCompany?: ShippingLineCompany | null;
@Column({ name: 'currency', type: 'varchar', length: 5 })
currency!: string;

View File

@@ -16,6 +16,8 @@ export interface IRatesRepository {
rateType: string;
/** Omitted for singly-resolved rates — see the repository implementation. */
rateUnit?: string;
/** Owning shipping line; null/omitted = the standard customer rate. */
shippingLineCompanyId?: string | null;
containerTypeId?: string | null;
cargoTypeId?: string | null;
tradeDirection?: string | null;

View File

@@ -69,6 +69,7 @@ export class RatesRepository implements IRatesRepository {
findByPattern(pattern: {
rateType: string;
rateUnit?: string;
shippingLineCompanyId?: string | null;
containerTypeId?: string | null;
cargoTypeId?: string | null;
tradeDirection?: string | null;
@@ -85,6 +86,16 @@ export class RatesRepository implements IRatesRepository {
qb.andWhere('rate.rate_unit = :rateUnit', { rateUnit: pattern.rateUnit });
}
// The owner is part of the identity: a line's rate for a lane is a
// different rate from the standard one, not a duplicate of it.
if (pattern.shippingLineCompanyId) {
qb.andWhere('rate.shipping_line_company_id = :shippingLineCompanyId', {
shippingLineCompanyId: pattern.shippingLineCompanyId,
});
} else {
qb.andWhere('rate.shipping_line_company_id IS NULL');
}
if (pattern.containerTypeId) {
qb.andWhere('rate.container_type_id = :containerTypeId', { containerTypeId: pattern.containerTypeId });
} else {
@@ -139,8 +150,22 @@ export class RatesRepository implements IRatesRepository {
// yards joined the route columns have only ids to render.
.leftJoinAndSelect('rate.originYard', 'originYard')
.leftJoinAndSelect('rate.destinationYard', 'destinationYard')
// The shipping-line tab renders the owning line's name, not its id.
.leftJoinAndSelect('rate.shippingLineCompany', 'shippingLineCompany')
.orderBy('rate.createdAt', query.sortOrder ?? 'DESC');
if (query.shippingLineCompanyId) {
qb.andWhere('rate.shippingLineCompanyId = :shippingLineCompanyId', {
shippingLineCompanyId: query.shippingLineCompanyId,
});
} else if (query.isShippingLineRate !== undefined) {
// Tab filter: shipping-line rates (any line) vs standard customer rates.
qb.andWhere(
query.isShippingLineRate
? 'rate.shippingLineCompanyId IS NOT NULL'
: 'rate.shippingLineCompanyId IS NULL',
);
}
if (query.status) {
qb.andWhere('rate.status = :status', { status: query.status });
}
@@ -164,7 +189,7 @@ export class RatesRepository implements IRatesRepository {
}
if (query.search) {
qb.andWhere(
'(rate.rateType ILIKE :search OR rate.status ILIKE :search OR rate.rateUnit ILIKE :search OR rate.currency ILIKE :search)',
'(rate.rateType ILIKE :search OR rate.status ILIKE :search OR rate.rateUnit ILIKE :search OR rate.currency ILIKE :search OR shippingLineCompany.name ILIKE :search)',
{ search: `%${query.search}%` },
);
}

View File

@@ -69,6 +69,7 @@ import { YardFacilitiesService } from './services/yard-facilities.service';
import { RuleEngineService } from './rule-engine.service';
import { NotificationInboxModule } from '../notification-inbox/notification-inbox.module';
import { ShippingLineCompaniesModule } from '../shipping-lines/shipping-line-companies.module';
import { WagonTypesModule } from '../wagon-types/wagon-types.module';
import { BookingCargoModifier } from '../bookings/entities/booking-cargo-modifier.entity';
@@ -102,6 +103,9 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.
// Rated wagon capacities — cargo types validate their per-wagon tonnage cap
// against them (a cap above the rating is a typo, not a policy).
WagonTypesModule,
// Rates may be scoped to one shipping line; creating such a rate validates
// the line exists and is active.
ShippingLineCompaniesModule,
],
controllers: [
CargoTypesController,

View File

@@ -527,3 +527,145 @@ describe('RuleEngineService — fuel surcharge (per lane + cargo type)', () => {
expect(fuelMods(result)).toHaveLength(0);
});
});
describe('RuleEngineService — shipping-line rates override the standard ones', () => {
const LINE = 'slc-msc';
/** Standard customer container-import rate on the lane. */
const standardBase: Rate = {
id: 'rate-standard-20',
rateType: 'CONTAINER_IMPORT',
trigger: 'ALWAYS',
rateValue: 1000,
rateUnit: 'PER_CONTAINER',
currency: 'USD',
status: 'LIVE',
containerTypeId: 'ct-20',
cargoTypeId: null,
shippingLineCompanyId: null,
originYardId: 'yard-dj',
destinationYardId: 'yard-adama',
} as Rate;
/** The same lane, priced for one shipping line. */
const lineBase: Rate = {
...standardBase,
id: 'rate-line-20',
rateValue: 1200,
shippingLineCompanyId: LINE,
} as Rate;
const standardHazard: Rate = {
id: 'rate-hazard-standard',
rateType: 'HAZARD_SURCHARGE',
trigger: 'HAZARDOUS',
rateValue: 50,
rateUnit: 'PER_CONTAINER',
currency: 'USD',
status: 'LIVE',
containerTypeId: null,
cargoTypeId: null,
shippingLineCompanyId: null,
} as Rate;
const lineHazard: Rate = {
...standardHazard,
id: 'rate-hazard-line',
rateValue: 80,
shippingLineCompanyId: LINE,
} as Rate;
const buildService = (rates: Rate[]) =>
new RuleEngineService(
{ findById: jest.fn().mockResolvedValue(null) } as never,
{ findById: jest.fn().mockResolvedValue(null) } as never,
{
findActiveByContainerTypeId: jest
.fn()
.mockResolvedValue([{ id: 'wlr-20', maxVgmTons: 20, maxCapacityTons: null }]),
} as never,
{ findAllActive: jest.fn().mockResolvedValue([]) } as never,
{ findLiveRates: jest.fn().mockResolvedValue(rates) } as never,
{ findById: jest.fn().mockResolvedValue(null) } as never,
{} as never,
);
// One 20ft at 25 t against a 20 t limit → 5 t excess.
const bookingInput = (
overrides: Partial<BookingEvaluationInput> = {},
): BookingEvaluationInput => ({
serviceTypeId: 'svc-1',
paymentCurrency: 'USD',
tradeDirection: 'IMPORT',
isHazardous: false,
totalWagons: 1,
originYardId: 'yard-dj',
destinationYardId: 'yard-adama',
containers: [
{ containerTypeId: 'ct-20', quantity: 1, vgmPerUnitTons: 25, totalVgmTons: 25 },
],
...overrides,
});
const overweightOf = (result: { appliedModifiers: Array<{ surchargeCode: string }> }) =>
result.appliedModifiers.filter((m) => m.surchargeCode === 'OVERWEIGHT_PER_TON');
it('derives a line booking\'s overweight from the LINE\'s base rate, not the standard one', async () => {
const result = await buildService([standardBase, lineBase]).evaluate(
bookingInput({ shippingLineCompanyId: LINE }),
);
const ow = overweightOf(result);
expect(ow).toHaveLength(1);
// The line's 1200 / (2 × 20) = 30 USD/t, not the standard 1000 → 25 USD/t.
expect(ow[0]).toMatchObject({
rateId: lineBase.id,
unitPriceUsd: 30,
calculatedAmount: 150,
});
});
it('keeps a customer booking on the standard rate even when a line rate exists', async () => {
const result = await buildService([standardBase, lineBase]).evaluate(bookingInput());
const ow = overweightOf(result);
expect(ow).toHaveLength(1);
expect(ow[0]).toMatchObject({
rateId: standardBase.id,
unitPriceUsd: 25,
calculatedAmount: 125,
});
});
it('does not fall back to the standard rate when the line has none for the lane', async () => {
const result = await buildService([standardBase]).evaluate(
bookingInput({ shippingLineCompanyId: LINE }),
);
// No line rate on the lane → nothing to derive from. Base freight is what
// hard-blocks the booking; the standard 1000 must never be borrowed here.
expect(overweightOf(result)).toHaveLength(0);
});
it('bills the line\'s own surcharge and never the standard one alongside it', async () => {
const result = await buildService([
standardBase,
lineBase,
standardHazard,
lineHazard,
]).evaluate(bookingInput({ shippingLineCompanyId: LINE, isHazardous: true }));
const hazard = result.appliedModifiers.filter(
(m) => m.surchargeCode === 'HAZARD_SURCHARGE',
);
expect(hazard).toHaveLength(1);
expect(hazard[0]).toMatchObject({ rateId: lineHazard.id, calculatedAmount: 80 });
});
it('hard-blocks a requested service the line has no surcharge rate for', async () => {
const result = await buildService([standardBase, lineBase, standardHazard]).evaluate(
bookingInput({ shippingLineCompanyId: LINE, isHazardous: true }),
);
// The standard hazard rate exists but belongs to customers, so the line's
// hazardous booking must block rather than borrow it.
expect(result.hardBlocked).toHaveLength(1);
expect(result.hardBlocked[0]).toContain('hazardous');
});
});

View File

@@ -74,6 +74,16 @@ export interface BookingEvaluationInput {
isGovernment?: boolean;
allowConsolidation?: boolean;
shippingLineId?: string | null;
/**
* The shipping line that OWNS this booking (`bookings.shipping_line_company_id`),
* when it is a shipping-line booking rather than a customer one. Such a booking
* prices exclusively off that line's own rates — see {@link ratesForOwner}.
*
* Not to be confused with `shippingLineId` above, which is cargo metadata
* naming the carrier that physically moves the goods and only feeds the
* SHIPPING_LINE double-handling trigger.
*/
shippingLineCompanyId?: string | null;
/**
* The booking's rail leg. Import overweight derives its per-ton price from
* this route's own container freight rate, so the engine needs the yards.
@@ -282,7 +292,10 @@ export class RuleEngineService {
// scope) must contribute exactly ONE line. Duplicate LIVE rate rows — e.g.
// from a non-idempotent seeder — would otherwise repeat the same surcharge
// many times and inflate the total, so we collapse them to one row each.
const liveRates = await this.ratesRepo.findLiveRates();
const liveRates = this.ratesForOwner(
await this.ratesRepo.findLiveRates(),
input.shippingLineCompanyId,
);
const surchargeRates = this.dedupeRatesBySignature(
liveRates.filter((r) => r.trigger && r.trigger !== 'ALWAYS'),
);
@@ -812,6 +825,28 @@ export class RuleEngineService {
return rate.rateType ?? rate.trigger;
}
/**
* Narrow the LIVE rate pool to the ones this booking's owner may price off.
*
* A customer booking sees only standard rates (no owner) — a shipping line's
* negotiated price must never leak into a customer quote. A shipping-line
* booking sees only that line's own rates: line rates OVERRIDE the standard
* ones rather than stacking on them, and the standard rate is deliberately
* NOT a fallback, so a lane the line has no rate for hard-blocks downstream
* (base freight already blocks on "no rate for this route") instead of
* quietly billing the line at the customer price.
*
* Filtering once, here, is what makes the override apply uniformly: every
* downstream lookup (base freight, derived overweight, empty return, lashing,
* fuel, and the additive surcharges) reads from this same pool, so none of
* them needs its own owner check.
*/
private ratesForOwner(rates: Rate[], shippingLineCompanyId?: string | null): Rate[] {
return shippingLineCompanyId
? rates.filter((r) => r.shippingLineCompanyId === shippingLineCompanyId)
: rates.filter((r) => !r.shippingLineCompanyId);
}
/**
* Collapse rates that describe the same charge to a single representative.
*

View File

@@ -61,6 +61,8 @@ describe('RatesService — one rate per pattern', () => {
})),
} as never,
{ findById: jest.fn().mockResolvedValue(null) } as never,
// Shipping line companies — these rates carry no owner, so it is never hit.
{ findById: jest.fn() } as never,
);
});

View File

@@ -8,6 +8,8 @@ import {
} from '@nestjs/common';
import { PaginatedResponse, YardCountry } from '@edr/types';
import { IsNull, Not } from 'typeorm';
import { ShippingLineStatus } from '../../shipping-lines/entities/shipping-line-company.entity';
import { ShippingLineCompaniesService } from '../../shipping-lines/shipping-line-companies.service';
import { CreateRateDto } from '../dto/create-rate.dto';
import { ListRatesQueryDto } from '../dto/list-rule-engine-query.dto';
import { UpdateRateDto } from '../dto/update-rate.dto';
@@ -39,6 +41,7 @@ export class RatesService {
private readonly yardsRepository: IYardsRepository,
@Inject(CARGO_TYPES_REPOSITORY)
private readonly cargoTypesRepository: ICargoTypesRepository,
private readonly shippingLineCompaniesService: ShippingLineCompaniesService,
) {}
/** List rates — standard paginated envelope with server-side search. */
@@ -491,6 +494,7 @@ export class RatesService {
rateType: string;
/** Passed only for additive surcharges — see {@link resolvesSingleRate}. */
rateUnit?: string;
shippingLineCompanyId: string | null;
containerTypeId: string | null;
cargoTypeId: string | null;
tradeDirection: string | null;
@@ -508,6 +512,35 @@ export class RatesService {
}
}
/**
* Validate the shipping line a rate is scoped to, when any.
*
* A shipping line only ever ships import — the export leg is sold through the
* customer's contract — so a line rate carrying an EXPORT direction is
* rejected here as well as by `CK_rates_shipping_line_import_only`.
* Returns the owner id to store (null = the standard customer rate).
*/
private async resolveShippingLineScope(
shippingLineCompanyId: string | null | undefined,
tradeDirection: string | null,
): Promise<string | null> {
if (!shippingLineCompanyId) return null;
// Throws NotFoundException when the line does not exist.
const line = await this.shippingLineCompaniesService.findById(shippingLineCompanyId);
if (line.status !== ShippingLineStatus.Active) {
throw new BadRequestException(
`${line.name} is ${line.status} — rates can only be configured for an active shipping line.`,
);
}
if (tradeDirection && tradeDirection !== 'IMPORT') {
throw new BadRequestException(
'Shipping line rates are import-only — the export leg is priced through the customer contract.',
);
}
return shippingLineCompanyId;
}
/** Create a rate in DRAFT status. */
async create(dto: CreateRateDto, proposedByStaffId: string): Promise<Rate> {
const appliesTo = dto.appliesTo as Rate['appliesTo'];
@@ -568,6 +601,11 @@ export class RatesService {
destinationYardId: dto.destinationYardId,
});
const shippingLineCompanyId = await this.resolveShippingLineScope(
dto.shippingLineCompanyId,
tradeDirection,
);
const rateType = deriveRateType({
appliesTo,
trigger,
@@ -602,6 +640,7 @@ export class RatesService {
await this.assertNoDuplicatePattern({
rateType,
...(this.resolvesSingleRate(appliesTo, trigger) ? {} : { rateUnit }),
shippingLineCompanyId,
containerTypeId,
cargoTypeId,
tradeDirection,
@@ -614,6 +653,7 @@ export class RatesService {
appliesTo,
trigger,
rateType,
shippingLineCompanyId,
containerTypeId,
cargoTypeId,
tradeDirection,
@@ -791,6 +831,17 @@ export class RatesService {
updates.originYardId = yardScope.originYardId;
updates.destinationYardId = yardScope.destinationYardId;
// The owning line is re-validated on every edit: a patch that flips the
// direction to EXPORT has to be refused for a line rate, and a patch that
// moves the rate to a suspended line too.
const shippingLineCompanyId = await this.resolveShippingLineScope(
dto.shippingLineCompanyId !== undefined
? dto.shippingLineCompanyId
: existing.shippingLineCompanyId,
updates.tradeDirection,
);
updates.shippingLineCompanyId = shippingLineCompanyId;
// Keep the derived rateType in sync with whatever changed.
const rateType = deriveRateType({
appliesTo,
@@ -839,6 +890,7 @@ export class RatesService {
await this.assertNoDuplicatePattern({
rateType,
...(this.resolvesSingleRate(appliesTo, trigger) ? {} : { rateUnit }),
shippingLineCompanyId,
containerTypeId: updates.containerTypeId,
cargoTypeId: updates.cargoTypeId,
tradeDirection: updates.tradeDirection,