Files
edr-platform/apps/edr-freight-api/src/modules/container-management/containers.service.ts
2026-07-16 00:33:31 +00:00

168 lines
6.9 KiB
TypeScript

// 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<Container>,
@InjectRepository(Wagon)
private readonly wagonRepo: Repository<Wagon>, // ✅ use raw repository
@InjectRepository(ContainerType)
private readonly containerTypeRepo: Repository<ContainerType>,
private readonly dataSource: DataSource,
) {}
async create(dto: CreateContainerDto): Promise<Container> {
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<string, string | undefined> = {}): Promise<Container[]> {
const where: FindOptionsWhere<Container>[] | FindOptionsWhere<Container> = [];
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<Container>,
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<Container> {
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<Container> {
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<void> {
const container = await this.findById(id);
await this.containerRepo.remove(container);
}
async assignToWagon(containerId: string, dto: AssignContainerToWagonDto): Promise<Container> {
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<Container> {
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);
}
}