mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 17:10:56 +00:00
Merge pull request #1113 from Tria-plc/freight_feature/usermanagement
Freight feature/usermanagement
This commit is contained in:
@@ -3,7 +3,10 @@ import Handlebars from 'handlebars';
|
||||
/** One numbered clause of a dynamic article, with optional nested bullets. */
|
||||
export interface RenderedClause {
|
||||
text: string;
|
||||
/** Computed outline number, e.g. "3" or "2.1.4". */
|
||||
/**
|
||||
* Computed outline marker for this clause at its own level: "3" at depth 1,
|
||||
* "b" at depth 2, "iv" at depth 3, cycling back to arabic at depth 4.
|
||||
*/
|
||||
number: string;
|
||||
/** Nesting level: 1 = clause, 2 = sub-clause (x.y), 3 = x.y.z, … */
|
||||
depth: number;
|
||||
@@ -35,6 +38,51 @@ const CLAUSE_NUMBER_RE = /^(?:(\d+(?:\.\d+)+)[.)]?|(\d+)[.)])(?:\s+|$)/;
|
||||
/** Deepest supported sub-clause level (1.1.1.1.1.1). */
|
||||
const MAX_CLAUSE_DEPTH = 6;
|
||||
|
||||
/** 1 → "a", 2 → "b", … 27 → "aa". */
|
||||
function toAlpha(n: number): string {
|
||||
let out = '';
|
||||
let value = n;
|
||||
while (value > 0) {
|
||||
const rem = (value - 1) % 26;
|
||||
out = String.fromCharCode(97 + rem) + out;
|
||||
value = Math.floor((value - 1) / 26);
|
||||
}
|
||||
return out || 'a';
|
||||
}
|
||||
|
||||
const ROMAN: Array<[number, string]> = [
|
||||
[1000, 'm'], [900, 'cm'], [500, 'd'], [400, 'cd'],
|
||||
[100, 'c'], [90, 'xc'], [50, 'l'], [40, 'xl'],
|
||||
[10, 'x'], [9, 'ix'], [5, 'v'], [4, 'iv'], [1, 'i'],
|
||||
];
|
||||
|
||||
/** 1 → "i", 4 → "iv", 9 → "ix". */
|
||||
function toRoman(n: number): string {
|
||||
let value = n;
|
||||
let out = '';
|
||||
for (const [amount, numeral] of ROMAN) {
|
||||
while (value >= amount) {
|
||||
out += numeral;
|
||||
value -= amount;
|
||||
}
|
||||
}
|
||||
return out || 'i';
|
||||
}
|
||||
|
||||
/**
|
||||
* Word-processor outline markers, cycling by depth the way Quill's own list
|
||||
* rendering does: 1. → a. → i. → 1. … Depth 1 keeps plain arabic numerals so
|
||||
* top-level clauses read as "1.", "2." in the contract; the marker is the
|
||||
* clause's own counter at its level, NOT a dotted path — "a" under clause 2 is
|
||||
* "a", not "2.a".
|
||||
*/
|
||||
export function clauseMarker(counter: number, depth: number): string {
|
||||
const style = (depth - 1) % 3;
|
||||
if (style === 1) return toAlpha(counter);
|
||||
if (style === 2) return toRoman(counter);
|
||||
return String(counter);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a template article body into clauses. Format: one clause per line.
|
||||
* A leading outline number ("2. ", "2.1 ", "2.1.3 ") nests the line as a
|
||||
@@ -82,7 +130,7 @@ export function parseArticleBody(body: string): Pick<RenderedArticle, 'paragraph
|
||||
|
||||
clauses.push({
|
||||
text: match ? line.slice(match[0].length).trim() : line,
|
||||
number: counters.slice(0, depth).join('.'),
|
||||
number: clauseMarker(counters[depth - 1], depth),
|
||||
depth,
|
||||
bullets: [],
|
||||
});
|
||||
|
||||
@@ -166,6 +166,8 @@ export class ContractDocumentViewModelBuilder {
|
||||
year: 'numeric',
|
||||
}),
|
||||
contractYear: new Date().getFullYear(),
|
||||
contractStartDate: this.formatDate(contract.contractValidFrom),
|
||||
contractEndDate: this.formatDate(contract.contractValidUntil),
|
||||
client: {
|
||||
companyName: contract.company?.name ?? 'Client',
|
||||
companyAddress: this.valueOrDash(contract.company?.address),
|
||||
@@ -281,7 +283,8 @@ export class ContractDocumentViewModelBuilder {
|
||||
|
||||
private buildSchedule(contract: Contract): ContractViewModel['schedule'] {
|
||||
const firstRoute = this.firstRoute(contract);
|
||||
const cargoScope = (contract.cargoScope ?? [])[0];
|
||||
const scope = contract.cargoScope ?? [];
|
||||
const cargoScope = scope[0];
|
||||
const cargoName =
|
||||
cargoScope?.cargoType?.cargoTypeName ||
|
||||
cargoScope?.cargoFreeText ||
|
||||
@@ -289,6 +292,28 @@ export class ContractDocumentViewModelBuilder {
|
||||
? `${cargoScope.containerSize} container`
|
||||
: 'Container cargo');
|
||||
|
||||
// A contract's scope can list several cargo lines (e.g. coffee in 20ft and
|
||||
// 40ft); name each distinctly rather than collapsing to the first.
|
||||
const containerType = [
|
||||
...new Set(scope.map((s) => s.containerSize ?? '').filter(Boolean)),
|
||||
].join(', ');
|
||||
const cargoTypeName = [
|
||||
...new Set(
|
||||
scope
|
||||
.map((s) => s.cargoType?.cargoTypeName ?? s.cargoFreeText ?? '')
|
||||
.filter(Boolean),
|
||||
),
|
||||
].join(', ');
|
||||
const cargoSummary = scope
|
||||
.map((s) => {
|
||||
const name = s.cargoType?.cargoTypeName ?? s.cargoFreeText ?? null;
|
||||
const size = s.containerSize ? `(${s.containerSize})` : null;
|
||||
const cap = s.quantityCap ? `× ${Number(s.quantityCap)}` : null;
|
||||
return [name, size, cap].filter(Boolean).join(' ');
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join('; ');
|
||||
|
||||
return {
|
||||
originLabel: this.yardLabel(firstRoute?.originYard),
|
||||
destinationLabel: this.yardLabel(firstRoute?.destinationYard),
|
||||
@@ -302,6 +327,9 @@ export class ContractDocumentViewModelBuilder {
|
||||
scheduledDate: this.formatDate(null),
|
||||
contractType: this.valueOrDash(contract.contractType),
|
||||
cargoDescription: this.valueOrDash(cargoName),
|
||||
cargoTypeName: this.valueOrDash(cargoTypeName),
|
||||
containerType: this.valueOrDash(containerType),
|
||||
cargoSummary: this.valueOrDash(cargoSummary),
|
||||
totalWeightVgm: '—',
|
||||
equipmentReturn: this.valueOrDash(contract.equipmentReturn),
|
||||
// A hazardous contract names the declared class + UN number on the
|
||||
|
||||
@@ -27,18 +27,32 @@ describe('parseArticleBody', () => {
|
||||
expect(parsed.clauses).toEqual([]);
|
||||
});
|
||||
|
||||
it('nests numbered sub-clauses by their outline token and renumbers sequentially', () => {
|
||||
it('nests sub-clauses by outline token and marks each level 1. → a. → i.', () => {
|
||||
const parsed = parseArticleBody(
|
||||
'1. Scope\n5.1 Rail transport\n1.1.1 Wagon supply\n2. Payment',
|
||||
);
|
||||
expect(parsed.clauses.map((c) => [c.number, c.depth, c.text])).toEqual([
|
||||
['1', 1, 'Scope'],
|
||||
['1.1', 2, 'Rail transport'],
|
||||
['1.1.1', 3, 'Wagon supply'],
|
||||
['a', 2, 'Rail transport'],
|
||||
['i', 3, 'Wagon supply'],
|
||||
['2', 1, 'Payment'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('cycles markers back to arabic at depth 4 and counts each level on its own', () => {
|
||||
const parsed = parseArticleBody(
|
||||
'1. One\n1.1 Alpha\n1.2 Beta\n1.2.1 Roman one\n1.2.2 Roman two\n1.2.2.1 Deep',
|
||||
);
|
||||
expect(parsed.clauses.map((c) => [c.number, c.depth])).toEqual([
|
||||
['1', 1],
|
||||
['a', 2],
|
||||
['b', 2],
|
||||
['i', 3],
|
||||
['ii', 3],
|
||||
['1', 4],
|
||||
]);
|
||||
});
|
||||
|
||||
it('clamps a sub-clause with no open parent to the next available level', () => {
|
||||
const parsed = parseArticleBody('1.1.1 Orphan sub-clause\nSecond clause.');
|
||||
expect(parsed.clauses.map((c) => [c.number, c.depth])).toEqual([
|
||||
@@ -90,6 +104,8 @@ describe('dynamic template rendering (edr-dynamic.hbs)', () => {
|
||||
template: { ...meta, title: 'Bulk Import Contract', templateFile: 'edr-dynamic.hbs' },
|
||||
contractDate: '1 January 2026',
|
||||
contractYear: 2026,
|
||||
contractStartDate: '1 January 2026',
|
||||
contractEndDate: '31 December 2026',
|
||||
client: {
|
||||
companyName: 'Abyssinia Trading PLC',
|
||||
companyAddress: 'Bole Sub-city, Addis Ababa',
|
||||
@@ -117,6 +133,9 @@ describe('dynamic template rendering (edr-dynamic.hbs)', () => {
|
||||
scheduledDate: '—',
|
||||
contractType: 'GENERAL',
|
||||
cargoDescription: 'Steel billets',
|
||||
cargoTypeName: 'Steel billets',
|
||||
containerType: '—',
|
||||
cargoSummary: 'Steel billets × 2,800',
|
||||
totalWeightVgm: '—',
|
||||
equipmentReturn: '—',
|
||||
hazardousLabel: 'No',
|
||||
@@ -192,6 +211,42 @@ describe('dynamic template rendering (edr-dynamic.hbs)', () => {
|
||||
expect(html).toContain('#1b9e7a');
|
||||
});
|
||||
|
||||
it('shows the contract validity window in the commercial schedule annex', () => {
|
||||
const html = renderer.render(dynamicView());
|
||||
expect(html).toContain('Valid from');
|
||||
expect(html).toContain('Valid until');
|
||||
expect(html).toContain('1 January 2026');
|
||||
expect(html).toContain('31 December 2026');
|
||||
});
|
||||
|
||||
it('interpolates the start/end date placeholders inside article text', () => {
|
||||
const view = dynamicView();
|
||||
expect(
|
||||
interpolateTemplateText(
|
||||
'In force {{contractStartDate}} to {{contractEndDate}}.',
|
||||
view,
|
||||
),
|
||||
).toBe('In force 1 January 2026 to 31 December 2026.');
|
||||
});
|
||||
|
||||
it('shows cargo type and container type in the commercial schedule annex', () => {
|
||||
const html = renderer.render(dynamicView());
|
||||
expect(html).toContain('Cargo type');
|
||||
expect(html).toContain('Container type');
|
||||
expect(html).toContain('Cargo scope');
|
||||
expect(html).toContain('Steel billets × 2,800');
|
||||
});
|
||||
|
||||
it('interpolates the cargo/container placeholders inside article text', () => {
|
||||
const view = dynamicView();
|
||||
const body =
|
||||
'Cargo: {{schedule.cargoTypeName}} in {{schedule.containerType}} ' +
|
||||
'({{schedule.freightType}}). Scope: {{schedule.cargoSummary}}.';
|
||||
expect(interpolateTemplateText(body, view)).toBe(
|
||||
'Cargo: Steel billets in — (BULK). Scope: Steel billets × 2,800.',
|
||||
);
|
||||
});
|
||||
|
||||
it('renders the live rate schedule lane under the pricing article', () => {
|
||||
const html = renderer.render(dynamicView());
|
||||
expect(html).toContain('Rate Schedule');
|
||||
|
||||
@@ -16,6 +16,8 @@ describe('ContractRendererService', () => {
|
||||
template,
|
||||
contractDate: '1 January 2026',
|
||||
contractYear: 2026,
|
||||
contractStartDate: '1 January 2026',
|
||||
contractEndDate: '31 December 2026',
|
||||
client: {
|
||||
companyName: 'Test Co',
|
||||
companyAddress: 'Addis Ababa',
|
||||
@@ -43,6 +45,9 @@ describe('ContractRendererService', () => {
|
||||
scheduledDate: '1 January 2026',
|
||||
contractType: 'NEW',
|
||||
cargoDescription: 'Container cargo',
|
||||
cargoTypeName: 'Coffee',
|
||||
containerType: '40ft',
|
||||
cargoSummary: 'Coffee (40ft) × 12',
|
||||
totalWeightVgm: '24 tons',
|
||||
equipmentReturn: 'RETURN',
|
||||
hazardousLabel: 'No',
|
||||
|
||||
@@ -41,6 +41,13 @@ export interface ContractViewModel {
|
||||
template: ContractTemplateMeta;
|
||||
contractDate: string;
|
||||
contractYear: number;
|
||||
/**
|
||||
* The contract's validity window (`contract_valid_from` / `_until`). Distinct
|
||||
* from `contractDate`, which is the day the document is generated — these are
|
||||
* the dates the contract is actually in force between. "—" when unset.
|
||||
*/
|
||||
contractStartDate: string;
|
||||
contractEndDate: string;
|
||||
client: {
|
||||
companyName: string;
|
||||
companyAddress: string;
|
||||
@@ -68,6 +75,16 @@ export interface ContractViewModel {
|
||||
scheduledDate: string;
|
||||
contractType: string;
|
||||
cargoDescription: string;
|
||||
/**
|
||||
* The named cargo type on its own (e.g. "Coffee"), separate from
|
||||
* `cargoDescription` which folds in free text and a container fallback.
|
||||
* Lets a clause name the commodity without the surrounding prose.
|
||||
*/
|
||||
cargoTypeName: string;
|
||||
/** Container size alone, e.g. "20ft" / "40ft"; "—" for bulk. */
|
||||
containerType: string;
|
||||
/** Every cargo line on the contract, e.g. "Coffee (40ft) × 12". */
|
||||
cargoSummary: string;
|
||||
totalWeightVgm: string;
|
||||
equipmentReturn: string;
|
||||
hazardousLabel: string;
|
||||
@@ -133,6 +150,8 @@ export class ContractViewModelBuilder {
|
||||
year: 'numeric',
|
||||
}),
|
||||
contractYear: new Date().getFullYear(),
|
||||
contractStartDate: this.formatDate(booking.contractValidFrom),
|
||||
contractEndDate: this.formatDate(booking.contractValidUntil),
|
||||
client: {
|
||||
companyName: booking.company?.name ?? 'Client',
|
||||
companyAddress: this.valueOrDash(booking.company?.address),
|
||||
@@ -195,6 +214,21 @@ export class ContractViewModelBuilder {
|
||||
'Bulk commodity'
|
||||
: booking.cargoType?.cargoTypeName || 'Container cargo';
|
||||
const totalWeight = Number(booking.cargoTotalWeightVgm || 0);
|
||||
// A booking may carry both sizes; name each one once, in the order booked.
|
||||
const containerType = [
|
||||
...new Set(
|
||||
(booking.bookingContainers ?? [])
|
||||
.map(
|
||||
(line) =>
|
||||
line.containerType?.label ??
|
||||
(line.containerType?.sizeFt
|
||||
? `${line.containerType.sizeFt}ft`
|
||||
: line.containerSize) ??
|
||||
'',
|
||||
)
|
||||
.filter(Boolean),
|
||||
),
|
||||
].join(', ');
|
||||
|
||||
return {
|
||||
originLabel: this.yardLabel(booking.originYard),
|
||||
@@ -207,6 +241,13 @@ export class ContractViewModelBuilder {
|
||||
scheduledDate: this.formatDate(booking.scheduledDate),
|
||||
contractType: this.valueOrDash(booking.contractType),
|
||||
cargoDescription: this.valueOrDash(cargoName),
|
||||
cargoTypeName: this.valueOrDash(booking.cargoType?.cargoTypeName),
|
||||
containerType: this.valueOrDash(containerType),
|
||||
cargoSummary: this.valueOrDash(
|
||||
[cargoName, containerType ? `(${containerType})` : null]
|
||||
.filter(Boolean)
|
||||
.join(' '),
|
||||
),
|
||||
totalWeightVgm:
|
||||
totalWeight > 0 ? `${totalWeight.toLocaleString()} tons` : '—',
|
||||
equipmentReturn: this.valueOrDash(booking.equipmentReturn),
|
||||
|
||||
@@ -125,12 +125,30 @@
|
||||
<th>Hazardous cargo</th>
|
||||
<td>{{schedule.hazardousLabel}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Cargo type</th>
|
||||
<td>{{schedule.cargoTypeName}}</td>
|
||||
<th>Container type</th>
|
||||
<td>{{schedule.containerType}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Cargo scope</th>
|
||||
<td>{{schedule.cargoSummary}}</td>
|
||||
<th>Freight type</th>
|
||||
<td>{{schedule.freightType}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Equipment return</th>
|
||||
<td>{{schedule.equipmentReturn}}</td>
|
||||
<th>Payment currency</th>
|
||||
<td>{{paymentArticle}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Valid from</th>
|
||||
<td>{{contractStartDate}}</td>
|
||||
<th>Valid until</th>
|
||||
<td>{{contractEndDate}}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
|
||||
@@ -208,6 +208,9 @@ export class ContractTemplatesService {
|
||||
year: "numeric",
|
||||
}),
|
||||
contractYear: now.getFullYear(),
|
||||
// Representative validity window for the admin preview only.
|
||||
contractStartDate: `1 January ${now.getFullYear()}`,
|
||||
contractEndDate: `31 December ${now.getFullYear()}`,
|
||||
client: {
|
||||
companyName: "Abyssinia Trading PLC",
|
||||
companyAddress: "Bole Sub-city, Woreda 03, H.No 1234, Addis Ababa",
|
||||
@@ -239,6 +242,11 @@ export class ContractTemplatesService {
|
||||
scheduledDate: "—",
|
||||
contractType: "GENERAL",
|
||||
cargoDescription: isBulk ? "Steel billets — 2,800 MT" : "40ft containers — FMCG cargo",
|
||||
cargoTypeName: isBulk ? "Steel billets" : "Coffee",
|
||||
containerType: isBulk ? "—" : "40ft",
|
||||
cargoSummary: isBulk
|
||||
? "Steel billets × 2,800"
|
||||
: "Coffee (40ft) × 12; Sesame (20ft) × 6",
|
||||
totalWeightVgm: "—",
|
||||
equipmentReturn: isBulk ? "—" : "With empty return",
|
||||
hazardousLabel: "No",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Body, Controller, Get, Patch } from "@nestjs/common";
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
import { CurrentUser, ExchangeService } from "@edr/api-common";
|
||||
import { CurrentUser } from "@edr/api-common";
|
||||
import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type";
|
||||
|
||||
import { FreightAdmin } from "../../common/booking-guards";
|
||||
@@ -11,10 +11,7 @@ import { ExchangeSettingsService } from "./exchange-settings.service";
|
||||
@ApiBearerAuth()
|
||||
@Controller("exchange-settings")
|
||||
export class ExchangeSettingsController {
|
||||
constructor(
|
||||
private readonly service: ExchangeSettingsService,
|
||||
private readonly exchangeService: ExchangeService,
|
||||
) {}
|
||||
constructor(private readonly service: ExchangeSettingsService) {}
|
||||
|
||||
@Get()
|
||||
@FreightAdmin()
|
||||
@@ -22,24 +19,15 @@ export class ExchangeSettingsController {
|
||||
summary: "Current USD→ETB fallback rate and CBE feed health",
|
||||
})
|
||||
async get() {
|
||||
const [setting, status] = [
|
||||
await this.service.get(),
|
||||
this.exchangeService.getProviderStatus(),
|
||||
];
|
||||
const setting = await this.service.get();
|
||||
const status = this.service.getFeedStatus();
|
||||
|
||||
return {
|
||||
fallbackRate: setting.fallbackRate,
|
||||
fallbackSource: setting.fallbackSource,
|
||||
lastSyncedAt: setting.lastSyncedAt,
|
||||
updatedById: setting.updatedById,
|
||||
feed: {
|
||||
rate: status.rate,
|
||||
source: status.source,
|
||||
lastSuccessAt: status.lastSuccessAt
|
||||
? new Date(status.lastSuccessAt).toISOString()
|
||||
: null,
|
||||
lastError: status.lastError,
|
||||
},
|
||||
feed: status,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,18 @@ import { ExchangeSetting } from "./entities/exchange-setting.entity";
|
||||
*/
|
||||
const SEED_FALLBACK_RATE = 162.4165;
|
||||
|
||||
/** Health of the CBE feed, as surfaced to the backoffice. */
|
||||
export interface ExchangeFeedStatus {
|
||||
/** Rate most recently observed, whatever its source. */
|
||||
rate: number | null;
|
||||
/** `live` means CBE answered; `stored`/`default` mean it is failing. */
|
||||
source: "live" | "stored" | null;
|
||||
/** ISO timestamp of the last successful fetch. */
|
||||
lastSuccessAt: string | null;
|
||||
/** Message from the most recent failure, cleared on success. */
|
||||
lastError: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns the single `exchange_settings` row: the USD→ETB fallback used when the
|
||||
* CBE endpoint is unreachable.
|
||||
@@ -22,11 +34,30 @@ const SEED_FALLBACK_RATE = 162.4165;
|
||||
export class ExchangeSettingsService {
|
||||
private readonly logger = new Logger(ExchangeSettingsService.name);
|
||||
|
||||
/**
|
||||
* Feed health, recorded from the exchange provider's callbacks rather than
|
||||
* read off an injected `ExchangeService`. The provider is registered several
|
||||
* times (bookings, contracts, warehouses), so no single instance sees every
|
||||
* fetch — and injecting one here would be circular, since those
|
||||
* registrations inject *this* service.
|
||||
*/
|
||||
private feed: ExchangeFeedStatus = {
|
||||
rate: null,
|
||||
source: null,
|
||||
lastSuccessAt: null,
|
||||
lastError: null,
|
||||
};
|
||||
|
||||
constructor(
|
||||
@InjectRepository(ExchangeSetting)
|
||||
private readonly repository: Repository<ExchangeSetting>,
|
||||
) {}
|
||||
|
||||
/** Health of the CBE feed as last observed by any provider instance. */
|
||||
getFeedStatus(): ExchangeFeedStatus {
|
||||
return { ...this.feed };
|
||||
}
|
||||
|
||||
/** The settings row, created at the seed rate on first access. */
|
||||
async get(): Promise<ExchangeSetting> {
|
||||
const existing = await this.repository.findOne({ where: {} });
|
||||
@@ -47,15 +78,22 @@ export class ExchangeSettingsService {
|
||||
* than propagating a database error into a pricing call.
|
||||
*/
|
||||
async loadFallbackRate(): Promise<number | null> {
|
||||
// Only reached when the live fetch failed, so this call is itself the
|
||||
// signal that the feed is down.
|
||||
try {
|
||||
const { fallbackRate } = await this.get();
|
||||
return Number.isFinite(fallbackRate) && fallbackRate > 0
|
||||
? fallbackRate
|
||||
: null;
|
||||
const usable = Number.isFinite(fallbackRate) && fallbackRate > 0;
|
||||
this.feed = {
|
||||
...this.feed,
|
||||
rate: usable ? fallbackRate : this.feed.rate,
|
||||
source: "stored",
|
||||
lastError: this.feed.lastError ?? "CBE endpoint unreachable",
|
||||
};
|
||||
return usable ? fallbackRate : null;
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Could not read stored exchange fallback: ${(err as Error).message}`,
|
||||
);
|
||||
const message = (err as Error).message;
|
||||
this.feed = { ...this.feed, source: "stored", lastError: message };
|
||||
this.logger.warn(`Could not read stored exchange fallback: ${message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -66,6 +104,14 @@ export class ExchangeSettingsService {
|
||||
* down, so a working CBE feed takes precedence again.
|
||||
*/
|
||||
async saveFallbackRate(rate: number): Promise<void> {
|
||||
// Only called after a successful fetch, so the feed is confirmed healthy.
|
||||
this.feed = {
|
||||
rate,
|
||||
source: "live",
|
||||
lastSuccessAt: new Date().toISOString(),
|
||||
lastError: null,
|
||||
};
|
||||
|
||||
const current = await this.get();
|
||||
await this.repository.update(current.id, {
|
||||
fallbackRate: rate,
|
||||
|
||||
@@ -1,7 +1,23 @@
|
||||
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common';
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { FleetManage, StaffReference } from '../../common/booking-guards';
|
||||
import {
|
||||
BookingStaff,
|
||||
FleetManage,
|
||||
StaffReference,
|
||||
} from '../../common/booking-guards';
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
import { CreateLocomotiveDto } from './dto/create-locomotive.dto';
|
||||
import { FilterLocomotivesDto } from './dto/filter-locomotives.dto';
|
||||
@@ -59,4 +75,18 @@ export class LocomotivesController {
|
||||
decommission(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.locomotivesService.decommission(id);
|
||||
}
|
||||
|
||||
// BookingStaff, not FleetManage: the latter also accepts the coarse
|
||||
// fleet:manage key, which would hand an irreversible purge to everyone who
|
||||
// can edit the fleet. This action requires its own grant, nothing else.
|
||||
@Delete(':id/permanent')
|
||||
@BookingStaff(FREIGHT_PERMS.locomotives.hardDelete)
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Permanently delete a locomotive (irreversible; refused if any train references it)',
|
||||
})
|
||||
purge(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.locomotivesService.purge(id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -222,4 +222,61 @@ export class LocomotivesService {
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Permanently purge a locomotive — irreversible, and only for rows nothing
|
||||
* references: a mistyped or duplicated entry.
|
||||
*
|
||||
* Every FK onto `locomotives` is NO ACTION, so Postgres would reject the
|
||||
* delete with a raw constraint error. The references are resolved up front
|
||||
* instead, naming the trains involved so the message says what to detach.
|
||||
* Decommissioning (`decommission`) stays the answer for a real locomotive
|
||||
* leaving service.
|
||||
*/
|
||||
async purge(id: string): Promise<void> {
|
||||
const locomotive = await this.locomotivesRepository.findById(id);
|
||||
if (!locomotive) {
|
||||
throw new NotFoundException(`Locomotive ${id} not found`);
|
||||
}
|
||||
|
||||
const [builtTrains, setsViaJoin, setsDirect] = await Promise.all([
|
||||
this.dataSource.query<Array<{ code: string | null }>>(
|
||||
`SELECT t.code
|
||||
FROM freight.train_locomotives tl
|
||||
JOIN freight.trains t ON t.id = tl.train_id
|
||||
WHERE tl.locomotive_id = $1`,
|
||||
[id],
|
||||
),
|
||||
this.dataSource.query<Array<{ code: string | null }>>(
|
||||
`SELECT t.code
|
||||
FROM freight.train_set_locomotives tsl
|
||||
JOIN freight.train_sets ts ON ts.id = tsl.train_set_id
|
||||
LEFT JOIN freight.trains t ON t.id = ts.train_id
|
||||
WHERE tsl.locomotive_id = $1`,
|
||||
[id],
|
||||
),
|
||||
this.dataSource.query<Array<{ code: string | null }>>(
|
||||
`SELECT t.code
|
||||
FROM freight.train_sets ts
|
||||
LEFT JOIN freight.trains t ON t.id = ts.train_id
|
||||
WHERE ts.locomotive_id = $1`,
|
||||
[id],
|
||||
),
|
||||
]);
|
||||
|
||||
const referencing = [...builtTrains, ...setsViaJoin, ...setsDirect];
|
||||
if (referencing.length > 0) {
|
||||
const codes = [
|
||||
...new Set(referencing.map((r) => r.code).filter(Boolean)),
|
||||
];
|
||||
const named = codes.length > 0 ? ` (${codes.join(', ')})` : '';
|
||||
throw new ConflictException(
|
||||
`Locomotive ${locomotive.code} is used by ${referencing.length} train record(s)${named}; detach it before deleting it permanently. Decommission it instead to take it out of service.`,
|
||||
);
|
||||
}
|
||||
|
||||
await this.dataSource
|
||||
.getRepository(Locomotive)
|
||||
.delete({ id });
|
||||
}
|
||||
}
|
||||
|
||||
61
apps/edr-freight-api/src/modules/routes/purge-guard.spec.ts
Normal file
61
apps/edr-freight-api/src/modules/routes/purge-guard.spec.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { ConflictException, NotFoundException } from '@nestjs/common';
|
||||
import { RoutesService } from './routes.service';
|
||||
|
||||
const ROUTE = {
|
||||
id: 'r1',
|
||||
originYard: { code: 'ADD', label: 'Addis' },
|
||||
destinationYard: { code: 'DIR', label: 'Dire Dawa' },
|
||||
milestones: [],
|
||||
};
|
||||
|
||||
const makeService = (scheduleCount: number, route: unknown = ROUTE) => {
|
||||
const deletes: string[] = [];
|
||||
const manager = {
|
||||
getRepository: (entity: { name?: string }) => ({
|
||||
delete: async () => {
|
||||
deletes.push(entity?.name ?? 'unknown');
|
||||
},
|
||||
}),
|
||||
};
|
||||
const dataSource = {
|
||||
query: jest.fn(async () => [{ count: scheduleCount }]),
|
||||
transaction: jest.fn(async (cb: (m: unknown) => Promise<void>) => cb(manager)),
|
||||
};
|
||||
const routesRepository = {};
|
||||
const svc = new RoutesService(dataSource as never, routesRepository as never);
|
||||
// findById is the service's own loader; stub it to isolate the purge guard.
|
||||
(svc as unknown as { findById: (id: string) => Promise<unknown> }).findById =
|
||||
async () => {
|
||||
if (!route) throw new NotFoundException('Route not found');
|
||||
return route;
|
||||
};
|
||||
return { svc, dataSource, deletes };
|
||||
};
|
||||
|
||||
describe('RoutesService.purge', () => {
|
||||
it('purges a route no schedule references', async () => {
|
||||
const { svc, dataSource, deletes } = makeService(0);
|
||||
await svc.purge('r1');
|
||||
expect(dataSource.transaction).toHaveBeenCalled();
|
||||
// Milestones then the route itself, inside one transaction.
|
||||
expect(deletes).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('refuses while train schedules reference it', async () => {
|
||||
const { svc, dataSource } = makeService(4);
|
||||
await expect(svc.purge('r1')).rejects.toThrow(ConflictException);
|
||||
await expect(svc.purge('r1')).rejects.toThrow(/4 train schedule\(s\)/);
|
||||
expect(dataSource.transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('names the route in the refusal so the message is actionable', async () => {
|
||||
const { svc } = makeService(1);
|
||||
await expect(svc.purge('r1')).rejects.toThrow(/ADD|Addis/);
|
||||
});
|
||||
|
||||
it('propagates a not-found route', async () => {
|
||||
const { svc, dataSource } = makeService(0, null);
|
||||
await expect(svc.purge('nope')).rejects.toThrow(NotFoundException);
|
||||
expect(dataSource.transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,23 @@
|
||||
import { Body, Controller, Delete, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common';
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { FleetManage, FleetView } from '../../common/booking-guards';
|
||||
import {
|
||||
BookingStaff,
|
||||
FleetManage,
|
||||
FleetView,
|
||||
} from '../../common/booking-guards';
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
import { CreateRouteDto } from './dto/create-route.dto';
|
||||
import { FilterRoutesDto } from './dto/filter-routes.dto';
|
||||
@@ -48,6 +64,21 @@ export class RoutesController {
|
||||
return this.routesService.update(id, dto);
|
||||
}
|
||||
|
||||
// Declared before @Delete(':id') so "permanent" is never captured as an id.
|
||||
// BookingStaff, not FleetManage: the latter also accepts the coarse
|
||||
// fleet:manage key, which would hand an irreversible purge to everyone who
|
||||
// can edit the fleet. This action requires its own grant, nothing else.
|
||||
@Delete(':id/permanent')
|
||||
@BookingStaff(FREIGHT_PERMS.routes.hardDelete)
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Permanently delete a route (irreversible; refused while any train schedule references it)',
|
||||
})
|
||||
purge(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.routesService.purge(id);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@FleetManage(FREIGHT_PERMS.routes.delete)
|
||||
@ApiOperation({ summary: 'Deactivate route' })
|
||||
|
||||
@@ -206,6 +206,41 @@ export class RoutesService {
|
||||
return this.findById(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Permanently purge a route — irreversible, and only for corridors nothing
|
||||
* has run on: a mistyped or duplicated definition.
|
||||
*
|
||||
* `train_schedules.route_id` is NO ACTION, so Postgres would reject the
|
||||
* delete with a raw constraint error; the schedules are counted up front
|
||||
* instead so the refusal says what is blocking. The route's own milestones
|
||||
* cascade with it, which is correct — they are the route's definition, not
|
||||
* history that outlives it. Deactivating (`deactivate`) stays the answer for
|
||||
* a corridor that has actually been used.
|
||||
*/
|
||||
async purge(id: string): Promise<void> {
|
||||
const route = await this.findById(id);
|
||||
|
||||
const [schedules] = await this.dataSource.query<Array<{ count: number }>>(
|
||||
`SELECT count(*)::int AS count
|
||||
FROM freight.train_schedules
|
||||
WHERE route_id = $1`,
|
||||
[id],
|
||||
);
|
||||
|
||||
if (schedules?.count > 0) {
|
||||
throw new ConflictException(
|
||||
`Route ${formatRouteLabel(route)} cannot be permanently deleted — ${schedules.count} train schedule(s) still reference it. Deactivate it instead, which keeps the history intact.`,
|
||||
);
|
||||
}
|
||||
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
// Milestones are FK-cascaded, but delete them explicitly so the intent is
|
||||
// visible here rather than depending on the constraint alone.
|
||||
await manager.getRepository(RouteMilestone).delete({ routeId: id });
|
||||
await manager.getRepository(Route).delete({ id });
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A route IS its ordered stop list — "Addis → Adama → Dire Dawa" and
|
||||
* "Addis → Dire Dawa" share endpoints but are different corridors. So the
|
||||
|
||||
61
apps/edr-freight-api/src/modules/wagons/purge-guard.spec.ts
Normal file
61
apps/edr-freight-api/src/modules/wagons/purge-guard.spec.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { ConflictException, NotFoundException } from '@nestjs/common';
|
||||
import { WagonsService } from './wagons.service';
|
||||
|
||||
// Minimal stubs: only what purge() touches.
|
||||
const makeService = (wagon: any, counts: [number, number, number], pinned = false) => {
|
||||
const wagonRepo = { findOne: jest.fn().mockResolvedValue(wagon), remove: jest.fn().mockResolvedValue(undefined) };
|
||||
// Keyed off the SQL so the stub survives repeated purge() calls in one test.
|
||||
const dataSource = {
|
||||
query: jest.fn(async (sql: string) => {
|
||||
if (sql.includes('train_schedule')) return pinned ? [{ x: 1 }] : [];
|
||||
if (sql.includes('wagon_movements')) return [{ count: counts[0] }];
|
||||
if (sql.includes('containers')) return [{ count: counts[1] }];
|
||||
if (sql.includes('train_set_wagons')) return [{ count: counts[2] }];
|
||||
return [];
|
||||
}),
|
||||
};
|
||||
const svc = new WagonsService(wagonRepo as any, {} as any, dataSource as any);
|
||||
return { svc, wagonRepo };
|
||||
};
|
||||
|
||||
describe('WagonsService.purge', () => {
|
||||
const clean = { id: 'w1', wagonNumber: 'W-0001', trainId: null };
|
||||
|
||||
it('purges a wagon with no references', async () => {
|
||||
const { svc, wagonRepo } = makeService(clean, [0, 0, 0]);
|
||||
await svc.purge('w1');
|
||||
expect(wagonRepo.remove).toHaveBeenCalledWith(clean);
|
||||
});
|
||||
|
||||
it('refuses when the wagon has movement history', async () => {
|
||||
const { svc, wagonRepo } = makeService(clean, [12, 0, 0]);
|
||||
await expect(svc.purge('w1')).rejects.toThrow(ConflictException);
|
||||
await expect(svc.purge('w1')).rejects.toThrow(/12 movement record/);
|
||||
expect(wagonRepo.remove).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refuses when containers or train-set slots reference it', async () => {
|
||||
const { svc, wagonRepo } = makeService(clean, [0, 3, 2]);
|
||||
await expect(svc.purge('w1')).rejects.toThrow(/3 container\(s\), 2 train-set slot/);
|
||||
expect(wagonRepo.remove).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refuses a coupled wagon before any count query runs', async () => {
|
||||
const { svc, wagonRepo } = makeService({ ...clean, trainId: 't1' }, [0, 0, 0]);
|
||||
await expect(svc.purge('w1')).rejects.toThrow(/coupled to a train/);
|
||||
expect(wagonRepo.remove).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refuses a wagon pinned to a live schedule', async () => {
|
||||
const { svc, wagonRepo } = makeService(clean, [0, 0, 0], true);
|
||||
await expect(svc.purge('w1')).rejects.toThrow(/pinned to an active schedule/);
|
||||
expect(wagonRepo.remove).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('404s an unknown wagon', async () => {
|
||||
const wagonRepo = { findOne: jest.fn().mockResolvedValue(null), remove: jest.fn() };
|
||||
const svc = new WagonsService(wagonRepo as any, {} as any, { query: jest.fn() } as any);
|
||||
await expect(svc.purge('nope')).rejects.toThrow(NotFoundException);
|
||||
expect(wagonRepo.remove).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,8 @@ import {
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
@@ -12,7 +14,11 @@ import {
|
||||
import { 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 { FleetManage, StaffReference } from '../../common/booking-guards';
|
||||
import {
|
||||
BookingStaff,
|
||||
FleetManage,
|
||||
StaffReference,
|
||||
} from '../../common/booking-guards';
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
import { CreateWagonDto } from './dto/create-wagon.dto';
|
||||
import { ListWagonsQueryDto } from './dto/list-wagons-query.dto';
|
||||
@@ -69,6 +75,21 @@ export class WagonsController {
|
||||
return this.wagonsService.update(id, dto);
|
||||
}
|
||||
|
||||
// Declared before @Delete(':id') so "permanent" is never captured as an id.
|
||||
// BookingStaff, not FleetManage: the latter also accepts the coarse
|
||||
// fleet:manage key, which would hand an irreversible purge to everyone who
|
||||
// can edit the fleet. This action requires its own grant, nothing else.
|
||||
@Delete(':id/permanent')
|
||||
@BookingStaff(FREIGHT_PERMS.wagons.hardDelete)
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Permanently delete a wagon (irreversible; refused if it has movements, containers or train-set slots)',
|
||||
})
|
||||
purge(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.wagonsService.purge(id);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@FleetManage(FREIGHT_PERMS.wagons.delete)
|
||||
@ApiOperation({ summary: 'Delete a wagon' })
|
||||
|
||||
@@ -212,6 +212,75 @@ export class WagonsService {
|
||||
await this.wagonRepo.softRemove(wagon);
|
||||
}
|
||||
|
||||
/**
|
||||
* Permanently purge a wagon — irreversible, and only for rows that carry no
|
||||
* history: a mistyped or duplicated entry someone wants gone for good.
|
||||
*
|
||||
* `wagon_movements` cascades on delete, so a wagon with movements would take
|
||||
* its ledger history down with it. Rather than allow that, every reference is
|
||||
* checked first and the purge is refused if any exist — soft delete (`remove`)
|
||||
* stays the answer for a wagon that has actually been used.
|
||||
*
|
||||
* Soft-deleted wagons are purgeable, so `withDeleted` is used to find them.
|
||||
*/
|
||||
async purge(id: string): Promise<void> {
|
||||
const wagon = await this.wagonRepo.findOne({
|
||||
where: { id },
|
||||
withDeleted: true,
|
||||
});
|
||||
if (!wagon) {
|
||||
throw new NotFoundException(`Wagon ${id} not found`);
|
||||
}
|
||||
|
||||
if (wagon.trainId != null) {
|
||||
throw new ConflictException(
|
||||
`Wagon ${wagon.wagonNumber} is coupled to a train; detach it via train-builder before deleting it permanently`,
|
||||
);
|
||||
}
|
||||
if (await this.isWagonPinnedToLiveSchedule(id)) {
|
||||
throw new ConflictException(
|
||||
`Wagon ${wagon.wagonNumber} is pinned to an active schedule and cannot be deleted permanently`,
|
||||
);
|
||||
}
|
||||
|
||||
// Each of these would either lose history (movements cascade) or silently
|
||||
// blank a live reference (containers / train-set slots are SET NULL).
|
||||
const blockers: string[] = [];
|
||||
const [movements, containers, trainSetSlots] = await Promise.all([
|
||||
this.dataSource.query(
|
||||
`SELECT count(*)::int AS count FROM freight.wagon_movements WHERE wagon_id = $1`,
|
||||
[id],
|
||||
),
|
||||
this.dataSource.query(
|
||||
`SELECT count(*)::int AS count FROM freight.containers WHERE wagon_id = $1`,
|
||||
[id],
|
||||
),
|
||||
this.dataSource.query(
|
||||
`SELECT count(*)::int AS count FROM freight.train_set_wagons WHERE physical_wagon_id = $1`,
|
||||
[id],
|
||||
),
|
||||
]);
|
||||
if (movements[0]?.count > 0) {
|
||||
blockers.push(`${movements[0].count} movement record(s)`);
|
||||
}
|
||||
if (containers[0]?.count > 0) {
|
||||
blockers.push(`${containers[0].count} container(s)`);
|
||||
}
|
||||
if (trainSetSlots[0]?.count > 0) {
|
||||
blockers.push(`${trainSetSlots[0].count} train-set slot(s)`);
|
||||
}
|
||||
|
||||
if (blockers.length > 0) {
|
||||
throw new ConflictException(
|
||||
`Wagon ${wagon.wagonNumber} cannot be permanently deleted — it still has ${blockers.join(
|
||||
', ',
|
||||
)}. Delete it normally instead, which keeps the history intact.`,
|
||||
);
|
||||
}
|
||||
|
||||
await this.wagonRepo.remove(wagon);
|
||||
}
|
||||
|
||||
/**
|
||||
* A wagon is busy when any live (DRAFT/SCHEDULED/DISPATCHED) schedule pins it
|
||||
* to one of its slots — schedule occupancy lives on TrainSetWagon rows, not
|
||||
|
||||
@@ -235,6 +235,7 @@ export const FLEET_RAIL_PERMISSIONS: FreightPermissionSeed[] = [
|
||||
perm('e1a00001-0001-4000-8000-000000000002', 'edr_freight_app:locomotives:create', 'Create locomotive'),
|
||||
perm('e1a00001-0001-4000-8000-000000000003', 'edr_freight_app:locomotives:update', 'Update locomotive'),
|
||||
perm('e1a00001-0001-4000-8000-000000000004', 'edr_freight_app:locomotives:delete', 'Delete locomotive'),
|
||||
perm('e1a00001-0001-4000-8000-000000000005', 'edr_freight_app:locomotives:hard_delete', 'Permanently delete locomotive'),
|
||||
perm('e1b00001-0001-4000-8000-000000000001', 'edr_freight_app:wagons:view', 'View wagons'),
|
||||
perm('e1b00001-0001-4000-8000-000000000002', 'edr_freight_app:wagons:create', 'Create wagon'),
|
||||
perm('e1b00001-0001-4000-8000-000000000003', 'edr_freight_app:wagons:update', 'Update wagon'),
|
||||
@@ -248,6 +249,7 @@ export const FLEET_RAIL_PERMISSIONS: FreightPermissionSeed[] = [
|
||||
perm('e1b00001-0001-4000-8000-000000000008', 'edr_freight_app:wagons:transfer_view', 'View wagon transfer requests'),
|
||||
perm('e1b00001-0001-4000-8000-000000000009', 'edr_freight_app:wagons:transfer_cancel', 'Withdraw a wagon transfer request'),
|
||||
perm('e1b00001-0001-4000-8000-00000000000a', 'edr_freight_app:wagons:transfer_close_short', 'Close a transfer request short of the requested count'),
|
||||
perm('e1b00001-0001-4000-8000-00000000000b', 'edr_freight_app:wagons:hard_delete', 'Permanently delete wagon'),
|
||||
perm('e1c00001-0001-4000-8000-000000000001', 'edr_freight_app:trains:view', 'View trains'),
|
||||
perm('e1c00001-0001-4000-8000-000000000002', 'edr_freight_app:trains:create', 'Create train'),
|
||||
perm('e1c00001-0001-4000-8000-000000000003', 'edr_freight_app:trains:update', 'Update train'),
|
||||
@@ -257,6 +259,7 @@ export const FLEET_RAIL_PERMISSIONS: FreightPermissionSeed[] = [
|
||||
perm('e1d00001-0001-4000-8000-000000000002', 'edr_freight_app:routes:create', 'Create route'),
|
||||
perm('e1d00001-0001-4000-8000-000000000003', 'edr_freight_app:routes:update', 'Update route'),
|
||||
perm('e1d00001-0001-4000-8000-000000000004', 'edr_freight_app:routes:delete', 'Delete route'),
|
||||
perm('e1d00001-0001-4000-8000-000000000005', 'edr_freight_app:routes:hard_delete', 'Permanently delete route'),
|
||||
perm('e1e00001-0001-4000-8000-000000000001', 'edr_freight_app:containers:view', 'View containers'),
|
||||
perm('e1e00001-0001-4000-8000-000000000002', 'edr_freight_app:containers:create', 'Create container'),
|
||||
perm('e1e00001-0001-4000-8000-000000000003', 'edr_freight_app:containers:update', 'Update container'),
|
||||
@@ -530,12 +533,19 @@ export const FREIGHT_PERMS = {
|
||||
create: 'edr_freight_app:locomotives:create',
|
||||
update: 'edr_freight_app:locomotives:update',
|
||||
delete: 'edr_freight_app:locomotives:delete',
|
||||
/**
|
||||
* Permanently purge the row — irreversible, and separate from `delete`
|
||||
* (which only decommissions) so it can be granted to far fewer people.
|
||||
*/
|
||||
hardDelete: 'edr_freight_app:locomotives:hard_delete',
|
||||
},
|
||||
wagons: {
|
||||
view: 'edr_freight_app:wagons:view',
|
||||
create: 'edr_freight_app:wagons:create',
|
||||
update: 'edr_freight_app:wagons:update',
|
||||
delete: 'edr_freight_app:wagons:delete',
|
||||
/** Permanently purge the row — irreversible; see locomotives.hardDelete. */
|
||||
hardDelete: 'edr_freight_app:wagons:hard_delete',
|
||||
// Requester creates a transfer request; OCC fulfils it (picks the wagons and
|
||||
// executes the move). Distinct keys so OCC can hold fulfil without request.
|
||||
transferRequest: 'edr_freight_app:wagons:transfer_request',
|
||||
@@ -562,6 +572,8 @@ export const FREIGHT_PERMS = {
|
||||
create: 'edr_freight_app:routes:create',
|
||||
update: 'edr_freight_app:routes:update',
|
||||
delete: 'edr_freight_app:routes:delete',
|
||||
/** Permanently purge the row — irreversible; see locomotives.hardDelete. */
|
||||
hardDelete: 'edr_freight_app:routes:hard_delete',
|
||||
},
|
||||
containers: {
|
||||
view: 'edr_freight_app:containers:view',
|
||||
|
||||
Reference in New Issue
Block a user