mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 05:30:55 +00:00
update joins in repository services to use entity classes and enhance booking form with hazardous/reefer toggles
This commit is contained in:
@@ -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');
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user