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(); 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, ): 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())); 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(); 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 { 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), }; } }