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:
marshalyordanos
2026-08-12 09:36:50 +03:00
parent 35e5404b41
commit 5da36eb128
77 changed files with 6275 additions and 296 deletions

View File

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

View File

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

View File

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

View File

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