mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 11:21:18 +00:00
257 lines
9.0 KiB
TypeScript
257 lines
9.0 KiB
TypeScript
import { NotificationAudience, NotificationType } from '@edr/types';
|
|
import {
|
|
BadRequestException,
|
|
ConflictException,
|
|
ForbiddenException,
|
|
Injectable,
|
|
Logger,
|
|
NotFoundException,
|
|
} from '@nestjs/common';
|
|
import { InjectRepository } from '@nestjs/typeorm';
|
|
import { Repository } from 'typeorm';
|
|
|
|
import { NotificationInboxService } from '../../notification-inbox/notification-inbox.service';
|
|
import { SubmitRateChangeDto } from '../dto/rate-change-request.dto';
|
|
import { UpdateRateDto } from '../dto/update-rate.dto';
|
|
import {
|
|
RateChangeRequest,
|
|
RateChangeStatus,
|
|
} from '../entities/rate-change-request.entity';
|
|
import { Rate } from '../entities/rate.entity';
|
|
import { RatesService } from './rates.service';
|
|
import { FREIGHT_PERMS } from '../../../seed/freight-permissions.registry';
|
|
|
|
/** Backoffice page where both the queue and the rates live. */
|
|
const RATES_LINK = '/dashboard/rules/rates';
|
|
|
|
/** Fields a change request may carry — anything else in the patch is ignored. */
|
|
const DIFFABLE_FIELDS = [
|
|
'rateValue',
|
|
'currency',
|
|
'rateUnit',
|
|
'appliesTo',
|
|
'trigger',
|
|
'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',
|
|
// Container last-mile distance bands. Missing here, a band-range edit on a
|
|
// LIVE last-mile rate would diff to "nothing changed".
|
|
'minKm',
|
|
'maxKm',
|
|
// PER_LITER fuel surcharge billing base. Missing here, a switch to PER_LITER
|
|
// dropped the submitted liters and validation failed with "needs a base
|
|
// liters amount" even though the payload carried one.
|
|
'baseLiters',
|
|
] as const;
|
|
|
|
/**
|
|
* Approval workflow for edits to LIVE rates.
|
|
*
|
|
* A LIVE rate is what pricing charges right now, so it is never edited in
|
|
* place. The edit is filed here as a PENDING request and the live row keeps
|
|
* its old value — a rate at 100 USD keeps quoting 100 while a change to 200
|
|
* waits. Approval replays the edit through RatesService, so every rule
|
|
* (unit validity, pattern uniqueness) is re-checked against whatever is true
|
|
* at approval time, not at submit time.
|
|
*/
|
|
@Injectable()
|
|
export class RateChangeRequestsService {
|
|
private readonly logger = new Logger(RateChangeRequestsService.name);
|
|
|
|
constructor(
|
|
@InjectRepository(RateChangeRequest)
|
|
private readonly repo: Repository<RateChangeRequest>,
|
|
private readonly rates: RatesService,
|
|
private readonly inbox: NotificationInboxService,
|
|
) {}
|
|
|
|
/**
|
|
* File an edit against a LIVE rate. Validated up front so the requester
|
|
* hears about a bad unit or a pattern clash immediately rather than the
|
|
* approver hitting it days later.
|
|
*/
|
|
async submit(dto: SubmitRateChangeDto, userId?: string | null): Promise<RateChangeRequest> {
|
|
const rate = await this.rates.findById(dto.rateId);
|
|
if (rate.status !== 'LIVE') {
|
|
throw new BadRequestException(
|
|
`Only LIVE rates go through approval — this rate is ${rate.status} and can be edited directly.`,
|
|
);
|
|
}
|
|
|
|
const payload = this.changedFieldsOnly(rate, dto.update);
|
|
if (Object.keys(payload).length === 0) {
|
|
throw new BadRequestException('Nothing changed — the proposed values match the live rate.');
|
|
}
|
|
|
|
// One pending edit per rate: two racing requests would both validate, then
|
|
// the second would silently overwrite the first on approval.
|
|
const inFlight = await this.repo.findOne({
|
|
where: { rateId: dto.rateId, status: 'PENDING' },
|
|
});
|
|
if (inFlight) {
|
|
throw new ConflictException(
|
|
'This rate already has a change awaiting approval. Have it approved or rejected first.',
|
|
);
|
|
}
|
|
|
|
await this.rates.assertUpdateValid(dto.rateId, payload as UpdateRateDto);
|
|
|
|
const request = await this.repo.save(
|
|
this.repo.create({
|
|
rateId: dto.rateId,
|
|
payload,
|
|
previousValues: this.snapshot(rate, payload),
|
|
status: 'PENDING',
|
|
requestedByUserId: userId ?? null,
|
|
}),
|
|
);
|
|
|
|
this.notifyTeam(
|
|
'Rate change submitted',
|
|
`A change to a LIVE rate was submitted and awaits approval. The current rate stays in effect until it is approved.`,
|
|
request,
|
|
);
|
|
return request;
|
|
}
|
|
|
|
async list(status?: RateChangeStatus): Promise<RateChangeRequest[]> {
|
|
return this.repo.find({
|
|
where: status ? { status } : {},
|
|
relations: { rate: true },
|
|
order: { createdAt: 'DESC' },
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Approve and apply. The live mutation runs FIRST — if it now fails (someone
|
|
* created a clashing rate since submit), the request stays PENDING and the
|
|
* approver sees the real error instead of a request marked approved that
|
|
* never landed.
|
|
*/
|
|
async approve(
|
|
id: string,
|
|
userId?: string | null,
|
|
decisionNote?: string,
|
|
canSelfApprove = false,
|
|
): Promise<RateChangeRequest> {
|
|
const request = await this.findPending(id);
|
|
|
|
// Separation of duties: the requester cannot approve their own repricing —
|
|
// except super admins, who have full backoffice authority.
|
|
if (!canSelfApprove && userId && userId === request.requestedByUserId) {
|
|
throw new ForbiddenException('You cannot approve a rate change you submitted');
|
|
}
|
|
|
|
await this.rates.applyApprovedUpdate(request.rateId, request.payload as UpdateRateDto);
|
|
|
|
request.status = 'APPROVED';
|
|
request.decidedByUserId = userId ?? null;
|
|
request.decidedAt = new Date();
|
|
request.decisionNote = decisionNote ?? null;
|
|
const saved = await this.repo.save(request);
|
|
|
|
this.notifyTeam(
|
|
'Rate change approved',
|
|
`The rate change was approved and is now live.` +
|
|
(decisionNote ? ` Note: ${decisionNote}` : ''),
|
|
saved,
|
|
);
|
|
return saved;
|
|
}
|
|
|
|
/** Reject — the live rate is never touched, so it simply keeps its value. */
|
|
async reject(
|
|
id: string,
|
|
userId?: string | null,
|
|
decisionNote?: string,
|
|
): Promise<RateChangeRequest> {
|
|
const request = await this.findPending(id);
|
|
request.status = 'REJECTED';
|
|
request.decidedByUserId = userId ?? null;
|
|
request.decidedAt = new Date();
|
|
request.decisionNote = decisionNote ?? null;
|
|
const saved = await this.repo.save(request);
|
|
|
|
this.notifyTeam(
|
|
'Rate change rejected',
|
|
`The rate change was rejected — the rate keeps its current value.` +
|
|
(decisionNote ? ` Note: ${decisionNote}` : ''),
|
|
saved,
|
|
);
|
|
return saved;
|
|
}
|
|
|
|
/**
|
|
* Keep only fields the requester actually changed. A form posts every field
|
|
* back, so without this the diff would list untouched values as changes.
|
|
*/
|
|
private changedFieldsOnly(rate: Rate, update: UpdateRateDto): Record<string, unknown> {
|
|
const patch: Record<string, unknown> = {};
|
|
for (const field of DIFFABLE_FIELDS) {
|
|
const proposed = (update as Record<string, unknown>)[field];
|
|
if (proposed === undefined) continue;
|
|
if (this.sameValue(proposed, (rate as unknown as Record<string, unknown>)[field])) continue;
|
|
patch[field] = proposed;
|
|
}
|
|
return patch;
|
|
}
|
|
|
|
/** The live values the patch would overwrite — the "before" side of the diff. */
|
|
private snapshot(rate: Rate, payload: Record<string, unknown>): Record<string, unknown> {
|
|
const before: Record<string, unknown> = {};
|
|
for (const field of Object.keys(payload)) {
|
|
before[field] = (rate as unknown as Record<string, unknown>)[field] ?? null;
|
|
}
|
|
return before;
|
|
}
|
|
|
|
/**
|
|
* rateValue arrives as a string from Postgres `numeric` but as a number from
|
|
* the form, so 100 and "100.0000" must compare equal or every submit would
|
|
* look like a change.
|
|
*/
|
|
private sameValue(a: unknown, b: unknown): boolean {
|
|
if (a === b) return true;
|
|
if (a == null && b == null) return true;
|
|
if (a == null || b == null) return false;
|
|
const numA = Number(a);
|
|
const numB = Number(b);
|
|
if (!Number.isNaN(numA) && !Number.isNaN(numB) && a !== '' && b !== '') {
|
|
return numA === numB;
|
|
}
|
|
return String(a) === String(b);
|
|
}
|
|
|
|
private async findPending(id: string): Promise<RateChangeRequest> {
|
|
const request = await this.repo.findOne({ where: { id }, relations: { rate: true } });
|
|
if (!request) throw new NotFoundException(`Rate change request ${id} not found`);
|
|
if (request.status !== 'PENDING') {
|
|
throw new ConflictException(`Rate change request is already ${request.status.toLowerCase()}`);
|
|
}
|
|
return request;
|
|
}
|
|
|
|
/** Fire-and-forget — a notification failure never blocks the workflow. */
|
|
private notifyTeam(title: string, body: string, request: RateChangeRequest): void {
|
|
void this.inbox
|
|
.notify({
|
|
recipients: {
|
|
permissionKeys: [FREIGHT_PERMS.ruleEngine.getNotification],
|
|
},
|
|
audience: NotificationAudience.BACKOFFICE,
|
|
type: NotificationType.REQUEST_SUBMITTED,
|
|
title,
|
|
body,
|
|
link: RATES_LINK,
|
|
data: { rateChangeRequestId: request.id, rateId: request.rateId },
|
|
})
|
|
.catch((err) =>
|
|
this.logger.warn(`Rate-change notification failed: ${(err as Error).message}`),
|
|
);
|
|
}
|
|
}
|