diff --git a/apps/edr-freight-api/src/modules/booking-orders/entities/booking-order-line.entity.ts b/apps/edr-freight-api/src/modules/booking-orders/entities/booking-order-line.entity.ts
index 7b4728e02..d6fff951a 100644
--- a/apps/edr-freight-api/src/modules/booking-orders/entities/booking-order-line.entity.ts
+++ b/apps/edr-freight-api/src/modules/booking-orders/entities/booking-order-line.entity.ts
@@ -3,6 +3,16 @@ import { Column, Entity, JoinColumn, ManyToOne } from 'typeorm';
import { ContainerType } from '../../rule-engine/entities/container-type.entity';
import { BookingOrder } from './booking-order.entity';
+/**
+ * Postgres `numeric` columns are serialized to JS strings by the driver. This
+ * transformer hydrates them back into real numbers so consumers (and the
+ * `quantity: number` API type) don't have to coerce on every read.
+ */
+const numericColumn = {
+ to: (value: number) => value,
+ from: (value: string | null) => (value == null ? value : Number(value)),
+};
+
/**
* One drawn-down quantity line of an order. For CONTAINER contracts there is one
* line per container type (matching the contract's pools); for BULK/BREAK_BULK a
@@ -25,7 +35,7 @@ export class BookingOrderLine extends BaseEntity {
containerType?: ContainerType | null;
/** Containers (count), tons, or items depending on the contract's freight/UoM. */
- @Column({ name: 'quantity', type: 'numeric', precision: 12, scale: 3 })
+ @Column({ name: 'quantity', type: 'numeric', precision: 12, scale: 3, transformer: numericColumn })
quantity!: number;
/**
@@ -33,9 +43,23 @@ export class BookingOrderLine extends BaseEntity {
* customer when they toggle the flag. Drives the HAZARD_SURCHARGE /
* REEFER_SURCHARGE rates on the spawned child booking. Both ≤ quantity.
*/
- @Column({ name: 'hazardous_quantity', type: 'numeric', precision: 12, scale: 3, default: 0 })
+ @Column({
+ name: 'hazardous_quantity',
+ type: 'numeric',
+ precision: 12,
+ scale: 3,
+ default: 0,
+ transformer: numericColumn,
+ })
hazardousQuantity!: number;
- @Column({ name: 'reefer_quantity', type: 'numeric', precision: 12, scale: 3, default: 0 })
+ @Column({
+ name: 'reefer_quantity',
+ type: 'numeric',
+ precision: 12,
+ scale: 3,
+ default: 0,
+ transformer: numericColumn,
+ })
reeferQuantity!: number;
}
diff --git a/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx b/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx
index 3e1367ba5..7b23d6b16 100644
--- a/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx
+++ b/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx
@@ -394,13 +394,10 @@ export default function NewBookingPage() {
const isPerItem =
bulkChild?.unit_of_measure === Freight.CargoUnitOfMeasure.PerItem;
const isContract = data.bookingType === "general_contract";
- // For bulk general contracts the contracted quantity is entered against the
- // primary route in the route step; one-time bookings use the cargo-step
- // amount. Item counts are rounded since fractional items are meaningless.
- const bulkAmountRaw =
- isContract && data.cargoType === "bulk"
- ? data.primaryRouteQuantity
- : data.cargoWeight;
+ // Both one-time and general contracts take the bulk amount from the cargo
+ // step (cargoWeight) — general contracts no longer collect a per-route
+ // quantity. Item counts are rounded since fractional items are meaningless.
+ const bulkAmountRaw = data.cargoWeight;
const totalWeight =
data.cargoType === "container"
? 0
@@ -493,7 +490,9 @@ export default function NewBookingPage() {
: { customsClearingEnabled: false }),
...(cargoFreeText ? { cargoFreeText } : {}),
// Multi-route general contracts: route #1 is the primary origin/destination
- // carrying the full contracted quantity; each extra route reserves its own.
+ // carrying the full contracted quantity (from the cargo step). Extra routes
+ // are just additional origin/destination pairs the contract covers — no
+ // per-route quantity is collected, so they are sent with quantity 0.
...(isContract
? {
routes: [
@@ -509,17 +508,11 @@ export default function NewBookingPage() {
: totalWeight,
},
...(data.extraRoutes ?? [])
- .filter(
- (r) =>
- r.originYard &&
- r.destinationYard &&
- Number(r.quantity) > 0,
- )
+ .filter((r) => r.originYard && r.destinationYard)
.map((r) => ({
originYardId: r.originYard,
destinationYardId: r.destinationYard,
- quantity: Number(r.quantity),
- ...(r.km && Number(r.km) > 0 ? { km: Number(r.km) } : {}),
+ quantity: 0,
})),
],
}
diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/schema.ts b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/schema.ts
index 969c109cc..84b8172c6 100644
--- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/schema.ts
+++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/schema.ts
@@ -140,22 +140,20 @@ export const bookingFormSchema = z
customsClearingAgent: z.string().default(""),
originYard: z.string().min(1, "Select an origin yard."),
destinationYard: z.string().min(1, "Select a destination yard."),
- // Quantity reserved on the PRIMARY route of a GENERAL contract, in the unit
- // of the selected commodity (items vs tons). Customers enter it explicitly in
- // the route step so the primary route reads consistently with the extra
- // routes below. Ignored for one-time bookings; for containers the value is
- // derived from the container count instead (see buildApiPayload).
+ // Retained for payload/back-compat only — no longer collected in the UI.
+ // The contracted quantity now comes from the cargo step (cargoWeight), the
+ // same as a one-time booking, so per-route quantity is no longer entered.
primaryRouteQuantity: z.string().default(""),
// Additional routes for a GENERAL contract (the primary origin/destination
- // above is route #1). Each adds another (origin, destination, quantity) pool.
- // Ignored for one-time bookings.
+ // above is route #1). Each route is just an (origin, destination) pair —
+ // identical to the one-time route — so a contract can cover several routes.
+ // Ignored for one-time bookings. quantity/km kept for payload back-compat.
extraRoutes: z
.array(
z.object({
originYard: z.string(),
destinationYard: z.string(),
- quantity: z.string(),
- // Road distance for this route; used to bill road (truck) orders.
+ quantity: z.string().default(""),
km: z.string().default(""),
}),
)
@@ -221,11 +219,10 @@ export const bookingFormSchema = z
)
.refine(
(data) => {
- // General contracts capture bulk quantity per route (primaryRouteQuantity),
- // not via the cargo-step cargoWeight — so only validate it for one-time
- // bulk bookings.
+ // Both one-time and general contracts capture the bulk amount in the cargo
+ // step (cargoWeight). General contracts no longer collect a per-route
+ // quantity, so the cargo amount is the single source for the contract total.
if (data.cargoType !== "bulk") return true;
- if (data.bookingType === "general_contract") return true;
const quantity = Number(data.cargoWeight);
return !!data.cargoWeight && !Number.isNaN(quantity) && quantity > 0;
},
@@ -257,19 +254,6 @@ export const bookingFormSchema = z
});
}
}
- // General contracts reserve quantity per route. The primary route's quantity
- // is entered in the route step; containers derive it from the container
- // count, so only bulk cargo requires it here.
- if (data.bookingType === "general_contract" && data.cargoType === "bulk") {
- const qty = Number(data.primaryRouteQuantity);
- if (!data.primaryRouteQuantity || Number.isNaN(qty) || qty <= 0) {
- ctx.addIssue({
- code: "custom",
- path: ["primaryRouteQuantity"],
- message: "Enter a quantity greater than 0.",
- });
- }
- }
if (data.cargoType === "container") {
data.containers.forEach((c, i) => {
if (!c.qty || +c.qty < 1) {
diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step4-route.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step4-route.tsx
index 7386f2d48..283315351 100644
--- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step4-route.tsx
+++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step4-route.tsx
@@ -4,7 +4,6 @@ import {
Button,
Divider,
Group,
- NumberInput,
Skeleton,
Stack,
Switch,
@@ -137,25 +136,11 @@ export function Step4Route({
const stationSelectDisabled = yardOptions.length === 0;
- // General contracts reserve quantity per route. The unit (items vs tons) comes
- // from the commodity picked in the cargo step, mirroring step5-cargo-details:
- // PER_ITEM → a whole item count; otherwise an estimated tonnage. Container
- // contracts reserve quantity by container count instead, so no quantity input
- // is shown for them here.
+ // A general contract can cover several routes, but each route is just an
+ // (origin, destination) pair — the same shape as the one-time route. The
+ // contracted quantity comes from the cargo step, so no per-route quantity or
+ // distance is collected here.
const cargoType = form.watch("cargoType");
- const cargoTypePath = form.watch("cargoTypePath") ?? [];
- const isContainer = cargoType === "container";
- const selectedCommodity = useMemo(() => {
- const parentId = cargoTypePath[0];
- const childId = cargoTypePath[1];
- if (!referenceData?.cargo_type || !parentId || !childId) return null;
- const group = referenceData.cargo_type.find((g) => g.id === parentId);
- return group?.children?.find((c) => c.id === childId) ?? null;
- }, [referenceData, cargoTypePath]);
- const isPerItem = selectedCommodity?.unit_of_measure === "PER_ITEM";
- const quantityLabel = isPerItem ? "Quantity (Items)" : "Quantity (Tons)";
- const quantityStep = isPerItem ? 1 : 0.01;
- const showRouteQuantity = isGeneralContract && !isContainer;
// The reefer toggle only exists for bulk; if the customer switches to
// containers, drop any reefer flag they set so it can't ride along unseen.
@@ -246,27 +231,6 @@ export function Step4Route({
/>
)}
- {showRouteQuantity && (
-
- (
- field.onChange(String(v ?? ""))}
- radius="md"
- />
- )}
- />
-
- )}
)}
@@ -294,9 +258,9 @@ export function Step4Route({
- A general contract can reserve quantity across several routes. The
- route above is your primary route; add more routes and set the
- quantity reserved for each.
+ A general contract can cover several routes. The route above is your
+ primary route; add more origin–destination routes the contract should
+ cover.
{extraRoutes.map((rf, i) => (
@@ -338,40 +302,6 @@ export function Step4Route({
)}
/>
-
- (
- field.onChange(String(v ?? ""))}
- radius="md"
- />
- )}
- />
-
-
- (
- field.onChange(String(v ?? ""))}
- radius="md"
- />
- )}
- />
-