// apps/edr-freight-api/src/modules/container-management/containers.service.ts import { Injectable, NotFoundException, ConflictException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { DataSource, FindOptionsOrder, FindOptionsWhere, ILike, Repository } from 'typeorm'; import { CreateContainerDto } from './dto/create-container.dto'; import { UpdateContainerDto } from './dto/update-container.dto'; import { AssignContainerToWagonDto } from './dto/assign-container-to-wagon.dto'; import { Container } from './entities/container.entity'; import { Wagon } from '../wagons/entities/wagon.entity'; import { ContainerType } from '../rule-engine/entities/container-type.entity'; @Injectable() export class ContainersService { constructor( @InjectRepository(Container) private readonly containerRepo: Repository, @InjectRepository(Wagon) private readonly wagonRepo: Repository, // ✅ use raw repository @InjectRepository(ContainerType) private readonly containerTypeRepo: Repository, private readonly dataSource: DataSource, ) {} async create(dto: CreateContainerDto): Promise { const existing = await this.containerRepo.findOne({ where: { containerNumber: dto.containerNumber }, }); if (existing) { throw new ConflictException(`Container number "${dto.containerNumber}" already exists`); } const containerType = await this.containerTypeRepo.findOne({ where: { id: dto.containerTypeId, isActive: true }, }); if (!containerType) { throw new NotFoundException(`Container type ${dto.containerTypeId} not found`); } if (dto.wagonId) { const wagon = await this.wagonRepo.findOne({ where: { id: dto.wagonId } }); if (!wagon) throw new NotFoundException(`Wagon ${dto.wagonId} not found`); } const container = this.containerRepo.create(dto); if (dto.wagonId === undefined) container.wagonId = null; if (dto.position === undefined) container.position = null; return this.containerRepo.save(container); } async findAll(query: Record = {}): Promise { const where: FindOptionsWhere[] | FindOptionsWhere = []; const search = query.search?.trim(); const status = query.status?.trim(); const wagonId = query.wagonId?.trim(); if (search) { where.push({ containerNumber: ILike(`%${search}%`), ...(status ? { status } : {}), ...(wagonId ? { wagonId } : {}), }); } const sortBy = ['containerNumber', 'tareWeight', 'maxGrossWeight', 'status', 'position'].includes(query.sortBy ?? '') ? (query.sortBy as keyof Container) : 'containerNumber'; const sortOrder = query.sortOrder?.toUpperCase() === 'DESC' ? 'DESC' : 'ASC'; return this.containerRepo.find({ where: search ? where : { ...(status ? { status } : {}), ...(wagonId ? { wagonId } : {}) }, order: { [sortBy]: sortOrder } as FindOptionsOrder, skip: query.page && query.limit ? (Number(query.page) - 1) * Number(query.limit) : undefined, take: query.limit ? Number(query.limit) : undefined, }); } async findById(id: string): Promise { const container = await this.containerRepo.findOne({ where: { id } }); if (!container) throw new NotFoundException(`Container ${id} not found`); return container; } async update(id: string, dto: UpdateContainerDto): Promise { const container = await this.findById(id); if (dto.containerNumber && dto.containerNumber !== container.containerNumber) { const existing = await this.containerRepo.findOne({ where: { containerNumber: dto.containerNumber }, }); if (existing) { throw new ConflictException(`Container number "${dto.containerNumber}" already exists`); } } if (dto.containerTypeId) { const containerType = await this.containerTypeRepo.findOne({ where: { id: dto.containerTypeId, isActive: true }, }); if (!containerType) { throw new NotFoundException(`Container type ${dto.containerTypeId} not found`); } } if (dto.wagonId) { const wagon = await this.wagonRepo.findOne({ where: { id: dto.wagonId } }); if (!wagon) throw new NotFoundException(`Wagon ${dto.wagonId} not found`); } Object.assign(container, dto); return this.containerRepo.save(container); } async remove(id: string): Promise { const container = await this.findById(id); await this.containerRepo.remove(container); } async assignToWagon(containerId: string, dto: AssignContainerToWagonDto): Promise { const container = await this.findById(containerId); if (container.status === 'LOADED') { throw new ConflictException('Cannot reassign a loaded container'); } // Reject a container that is already placed on a wagon — it must be // unassigned first, otherwise it would silently jump to another wagon. if (container.wagonId) { throw new ConflictException( `Container ${containerId} is already assigned to wagon ${container.wagonId}`, ); } const wagon = await this.wagonRepo.findOne({ where: { id: dto.wagonId } }); if (!wagon) throw new NotFoundException(`Wagon ${dto.wagonId} not found`); // The MAX(position)+1 allocation is check-then-act: two concurrent assigns can // read the same MAX and collide on the same position. Do the read + save inside // one transaction to narrow the race window. // TODO: add a unique (wagon_id, position) DB index so the database itself // rejects a colliding position even under concurrency. return this.dataSource.transaction(async (manager) => { const containerRepo = manager.getRepository(Container); let position: number | null = dto.position ?? null; if (position === null) { const maxPos = await containerRepo .createQueryBuilder('c') .select('MAX(c.position)', 'max') .where('c.wagonId = :wagonId', { wagonId: wagon.id }) .getRawOne<{ max: number | null }>(); position = (maxPos?.max ?? 0) + 1; } container.wagonId = wagon.id; container.position = position; // Placing a container on a wagon does not make it AVAILABLE. The status enum // (AVAILABLE, LOADED, IN_TRANSIT, MAINTENANCE, DAMAGED) has no ASSIGNED/ON_WAGON // state, so leave the existing status unchanged rather than forcing AVAILABLE. return containerRepo.save(container); }); } async unassignFromWagon(containerId: string): Promise { const container = await this.findById(containerId); if (container.status === 'LOADED') { throw new ConflictException('Cannot unassign a loaded container'); } container.wagonId = null; container.position = null; container.status = 'AVAILABLE'; return this.containerRepo.save(container); } }