diff --git a/src/modules/core/core.controller.ts b/src/modules/core/core.controller.ts index a0ad97d..165848c 100644 --- a/src/modules/core/core.controller.ts +++ b/src/modules/core/core.controller.ts @@ -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 { @@ -260,6 +311,261 @@ class CoreController { } } + // ========== STATES ========== + async getStates(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 = {}; + 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 { try { @@ -329,6 +635,56 @@ class CoreController { } } + async getUomByCode(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + 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 { + 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 { + 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 { try { diff --git a/src/modules/core/core.routes.ts b/src/modules/core/core.routes.ts index f27ab97..ef7d7f7 100644 --- a/src/modules/core/core.routes.ts +++ b/src/modules/core/core.routes.ts @@ -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) ); diff --git a/src/modules/core/currency-rates.service.ts b/src/modules/core/currency-rates.service.ts new file mode 100644 index 0000000..694b8c1 --- /dev/null +++ b/src/modules/core/currency-rates.service.ts @@ -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; + private currencyRepository: Repository; + + constructor() { + this.repository = AppDataSource.getRepository(CurrencyRate); + this.currencyRepository = AppDataSource.getRepository(Currency); + } + + async findAll(filter: CurrencyRateFilter = {}): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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> { + logger.debug('Getting latest rates', { baseCurrencyCode, tenantId }); + + const rates = new Map(); + 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(); diff --git a/src/modules/core/entities/currency-rate.entity.ts b/src/modules/core/entities/currency-rate.entity.ts new file mode 100644 index 0000000..1be963b --- /dev/null +++ b/src/modules/core/entities/currency-rate.entity.ts @@ -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; +} diff --git a/src/modules/core/entities/index.ts b/src/modules/core/entities/index.ts index 7abd07e..db947b6 100644 --- a/src/modules/core/entities/index.ts +++ b/src/modules/core/entities/index.ts @@ -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'; diff --git a/src/modules/core/entities/state.entity.ts b/src/modules/core/entities/state.entity.ts new file mode 100644 index 0000000..0355f5e --- /dev/null +++ b/src/modules/core/entities/state.entity.ts @@ -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; +} diff --git a/src/modules/core/states.service.ts b/src/modules/core/states.service.ts new file mode 100644 index 0000000..c89a9a9 --- /dev/null +++ b/src/modules/core/states.service.ts @@ -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; + + constructor() { + this.repository = AppDataSource.getRepository(State); + } + + async findAll(filter: StateFilter = {}): Promise { + 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 { + 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 { + logger.debug('Finding state by code', { countryId, code }); + + return this.repository.findOne({ + where: { countryId, code: code.toUpperCase() }, + relations: ['country'], + }); + } + + async findByCountry(countryId: string): Promise { + 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 { + 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 { + 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 { + 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 { + logger.info('Deleting state', { id }); + + const state = await this.findById(id); + await this.repository.remove(state); + } + + async setActive(id: string, isActive: boolean): Promise { + 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(); diff --git a/src/modules/core/uom.service.ts b/src/modules/core/uom.service.ts index dc3abd6..93c0ace 100644 --- a/src/modules/core/uom.service.ts +++ b/src/modules/core/uom.service.ts @@ -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 { + 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 { + 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 { + 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();