Merge pull request #452 from Tria-plc/freight_feature/usermanagement

Freight feature/usermanagement
This commit is contained in:
marshal
2026-07-04 07:25:38 +03:00
committed by GitHub
4 changed files with 218 additions and 86 deletions

View File

@@ -5,6 +5,7 @@ import { InjectRepository } from '@nestjs/typeorm';
import { DataSource, EntityManager, FindOptionsWhere, In, Repository, SelectQueryBuilder } from 'typeorm';
import { ContainerType } from '../rule-engine/entities/container-type.entity';
import { Contract } from '../contracts/entities/contract.entity';
import { ContractRoute } from '../contracts/entities/contract-route.entity';
import { BookingApprovalStep } from './entities/booking-approval-step.entity';
import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity';
@@ -588,8 +589,10 @@ export class BookingsRepository extends BaseRepository<Booking> {
.leftJoinAndSelect('booking.approvalSteps', 'approvalSteps')
.leftJoinAndSelect('booking.consolidationPartner', 'consolidationPartner')
// Contract reference for the list column + search (no entity relation on
// Booking → contract, so join by id and select just the reference).
.leftJoin('freight.contracts', 'contract', 'contract.id = booking.contract_id')
// Booking → contract, so join the entity by id and select just the
// reference — a schema-qualified table string is parsed as alias.relation
// by TypeORM and crashes).
.leftJoin(Contract, 'contract', 'contract.id = booking.contract_id')
.addSelect('contract.reference', 'contract_reference')
.where('booking.deleted_at IS NULL');

View File

@@ -138,6 +138,11 @@ export class ContractBookingService {
// no override. Checked before any row is written.
if (freightType === 'CONTAINER') {
await this.assertWithinMaxCapacity(contract, dto);
// 20ft weight-pairing gate at CREATION: two 20ft on a wagon must differ
// ≤ the cap, and drawdown bookings never pass through submit — so this is
// their only chance to hard-block an unbalanceable set. Entry order is
// irrelevant (the check sorts by weight before pairing).
await this.assert20ftPairableAtCreate(dto);
}
// Denormalize route/direction/freight onto the booking for the scheduling engine.
@@ -761,6 +766,35 @@ export class ContractBookingService {
}
}
/**
* Hard-block booking creation when the 20ft container weights cannot be
* balanced onto wagons (pair diff over the global cap). Same rule the
* shipment-form preview reports as `pairingErrors`, enforced server-side.
*/
private async assert20ftPairableAtCreate(
dto: CreateBookingUnderContractDto,
): Promise<void> {
const twentyFtUnits = (dto.containers ?? [])
.filter((line) => (line.containerSize ?? '').includes('20'))
.flatMap((line, lineIdx) =>
(line.units ?? []).map((u, idx) => ({
label: u.containerNumber || `20ft-${lineIdx + 1}.${idx + 1}`,
grossWeightTons: Number(u.vgmTons ?? 0),
})),
);
if (twentyFtUnits.length < 2) return;
const maxDiff = await this.max20ftPairDiffTons();
const violations = validate20ftWeightPairing(twentyFtUnits, maxDiff);
if (violations.length) {
throw new BadRequestException(
`Cannot create booking — 20ft containers cannot be paired on wagons: ${violations
.map((v) => v.message)
.join(' ')}`,
);
}
}
private async max20ftPairDiffTons(): Promise<number> {
const row = await this.dataSource
.getRepository(TrainSchedulingGlobalRules)

View File

@@ -7,6 +7,7 @@ import {
import { useMutation, useQuery } from "@tanstack/react-query";
import {
Alert,
Badge,
Box,
Button,
Center,
@@ -18,6 +19,7 @@ import {
Paper,
Select,
Stack,
Switch,
Text,
Textarea,
TextInput,
@@ -82,12 +84,13 @@ interface UnitDraft {
containerNumber: string;
sealNumber: string;
vgmTons: number | string;
/** Per-unit flags — the line's hazardous/reefer counts are derived from these. */
hazardous: boolean;
reefer: boolean;
}
interface ContainerLineDraft {
containerSize: string;
hazardousQuantity: number | string;
reeferQuantity: number | string;
units: UnitDraft[];
}
@@ -100,7 +103,13 @@ interface BulkLineDraft {
}
function emptyUnit(): UnitDraft {
return { containerNumber: "", sealNumber: "", vgmTons: "" };
return {
containerNumber: "",
sealNumber: "",
vgmTons: "",
hazardous: false,
reefer: false,
};
}
function bulkUnitOfMeasure(
@@ -199,11 +208,13 @@ export default function GlCreateBookingForm() {
setContainerLines(
lines.containers.map((c) => ({
containerSize: c.containerSize,
hazardousQuantity: c.hazardousQuantity ?? "0",
reeferQuantity: c.reeferQuantity ?? "",
units: Array.from({ length: Math.max(1, c.quantity) }, () =>
emptyUnit(),
),
// The request carries counts; pre-toggle the first N units so GL sees
// the customer's declared hazardous/reefer split and can adjust it.
units: Array.from({ length: Math.max(1, c.quantity) }, (_, i) => ({
...emptyUnit(),
hazardous: i < Number(c.hazardousQuantity ?? 0),
reefer: i < Number(c.reeferQuantity ?? 0),
})),
})),
);
} else if (lines.bulk) {
@@ -229,8 +240,6 @@ export default function GlCreateBookingForm() {
setContainerLines(
containerSizes.map((size) => ({
containerSize: size,
hazardousQuantity: "0",
reeferQuantity: "0",
units: [emptyUnit()],
})),
);
@@ -261,8 +270,8 @@ export default function GlCreateBookingForm() {
containers: containerLines.map((l) => ({
containerSize: l.containerSize,
quantity: l.units.length,
hazardousQuantity: Number(l.hazardousQuantity || 0),
reeferQuantity: Number(l.reeferQuantity || 0),
hazardousQuantity: l.units.filter((u) => u.hazardous).length,
reeferQuantity: l.units.filter((u) => u.reefer).length,
})),
bulkQuantity: bulkLines.reduce(
(s, l) => s + Number(l.cargoWeightTons || l.itemCount || 0),
@@ -384,12 +393,10 @@ export default function GlCreateBookingForm() {
.map((l) => ({
containerSize: l.containerSize,
quantity: l.units.length,
...(l.hazardousQuantity !== ""
? { hazardousQuantity: Number(l.hazardousQuantity) }
: {}),
...(l.reeferQuantity !== ""
? { reeferQuantity: Number(l.reeferQuantity) }
: {}),
// Counts are derived from the per-unit toggles — they can never
// exceed the line quantity.
hazardousQuantity: l.units.filter((u) => u.hazardous).length,
reeferQuantity: l.units.filter((u) => u.reefer).length,
units: l.units.map((u) => ({
containerNumber: u.containerNumber,
...(u.sealNumber ? { sealNumber: u.sealNumber } : {}),
@@ -652,7 +659,7 @@ export default function GlCreateBookingForm() {
<Text fz={14} fw={700} mb={10}>
{line.containerSize} containers
</Text>
<Group gap={12} grow mb={12} align="flex-start">
<Group gap={12} mb={12} align="flex-start">
<NumberInput
label="Quantity *"
min={1}
@@ -660,37 +667,24 @@ export default function GlCreateBookingForm() {
onChange={(v) => syncUnits(lineIdx, Number(v) || 0)}
radius={10}
styles={fieldStyles}
w={160}
/>
{contract.isHazardous ? (
<NumberInput
label="Hazardous qty"
min={0}
value={line.hazardousQuantity}
onChange={(v) =>
patchLine(lineIdx, { hazardousQuantity: v })
}
radius={10}
styles={fieldStyles}
/>
<Badge variant="light" color="red" radius="sm" mt={30}>
{line.units.filter((u) => u.hazardous).length} hazardous
</Badge>
) : null}
{contract.isReefer ? (
<NumberInput
label="Reefer qty"
min={0}
value={line.reeferQuantity}
onChange={(v) =>
patchLine(lineIdx, { reeferQuantity: v })
}
radius={10}
styles={fieldStyles}
/>
<Badge variant="light" color="blue" radius="sm" mt={30}>
{line.units.filter((u) => u.reefer).length} refrigerated
</Badge>
) : null}
</Group>
<StepLabel>Per-container details</StepLabel>
<Stack gap={10} mt={8}>
{line.units.map((unit, unitIdx) => (
<Group key={unitIdx} gap={10} grow align="flex-start">
<Group key={unitIdx} gap={10} align="flex-start" wrap="nowrap">
<TextInput
label={unitIdx === 0 ? "Container number *" : undefined}
placeholder="e.g. MSCU1234567"
@@ -702,6 +696,7 @@ export default function GlCreateBookingForm() {
}
radius={10}
styles={fieldStyles}
style={{ flex: 1 }}
/>
<TextInput
label={unitIdx === 0 ? "Seal number" : undefined}
@@ -714,6 +709,7 @@ export default function GlCreateBookingForm() {
}
radius={10}
styles={fieldStyles}
style={{ flex: 1 }}
/>
<NumberInput
label={unitIdx === 0 ? "VGM (tons) *" : undefined}
@@ -726,7 +722,52 @@ export default function GlCreateBookingForm() {
}
radius={10}
styles={fieldStyles}
style={{ flex: 1 }}
/>
{/* Per-unit flags: toggle exactly the containers that are
hazardous / refrigerated; line counts derive from these. */}
{contract.isHazardous ? (
<Stack gap={6} align="center" style={{ flexShrink: 0 }}>
{unitIdx === 0 ? (
<Text fz={12} fw={600} c="#4A5A68">
Hazardous
</Text>
) : null}
<Switch
color="red"
size="sm"
mt={unitIdx === 0 ? 0 : 8}
aria-label={`Container ${unitIdx + 1} hazardous`}
checked={unit.hazardous}
onChange={(e) =>
patchUnit(lineIdx, unitIdx, {
hazardous: e.currentTarget.checked,
})
}
/>
</Stack>
) : null}
{contract.isReefer ? (
<Stack gap={6} align="center" style={{ flexShrink: 0 }}>
{unitIdx === 0 ? (
<Text fz={12} fw={600} c="#4A5A68">
Reefer
</Text>
) : null}
<Switch
color="blue"
size="sm"
mt={unitIdx === 0 ? 0 : 8}
aria-label={`Container ${unitIdx + 1} refrigerated`}
checked={unit.reefer}
onChange={(e) =>
patchUnit(lineIdx, unitIdx, {
reefer: e.currentTarget.checked,
})
}
/>
</Stack>
) : null}
</Group>
))}
</Stack>

View File

@@ -26,6 +26,70 @@ type BookingForm = UseFormReturn<
BookingFormValues
>;
/**
* 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
* fills from unit 1: tapping unit N selects 1..N, tapping a selected unit N
* keeps 1..N-1 — the count is always derived, never free-typed, so it can't
* exceed the line quantity.
*/
function UnitCountToggles({
total,
value,
onChange,
label,
activeBg,
activeBorder,
activeColor,
}: {
total: number;
value: string;
onChange: (v: string) => void;
label: string;
activeBg: string;
activeBorder: string;
activeColor: string;
}) {
const count = Math.min(total, Math.max(0, Math.floor(Number(value) || 0)));
return (
<div>
<Text fz={12} fw={600} c="#4A5A68" mb={6}>
{label} · {count}/{total} selected
</Text>
<div className="flex flex-wrap gap-2">
{Array.from({ length: total }, (_, i) => {
const selected = i < count;
return (
<button
key={i}
type="button"
aria-pressed={selected}
aria-label={`Container ${i + 1}`}
onClick={() => onChange(String(selected ? i : i + 1))}
className="rounded-lg"
style={{
minWidth: 40,
padding: "6px 10px",
fontSize: 12,
fontWeight: 700,
cursor: "pointer",
border: `1.5px solid ${selected ? activeBorder : "#E6ECF2"}`,
background: selected ? activeBg : "#fff",
color: selected ? activeColor : "#6B7C8E",
transition:
"background 120ms ease, border-color 120ms ease, color 120ms ease",
}}
>
#{i + 1}
</button>
);
})}
</div>
</div>
);
}
export function Step5CargoDetails({
form,
referenceData,
@@ -144,16 +208,6 @@ export function Step5CargoDetails({
const lineQtyOf = (index: number) =>
Math.max(1, Number(form.getValues(`containers.${index}.qty`) ?? 1) || 1);
const lineMax = (index: number) => lineQtyOf(index);
// When a flag is switched on, default its count to the whole line.
const defaultLineQty = (index: number) => lineQtyOf(index).toString();
// Clamp a typed value into 1..lineQty (empty stays empty so the field can be
// cleared; the schema flags an empty value as required while the switch is on).
const clampToLine = (raw: string, index: number) => {
if (raw === "") return "";
const n = Number(raw);
if (Number.isNaN(n)) return raw;
return Math.min(lineQtyOf(index), Math.max(1, Math.floor(n))).toString();
};
// After the line quantity changes, pull any active count back within bounds.
const clampDependentQty = (index: number, newLineQty: number) => {
const max = Math.max(1, newLineQty);
@@ -636,7 +690,7 @@ export function Step5CargoDetails({
hazField.onChange(v);
form.setValue(
`containers.${index}.hazardousQty`,
v ? defaultLineQty(index) : "0",
v ? "1" : "0",
{ shouldDirty: true, shouldValidate: true },
);
}}
@@ -645,22 +699,22 @@ export function Step5CargoDetails({
name={`containers.${index}.hazardousQty`}
control={form.control}
render={({ field: hq, fieldState }) => (
<TextInput
type="number"
size="sm"
label="How many hazardous?"
min={1}
max={lineMax(index)}
value={hq.value ?? ""}
onChange={(e) =>
hq.onChange(
clampToLine(e.currentTarget.value, index),
)
}
onBlur={hq.onBlur}
error={fieldState.error?.message}
radius="md"
/>
<div>
<UnitCountToggles
total={lineMax(index)}
value={hq.value ?? "0"}
onChange={hq.onChange}
label="Tap the hazardous containers"
activeBg="#FBEAE7"
activeBorder="#E4A69B"
activeColor="#C0392B"
/>
{fieldState.error?.message ? (
<Text fz={11} c="red.7" mt={4}>
{fieldState.error.message}
</Text>
) : null}
</div>
)}
/>
</ToggleRow>
@@ -681,7 +735,7 @@ export function Step5CargoDetails({
reeField.onChange(v);
form.setValue(
`containers.${index}.reeferQty`,
v ? defaultLineQty(index) : "0",
v ? "1" : "0",
{ shouldDirty: true, shouldValidate: true },
);
}}
@@ -690,22 +744,22 @@ export function Step5CargoDetails({
name={`containers.${index}.reeferQty`}
control={form.control}
render={({ field: rq, fieldState }) => (
<TextInput
type="number"
size="sm"
label="How many refrigerated?"
min={1}
max={lineMax(index)}
value={rq.value ?? ""}
onChange={(e) =>
rq.onChange(
clampToLine(e.currentTarget.value, index),
)
}
onBlur={rq.onBlur}
error={fieldState.error?.message}
radius="md"
/>
<div>
<UnitCountToggles
total={lineMax(index)}
value={rq.value ?? "0"}
onChange={rq.onChange}
label="Tap the refrigerated containers"
activeBg="#E9F0F8"
activeBorder="#A9C2E0"
activeColor="#2E5B96"
/>
{fieldState.error?.message ? (
<Text fz={11} c="red.7" mt={4}>
{fieldState.error.message}
</Text>
) : null}
</div>
)}
/>
</ToggleRow>