import { FindOptionsWhere, ILike, IsNull } from 'typeorm'; import { AppDataSource } from '../../config/typeorm.js'; import { Product } from './entities/product.entity.js'; import { ProductCategory } from './entities/product-category.entity.js'; export interface ProductSearchParams { tenantId: string; search?: string; categoryId?: string; productType?: 'product' | 'service' | 'consumable' | 'kit'; isActive?: boolean; isSellable?: boolean; isPurchasable?: boolean; limit?: number; offset?: number; } export interface CategorySearchParams { tenantId: string; search?: string; parentId?: string; isActive?: boolean; limit?: number; offset?: number; } export interface CreateProductDto { sku: string; name: string; description?: string; shortName?: string; barcode?: string; categoryId?: string; productType?: 'product' | 'service' | 'consumable' | 'kit'; salePrice?: number; costPrice?: number; currency?: string; taxRate?: number; isActive?: boolean; isSellable?: boolean; isPurchasable?: boolean; } export interface UpdateProductDto { sku?: string; name?: string; description?: string | null; shortName?: string | null; barcode?: string | null; categoryId?: string | null; productType?: 'product' | 'service' | 'consumable' | 'kit'; salePrice?: number; costPrice?: number; currency?: string; taxRate?: number; isActive?: boolean; isSellable?: boolean; isPurchasable?: boolean; } export interface CreateCategoryDto { code: string; name: string; description?: string; parentId?: string; isActive?: boolean; } export interface UpdateCategoryDto { code?: string; name?: string; description?: string | null; parentId?: string | null; isActive?: boolean; } class ProductsServiceClass { private get productRepository() { return AppDataSource.getRepository(Product); } private get categoryRepository() { return AppDataSource.getRepository(ProductCategory); } // ==================== Products ==================== async findAll(params: ProductSearchParams): Promise<{ data: Product[]; total: number }> { const { tenantId, search, categoryId, productType, isActive, isSellable, isPurchasable, limit = 50, offset = 0, } = params; const where: FindOptionsWhere[] = []; const baseWhere: FindOptionsWhere = { tenantId, deletedAt: IsNull() }; if (categoryId) { baseWhere.categoryId = categoryId; } if (productType) { baseWhere.productType = productType; } if (isActive !== undefined) { baseWhere.isActive = isActive; } if (isSellable !== undefined) { baseWhere.isSellable = isSellable; } if (isPurchasable !== undefined) { baseWhere.isPurchasable = isPurchasable; } if (search) { where.push( { ...baseWhere, name: ILike(`%${search}%`) }, { ...baseWhere, sku: ILike(`%${search}%`) }, { ...baseWhere, barcode: ILike(`%${search}%`) } ); } else { where.push(baseWhere); } const [data, total] = await this.productRepository.findAndCount({ where, relations: ['category'], take: limit, skip: offset, order: { name: 'ASC' }, }); return { data, total }; } async findOne(id: string, tenantId: string): Promise { return this.productRepository.findOne({ where: { id, tenantId, deletedAt: IsNull() }, relations: ['category'], }); } async findBySku(sku: string, tenantId: string): Promise { return this.productRepository.findOne({ where: { sku, tenantId, deletedAt: IsNull() }, relations: ['category'], }); } async findByBarcode(barcode: string, tenantId: string): Promise { return this.productRepository.findOne({ where: { barcode, tenantId, deletedAt: IsNull() }, relations: ['category'], }); } async create(tenantId: string, dto: CreateProductDto, createdBy?: string): Promise { // Validate unique SKU within tenant (RLS compliance) const existingSku = await this.productRepository.findOne({ where: { sku: dto.sku, tenantId, deletedAt: IsNull() }, }); if (existingSku) { throw new Error(`Product with SKU '${dto.sku}' already exists`); } // Validate unique barcode within tenant if provided (RLS compliance) if (dto.barcode) { const existingBarcode = await this.productRepository.findOne({ where: { barcode: dto.barcode, tenantId, deletedAt: IsNull() }, }); if (existingBarcode) { throw new Error(`Product with barcode '${dto.barcode}' already exists`); } } const product = this.productRepository.create({ ...dto, tenantId, createdBy, }); return this.productRepository.save(product); } async update(id: string, tenantId: string, dto: UpdateProductDto, updatedBy?: string): Promise { const product = await this.findOne(id, tenantId); if (!product) return null; // Validate unique SKU within tenant if changing (RLS compliance) if (dto.sku && dto.sku !== product.sku) { const existingSku = await this.productRepository.findOne({ where: { sku: dto.sku, tenantId, deletedAt: IsNull() }, }); if (existingSku) { throw new Error(`Product with SKU '${dto.sku}' already exists`); } } // Validate unique barcode within tenant if changing (RLS compliance) if (dto.barcode && dto.barcode !== product.barcode) { const existingBarcode = await this.productRepository.findOne({ where: { barcode: dto.barcode, tenantId, deletedAt: IsNull() }, }); if (existingBarcode) { throw new Error(`Product with barcode '${dto.barcode}' already exists`); } } Object.assign(product, { ...dto, updatedBy }); return this.productRepository.save(product); } async delete(id: string, tenantId: string): Promise { const result = await this.productRepository.softDelete({ id, tenantId }); return (result.affected ?? 0) > 0; } async getSellableProducts(tenantId: string, limit = 50, offset = 0): Promise<{ data: Product[]; total: number }> { return this.findAll({ tenantId, isSellable: true, isActive: true, limit, offset }); } async getPurchasableProducts(tenantId: string, limit = 50, offset = 0): Promise<{ data: Product[]; total: number }> { return this.findAll({ tenantId, isPurchasable: true, isActive: true, limit, offset }); } // ==================== Categories ==================== async findAllCategories(params: CategorySearchParams): Promise<{ data: ProductCategory[]; total: number }> { const { tenantId, search, parentId, isActive, limit = 50, offset = 0 } = params; const where: FindOptionsWhere = { tenantId, deletedAt: IsNull() }; if (parentId) { where.parentId = parentId; } if (isActive !== undefined) { where.isActive = isActive; } if (search) { const [data, total] = await this.categoryRepository.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.categoryRepository.findAndCount({ where, take: limit, skip: offset, order: { sortOrder: 'ASC', name: 'ASC' }, }); return { data, total }; } async findCategory(id: string, tenantId: string): Promise { return this.categoryRepository.findOne({ where: { id, tenantId, deletedAt: IsNull() }, }); } async createCategory(tenantId: string, dto: CreateCategoryDto, _createdBy?: string): Promise { const category = this.categoryRepository.create({ ...dto, tenantId, }); return this.categoryRepository.save(category); } async updateCategory(id: string, tenantId: string, dto: UpdateCategoryDto, _updatedBy?: string): Promise { const category = await this.findCategory(id, tenantId); if (!category) return null; Object.assign(category, dto); return this.categoryRepository.save(category); } async deleteCategory(id: string, tenantId: string): Promise { const result = await this.categoryRepository.softDelete({ id, tenantId }); return (result.affected ?? 0) > 0; } } // Export singleton instance export const productsService = new ProductsServiceClass();