/** * UomService - Unit of Measure Management * * Provides centralized UoM operations: * - UoM category management * - Unit of measure CRUD * - Unit conversion calculations * * @module Core */ import { Repository } from 'typeorm'; import { Uom, UomType } from '../entities/uom.entity'; import { UomCategory } from '../entities/uom-category.entity'; /** * Service context for multi-tenant operations */ export interface ServiceContext { tenantId: string; userId?: string; } /** * Paginated result interface */ export interface PaginatedResult { data: T[]; total: number; page: number; limit: number; totalPages: number; } export interface CreateUomCategoryDto { name: string; description?: string; } export interface UpdateUomCategoryDto { name?: string; description?: string; } export interface CreateUomDto { categoryId: string; name: string; code?: string; uomType?: UomType; factor?: number; rounding?: number; active?: boolean; } export interface UpdateUomDto { name?: string; code?: string; uomType?: UomType; factor?: number; rounding?: number; active?: boolean; } export interface UomFilters { categoryId?: string; active?: boolean; search?: string; } export interface ConversionResult { fromUom: string; toUom: string; originalQuantity: number; convertedQuantity: number; factor: number; } export class UomService { private categoryRepository: Repository; private uomRepository: Repository; constructor( categoryRepository: Repository, uomRepository: Repository ) { this.categoryRepository = categoryRepository; this.uomRepository = uomRepository; } // ==================== CATEGORY OPERATIONS ==================== /** * Create a new UoM category */ async createCategory( ctx: ServiceContext, data: CreateUomCategoryDto ): Promise { const existing = await this.categoryRepository.findOne({ where: { tenantId: ctx.tenantId, name: data.name }, }); if (existing) { throw new Error(`UoM category '${data.name}' already exists`); } const entity = this.categoryRepository.create({ tenantId: ctx.tenantId, name: data.name, description: data.description ?? null, }); return this.categoryRepository.save(entity); } /** * Find category by ID */ async findCategoryById( ctx: ServiceContext, id: string ): Promise { return this.categoryRepository.findOne({ where: { id, tenantId: ctx.tenantId }, relations: ['uoms'], }); } /** * Get all categories for tenant */ async findAllCategories(ctx: ServiceContext): Promise { return this.categoryRepository.find({ where: { tenantId: ctx.tenantId }, relations: ['uoms'], order: { name: 'ASC' }, }); } /** * Update category */ async updateCategory( ctx: ServiceContext, id: string, data: UpdateUomCategoryDto ): Promise { const entity = await this.findCategoryById(ctx, id); if (!entity) { return null; } if (data.name && data.name !== entity.name) { const existing = await this.categoryRepository.findOne({ where: { tenantId: ctx.tenantId, name: data.name }, }); if (existing) { throw new Error(`UoM category '${data.name}' already exists`); } } Object.assign(entity, data); return this.categoryRepository.save(entity); } /** * Delete category (only if no UoMs assigned) */ async deleteCategory(ctx: ServiceContext, id: string): Promise { const category = await this.findCategoryById(ctx, id); if (!category) { return false; } // Check for associated UoMs const uomCount = await this.uomRepository.count({ where: { tenantId: ctx.tenantId, categoryId: id }, }); if (uomCount > 0) { throw new Error('Cannot delete category with associated units of measure'); } const result = await this.categoryRepository.delete({ id, tenantId: ctx.tenantId, }); return (result.affected || 0) > 0; } // ==================== UOM OPERATIONS ==================== /** * Create a new unit of measure */ async create(ctx: ServiceContext, data: CreateUomDto): Promise { // Validate category exists const category = await this.findCategoryById(ctx, data.categoryId); if (!category) { throw new Error('UoM category not found'); } // Check for duplicate name in category const existing = await this.uomRepository.findOne({ where: { tenantId: ctx.tenantId, categoryId: data.categoryId, name: data.name, }, }); if (existing) { throw new Error(`UoM '${data.name}' already exists in this category`); } // If this is the first UoM in category, it must be reference type const categoryUoms = await this.findByCategory(ctx, data.categoryId); if (categoryUoms.length === 0 && data.uomType !== UomType.REFERENCE) { throw new Error('First unit in category must be reference type'); } // Reference type must have factor of 1 if (data.uomType === UomType.REFERENCE && data.factor !== 1) { throw new Error('Reference unit must have factor of 1'); } const entity = this.uomRepository.create({ tenantId: ctx.tenantId, categoryId: data.categoryId, name: data.name, code: data.code ?? null, uomType: data.uomType ?? UomType.REFERENCE, factor: data.factor ?? 1, rounding: data.rounding ?? 0.01, active: data.active ?? true, }); return this.uomRepository.save(entity); } /** * Find UoM by ID */ async findById(ctx: ServiceContext, id: string): Promise { return this.uomRepository.findOne({ where: { id, tenantId: ctx.tenantId }, relations: ['category'], }); } /** * Find UoM by code */ async findByCode(ctx: ServiceContext, code: string): Promise { return this.uomRepository.findOne({ where: { tenantId: ctx.tenantId, code }, relations: ['category'], }); } /** * Get all UoMs by category */ async findByCategory(ctx: ServiceContext, categoryId: string): Promise { return this.uomRepository.find({ where: { tenantId: ctx.tenantId, categoryId }, order: { name: 'ASC' }, }); } /** * Get all active UoMs for tenant */ async findAll(ctx: ServiceContext): Promise { return this.uomRepository.find({ where: { tenantId: ctx.tenantId, active: true }, relations: ['category'], order: { name: 'ASC' }, }); } /** * Find UoMs with filters and pagination */ async findWithFilters( ctx: ServiceContext, filters: UomFilters, page = 1, limit = 50 ): Promise> { const qb = this.uomRepository .createQueryBuilder('uom') .leftJoinAndSelect('uom.category', 'category') .where('uom.tenant_id = :tenantId', { tenantId: ctx.tenantId }); if (filters.categoryId) { qb.andWhere('uom.category_id = :categoryId', { categoryId: filters.categoryId, }); } if (filters.active !== undefined) { qb.andWhere('uom.active = :active', { active: filters.active }); } if (filters.search) { qb.andWhere('(uom.name ILIKE :search OR uom.code ILIKE :search)', { search: `%${filters.search}%`, }); } const skip = (page - 1) * limit; qb.orderBy('uom.name', 'ASC').skip(skip).take(limit); const [data, total] = await qb.getManyAndCount(); return { data, total, page, limit, totalPages: Math.ceil(total / limit), }; } /** * Update UoM */ async update( ctx: ServiceContext, id: string, data: UpdateUomDto ): Promise { const entity = await this.findById(ctx, id); if (!entity) { return null; } // Cannot change reference type factor if (entity.uomType === UomType.REFERENCE && data.factor && data.factor !== 1) { throw new Error('Cannot change factor for reference unit'); } Object.assign(entity, data); return this.uomRepository.save(entity); } /** * Delete UoM (soft delete by setting inactive) */ async delete(ctx: ServiceContext, id: string): Promise { const entity = await this.findById(ctx, id); if (!entity) { return false; } // Cannot delete reference unit if others exist if (entity.uomType === UomType.REFERENCE) { const otherUoms = await this.uomRepository.count({ where: { tenantId: ctx.tenantId, categoryId: entity.categoryId, active: true, }, }); if (otherUoms > 1) { throw new Error('Cannot delete reference unit while other units exist'); } } entity.active = false; await this.uomRepository.save(entity); return true; } // ==================== CONVERSION OPERATIONS ==================== /** * Get reference unit for a category */ async getReferenceUnit( ctx: ServiceContext, categoryId: string ): Promise { return this.uomRepository.findOne({ where: { tenantId: ctx.tenantId, categoryId, uomType: UomType.REFERENCE, active: true, }, }); } /** * Convert quantity between UoMs (must be same category) */ async convert( ctx: ServiceContext, quantity: number, fromUomId: string, toUomId: string ): Promise { // Same UoM - no conversion if (fromUomId === toUomId) { const uom = await this.findById(ctx, fromUomId); return { fromUom: uom?.name || fromUomId, toUom: uom?.name || toUomId, originalQuantity: quantity, convertedQuantity: quantity, factor: 1, }; } const fromUom = await this.findById(ctx, fromUomId); const toUom = await this.findById(ctx, toUomId); if (!fromUom || !toUom) { throw new Error('Unit of measure not found'); } if (fromUom.categoryId !== toUom.categoryId) { throw new Error('Cannot convert between different UoM categories'); } // Convert through reference unit // fromUom -> reference -> toUom const referenceQuantity = quantity * Number(fromUom.factor); const convertedQuantity = referenceQuantity / Number(toUom.factor); // Apply rounding const rounding = Number(toUom.rounding) || 0.01; const roundedQuantity = Math.round(convertedQuantity / rounding) * rounding; return { fromUom: fromUom.name, toUom: toUom.name, originalQuantity: quantity, convertedQuantity: roundedQuantity, factor: Number(fromUom.factor) / Number(toUom.factor), }; } /** * Get conversion factor between two UoMs */ async getConversionFactor( ctx: ServiceContext, fromUomId: string, toUomId: string ): Promise { if (fromUomId === toUomId) { return 1; } const fromUom = await this.findById(ctx, fromUomId); const toUom = await this.findById(ctx, toUomId); if (!fromUom || !toUom) { throw new Error('Unit of measure not found'); } if (fromUom.categoryId !== toUom.categoryId) { throw new Error('Cannot get factor between different UoM categories'); } return Number(fromUom.factor) / Number(toUom.factor); } }