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 f858c6c14..79b009d79 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 @@ -17,7 +17,7 @@ import { } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { InjectDataSource } from '@nestjs/typeorm'; -import { DataSource, EntityManager, In, IsNull, Not, QueryFailedError } from 'typeorm'; +import { DataSource, EntityManager, In, Not, QueryFailedError } from 'typeorm'; import { BookingsRepository } from '../bookings/bookings.repository'; import { Booking } from '../bookings/entities/booking.entity'; @@ -43,8 +43,6 @@ import { WagonAllocationBulkLoadsRepository } from '../train-schedules/wagon-all import { WagonAllocationContainerItemsRepository } from '../train-schedules/wagon-allocation-container-items.repository'; import { WagonBookingAllocationsRepository } from '../train-schedules/wagon-booking-allocations.repository'; import { WagonType } from '../wagon-types/entities/wagon-type.entity'; -import { CargoType } from '../rule-engine/entities/cargo-type.entity'; -import { ContainerType } from '../rule-engine/entities/container-type.entity'; import { WagonTypesRepository } from '../wagon-types/wagon-types.repository'; import { Wagon } from '../wagons/entities/wagon.entity'; import { AssignBookingsDto } from './dto/assign-bookings.dto'; @@ -3446,37 +3444,6 @@ export class TrainSchedulingService { return wagonType; } - /** - * Soft wagon-type resolution for the customer-facing availability preview - * (getAvailableDaysForCargo). Reads the configured FK by cargo/container type; - * returns null (→ "no days") instead of throwing when nothing is configured, - * since this only estimates which days have wagons and creates no booking. - */ - private async resolveWagonTypeForPreview( - freightType: 'CONTAINER' | 'BULK', - cargoTypeCode: string | null, - ): Promise { - if (freightType === 'BULK') { - if (!cargoTypeCode) return null; - const cargoType = await this.dataSource.getRepository(CargoType).findOne({ - where: { code: cargoTypeCode }, - relations: { wagonType: true }, - }); - return cargoType?.wagonType?.isActive ? cargoType.wagonType : null; - } - - // Container preview: the input carries no specific container type, so use the - // wagon type of the first configured (active) container type. - const containerType = await this.dataSource - .getRepository(ContainerType) - .findOne({ - where: { isActive: true, wagonTypeId: Not(IsNull()) }, - relations: { wagonType: true }, - order: { displayOrder: 'ASC' }, - }); - return containerType?.wagonType?.isActive ? containerType.wagonType : null; - } - /** * Stamp each plan slot with the leg it occupies (dynamic consist): the * boarding/alighting yards of the bookings it carries. Null means the @@ -4237,13 +4204,14 @@ export class TrainSchedulingService { } /** - * Cargo-aware day pool: the EAT days that are actually FEASIBLE for the given - * cargo. A day is selectable only when ≥1 OPEN schedule on the route that day - * has BOTH (a) enough AVAILABLE wagons of the cargo's matching type at that - * schedule's origin yard, and (b) remaining train capacity (not fully - * allocated). Days with trains but not enough matching wagons are excluded. - * Same `{ days: string[] }` shape as getAvailableDays — the customer still - * picks a DAY, not a train. + * Cargo-aware day pool: the EAT days a customer may pick for this cargo. A day + * is selectable when ≥1 OPEN schedule on the route that day still has remaining + * train capacity (not fully allocated). Wagon availability is deliberately NOT + * checked here: whether a matching wagon currently sits in the right yard is an + * operational question staff resolve when they approve or reject the booking, + * not something the customer can act on while choosing a date. Same + * `{ days: string[] }` shape as getAvailableDays — the customer picks a DAY, + * not a train. */ async getAvailableDaysForCargo(input: { originYardId?: string; @@ -4259,85 +4227,17 @@ export class TrainSchedulingService { ); if (schedules.length === 0) return { days: [] }; - // Resolve the wagon type this cargo needs via the cargo/container-type FK. - // Soft (customer availability preview): no days if unresolved, never throws. - const requiredType = await this.resolveWagonTypeForPreview( - input.freightType, - input.cargoTypeCode ?? null, - ); - if (!requiredType) return { days: [] }; - - // How many wagons of that type the cargo needs. - const slotsNeeded = this.wagonsNeededForCargo(input, requiredType); - void slotsNeeded; // TEMP: unused while the wagon-availability filter is off. - - // TEMP (per request): wagon-availability filtering is DISABLED. A day is now - // offered whenever a bookable schedule that day has remaining train capacity - // — regardless of whether matching wagons are actually available at the - // origin / boarding yard. This surfaces days even when no wagon is on hand. - // Restore the block below to bring back the "enough matching wagons" gate. - // - // // AVAILABLE wagons of the required type, counted once per origin yard. - // const availableByYard = new Map(); - // const availableAt = async (yardId: string): Promise => { - // const cached = availableByYard.get(yardId); - // if (cached !== undefined) return cached; - // const counts = await this.countFleetAvailability(yardId); - // const n = - // counts.find((c) => c.wagonTypeId === requiredType.id)?.available ?? 0; - // availableByYard.set(yardId, n); - // return n; - // }; - const days = new Set(); for (const s of schedules) { const hasCapacity = Math.max(0, (s.maxWagons ?? 0) - (s.trainSet?.wagonCount ?? 0)) > 0; if (!hasCapacity) continue; - // TEMP (per request): wagon-availability check commented out — see note - // above. Dynamic consist: wagons may ride from the train's origin OR - // already sit at the booking's own boarding yard and attach when the train - // arrives — either pool can serve a sub-corridor booking. - // let enoughWagons = (await availableAt(s.originStationId)) >= slotsNeeded; - // if ( - // !enoughWagons && - // input.originYardId && - // input.originYardId !== s.originStationId - // ) { - // enoughWagons = (await availableAt(input.originYardId)) >= slotsNeeded; - // } - // if (!enoughWagons) continue; if (s.scheduledDepartureDate) days.add(eatDay(new Date(s.scheduledDepartureDate))); } return { days: [...days].sort() }; } - /** - * Wagons needed for a cargo (pre-booking estimate). BULK: ceil(weight / - * capacity). CONTAINER: TEU packing — 40ft = 2 TEU, 20ft = 1 TEU, 2 TEU per - * wagon. Mirrors wagon-plan.util without fabricating Booking entities. - */ - private wagonsNeededForCargo( - input: { - freightType: 'CONTAINER' | 'BULK'; - totalWeightTons?: number; - containers?: Array<{ containerSize: string; quantity: number }>; - }, - wagonType: WagonType, - ): number { - if (input.freightType === 'BULK') { - const capacity = Number(wagonType.capacityTons) || 1; - const weight = Number(input.totalWeightTons ?? 0); - return Math.max(1, Math.ceil(weight / capacity)); - } - const teu = (input.containers ?? []).reduce((sum, c) => { - const per = c.containerSize === '40ft' ? 2 : 1; - return sum + per * Math.max(0, Number(c.quantity ?? 0)); - }, 0); - return Math.max(1, Math.ceil(teu / 2)); - } - /** * Ordered stop yards of a schedule's route: origin → milestones → destination, * de-duplicated. Falls back to the two-endpoint pseudo-route when the schedule diff --git a/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineFormDialog.tsx b/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineFormDialog.tsx index 8553bcf0b..403a98f1b 100644 --- a/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineFormDialog.tsx +++ b/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineFormDialog.tsx @@ -307,13 +307,23 @@ const RuleEngineFormDialog = ({ ); } + const isNumber = field.type === "number"; + return ( setField(field.name, e.currentTarget.value)} + onChange={(e) => { + const next = e.currentTarget.value; + if (isNumber && next.trim().startsWith("-")) return; + setField(field.name, next); + }} placeholder={field.placeholder} required={field.required} size="md" diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/BookingWindowSettingsModal.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/BookingWindowSettingsModal.tsx index ec3774ce6..1bfa392bc 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/BookingWindowSettingsModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/BookingWindowSettingsModal.tsx @@ -392,6 +392,7 @@ export default function BookingWindowSettingsModal({ } min={1} clampBehavior="none" + allowNegative={false} allowDecimal={false} /> ) : ( @@ -410,6 +411,7 @@ export default function BookingWindowSettingsModal({ } min={0} clampBehavior="none" + allowNegative={false} allowDecimal={false} /> )} diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/DurationField.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/DurationField.tsx index 06c9013d4..9c3c54f3c 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/DurationField.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/DurationField.tsx @@ -98,6 +98,7 @@ export default function DurationField({ emitNative(v === "" ? "" : Number(v), unit) } clampBehavior="none" + allowNegative={false} allowDecimal min={min != null ? convert(min, nativeUnit, unit) : 0} disabled={disabled} diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainSchedulingGlobalRulesPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainSchedulingGlobalRulesPage.tsx index c71235640..0186d5acb 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainSchedulingGlobalRulesPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainSchedulingGlobalRulesPage.tsx @@ -107,6 +107,7 @@ export default function TrainSchedulingGlobalRulesPage() { setForm((current) => ({ ...current, maxTrainLengthMeters: value })) } clampBehavior="none" + allowNegative={false} allowDecimal min={1} disabled={loading} @@ -119,6 +120,7 @@ export default function TrainSchedulingGlobalRulesPage() { setForm((current) => ({ ...current, maxTrainWeightTons: value })) } clampBehavior="none" + allowNegative={false} allowDecimal min={1} disabled={loading} @@ -130,6 +132,7 @@ export default function TrainSchedulingGlobalRulesPage() { setForm((current) => ({ ...current, maxWagonsPerTrain: value })) } clampBehavior="none" + allowNegative={false} allowDecimal min={1} disabled={loading} @@ -145,6 +148,7 @@ export default function TrainSchedulingGlobalRulesPage() { })) } clampBehavior="none" + allowNegative={false} allowDecimal min={0.001} disabled={loading} @@ -160,6 +164,7 @@ export default function TrainSchedulingGlobalRulesPage() { })) } clampBehavior="none" + allowNegative={false} allowDecimal min={0} disabled={loading} @@ -203,6 +208,7 @@ export default function TrainSchedulingGlobalRulesPage() { setForm((current) => ({ ...current, windowOpenHour: value })) } clampBehavior="none" + allowNegative={false} allowDecimal min={0} max={23} @@ -216,6 +222,7 @@ export default function TrainSchedulingGlobalRulesPage() { setForm((current) => ({ ...current, windowCloseHour: value })) } clampBehavior="none" + allowNegative={false} allowDecimal min={0} max={23} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step5-cargo-details.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step5-cargo-details.tsx index ac336c47c..b582468cf 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step5-cargo-details.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step5-cargo-details.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef } from "react"; +import { useEffect, useMemo, useRef, type KeyboardEvent } from "react"; import { Controller, useFieldArray, type UseFormReturn } from "react-hook-form"; import { Flame, Package, Plus, Snowflake, Trash2, Weight } from "lucide-react"; import { ActionIcon, Button, Skeleton, Text, TextInput } from "@mantine/core"; @@ -26,6 +26,15 @@ type BookingForm = UseFormReturn< BookingFormValues >; +/** + * Every quantity on this step is a non-negative magnitude. A native number + * input's `min` only constrains its stepper, so swallow the minus key before it + * can put a negative into the field at all. + */ +const blockNegative = (event: KeyboardEvent) => { + if (event.key === "-") event.preventDefault(); +}; + /** * One numbered toggle per container unit in the line — tap units to mark how * many are hazardous/refrigerated (2 hazardous → toggle 2 units on). Selection @@ -379,6 +388,7 @@ export function Step5CargoDetails({ }} id="cargoWeight" type="number" + onKeyDown={blockNegative} label={isPerItem ? "Quantity (Items) *" : "Quantity (Tons) *"} placeholder={isPerItem ? "e.g. 500" : "e.g. 1200"} leftSection={ @@ -435,6 +445,7 @@ export function Step5CargoDetails({ render={({ field: hq, fieldState }) => ( ( diff --git a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx index 77086103b..c36a74255 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState, type KeyboardEvent } from "react"; import { useForm, Controller } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; @@ -58,6 +58,15 @@ type ShipmentForm = ReturnType< typeof useForm >; +/** + * Every quantity on this form is a non-negative magnitude. A native number + * input's `min` only constrains its stepper, so swallow the minus key before it + * can put a negative into the field at all. + */ +const blockNegative = (event: KeyboardEvent) => { + if (event.key === "-") event.preventDefault(); +}; + export default function NewShipmentPage() { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); @@ -962,6 +971,7 @@ function CargoStep({ qty) { + if (h < 0) { + refineCtx.addIssue({ + code: "custom", + path: ["containers", i, "hazardousQuantity"], + message: "Enter a valid hazardous quantity.", + }); + } else if (h > qty) { refineCtx.addIssue({ code: "custom", path: ["containers", i, "hazardousQuantity"], @@ -120,7 +126,13 @@ export function createShipmentFormSchema(ctx: ShipmentValidationContext) { } if (ctx.isReefer) { const r = Number(line.reeferQuantity || 0); - if (r > qty) { + if (r < 0) { + refineCtx.addIssue({ + code: "custom", + path: ["containers", i, "reeferQuantity"], + message: "Enter a valid refrigerated quantity.", + }); + } else if (r > qty) { refineCtx.addIssue({ code: "custom", path: ["containers", i, "reeferQuantity"], @@ -130,10 +142,21 @@ export function createShipmentFormSchema(ctx: ShipmentValidationContext) { } }); } else { - const bulkCap = - ctx.unitOfMeasure === "PER_ITEM" - ? Number(data.itemCount || 0) - : Number(data.cargoWeightTons || 0); + const isPerItem = ctx.unitOfMeasure === "PER_ITEM"; + const bulkCap = isPerItem + ? Number(data.itemCount || 0) + : Number(data.cargoWeightTons || 0); + + // The bulk cargo amount itself: a positive magnitude. Without this a + // negative (typed past the input's `min`) reaches the API unchecked. + const bulkPath = isPerItem ? "itemCount" : "cargoWeightTons"; + if (Number.isNaN(bulkCap) || bulkCap <= 0) { + refineCtx.addIssue({ + code: "custom", + path: [bulkPath], + message: "Enter a quantity greater than 0.", + }); + } const boundBulkPortion = ( on: boolean,