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

feat(wagons): enforce wagon availability limits in transfer requests
This commit is contained in:
marshal
2026-08-10 23:32:46 +03:00
committed by GitHub
5 changed files with 127 additions and 18 deletions

View File

@@ -14,7 +14,8 @@ import {
* A count-only wagon-transfer request. The requester picks source yard, wagon * 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 * 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 * those at fulfilment. The quantity may not exceed the AVAILABLE wagons of that
* type currently in the source yard, and a reason is mandatory. * type currently in the source yard (enforced in the service, which is the only
* layer that can count them), and a reason is mandatory.
*/ */
export class CreateTransferRequestDto { export class CreateTransferRequestDto {
@IsUUID() @IsUUID()

View File

@@ -199,7 +199,7 @@ describe('WagonTransferRequestsService — partial fulfilment', () => {
}); });
describe('createRequest', () => { describe('createRequest', () => {
it('accepts a count larger than what the yard holds today', async () => { it('accepts a count up to what the yard holds today', async () => {
wagonRepo.count.mockResolvedValue(20); wagonRepo.count.mockResolvedValue(20);
await service.createRequest( await service.createRequest(
@@ -207,14 +207,50 @@ describe('WagonTransferRequestsService — partial fulfilment', () => {
fromYardId: 'yard-a', fromYardId: 'yard-a',
toYardId: 'yard-b', toYardId: 'yard-b',
wagonTypeId: 'type-1', wagonTypeId: 'type-1',
quantity: 50, quantity: 20,
reason: 'Grain campaign', reason: 'Grain campaign',
}, },
'user-1', 'user-1',
); );
expect(requestRepo.save).toHaveBeenCalled(); expect(requestRepo.save).toHaveBeenCalled();
expect(stored.quantity).toBe(50); expect(stored.quantity).toBe(20);
});
it('refuses a count larger than what the yard holds today', async () => {
wagonRepo.count.mockResolvedValue(20);
await expect(
service.createRequest(
{
fromYardId: 'yard-a',
toYardId: 'yard-b',
wagonTypeId: 'type-1',
quantity: 50,
reason: 'Grain campaign',
},
'user-1',
),
).rejects.toThrow(/only 20 wagon\(s\).*available/i);
expect(requestRepo.save).not.toHaveBeenCalled();
});
it('refuses when the yard has nothing of that type available', async () => {
wagonRepo.count.mockResolvedValue(0);
await expect(
service.createRequest(
{
fromYardId: 'yard-a',
toYardId: 'yard-b',
wagonTypeId: 'type-1',
quantity: 1,
reason: 'Grain campaign',
},
'user-1',
),
).rejects.toThrow(/no available wagons/i);
expect(requestRepo.save).not.toHaveBeenCalled();
}); });
it('still refuses a same-yard move', async () => { it('still refuses a same-yard move', async () => {

View File

@@ -75,10 +75,11 @@ export class WagonTransferRequestsService {
) {} ) {}
/** /**
* Record a PENDING request. Count-only — no wagons are picked here, and the * Record a PENDING request. Count-only — no wagons are picked here, but the
* count is NOT capped by what the source yard holds today: OCC fulfils in * count IS capped by what the source yard can hand over right now: a request
* instalments, so asking for 50 while only 20 sit there is a normal, useful * may not exceed the AVAILABLE, uncoupled wagons of that type in the source
* request. A reason is mandatory and is shown on the OCC queue. * yard (the same number the yard desk shows). A reason is mandatory and is
* shown on the OCC queue.
*/ */
async createRequest( async createRequest(
dto: CreateTransferRequestDto, dto: CreateTransferRequestDto,
@@ -89,6 +90,20 @@ export class WagonTransferRequestsService {
'Source and destination yard must be different', 'Source and destination yard must be different',
); );
} }
const available = await this.countAvailable(
dto.fromYardId,
dto.wagonTypeId,
);
if (available === 0) {
throw new BadRequestException(
'No available wagons of this type in the source yard',
);
}
if (dto.quantity > available) {
throw new BadRequestException(
`Only ${available} wagon(s) of this type are available in the source yard — cannot request ${dto.quantity}`,
);
}
const request = this.requestRepo.create({ const request = this.requestRepo.create({
fromYardId: dto.fromYardId, fromYardId: dto.fromYardId,
toYardId: dto.toYardId, toYardId: dto.toYardId,

View File

@@ -44,9 +44,8 @@ const clampInt = (v: number | string, max: number): number => {
/** /**
* NumberInput + Slider + All/Half presets, kept in sync. `max` bounds the field * NumberInput + Slider + All/Half presets, kept in sync. `max` bounds the field
* for actions that move real wagons; omit it for a transfer REQUEST, which may * to the wagons on hand; omitting it leaves the field unbounded and the slider
* legitimately ask for more than the yard holds today (OCC fulfils it in * simply tracks the current value.
* instalments) — the slider then just tracks the current value.
*/ */
const QuantityField = ({ const QuantityField = ({
value, value,
@@ -434,9 +433,14 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro
{availableCount} available {availableCount} available
</Badge> </Badge>
</Group> </Group>
{/* No max: the request may exceed what the yard holds {/* Capped at the wagons actually available in this yard
today — OCC fulfils it in instalments. */} right now (uncoupled + Available) — a request may not
<QuantityField value={transferQty} onChange={setTransferQty} /> ask for more than the yard can hand over. */}
<QuantityField
value={transferQty}
onChange={setTransferQty}
max={availableCount}
/>
</div> </div>
<Select <Select
label="Destination yard" label="Destination yard"

View File

@@ -1,3 +1,4 @@
import { Freight } from "@edr/types";
import { import {
Alert, Alert,
Button, Button,
@@ -41,6 +42,30 @@ function useTransferOptions(enabled: boolean) {
}; };
} }
/**
* Wagons the source yard can hand over right now — AVAILABLE and not coupled to
* a built train. Mirrors `countAvailable` on the API, which rejects any request
* asking for more than this, so the field must not let one be filed.
*/
function useAvailableCount(
enabled: boolean,
fromYardId: string | null,
wagonTypeId: string | null,
) {
const { data: wagons = [] } = useQuery({
...api.wagons.list.queryOptions({ input: {} }),
enabled: enabled && Boolean(fromYardId && wagonTypeId),
});
if (!fromYardId || !wagonTypeId) return null;
return wagons.filter(
(w) =>
w.currentYardId === fromYardId &&
w.wagonTypeId === wagonTypeId &&
w.status === Freight.WagonStatus.Available &&
!w.trainId,
).length;
}
export interface TransferRequestFormModalProps { export interface TransferRequestFormModalProps {
opened: boolean; opened: boolean;
onClose: () => void; onClose: () => void;
@@ -54,9 +79,9 @@ export interface TransferRequestFormModalProps {
} }
/** /**
* File a wagon-transfer request. The count is deliberately NOT capped by what * File a wagon-transfer request. The count is capped by what the source yard
* the source yard holds today — OCC fulfils in instalments, so asking for 50 * has available right now; the API enforces the same ceiling, so a larger ask
* where 20 sit is a normal request. * is rejected rather than queued.
*/ */
export function TransferRequestFormModal({ export function TransferRequestFormModal({
opened, opened,
@@ -83,10 +108,23 @@ export function TransferRequestFormModal({
const create = useMutation(api.wagonTransferRequests.create.mutationOptions()); const create = useMutation(api.wagonTransferRequests.create.mutationOptions());
const available = useAvailableCount(opened, fromYardId, wagonTypeId);
// A prefilled outstanding count (or a count typed before the yard was picked)
// can exceed what the chosen source yard actually has — pull it back down so
// the field never holds a value the API would reject.
useEffect(() => {
if (available == null) return;
setQuantity((q) => (Number(q) > available ? available : q));
}, [available]);
const sameYard = Boolean(fromYardId && fromYardId === toYardId); const sameYard = Boolean(fromYardId && fromYardId === toYardId);
const overAvailable = available != null && Number(quantity) > available;
const valid = const valid =
Boolean(fromYardId && toYardId && wagonTypeId && reason.trim()) && Boolean(fromYardId && toYardId && wagonTypeId && reason.trim()) &&
!sameYard && !sameYard &&
!overAvailable &&
available !== 0 &&
Number(quantity) >= 1; Number(quantity) >= 1;
const submit = async () => { const submit = async () => {
@@ -155,10 +193,25 @@ export function TransferRequestFormModal({
/> />
<NumberInput <NumberInput
label="How many" label="How many"
description="Can exceed what the yard holds today — OCC delivers in instalments" description={
available == null
? "Pick a source yard and wagon type to see what is available"
: `${available} wagon(s) available in the source yard`
}
min={1} min={1}
max={available ?? undefined}
clampBehavior={available == null ? "none" : "strict"}
allowNegative={false}
value={quantity} value={quantity}
onChange={setQuantity} onChange={setQuantity}
disabled={available === 0}
error={
available === 0
? "This yard has no wagons of that type available"
: overAvailable
? `Only ${available} available`
: undefined
}
required required
/> />
<Textarea <Textarea