Merge branch 'freight_feature/profile' of github.com:Tria-plc/edr-platform into freight_feature/profile

This commit is contained in:
marshal
2026-06-24 02:49:34 +03:00
8 changed files with 338 additions and 64 deletions

View File

@@ -242,12 +242,23 @@ export class BookingPricingService {
)
: 0;
// Consolidation is system-managed: the CONSOLIDATION_ENABLED surcharge fires
// whenever any container line leaves a wagon partially filled. Derived from
// the container quantities there is no persisted opt-in flag.
// Consolidation is system-managed: the CONSOLIDATION surcharge fires whenever
// a container type leaves a wagon partially filled. Aggregate by type first —
// two lines of the same type share wagons, so 2× 20FT (= one full wagon) must
// NOT count as a partial wagon. Mirrors ConsolidationService.slotsFromContainerLines.
const remainderByType = new Map<string, { quantity: number; perWagon: number }>();
for (const l of lines) {
const prev = remainderByType.get(l.container.containerTypeId);
remainderByType.set(l.container.containerTypeId, {
quantity: (prev?.quantity ?? 0) + Number(l.quantity || 0),
perWagon: l.perWagon,
});
}
const allowConsolidation =
booking.freightType === 'CONTAINER' &&
lines.some((l) => wagonRemainder(l.quantity, l.perWagon) > 0);
[...remainderByType.values()].some(
(t) => wagonRemainder(t.quantity, t.perWagon) > 0,
);
return {
freightType: booking.freightType as 'CONTAINER' | 'BULK',

View File

@@ -57,16 +57,29 @@ export class ConsolidationService {
async slotsFromContainerLines(
lines: Array<{ containerTypeId: string; quantity: number }>,
): Promise<ConsolidationSlot[]> {
const slots: ConsolidationSlot[] = [];
// Aggregate by container type first: two lines of the same type on one
// booking share the same wagons. Counting them separately would flag a
// self-complete booking (e.g. 2× 20FT = exactly one wagon) as a partial
// wagon and wrongly park it in PENDING_CONSOLIDATION.
const quantityByType = new Map<string, number>();
for (const line of lines) {
const ct = await this.containerTypesService.findById(line.containerTypeId);
if (!line.containerTypeId) continue;
quantityByType.set(
line.containerTypeId,
(quantityByType.get(line.containerTypeId) ?? 0) + Number(line.quantity || 0),
);
}
const slots: ConsolidationSlot[] = [];
for (const [containerTypeId, quantity] of quantityByType) {
const ct = await this.containerTypesService.findById(containerTypeId);
const perWagon = containersPerWagon(Number(ct.wagonsPerUnit));
const remainder = wagonRemainder(line.quantity, perWagon);
const remainder = wagonRemainder(quantity, perWagon);
if (remainder === 0) continue;
slots.push({
containerTypeId: line.containerTypeId,
containerTypeId,
containerTypeCode: ct.code,
quantity: line.quantity,
quantity,
containersPerWagon: perWagon,
remainder,
slotsNeeded: perWagon - remainder,

View File

@@ -201,8 +201,15 @@ export class RuleEngineService {
// Surcharges are now self-describing rates: any LIVE rate whose `trigger`
// is not ALWAYS. Each fires independently and stacks on top of base freight
// — hazard + reefer + overweight all add together, each with its own unit.
//
// A given surcharge identity (same trigger + rateType + unit + value +
// 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 surchargeRates = liveRates.filter((r) => r.trigger && r.trigger !== 'ALWAYS');
const surchargeRates = this.dedupeRatesBySignature(
liveRates.filter((r) => r.trigger && r.trigger !== 'ALWAYS'),
);
for (const rate of surchargeRates) {
const triggered = this.matchesTrigger(rate.trigger, {
@@ -404,4 +411,34 @@ export class RuleEngineService {
private surchargeCode(rate: Rate): string {
return rate.rateType ?? rate.trigger;
}
/**
* Collapse rates that describe the same charge to a single representative.
*
* Two rates are "the same" when they would produce an identical price line:
* same trigger, rateType, unit, value, currency, and scoping (container /
* cargo type). Duplicate rows (e.g. a seeder run more than once) therefore
* stack into one line instead of repeating — keeping the breakdown clean and
* the total correct. The first row of each signature is kept so an existing
* rateId is preserved for snapshotting.
*/
private dedupeRatesBySignature(rates: Rate[]): Rate[] {
const seen = new Set<string>();
const result: Rate[] = [];
for (const rate of rates) {
const signature = [
rate.trigger,
rate.rateType,
rate.rateUnit,
Number(rate.rateValue),
rate.currency,
rate.containerTypeId ?? '',
rate.cargoTypeId ?? '',
].join('|');
if (seen.has(signature)) continue;
seen.add(signature);
result.push(rate);
}
return result;
}
}

View File

@@ -453,18 +453,50 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise<void> {
{ appliesTo: "OTHER", trigger: "CONSOLIDATION", rateType: "LASHING", rateValue: 50, rateUnit: "PER_CONTAINER" },
];
const entities = rateData.map((d) =>
rRepo.create({
// Idempotent: insert each canonical rate only if no row with the same
// signature already exists. Re-running the seeder must NOT accumulate
// duplicate rows — duplicated surcharge rates would otherwise repeat on
// every booking's price breakdown.
const signature = (r: {
rateType: string;
rateUnit: string;
rateValue: number;
currency: string;
containerTypeId?: string | null;
cargoTypeId?: string | null;
}) =>
[
r.rateType,
r.rateUnit,
Number(r.rateValue),
r.currency,
r.containerTypeId ?? "",
r.cargoTypeId ?? "",
].join("|");
const existing: Rate[] = await rRepo.find();
const existingBySignature = new Set(existing.map((r) => signature(r)));
const toCreate = rateData
.map((d) => ({
currency: "USD",
...d,
status: "LIVE",
status: "LIVE" as const,
proposedByStaffId: STAFF_USER_ID,
approvedByCeoId: CEO_USER_ID,
approvedAt: now,
effectiveFrom,
}),
);
return rRepo.save(entities);
}))
.filter((d) => !existingBySignature.has(signature(d)));
if (toCreate.length === 0) {
this.logger.log("Rates already seeded — skipping (idempotent)");
return existing;
}
const created = await rRepo.save(toCreate.map((d) => rRepo.create(d)));
this.logger.log(`Seeded ${created.length} new rate(s)`);
return [...existing, ...created];
}
private async seedDraftBookings(