mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge branch 'freight_feature/profile' of github.com:Tria-plc/edr-platform into freight_feature/profile
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})),
|
||||
],
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 origin–destination 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"
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user