mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
fix issues
This commit is contained in:
@@ -29,7 +29,8 @@ export class SimplifyRatesAndWeightLimitRules1900000000000 implements MigrationI
|
||||
PARTITION BY rate_type,
|
||||
COALESCE(container_type_id, '00000000-0000-0000-0000-000000000000'::uuid),
|
||||
COALESCE(cargo_type_id, '00000000-0000-0000-0000-000000000000'::uuid),
|
||||
COALESCE(trade_direction, '')
|
||||
COALESCE(trade_direction, ''),
|
||||
rate_unit
|
||||
ORDER BY created_at DESC, id DESC
|
||||
) AS rn
|
||||
FROM freight.rates
|
||||
@@ -83,13 +84,17 @@ export class SimplifyRatesAndWeightLimitRules1900000000000 implements MigrationI
|
||||
await queryRunner.query(`ALTER TABLE freight.weight_limit_rules DROP COLUMN IF EXISTS effective_to;`);
|
||||
|
||||
// ── 3. One-rate-per-pattern partial unique indexes ─────────────────────
|
||||
// The unit is part of the identity so a surcharge can legitimately carry two
|
||||
// rows that bill different ways (e.g. reefer PER_CONTAINER + reefer PER_TON),
|
||||
// while still blocking a true duplicate (same rateType + scope + unit).
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_rates_pattern"
|
||||
ON freight.rates (
|
||||
rate_type,
|
||||
COALESCE(container_type_id, '00000000-0000-0000-0000-000000000000'::uuid),
|
||||
COALESCE(cargo_type_id, '00000000-0000-0000-0000-000000000000'::uuid),
|
||||
COALESCE(trade_direction, '')
|
||||
COALESCE(trade_direction, ''),
|
||||
rate_unit
|
||||
)
|
||||
WHERE deleted_at IS NULL AND status <> 'SUPERSEDED';
|
||||
`);
|
||||
|
||||
@@ -6,6 +6,7 @@ export interface IRatesRepository {
|
||||
findLiveRates(): Promise<Rate[]>;
|
||||
findByPattern(pattern: {
|
||||
rateType: string;
|
||||
rateUnit: string;
|
||||
containerTypeId?: string | null;
|
||||
cargoTypeId?: string | null;
|
||||
tradeDirection?: string | null;
|
||||
|
||||
@@ -30,6 +30,7 @@ export class RatesRepository implements IRatesRepository {
|
||||
*/
|
||||
findByPattern(pattern: {
|
||||
rateType: string;
|
||||
rateUnit: string;
|
||||
containerTypeId?: string | null;
|
||||
cargoTypeId?: string | null;
|
||||
tradeDirection?: string | null;
|
||||
@@ -37,6 +38,7 @@ export class RatesRepository implements IRatesRepository {
|
||||
const qb = this.repo
|
||||
.createQueryBuilder('rate')
|
||||
.where('rate.rate_type = :rateType', { rateType: pattern.rateType })
|
||||
.andWhere('rate.rate_unit = :rateUnit', { rateUnit: pattern.rateUnit })
|
||||
.andWhere('rate.status <> :superseded', { superseded: 'SUPERSEDED' });
|
||||
|
||||
if (pattern.containerTypeId) {
|
||||
|
||||
@@ -83,6 +83,7 @@ export class RatesService {
|
||||
*/
|
||||
private async assertNoDuplicatePattern(pattern: {
|
||||
rateType: string;
|
||||
rateUnit: string;
|
||||
containerTypeId: string | null;
|
||||
cargoTypeId: string | null;
|
||||
tradeDirection: string | null;
|
||||
@@ -115,7 +116,7 @@ export class RatesService {
|
||||
});
|
||||
const rateUnit = this.resolveRateUnit(appliesTo, trigger, dto.rateUnit as Rate['rateUnit']);
|
||||
|
||||
await this.assertNoDuplicatePattern({ rateType, containerTypeId, cargoTypeId, tradeDirection });
|
||||
await this.assertNoDuplicatePattern({ rateType, rateUnit, containerTypeId, cargoTypeId, tradeDirection });
|
||||
|
||||
return this.repository.create({
|
||||
appliesTo,
|
||||
@@ -183,6 +184,7 @@ export class RatesService {
|
||||
// Guard the pattern uniqueness for the new identity, ignoring this row.
|
||||
await this.assertNoDuplicatePattern({
|
||||
rateType,
|
||||
rateUnit: updates.rateUnit,
|
||||
containerTypeId: updates.containerTypeId,
|
||||
cargoTypeId: updates.cargoTypeId,
|
||||
tradeDirection: updates.tradeDirection,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Inject, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { CreateWeightLimitRuleDto } from '../dto/create-weight-limit-rule.dto';
|
||||
import { UpdateWeightLimitRuleDto } from '../dto/update-weight-limit-rule.dto';
|
||||
import { WeightLimitRule } from '../entities/weight-limit-rule.entity';
|
||||
@@ -30,7 +30,7 @@ export class WeightLimitRulesService {
|
||||
const [data, total] = await this.repository.findAndCount({
|
||||
where,
|
||||
relations: { containerType: true },
|
||||
order: { effectiveFrom: 'DESC' },
|
||||
order: { createdAt: 'DESC' },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
});
|
||||
@@ -44,26 +44,51 @@ export class WeightLimitRulesService {
|
||||
return entity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject a second rule for the same container + direction. One VGM limit per
|
||||
* (container, direction) — otherwise the booking engine can't tell which
|
||||
* applies.
|
||||
*/
|
||||
private async assertNoDuplicate(
|
||||
containerTypeId: string,
|
||||
tradeDirection: string,
|
||||
ignoreId?: string,
|
||||
): Promise<void> {
|
||||
const existing = await this.repository.findByPattern(containerTypeId, tradeDirection, ignoreId);
|
||||
if (existing) {
|
||||
throw new ConflictException(
|
||||
'A weight limit rule for this container type and trade direction already exists. Edit the existing rule instead.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Create a new weight limit rule. */
|
||||
async create(dto: CreateWeightLimitRuleDto): Promise<WeightLimitRule> {
|
||||
await this.assertNoDuplicate(dto.containerTypeId, dto.tradeDirection);
|
||||
return this.repository.create({
|
||||
containerTypeId: dto.containerTypeId,
|
||||
tradeDirection: dto.tradeDirection,
|
||||
maxVgmTons: dto.maxVgmTons,
|
||||
effectiveFrom: new Date(dto.effectiveFrom),
|
||||
effectiveTo: dto.effectiveTo ? new Date(dto.effectiveTo) : null,
|
||||
});
|
||||
}
|
||||
|
||||
/** Update an existing weight limit rule. */
|
||||
async update(id: string, dto: UpdateWeightLimitRuleDto): Promise<WeightLimitRule> {
|
||||
await this.findById(id);
|
||||
const existing = await this.findById(id);
|
||||
const patch: Partial<WeightLimitRule> = {};
|
||||
if (dto.containerTypeId !== undefined) patch.containerTypeId = dto.containerTypeId;
|
||||
if (dto.tradeDirection !== undefined) patch.tradeDirection = dto.tradeDirection;
|
||||
if (dto.maxVgmTons !== undefined) patch.maxVgmTons = dto.maxVgmTons;
|
||||
if (dto.effectiveFrom !== undefined) patch.effectiveFrom = new Date(dto.effectiveFrom);
|
||||
if (dto.effectiveTo !== undefined) patch.effectiveTo = new Date(dto.effectiveTo);
|
||||
|
||||
// Re-check uniqueness when the identity (container/direction) changes.
|
||||
if (dto.containerTypeId !== undefined || dto.tradeDirection !== undefined) {
|
||||
await this.assertNoDuplicate(
|
||||
patch.containerTypeId ?? existing.containerTypeId,
|
||||
patch.tradeDirection ?? existing.tradeDirection,
|
||||
id,
|
||||
);
|
||||
}
|
||||
|
||||
const updated = await this.repository.update(id, patch);
|
||||
if (!updated) throw new NotFoundException(`Weight limit rule ${id} not found`);
|
||||
return updated;
|
||||
|
||||
@@ -319,37 +319,11 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise<void> {
|
||||
const twenty = await ctRepo.findOneByOrFail({ code: "20FT" });
|
||||
const forty = await ctRepo.findOneByOrFail({ code: "40FT" });
|
||||
|
||||
const base = new Date("2026-01-01");
|
||||
|
||||
const rules = [
|
||||
{
|
||||
containerTypeId: twenty.id,
|
||||
tradeDirection: "IMPORT",
|
||||
maxVgmTons: 26,
|
||||
effectiveFrom: base,
|
||||
isActive: true,
|
||||
},
|
||||
{
|
||||
containerTypeId: twenty.id,
|
||||
tradeDirection: "EXPORT",
|
||||
maxVgmTons: 26,
|
||||
effectiveFrom: base,
|
||||
isActive: true,
|
||||
},
|
||||
{
|
||||
containerTypeId: forty.id,
|
||||
tradeDirection: "IMPORT",
|
||||
maxVgmTons: 28,
|
||||
effectiveFrom: base,
|
||||
isActive: true,
|
||||
},
|
||||
{
|
||||
containerTypeId: forty.id,
|
||||
tradeDirection: "EXPORT",
|
||||
maxVgmTons: 28,
|
||||
effectiveFrom: base,
|
||||
isActive: true,
|
||||
},
|
||||
{ containerTypeId: twenty.id, tradeDirection: "IMPORT", maxVgmTons: 26 },
|
||||
{ containerTypeId: twenty.id, tradeDirection: "EXPORT", maxVgmTons: 26 },
|
||||
{ containerTypeId: forty.id, tradeDirection: "IMPORT", maxVgmTons: 28 },
|
||||
{ containerTypeId: forty.id, tradeDirection: "EXPORT", maxVgmTons: 28 },
|
||||
];
|
||||
|
||||
for (const rule of rules) {
|
||||
@@ -361,10 +335,7 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise<void> {
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
await wlRepo.update(existing.id, {
|
||||
maxVgmTons: rule.maxVgmTons,
|
||||
effectiveFrom: rule.effectiveFrom,
|
||||
});
|
||||
await wlRepo.update(existing.id, { maxVgmTons: rule.maxVgmTons });
|
||||
} else {
|
||||
await wlRepo.insert(rule);
|
||||
}
|
||||
@@ -409,7 +380,6 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise<void> {
|
||||
ctByCode: Map<string, any>,
|
||||
cargoByCode: Map<string, any>,
|
||||
): Promise<Rate[]> {
|
||||
const effectiveFrom = new Date("2026-01-01");
|
||||
const now = new Date();
|
||||
|
||||
// Each rate is self-describing: `appliesTo` + `trigger` decide how the
|
||||
@@ -479,7 +449,6 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise<void> {
|
||||
proposedByStaffId: STAFF_USER_ID,
|
||||
approvedByCeoId: CEO_USER_ID,
|
||||
approvedAt: now,
|
||||
effectiveFrom,
|
||||
}))
|
||||
.filter((d) => !existingBySignature.has(signature(d)));
|
||||
|
||||
|
||||
@@ -179,7 +179,16 @@ const RuleEngineFormDialog = ({
|
||||
const formRows = useMemo(() => buildFormRows(visibleFields), [visibleFields]);
|
||||
|
||||
const setField = (name: string, value: unknown) => {
|
||||
setValues((current) => ({ ...current, [name]: value }));
|
||||
setValues((current) => {
|
||||
const next = { ...current, [name]: value };
|
||||
// Changing what a rate applies to (or its surcharge trigger) can invalidate
|
||||
// the previously-chosen unit — reset it so the admin re-picks from the new
|
||||
// allowed set instead of submitting a stale, rejected unit.
|
||||
if ((name === "appliesTo" || name === "trigger") && "rateUnit" in current) {
|
||||
next.rateUnit = "";
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = (event: React.FormEvent) => {
|
||||
@@ -250,6 +259,9 @@ const RuleEngineFormDialog = ({
|
||||
const label = <FieldLabel label={field.label} required={field.required} />;
|
||||
|
||||
if (field.type === "select") {
|
||||
// Dynamic options (e.g. rate unit) resolve from the live form values so
|
||||
// the choices track the other fields the admin has picked.
|
||||
const options = field.optionsFromValues ? field.optionsFromValues(values) : (field.options ?? []);
|
||||
return (
|
||||
<Select
|
||||
key={field.name}
|
||||
@@ -261,7 +273,7 @@ const RuleEngineFormDialog = ({
|
||||
value={resolveSelectValue(field, values)}
|
||||
onChange={(v) => setField(field.name, v === RULE_ENGINE_SELECT_NONE ? "" : v)}
|
||||
disabled={selectOptionsLoading}
|
||||
data={(field.options ?? [])
|
||||
data={options
|
||||
.filter((opt) => opt.value !== "")
|
||||
.map((opt) => ({
|
||||
label: opt.label,
|
||||
|
||||
@@ -47,6 +47,14 @@ export interface FormFieldDef {
|
||||
* showWhen and not match hideWhen.
|
||||
*/
|
||||
showWhen?: { field: string; equals: string[] };
|
||||
/**
|
||||
* Select options computed from other fields' current values. When set, the
|
||||
* form resolves the option list at render time from the live form state
|
||||
* instead of the static `options` list. Used for the rate unit selector,
|
||||
* whose valid choices depend on `appliesTo` + `trigger`. (Named distinctly
|
||||
* from the fleet config's string-based `dynamicOptions` to avoid a clash.)
|
||||
*/
|
||||
optionsFromValues?: (values: Record<string, unknown>) => { label: string; value: string }[];
|
||||
}
|
||||
|
||||
export interface RuleEngineOrderConfig {
|
||||
@@ -116,12 +124,54 @@ const RATE_TRIGGERS = [
|
||||
{ label: "Shipping line extra fee (PIL)", value: "PIL_EXTRA_FEE" },
|
||||
];
|
||||
|
||||
const RATE_UNITS =["PER_WAGON", "PER_TON", "PER_CONTAINER", "PER_KM", "PER_INVOICE", "FLAT"].map(
|
||||
(v) => ({
|
||||
label: v.replace(/_/g, " "),
|
||||
value: v,
|
||||
}),
|
||||
);
|
||||
const unitOption = (value: string) => ({ label: value.replace(/_/g, " "), value });
|
||||
|
||||
/**
|
||||
* Valid weighting units for a rate shape — mirrors the API's
|
||||
* `allowedRateUnits`. The unit is driven by the *type* being billed: containers
|
||||
* bill per container, bulk per ton, overweight always per excess ton, etc. Kept
|
||||
* in sync with apps/edr-freight-api/.../entities/rate-unit.util.ts.
|
||||
*/
|
||||
const allowedRateUnits = (appliesTo: string, trigger: string): string[] => {
|
||||
if (appliesTo === "OTHER") {
|
||||
switch (trigger) {
|
||||
case "OVERWEIGHT":
|
||||
return ["PER_TON"];
|
||||
case "REEFER":
|
||||
case "HAZARDOUS":
|
||||
case "DEMURRAGE":
|
||||
return ["PER_CONTAINER", "PER_TON"];
|
||||
case "CANCELLATION":
|
||||
return ["FLAT", "PER_INVOICE"];
|
||||
case "CONSOLIDATION":
|
||||
case "SHIPPING_LINE":
|
||||
case "PIL_EXTRA_FEE":
|
||||
return ["PER_CONTAINER", "FLAT"];
|
||||
default:
|
||||
return ["FLAT", "PER_TON", "PER_CONTAINER"];
|
||||
}
|
||||
}
|
||||
switch (appliesTo) {
|
||||
case "CONTAINER":
|
||||
return ["PER_CONTAINER", "PER_WAGON"];
|
||||
case "BULK":
|
||||
return ["PER_TON", "PER_WAGON"];
|
||||
case "INTERCITY":
|
||||
return ["PER_CONTAINER", "PER_TON", "PER_WAGON", "PER_KM"];
|
||||
case "FIRST_MILE":
|
||||
case "LAST_MILE":
|
||||
return ["PER_CONTAINER", "PER_TON", "PER_KM", "FLAT"];
|
||||
default:
|
||||
return ["FLAT"];
|
||||
}
|
||||
};
|
||||
|
||||
const rateUnitOptions = (values: Record<string, unknown>) => {
|
||||
const appliesTo = String(values.appliesTo ?? "");
|
||||
const trigger = appliesTo === "OTHER" ? String(values.trigger ?? "") : "ALWAYS";
|
||||
if (!appliesTo) return [];
|
||||
return allowedRateUnits(appliesTo, trigger).map(unitOption);
|
||||
};
|
||||
|
||||
const CURRENCIES = [
|
||||
{ label: "USD", value: "USD" },
|
||||
@@ -324,8 +374,6 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
},
|
||||
{ id: "tradeDirection", header: "Direction", accessorKey: "tradeDirection" },
|
||||
{ id: "maxVgmTons", header: "Max VGM (t)", accessorKey: "maxVgmTons", format: "number" },
|
||||
{ id: "effectiveFrom", header: "From", accessorKey: "effectiveFrom", format: "date" },
|
||||
{ id: "effectiveTo", header: "To", accessorKey: "effectiveTo", format: "date" },
|
||||
],
|
||||
formFields: [
|
||||
{
|
||||
@@ -343,8 +391,6 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
options: TRADE_DIRECTIONS,
|
||||
},
|
||||
{ name: "maxVgmTons", label: "Max VGM (tons)", type: "number", required: true },
|
||||
{ name: "effectiveFrom", label: "Effective from", type: "date", required: true },
|
||||
{ name: "effectiveTo", label: "Effective to", type: "date" },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -407,7 +453,6 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
{ id: "rateValue", header: "Value", accessorKey: "rateValue", format: "currency" },
|
||||
{ id: "rateUnit", header: "Unit", accessorKey: "rateUnit" },
|
||||
{ id: "status", header: "Status", accessorKey: "status", format: "rateStatus" },
|
||||
{ id: "effectiveFrom", header: "From", accessorKey: "effectiveFrom", format: "date" },
|
||||
],
|
||||
formFields: [
|
||||
{
|
||||
@@ -457,9 +502,18 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
showWhen: { field: "appliesTo", equals: ["BULK", "INTERCITY"] },
|
||||
},
|
||||
{ name: "rateValue", label: "Rate value", type: "number", required: true, suffix: "USD" },
|
||||
{ name: "rateUnit", label: "Rate unit", type: "select", required: true, options: RATE_UNITS },
|
||||
{ name: "effectiveFrom", label: "Effective from", type: "date", required: true },
|
||||
{ name: "effectiveTo", label: "Effective to", type: "date" },
|
||||
// Unit choices are driven by the rate shape (appliesTo + trigger). Overweight
|
||||
// is always per excess ton, so the unit field is hidden for it — the API
|
||||
// forces PER_TON regardless.
|
||||
{
|
||||
name: "rateUnit",
|
||||
label: "Rate unit",
|
||||
type: "select",
|
||||
required: true,
|
||||
optionsFromValues: rateUnitOptions,
|
||||
description: "Weighting basis — options depend on what the rate applies to.",
|
||||
hideWhen: { field: "trigger", equals: ["OVERWEIGHT"] },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -15,8 +15,6 @@ export interface Rate {
|
||||
proposedByStaffId: string;
|
||||
approvedByCeoId: string | null;
|
||||
approvedAt: string | null;
|
||||
effectiveFrom: string;
|
||||
effectiveTo: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deletedAt: string | null;
|
||||
|
||||
Reference in New Issue
Block a user