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>
469 lines
11 KiB
TypeScript
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);
|
|
}
|
|
}
|