mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge pull request #1230 from Tria-plc/freight_feature/usermanagement
feat(wagons): enforce wagon availability limits in transfer requests
This commit is contained in:
@@ -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()
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user