Files
edr-platform/apps/edr-freight-api/src/modules/bookings/booking-reference-data.service.ts
2026-08-02 22:29:58 +00:00

248 lines
8.1 KiB
TypeScript

import { Inject, Injectable } from "@nestjs/common";
import { CargoType } from "../rule-engine/entities/cargo-type.entity";
import { ContainerType } from "../rule-engine/entities/container-type.entity";
import {
CARGO_TYPES_REPOSITORY,
ICargoTypesRepository,
} from "../rule-engine/interfaces/cargo-types.repository.interface";
import {
CONTAINER_TYPES_REPOSITORY,
IContainerTypesRepository,
} from "../rule-engine/interfaces/container-types.repository.interface";
import {
IServiceTypesRepository,
SERVICE_TYPES_REPOSITORY,
} from "../rule-engine/interfaces/service-types.repository.interface";
import {
IShippingLinesRepository,
SHIPPING_LINES_REPOSITORY,
} from "../rule-engine/interfaces/shipping-lines.repository.interface";
import {
IYardsRepository,
YARDS_REPOSITORY,
} from "../rule-engine/interfaces/yards.repository.interface";
import { YardFacilitiesService } from "../rule-engine/services/yard-facilities.service";
import {
BookingReferenceCargoTypeChildDto,
BookingReferenceCargoTypeGroupDto,
BookingReferenceContainerSizeGroupDto,
BookingReferenceContainerTypeDto,
BookingReferenceDataDto,
BookingReferenceServiceDto,
BookingReferenceShippingLineDto,
BookingReferenceYardDto,
} from "./dto/booking-reference-data.dto";
/**
* Reference cargo tree: top-level groups, each carrying its selectable
* commodities.
*
* `cargo_types` is an arbitrary-depth tree (Bulk → Steel Billet → S1 → …), but
* only a LEAF is a real commodity — an intermediate node is a container for
* finer types, and booking against it would be ambiguous. So each group's
* `children` are all of its leaf descendants, flattened, whatever the depth.
* Deep leaves carry their path below the group ("Steel Billet → S1") so a
* generically-named leaf still reads unambiguously in a dropdown.
*
* A group with no active descendants is its own leaf and is emitted as its
* single child — otherwise it is selectable as a group but offers no commodity,
* which dead-ends every form that requires one.
*/
export function buildCargoTypeTree(
rows: CargoType[],
): BookingReferenceCargoTypeGroupDto[] {
const active = rows.filter((r) => r.isActive);
const byOrder = (a: CargoType, b: CargoType) =>
a.displayOrder - b.displayOrder || a.code.localeCompare(b.code);
const childrenOf = new Map<string, CargoType[]>();
for (const row of active) {
if (!row.parentGroupId) continue;
const siblings = childrenOf.get(row.parentGroupId) ?? [];
siblings.push(row);
childrenOf.set(row.parentGroupId, siblings);
}
for (const siblings of childrenOf.values()) siblings.sort(byOrder);
const parents = active.filter((r) => !r.parentGroupId).sort(byOrder);
/** Depth-first leaf walk; `trail` is the path below the group. */
const collectLeaves = (
node: CargoType,
trail: string[],
seen: Set<string>,
): BookingReferenceCargoTypeChildDto[] => {
// Admin-entered parent pointers could in principle cycle — never loop.
if (seen.has(node.id)) return [];
seen.add(node.id);
const kids = childrenOf.get(node.id) ?? [];
if (kids.length === 0) {
return [
{
id: node.id,
name: [...trail, node.cargoTypeName].join(" → "),
code: node.code,
unit_of_measure: node.unitOfMeasure ?? null,
},
];
}
const nextTrail = [...trail, node.cargoTypeName];
return kids.flatMap((kid) => collectLeaves(kid, nextTrail, seen));
};
return parents.map((parent) => {
const kids = childrenOf.get(parent.id) ?? [];
const children =
kids.length === 0
? // The group itself is the commodity.
[
{
id: parent.id,
name: parent.cargoTypeName,
code: parent.code,
unit_of_measure: parent.unitOfMeasure ?? null,
},
]
: kids.flatMap((kid) => collectLeaves(kid, [], new Set<string>()));
return {
id: parent.id,
name: parent.cargoTypeName,
code: parent.code,
children,
};
});
}
export function groupContainersBySize(
rows: ContainerType[],
): BookingReferenceContainerSizeGroupDto[] {
const active = rows.filter((r) => r.isActive);
const bySize = new Map<string, ContainerType[]>();
for (const ct of active) {
const sizeKey =
ct.sizeFt != null && ct.sizeFt > 0 ? `${ct.sizeFt}ft` : "other";
const list = bySize.get(sizeKey) ?? [];
list.push(ct);
bySize.set(sizeKey, list);
}
const sortSizeKey = (key: string): number => {
if (key === "other") return Number.MAX_SAFE_INTEGER;
const n = parseInt(key, 10);
return Number.isNaN(n) ? Number.MAX_SAFE_INTEGER - 1 : n;
};
return [...bySize.entries()]
.sort(([a], [b]) => sortSizeKey(a) - sortSizeKey(b))
.map(([size, types]) => ({
size,
types: types
.sort(
(a, b) =>
(a.displayOrder ?? 0) - (b.displayOrder ?? 0) ||
a.code.localeCompare(b.code),
)
.map(
(ct): BookingReferenceContainerTypeDto => ({
id: ct.id,
name: ct.label?.trim() ? ct.label : ct.code,
code: ct.code,
is_reefer: ct.isReefer ?? false,
}),
),
}));
}
@Injectable()
export class BookingReferenceDataService {
constructor(
@Inject(YARDS_REPOSITORY)
private readonly yardsRepository: IYardsRepository,
@Inject(CONTAINER_TYPES_REPOSITORY)
private readonly containerTypesRepository: IContainerTypesRepository,
@Inject(SERVICE_TYPES_REPOSITORY)
private readonly serviceTypesRepository: IServiceTypesRepository,
@Inject(SHIPPING_LINES_REPOSITORY)
private readonly shippingLinesRepository: IShippingLinesRepository,
@Inject(CARGO_TYPES_REPOSITORY)
private readonly cargoTypesRepository: ICargoTypesRepository,
private readonly yardFacilitiesService: YardFacilitiesService,
) { }
async getReferenceData(): Promise<BookingReferenceDataDto> {
const [
yards,
containerTypes,
serviceTypes,
shippingLines,
cargoTypes,
facilityYards,
] = await Promise.all([
this.yardsRepository.findAll({
where: { isActive: true },
order: { displayOrder: "ASC", code: "ASC" },
}),
this.containerTypesRepository.findAll({
where: { isActive: true },
order: { displayOrder: "ASC", code: "ASC" },
}),
this.serviceTypesRepository.findAll({
where: { isActive: true },
order: { displayOrder: "ASC", code: "ASC" },
}),
this.shippingLinesRepository.findAll({
where: { isActive: true },
order: { label: "ASC", code: "ASC" },
}),
this.cargoTypesRepository.findAll({
where: { isActive: true },
order: { displayOrder: "ASC", code: "ASC" },
}),
this.yardFacilitiesService.listFacilityYards(),
]);
// Every active yard is still listed; a yard with no facility record simply
// reports no capability, so the forms drop it from the pickers themselves.
const facilityByYardId = new Map(facilityYards.map((f) => [f.yardId, f]));
return {
yard: yards.map((y): BookingReferenceYardDto => {
const facility = facilityByYardId.get(y.id);
return {
id: y.id,
name: y.label,
code: y.code,
country: y.country,
hasContainerFacilityOrigin:
facility?.hasContainerFacilityOrigin ?? false,
hasBulkFacilityOrigin: facility?.hasBulkFacilityOrigin ?? false,
hasContainerFacilityDestination:
facility?.hasContainerFacilityDestination ?? false,
hasBulkFacilityDestination:
facility?.hasBulkFacilityDestination ?? false,
};
}),
containers: groupContainersBySize(containerTypes),
service: serviceTypes.map(
(s): BookingReferenceServiceDto => ({
name: s.serviceName,
...s,
}),
),
shipping_line: shippingLines.map(
(sl): BookingReferenceShippingLineDto => ({
id: sl.id,
name: sl.label,
code: sl.code,
}),
),
cargo_type: buildCargoTypeTree(cargoTypes),
};
}
}