Merge pull request #1113 from Tria-plc/freight_feature/usermanagement

Freight feature/usermanagement
This commit is contained in:
marshal
2026-08-04 20:31:16 +03:00
committed by GitHub
34 changed files with 1322 additions and 243 deletions

View File

@@ -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",

View File

@@ -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,
};
}

View File

@@ -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,

View File

@@ -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);
}
}

View File

@@ -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 });
}
}

View 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();
});
});

View File

@@ -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' })

View File

@@ -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

View 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();
});
});

View File

@@ -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' })

View File

@@ -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