diff --git a/apps/edr-freight-api/src/common/booking-guards.ts b/apps/edr-freight-api/src/common/booking-guards.ts index 3e086101c..854594ffc 100644 --- a/apps/edr-freight-api/src/common/booking-guards.ts +++ b/apps/edr-freight-api/src/common/booking-guards.ts @@ -81,6 +81,32 @@ export const WagonTransferFulfill = () => export const WagonTransferHistoryAll = () => BookingStaff(FREIGHT_PERMS.wagons.transferHistoryAll); +/** + * Open the transfer-requests desk. `wagons:view` is accepted as a one-of + * fallback so staff who could already reach the queue keep it without a + * re-grant — same pattern the granular fleet keys use. + */ +export const WagonTransferView = () => + BookingStaff([FREIGHT_PERMS.wagons.transferView, FREIGHT_PERMS.wagons.view]); + +/** Withdraw a request that has not moved any wagon yet. */ +export const WagonTransferCancel = () => + BookingStaff([ + FREIGHT_PERMS.wagons.transferCancel, + FREIGHT_PERMS.wagons.transferRequest, + ]); + +/** + * End a request short of the requested count. Whoever may move wagons may also + * declare the yard has no more to give, so fulfil is accepted alongside the + * dedicated key. + */ +export const WagonTransferCloseShort = () => + BookingStaff([ + FREIGHT_PERMS.wagons.transferCloseShort, + FREIGHT_PERMS.wagons.transferFulfill, + ]); + /** Org-administration endpoints (user mgmt, billing config, company CRUD, settings). */ export const FreightAdmin = () => BookingStaff(FREIGHT_PERMS.admin); diff --git a/apps/edr-freight-api/src/migrations/2930000000000-AddWagonTransferPartialFulfilment.ts b/apps/edr-freight-api/src/migrations/2930000000000-AddWagonTransferPartialFulfilment.ts new file mode 100644 index 000000000..a9cc5e74d --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2930000000000-AddWagonTransferPartialFulfilment.ts @@ -0,0 +1,40 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Partial wagon-transfer fulfilment. + * + * A request for 50 wagons no longer has to be met in one go: OCC moves what the + * source yard can spare, whenever it can, and the request stays open until the + * full count is met (FULFILLED) or OCC ends it short (CLOSED_SHORT) so the + * requester can ask another yard for the rest. + * + * Existing rows are back-filled so history keeps reading correctly: a FULFILLED + * request delivered its whole quantity; anything else delivered nothing. + */ +export class AddWagonTransferPartialFulfilment2930000000000 + implements MigrationInterface +{ + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.wagon_transfer_requests + ADD COLUMN IF NOT EXISTS fulfilled_quantity integer NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS closed_short_at timestamptz NULL, + ADD COLUMN IF NOT EXISTS closed_short_by_user_id uuid NULL + `); + await queryRunner.query(` + UPDATE freight.wagon_transfer_requests + SET fulfilled_quantity = quantity + WHERE status = 'FULFILLED' + AND fulfilled_quantity = 0 + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.wagon_transfer_requests + DROP COLUMN IF EXISTS fulfilled_quantity, + DROP COLUMN IF EXISTS closed_short_at, + DROP COLUMN IF EXISTS closed_short_by_user_id + `); + } +} diff --git a/apps/edr-freight-api/src/modules/companies/companies.service.ts b/apps/edr-freight-api/src/modules/companies/companies.service.ts index 3867bef3a..6650dfca5 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -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 { + // 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 = { 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); diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts b/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts index 6b16d5647..abce1884d 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts @@ -86,6 +86,17 @@ export class ContractsRepository extends BaseRepository { .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(); } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/consist-order.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/consist-order.util.spec.ts new file mode 100644 index 000000000..44fff49e8 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/consist-order.util.spec.ts @@ -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]); + }); +}); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/consist-order.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/consist-order.util.ts new file mode 100644 index 000000000..496b91952 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/consist-order.util.ts @@ -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 = ( + 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 })); +}; diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index da8982d95..fe5bb3a8e 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -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 = ( + 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), ); diff --git a/apps/edr-freight-api/src/modules/wagons/dto/close-short-transfer-request.dto.ts b/apps/edr-freight-api/src/modules/wagons/dto/close-short-transfer-request.dto.ts new file mode 100644 index 000000000..a277cfe72 --- /dev/null +++ b/apps/edr-freight-api/src/modules/wagons/dto/close-short-transfer-request.dto.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/wagons/dto/list-transfer-requests-query.dto.ts b/apps/edr-freight-api/src/modules/wagons/dto/list-transfer-requests-query.dto.ts new file mode 100644 index 000000000..733b774bb --- /dev/null +++ b/apps/edr-freight-api/src/modules/wagons/dto/list-transfer-requests-query.dto.ts @@ -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]; +} diff --git a/apps/edr-freight-api/src/modules/wagons/entities/wagon-transfer-request.entity.ts b/apps/edr-freight-api/src/modules/wagons/entities/wagon-transfer-request.entity.ts index c81b6c365..c39b12b9d 100644 --- a/apps/edr-freight-api/src/modules/wagons/entities/wagon-transfer-request.entity.ts +++ b/apps/edr-freight-api/src/modules/wagons/entities/wagon-transfer-request.entity.ts @@ -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; diff --git a/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.controller.ts b/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.controller.ts index b00e4a64f..9e69c3bf6 100644 --- a/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.controller.ts +++ b/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.controller.ts @@ -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); } diff --git a/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.service.spec.ts b/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.service.spec.ts new file mode 100644 index 000000000..ecf512aac --- /dev/null +++ b/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.service.spec.ts @@ -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 => + ({ + 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); + }); + }); +}); diff --git a/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.service.ts b/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.service.ts index 068d4dc6d..79a74f4ce 100644 --- a/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.service.ts +++ b/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.service.ts @@ -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, @@ -46,13 +71,14 @@ export class WagonTransferRequestsService { @InjectRepository(WagonMovement) private readonly movementRepo: Repository, 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 { + /** + * 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 { 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 { - return this.requestRepo.find({ - where: status ? { status } : {}, - relations: REQUEST_RELATIONS, - order: { createdAt: 'DESC' }, - }); + query: ListTransferRequestsQueryDto, + ): Promise> { + 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 { @@ -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 { 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 { + 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 { + 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 { - const requests = await this.requestRepo.find({ + async getHistory( + userId?: string | null, + page?: number, + pageSize?: number, + ): Promise { + 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 { 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; diff --git a/apps/edr-freight-api/src/modules/wagons/wagons.module.ts b/apps/edr-freight-api/src/modules/wagons/wagons.module.ts index 05162ea23..8c8a0d11f 100644 --- a/apps/edr-freight-api/src/modules/wagons/wagons.module.ts +++ b/apps/edr-freight-api/src/modules/wagons/wagons.module.ts @@ -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, diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index 65ca3bcde..2924169eb 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -233,6 +233,12 @@ export const FLEET_RAIL_PERMISSIONS: FreightPermissionSeed[] = [ perm('e1b00001-0001-4000-8000-000000000005', 'edr_freight_app:wagons:transfer_request', 'Request wagon transfer'), perm('e1b00001-0001-4000-8000-000000000006', 'edr_freight_app:wagons:transfer_fulfill', 'Fulfil wagon transfer (OCC)'), perm('e1b00001-0001-4000-8000-000000000007', 'edr_freight_app:wagons:transfer_history_all', "View all staff's transfer history"), + // The transfer desk is its own screen, so it carries its own per-action keys — + // seeing the queue, withdrawing a request and short-closing one are separate + // grants from filing or fulfilling. + perm('e1b00001-0001-4000-8000-000000000008', 'edr_freight_app:wagons:transfer_view', 'View wagon transfer requests'), + perm('e1b00001-0001-4000-8000-000000000009', 'edr_freight_app:wagons:transfer_cancel', 'Withdraw a wagon transfer request'), + perm('e1b00001-0001-4000-8000-00000000000a', 'edr_freight_app:wagons:transfer_close_short', 'Close a transfer request short of the requested count'), perm('e1c00001-0001-4000-8000-000000000001', 'edr_freight_app:trains:view', 'View trains'), perm('e1c00001-0001-4000-8000-000000000002', 'edr_freight_app:trains:create', 'Create train'), perm('e1c00001-0001-4000-8000-000000000003', 'edr_freight_app:trains:update', 'Update train'), @@ -524,6 +530,12 @@ export const FREIGHT_PERMS = { // executes the move). Distinct keys so OCC can hold fulfil without request. transferRequest: 'edr_freight_app:wagons:transfer_request', transferFulfill: 'edr_freight_app:wagons:transfer_fulfill', + /** Open the transfer-requests desk (list + detail). */ + transferView: 'edr_freight_app:wagons:transfer_view', + /** Withdraw a request that has not moved any wagon yet. */ + transferCancel: 'edr_freight_app:wagons:transfer_cancel', + /** End a request short — anyone who can fulfil may also do this. */ + transferCloseShort: 'edr_freight_app:wagons:transfer_close_short', // Admin: read every staffer's transfer history. Without it, a user only sees // their own (the /history endpoint uses the caller id, backend-enforced). transferHistoryAll: 'edr_freight_app:wagons:transfer_history_all', @@ -905,6 +917,12 @@ export const POSITION_PERMISSION_PRESETS = { ...ROLE_PERMISSION_PRESETS.director, ...ROLE_PERMISSION_PRESETS.operationsOfficer, FREIGHT_PERMS.allocation.manage, + // Customer desk: onboarding intake lands on the chief — open the customer + // list and approve/suspend a submitted profile. Deliberately NOT granted: + // create, update and password reset, which stay with the customer admins. + FREIGHT_PERMS.customers.view, + FREIGHT_PERMS.customers.verify, + FREIGHT_PERMS.customers.deactivate, ]), director: dedupe([...ROLE_PERMISSION_PRESETS.director]), ceo: dedupe([...ROLE_PERMISSION_PRESETS.ceo]), diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index bb4d024c8..5e2e48f32 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -1,4 +1,5 @@ import { + ArrowLeftRight, Boxes, Building2, Container, @@ -86,6 +87,7 @@ import DropdownSettingsPage from "./pages/dropdown_settings/DropdownSettingsPage import ContractTemplatesPage from "./pages/contract_templates/ContractTemplatesPage"; import ContractTemplateEditorPage from "./pages/contract_templates/ContractTemplateEditorPage"; import FleetResourcePage from "./pages/fleet/FleetResourcePage"; +import WagonTransfersPage from "./pages/wagons/WagonTransfersPage"; import VehicleDetailPage from "./pages/fleet/VehicleDetailPage"; import DriverDetailPage from "./pages/fleet/DriverDetailPage"; import RoutesPage from "./pages/fleet/RoutesPage"; @@ -297,6 +299,15 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ icon: , permission: [FREIGHT_PERMS.wagons.view, FREIGHT_PERMS.fleet.view], }, + { + label: "Wagon Transfers", + href: "/dashboard/wagon-transfers", + icon: , + permission: [ + FREIGHT_PERMS.wagons.transferView, + FREIGHT_PERMS.wagons.view, + ], + }, { label: "Vehicles", href: "/dashboard/vehicles", @@ -1180,6 +1191,19 @@ const App = () => { } /> + + + + } + /> { } /> + + + + } + /> {bookingWagons.map((w) => ( - #{w.sequenceNo} + #{w.position ?? w.sequenceNo} ))} diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/InteractiveTrainConsist.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/InteractiveTrainConsist.tsx index 1e1bb706e..b0f58a67a 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/InteractiveTrainConsist.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/InteractiveTrainConsist.tsx @@ -281,7 +281,7 @@ function WagonCar({ {/* header */} - #{wagon.sequenceNo} + #{wagon.position ?? wagon.sequenceNo} {isEmpty ? ( @@ -425,7 +425,7 @@ function WagonCar({
- Wagon #{wagon.sequenceNo} + Wagon #{wagon.position ?? wagon.sequenceNo} {wagon.physicalWagonNumber ?? wagon.wagonType?.code ?? "Unassigned"} diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/RemoveBookingModal.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/RemoveBookingModal.tsx index 80cbeaad6..dc8db43f4 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/RemoveBookingModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/RemoveBookingModal.tsx @@ -45,7 +45,7 @@ export const RemoveBookingModal = ({ Gross weight: {grossTons.toFixed(2)} T - Wagon Slot: #{wagon.sequenceNo} + Wagon Slot: #{wagon.position ?? wagon.sequenceNo}
diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/TrainConsistView.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/TrainConsistView.tsx index 5dd55680b..f9f969222 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/TrainConsistView.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/TrainConsistView.tsx @@ -217,7 +217,7 @@ export const TrainConsistView = ({ - Editing wagon #{selectedWagon.sequenceNo} + Editing wagon #{selectedWagon.position ?? selectedWagon.sequenceNo} Update container numbers, move containers to another wagon, or remove the booking diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/WagonCard.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/WagonCard.tsx index d1d1e8d0b..3678b2e68 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/WagonCard.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/WagonCard.tsx @@ -80,7 +80,7 @@ export const WagonCard = ({
- Wagon #{wagon.sequenceNo} + Wagon #{wagon.position ?? wagon.sequenceNo} {wagonType} @@ -205,7 +205,7 @@ export const WagonCard = ({ > - #{w.sequenceNo} ·{" "} + #{w.position ?? w.sequenceNo} ·{" "} {w.physicalWagonNumber ?? w.wagonType?.code ?? "Wagon"} void; -} - -const PENDING = Freight.WagonTransferRequestStatus.Pending; -const AVAILABLE = Freight.WagonStatus.Available; - -const yardLabel = (y?: { label?: string; code?: string } | null) => - y?.label || y?.code || "—"; -const typeLabel = (t?: { code?: string; name?: string } | null) => - t ? `${t.code ?? ""}${t.name ? ` · ${t.name}` : ""}` : "—"; - -/** Requester → destination + type + count summary line, reused in list and picker. */ -const RequestSummary = ({ r }: { r: WagonTransferRequest }) => ( - - - {yardLabel(r.fromYard)} - - - - {yardLabel(r.toYard)} - - - {r.quantity}× {typeLabel(r.wagonType)} - - -); - -const STATUS_COLOR: Record = { - PENDING: "gray", - FULFILLED: "teal", - CANCELLED: "red", -}; - -const fmtDateTime = (iso: string) => - new Date(iso).toLocaleString("en-GB", { - day: "numeric", - month: "short", - hour: "2-digit", - minute: "2-digit", - hour12: false, - }); - -/** - * Per-user transfer history. A staffer sees their OWN activity — the requests - * they filed or fulfilled, and the individual wagons they moved. Holders of - * `transfer_history_all` get an "All staff" toggle that widens the view; the - * backend enforces the scope regardless of the toggle. - */ -function HistoryPanel({ opened }: { opened: boolean }) { - const { user } = useAuth(); - const canSeeAll = hasPermission( - user, - FREIGHT_PERMS.wagons.transferHistoryAll, - ); - const myId = (user as { id?: string } | null | undefined)?.id; - const [allStaff, setAllStaff] = useState(false); - const scopeAll = canSeeAll && allStaff; - - const mine = useQuery({ - ...api.wagonTransferRequests.history.queryOptions(), - enabled: opened && !scopeAll, - }); - const all = useQuery({ - ...api.wagonTransferRequests.historyAll.queryOptions({ input: {} }), - enabled: opened && scopeAll, - }); - const source = scopeAll ? all : mine; - const requests = source.data?.requests ?? []; - const movements: WagonMovementRecord[] = source.data?.movements ?? []; - - const roleBadge = (r: WagonTransferRequest) => { - if (myId && r.fulfilledByUserId === myId) - return ( - - fulfilled - - ); - if (myId && r.requestedByUserId === myId) - return ( - - requested - - ); - return null; - }; - - return ( - - {canSeeAll ? ( - - setAllStaff(e.currentTarget.checked)} - label="All staff" - color="edr-green" - /> - - ) : null} - - {source.isLoading ? ( - - - - ) : ( - <> -
- - Requests{scopeAll ? "" : " you touched"} - - {requests.length === 0 ? ( - - No requests yet. - - ) : ( - - {requests.map((r) => ( - - - - - {roleBadge(r)} - - {r.status.toLowerCase()} - - - - {r.reason ? ( - - Reason: {r.reason} - - ) : null} - - ))} - - )} -
- - - -
- - Wagons moved - - {movements.length === 0 ? ( - - No wagon moves yet. - - ) : ( - - - {movements.map((m) => ( - - - - - {m.wagon?.wagonNumber ?? "Wagon"} - - - {yardLabel(m.fromYard)} → {yardLabel(m.toYard)} - - {m.transferRequestId ? ( - - from request - - ) : null} - - - {fmtDateTime(m.occurredAt)} - - - - ))} - - - )} -
- - )} -
- ); -} - -/** - * OCC fulfilment queue for wagon-transfer requests. Lists PENDING requests; open - * one to hand-pick exactly the requested number of wagons from the source yard - * (of the requested type) and execute the move, or cancel the request. - * A second tab shows per-user transfer history. - */ -const WagonTransferRequestsModal = ({ - opened, - onClose, -}: WagonTransferRequestsModalProps) => { - const { toast } = useToast(); - const [tab, setTab] = useState("queue"); - const [active, setActive] = useState(null); - const [picked, setPicked] = useState>(new Set()); - // Bulk accept-and-execute: the subset of pending requests OCC ticked. - const [selected, setSelected] = useState>(new Set()); - - const { data: requests = [], isLoading } = useQuery({ - ...api.wagonTransferRequests.list.queryOptions({ input: { status: PENDING } }), - enabled: opened, - }); - - // Available wagons of the requested type sitting in the request's source yard. - const { data: wagons = [], isLoading: wagonsLoading } = useQuery({ - ...api.wagons.list.queryOptions({ - input: { - filters: active - ? { - currentYardId: active.fromYardId, - wagonTypeId: active.wagonTypeId, - status: AVAILABLE, - } - : {}, - }, - }), - enabled: opened && Boolean(active), - }); - - const fulfill = useMutation(api.wagonTransferRequests.fulfill.mutationOptions()); - const bulkFulfill = useMutation( - api.wagonTransferRequests.bulkFulfill.mutationOptions(), - ); - const cancel = useMutation(api.wagonTransferRequests.cancel.mutationOptions()); - - const showError = (err: unknown, fallback: string) => { - const message = - (err as { response?: { data?: { message?: string } } })?.response?.data - ?.message ?? fallback; - toast({ title: fallback, description: String(message), variant: "destructive" }); - }; - - const openPicker = (r: WagonTransferRequest) => { - setActive(r); - setPicked(new Set()); - }; - const closePicker = () => { - setActive(null); - setPicked(new Set()); - }; - - const toggle = (id: string) => - setPicked((prev) => { - const next = new Set(prev); - if (next.has(id)) next.delete(id); - else if (active && next.size >= active.quantity) return prev; // cap at quantity - else next.add(id); - return next; - }); - - const need = active?.quantity ?? 0; - const shortfall = active ? Math.max(0, need - wagons.length) : 0; - - const handleFulfill = async () => { - if (!active || picked.size !== need) return; - try { - await fulfill.mutateAsync({ id: active.id, wagonIds: [...picked] }); - toast({ - title: `Transferred ${need} wagon(s) · ${yardLabel(active.fromYard)} → ${yardLabel( - active.toYard, - )}`, - }); - closePicker(); - } catch (err) { - showError(err, "Transfer failed"); - } - }; - - const handleCancel = async (r: WagonTransferRequest) => { - try { - await cancel.mutateAsync({ id: r.id }); - toast({ title: "Request cancelled" }); - } catch (err) { - showError(err, "Cancel failed"); - } - }; - - const toggleSelected = (id: string) => - setSelected((prev) => { - const next = new Set(prev); - if (next.has(id)) next.delete(id); - else next.add(id); - return next; - }); - - // Execute the ticked subset; whatever cannot run (not enough available - // wagons, already decided) is reported and simply stays PENDING. - const handleBulkFulfill = async () => { - if (selected.size === 0) return; - try { - const res = await bulkFulfill.mutateAsync({ requestIds: [...selected] }); - setSelected(new Set()); - const skippedNote = res.skipped.length - ? ` · ${res.skipped.length} left pending (${res.skipped - .map((s) => s.reason) - .join('; ')})` - : ""; - toast({ - title: `Executed ${res.fulfilled.length} transfer request(s)`, - description: skippedNote || undefined, - variant: res.fulfilled.length === 0 ? "destructive" : undefined, - }); - } catch (err) { - showError(err, "Bulk execute failed"); - } - }; - - const sortedWagons = useMemo( - () => [...wagons].sort((a, b) => a.wagonNumber.localeCompare(b.wagonNumber)), - [wagons], - ); - - return ( - - - - -
- Wagon Transfer Requests - - {active - ? "Pick the wagons to move, then transfer" - : "OCC queue — pick wagons and complete each move"} - -
-
- } - > - - - }> - Queue - - }> - History - - - - - {!active ? ( - // ---- Pending queue ---- - isLoading ? ( - - - - ) : requests.length === 0 ? ( - - - - - - No pending transfer requests - - When staff request a yard-to-yard wagon move, it appears here for - you to fulfil. - - - - ) : ( - - {/* Bulk accept-and-execute action bar: tick a subset, run it, and - everything unticked (or unexecutable) stays PENDING. */} - - 0 - ? `${selected.size} of ${requests.length} selected` - : "Select all" - } - checked={selected.size === requests.length && requests.length > 0} - indeterminate={selected.size > 0 && selected.size < requests.length} - onChange={() => - setSelected( - selected.size === requests.length - ? new Set() - : new Set(requests.map((r) => r.id)), - ) - } - color="edr-green" - /> - - - - {requests.map((r) => ( - - - - toggleSelected(r.id)} - color="edr-green" - mt={2} - /> - - - {r.reason ? ( - - - Reason: - {" "} - {r.reason} - - ) : null} - {r.note ? ( - - “{r.note}” - - ) : null} - - - - - - - - - ))} - - ) - ) : ( - // ---- Wagon picker for the active request ---- - - - - - - - - Select wagons in {yardLabel(active.fromYard)} - - - {picked.size} / {need} selected - - - - {wagonsLoading ? ( - - - - ) : sortedWagons.length === 0 ? ( - - - - - No available wagons of this type in {yardLabel(active.fromYard)}. - - - - ) : ( - <> - {shortfall > 0 ? ( - - Only {sortedWagons.length} available — {shortfall} short of the{" "} - {need} requested. - - ) : null} - - - {sortedWagons.map((w) => { - const checked = picked.has(w.id); - const atCap = !checked && picked.size >= need; - return ( - !atCap && toggle(w.id)} - style={{ - cursor: atCap ? "not-allowed" : "pointer", - borderColor: checked - ? "var(--mantine-color-edr-green-4)" - : undefined, - opacity: atCap ? 0.55 : 1, - }} - > - - {/* Visual only — the Card's onClick owns the toggle so a - click on the box doesn't fire both and cancel out. */} - - - {w.wagonNumber} - - - - ); - })} - - - - )} - - - - - - - - )} - - - - - - - - ); -}; - -export default WagonTransferRequestsModal; diff --git a/apps/edr-freight-web/backoffice/src/components/wagons/WagonYardWorkspaceModal.tsx b/apps/edr-freight-web/backoffice/src/components/wagons/WagonYardWorkspaceModal.tsx index c10b1b033..56d5a14de 100644 --- a/apps/edr-freight-web/backoffice/src/components/wagons/WagonYardWorkspaceModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/wagons/WagonYardWorkspaceModal.tsx @@ -40,7 +40,12 @@ const clampInt = (v: number | string, max: number): number => { return Math.min(Math.floor(n), max); }; -/** NumberInput + Slider + All/Half presets, kept in sync and bounded to `max`. */ +/** + * 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 + * legitimately ask for more than the yard holds today (OCC fulfils it in + * instalments) — the slider then just tracks the current value. + */ const QuantityField = ({ value, onChange, @@ -49,10 +54,11 @@ const QuantityField = ({ }: { value: number; onChange: (n: number) => void; - max: number; + max?: number; disabled?: boolean; }) => { - const set = (v: number | string) => onChange(clampInt(v, max)); + const capped = max ?? Number.MAX_SAFE_INTEGER; + const set = (v: number | string) => onChange(clampInt(v, capped)); const off = disabled || max === 0; return ( @@ -73,19 +79,25 @@ const QuantityField = ({ value={value} onChange={set} min={0} - max={Math.max(max, 1)} + max={Math.max(max ?? Math.max(value, 10), 1)} disabled={off} label={(v) => `${v}`} color="edr-green" />
- - + {/* Presets only make sense against a real ceiling — an uncapped request + field (transfer ask) shows the manual input alone. */} + {max != null ? ( + <> + + + + ) : null} {value > 0 ? (
+ + +