- Create invoices.controller.ts and invoices.routes.ts with singleton service - Create products.service.ts, products.controller.ts, products.routes.ts - Create warehouses.service.ts, warehouses.controller.ts, warehouses.routes.ts - Register all routes in app.ts - Use Zod validation schemas in all controllers - Apply multi-tenant isolation via tenantId - Update invoices.module.ts to use singleton pattern All business modules now have API routes registered and build passes. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
263 lines
8.0 KiB
TypeScript
263 lines
8.0 KiB
TypeScript
import { FindOptionsWhere, ILike, IsNull } from 'typeorm';
|
|
import { AppDataSource } from '../../config/typeorm.js';
|
|
import { Warehouse } from './entities/warehouse.entity.js';
|
|
import { WarehouseLocation } from './entities/warehouse-location.entity.js';
|
|
|
|
export interface WarehouseSearchParams {
|
|
tenantId: string;
|
|
search?: string;
|
|
isActive?: boolean;
|
|
limit?: number;
|
|
offset?: number;
|
|
}
|
|
|
|
export interface LocationSearchParams {
|
|
warehouseId?: string;
|
|
parentId?: string;
|
|
locationType?: 'zone' | 'aisle' | 'rack' | 'shelf' | 'bin';
|
|
isActive?: boolean;
|
|
limit?: number;
|
|
offset?: number;
|
|
}
|
|
|
|
export interface CreateWarehouseDto {
|
|
code: string;
|
|
name: string;
|
|
address?: string;
|
|
city?: string;
|
|
state?: string;
|
|
country?: string;
|
|
postalCode?: string;
|
|
phone?: string;
|
|
email?: string;
|
|
isActive?: boolean;
|
|
isDefault?: boolean;
|
|
}
|
|
|
|
export interface UpdateWarehouseDto {
|
|
code?: string;
|
|
name?: string;
|
|
address?: string | null;
|
|
city?: string | null;
|
|
state?: string | null;
|
|
country?: string | null;
|
|
postalCode?: string | null;
|
|
phone?: string | null;
|
|
email?: string | null;
|
|
isActive?: boolean;
|
|
isDefault?: boolean;
|
|
}
|
|
|
|
export interface CreateLocationDto {
|
|
warehouseId: string;
|
|
code: string;
|
|
name: string;
|
|
parentId?: string;
|
|
locationType?: 'zone' | 'aisle' | 'rack' | 'shelf' | 'bin';
|
|
barcode?: string;
|
|
isActive?: boolean;
|
|
}
|
|
|
|
export interface UpdateLocationDto {
|
|
code?: string;
|
|
name?: string;
|
|
parentId?: string | null;
|
|
locationType?: 'zone' | 'aisle' | 'rack' | 'shelf' | 'bin';
|
|
barcode?: string | null;
|
|
isActive?: boolean;
|
|
}
|
|
|
|
class WarehousesServiceClass {
|
|
private get warehouseRepository() {
|
|
return AppDataSource.getRepository(Warehouse);
|
|
}
|
|
|
|
private get locationRepository() {
|
|
return AppDataSource.getRepository(WarehouseLocation);
|
|
}
|
|
|
|
// ==================== Warehouses ====================
|
|
|
|
async findAll(params: WarehouseSearchParams): Promise<{ data: Warehouse[]; total: number }> {
|
|
const { tenantId, search, isActive, limit = 50, offset = 0 } = params;
|
|
|
|
const where: FindOptionsWhere<Warehouse> = { tenantId, deletedAt: IsNull() };
|
|
|
|
if (isActive !== undefined) {
|
|
where.isActive = isActive;
|
|
}
|
|
|
|
if (search) {
|
|
const [data, total] = await this.warehouseRepository.findAndCount({
|
|
where: [
|
|
{ ...where, name: ILike(`%${search}%`) },
|
|
{ ...where, code: ILike(`%${search}%`) },
|
|
],
|
|
take: limit,
|
|
skip: offset,
|
|
order: { name: 'ASC' },
|
|
});
|
|
return { data, total };
|
|
}
|
|
|
|
const [data, total] = await this.warehouseRepository.findAndCount({
|
|
where,
|
|
take: limit,
|
|
skip: offset,
|
|
order: { isDefault: 'DESC', name: 'ASC' },
|
|
});
|
|
|
|
return { data, total };
|
|
}
|
|
|
|
async findOne(id: string, tenantId: string): Promise<Warehouse | null> {
|
|
return this.warehouseRepository.findOne({
|
|
where: { id, tenantId, deletedAt: IsNull() },
|
|
});
|
|
}
|
|
|
|
async findByCode(code: string, tenantId: string): Promise<Warehouse | null> {
|
|
return this.warehouseRepository.findOne({
|
|
where: { code, tenantId, deletedAt: IsNull() },
|
|
});
|
|
}
|
|
|
|
async getDefault(tenantId: string): Promise<Warehouse | null> {
|
|
return this.warehouseRepository.findOne({
|
|
where: { tenantId, isDefault: true, deletedAt: IsNull() },
|
|
});
|
|
}
|
|
|
|
async getActive(tenantId: string): Promise<Warehouse[]> {
|
|
return this.warehouseRepository.find({
|
|
where: { tenantId, isActive: true, deletedAt: IsNull() },
|
|
order: { isDefault: 'DESC', name: 'ASC' },
|
|
});
|
|
}
|
|
|
|
async create(tenantId: string, dto: CreateWarehouseDto, _createdBy?: string): Promise<Warehouse> {
|
|
// If this is set as default, unset other defaults
|
|
if (dto.isDefault) {
|
|
await this.warehouseRepository.update(
|
|
{ tenantId, isDefault: true },
|
|
{ isDefault: false }
|
|
);
|
|
}
|
|
|
|
const warehouse = this.warehouseRepository.create({
|
|
...dto,
|
|
tenantId,
|
|
});
|
|
return this.warehouseRepository.save(warehouse);
|
|
}
|
|
|
|
async update(id: string, tenantId: string, dto: UpdateWarehouseDto, _updatedBy?: string): Promise<Warehouse | null> {
|
|
const warehouse = await this.findOne(id, tenantId);
|
|
if (!warehouse) return null;
|
|
|
|
// If setting as default, unset other defaults
|
|
if (dto.isDefault && !warehouse.isDefault) {
|
|
await this.warehouseRepository.update(
|
|
{ tenantId, isDefault: true },
|
|
{ isDefault: false }
|
|
);
|
|
}
|
|
|
|
Object.assign(warehouse, dto);
|
|
return this.warehouseRepository.save(warehouse);
|
|
}
|
|
|
|
async delete(id: string, tenantId: string): Promise<boolean> {
|
|
const result = await this.warehouseRepository.softDelete({ id, tenantId });
|
|
return (result.affected ?? 0) > 0;
|
|
}
|
|
|
|
// ==================== Locations ====================
|
|
// Note: Locations don't have tenantId directly, they belong to a Warehouse which has tenantId
|
|
|
|
async findAllLocations(params: LocationSearchParams & { tenantId: string }): Promise<{ data: WarehouseLocation[]; total: number }> {
|
|
const { tenantId, warehouseId, parentId, locationType, isActive, limit = 50, offset = 0 } = params;
|
|
|
|
// Build query to join with warehouse for tenant filtering
|
|
const queryBuilder = this.locationRepository
|
|
.createQueryBuilder('location')
|
|
.leftJoinAndSelect('location.warehouse', 'warehouse')
|
|
.where('warehouse.tenantId = :tenantId', { tenantId })
|
|
.andWhere('location.deletedAt IS NULL');
|
|
|
|
if (warehouseId) {
|
|
queryBuilder.andWhere('location.warehouseId = :warehouseId', { warehouseId });
|
|
}
|
|
|
|
if (parentId) {
|
|
queryBuilder.andWhere('location.parentId = :parentId', { parentId });
|
|
}
|
|
|
|
if (locationType) {
|
|
queryBuilder.andWhere('location.locationType = :locationType', { locationType });
|
|
}
|
|
|
|
if (isActive !== undefined) {
|
|
queryBuilder.andWhere('location.isActive = :isActive', { isActive });
|
|
}
|
|
|
|
queryBuilder.orderBy('location.code', 'ASC');
|
|
queryBuilder.skip(offset).take(limit);
|
|
|
|
const [data, total] = await queryBuilder.getManyAndCount();
|
|
return { data, total };
|
|
}
|
|
|
|
async findLocation(id: string, tenantId: string): Promise<WarehouseLocation | null> {
|
|
return this.locationRepository
|
|
.createQueryBuilder('location')
|
|
.leftJoinAndSelect('location.warehouse', 'warehouse')
|
|
.where('location.id = :id', { id })
|
|
.andWhere('warehouse.tenantId = :tenantId', { tenantId })
|
|
.andWhere('location.deletedAt IS NULL')
|
|
.getOne();
|
|
}
|
|
|
|
async createLocation(_tenantId: string, dto: CreateLocationDto, _createdBy?: string): Promise<WarehouseLocation> {
|
|
const location = this.locationRepository.create({
|
|
warehouseId: dto.warehouseId,
|
|
code: dto.code,
|
|
name: dto.name,
|
|
parentId: dto.parentId,
|
|
locationType: dto.locationType || 'shelf',
|
|
barcode: dto.barcode,
|
|
isActive: dto.isActive ?? true,
|
|
});
|
|
return this.locationRepository.save(location);
|
|
}
|
|
|
|
async updateLocation(id: string, tenantId: string, dto: UpdateLocationDto, _updatedBy?: string): Promise<WarehouseLocation | null> {
|
|
const location = await this.findLocation(id, tenantId);
|
|
if (!location) return null;
|
|
Object.assign(location, dto);
|
|
return this.locationRepository.save(location);
|
|
}
|
|
|
|
async deleteLocation(id: string, tenantId: string): Promise<boolean> {
|
|
const location = await this.findLocation(id, tenantId);
|
|
if (!location) return false;
|
|
const result = await this.locationRepository.softDelete({ id });
|
|
return (result.affected ?? 0) > 0;
|
|
}
|
|
|
|
async getLocationsByWarehouse(warehouseId: string, tenantId: string): Promise<WarehouseLocation[]> {
|
|
return this.locationRepository
|
|
.createQueryBuilder('location')
|
|
.leftJoin('location.warehouse', 'warehouse')
|
|
.where('location.warehouseId = :warehouseId', { warehouseId })
|
|
.andWhere('warehouse.tenantId = :tenantId', { tenantId })
|
|
.andWhere('location.isActive = :isActive', { isActive: true })
|
|
.andWhere('location.deletedAt IS NULL')
|
|
.orderBy('location.code', 'ASC')
|
|
.getMany();
|
|
}
|
|
}
|
|
|
|
// Export singleton instance
|
|
export const warehousesService = new WarehousesServiceClass();
|