revert back the clerance payment

This commit is contained in:
Marshal
2026-07-23 13:54:09 +00:00
parent 13609f8d59
commit 15a6bab5d0
54 changed files with 1222 additions and 712 deletions

View File

@@ -101,6 +101,23 @@ describe('RateChangeRequestsService', () => {
expect(request.payload).toEqual({ rateValue: 200 });
});
it('carries a re-routed leg — a yard-only edit is a real change', async () => {
const { service } = build({
rate: liveRate({ originYardId: 'yard-a', destinationYardId: 'yard-b' }),
});
const request = await service.submit({
rateId: 'rate-1',
update: {
rateValue: 100,
originYardId: 'yard-a',
destinationYardId: 'yard-c',
},
});
expect(request.payload).toEqual({ destinationYardId: 'yard-c' });
});
it('rejects a no-op — 100 posted against a live 100.0000 is not a change', async () => {
const { service } = build();
await expect(

View File

@@ -33,6 +33,10 @@ const DIFFABLE_FIELDS = [
'tradeDirection',
'containerTypeId',
'cargoTypeId',
// The leg a route-scoped rate prices. Missing here, a re-routed LIVE rate
// diffed to nothing and the submit was refused as "nothing changed".
'originYardId',
'destinationYardId',
] as const;
/**

View File

@@ -69,18 +69,19 @@ export class RatesService {
appliesTo: Rate['appliesTo'],
trigger: Rate['trigger'],
requestedUnit: Rate['rateUnit'] | undefined,
cargoKind?: 'CONTAINER' | 'BULK' | null,
): 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 });
const allowed = allowedRateUnits({ appliesTo, trigger, cargoKind });
if (!requestedUnit) {
throw new BadRequestException(
`Pick a rate unit for this rate. Allowed: ${allowed.join(', ')}.`,
);
}
if (!isRateUnitAllowed({ appliesTo, trigger, unit: requestedUnit })) {
if (!isRateUnitAllowed({ appliesTo, trigger, cargoKind, unit: requestedUnit })) {
throw new BadRequestException(
`Rate unit "${requestedUnit}" is not valid for this rate. Allowed: ${allowed.join(', ')}.`,
);
@@ -187,17 +188,42 @@ export class RatesService {
trigger: Rate['trigger'];
tradeDirection: string | null;
intercityKind: string | null;
cargoKind: string | null;
containerTypeId: string | null;
cargoTypeId: string | null;
}): void {
const { appliesTo, trigger, tradeDirection, intercityKind } = input;
const { appliesTo, trigger, tradeDirection, intercityKind, cargoKind } = input;
const { containerTypeId, cargoTypeId } = input;
if (trigger === 'CUSTOMS_CLEARANCE') {
if (tradeDirection !== 'IMPORT' && tradeDirection !== 'EXPORT') {
if (trigger === 'CUSTOMS_CLEARANCE' || trigger === 'LASHING') {
const label = trigger === 'CUSTOMS_CLEARANCE' ? 'customs clearance' : 'lashing';
// Customs is sold per direction; lashing applies both ways.
if (
trigger === 'CUSTOMS_CLEARANCE' &&
tradeDirection !== 'IMPORT' &&
tradeDirection !== 'EXPORT'
) {
throw new BadRequestException(
'A customs clearance rate must say whether it covers IMPORT or EXPORT.',
);
}
// Sold per cargo kind: a container fee names the container type it covers
// (20ft and 40ft price differently); a bulk fee carries no type at all —
// that absence is what marks it as the bulk fee.
if (cargoKind !== 'CONTAINER' && cargoKind !== 'BULK') {
throw new BadRequestException(
`A ${label} rate must say whether it covers containers or bulk.`,
);
}
if (cargoKind === 'CONTAINER' && !containerTypeId) {
throw new BadRequestException(
`A container ${label} rate must name the container type it covers.`,
);
}
if (cargoKind === 'BULK' && containerTypeId) {
throw new BadRequestException(
`A bulk ${label} rate cannot be scoped to a container type.`,
);
}
return;
}
if (trigger === 'WITH_RETURN') {
@@ -279,11 +305,17 @@ export class RatesService {
const trigger = dto.trigger as Rate['trigger'];
// Surcharges (trigger ≠ ALWAYS) carry no direction/scope — clear them so
// the engine never accidentally narrows a surcharge by container/direction.
// Exceptions: customs clearance keeps a direction, and empty-container
// return keeps direction + container type — both are sold per lane.
// Exceptions: customs clearance and empty-container return keep direction +
// container type — both are sold per lane (and per container type).
const isSurcharge = trigger !== 'ALWAYS';
const cargoKind =
trigger === 'CUSTOMS_CLEARANCE' || trigger === 'LASHING'
? ((dto.cargoKind as 'CONTAINER' | 'BULK' | undefined) ?? null)
: null;
const containerTypeId =
trigger === 'WITH_RETURN'
trigger === 'WITH_RETURN' ||
((trigger === 'CUSTOMS_CLEARANCE' || trigger === 'LASHING') &&
cargoKind === 'CONTAINER')
? (dto.containerTypeId ?? null)
: isSurcharge
? null
@@ -304,6 +336,7 @@ export class RatesService {
trigger,
tradeDirection,
intercityKind,
cargoKind,
containerTypeId,
cargoTypeId,
});
@@ -325,6 +358,7 @@ export class RatesService {
appliesTo,
trigger,
dto.rateUnit as Rate['rateUnit'] | undefined,
cargoKind,
);
await this.assertNoDuplicatePattern({
@@ -419,7 +453,19 @@ export class RatesService {
if (dto.appliesTo) updates.appliesTo = appliesTo;
if (dto.trigger) updates.trigger = trigger;
const keepsContainerType = !isSurcharge || trigger === 'WITH_RETURN';
// A patch that leaves the cargo kind unsaid keeps the one the rate already
// has — read back off its container scope (container fees carry the type).
const cargoKind =
trigger !== 'CUSTOMS_CLEARANCE' && trigger !== 'LASHING'
? null
: ((dto.cargoKind as 'CONTAINER' | 'BULK' | undefined) ??
(existing.containerTypeId ? 'CONTAINER' : 'BULK'));
const keepsContainerType =
!isSurcharge ||
trigger === 'WITH_RETURN' ||
((trigger === 'CUSTOMS_CLEARANCE' || trigger === 'LASHING') &&
cargoKind === 'CONTAINER');
const containerTypeId = !keepsContainerType
? null
: dto.containerTypeId !== undefined
@@ -455,6 +501,7 @@ export class RatesService {
trigger,
tradeDirection: updates.tradeDirection,
intercityKind,
cargoKind,
containerTypeId: updates.containerTypeId,
cargoTypeId: updates.cargoTypeId,
});
@@ -486,7 +533,7 @@ 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);
updates.rateUnit = this.resolveRateUnit(appliesTo, trigger, requestedUnit, cargoKind);
// Guard the pattern uniqueness for the new identity, ignoring this row.
await this.assertNoDuplicatePattern({