feat: implement wagon transfer management modals and page

- Add TransferFulfillModal for fulfilling wagon transfer requests.
- Create TransferRequestFormModal for filing new wagon transfer requests.
- Introduce TransferCloseShortModal for closing requests that cannot be fully fulfilled.
- Develop WagonTransfersPage to manage and display wagon transfer requests.
- Implement utility functions for handling wagon transfer request data and UI components.
- Enhance UI with Mantine components for better user experience.
This commit is contained in:
Marshal
2026-07-26 15:11:50 +00:00
parent 9a1c8e5603
commit 9b13fa2ac6
40 changed files with 2584 additions and 809 deletions

View File

@@ -5,7 +5,7 @@ import {
BadRequestException,
ForbiddenException,
} from "@nestjs/common";
import { DataSource } from "typeorm";
import { DataSource, EntityManager } from "typeorm";
import { CompaniesRepository } from "./companies.repository";
import { CompanyProfileRepository } from "./company-profile.repository";
import { CompanyChangeRequestRepository } from "./company-change-request.repository";
@@ -1111,9 +1111,11 @@ export class CompaniesService {
}
// Anything other than approval has no document gate and no concurrency
// hazard — apply it directly.
// hazard — no row lock, just the write.
if (status !== ProfileStatus.Active) {
return this.applyProfileStatus(existing, status, note, reviewerId);
return this.dataSource.transaction((manager) =>
this.applyProfileStatus(manager, existing, status, note, reviewerId),
);
}
// Approving over an outstanding document correction would silently accept the
@@ -1149,7 +1151,7 @@ export class CompaniesService {
);
}
return this.applyProfileStatus(existing, status, note, reviewerId);
return this.applyProfileStatus(manager, existing, status, note, reviewerId);
});
}
@@ -1160,11 +1162,21 @@ export class CompaniesService {
* transaction while every other status skips that overhead.
*/
private async applyProfileStatus(
manager: EntityManager,
existing: CompanyProfile,
status: ProfileStatus,
note?: string,
reviewerId?: string,
): Promise<CompanyProfile> {
// Every write below goes through `manager`. The approval path holds a
// pessimistic_write lock on the company row, and the injected repositories
// are bound to the DataSource's default pool — writing the same row through
// one of them would block on a lock this very transaction holds, hanging the
// request until the statement timed out. That deadlocked the first approval
// of any customer: the profile went Active on its own connection while the
// company stayed Pending and the caller never got a response.
const profileRepo = manager.getRepository(CompanyProfile);
const companyRepo = manager.getRepository(Company);
// A reference number is only minted the first time a profile is approved
// (status → Active). Pending/unapproved profiles carry no reference.
const patch: Partial<CompanyProfile> = { status };
@@ -1190,7 +1202,8 @@ export class CompaniesService {
patch.reviewedAt = new Date();
}
const updated = await this.companyProfilesRepo.update(existing.id, patch);
await profileRepo.update(existing.id, patch);
const updated = await profileRepo.findOne({ where: { id: existing.id } });
if (!updated)
throw new NotFoundException(`Company profile ${existing.id} not found`);
@@ -1208,7 +1221,9 @@ export class CompaniesService {
: "approved"
: null;
if (change) {
const company = await this.companiesRepo.findById(updated.companyId);
const company = await companyRepo.findOne({
where: { id: updated.companyId },
});
if (company) {
this.companyNotifier.profileStatusChanged(
company,
@@ -1222,7 +1237,7 @@ export class CompaniesService {
status === ProfileStatus.Active &&
company.status === CompanyStatus.Pending
) {
await this.companiesRepo.update(updated.companyId, {
await companyRepo.update(updated.companyId, {
status: CompanyStatus.Active,
});
this.companyNotifier.companyApproved(company);

View File

@@ -86,6 +86,17 @@ export class ContractsRepository extends BaseRepository<Contract> {
.andWhere('contract.status NOT IN (:...terminal)', {
terminal: TERMINAL_CONTRACT_STATUSES,
})
// A ONE_TIME contract allows a single booking, so once that booking
// exists the contract is spent and can never carry another shipment.
// Without this it kept blocking new requests on the same service type +
// route until its validity lapsed — locking a customer out of a lane for
// the rest of the term after one completed shipment.
.andWhere(
`(contract.contract_kind <> 'ONE_TIME' OR NOT EXISTS (
SELECT 1 FROM freight.bookings b
WHERE b.contract_id = contract.id AND b.deleted_at IS NULL
))`,
)
.getMany();
}

View File

@@ -0,0 +1,94 @@
import { orderConsistWagons } from './consist-order.util';
// Built train: A-B-C-D coupled in that order. Slots are created by the wagon
// PLAN, so their sequenceNo says nothing about where the wagon actually sits.
const TRAIN = ['A', 'B', 'C', 'D'];
const slot = (sequenceNo: number, physicalWagonId: string | null) => ({
sequenceNo,
physicalWagonId,
});
describe('orderConsistWagons', () => {
it('draws slots in the train coupling order, not slot order', () => {
// Plan order says D then B; the train says B sits ahead of D.
const drawn = orderConsistWagons([slot(1, 'D'), slot(2, 'B')], {
physicalWagonIdsInOrder: TRAIN,
});
expect(drawn.map((w) => w.physicalWagonId)).toEqual(['B', 'D']);
expect(drawn.map((w) => w.position)).toEqual([1, 2]);
});
it('interleaves empty consist wagons in their real place', () => {
// Loaded slots on A and C; B and D ride along empty. The empties used to be
// appended after every loaded slot, so the drawing was never the train.
const drawn = orderConsistWagons(
[slot(1, 'A'), slot(2, 'C'), slot(98, 'B'), slot(99, 'D')],
{ physicalWagonIdsInOrder: TRAIN },
);
expect(drawn.map((w) => w.physicalWagonId)).toEqual(['A', 'B', 'C', 'D']);
});
it('keeps every wagon in place when a load moves between wagons', () => {
// Load sat on A (slot 1); staff drag it onto empty D. The move repins the
// slot, so the SAME slot now reads as wagon D and A falls back to empty.
const before = orderConsistWagons([slot(1, 'A'), slot(98, 'D')], {
physicalWagonIdsInOrder: TRAIN,
});
const after = orderConsistWagons([slot(1, 'D'), slot(98, 'A')], {
physicalWagonIdsInOrder: TRAIN,
});
// A is drawn first and D last, before and after — the train did not shuffle.
expect(before.map((w) => w.physicalWagonId)).toEqual(['A', 'D']);
expect(after.map((w) => w.physicalWagonId)).toEqual(['A', 'D']);
});
it('follows a train-builder reorder without touching any slot row', () => {
const slots = [slot(1, 'A'), slot(2, 'B')];
// Builder swaps the coupling order; the slots are untouched.
const drawn = orderConsistWagons(slots, {
physicalWagonIdsInOrder: ['B', 'A', 'C', 'D'],
});
expect(drawn.map((w) => w.physicalWagonId)).toEqual(['B', 'A']);
});
it('draws back-to-front when the caller reverses the train', () => {
const drawn = orderConsistWagons([slot(1, 'A'), slot(2, 'C')], {
physicalWagonIdsInOrder: [...TRAIN].reverse(),
reverseWagonOrder: true,
});
expect(drawn.map((w) => w.physicalWagonId)).toEqual(['C', 'A']);
});
it('parks unpinned slots last, in slot order', () => {
const drawn = orderConsistWagons([slot(9, null), slot(4, null), slot(1, 'C')], {
physicalWagonIdsInOrder: TRAIN,
});
expect(drawn.map((w) => [w.physicalWagonId, w.sequenceNo])).toEqual([
['C', 1],
[null, 4],
[null, 9],
]);
});
it('falls back to slot order when there is no built train', () => {
// Frozen schedules and loose-wagon schedules pass no physical order.
const drawn = orderConsistWagons([slot(2, 'X'), slot(1, 'Y')], {
physicalWagonIdsInOrder: [],
});
expect(drawn.map((w) => w.sequenceNo)).toEqual([1, 2]);
const reversed = orderConsistWagons([slot(1, 'X'), slot(2, 'Y')], {
physicalWagonIdsInOrder: [],
reverseWagonOrder: true,
});
expect(reversed.map((w) => w.sequenceNo)).toEqual([2, 1]);
});
});

View File

@@ -0,0 +1,54 @@
/**
* Draw order for a schedule's consist.
*
* A slot's stored `sequenceNo` is its place in the wagon PLAN, not its place in
* the train. The train's real coupling order lives on the physical wagons
* (`wagons.sequence_number`), which the caller passes in already ordered — ASC
* normally, DESC for a `reverseWagonOrder` schedule.
*
* Ordering by the physical wagon is what keeps the drawing honest:
* - moving a load between wagons repaints WHICH wagon is loaded and never
* shuffles the train, because each slot is drawn wherever its wagon sits;
* - a train-builder reorder lands on the next read, allocations included,
* since the order is derived on every read instead of copied at pin time.
*
* Slots with no physical wagon (not pinned yet, or a schedule that isn't tied
* to a built train) have no place in the consist — they keep slot order, last.
*/
export interface ConsistOrderable {
sequenceNo: number;
physicalWagonId?: string | null;
}
export interface ConsistOrderOptions {
/**
* Every wagon coupled to the built train, in real coupling order (already
* reversed by the caller for a `reverseWagonOrder` schedule). Empty for a
* frozen schedule or one with no built train — the consist then keeps slot
* order.
*/
physicalWagonIdsInOrder: string[];
reverseWagonOrder?: boolean;
}
export const orderConsistWagons = <T extends ConsistOrderable>(
wagons: T[],
{ physicalWagonIdsInOrder, reverseWagonOrder }: ConsistOrderOptions,
): (T & { position: number })[] => {
const physicalOrder = new Map(physicalWagonIdsInOrder.map((id, index) => [id, index]));
const bySlotSequence = (a: T, b: T) =>
reverseWagonOrder ? b.sequenceNo - a.sequenceNo : a.sequenceNo - b.sequenceNo;
const ordered = physicalOrder.size
? [...wagons].sort((a, b) => {
const ai = a.physicalWagonId ? physicalOrder.get(a.physicalWagonId) : undefined;
const bi = b.physicalWagonId ? physicalOrder.get(b.physicalWagonId) : undefined;
if (ai == null && bi == null) return bySlotSequence(a, b);
if (ai == null) return 1;
if (bi == null) return -1;
return ai - bi;
})
: [...wagons].sort(bySlotSequence);
return ordered.map((wagon, index) => ({ ...wagon, position: index + 1 }));
};

View File

@@ -147,6 +147,7 @@ import {
DEFAULT_CONTAINER_WAGON_LENGTH_METERS,
DEFAULT_CONTAINER_WAGON_TARE_TONS,
} from './booking-batch.constants';
import { orderConsistWagons } from './consist-order.util';
import {
computeExportWindowTimes,
computeImportWindowTimes,
@@ -6696,6 +6697,18 @@ export class TrainSchedulingService {
consistOnly: true,
}));
// The consist is DRAWN in the built train's real coupling order (rawConsistWagons
// is already ASC/DESC per reverseWagonOrder), not in slot order — see
// consist-order.util. `position` is the drawn place, 1..n; `sequenceNo` stays
// the slot's own stored value.
const drawConsist = <T extends { sequenceNo: number; physicalWagonId: string | null }>(
list: T[],
) =>
orderConsistWagons(list, {
physicalWagonIdsInOrder: rawConsistWagons.map((wagon) => wagon.id),
reverseWagonOrder: schedule.reverseWagonOrder,
});
return {
id: schedule.id,
reference: schedule.reference ?? null,
@@ -6785,7 +6798,8 @@ export class TrainSchedulingService {
maxPullWeightTons: roundTons(Number(loco.maxPullWeightTons)),
maxTrainLengthMeters: roundTons(Number(loco.maxTrainLengthMeters)),
})),
wagons: (schedule.trainSet.wagons ?? [])
wagons: drawConsist(
(schedule.trainSet.wagons ?? [])
.map((wagon) => {
// Frozen schedules read the wagon number + allocations from the
// snapshot slot; the immutable slot geometry (capacity/type) still
@@ -6875,12 +6889,8 @@ export class TrainSchedulingService {
})) ?? [],
};
})
.concat(emptyConsistWagons)
.sort((a, b) =>
schedule.reverseWagonOrder
? b.sequenceNo - a.sequenceNo
: a.sequenceNo - b.sequenceNo,
),
.concat(emptyConsistWagons),
),
}
: null,
bookings:
@@ -7458,8 +7468,12 @@ export class TrainSchedulingService {
];
const cargoOf = (allocs: WagonBookingAllocation[]) =>
allocs.reduce((sum, a) => sum + Number(a.allocatedWeightTons || 0), 0);
const wagonLabel = (slot: { sequenceNo: number } | null, wagon: Wagon | null) =>
slot ? `#${slot.sequenceNo}` : (wagon?.wagonNumber ?? 'the target wagon');
// Name wagons by their physical number — the consist is drawn in the train's
// coupling order, so a slot's sequenceNo is not the position staff can see.
const slotLabel = (slot: TrainSetWagon) =>
slot.physicalWagon?.wagonNumber ?? `#${slot.sequenceNo}`;
const wagonLabel = (slot: TrainSetWagon | null, wagon: Wagon | null) =>
slot ? slotLabel(slot) : (wagon?.wagonNumber ?? 'the target wagon');
const checkReceives = (
allocs: WagonBookingAllocation[],
label: string,
@@ -7501,7 +7515,7 @@ export class TrainSchedulingService {
if (targetAllocs.length) {
checkReceives(
targetAllocs,
`#${source.sequenceNo}`,
slotLabel(source),
source.wagonType,
Number(source.capacityTons),
);

View File

@@ -0,0 +1,17 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsOptional, IsString, MaxLength } from 'class-validator';
/**
* OCC ends a transfer request with fewer wagons than asked for. The note is
* carried into the requester's notification — it is what tells them WHY the
* yard could not give the rest.
*/
export class CloseShortTransferRequestDto {
@ApiPropertyOptional({
description: 'Why the source yard cannot supply the remainder',
})
@IsOptional()
@IsString()
@MaxLength(2000)
note?: string;
}

View File

@@ -0,0 +1,44 @@
import { WagonTransferRequestStatus } from '@edr/types';
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsIn, IsOptional, IsUUID } from 'class-validator';
import { PaginationQueryDto } from '../../../common/dto/pagination-query.dto';
const SORT_FIELDS = ['createdAt', 'quantity', 'status'] as const;
/**
* Transfer-desk list query. `status` accepts a comma-separated list so the
* "Open" tab can ask for PENDING + PARTIALLY_FULFILLED in one call.
*/
export class ListTransferRequestsQueryDto extends PaginationQueryDto {
@ApiPropertyOptional({
description: 'One status or a comma-separated list',
enum: WagonTransferRequestStatus,
})
@IsOptional()
@Transform(({ value }) =>
typeof value === 'string' && value.trim() ? value.trim() : undefined,
)
status?: string;
@ApiPropertyOptional({ description: 'Source yard' })
@IsOptional()
@IsUUID()
fromYardId?: string;
@ApiPropertyOptional({ description: 'Destination yard' })
@IsOptional()
@IsUUID()
toYardId?: string;
@ApiPropertyOptional({ description: 'Wagon type' })
@IsOptional()
@IsUUID()
wagonTypeId?: string;
@ApiPropertyOptional({ enum: SORT_FIELDS, default: 'createdAt' })
@IsOptional()
@IsIn([...SORT_FIELDS])
sortBy?: (typeof SORT_FIELDS)[number];
}

View File

@@ -40,6 +40,14 @@ export class WagonTransferRequest extends BaseEntity {
@Column({ name: 'quantity', type: 'int' })
quantity!: number;
/**
* How many have actually moved so far. OCC sends what the yard can spare,
* whenever it can — the request stays open until this reaches `quantity` or
* OCC closes it short.
*/
@Column({ name: 'fulfilled_quantity', type: 'int', default: 0 })
fulfilledQuantity!: number;
@Column({
name: 'status',
type: 'varchar',
@@ -54,9 +62,17 @@ export class WagonTransferRequest extends BaseEntity {
@Column({ name: 'fulfilled_by_user_id', type: 'uuid', nullable: true })
fulfilledByUserId?: string | null;
/** When the LAST transfer against this request ran (not necessarily the full count). */
@Column({ name: 'fulfilled_at', type: 'timestamptz', nullable: true })
fulfilledAt?: Date | null;
/** Set when OCC ended the request with fewer wagons than asked for. */
@Column({ name: 'closed_short_at', type: 'timestamptz', nullable: true })
closedShortAt?: Date | null;
@Column({ name: 'closed_short_by_user_id', type: 'uuid', nullable: true })
closedShortByUserId?: string | null;
@Column({ name: 'note', type: 'text', nullable: true })
note?: string | null;

View File

@@ -1,4 +1,3 @@
import { WagonTransferRequestStatus } from '@edr/types';
import {
Body,
Controller,
@@ -13,26 +12,36 @@ import { CurrentUser } from '@edr/api-common';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import {
FleetManage,
FleetView,
WagonTransferCancel,
WagonTransferCloseShort,
WagonTransferFulfill,
WagonTransferHistoryAll,
WagonTransferRequest,
WagonTransferView,
} from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { BulkFulfillTransferRequestsDto } from './dto/bulk-fulfill-transfer-requests.dto';
import { CloseShortTransferRequestDto } from './dto/close-short-transfer-request.dto';
import { CreateTransferRequestDto } from './dto/create-transfer-request.dto';
import { FulfillTransferRequestDto } from './dto/fulfill-transfer-request.dto';
import { ListTransferRequestsQueryDto } from './dto/list-transfer-requests-query.dto';
import { WagonTransferRequestsService } from './wagon-transfer-requests.service';
/** Query-string number, or undefined when absent/garbage (service defaults it). */
const toInt = (value?: string): number | undefined => {
const n = Number.parseInt(String(value ?? ''), 10);
return Number.isFinite(n) && n > 0 ? n : undefined;
};
/**
* Two-person wagon-transfer queue. Requester (transfer_request perm) files a
* count-only request; OCC (transfer_fulfill perm) picks the wagons and executes
* the move. Separate top-level path so it never collides with `wagons/:id`.
* The wagon-transfer desk. A requester (transfer_request) files a count-only
* request; OCC (transfer_fulfill) moves wagons against it in as many
* instalments as the source yard allows, and closes it short
* (transfer_close_short) when the yard has no more to give. Separate top-level
* path so it never collides with `wagons/:id`.
*/
@ApiTags('wagon-transfer-requests')
@Controller('wagon-transfer-requests')
@FleetView(FREIGHT_PERMS.wagons.view)
@WagonTransferView()
export class WagonTransferRequestsController {
constructor(private readonly service: WagonTransferRequestsService) {}
@@ -47,10 +56,12 @@ export class WagonTransferRequestsController {
}
@Get()
@ApiQuery({ name: 'status', required: false, enum: WagonTransferRequestStatus })
@ApiOperation({ summary: 'List transfer requests (OCC queue: status=PENDING)' })
list(@Query('status') status?: WagonTransferRequestStatus) {
return this.service.listRequests(status);
@ApiOperation({
summary:
'Transfer desk list — paginated, filterable by status (comma-separated), yards and wagon type',
})
list(@Query() query: ListTransferRequestsQueryDto) {
return this.service.listRequests(query);
}
// NOTE: static routes (`history`, `bulk-fulfill`) MUST stay above `@Get(':id')`
@@ -73,24 +84,48 @@ export class WagonTransferRequestsController {
// matches in declaration order, so `/history` would otherwise be captured by
// the `:id` param route (and rejected by ParseUUIDPipe).
@Get('history')
@ApiQuery({ name: 'page', required: false })
@ApiQuery({ name: 'pageSize', required: false })
@ApiOperation({
summary: "Caller's own transfer history (requests filed/fulfilled + wagons moved)",
})
myHistory(@CurrentUser() user: TCurrentUser) {
myHistory(
@CurrentUser() user: TCurrentUser,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
// Never fall through to the all-staff view: getHistory(undefined) means
// "everyone", so a missing caller id must return empty, not leak scope.
if (!user?.id) return { requests: [], movements: [] };
return this.service.getHistory(user.id);
if (!user?.id) {
return {
requests: [],
movements: [],
meta: {
page: 1,
pageSize: 20,
requestsTotal: 0,
movementsTotal: 0,
totalPages: 1,
},
};
}
return this.service.getHistory(user.id, toInt(page), toInt(pageSize));
}
@Get('history/all')
@WagonTransferHistoryAll()
@ApiQuery({ name: 'userId', required: false })
@ApiQuery({ name: 'page', required: false })
@ApiQuery({ name: 'pageSize', required: false })
@ApiOperation({
summary: "Admin: any/all staff's transfer history (optional ?userId filter)",
})
allHistory(@Query('userId') userId?: string) {
return this.service.getHistory(userId);
allHistory(
@Query('userId') userId?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return this.service.getHistory(userId, toInt(page), toInt(pageSize));
}
@Get(':id')
@@ -110,9 +145,26 @@ export class WagonTransferRequestsController {
return this.service.fulfillRequest(id, dto, user?.id);
}
@Post(':id/close-short')
@WagonTransferCloseShort()
@ApiOperation({
summary:
'OCC: end the request with fewer wagons than asked for — what moved stays, the requester is told the shortfall',
})
closeShort(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: CloseShortTransferRequestDto,
@CurrentUser() user: TCurrentUser,
) {
return this.service.closeShort(id, dto, user?.id);
}
@Post(':id/cancel')
@FleetManage(FREIGHT_PERMS.wagons.transferRequest)
@ApiOperation({ summary: 'Withdraw a pending transfer request' })
@WagonTransferCancel()
@ApiOperation({
summary:
'Withdraw a request that has not moved any wagon yet (use close-short once wagons have moved)',
})
cancel(@Param('id', ParseUUIDPipe) id: string) {
return this.service.cancelRequest(id);
}

View File

@@ -0,0 +1,235 @@
import { WagonTransferRequestStatus } from '@edr/types';
import { ConflictException, BadRequestException } from '@nestjs/common';
import { WagonTransferRequestsService } from './wagon-transfer-requests.service';
import type { WagonTransferRequest } from './entities/wagon-transfer-request.entity';
/**
* Instalment fulfilment: a request for 50 wagons is met with whatever the source
* yard can spare, whenever it can spare it. It stays open until the full count
* lands or OCC closes it short — which is what tells the requester to go ask
* another yard.
*/
describe('WagonTransferRequestsService — partial fulfilment', () => {
const request = (over: Partial<WagonTransferRequest> = {}): WagonTransferRequest =>
({
id: 'req-1',
fromYardId: 'yard-a',
toYardId: 'yard-b',
wagonTypeId: 'type-1',
quantity: 50,
fulfilledQuantity: 0,
status: WagonTransferRequestStatus.Pending,
requestedByUserId: 'user-1',
...over,
}) as WagonTransferRequest;
let requestRepo: {
findOne: jest.Mock;
find: jest.Mock;
save: jest.Mock;
create: jest.Mock;
createQueryBuilder: jest.Mock;
};
let wagonRepo: { find: jest.Mock; count: jest.Mock };
let wagonsService: { bulkTransfer: jest.Mock };
let inbox: { notify: jest.Mock };
let service: WagonTransferRequestsService;
let stored: WagonTransferRequest;
const flush = () => new Promise((resolve) => setImmediate(resolve));
const build = (row: WagonTransferRequest) => {
stored = row;
requestRepo.findOne.mockImplementation(async () => stored);
requestRepo.save.mockImplementation(async (r: WagonTransferRequest) => {
stored = r;
return r;
});
};
beforeEach(() => {
requestRepo = {
findOne: jest.fn(),
find: jest.fn().mockResolvedValue([]),
save: jest.fn(),
create: jest.fn((r) => r),
createQueryBuilder: jest.fn(),
};
wagonRepo = { find: jest.fn().mockResolvedValue([]), count: jest.fn() };
wagonsService = { bulkTransfer: jest.fn().mockResolvedValue(undefined) };
inbox = { notify: jest.fn().mockResolvedValue(undefined) };
service = new WagonTransferRequestsService(
requestRepo as never,
wagonRepo as never,
{ find: jest.fn(), findAndCount: jest.fn() } as never,
wagonsService as never,
inbox as never,
);
build(request());
});
const availableWagons = (n: number) =>
Array.from({ length: n }, (_, i) => ({
id: `w-${i}`,
wagonNumber: `100${i}`,
currentYardId: 'yard-a',
wagonTypeId: 'type-1',
status: 'AVAILABLE',
}));
describe('fulfillRequest', () => {
it('books an instalment and keeps the request open', async () => {
wagonRepo.find.mockResolvedValue(availableWagons(20));
await service.fulfillRequest('req-1', {
wagonIds: availableWagons(20).map((w) => w.id),
});
expect(stored.fulfilledQuantity).toBe(20);
expect(stored.status).toBe(WagonTransferRequestStatus.PartiallyFulfilled);
expect(wagonsService.bulkTransfer).toHaveBeenCalledTimes(1);
});
it('completes the request when the last instalment lands', async () => {
build(request({ fulfilledQuantity: 30, status: WagonTransferRequestStatus.PartiallyFulfilled }));
wagonRepo.find.mockResolvedValue(availableWagons(20));
await service.fulfillRequest('req-1', {
wagonIds: availableWagons(20).map((w) => w.id),
});
expect(stored.fulfilledQuantity).toBe(50);
expect(stored.status).toBe(WagonTransferRequestStatus.Fulfilled);
});
it('refuses to move more than is still owed', async () => {
build(request({ fulfilledQuantity: 45, status: WagonTransferRequestStatus.PartiallyFulfilled }));
wagonRepo.find.mockResolvedValue(availableWagons(10));
await expect(
service.fulfillRequest('req-1', {
wagonIds: availableWagons(10).map((w) => w.id),
}),
).rejects.toBeInstanceOf(BadRequestException);
expect(wagonsService.bulkTransfer).not.toHaveBeenCalled();
});
it('refuses to touch a request that is already closed', async () => {
build(request({ status: WagonTransferRequestStatus.ClosedShort, fulfilledQuantity: 20 }));
await expect(
service.fulfillRequest('req-1', { wagonIds: ['w-0'] }),
).rejects.toBeInstanceOf(ConflictException);
});
it('tells the requester what landed and what is still owed', async () => {
wagonRepo.find.mockResolvedValue(availableWagons(20));
await service.fulfillRequest('req-1', {
wagonIds: availableWagons(20).map((w) => w.id),
});
await flush();
const sent = inbox.notify.mock.calls[0][0];
expect(sent.recipients).toEqual({ userIds: ['user-1'] });
expect(sent.body).toContain('20 wagon(s) have arrived');
expect(sent.body).toContain('30 of 50 still to come');
});
});
describe('bulkFulfill', () => {
it('sends what the yard has instead of skipping a short request', async () => {
wagonRepo.find.mockResolvedValue(availableWagons(20));
const result = await service.bulkFulfill(['req-1']);
expect(stored.fulfilledQuantity).toBe(20);
expect(stored.status).toBe(WagonTransferRequestStatus.PartiallyFulfilled);
expect(result.skipped).toHaveLength(0);
});
it('skips only when the yard has nothing to give', async () => {
wagonRepo.find.mockResolvedValue([]);
const result = await service.bulkFulfill(['req-1']);
expect(wagonsService.bulkTransfer).not.toHaveBeenCalled();
expect(result.skipped[0].reason).toContain('No available wagons');
});
});
describe('closeShort', () => {
it('ends the request and tells the requester to ask another yard', async () => {
build(request({ fulfilledQuantity: 20, status: WagonTransferRequestStatus.PartiallyFulfilled }));
await service.closeShort('req-1', { note: 'Yard is empty until Friday' });
await flush();
expect(stored.status).toBe(WagonTransferRequestStatus.ClosedShort);
expect(stored.closedShortAt).toBeInstanceOf(Date);
const sent = inbox.notify.mock.calls[0][0];
expect(sent.body).toContain('Only 20 of the 50');
expect(sent.body).toContain('Yard is empty until Friday');
expect(sent.body).toContain('Request the remaining 30');
});
it('refuses when the request is already fully supplied', async () => {
build(request({ fulfilledQuantity: 50, status: WagonTransferRequestStatus.PartiallyFulfilled }));
await expect(service.closeShort('req-1', {})).rejects.toBeInstanceOf(
ConflictException,
);
});
});
describe('cancelRequest', () => {
it('withdraws a request that never moved a wagon', async () => {
await service.cancelRequest('req-1');
expect(stored.status).toBe(WagonTransferRequestStatus.Cancelled);
});
it('refuses once wagons have moved — close it short instead', async () => {
build(request({ fulfilledQuantity: 20, status: WagonTransferRequestStatus.PartiallyFulfilled }));
await expect(service.cancelRequest('req-1')).rejects.toThrow(
/close it short/i,
);
});
});
describe('createRequest', () => {
it('accepts a count larger than what the yard holds today', async () => {
wagonRepo.count.mockResolvedValue(20);
await service.createRequest(
{
fromYardId: 'yard-a',
toYardId: 'yard-b',
wagonTypeId: 'type-1',
quantity: 50,
reason: 'Grain campaign',
},
'user-1',
);
expect(requestRepo.save).toHaveBeenCalled();
expect(stored.quantity).toBe(50);
});
it('still refuses a same-yard move', async () => {
await expect(
service.createRequest(
{
fromYardId: 'yard-a',
toYardId: 'yard-a',
wagonTypeId: 'type-1',
quantity: 5,
reason: 'x',
},
'user-1',
),
).rejects.toBeInstanceOf(BadRequestException);
});
});
});

View File

@@ -1,15 +1,27 @@
import { WagonStatus, WagonTransferRequestStatus } from '@edr/types';
import {
NotificationAudience,
NotificationType,
OPEN_WAGON_TRANSFER_STATUSES,
PaginatedResponse,
WagonStatus,
WagonTransferRequestStatus,
} from '@edr/types';
import {
BadRequestException,
ConflictException,
Injectable,
Logger,
NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { In, IsNull, Not, Repository } from 'typeorm';
import { paginateQuery } from '../../common/utils/pagination.util';
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
import { CloseShortTransferRequestDto } from './dto/close-short-transfer-request.dto';
import { CreateTransferRequestDto } from './dto/create-transfer-request.dto';
import { FulfillTransferRequestDto } from './dto/fulfill-transfer-request.dto';
import { ListTransferRequestsQueryDto } from './dto/list-transfer-requests-query.dto';
import { Wagon } from './entities/wagon.entity';
import { WagonMovement } from './entities/wagon-movement.entity';
import { WagonTransferRequest } from './entities/wagon-transfer-request.entity';
@@ -19,10 +31,21 @@ import { WagonsService } from './wagons.service';
export interface TransferHistory {
requests: WagonTransferRequest[];
movements: WagonMovement[];
/**
* One pager drives both lists (they are shown side by side), so it carries a
* total per list and the page count of the longer one.
*/
meta: {
page: number;
pageSize: number;
requestsTotal: number;
movementsTotal: number;
totalPages: number;
};
}
/** How many ledger rows the history returns at most (newest first). */
const HISTORY_LIMIT = 500;
/** Hard ceiling on a single history page, whatever the client asks for. */
const HISTORY_LIMIT = 100;
const REQUEST_RELATIONS = {
fromYard: true,
@@ -38,6 +61,8 @@ const REQUEST_RELATIONS = {
*/
@Injectable()
export class WagonTransferRequestsService {
private readonly logger = new Logger(WagonTransferRequestsService.name);
constructor(
@InjectRepository(WagonTransferRequest)
private readonly requestRepo: Repository<WagonTransferRequest>,
@@ -46,13 +71,14 @@ export class WagonTransferRequestsService {
@InjectRepository(WagonMovement)
private readonly movementRepo: Repository<WagonMovement>,
private readonly wagonsService: WagonsService,
private readonly inbox: NotificationInboxService,
) {}
/**
* Record a PENDING request. Count-only — no wagons are picked here, but the
* count is capped at the AVAILABLE wagons of that type currently sitting in
* the source yard: staff may only ask for wagons that are actually there to
* give. A reason is mandatory and is shown on the OCC queue.
* Record a PENDING request. Count-only — no wagons are picked here, and the
* count is NOT capped by what the source yard holds today: OCC fulfils in
* instalments, so asking for 50 while only 20 sit there is a normal, useful
* request. A reason is mandatory and is shown on the OCC queue.
*/
async createRequest(
dto: CreateTransferRequestDto,
@@ -63,14 +89,6 @@ export class WagonTransferRequestsService {
'Source and destination yard must be different',
);
}
const available = await this.countAvailable(dto.fromYardId, dto.wagonTypeId);
if (available < dto.quantity) {
throw new BadRequestException(
available === 0
? 'No available wagons of this type in the source yard'
: `Only ${available} available wagon(s) of this type in the source yard — request at most ${available}`,
);
}
const request = this.requestRepo.create({
fromYardId: dto.fromYardId,
toYardId: dto.toYardId,
@@ -85,8 +103,12 @@ export class WagonTransferRequestsService {
return this.findById(saved.id);
}
/** AVAILABLE wagons of `wagonTypeId` currently in `yardId`. */
private countAvailable(yardId: string, wagonTypeId: string): Promise<number> {
/**
* 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
* glance how much of a request the yard can cover today.
*/
countAvailable(yardId: string, wagonTypeId: string): Promise<number> {
return this.wagonRepo.count({
where: {
currentYardId: yardId,
@@ -96,15 +118,51 @@ export class WagonTransferRequestsService {
});
}
/** Requests, newest first, optionally filtered by status (OCC queue = PENDING). */
/**
* The transfer desk list: paginated, newest first, filterable by status (one
* or a comma-separated set — the "Open" tab asks for PENDING +
* PARTIALLY_FULFILLED), yards and wagon type. Search matches the reason text.
*/
async listRequests(
status?: WagonTransferRequestStatus,
): Promise<WagonTransferRequest[]> {
return this.requestRepo.find({
where: status ? { status } : {},
relations: REQUEST_RELATIONS,
order: { createdAt: 'DESC' },
});
query: ListTransferRequestsQueryDto,
): Promise<PaginatedResponse<WagonTransferRequest>> {
const qb = this.requestRepo
.createQueryBuilder('r')
.leftJoinAndSelect('r.fromYard', 'fromYard')
.leftJoinAndSelect('r.toYard', 'toYard')
.leftJoinAndSelect('r.wagonType', 'wagonType');
const statuses = (query.status ?? '')
.split(',')
.map((s) => s.trim())
.filter(Boolean);
if (statuses.length) {
qb.andWhere('r.status IN (:...statuses)', { statuses });
}
if (query.fromYardId) {
qb.andWhere('r.from_yard_id = :fromYardId', { fromYardId: query.fromYardId });
}
if (query.toYardId) {
qb.andWhere('r.to_yard_id = :toYardId', { toYardId: query.toYardId });
}
if (query.wagonTypeId) {
qb.andWhere('r.wagon_type_id = :wagonTypeId', {
wagonTypeId: query.wagonTypeId,
});
}
if (query.search) {
qb.andWhere('r.reason ILIKE :search', { search: `%${query.search}%` });
}
const sortColumn =
query.sortBy === 'quantity'
? 'r.quantity'
: query.sortBy === 'status'
? 'r.status'
: 'r.created_at';
qb.orderBy(sortColumn, query.sortOrder ?? 'DESC');
return paginateQuery(qb, { page: query.page, pageSize: query.pageSize });
}
async findById(id: string): Promise<WagonTransferRequest> {
@@ -116,11 +174,23 @@ export class WagonTransferRequestsService {
return request;
}
/** Wagons still owed on an open request. */
private remainingOn(request: WagonTransferRequest): number {
return Math.max(0, request.quantity - (request.fulfilledQuantity ?? 0));
}
/** True while OCC can still move wagons against this request. */
private isOpen(request: WagonTransferRequest): boolean {
return OPEN_WAGON_TRANSFER_STATUSES.includes(request.status);
}
/**
* OCC fulfils a PENDING request with hand-picked wagons. Every wagon must sit
* in the request's source yard, match its wagon type, and the count must equal
* the requested quantity — then the transfer runs and the request is marked
* FULFILLED.
* OCC moves hand-picked wagons against an open request. Any number from 1 up
* to whatever is still owed — the yard rarely has the whole ask at once, so a
* request for 50 can be met 20 now, 30 later. Every wagon must sit in the
* source yard, match the type and be available. The request completes on its
* own once the full count has moved; short of that it stays open as
* PARTIALLY_FULFILLED and the requester is told what landed.
*/
async fulfillRequest(
id: string,
@@ -128,16 +198,17 @@ export class WagonTransferRequestsService {
userId?: string | null,
): Promise<WagonTransferRequest> {
const request = await this.findById(id);
if (request.status !== WagonTransferRequestStatus.Pending) {
if (!this.isOpen(request)) {
throw new ConflictException(
`Request is already ${request.status.toLowerCase()}`,
`Request is already ${request.status.toLowerCase().replace(/_/g, ' ')}`,
);
}
const wagonIds = [...new Set(dto.wagonIds)];
if (wagonIds.length !== request.quantity) {
const remaining = this.remainingOn(request);
if (wagonIds.length > remaining) {
throw new BadRequestException(
`Select exactly ${request.quantity} wagon(s); you selected ${wagonIds.length}`,
`Only ${remaining} wagon(s) still owed on this request; you selected ${wagonIds.length}`,
);
}
@@ -178,21 +249,124 @@ export class WagonTransferRequestsService {
{ transferRequestId: request.id },
);
request.status = WagonTransferRequestStatus.Fulfilled;
request.fulfilledByUserId = userId ?? null;
request.fulfilledAt = new Date();
await this.requestRepo.save(request);
await this.recordDelivery(request, wagonIds.length, userId);
return this.findById(id);
}
/**
* OCC accepts AND executes a subset of pending requests in one action. For
* each selected request the system auto-picks the required number of
* AVAILABLE wagons of the requested type from the source yard (lowest wagon
* number first) and runs the audited transfer. A request that cannot be
* executed — already decided, or not enough available wagons left after the
* ones processed before it — is SKIPPED and simply stays PENDING, visible to
* both teams; nothing is rolled back for the others.
* Book an instalment against a request: bump the delivered count, complete it
* when the full ask has landed, and tell the requester what moved. Shared by
* the hand-picked and auto-picked (bulk) fulfilment paths.
*/
private async recordDelivery(
request: WagonTransferRequest,
moved: number,
userId?: string | null,
): Promise<void> {
request.fulfilledQuantity = (request.fulfilledQuantity ?? 0) + moved;
request.status =
request.fulfilledQuantity >= request.quantity
? WagonTransferRequestStatus.Fulfilled
: WagonTransferRequestStatus.PartiallyFulfilled;
request.fulfilledByUserId = userId ?? null;
request.fulfilledAt = new Date();
await this.requestRepo.save(request);
this.notifyRequester(request, moved);
}
/**
* Tell the requester what landed. Fire-and-forget: a notification failure must
* never undo a transfer that already moved wagons.
*/
private notifyRequester(
request: WagonTransferRequest,
moved: number,
closedShortNote?: string | null,
): void {
if (!request.requestedByUserId) return;
const outstanding = this.remainingOn(request);
const complete = request.status === WagonTransferRequestStatus.Fulfilled;
const closedShort =
request.status === WagonTransferRequestStatus.ClosedShort;
const title = complete
? `All ${request.quantity} wagon(s) transferred`
: closedShort
? `Transfer closed short — ${request.fulfilledQuantity} of ${request.quantity} wagon(s)`
: `${moved} of ${request.quantity} wagon(s) transferred`;
const body = complete
? `Your wagon transfer request is complete — all ${request.quantity} wagon(s) have arrived.`
: closedShort
? `Only ${request.fulfilledQuantity} of the ${request.quantity} wagon(s) you asked for could be supplied` +
`${closedShortNote ? `: ${closedShortNote}` : '.'} ` +
`Request the remaining ${outstanding} from another yard.`
: `${moved} wagon(s) have arrived against your request. ` +
`${outstanding} of ${request.quantity} still to come.`;
void this.inbox
.notify({
recipients: { userIds: [request.requestedByUserId] },
audience: NotificationAudience.BACKOFFICE,
type: NotificationType.GENERIC,
title,
body,
link: `/dashboard/wagon-transfers/${request.id}`,
data: {
transferRequestId: request.id,
delivered: request.fulfilledQuantity,
requested: request.quantity,
outstanding,
},
})
.catch((err) =>
this.logger.warn(
`Transfer notification failed for ${request.id}: ${(err as Error).message}`,
),
);
}
/**
* OCC ends a request with fewer wagons than asked for — the source yard has
* nothing more to give. What already moved stays moved; the requester is told
* the shortfall so they can raise it against another yard. Cancelling is for
* requests that never moved anything; this is the close for ones that did.
*/
async closeShort(
id: string,
dto: CloseShortTransferRequestDto,
userId?: string | null,
): Promise<WagonTransferRequest> {
const request = await this.findById(id);
if (!this.isOpen(request)) {
throw new ConflictException(
`Request is already ${request.status.toLowerCase().replace(/_/g, ' ')}`,
);
}
if (this.remainingOn(request) === 0) {
throw new ConflictException(
'Nothing outstanding — this request is already fully supplied',
);
}
request.status = WagonTransferRequestStatus.ClosedShort;
request.closedShortAt = new Date();
request.closedShortByUserId = userId ?? null;
if (dto.note?.trim()) {
request.note = dto.note.trim();
}
await this.requestRepo.save(request);
this.notifyRequester(request, 0, dto.note ?? null);
return this.findById(id);
}
/**
* OCC executes a set of open requests in one action, auto-picking AVAILABLE
* wagons of the requested type from each source yard (lowest wagon number
* first). A yard that cannot cover the whole ask still sends what it has —
* the request stays open for the rest rather than being skipped, which is the
* whole point of instalments. Only a request with NOTHING available is
* skipped, and nothing is rolled back for the others.
*/
async bulkFulfill(
requestIds: string[],
@@ -212,13 +386,14 @@ export class WagonTransferRequestsService {
skipped.push({ id, reason: 'Request not found' });
continue;
}
if (request.status !== WagonTransferRequestStatus.Pending) {
if (!this.isOpen(request)) {
skipped.push({
id,
reason: `Already ${request.status.toLowerCase()}`,
reason: `Already ${request.status.toLowerCase().replace(/_/g, ' ')}`,
});
continue;
}
const remaining = this.remainingOn(request);
const wagons = await this.wagonRepo.find({
where: {
currentYardId: request.fromYardId,
@@ -226,12 +401,12 @@ export class WagonTransferRequestsService {
status: WagonStatus.Available,
},
order: { wagonNumber: 'ASC' },
take: request.quantity,
take: remaining,
});
if (wagons.length < request.quantity) {
if (wagons.length === 0) {
skipped.push({
id,
reason: `Only ${wagons.length} of ${request.quantity} wagon(s) available in the source yard — left pending`,
reason: 'No available wagons of this type in the source yard — left open',
});
continue;
}
@@ -240,10 +415,7 @@ export class WagonTransferRequestsService {
userId,
{ transferRequestId: request.id },
);
request.status = WagonTransferRequestStatus.Fulfilled;
request.fulfilledByUserId = userId ?? null;
request.fulfilledAt = new Date();
await this.requestRepo.save(request);
await this.recordDelivery(request, wagons.length, userId);
fulfilled.push(await this.findById(id));
}
@@ -258,17 +430,25 @@ export class WagonTransferRequestsService {
* (the controller passes the caller's id unless they hold the history-all
* permission) — this method trusts its argument.
*/
async getHistory(userId?: string | null): Promise<TransferHistory> {
const requests = await this.requestRepo.find({
async getHistory(
userId?: string | null,
page?: number,
pageSize?: number,
): Promise<TransferHistory> {
const take = Math.min(pageSize ?? 20, HISTORY_LIMIT);
const skip = ((page ?? 1) - 1) * take;
const [requests, requestsTotal] = await this.requestRepo.findAndCount({
where: userId
? [{ requestedByUserId: userId }, { fulfilledByUserId: userId }]
: {},
relations: REQUEST_RELATIONS,
order: { createdAt: 'DESC' },
take: HISTORY_LIMIT,
skip,
take,
});
const movements = await this.movementRepo.find({
const [movements, movementsTotal] = await this.movementRepo.findAndCount({
// Own view: moves I made. All view: every user-attributed move (skip the
// system-written loaded/reposition legs that carry no mover).
where: userId
@@ -276,18 +456,39 @@ export class WagonTransferRequestsService {
: { movedByUserId: Not(IsNull()) },
relations: { wagon: true, fromYard: true, toYard: true, transferRequest: true },
order: { occurredAt: 'DESC' },
take: HISTORY_LIMIT,
skip,
take,
});
return { requests, movements };
return {
requests,
movements,
meta: {
page: page ?? 1,
pageSize: take,
requestsTotal,
movementsTotal,
// Whichever list is longer decides how far the pager can go.
totalPages: Math.max(
1,
Math.ceil(Math.max(requestsTotal, movementsTotal) / take),
),
},
};
}
/** Withdraw a still-PENDING request. */
/**
* Withdraw a request before anything moved. Once wagons have been delivered
* the request can only be completed or closed short — cancelling would erase
* the fact that a transfer happened.
*/
async cancelRequest(id: string): Promise<WagonTransferRequest> {
const request = await this.findById(id);
if (request.status !== WagonTransferRequestStatus.Pending) {
throw new ConflictException(
`Only pending requests can be cancelled (this one is ${request.status.toLowerCase()})`,
request.status === WagonTransferRequestStatus.PartiallyFulfilled
? 'Wagons have already moved against this request — close it short instead of cancelling'
: `Only pending requests can be cancelled (this one is ${request.status.toLowerCase().replace(/_/g, ' ')})`,
);
}
request.status = WagonTransferRequestStatus.Cancelled;

View File

@@ -5,6 +5,7 @@ import { WagonMovement } from './entities/wagon-movement.entity';
import { WagonTransferRequest } from './entities/wagon-transfer-request.entity';
import { Train } from '../trains/entities/train.entity';
import { Yard } from '../rule-engine/entities/yard.entity';
import { NotificationInboxModule } from '../notification-inbox/notification-inbox.module';
import { WagonsController } from './wagons.controller';
import { WagonTransferRequestsController } from './wagon-transfer-requests.controller';
import { WagonsService } from './wagons.service';
@@ -19,6 +20,8 @@ import { WagonTransferRequestsService } from './wagon-transfer-requests.service'
Train,
Yard,
]),
// The transfer desk notifies the requester as instalments land.
NotificationInboxModule,
],
controllers: [
WagonsController,