mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 04:08:11 +00:00
feat: add wagon usage computation and maintenance logging features
- Implemented utility to calculate wagon usage metrics for train schedules. - Created for sending wagons to maintenance with optional notes. - Added unit tests for train builder maintenance functionalities, including formatting train run labels and building maintenance notes. - Developed component for merging train schedules with detailed previews and reasons for merging. - Introduced component for selecting wagons with search functionality and selection limits. - Created for displaying and filtering audit logs, including detailed views of individual log entries. - Added for handling API interactions related to audit logs, including fetching logs and entity types.
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
IsArray,
|
||||
IsInt,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
@@ -11,11 +13,14 @@ import {
|
||||
} from 'class-validator';
|
||||
|
||||
/**
|
||||
* A count-only wagon-transfer request. The requester picks source yard, wagon
|
||||
* type, destination yard and HOW MANY — never the specific wagons; OCC hand-picks
|
||||
* those at fulfilment. The quantity may not exceed the AVAILABLE wagons of that
|
||||
* type currently in the source yard (enforced in the service, which is the only
|
||||
* layer that can count them), and a reason is mandatory.
|
||||
* A wagon-transfer request. The requester picks source yard, wagon type,
|
||||
* destination yard and HOW MANY. The quantity may not exceed the AVAILABLE
|
||||
* wagons of that type currently in the source yard (enforced in the service,
|
||||
* which is the only layer that can count them), and a reason is mandatory.
|
||||
*
|
||||
* The requester may additionally name the specific wagons they want via
|
||||
* `preferredWagonIds`. That is a preference recorded for OCC, not a
|
||||
* reservation — the count still drives fulfilment.
|
||||
*/
|
||||
export class CreateTransferRequestDto {
|
||||
@IsUUID()
|
||||
@@ -38,6 +43,17 @@ export class CreateTransferRequestDto {
|
||||
@MaxLength(2000)
|
||||
reason!: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'Specific wagons the requester wants, if they picked any. A preference for OCC — the wagons are not reserved.',
|
||||
type: [String],
|
||||
})
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayMaxSize(1000)
|
||||
@IsUUID('4', { each: true })
|
||||
preferredWagonIds?: string[];
|
||||
|
||||
@ApiPropertyOptional({ description: 'Optional note for the fulfilling staff' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
|
||||
@@ -56,6 +56,14 @@ export class WagonTransferRequest extends BaseEntity {
|
||||
})
|
||||
status!: WagonTransferRequestStatus;
|
||||
|
||||
/**
|
||||
* The wagons the requester specifically asked for, when they picked any. A
|
||||
* preference, not a reservation — the wagons stay AVAILABLE to everyone else,
|
||||
* and OCC may still send different ones. Null/empty on a plain count request.
|
||||
*/
|
||||
@Column({ name: 'preferred_wagon_ids', type: 'uuid', array: true, nullable: true })
|
||||
preferredWagonIds?: string[] | null;
|
||||
|
||||
@Column({ name: 'requested_by_user_id', type: 'uuid', nullable: true })
|
||||
requestedByUserId?: string | null;
|
||||
|
||||
|
||||
@@ -149,6 +149,19 @@ describe('WagonTransferRequestsService — partial fulfilment', () => {
|
||||
expect(result.skipped).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('auto-picks the wagons the requester named ahead of the rest', async () => {
|
||||
// Asked for 2 and named w-3 — the auto-pick must take it even though
|
||||
// wagon-number order would have sent w-0 and w-1.
|
||||
build(request({ quantity: 2, preferredWagonIds: ['w-3'] }));
|
||||
wagonRepo.find.mockResolvedValue(availableWagons(5));
|
||||
|
||||
await service.bulkFulfill(['req-1']);
|
||||
|
||||
const [{ wagonIds }] = wagonsService.bulkTransfer.mock.calls[0];
|
||||
expect(wagonIds).toHaveLength(2);
|
||||
expect(wagonIds[0]).toBe('w-3');
|
||||
});
|
||||
|
||||
it('skips only when the yard has nothing to give', async () => {
|
||||
wagonRepo.find.mockResolvedValue([]);
|
||||
|
||||
@@ -253,6 +266,84 @@ describe('WagonTransferRequestsService — partial fulfilment', () => {
|
||||
expect(requestRepo.save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('records the wagons the requester hand-picked', async () => {
|
||||
wagonRepo.count.mockResolvedValue(20);
|
||||
wagonRepo.find.mockResolvedValue(availableWagons(3));
|
||||
|
||||
await service.createRequest(
|
||||
{
|
||||
fromYardId: 'yard-a',
|
||||
toYardId: 'yard-b',
|
||||
wagonTypeId: 'type-1',
|
||||
quantity: 3,
|
||||
reason: 'Grain campaign',
|
||||
preferredWagonIds: ['w-0', 'w-1', 'w-2'],
|
||||
},
|
||||
'user-1',
|
||||
);
|
||||
|
||||
expect(stored.preferredWagonIds).toEqual(['w-0', 'w-1', 'w-2']);
|
||||
});
|
||||
|
||||
it('leaves the picks null on a plain count request', async () => {
|
||||
wagonRepo.count.mockResolvedValue(20);
|
||||
|
||||
await service.createRequest(
|
||||
{
|
||||
fromYardId: 'yard-a',
|
||||
toYardId: 'yard-b',
|
||||
wagonTypeId: 'type-1',
|
||||
quantity: 5,
|
||||
reason: 'Grain campaign',
|
||||
},
|
||||
'user-1',
|
||||
);
|
||||
|
||||
expect(stored.preferredWagonIds).toBeNull();
|
||||
});
|
||||
|
||||
it('refuses picks that are not available in the source yard', async () => {
|
||||
wagonRepo.count.mockResolvedValue(20);
|
||||
// Sitting in another yard — the requester's list is stale.
|
||||
wagonRepo.find.mockResolvedValue([
|
||||
{ ...availableWagons(1)[0], currentYardId: 'yard-z' },
|
||||
]);
|
||||
|
||||
await expect(
|
||||
service.createRequest(
|
||||
{
|
||||
fromYardId: 'yard-a',
|
||||
toYardId: 'yard-b',
|
||||
wagonTypeId: 'type-1',
|
||||
quantity: 1,
|
||||
reason: 'Grain campaign',
|
||||
preferredWagonIds: ['w-0'],
|
||||
},
|
||||
'user-1',
|
||||
),
|
||||
).rejects.toThrow(/no longer available in the source yard/i);
|
||||
expect(requestRepo.save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refuses more picks than the requested quantity', async () => {
|
||||
wagonRepo.count.mockResolvedValue(20);
|
||||
|
||||
await expect(
|
||||
service.createRequest(
|
||||
{
|
||||
fromYardId: 'yard-a',
|
||||
toYardId: 'yard-b',
|
||||
wagonTypeId: 'type-1',
|
||||
quantity: 2,
|
||||
reason: 'Grain campaign',
|
||||
preferredWagonIds: ['w-0', 'w-1', 'w-2'],
|
||||
},
|
||||
'user-1',
|
||||
),
|
||||
).rejects.toThrow(/picked 3 wagon\(s\) but are requesting 2/i);
|
||||
expect(requestRepo.save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('still refuses a same-yard move', async () => {
|
||||
await expect(
|
||||
service.createRequest(
|
||||
|
||||
@@ -27,6 +27,15 @@ import { WagonMovement } from './entities/wagon-movement.entity';
|
||||
import { WagonTransferRequest } from './entities/wagon-transfer-request.entity';
|
||||
import { WagonsService } from './wagons.service';
|
||||
|
||||
/**
|
||||
* A request as sent to clients: the entity plus the resolved wagon numbers for
|
||||
* whatever the requester hand-picked, so the desk can name them without a
|
||||
* second round trip.
|
||||
*/
|
||||
export interface TransferRequestView extends WagonTransferRequest {
|
||||
preferredWagons?: Array<{ id: string; wagonNumber: string }>;
|
||||
}
|
||||
|
||||
/** Bundled per-user activity: requests they touched + wagons they moved. */
|
||||
export interface TransferHistory {
|
||||
requests: WagonTransferRequest[];
|
||||
@@ -75,11 +84,15 @@ export class WagonTransferRequestsService {
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Record a PENDING request. Count-only — no wagons are picked here, but the
|
||||
* count IS capped by what the source yard can hand over right now: a request
|
||||
* may not exceed the AVAILABLE, uncoupled wagons of that type in the source
|
||||
* yard (the same number the yard desk shows). A reason is mandatory and is
|
||||
* shown on the OCC queue.
|
||||
* Record a PENDING request. The count is capped by what the source yard can
|
||||
* hand over right now: a request may not exceed the AVAILABLE, uncoupled
|
||||
* wagons of that type in the source yard (the same number the yard desk
|
||||
* shows). A reason is mandatory and is shown on the OCC queue.
|
||||
*
|
||||
* The requester may also name the wagons they want (`preferredWagonIds`).
|
||||
* Those are validated against the source yard here so a bad pick is rejected
|
||||
* at request time rather than surfacing at fulfilment, but they are only a
|
||||
* preference — the wagons are not reserved and OCC may send others.
|
||||
*/
|
||||
async createRequest(
|
||||
dto: CreateTransferRequestDto,
|
||||
@@ -104,11 +117,15 @@ export class WagonTransferRequestsService {
|
||||
`Only ${available} wagon(s) of this type are available in the source yard — cannot request ${dto.quantity}`,
|
||||
);
|
||||
}
|
||||
|
||||
const preferredWagonIds = await this.validatePreferredWagons(dto);
|
||||
|
||||
const request = this.requestRepo.create({
|
||||
fromYardId: dto.fromYardId,
|
||||
toYardId: dto.toYardId,
|
||||
wagonTypeId: dto.wagonTypeId,
|
||||
quantity: dto.quantity,
|
||||
preferredWagonIds,
|
||||
status: WagonTransferRequestStatus.Pending,
|
||||
requestedByUserId: userId ?? null,
|
||||
reason: dto.reason,
|
||||
@@ -118,6 +135,45 @@ export class WagonTransferRequestsService {
|
||||
return this.findById(saved.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check the requester's hand-picked wagons against the source yard: each must
|
||||
* exist, sit in that yard, match the requested type, be AVAILABLE and be
|
||||
* uncoupled — the same conditions fulfilment will apply. Returns the
|
||||
* de-duplicated ids, or null when the requester picked nothing.
|
||||
*/
|
||||
private async validatePreferredWagons(
|
||||
dto: CreateTransferRequestDto,
|
||||
): Promise<string[] | null> {
|
||||
const ids = [...new Set(dto.preferredWagonIds ?? [])];
|
||||
if (ids.length === 0) return null;
|
||||
|
||||
if (ids.length > dto.quantity) {
|
||||
throw new BadRequestException(
|
||||
`You picked ${ids.length} wagon(s) but are requesting ${dto.quantity} — pick at most ${dto.quantity}`,
|
||||
);
|
||||
}
|
||||
|
||||
const wagons = await this.wagonRepo.find({ where: { id: In(ids) } });
|
||||
if (wagons.length !== ids.length) {
|
||||
throw new NotFoundException('One or more selected wagons not found');
|
||||
}
|
||||
const unusable = wagons.filter(
|
||||
(w) =>
|
||||
w.currentYardId !== dto.fromYardId ||
|
||||
w.wagonTypeId !== dto.wagonTypeId ||
|
||||
w.status !== WagonStatus.Available ||
|
||||
w.trainId != null,
|
||||
);
|
||||
if (unusable.length) {
|
||||
throw new BadRequestException(
|
||||
`These wagons are no longer available in the source yard: ${unusable
|
||||
.map((w) => w.wagonNumber)
|
||||
.join(', ')}`,
|
||||
);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* AVAILABLE wagons of `wagonTypeId` currently in `yardId` — what OCC can move
|
||||
* right now. Shown on the desk beside the outstanding count so staff see at a
|
||||
@@ -179,16 +235,51 @@ export class WagonTransferRequestsService {
|
||||
: 'r.createdAt';
|
||||
qb.orderBy(sortColumn, query.sortOrder ?? 'DESC');
|
||||
|
||||
return paginateQuery(qb, { page: query.page, pageSize: query.pageSize });
|
||||
const page = await paginateQuery<WagonTransferRequest>(qb, {
|
||||
page: query.page,
|
||||
pageSize: query.pageSize,
|
||||
});
|
||||
return { ...page, items: await this.withPreferredWagons(page.items) };
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<WagonTransferRequest> {
|
||||
/**
|
||||
* Resolve `preferredWagonIds` into wagon numbers for a page of requests in a
|
||||
* single query, so the desk can show WHICH wagons were asked for. Ids that no
|
||||
* longer resolve (purged wagon) simply drop out — the column carries no FK.
|
||||
*/
|
||||
private async withPreferredWagons(
|
||||
requests: WagonTransferRequest[],
|
||||
): Promise<TransferRequestView[]> {
|
||||
const ids = [
|
||||
...new Set(requests.flatMap((r) => r.preferredWagonIds ?? [])),
|
||||
];
|
||||
if (ids.length === 0) return requests;
|
||||
|
||||
const wagons = await this.wagonRepo.find({
|
||||
where: { id: In(ids) },
|
||||
select: { id: true, wagonNumber: true },
|
||||
});
|
||||
const byId = new Map(wagons.map((w) => [w.id, w.wagonNumber]));
|
||||
|
||||
return requests.map((r) => {
|
||||
const picked = r.preferredWagonIds ?? [];
|
||||
if (picked.length === 0) return r;
|
||||
return Object.assign(r, {
|
||||
preferredWagons: picked
|
||||
.filter((id) => byId.has(id))
|
||||
.map((id) => ({ id, wagonNumber: byId.get(id)! })),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<TransferRequestView> {
|
||||
const request = await this.requestRepo.findOne({
|
||||
where: { id },
|
||||
relations: REQUEST_RELATIONS,
|
||||
});
|
||||
if (!request) throw new NotFoundException(`Transfer request ${id} not found`);
|
||||
return request;
|
||||
const [view] = await this.withPreferredWagons([request]);
|
||||
return view;
|
||||
}
|
||||
|
||||
/** Wagons still owed on an open request. */
|
||||
@@ -411,7 +502,7 @@ export class WagonTransferRequestsService {
|
||||
continue;
|
||||
}
|
||||
const remaining = this.remainingOn(request);
|
||||
const wagons = await this.wagonRepo.find({
|
||||
const candidates = await this.wagonRepo.find({
|
||||
where: {
|
||||
currentYardId: request.fromYardId,
|
||||
wagonTypeId: request.wagonTypeId,
|
||||
@@ -419,8 +510,19 @@ export class WagonTransferRequestsService {
|
||||
trainId: IsNull(),
|
||||
},
|
||||
order: { wagonNumber: 'ASC' },
|
||||
take: remaining,
|
||||
});
|
||||
// Honour the requester's picks first — any that are still available in
|
||||
// the yard go out ahead of the plain wagon-number order, and the rest of
|
||||
// the instalment is topped up from whatever else is on hand.
|
||||
const preferred = new Set(request.preferredWagonIds ?? []);
|
||||
const wagons = (
|
||||
preferred.size
|
||||
? [
|
||||
...candidates.filter((w) => preferred.has(w.id)),
|
||||
...candidates.filter((w) => !preferred.has(w.id)),
|
||||
]
|
||||
: candidates
|
||||
).slice(0, remaining);
|
||||
if (wagons.length === 0) {
|
||||
skipped.push({
|
||||
id,
|
||||
@@ -479,7 +581,7 @@ export class WagonTransferRequestsService {
|
||||
});
|
||||
|
||||
return {
|
||||
requests,
|
||||
requests: await this.withPreferredWagons(requests),
|
||||
movements,
|
||||
meta: {
|
||||
page: page ?? 1,
|
||||
|
||||
Reference in New Issue
Block a user