mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge pull request #761 from Tria-plc/freight_feature/usermanagement
fix rate edit
This commit is contained in:
@@ -4,6 +4,7 @@ import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard';
|
||||
import { FreightPermissionGuard } from './freight-permission.guard';
|
||||
import {
|
||||
FREIGHT_PERMS,
|
||||
type RuleEngineApprovableSlug,
|
||||
type RuleEngineResourceSlug,
|
||||
} from '../seed/freight-permissions.registry';
|
||||
|
||||
@@ -16,3 +17,13 @@ export const RuleEngineManage = (slug: RuleEngineResourceSlug) =>
|
||||
applyDecorators(
|
||||
UseGuards(JwtGuard, FreightPermissionGuard([FREIGHT_PERMS.ruleEngine.manage(slug)])),
|
||||
);
|
||||
|
||||
/**
|
||||
* Deciding a filed change — a step above `manage`, which only lets a staff
|
||||
* member propose one. Super admins pass any freight permission check, so
|
||||
* approvals work before the permission is granted to a director role.
|
||||
*/
|
||||
export const RuleEngineApprove = (slug: RuleEngineApprovableSlug) =>
|
||||
applyDecorators(
|
||||
UseGuards(JwtGuard, FreightPermissionGuard([FREIGHT_PERMS.ruleEngine.approve(slug)])),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Approval workflow for edits to LIVE rates. A LIVE rate is what pricing
|
||||
* charges, so it is never edited in place: the edit is filed here as PENDING
|
||||
* and the live row keeps its value until an approver applies it.
|
||||
*
|
||||
* `payload` holds the changed fields only; `previous_values` snapshots what
|
||||
* they were at submit time so the approver sees a real before→after diff.
|
||||
*/
|
||||
export class CreateRateChangeRequests2300000000000 implements MigrationInterface {
|
||||
name = 'CreateRateChangeRequests2300000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.rate_change_requests (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
rate_id uuid NOT NULL REFERENCES freight.rates (id),
|
||||
payload jsonb NOT NULL,
|
||||
previous_values jsonb NOT NULL,
|
||||
status varchar(10) NOT NULL DEFAULT 'PENDING',
|
||||
requested_by_user_id uuid NULL,
|
||||
decided_by_user_id uuid NULL,
|
||||
decided_at timestamptz NULL,
|
||||
decision_note text NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
deleted_at timestamptz NULL
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_rcr_status
|
||||
ON freight.rate_change_requests (status)
|
||||
`);
|
||||
// At most one pending edit per rate — two racing requests would both pass
|
||||
// validation and the second would silently overwrite the first on approval.
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_rcr_one_pending_per_rate
|
||||
ON freight.rate_change_requests (rate_id)
|
||||
WHERE status = 'PENDING' AND deleted_at IS NULL
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.rate_change_requests`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { Body, Controller, Get, Param, ParseUUIDPipe, Post, Query } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { CurrentUser } from '@edr/api-common';
|
||||
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
||||
|
||||
import { isSuperAdmin } from '../../../common/freight-permission.util';
|
||||
import { RuleEngineApprove, RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
|
||||
import { DecideRateChangeDto, SubmitRateChangeDto } from '../dto/rate-change-request.dto';
|
||||
import { RateChangeStatus } from '../entities/rate-change-request.entity';
|
||||
import { RateChangeRequestsService } from '../services/rate-change-requests.service';
|
||||
|
||||
/**
|
||||
* Edits to LIVE rates. Staff with `manage` propose (submit); only holders of
|
||||
* `approve` decide. Until a change is approved the live rate keeps its current
|
||||
* value, so pricing never moves on an unapproved edit.
|
||||
*/
|
||||
@ApiTags('rate-change-requests')
|
||||
@Controller('rate-change-requests')
|
||||
@ApiBearerAuth()
|
||||
export class RateChangeRequestsController {
|
||||
constructor(private readonly service: RateChangeRequestsService) {}
|
||||
|
||||
@Post()
|
||||
@RuleEngineManage('rates')
|
||||
@ApiOperation({ summary: 'Propose a change to a LIVE rate' })
|
||||
submit(@Body() dto: SubmitRateChangeDto, @CurrentUser() user: TCurrentUser) {
|
||||
return this.service.submit(dto, user?.id);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@RuleEngineView('rates')
|
||||
@ApiOperation({ summary: 'List rate change requests, optionally by status' })
|
||||
list(@Query('status') status?: RateChangeStatus) {
|
||||
return this.service.list(status);
|
||||
}
|
||||
|
||||
@Post(':id/approve')
|
||||
@RuleEngineApprove('rates')
|
||||
@ApiOperation({ summary: 'Approve a rate change and put it into effect' })
|
||||
approve(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: DecideRateChangeDto,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
return this.service.approve(id, user?.id, dto.decisionNote, isSuperAdmin(user));
|
||||
}
|
||||
|
||||
@Post(':id/reject')
|
||||
@RuleEngineApprove('rates')
|
||||
@ApiOperation({ summary: 'Reject a rate change — the rate keeps its current value' })
|
||||
reject(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: DecideRateChangeDto,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
return this.service.reject(id, user?.id, dto.decisionNote);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsOptional, IsString, IsUUID, MaxLength, ValidateNested } from 'class-validator';
|
||||
|
||||
import { UpdateRateDto } from './update-rate.dto';
|
||||
|
||||
export class SubmitRateChangeDto {
|
||||
@ApiProperty({ description: 'The LIVE rate to reprice' })
|
||||
@IsUUID()
|
||||
rateId!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'Proposed field changes. The live rate keeps its current values until this is approved.',
|
||||
type: UpdateRateDto,
|
||||
})
|
||||
@ValidateNested()
|
||||
@Type(() => UpdateRateDto)
|
||||
update!: UpdateRateDto;
|
||||
}
|
||||
|
||||
export class DecideRateChangeDto {
|
||||
@ApiPropertyOptional({ description: 'Optional note shown to the requester' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(1000)
|
||||
decisionNote?: string;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
||||
|
||||
import { Rate } from './rate.entity';
|
||||
|
||||
export type RateChangeStatus = 'PENDING' | 'APPROVED' | 'REJECTED';
|
||||
|
||||
/**
|
||||
* One proposed edit to a LIVE rate, awaiting approval.
|
||||
*
|
||||
* A LIVE rate is what pricing actually charges, so it is never mutated in
|
||||
* place: the edit is filed here and the live row keeps its old value until an
|
||||
* approver applies it. `payload` holds only the changed fields (an
|
||||
* UpdateRateDto patch), `rateId` the rate being repriced.
|
||||
*
|
||||
* DRAFT rates are not covered — nothing prices off a draft, so those still
|
||||
* edit directly and reach LIVE through the existing submit/approve flow.
|
||||
*/
|
||||
@Entity({ schema: 'freight', name: 'rate_change_requests' })
|
||||
@Index(['status'])
|
||||
export class RateChangeRequest extends BaseEntity {
|
||||
@Column({ name: 'rate_id', type: 'uuid' })
|
||||
rateId!: string;
|
||||
|
||||
@ManyToOne(() => Rate, { nullable: false })
|
||||
@JoinColumn({ name: 'rate_id' })
|
||||
rate?: Rate | null;
|
||||
|
||||
/** Proposed field changes — an UpdateRateDto patch, changed keys only. */
|
||||
@Column({ name: 'payload', type: 'jsonb' })
|
||||
payload!: Record<string, unknown>;
|
||||
|
||||
/**
|
||||
* The rate's values at submit time, for the approver's before→after diff.
|
||||
* Snapshotted because the live row can move on between submit and decision.
|
||||
*/
|
||||
@Column({ name: 'previous_values', type: 'jsonb' })
|
||||
previousValues!: Record<string, unknown>;
|
||||
|
||||
@Column({ name: 'status', type: 'varchar', length: 10, default: 'PENDING' })
|
||||
status!: RateChangeStatus;
|
||||
|
||||
@Column({ name: 'requested_by_user_id', type: 'uuid', nullable: true })
|
||||
requestedByUserId?: string | null;
|
||||
|
||||
@Column({ name: 'decided_by_user_id', type: 'uuid', nullable: true })
|
||||
decidedByUserId?: string | null;
|
||||
|
||||
@Column({ name: 'decided_at', type: 'timestamptz', nullable: true })
|
||||
decidedAt?: Date | null;
|
||||
|
||||
@Column({ name: 'decision_note', type: 'text', nullable: true })
|
||||
decisionNote?: string | null;
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { CargoTypesController } from './controllers/cargo-types.controller';
|
||||
import { ContainerTypesController } from './controllers/container-types.controller';
|
||||
import { PriorityConfigsController } from './controllers/priority-configs.controller';
|
||||
import { PriorityRuleChangeRequestsController } from './controllers/priority-rule-change-requests.controller';
|
||||
import { RateChangeRequestsController } from './controllers/rate-change-requests.controller';
|
||||
import { RatesController } from './controllers/rates.controller';
|
||||
import { ServiceTypesController } from './controllers/service-types.controller';
|
||||
import { ShippingLinesController } from './controllers/shipping-lines.controller';
|
||||
@@ -17,6 +18,7 @@ import { CargoType } from './entities/cargo-type.entity';
|
||||
import { ContainerType } from './entities/container-type.entity';
|
||||
import { PriorityConfig } from './entities/priority-config.entity';
|
||||
import { PriorityRuleChangeRequest } from './entities/priority-rule-change-request.entity';
|
||||
import { RateChangeRequest } from './entities/rate-change-request.entity';
|
||||
import { Rate } from './entities/rate.entity';
|
||||
import { ServiceType } from './entities/service-type.entity';
|
||||
import { ShippingLine } from './entities/shipping-line.entity';
|
||||
@@ -49,6 +51,7 @@ import { CargoTypesService } from './services/cargo-types.service';
|
||||
import { ContainerTypesService } from './services/container-types.service';
|
||||
import { PriorityConfigsService } from './services/priority-configs.service';
|
||||
import { PriorityRuleChangeRequestsService } from './services/priority-rule-change-requests.service';
|
||||
import { RateChangeRequestsService } from './services/rate-change-requests.service';
|
||||
import { RatesService } from './services/rates.service';
|
||||
import { ServiceTypesService } from './services/service-types.service';
|
||||
import { ShippingLinesService } from './services/shipping-lines.service';
|
||||
@@ -72,6 +75,7 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.
|
||||
ContainerType,
|
||||
PriorityConfig,
|
||||
PriorityRuleChangeRequest,
|
||||
RateChangeRequest,
|
||||
ServiceType,
|
||||
WeightLimitRule,
|
||||
Yard,
|
||||
@@ -91,6 +95,7 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.
|
||||
ContainerTypesController,
|
||||
PriorityConfigsController,
|
||||
PriorityRuleChangeRequestsController,
|
||||
RateChangeRequestsController,
|
||||
ServiceTypesController,
|
||||
WeightLimitRulesController,
|
||||
YardsController,
|
||||
@@ -121,6 +126,7 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.
|
||||
ContainerTypesService,
|
||||
PriorityConfigsService,
|
||||
PriorityRuleChangeRequestsService,
|
||||
RateChangeRequestsService,
|
||||
ServiceTypesService,
|
||||
WeightLimitRulesService,
|
||||
YardsService,
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
import { BadRequestException, ConflictException, ForbiddenException } from '@nestjs/common';
|
||||
|
||||
import { RateChangeRequest } from '../entities/rate-change-request.entity';
|
||||
import { Rate } from '../entities/rate.entity';
|
||||
import { RateChangeRequestsService } from './rate-change-requests.service';
|
||||
|
||||
/**
|
||||
* The guarantee under test: editing a LIVE rate never moves the live value.
|
||||
* A rate at 100 keeps charging 100 while a change to 200 sits PENDING; only
|
||||
* approval applies it, and only then through RatesService (so every rate rule
|
||||
* is re-checked against the state at approval time).
|
||||
*/
|
||||
describe('RateChangeRequestsService', () => {
|
||||
const liveRate = (overrides: Partial<Rate> = {}): Rate =>
|
||||
({
|
||||
id: 'rate-1',
|
||||
status: 'LIVE',
|
||||
rateType: 'OCEAN_FREIGHT',
|
||||
appliesTo: 'CONTAINER',
|
||||
trigger: 'ALWAYS',
|
||||
currency: 'USD',
|
||||
// Postgres numeric comes back as a string — the no-op check must cope.
|
||||
rateValue: '100.0000' as unknown as number,
|
||||
rateUnit: 'PER_CONTAINER',
|
||||
containerTypeId: null,
|
||||
cargoTypeId: null,
|
||||
tradeDirection: null,
|
||||
proposedByStaffId: 'staff-1',
|
||||
...overrides,
|
||||
}) as unknown as Rate;
|
||||
|
||||
const build = (opts: {
|
||||
rate?: Rate;
|
||||
pending?: RateChangeRequest | null;
|
||||
applyThrows?: Error;
|
||||
} = {}) => {
|
||||
const rate = opts.rate ?? liveRate();
|
||||
const saved: RateChangeRequest[] = [];
|
||||
|
||||
const repo = {
|
||||
findOne: jest.fn(async ({ where }: { where: Record<string, unknown> }) => {
|
||||
if (where.status === 'PENDING' && where.rateId) return opts.pending ?? null;
|
||||
return saved.find((r) => r.id === where.id) ?? opts.pending ?? null;
|
||||
}),
|
||||
create: jest.fn((data: Partial<RateChangeRequest>) => ({ id: 'req-1', ...data })),
|
||||
save: jest.fn(async (entity: RateChangeRequest) => {
|
||||
saved.push(entity);
|
||||
return entity;
|
||||
}),
|
||||
find: jest.fn(async () => saved),
|
||||
};
|
||||
|
||||
const rates = {
|
||||
findById: jest.fn(async () => rate),
|
||||
assertUpdateValid: jest.fn(async () => undefined),
|
||||
applyApprovedUpdate: jest.fn(async () => {
|
||||
if (opts.applyThrows) throw opts.applyThrows;
|
||||
return rate;
|
||||
}),
|
||||
};
|
||||
|
||||
const inbox = { notify: jest.fn(async () => undefined) };
|
||||
|
||||
const service = new RateChangeRequestsService(
|
||||
repo as never,
|
||||
rates as never,
|
||||
inbox as never,
|
||||
);
|
||||
// `pending` is the very object approve/reject mutate — assert on it, not a copy.
|
||||
return { service, repo, rates, inbox, pending: opts.pending };
|
||||
};
|
||||
|
||||
describe('submit', () => {
|
||||
it('files a pending request instead of touching the live rate', async () => {
|
||||
const { service, rates } = build();
|
||||
|
||||
const request = await service.submit({ rateId: 'rate-1', update: { rateValue: 200 } });
|
||||
|
||||
expect(request.status).toBe('PENDING');
|
||||
expect(request.payload).toEqual({ rateValue: 200 });
|
||||
// The old value is snapshotted for the approver's diff...
|
||||
expect(request.previousValues).toEqual({ rateValue: '100.0000' });
|
||||
// ...and nothing wrote to the rate itself.
|
||||
expect(rates.applyApprovedUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps only the fields that actually changed', async () => {
|
||||
const { service } = build();
|
||||
|
||||
// A form posts every field back; only rateValue differs from the live rate.
|
||||
const request = await service.submit({
|
||||
rateId: 'rate-1',
|
||||
update: {
|
||||
rateValue: 200,
|
||||
currency: 'USD',
|
||||
rateUnit: 'PER_CONTAINER',
|
||||
appliesTo: 'CONTAINER',
|
||||
},
|
||||
});
|
||||
|
||||
expect(request.payload).toEqual({ rateValue: 200 });
|
||||
});
|
||||
|
||||
it('rejects a no-op — 100 posted against a live 100.0000 is not a change', async () => {
|
||||
const { service } = build();
|
||||
await expect(
|
||||
service.submit({ rateId: 'rate-1', update: { rateValue: 100 } }),
|
||||
).rejects.toThrow(/Nothing changed/);
|
||||
});
|
||||
|
||||
it('refuses a rate that is not LIVE — those edit directly', async () => {
|
||||
const { service } = build({ rate: liveRate({ status: 'DRAFT' }) });
|
||||
await expect(
|
||||
service.submit({ rateId: 'rate-1', update: { rateValue: 200 } }),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('refuses a second pending change for the same rate', async () => {
|
||||
const { service } = build({
|
||||
pending: { id: 'req-0', status: 'PENDING' } as unknown as RateChangeRequest,
|
||||
});
|
||||
await expect(
|
||||
service.submit({ rateId: 'rate-1', update: { rateValue: 200 } }),
|
||||
).rejects.toThrow(ConflictException);
|
||||
});
|
||||
|
||||
it('validates up front so the requester hears about a bad patch, not the approver', async () => {
|
||||
const { service, rates } = build();
|
||||
rates.assertUpdateValid.mockRejectedValueOnce(
|
||||
new BadRequestException('Rate unit "PER_TON" is not valid for this rate.'),
|
||||
);
|
||||
await expect(
|
||||
service.submit({ rateId: 'rate-1', update: { rateUnit: 'PER_TON' } }),
|
||||
).rejects.toThrow(/not valid for this rate/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('approve', () => {
|
||||
const pendingRequest = (): RateChangeRequest =>
|
||||
({
|
||||
id: 'req-1',
|
||||
rateId: 'rate-1',
|
||||
payload: { rateValue: 200 },
|
||||
previousValues: { rateValue: '100.0000' },
|
||||
status: 'PENDING',
|
||||
requestedByUserId: 'staff-1',
|
||||
}) as unknown as RateChangeRequest;
|
||||
|
||||
it('applies the change through RatesService and marks it approved', async () => {
|
||||
const { service, rates } = build({ pending: pendingRequest() });
|
||||
|
||||
const decided = await service.approve('req-1', 'approver-1', 'Agreed');
|
||||
|
||||
expect(rates.applyApprovedUpdate).toHaveBeenCalledWith('rate-1', { rateValue: 200 });
|
||||
expect(decided.status).toBe('APPROVED');
|
||||
expect(decided.decidedByUserId).toBe('approver-1');
|
||||
expect(decided.decisionNote).toBe('Agreed');
|
||||
});
|
||||
|
||||
it('blocks the requester from approving their own change', async () => {
|
||||
const { service, rates } = build({ pending: pendingRequest() });
|
||||
await expect(service.approve('req-1', 'staff-1')).rejects.toThrow(ForbiddenException);
|
||||
expect(rates.applyApprovedUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('lets a super admin self-approve', async () => {
|
||||
const { service } = build({ pending: pendingRequest() });
|
||||
await expect(service.approve('req-1', 'staff-1', undefined, true)).resolves.toMatchObject({
|
||||
status: 'APPROVED',
|
||||
});
|
||||
});
|
||||
|
||||
it('stays PENDING when applying now fails — never marks a change that did not land', async () => {
|
||||
const { service, pending, repo } = build({
|
||||
pending: pendingRequest(),
|
||||
applyThrows: new ConflictException('A rate for this exact combination already exists.'),
|
||||
});
|
||||
|
||||
await expect(service.approve('req-1', 'approver-1')).rejects.toThrow(/already exists/);
|
||||
// Apply runs first, so a failure leaves the request untouched and re-decidable.
|
||||
expect(pending!.status).toBe('PENDING');
|
||||
expect(repo.save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refuses to decide an already-decided request', async () => {
|
||||
const { service } = build({
|
||||
pending: { ...pendingRequest(), status: 'APPROVED' } as unknown as RateChangeRequest,
|
||||
});
|
||||
await expect(service.approve('req-1', 'approver-1')).rejects.toThrow(ConflictException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reject', () => {
|
||||
it('never touches the rate — it simply keeps its current value', async () => {
|
||||
const { service, rates } = build({
|
||||
pending: {
|
||||
id: 'req-1',
|
||||
rateId: 'rate-1',
|
||||
payload: { rateValue: 200 },
|
||||
previousValues: { rateValue: '100.0000' },
|
||||
status: 'PENDING',
|
||||
requestedByUserId: 'staff-1',
|
||||
} as unknown as RateChangeRequest,
|
||||
});
|
||||
|
||||
const decided = await service.reject('req-1', 'approver-1', 'Too steep');
|
||||
|
||||
expect(decided.status).toBe('REJECTED');
|
||||
expect(decided.decisionNote).toBe('Too steep');
|
||||
expect(rates.applyApprovedUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,241 @@
|
||||
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';
|
||||
|
||||
/** 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',
|
||||
] 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: { allBackoffice: true },
|
||||
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}`),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -119,12 +119,62 @@ export class RatesService {
|
||||
});
|
||||
}
|
||||
|
||||
/** Update a DRAFT rate. */
|
||||
/**
|
||||
* Update a DRAFT rate in place. Nothing prices off a draft, so a direct edit
|
||||
* is safe. A LIVE rate cannot take this path — see `applyApprovedUpdate`.
|
||||
*/
|
||||
async update(id: string, dto: UpdateRateDto): Promise<Rate> {
|
||||
const existing = await this.findById(id);
|
||||
if (existing.status !== 'DRAFT') {
|
||||
throw new BadRequestException('Only DRAFT rates can be updated');
|
||||
throw new BadRequestException(
|
||||
existing.status === 'LIVE'
|
||||
? 'A LIVE rate cannot be edited directly — file a rate change request so an approver can apply it.'
|
||||
: 'Only DRAFT rates can be updated',
|
||||
);
|
||||
}
|
||||
return this.applyUpdate(existing, dto);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply an approved change request to a LIVE rate. Same validation as a
|
||||
* DRAFT edit — it just skips the DRAFT guard, because a LIVE rate reaching
|
||||
* here has already been through approval. Only ever called by
|
||||
* RateChangeRequestsService.approve.
|
||||
*/
|
||||
async applyApprovedUpdate(id: string, dto: UpdateRateDto): Promise<Rate> {
|
||||
const existing = await this.findById(id);
|
||||
if (existing.status !== 'LIVE') {
|
||||
throw new BadRequestException(
|
||||
`Rate change requests apply to LIVE rates only — this rate is ${existing.status}.`,
|
||||
);
|
||||
}
|
||||
return this.applyUpdate(existing, dto);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a proposed patch against a rate without writing anything — lets a
|
||||
* change request be refused at submit time instead of surprising the
|
||||
* approver. Throws exactly what applying it would throw.
|
||||
*/
|
||||
async assertUpdateValid(id: string, dto: UpdateRateDto): Promise<void> {
|
||||
await this.buildUpdate(await this.findById(id), dto);
|
||||
}
|
||||
|
||||
private async applyUpdate(existing: Rate, dto: UpdateRateDto): Promise<Rate> {
|
||||
const updates = await this.buildUpdate(existing, dto);
|
||||
const updated = await this.repository.update(existing.id, updates);
|
||||
if (!updated) throw new NotFoundException(`Rate ${existing.id} not found`);
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* The shared edit body: re-derives rateType, re-validates the unit against
|
||||
* the (possibly changed) shape, and guards pattern uniqueness. Status is
|
||||
* never touched — an approved edit to a LIVE rate stays LIVE. Pure apart
|
||||
* from the uniqueness read, so it doubles as the dry-run validator.
|
||||
*/
|
||||
private async buildUpdate(existing: Rate, dto: UpdateRateDto): Promise<Partial<Rate>> {
|
||||
const id = existing.id;
|
||||
const updates: Partial<Rate> = {};
|
||||
|
||||
const appliesTo = (dto.appliesTo as Rate['appliesTo']) ?? existing.appliesTo;
|
||||
@@ -179,9 +229,7 @@ export class RatesService {
|
||||
|
||||
updates.currency = dto.currency ?? existing.currency ?? 'USD';
|
||||
if (dto.rateValue !== undefined) updates.rateValue = dto.rateValue;
|
||||
const updated = await this.repository.update(id, updates);
|
||||
if (!updated) throw new NotFoundException(`Rate ${id} not found`);
|
||||
return updated;
|
||||
return updates;
|
||||
}
|
||||
|
||||
/** Submit a DRAFT rate for CEO approval. */
|
||||
|
||||
@@ -3,20 +3,6 @@ import { Type } from 'class-transformer';
|
||||
import { IsInt, IsNumber, IsOptional, Max, Min } from 'class-validator';
|
||||
|
||||
export class UpdateTrainSchedulingGlobalRulesDto {
|
||||
@ApiPropertyOptional({ example: 760 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
maxTrainLengthMeters?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 3500 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
maxTrainWeightTons?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 53 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@@ -24,20 +10,6 @@ export class UpdateTrainSchedulingGlobalRulesDto {
|
||||
@Min(1)
|
||||
maxWagonsPerTrain?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 30 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(0.001)
|
||||
max20ftContainerWeightTons?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 10 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
max20ftPairWeightDiffTons?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 3, description: 'Days before departure the import booking-window day falls on' })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
|
||||
@@ -600,7 +600,24 @@ export class TrainSchedulingService {
|
||||
}
|
||||
|
||||
async getTrainSchedulingGlobalRules() {
|
||||
return this.loadGlobalRulesRow();
|
||||
return this.toPublicGlobalRules(await this.loadGlobalRulesRow());
|
||||
}
|
||||
|
||||
/**
|
||||
* Train length/weight and 20ft weight caps are engine-internal (wagon
|
||||
* planning still reads them off the row); they are no longer exposed or
|
||||
* editable through the global-rules endpoints.
|
||||
*/
|
||||
private toPublicGlobalRules(row: TrainSchedulingGlobalRules | null) {
|
||||
if (!row) return row;
|
||||
const {
|
||||
maxTrainLengthMeters: _len,
|
||||
maxTrainWeightTons: _wt,
|
||||
max20ftContainerWeightTons: _cw,
|
||||
max20ftPairWeightDiffTons: _pd,
|
||||
...pub
|
||||
} = row;
|
||||
return pub;
|
||||
}
|
||||
|
||||
async updateTrainSchedulingGlobalRules(dto: UpdateTrainSchedulingGlobalRulesDto) {
|
||||
@@ -608,15 +625,7 @@ export class TrainSchedulingService {
|
||||
if (!row) {
|
||||
throw new NotFoundException('Train scheduling global rules not configured');
|
||||
}
|
||||
if (dto.maxTrainLengthMeters != null) row.maxTrainLengthMeters = dto.maxTrainLengthMeters;
|
||||
if (dto.maxTrainWeightTons != null) row.maxTrainWeightTons = dto.maxTrainWeightTons;
|
||||
if (dto.maxWagonsPerTrain != null) row.maxWagonsPerTrain = dto.maxWagonsPerTrain;
|
||||
if (dto.max20ftContainerWeightTons != null) {
|
||||
row.max20ftContainerWeightTons = dto.max20ftContainerWeightTons;
|
||||
}
|
||||
if (dto.max20ftPairWeightDiffTons != null) {
|
||||
row.max20ftPairWeightDiffTons = dto.max20ftPairWeightDiffTons;
|
||||
}
|
||||
if (dto.importWindowLeadDays != null) row.importWindowLeadDays = dto.importWindowLeadDays;
|
||||
if (dto.exportBookingLeadHours != null) row.exportBookingLeadHours = dto.exportBookingLeadHours;
|
||||
if (dto.windowOpenHour != null) row.windowOpenHour = dto.windowOpenHour;
|
||||
@@ -654,7 +663,7 @@ export class TrainSchedulingService {
|
||||
await this.restampPendingWindows();
|
||||
}
|
||||
|
||||
return saved;
|
||||
return this.toPublicGlobalRules(saved);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -99,13 +99,28 @@ const RULE_ENGINE_PERMISSION_IDS: Record<RuleEngineResourceSlug, { view: string;
|
||||
'approval-rules': { view: 'b2000001-0001-4000-8000-000000000013', manage: 'b2000001-0001-4000-8000-000000000014' },
|
||||
};
|
||||
|
||||
/**
|
||||
* Slugs whose changes go through a separate approver. `manage` lets a staff
|
||||
* member propose a change; only `approve` lets someone put it into effect.
|
||||
* Only listed slugs get the permission — the rest are manage-only.
|
||||
*/
|
||||
const RULE_ENGINE_APPROVE_PERMISSION_IDS: Partial<Record<RuleEngineResourceSlug, string>> = {
|
||||
rates: 'b2000001-0001-4000-8000-000000000017',
|
||||
};
|
||||
|
||||
export type RuleEngineApprovableSlug = 'rates';
|
||||
|
||||
export const RULE_ENGINE_PERMISSIONS: FreightPermissionSeed[] = RULE_ENGINE_RESOURCE_SLUGS.flatMap(
|
||||
(slug) => {
|
||||
const resource = slugToResourceKey(slug);
|
||||
const ids = RULE_ENGINE_PERMISSION_IDS[slug];
|
||||
const approveId = RULE_ENGINE_APPROVE_PERMISSION_IDS[slug];
|
||||
return [
|
||||
perm(ids.view, `edr_freight_app:rule_engine:${resource}:view`, `View ${slug}`),
|
||||
perm(ids.manage, `edr_freight_app:rule_engine:${resource}:manage`, `Manage ${slug}`),
|
||||
...(approveId
|
||||
? [perm(approveId, `edr_freight_app:rule_engine:${resource}:approve`, `Approve ${slug} changes`)]
|
||||
: []),
|
||||
];
|
||||
},
|
||||
);
|
||||
@@ -389,6 +404,8 @@ export const FREIGHT_PERMS = {
|
||||
`edr_freight_app:rule_engine:${slugToResourceKey(slug)}:view`,
|
||||
manage: (slug: RuleEngineResourceSlug) =>
|
||||
`edr_freight_app:rule_engine:${slugToResourceKey(slug)}:manage`,
|
||||
approve: (slug: RuleEngineApprovableSlug) =>
|
||||
`edr_freight_app:rule_engine:${slugToResourceKey(slug)}:approve`,
|
||||
},
|
||||
allocation: {
|
||||
manage: 'edr_freight_app:allocation:manage',
|
||||
|
||||
@@ -167,6 +167,7 @@ export const QUERY_KEYS = {
|
||||
orderList: (resource: RuleEngineResourceSlug | string) =>
|
||||
["rule-engine", "order-list", resource] as const,
|
||||
priorityRuleChanges: ["rule-engine", "priority-rule-changes"] as const,
|
||||
rateChanges: ["rule-engine", "rate-changes"] as const,
|
||||
},
|
||||
|
||||
OVERVIEW: {
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
ruleEngineService,
|
||||
type RuleEngineListParams,
|
||||
type SubmitPriorityRuleChangePayload,
|
||||
type SubmitRateChangePayload,
|
||||
} from "@/services/ruleEngine/ruleEngine.service";
|
||||
import { RULE_ENGINE_SELECT_NONE } from "@/pages/ruleEngine/config/resources";
|
||||
import type {
|
||||
@@ -318,6 +319,71 @@ export const usePriorityRuleWorkflow = (
|
||||
return { pending, submit, approve, reject };
|
||||
};
|
||||
|
||||
/**
|
||||
* Approval workflow for edits to LIVE rates. The live rate keeps its current
|
||||
* value until a change is approved, so the rates list is invalidated on every
|
||||
* outcome — including reject, which restores the row's "no pending" state.
|
||||
*/
|
||||
export const useRateChangeWorkflow = (
|
||||
enabled: boolean,
|
||||
onErrorMessage?: (message: string) => void,
|
||||
) => {
|
||||
const qc = useQueryClient();
|
||||
|
||||
const showError = (err: unknown, fallback: string) => {
|
||||
const raw = (err as { response?: { data?: { message?: string | string[] } } })
|
||||
?.response?.data?.message;
|
||||
const message = (Array.isArray(raw) ? raw.join(", ") : raw) || fallback;
|
||||
if (onErrorMessage) onErrorMessage(message);
|
||||
else toast.error(message);
|
||||
};
|
||||
|
||||
const pending = useQuery({
|
||||
queryKey: QUERY_KEYS.RULE_ENGINE.rateChanges,
|
||||
queryFn: () => ruleEngineService.listRateChanges("PENDING"),
|
||||
enabled,
|
||||
});
|
||||
|
||||
const invalidate = async () => {
|
||||
await qc.invalidateQueries({ queryKey: QUERY_KEYS.RULE_ENGINE.rateChanges });
|
||||
await invalidateRuleEngineList(qc, "rates");
|
||||
};
|
||||
|
||||
const submit = useMutation({
|
||||
mutationFn: (payload: SubmitRateChangePayload) =>
|
||||
ruleEngineService.submitRateChange(payload),
|
||||
onSuccess: async () => {
|
||||
toast.success(
|
||||
"Change submitted for approval — the rate keeps its current value until approved",
|
||||
);
|
||||
await invalidate();
|
||||
},
|
||||
onError: (err) => showError(err, "Failed to submit rate change"),
|
||||
});
|
||||
|
||||
const approve = useMutation({
|
||||
mutationFn: ({ id, decisionNote }: { id: string; decisionNote?: string }) =>
|
||||
ruleEngineService.approveRateChange(id, decisionNote),
|
||||
onSuccess: async () => {
|
||||
toast.success("Rate change approved — the new rate is now live");
|
||||
await invalidate();
|
||||
},
|
||||
onError: (err) => showError(err, "Failed to approve rate change"),
|
||||
});
|
||||
|
||||
const reject = useMutation({
|
||||
mutationFn: ({ id, decisionNote }: { id: string; decisionNote?: string }) =>
|
||||
ruleEngineService.rejectRateChange(id, decisionNote),
|
||||
onSuccess: async () => {
|
||||
toast.success("Rate change rejected — the rate keeps its current value");
|
||||
await invalidate();
|
||||
},
|
||||
onError: (err) => showError(err, "Failed to reject rate change"),
|
||||
});
|
||||
|
||||
return { pending, submit, approve, reject };
|
||||
};
|
||||
|
||||
export const useRateWorkflow = () => {
|
||||
const qc = useQueryClient();
|
||||
|
||||
|
||||
@@ -446,6 +446,21 @@ export function ruleEngineManageKey(slug: RuleEngineResourceSlug): string {
|
||||
return `edr_freight_app:rule_engine:${slugToResourceKey(slug)}:manage`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deciding a filed change — a step above `manage`, which only lets a staff
|
||||
* member propose one. Only resources with an approval workflow have it.
|
||||
*/
|
||||
export function ruleEngineApproveKey(slug: "rates"): string {
|
||||
return `edr_freight_app:rule_engine:${slugToResourceKey(slug)}:approve`;
|
||||
}
|
||||
|
||||
export function canApproveRuleEngineChange(
|
||||
user: AuthUser | null | undefined,
|
||||
slug: "rates",
|
||||
): boolean {
|
||||
return hasPermission(user, ruleEngineApproveKey(slug));
|
||||
}
|
||||
|
||||
export function canAccessRuleEngineResource(
|
||||
user: AuthUser | null | undefined,
|
||||
slug: RuleEngineResourceSlug,
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Collapse,
|
||||
Group,
|
||||
Stack,
|
||||
Text,
|
||||
Textarea,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import type { UseMutationResult } from "@tanstack/react-query";
|
||||
import { ArrowRight, CheckCircle2, Clock, XCircle } from "lucide-react";
|
||||
|
||||
import type { RateChangeRequest } from "@/services/ruleEngine/ruleEngine.service";
|
||||
|
||||
/** Field labels for the diff — anything not listed falls back to the raw key. */
|
||||
const FIELD_LABELS: Record<string, string> = {
|
||||
rateValue: "Rate",
|
||||
currency: "Currency",
|
||||
rateUnit: "Unit",
|
||||
appliesTo: "Applies to",
|
||||
trigger: "Trigger",
|
||||
tradeDirection: "Direction",
|
||||
containerTypeId: "Container type",
|
||||
cargoTypeId: "Cargo type",
|
||||
};
|
||||
|
||||
const fmtDateTime = (iso: string) =>
|
||||
new Date(iso).toLocaleString("en-GB", {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
});
|
||||
|
||||
const fmtValue = (field: string, value: unknown): string => {
|
||||
if (value === null || value === undefined || value === "") return "—";
|
||||
if (field === "rateValue") {
|
||||
const num = Number(value);
|
||||
return Number.isNaN(num) ? String(value) : num.toLocaleString();
|
||||
}
|
||||
return String(value).replace(/_/g, " ");
|
||||
};
|
||||
|
||||
/** "Ocean freight · 40HC" — what rate this change targets. */
|
||||
const rateSummary = (r: RateChangeRequest): string => {
|
||||
const rate = (r.rate ?? {}) as Record<string, unknown>;
|
||||
const parts = [
|
||||
rate.rateType ? String(rate.rateType).replace(/_/g, " ") : null,
|
||||
rate.appliesTo ? String(rate.appliesTo) : null,
|
||||
rate.trigger && rate.trigger !== "ALWAYS" ? String(rate.trigger) : null,
|
||||
].filter(Boolean);
|
||||
return parts.join(" · ") || "Rate";
|
||||
};
|
||||
|
||||
/** The headline change, so the queue is scannable without expanding: "100 → 200 USD". */
|
||||
const headline = (r: RateChangeRequest): string | null => {
|
||||
if (!("rateValue" in r.payload)) return null;
|
||||
const currency = String(r.payload.currency ?? r.previousValues.currency ?? (r.rate as Record<string, unknown> | undefined)?.currency ?? "");
|
||||
const before = fmtValue("rateValue", r.previousValues.rateValue);
|
||||
const after = fmtValue("rateValue", r.payload.rateValue);
|
||||
return `${before} → ${after}${currency ? ` ${currency}` : ""}`;
|
||||
};
|
||||
|
||||
type Decide = UseMutationResult<
|
||||
RateChangeRequest,
|
||||
unknown,
|
||||
{ id: string; decisionNote?: string }
|
||||
>;
|
||||
|
||||
interface RateApprovalsSectionProps {
|
||||
requests: RateChangeRequest[];
|
||||
/** Whether this user holds the rates approve permission. */
|
||||
canDecide: boolean;
|
||||
approve: Decide;
|
||||
reject: Decide;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pending edits to LIVE rates. Each row is a before→after diff: the left value
|
||||
* is what pricing charges right now and keeps charging until someone approves.
|
||||
* Rendered above the rates table.
|
||||
*/
|
||||
const RateApprovalsSection = ({
|
||||
requests,
|
||||
canDecide,
|
||||
approve,
|
||||
reject,
|
||||
}: RateApprovalsSectionProps) => {
|
||||
const [openId, setOpenId] = useState<string | null>(null);
|
||||
const [notes, setNotes] = useState<Record<string, string>>({});
|
||||
|
||||
if (requests.length === 0) return null;
|
||||
|
||||
const decidingId = approve.variables?.id ?? reject.variables?.id ?? null;
|
||||
|
||||
return (
|
||||
<Card withBorder radius="md" padding="md" mb="md">
|
||||
<Group gap={8} mb={4}>
|
||||
<Clock size={16} />
|
||||
<Text fw={700}>Pending rate changes</Text>
|
||||
<Badge variant="light" color="yellow">
|
||||
{requests.length}
|
||||
</Badge>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed" mb="sm">
|
||||
Each rate below still charges its current value. Nothing changes until approved.
|
||||
</Text>
|
||||
|
||||
<Stack gap={8}>
|
||||
{requests.map((r) => {
|
||||
const isOpen = openId === r.id;
|
||||
const fields = Object.keys(r.payload);
|
||||
const summaryLine = headline(r);
|
||||
// Only the row being decided shows a spinner — the mutation's
|
||||
// isPending is shared across every row.
|
||||
const busy = decidingId === r.id;
|
||||
|
||||
return (
|
||||
<Card key={r.id} withBorder radius="md" padding="sm">
|
||||
<Group justify="space-between" wrap="nowrap" align="flex-start">
|
||||
<Stack gap={4} style={{ minWidth: 0 }}>
|
||||
<Group gap={8} wrap="nowrap">
|
||||
<Badge variant="light" color="blue" radius="sm">
|
||||
update
|
||||
</Badge>
|
||||
<Text size="sm" fw={600} truncate>
|
||||
{rateSummary(r)}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
{summaryLine ? (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Text size="sm" c="dimmed" td="line-through">
|
||||
{fmtValue("rateValue", r.previousValues.rateValue)}
|
||||
</Text>
|
||||
<ArrowRight size={13} />
|
||||
<Text size="sm" fw={700} c="edr-green">
|
||||
{fmtValue("rateValue", r.payload.rateValue)}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{String(
|
||||
r.payload.currency ??
|
||||
r.previousValues.currency ??
|
||||
(r.rate as Record<string, unknown> | undefined)?.currency ??
|
||||
"",
|
||||
)}
|
||||
</Text>
|
||||
</Group>
|
||||
) : null}
|
||||
|
||||
<Group gap={6}>
|
||||
<Text size="xs" c="dimmed">
|
||||
Submitted {fmtDateTime(r.createdAt)} · {fields.length}{" "}
|
||||
{fields.length === 1 ? "field" : "fields"} changed
|
||||
</Text>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="subtle"
|
||||
onClick={() => setOpenId(isOpen ? null : r.id)}
|
||||
>
|
||||
{isOpen ? "Hide details" : "See all changes"}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
{canDecide ? (
|
||||
<Group gap={8} wrap="nowrap">
|
||||
<Button
|
||||
size="compact-sm"
|
||||
variant="subtle"
|
||||
color="red"
|
||||
leftSection={<XCircle size={14} />}
|
||||
loading={busy && reject.isPending}
|
||||
disabled={busy && approve.isPending}
|
||||
onClick={() =>
|
||||
reject.mutate({ id: r.id, decisionNote: notes[r.id] || undefined })
|
||||
}
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
color="edr-green"
|
||||
leftSection={<CheckCircle2 size={14} />}
|
||||
loading={busy && approve.isPending}
|
||||
disabled={busy && reject.isPending}
|
||||
onClick={() =>
|
||||
approve.mutate({ id: r.id, decisionNote: notes[r.id] || undefined })
|
||||
}
|
||||
>
|
||||
Approve & apply
|
||||
</Button>
|
||||
</Group>
|
||||
) : (
|
||||
<Tooltip label="You need the rates approve permission to decide this">
|
||||
<Badge variant="light" color="gray" radius="sm">
|
||||
Awaiting approver
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
<Collapse in={isOpen}>
|
||||
<Stack gap={6} mt="sm" pt="sm" style={{ borderTop: "1px solid var(--mantine-color-default-border)" }}>
|
||||
{fields.map((field) => (
|
||||
<Group key={field} gap={8} wrap="nowrap">
|
||||
<Text size="xs" c="dimmed" w={110} style={{ flexShrink: 0 }}>
|
||||
{FIELD_LABELS[field] ?? field}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed" td="line-through">
|
||||
{fmtValue(field, r.previousValues[field])}
|
||||
</Text>
|
||||
<ArrowRight size={13} />
|
||||
<Text size="sm" fw={600}>
|
||||
{fmtValue(field, r.payload[field])}
|
||||
</Text>
|
||||
</Group>
|
||||
))}
|
||||
{canDecide ? (
|
||||
<Textarea
|
||||
mt={4}
|
||||
size="xs"
|
||||
autosize
|
||||
minRows={2}
|
||||
label="Decision note (optional)"
|
||||
placeholder="Shown to the requester with your decision"
|
||||
value={notes[r.id] ?? ""}
|
||||
onChange={(e) =>
|
||||
setNotes((prev) => ({ ...prev, [r.id]: e.currentTarget.value }))
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Collapse>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default RateApprovalsSection;
|
||||
@@ -1,5 +1,8 @@
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { canAccessRuleEngineResource } from "@/lib/permissions";
|
||||
import {
|
||||
canAccessRuleEngineResource,
|
||||
canApproveRuleEngineChange,
|
||||
} from "@/lib/permissions";
|
||||
import type { ColumnDef } from "@edr/ui-common";
|
||||
import {
|
||||
Box,
|
||||
@@ -11,14 +14,16 @@ import {
|
||||
Modal,
|
||||
Stack,
|
||||
Text,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { Plus } from "lucide-react";
|
||||
import { Clock, Plus } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Navigate, useLocation, useParams } from "react-router-dom";
|
||||
|
||||
import { PageContainer, PageHeader } from "@/components/page";
|
||||
import ManageRuleEngineOrderDialog from "@/components/ruleEngine/ManageRuleEngineOrderDialog";
|
||||
import PriorityRuleApprovalsSection from "@/pages/ruleEngine/PriorityRuleApprovalsSection";
|
||||
import RateApprovalsSection from "@/pages/ruleEngine/RateApprovalsSection";
|
||||
import { nextPriorityRangeStart } from "@/pages/ruleEngine/priorityRuleRange";
|
||||
import RuleEngineCardGrid from "@/components/ruleEngine/RuleEngineCardGrid";
|
||||
import RuleEngineFormDialog from "@/components/ruleEngine/RuleEngineFormDialog";
|
||||
@@ -37,6 +42,7 @@ import {
|
||||
useLiveRateOptions,
|
||||
useWagonTypeOptions,
|
||||
usePriorityRuleWorkflow,
|
||||
useRateChangeWorkflow,
|
||||
useRateWorkflow,
|
||||
useRuleEngineList,
|
||||
useRuleEngineMutations,
|
||||
@@ -51,6 +57,7 @@ import {
|
||||
getRuleEngineResource,
|
||||
type RuleEngineNavCategory,
|
||||
} from "@/pages/ruleEngine/config/resources";
|
||||
import type { RateChangeRequest } from "@/services/ruleEngine/ruleEngine.service";
|
||||
import type { RuleEngineRecord } from "@/types/rule-engine";
|
||||
import {
|
||||
DataTable,
|
||||
@@ -145,6 +152,23 @@ const RuleEngineResourcePage = () => {
|
||||
chainOpen && config?.slug === "approval-rules",
|
||||
);
|
||||
|
||||
// A LIVE rate is what pricing charges, so editing one files a change request
|
||||
// instead of mutating: the rate keeps its current value until an approver
|
||||
// applies the change. DRAFT rates still edit directly.
|
||||
const isRates = config?.slug === "rates";
|
||||
const [rateError, setRateError] = useState<string | null>(null);
|
||||
const rateChangeWorkflow = useRateChangeWorkflow(
|
||||
Boolean(isRates && canView),
|
||||
setRateError,
|
||||
);
|
||||
const canApproveRates = Boolean(isRates && canApproveRuleEngineChange(user, "rates"));
|
||||
/** rateId → its pending change, for the row badge. */
|
||||
const pendingByRateId = useMemo(() => {
|
||||
const map = new Map<string, RateChangeRequest>();
|
||||
for (const r of rateChangeWorkflow.pending.data ?? []) map.set(r.rateId, r);
|
||||
return map;
|
||||
}, [rateChangeWorkflow.pending.data]);
|
||||
|
||||
// Priority rules never mutate directly: changes are filed for approval and a
|
||||
// pending queue renders above the table. Validation errors (range collision,
|
||||
// gap, ceiling) surface in a modal so the text is impossible to miss.
|
||||
@@ -306,7 +330,27 @@ const RuleEngineResourcePage = () => {
|
||||
id: col.id,
|
||||
header: col.header,
|
||||
meta: { headerClassName, cellClassName },
|
||||
cell: ({ row }) => formatCell(row.original[col.accessorKey], col.format),
|
||||
cell: ({ row }) => {
|
||||
const cell = formatCell(row.original[col.accessorKey], col.format);
|
||||
// On the rate column, show the proposed value under the live one — the
|
||||
// live value stays the headline because it is what still gets charged.
|
||||
if (!isRates || col.accessorKey !== "rateValue") return cell;
|
||||
const change = pendingByRateId.get(String(row.original.id));
|
||||
if (!change || change.payload.rateValue === undefined) return cell;
|
||||
return (
|
||||
<Stack gap={0}>
|
||||
{cell}
|
||||
<Tooltip label="Awaiting approval — this rate still charges its current value">
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Clock size={11} color="var(--mantine-color-orange-6)" />
|
||||
<Text size="xs" c="orange.7" fw={600}>
|
||||
{Number(change.payload.rateValue).toLocaleString()} pending
|
||||
</Text>
|
||||
</Group>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
base.push({
|
||||
@@ -357,6 +401,8 @@ const RuleEngineResourcePage = () => {
|
||||
}, [
|
||||
canManage,
|
||||
config,
|
||||
isRates,
|
||||
pendingByRateId,
|
||||
submit,
|
||||
handleApproveRate,
|
||||
handleMoveOrder,
|
||||
@@ -400,6 +446,21 @@ const RuleEngineResourcePage = () => {
|
||||
currency: "USD",
|
||||
trigger: isSurcharge ? values.trigger : "ALWAYS",
|
||||
};
|
||||
// Editing a LIVE rate files a change request — the rate keeps charging
|
||||
// its current value until an approver applies it. DRAFT rates fall
|
||||
// through to the normal update below.
|
||||
if (editing?.id && editing.status === "LIVE") {
|
||||
rateChangeWorkflow.submit.mutate(
|
||||
{ rateId: String(editing.id), update: payload },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setFormOpen(false);
|
||||
setEditing(null);
|
||||
},
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
} else if (isPriorityRules) {
|
||||
// Label is required by the backend but hidden in the UI for now.
|
||||
payload = { ...values, label: String(Date.now()) };
|
||||
@@ -483,6 +544,31 @@ const RuleEngineResourcePage = () => {
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{isRates ? (
|
||||
<RateApprovalsSection
|
||||
requests={rateChangeWorkflow.pending.data ?? []}
|
||||
canDecide={canApproveRates}
|
||||
approve={rateChangeWorkflow.approve}
|
||||
reject={rateChangeWorkflow.reject}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Modal
|
||||
opened={rateError != null}
|
||||
onClose={() => setRateError(null)}
|
||||
title="Cannot save rate change"
|
||||
centered
|
||||
>
|
||||
<Text size="sm" c="red">
|
||||
{rateError}
|
||||
</Text>
|
||||
<Group justify="flex-end" mt="md">
|
||||
<Button variant="light" onClick={() => setRateError(null)}>
|
||||
Close
|
||||
</Button>
|
||||
</Group>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
opened={priorityError != null}
|
||||
onClose={() => setPriorityError(null)}
|
||||
|
||||
@@ -50,11 +50,7 @@ export default function TrainSchedulingGlobalRulesPage() {
|
||||
// refilled) must not silently save as 0. Collect the numeric payload and
|
||||
// reject if any value is blank or NaN.
|
||||
const fields: (keyof TrainSchedulingGlobalRules)[] = [
|
||||
"maxTrainLengthMeters",
|
||||
"maxTrainWeightTons",
|
||||
"maxWagonsPerTrain",
|
||||
"max20ftContainerWeightTons",
|
||||
"max20ftPairWeightDiffTons",
|
||||
"importWindowLeadDays",
|
||||
"exportBookingLeadHours",
|
||||
"windowOpenHour",
|
||||
@@ -98,32 +94,6 @@ export default function TrainSchedulingGlobalRulesPage() {
|
||||
|
||||
<Card maw={720}>
|
||||
<Stack gap="md">
|
||||
<NumberInput
|
||||
label="Max train length (m)"
|
||||
description="Sum of all wagon lengths must not exceed this"
|
||||
value={form.maxTrainLengthMeters ?? ""}
|
||||
onChange={(value) =>
|
||||
setForm((current) => ({ ...current, maxTrainLengthMeters: value }))
|
||||
}
|
||||
clampBehavior="none"
|
||||
allowNegative={false}
|
||||
allowDecimal
|
||||
min={1}
|
||||
disabled={loading}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Max train weight (T)"
|
||||
description="Total container and bulk cargo weight must not exceed this"
|
||||
value={form.maxTrainWeightTons ?? ""}
|
||||
onChange={(value) =>
|
||||
setForm((current) => ({ ...current, maxTrainWeightTons: value }))
|
||||
}
|
||||
clampBehavior="none"
|
||||
allowNegative={false}
|
||||
allowDecimal
|
||||
min={1}
|
||||
disabled={loading}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Max wagons per train"
|
||||
value={form.maxWagonsPerTrain ?? ""}
|
||||
@@ -136,38 +106,6 @@ export default function TrainSchedulingGlobalRulesPage() {
|
||||
min={1}
|
||||
disabled={loading}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Max 20ft container weight (T)"
|
||||
description="Each individual 20ft container gross weight limit"
|
||||
value={form.max20ftContainerWeightTons ?? ""}
|
||||
onChange={(value) =>
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
max20ftContainerWeightTons: value,
|
||||
}))
|
||||
}
|
||||
clampBehavior="none"
|
||||
allowNegative={false}
|
||||
allowDecimal
|
||||
min={0.001}
|
||||
disabled={loading}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Max 20ft pair weight difference (T)"
|
||||
description="When two 20ft containers share a wagon, |weight1 − weight2| must not exceed this"
|
||||
value={form.max20ftPairWeightDiffTons ?? ""}
|
||||
onChange={(value) =>
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
max20ftPairWeightDiffTons: value,
|
||||
}))
|
||||
}
|
||||
clampBehavior="none"
|
||||
allowNegative={false}
|
||||
allowDecimal
|
||||
min={0}
|
||||
disabled={loading}
|
||||
/>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
|
||||
@@ -48,6 +48,30 @@ export interface SubmitPriorityRuleChangePayload {
|
||||
update?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Approval workflow for edits to LIVE rates — a DRAFT rate still edits directly. */
|
||||
const RATE_CHANGES_BASE = "/rate-change-requests";
|
||||
|
||||
export interface RateChangeRequest {
|
||||
id: string;
|
||||
rateId: string;
|
||||
rate?: RuleEngineRecord | null;
|
||||
/** Changed fields only. */
|
||||
payload: Record<string, unknown>;
|
||||
/** What those same fields were when the change was filed. */
|
||||
previousValues: Record<string, unknown>;
|
||||
status: "PENDING" | "APPROVED" | "REJECTED";
|
||||
requestedByUserId: string | null;
|
||||
decidedByUserId: string | null;
|
||||
decidedAt: string | null;
|
||||
decisionNote: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface SubmitRateChangePayload {
|
||||
rateId: string;
|
||||
update: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const RESOURCE_BASE: Record<RuleEngineResourceSlug, string> = {
|
||||
"cargo-types": URL_CONSTANTS.RULE_ENGINE.CARGO_TYPES,
|
||||
"container-types": URL_CONSTANTS.RULE_ENGINE.CONTAINER_TYPES,
|
||||
@@ -306,6 +330,44 @@ export const ruleEngineService = {
|
||||
return unwrap(response.data) as PriorityRuleChangeRequest;
|
||||
},
|
||||
|
||||
/** Propose a change to a LIVE rate — it stays at its current value until approved. */
|
||||
submitRateChange: async (
|
||||
payload: SubmitRateChangePayload,
|
||||
): Promise<RateChangeRequest> => {
|
||||
const response = await client.post(RATE_CHANGES_BASE, payload);
|
||||
return unwrap(response.data) as RateChangeRequest;
|
||||
},
|
||||
|
||||
listRateChanges: async (
|
||||
status?: RateChangeRequest["status"],
|
||||
): Promise<RateChangeRequest[]> => {
|
||||
const response = await client.get(RATE_CHANGES_BASE, {
|
||||
params: status ? { status } : undefined,
|
||||
});
|
||||
const body = unwrap(response.data) as unknown;
|
||||
return Array.isArray(body) ? (body as RateChangeRequest[]) : [];
|
||||
},
|
||||
|
||||
approveRateChange: async (
|
||||
id: string,
|
||||
decisionNote?: string,
|
||||
): Promise<RateChangeRequest> => {
|
||||
const response = await client.post(`${RATE_CHANGES_BASE}/${id}/approve`, {
|
||||
decisionNote,
|
||||
});
|
||||
return unwrap(response.data) as RateChangeRequest;
|
||||
},
|
||||
|
||||
rejectRateChange: async (
|
||||
id: string,
|
||||
decisionNote?: string,
|
||||
): Promise<RateChangeRequest> => {
|
||||
const response = await client.post(`${RATE_CHANGES_BASE}/${id}/reject`, {
|
||||
decisionNote,
|
||||
});
|
||||
return unwrap(response.data) as RateChangeRequest;
|
||||
},
|
||||
|
||||
getApprovalChain: async (
|
||||
requiresDirectorApproval = true,
|
||||
): Promise<RuleEngineRecord[]> => {
|
||||
|
||||
@@ -112,11 +112,7 @@ export interface DeferredBookingRow {
|
||||
|
||||
export interface TrainSchedulingGlobalRules {
|
||||
id: string;
|
||||
maxTrainLengthMeters: number;
|
||||
maxTrainWeightTons: number;
|
||||
maxWagonsPerTrain: number;
|
||||
max20ftContainerWeightTons: number;
|
||||
max20ftPairWeightDiffTons: number;
|
||||
importWindowLeadDays: number;
|
||||
exportBookingLeadHours: number;
|
||||
windowOpenHour: number;
|
||||
|
||||
Reference in New Issue
Block a user