feat(core): Add States, CurrencyRates, and UoM conversion (MGN-005)

- State entity and service with CRUD operations
- CurrencyRate entity and service with conversion support
- UoM conversion methods (convertQuantity, getConversionTable)
- New API endpoints for states, currency rates, UoM conversions
- Updated controller and routes

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
rckrdmrd 2026-01-18 08:57:21 -06:00
parent 028c037160
commit d809e23b5c
8 changed files with 996 additions and 0 deletions

View File

@ -2,6 +2,8 @@ import { Response, NextFunction } from 'express';
import { z } from 'zod';
import { currenciesService, CreateCurrencyDto, UpdateCurrencyDto } from './currencies.service.js';
import { countriesService } from './countries.service.js';
import { statesService, CreateStateDto, UpdateStateDto } from './states.service.js';
import { currencyRatesService, CreateCurrencyRateDto, ConvertCurrencyDto } from './currency-rates.service.js';
import { uomService, CreateUomDto, UpdateUomDto } from './uom.service.js';
import { productCategoriesService, CreateProductCategoryDto, UpdateProductCategoryDto } from './product-categories.service.js';
import { paymentTermsService, CreatePaymentTermDto, UpdatePaymentTermDto } from './payment-terms.service.js';
@ -192,6 +194,55 @@ const applyDiscountsSchema = z.object({
isFirstPurchase: z.boolean().optional(),
});
// States Schemas
const createStateSchema = z.object({
country_id: z.string().uuid().optional(),
countryId: z.string().uuid().optional(),
code: z.string().min(1).max(10).toUpperCase(),
name: z.string().min(1).max(255),
timezone: z.string().max(50).optional(),
is_active: z.boolean().optional(),
isActive: z.boolean().optional(),
}).refine((data) => data.country_id !== undefined || data.countryId !== undefined, {
message: 'country_id or countryId is required',
});
const updateStateSchema = z.object({
name: z.string().min(1).max(255).optional(),
timezone: z.string().max(50).optional().nullable(),
is_active: z.boolean().optional(),
isActive: z.boolean().optional(),
});
// Currency Rates Schemas
const createCurrencyRateSchema = z.object({
from_currency_code: z.string().length(3).toUpperCase().optional(),
fromCurrencyCode: z.string().length(3).toUpperCase().optional(),
to_currency_code: z.string().length(3).toUpperCase().optional(),
toCurrencyCode: z.string().length(3).toUpperCase().optional(),
rate: z.number().positive(),
rate_date: z.string().optional(),
rateDate: z.string().optional(),
source: z.enum(['manual', 'banxico', 'xe', 'openexchange']).optional(),
}).refine((data) => data.from_currency_code !== undefined || data.fromCurrencyCode !== undefined, {
message: 'from_currency_code or fromCurrencyCode is required',
}).refine((data) => data.to_currency_code !== undefined || data.toCurrencyCode !== undefined, {
message: 'to_currency_code or toCurrencyCode is required',
});
const convertCurrencySchema = z.object({
amount: z.number().min(0),
from_currency_code: z.string().length(3).toUpperCase().optional(),
fromCurrencyCode: z.string().length(3).toUpperCase().optional(),
to_currency_code: z.string().length(3).toUpperCase().optional(),
toCurrencyCode: z.string().length(3).toUpperCase().optional(),
date: z.string().optional(),
}).refine((data) => data.from_currency_code !== undefined || data.fromCurrencyCode !== undefined, {
message: 'from_currency_code or fromCurrencyCode is required',
}).refine((data) => data.to_currency_code !== undefined || data.toCurrencyCode !== undefined, {
message: 'to_currency_code or toCurrencyCode is required',
});
class CoreController {
// ========== CURRENCIES ==========
async getCurrencies(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
@ -260,6 +311,261 @@ class CoreController {
}
}
// ========== STATES ==========
async getStates(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
try {
const filter = {
countryId: req.query.country_id as string | undefined,
countryCode: req.query.country_code as string | undefined,
isActive: req.query.active === 'true' ? true : undefined,
};
const states = await statesService.findAll(filter);
res.json({ success: true, data: states });
} catch (error) {
next(error);
}
}
async getState(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
try {
const state = await statesService.findById(req.params.id);
res.json({ success: true, data: state });
} catch (error) {
next(error);
}
}
async getStatesByCountry(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
try {
const states = await statesService.findByCountry(req.params.countryId);
res.json({ success: true, data: states });
} catch (error) {
next(error);
}
}
async getStatesByCountryCode(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
try {
const states = await statesService.findByCountryCode(req.params.countryCode);
res.json({ success: true, data: states });
} catch (error) {
next(error);
}
}
async createState(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
try {
const parseResult = createStateSchema.safeParse(req.body);
if (!parseResult.success) {
throw new ValidationError('Datos de estado inválidos', parseResult.error.errors);
}
const data = parseResult.data;
const dto: CreateStateDto = {
countryId: data.country_id ?? data.countryId!,
code: data.code,
name: data.name,
timezone: data.timezone,
isActive: data.is_active ?? data.isActive,
};
const state = await statesService.create(dto);
res.status(201).json({ success: true, data: state, message: 'Estado creado exitosamente' });
} catch (error) {
next(error);
}
}
async updateState(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
try {
const parseResult = updateStateSchema.safeParse(req.body);
if (!parseResult.success) {
throw new ValidationError('Datos de estado inválidos', parseResult.error.errors);
}
const data = parseResult.data;
const dto: UpdateStateDto = {
name: data.name,
timezone: data.timezone ?? undefined,
isActive: data.is_active ?? data.isActive,
};
const state = await statesService.update(req.params.id, dto);
res.json({ success: true, data: state, message: 'Estado actualizado exitosamente' });
} catch (error) {
next(error);
}
}
async deleteState(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
try {
await statesService.delete(req.params.id);
res.json({ success: true, message: 'Estado eliminado exitosamente' });
} catch (error) {
next(error);
}
}
// ========== CURRENCY RATES ==========
async getCurrencyRates(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
try {
const filter = {
tenantId: req.tenantId,
fromCurrencyCode: req.query.from as string | undefined,
toCurrencyCode: req.query.to as string | undefined,
limit: req.query.limit ? parseInt(req.query.limit as string, 10) : 100,
};
const rates = await currencyRatesService.findAll(filter);
res.json({ success: true, data: rates });
} catch (error) {
next(error);
}
}
async getCurrencyRate(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
try {
const rate = await currencyRatesService.findById(req.params.id);
res.json({ success: true, data: rate });
} catch (error) {
next(error);
}
}
async getLatestRate(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
try {
const fromCode = req.params.from.toUpperCase();
const toCode = req.params.to.toUpperCase();
const dateStr = req.query.date as string | undefined;
const date = dateStr ? new Date(dateStr) : new Date();
const rate = await currencyRatesService.getRate(fromCode, toCode, date, req.tenantId);
if (rate === null) {
res.status(404).json({
success: false,
message: `No se encontró tipo de cambio para ${fromCode}/${toCode}`
});
return;
}
res.json({
success: true,
data: {
from: fromCode,
to: toCode,
rate,
date: date.toISOString().split('T')[0]
}
});
} catch (error) {
next(error);
}
}
async createCurrencyRate(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
try {
const parseResult = createCurrencyRateSchema.safeParse(req.body);
if (!parseResult.success) {
throw new ValidationError('Datos de tipo de cambio inválidos', parseResult.error.errors);
}
const data = parseResult.data;
const dto: CreateCurrencyRateDto = {
tenantId: req.tenantId,
fromCurrencyCode: data.from_currency_code ?? data.fromCurrencyCode!,
toCurrencyCode: data.to_currency_code ?? data.toCurrencyCode!,
rate: data.rate,
rateDate: data.rate_date ?? data.rateDate ? new Date(data.rate_date ?? data.rateDate!) : new Date(),
source: data.source,
createdBy: req.user?.userId,
};
const rate = await currencyRatesService.create(dto);
res.status(201).json({ success: true, data: rate, message: 'Tipo de cambio creado exitosamente' });
} catch (error) {
next(error);
}
}
async convertCurrency(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
try {
const parseResult = convertCurrencySchema.safeParse(req.body);
if (!parseResult.success) {
throw new ValidationError('Datos de conversión inválidos', parseResult.error.errors);
}
const data = parseResult.data;
const dto: ConvertCurrencyDto = {
amount: data.amount,
fromCurrencyCode: data.from_currency_code ?? data.fromCurrencyCode!,
toCurrencyCode: data.to_currency_code ?? data.toCurrencyCode!,
date: data.date ? new Date(data.date) : new Date(),
tenantId: req.tenantId,
};
const result = await currencyRatesService.convert(dto);
if (result === null) {
res.status(404).json({
success: false,
message: `No se encontró tipo de cambio para ${dto.fromCurrencyCode}/${dto.toCurrencyCode}`
});
return;
}
res.json({
success: true,
data: {
originalAmount: dto.amount,
convertedAmount: result.amount,
rate: result.rate,
from: dto.fromCurrencyCode,
to: dto.toCurrencyCode,
}
});
} catch (error) {
next(error);
}
}
async deleteCurrencyRate(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
try {
await currencyRatesService.delete(req.params.id);
res.json({ success: true, message: 'Tipo de cambio eliminado exitosamente' });
} catch (error) {
next(error);
}
}
async getCurrencyRateHistory(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
try {
const fromCode = req.params.from.toUpperCase();
const toCode = req.params.to.toUpperCase();
const days = req.query.days ? parseInt(req.query.days as string, 10) : 30;
const history = await currencyRatesService.getHistory(fromCode, toCode, days, req.tenantId);
res.json({ success: true, data: history });
} catch (error) {
next(error);
}
}
async getLatestRates(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
try {
const baseCurrency = (req.query.base as string) || 'MXN';
const ratesMap = await currencyRatesService.getLatestRates(baseCurrency, req.tenantId);
// Convert Map to object for JSON response
const rates: Record<string, number> = {};
ratesMap.forEach((value, key) => {
rates[key] = value;
});
res.json({
success: true,
data: {
base: baseCurrency,
rates,
date: new Date().toISOString().split('T')[0],
}
});
} catch (error) {
next(error);
}
}
// ========== UOM CATEGORIES ==========
async getUomCategories(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
try {
@ -329,6 +635,56 @@ class CoreController {
}
}
async getUomByCode(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
try {
const uom = await uomService.findByCode(req.params.code);
if (!uom) {
res.status(404).json({ success: false, message: 'Unidad de medida no encontrada' });
return;
}
res.json({ success: true, data: uom });
} catch (error) {
next(error);
}
}
async convertUom(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
try {
const { quantity, from_uom_id, fromUomId, to_uom_id, toUomId } = req.body;
const fromId = from_uom_id ?? fromUomId;
const toId = to_uom_id ?? toUomId;
if (!quantity || !fromId || !toId) {
throw new ValidationError('Se requiere quantity, from_uom_id y to_uom_id');
}
const result = await uomService.convertQuantity(quantity, fromId, toId);
const fromUom = await uomService.findById(fromId);
const toUom = await uomService.findById(toId);
res.json({
success: true,
data: {
originalQuantity: quantity,
originalUom: fromUom.name,
convertedQuantity: result,
targetUom: toUom.name,
}
});
} catch (error) {
next(error);
}
}
async getUomConversions(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
try {
const result = await uomService.getConversionTable(req.params.categoryId);
res.json({ success: true, data: result });
} catch (error) {
next(error);
}
}
// ========== PRODUCT CATEGORIES ==========
async getProductCategories(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
try {

View File

@ -21,16 +21,50 @@ router.put('/currencies/:id', requireRoles('admin', 'super_admin'), (req, res, n
router.get('/countries', (req, res, next) => coreController.getCountries(req, res, next));
router.get('/countries/:id', (req, res, next) => coreController.getCountry(req, res, next));
// ========== STATES ==========
router.get('/states', (req, res, next) => coreController.getStates(req, res, next));
router.get('/states/:id', (req, res, next) => coreController.getState(req, res, next));
router.get('/countries/:countryId/states', (req, res, next) => coreController.getStatesByCountry(req, res, next));
router.get('/countries/code/:countryCode/states', (req, res, next) => coreController.getStatesByCountryCode(req, res, next));
router.post('/states', requireRoles('admin', 'super_admin'), (req, res, next) =>
coreController.createState(req, res, next)
);
router.put('/states/:id', requireRoles('admin', 'super_admin'), (req, res, next) =>
coreController.updateState(req, res, next)
);
router.delete('/states/:id', requireRoles('admin', 'super_admin'), (req, res, next) =>
coreController.deleteState(req, res, next)
);
// ========== CURRENCY RATES ==========
router.get('/currency-rates', (req, res, next) => coreController.getCurrencyRates(req, res, next));
router.get('/currency-rates/latest', (req, res, next) => coreController.getLatestRates(req, res, next));
router.get('/currency-rates/rate/:from/:to', (req, res, next) => coreController.getLatestRate(req, res, next));
router.get('/currency-rates/history/:from/:to', (req, res, next) => coreController.getCurrencyRateHistory(req, res, next));
router.get('/currency-rates/:id', (req, res, next) => coreController.getCurrencyRate(req, res, next));
router.post('/currency-rates', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) =>
coreController.createCurrencyRate(req, res, next)
);
router.post('/currency-rates/convert', (req, res, next) => coreController.convertCurrency(req, res, next));
router.delete('/currency-rates/:id', requireRoles('admin', 'super_admin'), (req, res, next) =>
coreController.deleteCurrencyRate(req, res, next)
);
// ========== UOM CATEGORIES ==========
router.get('/uom-categories', (req, res, next) => coreController.getUomCategories(req, res, next));
router.get('/uom-categories/:id', (req, res, next) => coreController.getUomCategory(req, res, next));
// ========== UOM ==========
router.get('/uom', (req, res, next) => coreController.getUoms(req, res, next));
router.get('/uom/by-code/:code', (req, res, next) => coreController.getUomByCode(req, res, next));
router.get('/uom/:id', (req, res, next) => coreController.getUom(req, res, next));
router.post('/uom', requireRoles('admin', 'super_admin'), (req, res, next) =>
coreController.createUom(req, res, next)
);
router.post('/uom/convert', (req, res, next) => coreController.convertUom(req, res, next));
router.get('/uom-categories/:categoryId/conversions', (req, res, next) =>
coreController.getUomConversions(req, res, next)
);
router.put('/uom/:id', requireRoles('admin', 'super_admin'), (req, res, next) =>
coreController.updateUom(req, res, next)
);

View File

@ -0,0 +1,269 @@
import { Repository, LessThanOrEqual } from 'typeorm';
import { AppDataSource } from '../../config/typeorm.js';
import { CurrencyRate, RateSource } from './entities/currency-rate.entity.js';
import { Currency } from './entities/currency.entity.js';
import { NotFoundError } from '../../shared/errors/index.js';
import { logger } from '../../shared/utils/logger.js';
export interface CreateCurrencyRateDto {
tenantId?: string;
fromCurrencyCode: string;
toCurrencyCode: string;
rate: number;
rateDate: Date;
source?: RateSource;
createdBy?: string;
}
export interface CurrencyRateFilter {
tenantId?: string;
fromCurrencyCode?: string;
toCurrencyCode?: string;
dateFrom?: Date;
dateTo?: Date;
limit?: number;
}
export interface ConvertCurrencyDto {
amount: number;
fromCurrencyCode: string;
toCurrencyCode: string;
date?: Date;
tenantId?: string;
}
class CurrencyRatesService {
private repository: Repository<CurrencyRate>;
private currencyRepository: Repository<Currency>;
constructor() {
this.repository = AppDataSource.getRepository(CurrencyRate);
this.currencyRepository = AppDataSource.getRepository(Currency);
}
async findAll(filter: CurrencyRateFilter = {}): Promise<CurrencyRate[]> {
logger.debug('Finding currency rates', { filter });
const query = this.repository
.createQueryBuilder('rate')
.leftJoinAndSelect('rate.fromCurrency', 'fromCurrency')
.leftJoinAndSelect('rate.toCurrency', 'toCurrency');
if (filter.tenantId) {
query.andWhere('(rate.tenantId = :tenantId OR rate.tenantId IS NULL)', {
tenantId: filter.tenantId,
});
}
if (filter.fromCurrencyCode) {
query.andWhere('fromCurrency.code = :fromCode', {
fromCode: filter.fromCurrencyCode.toUpperCase(),
});
}
if (filter.toCurrencyCode) {
query.andWhere('toCurrency.code = :toCode', {
toCode: filter.toCurrencyCode.toUpperCase(),
});
}
if (filter.dateFrom) {
query.andWhere('rate.rateDate >= :dateFrom', { dateFrom: filter.dateFrom });
}
if (filter.dateTo) {
query.andWhere('rate.rateDate <= :dateTo', { dateTo: filter.dateTo });
}
query.orderBy('rate.rateDate', 'DESC');
if (filter.limit) {
query.take(filter.limit);
}
return query.getMany();
}
async findById(id: string): Promise<CurrencyRate> {
logger.debug('Finding currency rate by id', { id });
const rate = await this.repository.findOne({
where: { id },
relations: ['fromCurrency', 'toCurrency'],
});
if (!rate) {
throw new NotFoundError('Tipo de cambio no encontrado');
}
return rate;
}
async getRate(
fromCurrencyCode: string,
toCurrencyCode: string,
date: Date = new Date(),
tenantId?: string
): Promise<number | null> {
logger.debug('Getting currency rate', { fromCurrencyCode, toCurrencyCode, date, tenantId });
// Same currency = rate 1
if (fromCurrencyCode.toUpperCase() === toCurrencyCode.toUpperCase()) {
return 1;
}
// Find direct rate
const directRate = await this.repository
.createQueryBuilder('rate')
.leftJoin('rate.fromCurrency', 'fromCurrency')
.leftJoin('rate.toCurrency', 'toCurrency')
.where('fromCurrency.code = :fromCode', { fromCode: fromCurrencyCode.toUpperCase() })
.andWhere('toCurrency.code = :toCode', { toCode: toCurrencyCode.toUpperCase() })
.andWhere('rate.rateDate <= :date', { date })
.andWhere('(rate.tenantId = :tenantId OR rate.tenantId IS NULL)', { tenantId: tenantId || null })
.orderBy('rate.rateDate', 'DESC')
.addOrderBy('rate.tenantId', 'DESC', 'NULLS LAST')
.getOne();
if (directRate) {
return Number(directRate.rate);
}
// Try inverse rate
const inverseRate = await this.repository
.createQueryBuilder('rate')
.leftJoin('rate.fromCurrency', 'fromCurrency')
.leftJoin('rate.toCurrency', 'toCurrency')
.where('fromCurrency.code = :toCode', { toCode: toCurrencyCode.toUpperCase() })
.andWhere('toCurrency.code = :fromCode', { fromCode: fromCurrencyCode.toUpperCase() })
.andWhere('rate.rateDate <= :date', { date })
.andWhere('(rate.tenantId = :tenantId OR rate.tenantId IS NULL)', { tenantId: tenantId || null })
.orderBy('rate.rateDate', 'DESC')
.addOrderBy('rate.tenantId', 'DESC', 'NULLS LAST')
.getOne();
if (inverseRate) {
return 1 / Number(inverseRate.rate);
}
return null;
}
async convert(dto: ConvertCurrencyDto): Promise<{ amount: number; rate: number } | null> {
logger.debug('Converting currency', dto);
const rate = await this.getRate(
dto.fromCurrencyCode,
dto.toCurrencyCode,
dto.date || new Date(),
dto.tenantId
);
if (rate === null) {
return null;
}
return {
amount: dto.amount * rate,
rate,
};
}
async create(dto: CreateCurrencyRateDto): Promise<CurrencyRate> {
logger.info('Creating currency rate', { dto });
// Get currency IDs
const fromCurrency = await this.currencyRepository.findOne({
where: { code: dto.fromCurrencyCode.toUpperCase() },
});
if (!fromCurrency) {
throw new NotFoundError(`Moneda ${dto.fromCurrencyCode} no encontrada`);
}
const toCurrency = await this.currencyRepository.findOne({
where: { code: dto.toCurrencyCode.toUpperCase() },
});
if (!toCurrency) {
throw new NotFoundError(`Moneda ${dto.toCurrencyCode} no encontrada`);
}
// Check if rate already exists for this date
const existing = await this.repository.findOne({
where: {
tenantId: dto.tenantId || undefined,
fromCurrencyId: fromCurrency.id,
toCurrencyId: toCurrency.id,
rateDate: dto.rateDate,
},
});
if (existing) {
// Update existing rate
existing.rate = dto.rate;
existing.source = dto.source || 'manual';
return this.repository.save(existing);
}
const rate = this.repository.create({
tenantId: dto.tenantId || null,
fromCurrencyId: fromCurrency.id,
toCurrencyId: toCurrency.id,
rate: dto.rate,
rateDate: dto.rateDate,
source: dto.source || 'manual',
createdBy: dto.createdBy || null,
});
return this.repository.save(rate);
}
async delete(id: string): Promise<void> {
logger.info('Deleting currency rate', { id });
const rate = await this.findById(id);
await this.repository.remove(rate);
}
async getHistory(
fromCurrencyCode: string,
toCurrencyCode: string,
days: number = 30,
tenantId?: string
): Promise<CurrencyRate[]> {
logger.debug('Getting rate history', { fromCurrencyCode, toCurrencyCode, days, tenantId });
const dateFrom = new Date();
dateFrom.setDate(dateFrom.getDate() - days);
return this.findAll({
fromCurrencyCode,
toCurrencyCode,
dateFrom,
tenantId,
limit: days,
});
}
async getLatestRates(baseCurrencyCode: string = 'MXN', tenantId?: string): Promise<Map<string, number>> {
logger.debug('Getting latest rates', { baseCurrencyCode, tenantId });
const rates = new Map<string, number>();
const currencies = await this.currencyRepository.find({ where: { active: true } });
for (const currency of currencies) {
if (currency.code === baseCurrencyCode.toUpperCase()) {
rates.set(currency.code, 1);
continue;
}
const rate = await this.getRate(baseCurrencyCode, currency.code, new Date(), tenantId);
if (rate !== null) {
rates.set(currency.code, rate);
}
}
return rates;
}
}
export const currencyRatesService = new CurrencyRatesService();

View File

@ -0,0 +1,55 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
Index,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { Currency } from './currency.entity.js';
export type RateSource = 'manual' | 'banxico' | 'xe' | 'openexchange';
@Entity({ schema: 'core', name: 'currency_rates' })
@Index('idx_currency_rates_tenant', ['tenantId'])
@Index('idx_currency_rates_from', ['fromCurrencyId'])
@Index('idx_currency_rates_to', ['toCurrencyId'])
@Index('idx_currency_rates_date', ['rateDate'])
@Index('idx_currency_rates_lookup', ['fromCurrencyId', 'toCurrencyId', 'rateDate'])
export class CurrencyRate {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid', name: 'tenant_id', nullable: true })
tenantId: string | null;
@Column({ type: 'uuid', name: 'from_currency_id', nullable: false })
fromCurrencyId: string;
@ManyToOne(() => Currency)
@JoinColumn({ name: 'from_currency_id' })
fromCurrency: Currency;
@Column({ type: 'uuid', name: 'to_currency_id', nullable: false })
toCurrencyId: string;
@ManyToOne(() => Currency)
@JoinColumn({ name: 'to_currency_id' })
toCurrency: Currency;
@Column({ type: 'decimal', precision: 18, scale: 8, nullable: false })
rate: number;
@Column({ type: 'date', name: 'rate_date', nullable: false })
rateDate: Date;
@Column({ type: 'varchar', length: 50, default: 'manual' })
source: RateSource;
@Column({ type: 'uuid', name: 'created_by', nullable: true })
createdBy: string | null;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
}

View File

@ -1,5 +1,7 @@
export { Currency } from './currency.entity.js';
export { Country } from './country.entity.js';
export { State } from './state.entity.js';
export { CurrencyRate, RateSource } from './currency-rate.entity.js';
export { UomCategory } from './uom-category.entity.js';
export { Uom, UomType } from './uom.entity.js';
export { ProductCategory } from './product-category.entity.js';

View File

@ -0,0 +1,45 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
Index,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { Country } from './country.entity.js';
@Entity({ schema: 'core', name: 'states' })
@Index('idx_states_country', ['countryId'])
@Index('idx_states_code', ['code'])
@Index('idx_states_country_code', ['countryId', 'code'], { unique: true })
export class State {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid', name: 'country_id', nullable: false })
countryId: string;
@ManyToOne(() => Country, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'country_id' })
country: Country;
@Column({ type: 'varchar', length: 10, nullable: false })
code: string;
@Column({ type: 'varchar', length: 255, nullable: false })
name: string;
@Column({ type: 'varchar', length: 50, nullable: true })
timezone: string | null;
@Column({ type: 'boolean', name: 'is_active', default: true })
isActive: boolean;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt: Date;
}

View File

@ -0,0 +1,148 @@
import { Repository } from 'typeorm';
import { AppDataSource } from '../../config/typeorm.js';
import { State } from './entities/state.entity.js';
import { NotFoundError } from '../../shared/errors/index.js';
import { logger } from '../../shared/utils/logger.js';
export interface CreateStateDto {
countryId: string;
code: string;
name: string;
timezone?: string;
isActive?: boolean;
}
export interface UpdateStateDto {
name?: string;
timezone?: string;
isActive?: boolean;
}
export interface StateFilter {
countryId?: string;
countryCode?: string;
isActive?: boolean;
}
class StatesService {
private repository: Repository<State>;
constructor() {
this.repository = AppDataSource.getRepository(State);
}
async findAll(filter: StateFilter = {}): Promise<State[]> {
logger.debug('Finding all states', { filter });
const query = this.repository
.createQueryBuilder('state')
.leftJoinAndSelect('state.country', 'country');
if (filter.countryId) {
query.andWhere('state.countryId = :countryId', { countryId: filter.countryId });
}
if (filter.countryCode) {
query.andWhere('country.code = :countryCode', { countryCode: filter.countryCode.toUpperCase() });
}
if (filter.isActive !== undefined) {
query.andWhere('state.isActive = :isActive', { isActive: filter.isActive });
}
query.orderBy('state.name', 'ASC');
return query.getMany();
}
async findById(id: string): Promise<State> {
logger.debug('Finding state by id', { id });
const state = await this.repository.findOne({
where: { id },
relations: ['country'],
});
if (!state) {
throw new NotFoundError('Estado no encontrado');
}
return state;
}
async findByCode(countryId: string, code: string): Promise<State | null> {
logger.debug('Finding state by code', { countryId, code });
return this.repository.findOne({
where: { countryId, code: code.toUpperCase() },
relations: ['country'],
});
}
async findByCountry(countryId: string): Promise<State[]> {
logger.debug('Finding states by country', { countryId });
return this.repository.find({
where: { countryId, isActive: true },
relations: ['country'],
order: { name: 'ASC' },
});
}
async findByCountryCode(countryCode: string): Promise<State[]> {
logger.debug('Finding states by country code', { countryCode });
return this.repository
.createQueryBuilder('state')
.leftJoinAndSelect('state.country', 'country')
.where('country.code = :countryCode', { countryCode: countryCode.toUpperCase() })
.andWhere('state.isActive = :isActive', { isActive: true })
.orderBy('state.name', 'ASC')
.getMany();
}
async create(dto: CreateStateDto): Promise<State> {
logger.info('Creating state', { dto });
// Check if state already exists for this country
const existing = await this.findByCode(dto.countryId, dto.code);
if (existing) {
throw new Error(`Estado con código ${dto.code} ya existe para este país`);
}
const state = this.repository.create({
...dto,
code: dto.code.toUpperCase(),
isActive: dto.isActive ?? true,
});
return this.repository.save(state);
}
async update(id: string, dto: UpdateStateDto): Promise<State> {
logger.info('Updating state', { id, dto });
const state = await this.findById(id);
Object.assign(state, dto);
return this.repository.save(state);
}
async delete(id: string): Promise<void> {
logger.info('Deleting state', { id });
const state = await this.findById(id);
await this.repository.remove(state);
}
async setActive(id: string, isActive: boolean): Promise<State> {
logger.info('Setting state active status', { id, isActive });
const state = await this.findById(id);
state.isActive = isActive;
return this.repository.save(state);
}
}
export const statesService = new StatesService();

View File

@ -157,6 +157,93 @@ class UomService {
return updated;
}
/**
* Convert quantity from one UoM to another
* Both UoMs must be in the same category
*/
async convertQuantity(quantity: number, fromUomId: string, toUomId: string): Promise<number> {
logger.debug('Converting quantity', { quantity, fromUomId, toUomId });
// Same UoM = no conversion needed
if (fromUomId === toUomId) {
return quantity;
}
const fromUom = await this.findById(fromUomId);
const toUom = await this.findById(toUomId);
// Validate same category
if (fromUom.categoryId !== toUom.categoryId) {
throw new Error('No se pueden convertir unidades de diferentes categorías');
}
// Convert: first to reference unit, then to target unit
// quantity * fromFactor = reference quantity
// reference quantity / toFactor = target quantity
const result = (quantity * fromUom.factor) / toUom.factor;
logger.debug('Conversion result', {
quantity,
fromUom: fromUom.name,
toUom: toUom.name,
result
});
return result;
}
/**
* Get the reference UoM for a category
*/
async getReferenceUom(categoryId: string): Promise<Uom | null> {
logger.debug('Getting reference UoM', { categoryId });
return this.repository.findOne({
where: {
categoryId,
uomType: 'reference' as UomType,
active: true,
},
relations: ['category'],
});
}
/**
* Find UoM by code
*/
async findByCode(code: string): Promise<Uom | null> {
logger.debug('Finding UoM by code', { code });
return this.repository.findOne({
where: { code },
relations: ['category'],
});
}
/**
* Get all UoMs in a category with their conversion factors
*/
async getConversionTable(categoryId: string): Promise<{
referenceUom: Uom;
conversions: Array<{ uom: Uom; toReference: number; fromReference: number }>;
}> {
logger.debug('Getting conversion table', { categoryId });
const referenceUom = await this.getReferenceUom(categoryId);
if (!referenceUom) {
throw new NotFoundError('No se encontró unidad de referencia para esta categoría');
}
const uoms = await this.findAll(categoryId, true);
const conversions = uoms.map(uom => ({
uom,
toReference: uom.factor, // Multiply by this to get reference unit
fromReference: 1 / uom.factor, // Multiply by this to get this unit from reference
}));
return { referenceUom, conversions };
}
}
export const uomService = new UomService();