erp-construccion-backend-v2/src/modules/core/services/uom.service.ts
Adrian Flores Cortes 100c5a6588 feat(modules): implement 13 backend modules for 100% completion
Implemented modules:
- audit: 8 services (GDPR compliance, retention policies, sensitive data)
- billing-usage: 8 services, 6 controllers (subscription management, usage tracking)
- biometrics: 3 services, 3 controllers (offline auth, device sync, lockout)
- core: 6 services (sequence, currency, UoM, payment-terms, geography)
- feature-flags: 3 services, 3 controllers (rollout strategies, A/B testing)
- fiscal: 7 services, 7 controllers (SAT/Mexican tax compliance)
- mobile: 4 services, 4 controllers (offline-first, sync queue, device management)
- partners: 6 services, 6 controllers (unified customers/suppliers, credit limits)
- profiles: 5 services, 3 controllers (avatar upload, preferences, completion)
- warehouses: 3 services, 3 controllers (zones, hierarchical locations)
- webhooks: 5 services, 5 controllers (HMAC signatures, retry logic)
- whatsapp: 5 services, 5 controllers (business API integration, templates)

Total: 154 files, ~43K lines of new backend code

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 01:54:23 -06:00

469 lines
11 KiB
TypeScript

/**
* 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<T> {
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<UomCategory>;
private uomRepository: Repository<Uom>;
constructor(
categoryRepository: Repository<UomCategory>,
uomRepository: Repository<Uom>
) {
this.categoryRepository = categoryRepository;
this.uomRepository = uomRepository;
}
// ==================== CATEGORY OPERATIONS ====================
/**
* Create a new UoM category
*/
async createCategory(
ctx: ServiceContext,
data: CreateUomCategoryDto
): Promise<UomCategory> {
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<UomCategory | null> {
return this.categoryRepository.findOne({
where: { id, tenantId: ctx.tenantId },
relations: ['uoms'],
});
}
/**
* Get all categories for tenant
*/
async findAllCategories(ctx: ServiceContext): Promise<UomCategory[]> {
return this.categoryRepository.find({
where: { tenantId: ctx.tenantId },
relations: ['uoms'],
order: { name: 'ASC' },
});
}
/**
* Update category
*/
async updateCategory(
ctx: ServiceContext,
id: string,
data: UpdateUomCategoryDto
): Promise<UomCategory | null> {
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<boolean> {
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<Uom> {
// 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<Uom | null> {
return this.uomRepository.findOne({
where: { id, tenantId: ctx.tenantId },
relations: ['category'],
});
}
/**
* Find UoM by code
*/
async findByCode(ctx: ServiceContext, code: string): Promise<Uom | null> {
return this.uomRepository.findOne({
where: { tenantId: ctx.tenantId, code },
relations: ['category'],
});
}
/**
* Get all UoMs by category
*/
async findByCategory(ctx: ServiceContext, categoryId: string): Promise<Uom[]> {
return this.uomRepository.find({
where: { tenantId: ctx.tenantId, categoryId },
order: { name: 'ASC' },
});
}
/**
* Get all active UoMs for tenant
*/
async findAll(ctx: ServiceContext): Promise<Uom[]> {
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<PaginatedResult<Uom>> {
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<Uom | null> {
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<boolean> {
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<Uom | null> {
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<ConversionResult> {
// 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<number> {
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);
}
}