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:
parent
028c037160
commit
d809e23b5c
@ -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 {
|
||||
|
||||
@ -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)
|
||||
);
|
||||
|
||||
269
src/modules/core/currency-rates.service.ts
Normal file
269
src/modules/core/currency-rates.service.ts
Normal 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();
|
||||
55
src/modules/core/entities/currency-rate.entity.ts
Normal file
55
src/modules/core/entities/currency-rate.entity.ts
Normal 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;
|
||||
}
|
||||
@ -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';
|
||||
|
||||
45
src/modules/core/entities/state.entity.ts
Normal file
45
src/modules/core/entities/state.entity.ts
Normal 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;
|
||||
}
|
||||
148
src/modules/core/states.service.ts
Normal file
148
src/modules/core/states.service.ts
Normal 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();
|
||||
@ -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();
|
||||
|
||||
Loading…
Reference in New Issue
Block a user