Merge branch 'freight_feature/profile' of github.com:Tria-plc/edr-platform into freight_feature/profile

This commit is contained in:
marshal
2026-06-25 09:10:02 +03:00
6 changed files with 65 additions and 137 deletions

View File

@@ -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;
}

View File

@@ -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,
})),
],
}

View File

@@ -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) {

View File

@@ -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({
/>
</Box>
)}
{showRouteQuantity && (
<Box style={{ maxWidth: 220 }}>
<Controller
name="primaryRouteQuantity"
control={form.control}
render={({ field, fieldState }) => (
<NumberInput
label={`${quantityLabel} *`}
placeholder={isPerItem ? "e.g. 500" : "e.g. 1200"}
description="Quantity reserved on the primary route."
min={0}
step={quantityStep}
error={fieldState.error?.message}
value={field.value === "" ? "" : Number(field.value)}
onChange={(v) => field.onChange(String(v ?? ""))}
radius="md"
/>
)}
/>
</Box>
)}
</div>
)}
@@ -294,9 +258,9 @@ export function Step4Route({
</Button>
</Group>
<Text fz={12} c="#6B7C8E" mb={12}>
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 origindestination routes the contract should
cover.
</Text>
<Stack gap={12}>
{extraRoutes.map((rf, i) => (
@@ -338,40 +302,6 @@ export function Step4Route({
)}
/>
</Box>
<Box style={{ width: 140 }}>
<Controller
name={`extraRoutes.${i}.quantity`}
control={form.control}
render={({ field }) => (
<NumberInput
label={quantityLabel}
placeholder="0"
min={0}
step={quantityStep}
value={field.value === "" ? "" : Number(field.value)}
onChange={(v) => field.onChange(String(v ?? ""))}
radius="md"
/>
)}
/>
</Box>
<Box style={{ width: 110 }}>
<Controller
name={`extraRoutes.${i}.km`}
control={form.control}
render={({ field }) => (
<NumberInput
label="Distance (km)"
placeholder="0"
min={0}
step={1}
value={field.value === "" ? "" : Number(field.value)}
onChange={(v) => field.onChange(String(v ?? ""))}
radius="md"
/>
)}
/>
</Box>
<Button
variant="subtle"
color="red"

View File

@@ -160,14 +160,10 @@ export function Step8Review({
.join(", ")
: "";
// For bulk general contracts the quantity is reserved per route (the primary
// route's amount lives in primaryRouteQuantity); one-time bookings use the
// cargo-step cargoWeight.
const isGeneralContract = values.bookingType === "general_contract";
const bulkAmount =
isGeneralContract && values.cargoType === "bulk"
? Number(values.primaryRouteQuantity || 0)
: Number(values.cargoWeight || 0);
// Both one-time and general contracts take the bulk amount from the cargo step
// (cargoWeight); general contracts no longer collect a per-route quantity.
const bulkAmount = Number(values.cargoWeight || 0);
const totalVgm =
values.cargoType === "container"
? values.containers.reduce(

View File

@@ -356,14 +356,15 @@ export default function ContractDetailPage() {
Ship {new Date(order.scheduledDate).toLocaleDateString()}
{" · "}
{order.lines
.map(
(l) =>
`${Number.isInteger(l.quantity) ? l.quantity : l.quantity.toFixed(2)}${
l.containerTypeName
? ` ${l.containerTypeName}`
: ""
}`,
)
.map((l) => {
const qty = Number(l.quantity);
const label = Number.isInteger(qty)
? `${qty}`
: qty.toFixed(2);
return `${label}${
l.containerTypeName ? ` ${l.containerTypeName}` : ""
}`;
})
.join(", ")}
</Text>
</Box>