import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { DataSource, FindOptionsWhere, ILike, Not } from 'typeorm'; import { deriveTradeDirection } from '../../common/derive-trade-direction.util'; import { GenerateFromScheduleDto } from './dto/generate-from-schedule.dto'; import { InterchangeDocumentQueryDto } from './dto/interchange-document-query.dto'; import { AcknowledgeInterchangeDocumentDto, DisputeInterchangeDocumentDto, } from './dto/update-interchange-document-status.dto'; import { InterchangeDocumentItem } from './entities/interchange-document-item.entity'; import { InterchangeDirection, InterchangeDocument, InterchangeDocumentStatus, } from './entities/interchange-document.entity'; interface ScheduleSnapshot { id: string; status: string; trainNo: string | null; routeId: string | null; originFacilityId: string | null; destinationFacilityId: string | null; originCountry: string | null; destinationCountry: string | null; } interface InterchangeItemSnapshot { bookingId: string; bookingReference: string | null; itemType: 'CONTAINER' | 'CARGO'; bookingContainerId: string | null; bookingCargoId: string | null; containerNumber: string | null; sealNumber: string | null; cargoId: string | null; cargoType: string | null; cargoDescription: string | null; weight: string | number | null; quantity: string | number | null; packageCount: string | number | null; wagonNumber: string | null; hasDamage: boolean | null; damageDescription: string | null; hasWeightLoss: boolean | null; hasMissingItems: boolean | null; missingItemsDescription: string | null; } @Injectable() export class InterchangeDocumentsService { constructor(private readonly dataSource: DataSource) {} async findAll(query: InterchangeDocumentQueryDto): Promise { const where: FindOptionsWhere[] = []; const base: FindOptionsWhere = { ...(query.direction ? { direction: query.direction } : {}), ...(query.status ? { status: query.status } : {}), ...(query.scheduleId ? { scheduleId: query.scheduleId } : {}), ...(query.documentNo ? { documentNo: ILike(`%${query.documentNo}%`) } : {}), }; const search = query.search?.trim(); if (search) { where.push( { ...base, documentNo: ILike(`%${search}%`) }, { ...base, trainNo: ILike(`%${search}%`) }, { ...base, handoverLocation: ILike(`%${search}%`) }, { ...base, handoverFrom: ILike(`%${search}%`) }, { ...base, handoverTo: ILike(`%${search}%`) }, ); } const qb = this.dataSource .getRepository(InterchangeDocument) .createQueryBuilder('doc') .leftJoinAndSelect('doc.items', 'items') .where(where.length ? where : base) .orderBy('doc.createdAt', 'DESC') .addOrderBy('items.createdAt', 'ASC'); if (query.dateFrom) qb.andWhere('doc.created_at >= :dateFrom', { dateFrom: query.dateFrom }); if (query.dateTo) qb.andWhere('doc.created_at <= :dateTo', { dateTo: query.dateTo }); return qb.getMany(); } async findOne(id: string): Promise { const document = await this.dataSource.getRepository(InterchangeDocument).findOne({ where: { id }, relations: { items: true }, order: { items: { createdAt: 'ASC' } }, }); if (!document) throw new NotFoundException(`Interchange document ${id} not found`); return document; } async generateFromSchedule(dto: GenerateFromScheduleDto): Promise { const existing = await this.dataSource.getRepository(InterchangeDocument).findOne({ where: { scheduleId: dto.scheduleId, direction: dto.direction, status: Not('CANCELLED') as unknown as InterchangeDocumentStatus, }, relations: { items: true }, }); if (existing) return existing; const schedule = await this.getScheduleSnapshot(dto.scheduleId); const routeDirection = deriveTradeDirection( { country: schedule.originCountry }, { country: schedule.destinationCountry }, ); if (routeDirection !== dto.direction) { throw new BadRequestException(`Train schedule route is ${routeDirection}, not ${dto.direction}`); } const itemSnapshots = await this.getScheduleItems(dto.scheduleId); if (itemSnapshots.length === 0) { throw new BadRequestException('No booking/container/cargo items found for this schedule'); } return this.dataSource.transaction(async (manager) => { const now = new Date(); const document = manager.getRepository(InterchangeDocument).create({ documentNo: await this.nextDocumentNo(dto.direction), direction: dto.direction, scheduleId: schedule.id, trainNo: schedule.trainNo, routeId: schedule.routeId, originFacilityId: schedule.originFacilityId, destinationFacilityId: schedule.destinationFacilityId, handoverLocation: dto.handoverLocation.trim(), handoverFrom: dto.handoverFrom.trim(), handoverTo: dto.handoverTo.trim(), operatorName: dto.operatorName?.trim() || null, portOperatorName: dto.portOperatorName?.trim() || null, shippingLineName: dto.shippingLineName?.trim() || null, customsReference: dto.customsReference?.trim() || null, manifestReference: dto.manifestReference?.trim() || null, status: 'GENERATED', generatedAt: now, generatedBy: dto.generatedBy?.trim() || null, remarks: dto.remarks?.trim() || null, }); const saved = await manager.getRepository(InterchangeDocument).save(document); const items = itemSnapshots.map((item) => manager.getRepository(InterchangeDocumentItem).create({ interchangeDocumentId: saved.id, bookingId: item.bookingId, bookingReference: item.bookingReference, itemType: item.itemType, bookingContainerId: item.bookingContainerId, bookingCargoId: item.bookingCargoId, containerNumber: item.containerNumber, sealNumber: item.sealNumber, cargoId: item.cargoId, cargoType: item.cargoType, cargoDescription: item.cargoDescription, weight: item.weight === null ? null : Number(item.weight) || null, quantity: item.quantity === null ? null : Number(item.quantity) || null, packageCount: item.packageCount === null ? null : Number(item.packageCount) || null, wagonNumber: item.wagonNumber, conditionStatus: this.conditionFromInspection(item), damageDescription: item.damageDescription ?? item.missingItemsDescription ?? null, remarks: null, }), ); await manager.getRepository(InterchangeDocumentItem).save(items); return manager.getRepository(InterchangeDocument).findOneOrFail({ where: { id: saved.id }, relations: { items: true }, order: { items: { createdAt: 'ASC' } }, }); }); } async acknowledge( id: string, dto: AcknowledgeInterchangeDocumentDto, ): Promise { const document = await this.findOne(id); if (document.status === 'CANCELLED') { throw new BadRequestException('Cancelled interchange document cannot be acknowledged'); } await this.dataSource.getRepository(InterchangeDocument).update(id, { status: 'ACKNOWLEDGED', acknowledgedAt: new Date(), acknowledgedBy: dto.acknowledgedBy, remarks: dto.remarks ?? document.remarks ?? null, }); return this.findOne(id); } async dispute(id: string, dto: DisputeInterchangeDocumentDto): Promise { await this.findOne(id); await this.dataSource.getRepository(InterchangeDocument).update(id, { status: 'DISPUTED', remarks: dto.remarks, }); return this.findOne(id); } async cancel(id: string): Promise { const document = await this.findOne(id); if (!['DRAFT', 'GENERATED'].includes(document.status)) { throw new BadRequestException(`Interchange document ${document.status} cannot be cancelled`); } await this.dataSource.getRepository(InterchangeDocument).update(id, { status: 'CANCELLED' }); return this.findOne(id); } private async getScheduleSnapshot(scheduleId: string): Promise { const [schedule] = await this.dataSource.query( `SELECT ts.id, ts.status, ts.train_number AS "trainNo", ts.route_id AS "routeId", ts.origin_station_id AS "originFacilityId", ts.destination_station_id AS "destinationFacilityId", oy.country AS "originCountry", dy.country AS "destinationCountry" FROM freight.train_schedules ts LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id WHERE ts.id = $1 AND ts.deleted_at IS NULL LIMIT 1`, [scheduleId], ); if (!schedule) throw new NotFoundException(`Train schedule ${scheduleId} not found`); return schedule; } private async getScheduleItems(scheduleId: string): Promise { return this.dataSource.query( `WITH assigned AS ( SELECT b.id AS booking_id, b.reference, COALESCE(cgt.cargo_type_name, b.cargo_free_text) AS booking_cargo_type, b.cargo_free_text, b.cargo_total_weight_vgm, ( SELECT string_agg(DISTINCT w.wagon_number, ', ' ORDER BY w.wagon_number) FROM freight.wagon_booking_allocations wba JOIN freight.train_set_wagons tsw ON tsw.id = wba.train_set_wagon_id LEFT JOIN freight.wagons w ON w.id = tsw.physical_wagon_id WHERE wba.booking_id = b.id ) AS wagon_number, bool_or(COALESCE(wir.has_damage, false)) AS has_damage, bool_or(COALESCE(wir.has_weight_loss, false)) AS has_weight_loss, bool_or(COALESCE(wir.has_missing_items, false)) AS has_missing_items, string_agg(DISTINCT NULLIF(wir.damage_description, ''), '; ') AS damage_description, string_agg(DISTINCT NULLIF(wir.missing_items_description, ''), '; ') AS missing_items_description FROM freight.train_schedule_bookings tsb JOIN freight.bookings b ON b.id = tsb.booking_id AND b.deleted_at IS NULL LEFT JOIN freight.cargo_types cgt ON cgt.id = b.cargo_type_id LEFT JOIN freight.warehouse_inventory inv ON inv.booking_id = b.id AND inv.deleted_at IS NULL LEFT JOIN freight.warehouse_inspection_reports wir ON wir.inventory_id = inv.id AND wir.deleted_at IS NULL WHERE tsb.train_schedule_id = $1 AND tsb.deleted_at IS NULL GROUP BY b.id, b.reference, cgt.cargo_type_name, b.cargo_free_text, b.cargo_total_weight_vgm ) SELECT a.booking_id AS "bookingId", a.reference AS "bookingReference", 'CONTAINER'::varchar AS "itemType", COALESCE(c.booking_container_id, bc.id) AS "bookingContainerId", NULL::uuid AS "bookingCargoId", COALESCE(c.container_number, bc.container_number) AS "containerNumber", c.seal_number AS "sealNumber", NULL::uuid AS "cargoId", a.booking_cargo_type AS "cargoType", a.cargo_free_text AS "cargoDescription", COALESCE(bc.total_vgm_tons, c.max_gross_weight, a.cargo_total_weight_vgm) AS "weight", COALESCE(bc.quantity, 1) AS "quantity", COALESCE(bc.quantity, 1) AS "packageCount", a.wagon_number AS "wagonNumber", a.has_damage AS "hasDamage", a.damage_description AS "damageDescription", a.has_weight_loss AS "hasWeightLoss", a.has_missing_items AS "hasMissingItems", a.missing_items_description AS "missingItemsDescription" FROM assigned a JOIN freight.containers c ON c.booking_id = a.booking_id AND c.deleted_at IS NULL LEFT JOIN freight.booking_container bc ON bc.id = c.booking_container_id AND bc.deleted_at IS NULL UNION ALL SELECT a.booking_id AS "bookingId", a.reference AS "bookingReference", 'CONTAINER'::varchar AS "itemType", bc.id AS "bookingContainerId", NULL::uuid AS "bookingCargoId", bc.container_number AS "containerNumber", NULL::varchar AS "sealNumber", NULL::uuid AS "cargoId", a.booking_cargo_type AS "cargoType", a.cargo_free_text AS "cargoDescription", COALESCE(bc.total_vgm_tons, a.cargo_total_weight_vgm) AS "weight", bc.quantity AS "quantity", bc.quantity AS "packageCount", a.wagon_number AS "wagonNumber", a.has_damage AS "hasDamage", a.damage_description AS "damageDescription", a.has_weight_loss AS "hasWeightLoss", a.has_missing_items AS "hasMissingItems", a.missing_items_description AS "missingItemsDescription" FROM assigned a JOIN freight.booking_container bc ON bc.booking_id = a.booking_id AND bc.deleted_at IS NULL WHERE NOT EXISTS ( SELECT 1 FROM freight.containers c WHERE c.booking_container_id = bc.id AND c.deleted_at IS NULL ) UNION ALL SELECT a.booking_id AS "bookingId", a.reference AS "bookingReference", 'CARGO'::varchar AS "itemType", NULL::uuid AS "bookingContainerId", cg.id AS "bookingCargoId", NULL::varchar AS "containerNumber", NULL::varchar AS "sealNumber", cg.id AS "cargoId", COALESCE(cgt.cargo_type_name, a.booking_cargo_type) AS "cargoType", COALESCE(cg.description, a.cargo_free_text) AS "cargoDescription", COALESCE(cg.weight, a.cargo_total_weight_vgm) AS "weight", cg.quantity AS "quantity", cg.quantity AS "packageCount", a.wagon_number AS "wagonNumber", a.has_damage AS "hasDamage", a.damage_description AS "damageDescription", a.has_weight_loss AS "hasWeightLoss", a.has_missing_items AS "hasMissingItems", a.missing_items_description AS "missingItemsDescription" FROM assigned a JOIN freight.cargoes cg ON cg.booking_id = a.booking_id AND cg.deleted_at IS NULL LEFT JOIN freight.cargo_types cgt ON cgt.id = cg.cargo_type_id UNION ALL SELECT a.booking_id AS "bookingId", a.reference AS "bookingReference", (CASE WHEN a.booking_cargo_type ILIKE '%container%' THEN 'CONTAINER' ELSE 'CARGO' END)::varchar AS "itemType", NULL::uuid AS "bookingContainerId", NULL::uuid AS "bookingCargoId", NULL::varchar AS "containerNumber", NULL::varchar AS "sealNumber", NULL::uuid AS "cargoId", a.booking_cargo_type AS "cargoType", a.cargo_free_text AS "cargoDescription", a.cargo_total_weight_vgm AS "weight", 1 AS "quantity", 1 AS "packageCount", a.wagon_number AS "wagonNumber", a.has_damage AS "hasDamage", a.damage_description AS "damageDescription", a.has_weight_loss AS "hasWeightLoss", a.has_missing_items AS "hasMissingItems", a.missing_items_description AS "missingItemsDescription" FROM assigned a WHERE NOT EXISTS (SELECT 1 FROM freight.containers c WHERE c.booking_id = a.booking_id AND c.deleted_at IS NULL) AND NOT EXISTS (SELECT 1 FROM freight.booking_container bc WHERE bc.booking_id = a.booking_id AND bc.deleted_at IS NULL) AND NOT EXISTS (SELECT 1 FROM freight.cargoes cg WHERE cg.booking_id = a.booking_id AND cg.deleted_at IS NULL) ORDER BY "bookingReference" ASC NULLS LAST, "itemType" ASC, "containerNumber" ASC NULLS LAST`, [scheduleId], ); } private conditionFromInspection(item: InterchangeItemSnapshot) { if (item.hasDamage) return 'DAMAGED'; if (item.hasWeightLoss || item.hasMissingItems) return 'SHORTAGE'; return 'GOOD'; } private async nextDocumentNo(direction: InterchangeDirection): Promise { const prefix = `ICD-${direction === 'EXPORT' ? 'EXP' : 'IMP'}-${this.yyyymmdd(new Date())}`; const [row] = await this.dataSource.query( `SELECT document_no AS "documentNo" FROM freight.interchange_documents WHERE document_no LIKE $1 ORDER BY document_no DESC LIMIT 1`, [`${prefix}-%`], ); const last = row?.documentNo ? Number(String(row.documentNo).split('-').pop()) || 0 : 0; return `${prefix}-${String(last + 1).padStart(4, '0')}`; } private yyyymmdd(date: Date): string { const yyyy = date.getUTCFullYear(); const mm = String(date.getUTCMonth() + 1).padStart(2, '0'); const dd = String(date.getUTCDate()).padStart(2, '0'); return `${yyyy}${mm}${dd}`; } }