[BACKEND] feat: EPIC-001 & EPIC-002 - Core module completion and entity consolidation
EPIC-001: Complete Core Module - Add PaymentTerm entity with multi-line support (30/60/90 days, early payment discounts) - Add PaymentTerms service with calculateDueDate() functionality - Add DiscountRule entity with volume/time-based conditions - Add DiscountRules service with applyDiscounts() and rule combination logic - Add REST endpoints for payment-terms and discount-rules in core module - Register new entities in TypeORM configuration EPIC-002: Entity Consolidation - Add inventoryProductId FK to products.products for linking to inventory module - Consolidate Warehouse entity in warehouses module as canonical source - Add companyId and Location relation to canonical Warehouse - Update inventory module to re-export Warehouse from warehouses module - Remove deprecated warehouse.entity.ts from inventory module - Update inventory/warehouses.service.ts to use canonical Warehouse Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
a7bf403367
commit
6054102774
@ -34,6 +34,9 @@ import {
|
||||
Uom,
|
||||
ProductCategory,
|
||||
Sequence,
|
||||
PaymentTerm,
|
||||
PaymentTermLine,
|
||||
DiscountRule,
|
||||
} from '../modules/core/entities/index.js';
|
||||
|
||||
// Import Financial Entities
|
||||
@ -109,6 +112,9 @@ export const AppDataSource = new DataSource({
|
||||
Uom,
|
||||
ProductCategory,
|
||||
Sequence,
|
||||
PaymentTerm,
|
||||
PaymentTermLine,
|
||||
DiscountRule,
|
||||
// Financial Entities
|
||||
AccountType,
|
||||
Account,
|
||||
|
||||
@ -4,6 +4,10 @@ import { currenciesService, CreateCurrencyDto, UpdateCurrencyDto } from './curre
|
||||
import { countriesService } from './countries.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';
|
||||
import { discountRulesService, CreateDiscountRuleDto, UpdateDiscountRuleDto, ApplyDiscountContext } from './discount-rules.service.js';
|
||||
import { PaymentTermLineType } from './entities/payment-term.entity.js';
|
||||
import { DiscountType, DiscountAppliesTo, DiscountCondition } from './entities/discount-rule.entity.js';
|
||||
import { AuthenticatedRequest } from '../../shared/middleware/auth.middleware.js';
|
||||
import { ValidationError } from '../../shared/errors/index.js';
|
||||
|
||||
@ -58,6 +62,136 @@ const updateCategorySchema = z.object({
|
||||
active: z.boolean().optional(),
|
||||
});
|
||||
|
||||
// Payment Terms Schemas
|
||||
const paymentTermLineSchema = z.object({
|
||||
sequence: z.number().int().min(1).optional(),
|
||||
line_type: z.enum(['balance', 'percent', 'fixed']).optional(),
|
||||
lineType: z.enum(['balance', 'percent', 'fixed']).optional(),
|
||||
value_percent: z.number().min(0).max(100).optional(),
|
||||
valuePercent: z.number().min(0).max(100).optional(),
|
||||
value_amount: z.number().min(0).optional(),
|
||||
valueAmount: z.number().min(0).optional(),
|
||||
days: z.number().int().min(0).optional(),
|
||||
day_of_month: z.number().int().min(1).max(31).optional(),
|
||||
dayOfMonth: z.number().int().min(1).max(31).optional(),
|
||||
end_of_month: z.boolean().optional(),
|
||||
endOfMonth: z.boolean().optional(),
|
||||
});
|
||||
|
||||
const createPaymentTermSchema = z.object({
|
||||
code: z.string().min(1).max(50),
|
||||
name: z.string().min(1).max(255),
|
||||
description: z.string().optional(),
|
||||
due_days: z.number().int().min(0).optional(),
|
||||
dueDays: z.number().int().min(0).optional(),
|
||||
discount_percent: z.number().min(0).max(100).optional(),
|
||||
discountPercent: z.number().min(0).max(100).optional(),
|
||||
discount_days: z.number().int().min(0).optional(),
|
||||
discountDays: z.number().int().min(0).optional(),
|
||||
is_immediate: z.boolean().optional(),
|
||||
isImmediate: z.boolean().optional(),
|
||||
lines: z.array(paymentTermLineSchema).optional(),
|
||||
});
|
||||
|
||||
const updatePaymentTermSchema = z.object({
|
||||
name: z.string().min(1).max(255).optional(),
|
||||
description: z.string().optional().nullable(),
|
||||
due_days: z.number().int().min(0).optional(),
|
||||
dueDays: z.number().int().min(0).optional(),
|
||||
discount_percent: z.number().min(0).max(100).optional().nullable(),
|
||||
discountPercent: z.number().min(0).max(100).optional().nullable(),
|
||||
discount_days: z.number().int().min(0).optional().nullable(),
|
||||
discountDays: z.number().int().min(0).optional().nullable(),
|
||||
is_immediate: z.boolean().optional(),
|
||||
isImmediate: z.boolean().optional(),
|
||||
is_active: z.boolean().optional(),
|
||||
isActive: z.boolean().optional(),
|
||||
lines: z.array(paymentTermLineSchema).optional(),
|
||||
});
|
||||
|
||||
const calculateDueDateSchema = z.object({
|
||||
invoice_date: z.string().datetime().optional(),
|
||||
invoiceDate: z.string().datetime().optional(),
|
||||
total_amount: z.number().min(0),
|
||||
totalAmount: z.number().min(0).optional(),
|
||||
});
|
||||
|
||||
// Discount Rules Schemas
|
||||
const createDiscountRuleSchema = z.object({
|
||||
code: z.string().min(1).max(50),
|
||||
name: z.string().min(1).max(255),
|
||||
description: z.string().optional(),
|
||||
discount_type: z.enum(['percentage', 'fixed', 'price_override']).optional(),
|
||||
discountType: z.enum(['percentage', 'fixed', 'price_override']).optional(),
|
||||
discount_value: z.number().min(0),
|
||||
discountValue: z.number().min(0).optional(),
|
||||
max_discount_amount: z.number().min(0).optional().nullable(),
|
||||
maxDiscountAmount: z.number().min(0).optional().nullable(),
|
||||
applies_to: z.enum(['all', 'category', 'product', 'customer', 'customer_group']).optional(),
|
||||
appliesTo: z.enum(['all', 'category', 'product', 'customer', 'customer_group']).optional(),
|
||||
applies_to_id: z.string().uuid().optional().nullable(),
|
||||
appliesToId: z.string().uuid().optional().nullable(),
|
||||
condition_type: z.enum(['none', 'min_quantity', 'min_amount', 'date_range', 'first_purchase']).optional(),
|
||||
conditionType: z.enum(['none', 'min_quantity', 'min_amount', 'date_range', 'first_purchase']).optional(),
|
||||
condition_value: z.number().optional().nullable(),
|
||||
conditionValue: z.number().optional().nullable(),
|
||||
start_date: z.string().datetime().optional().nullable(),
|
||||
startDate: z.string().datetime().optional().nullable(),
|
||||
end_date: z.string().datetime().optional().nullable(),
|
||||
endDate: z.string().datetime().optional().nullable(),
|
||||
priority: z.number().int().min(0).optional(),
|
||||
combinable: z.boolean().optional(),
|
||||
usage_limit: z.number().int().min(0).optional().nullable(),
|
||||
usageLimit: z.number().int().min(0).optional().nullable(),
|
||||
});
|
||||
|
||||
const updateDiscountRuleSchema = z.object({
|
||||
name: z.string().min(1).max(255).optional(),
|
||||
description: z.string().optional().nullable(),
|
||||
discount_type: z.enum(['percentage', 'fixed', 'price_override']).optional(),
|
||||
discountType: z.enum(['percentage', 'fixed', 'price_override']).optional(),
|
||||
discount_value: z.number().min(0).optional(),
|
||||
discountValue: z.number().min(0).optional(),
|
||||
max_discount_amount: z.number().min(0).optional().nullable(),
|
||||
maxDiscountAmount: z.number().min(0).optional().nullable(),
|
||||
applies_to: z.enum(['all', 'category', 'product', 'customer', 'customer_group']).optional(),
|
||||
appliesTo: z.enum(['all', 'category', 'product', 'customer', 'customer_group']).optional(),
|
||||
applies_to_id: z.string().uuid().optional().nullable(),
|
||||
appliesToId: z.string().uuid().optional().nullable(),
|
||||
condition_type: z.enum(['none', 'min_quantity', 'min_amount', 'date_range', 'first_purchase']).optional(),
|
||||
conditionType: z.enum(['none', 'min_quantity', 'min_amount', 'date_range', 'first_purchase']).optional(),
|
||||
condition_value: z.number().optional().nullable(),
|
||||
conditionValue: z.number().optional().nullable(),
|
||||
start_date: z.string().datetime().optional().nullable(),
|
||||
startDate: z.string().datetime().optional().nullable(),
|
||||
end_date: z.string().datetime().optional().nullable(),
|
||||
endDate: z.string().datetime().optional().nullable(),
|
||||
priority: z.number().int().min(0).optional(),
|
||||
combinable: z.boolean().optional(),
|
||||
usage_limit: z.number().int().min(0).optional().nullable(),
|
||||
usageLimit: z.number().int().min(0).optional().nullable(),
|
||||
is_active: z.boolean().optional(),
|
||||
isActive: z.boolean().optional(),
|
||||
});
|
||||
|
||||
const applyDiscountsSchema = z.object({
|
||||
product_id: z.string().uuid().optional(),
|
||||
productId: z.string().uuid().optional(),
|
||||
category_id: z.string().uuid().optional(),
|
||||
categoryId: z.string().uuid().optional(),
|
||||
customer_id: z.string().uuid().optional(),
|
||||
customerId: z.string().uuid().optional(),
|
||||
customer_group_id: z.string().uuid().optional(),
|
||||
customerGroupId: z.string().uuid().optional(),
|
||||
quantity: z.number().min(0),
|
||||
unit_price: z.number().min(0),
|
||||
unitPrice: z.number().min(0).optional(),
|
||||
total_amount: z.number().min(0),
|
||||
totalAmount: z.number().min(0).optional(),
|
||||
is_first_purchase: z.boolean().optional(),
|
||||
isFirstPurchase: z.boolean().optional(),
|
||||
});
|
||||
|
||||
class CoreController {
|
||||
// ========== CURRENCIES ==========
|
||||
async getCurrencies(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||
@ -252,6 +386,205 @@ class CoreController {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
// ========== PAYMENT TERMS ==========
|
||||
async getPaymentTerms(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const activeOnly = req.query.active === 'true';
|
||||
const paymentTerms = await paymentTermsService.findAll(req.tenantId!, activeOnly);
|
||||
res.json({ success: true, data: paymentTerms });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async getPaymentTerm(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const paymentTerm = await paymentTermsService.findById(req.params.id, req.tenantId!);
|
||||
res.json({ success: true, data: paymentTerm });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async createPaymentTerm(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const parseResult = createPaymentTermSchema.safeParse(req.body);
|
||||
if (!parseResult.success) {
|
||||
throw new ValidationError('Datos de término de pago inválidos', parseResult.error.errors);
|
||||
}
|
||||
const dto: CreatePaymentTermDto = parseResult.data;
|
||||
const paymentTerm = await paymentTermsService.create(dto, req.tenantId!, req.user?.userId);
|
||||
res.status(201).json({ success: true, data: paymentTerm, message: 'Término de pago creado exitosamente' });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async updatePaymentTerm(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const parseResult = updatePaymentTermSchema.safeParse(req.body);
|
||||
if (!parseResult.success) {
|
||||
throw new ValidationError('Datos de término de pago inválidos', parseResult.error.errors);
|
||||
}
|
||||
const dto: UpdatePaymentTermDto = parseResult.data;
|
||||
const paymentTerm = await paymentTermsService.update(req.params.id, dto, req.tenantId!, req.user?.userId);
|
||||
res.json({ success: true, data: paymentTerm, message: 'Término de pago actualizado exitosamente' });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async deletePaymentTerm(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
await paymentTermsService.delete(req.params.id, req.tenantId!, req.user?.userId);
|
||||
res.json({ success: true, message: 'Término de pago eliminado exitosamente' });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async calculateDueDate(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const parseResult = calculateDueDateSchema.safeParse(req.body);
|
||||
if (!parseResult.success) {
|
||||
throw new ValidationError('Datos inválidos para cálculo', parseResult.error.errors);
|
||||
}
|
||||
const paymentTerm = await paymentTermsService.findById(req.params.id, req.tenantId!);
|
||||
const invoiceDate = parseResult.data.invoice_date ?? parseResult.data.invoiceDate ?? new Date().toISOString();
|
||||
const totalAmount = parseResult.data.total_amount ?? parseResult.data.totalAmount ?? 0;
|
||||
const result = paymentTermsService.calculateDueDate(paymentTerm, new Date(invoiceDate), totalAmount);
|
||||
res.json({ success: true, data: result });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async getStandardPaymentTerms(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const standardTerms = paymentTermsService.getStandardTerms();
|
||||
res.json({ success: true, data: standardTerms });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async initializePaymentTerms(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
await paymentTermsService.initializeForTenant(req.tenantId!, req.user?.userId);
|
||||
res.json({ success: true, message: 'Términos de pago inicializados exitosamente' });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
// ========== DISCOUNT RULES ==========
|
||||
async getDiscountRules(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const activeOnly = req.query.active === 'true';
|
||||
const discountRules = await discountRulesService.findAll(req.tenantId!, activeOnly);
|
||||
res.json({ success: true, data: discountRules });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async getDiscountRule(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const discountRule = await discountRulesService.findById(req.params.id, req.tenantId!);
|
||||
res.json({ success: true, data: discountRule });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async createDiscountRule(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const parseResult = createDiscountRuleSchema.safeParse(req.body);
|
||||
if (!parseResult.success) {
|
||||
throw new ValidationError('Datos de regla de descuento inválidos', parseResult.error.errors);
|
||||
}
|
||||
const dto: CreateDiscountRuleDto = parseResult.data;
|
||||
const discountRule = await discountRulesService.create(dto, req.tenantId!, req.user?.userId);
|
||||
res.status(201).json({ success: true, data: discountRule, message: 'Regla de descuento creada exitosamente' });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async updateDiscountRule(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const parseResult = updateDiscountRuleSchema.safeParse(req.body);
|
||||
if (!parseResult.success) {
|
||||
throw new ValidationError('Datos de regla de descuento inválidos', parseResult.error.errors);
|
||||
}
|
||||
const dto: UpdateDiscountRuleDto = parseResult.data;
|
||||
const discountRule = await discountRulesService.update(req.params.id, dto, req.tenantId!, req.user?.userId);
|
||||
res.json({ success: true, data: discountRule, message: 'Regla de descuento actualizada exitosamente' });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async deleteDiscountRule(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
await discountRulesService.delete(req.params.id, req.tenantId!, req.user?.userId);
|
||||
res.json({ success: true, message: 'Regla de descuento eliminada exitosamente' });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async applyDiscounts(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const parseResult = applyDiscountsSchema.safeParse(req.body);
|
||||
if (!parseResult.success) {
|
||||
throw new ValidationError('Datos inválidos para aplicar descuentos', parseResult.error.errors);
|
||||
}
|
||||
const data = parseResult.data;
|
||||
const context: ApplyDiscountContext = {
|
||||
productId: data.product_id ?? data.productId,
|
||||
categoryId: data.category_id ?? data.categoryId,
|
||||
customerId: data.customer_id ?? data.customerId,
|
||||
customerGroupId: data.customer_group_id ?? data.customerGroupId,
|
||||
quantity: data.quantity,
|
||||
unitPrice: data.unit_price ?? data.unitPrice ?? 0,
|
||||
totalAmount: data.total_amount ?? data.totalAmount ?? 0,
|
||||
isFirstPurchase: data.is_first_purchase ?? data.isFirstPurchase,
|
||||
};
|
||||
const result = await discountRulesService.applyDiscounts(req.tenantId!, context);
|
||||
res.json({ success: true, data: result });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async resetDiscountRuleUsage(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const discountRule = await discountRulesService.resetUsageCount(req.params.id, req.tenantId!);
|
||||
res.json({ success: true, data: discountRule, message: 'Contador de uso reiniciado exitosamente' });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async getDiscountRulesByProduct(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const discountRules = await discountRulesService.findByProduct(req.params.productId, req.tenantId!);
|
||||
res.json({ success: true, data: discountRules });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async getDiscountRulesByCustomer(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const discountRules = await discountRulesService.findByCustomer(req.params.customerId, req.tenantId!);
|
||||
res.json({ success: true, data: discountRules });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const coreController = new CoreController();
|
||||
|
||||
@ -48,4 +48,47 @@ router.delete('/product-categories/:id', requireRoles('admin', 'super_admin'), (
|
||||
coreController.deleteProductCategory(req, res, next)
|
||||
);
|
||||
|
||||
// ========== PAYMENT TERMS ==========
|
||||
router.get('/payment-terms', (req, res, next) => coreController.getPaymentTerms(req, res, next));
|
||||
router.get('/payment-terms/standard', (req, res, next) => coreController.getStandardPaymentTerms(req, res, next));
|
||||
router.get('/payment-terms/:id', (req, res, next) => coreController.getPaymentTerm(req, res, next));
|
||||
router.post('/payment-terms', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) =>
|
||||
coreController.createPaymentTerm(req, res, next)
|
||||
);
|
||||
router.post('/payment-terms/initialize', requireRoles('admin', 'super_admin'), (req, res, next) =>
|
||||
coreController.initializePaymentTerms(req, res, next)
|
||||
);
|
||||
router.post('/payment-terms/:id/calculate-due-date', (req, res, next) =>
|
||||
coreController.calculateDueDate(req, res, next)
|
||||
);
|
||||
router.put('/payment-terms/:id', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) =>
|
||||
coreController.updatePaymentTerm(req, res, next)
|
||||
);
|
||||
router.delete('/payment-terms/:id', requireRoles('admin', 'super_admin'), (req, res, next) =>
|
||||
coreController.deletePaymentTerm(req, res, next)
|
||||
);
|
||||
|
||||
// ========== DISCOUNT RULES ==========
|
||||
router.get('/discount-rules', (req, res, next) => coreController.getDiscountRules(req, res, next));
|
||||
router.get('/discount-rules/by-product/:productId', (req, res, next) =>
|
||||
coreController.getDiscountRulesByProduct(req, res, next)
|
||||
);
|
||||
router.get('/discount-rules/by-customer/:customerId', (req, res, next) =>
|
||||
coreController.getDiscountRulesByCustomer(req, res, next)
|
||||
);
|
||||
router.get('/discount-rules/:id', (req, res, next) => coreController.getDiscountRule(req, res, next));
|
||||
router.post('/discount-rules', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) =>
|
||||
coreController.createDiscountRule(req, res, next)
|
||||
);
|
||||
router.post('/discount-rules/apply', (req, res, next) => coreController.applyDiscounts(req, res, next));
|
||||
router.put('/discount-rules/:id', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) =>
|
||||
coreController.updateDiscountRule(req, res, next)
|
||||
);
|
||||
router.post('/discount-rules/:id/reset-usage', requireRoles('admin', 'super_admin'), (req, res, next) =>
|
||||
coreController.resetDiscountRuleUsage(req, res, next)
|
||||
);
|
||||
router.delete('/discount-rules/:id', requireRoles('admin', 'super_admin'), (req, res, next) =>
|
||||
coreController.deleteDiscountRule(req, res, next)
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
||||
527
src/modules/core/discount-rules.service.ts
Normal file
527
src/modules/core/discount-rules.service.ts
Normal file
@ -0,0 +1,527 @@
|
||||
import { Repository, LessThanOrEqual, MoreThanOrEqual, IsNull, Or } from 'typeorm';
|
||||
import { AppDataSource } from '../../config/typeorm.js';
|
||||
import {
|
||||
DiscountRule,
|
||||
DiscountType,
|
||||
DiscountAppliesTo,
|
||||
DiscountCondition,
|
||||
} from './entities/discount-rule.entity.js';
|
||||
import { NotFoundError, ValidationError, ConflictError } from '../../shared/errors/index.js';
|
||||
import { logger } from '../../shared/utils/logger.js';
|
||||
|
||||
// ============================================================================
|
||||
// TYPES
|
||||
// ============================================================================
|
||||
|
||||
export interface CreateDiscountRuleDto {
|
||||
code: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
discount_type?: DiscountType | 'percentage' | 'fixed' | 'price_override';
|
||||
discountType?: DiscountType | 'percentage' | 'fixed' | 'price_override';
|
||||
discount_value: number;
|
||||
discountValue?: number;
|
||||
max_discount_amount?: number | null;
|
||||
maxDiscountAmount?: number | null;
|
||||
applies_to?: DiscountAppliesTo | 'all' | 'category' | 'product' | 'customer' | 'customer_group';
|
||||
appliesTo?: DiscountAppliesTo | 'all' | 'category' | 'product' | 'customer' | 'customer_group';
|
||||
applies_to_id?: string | null;
|
||||
appliesToId?: string | null;
|
||||
condition_type?: DiscountCondition | 'none' | 'min_quantity' | 'min_amount' | 'date_range' | 'first_purchase';
|
||||
conditionType?: DiscountCondition | 'none' | 'min_quantity' | 'min_amount' | 'date_range' | 'first_purchase';
|
||||
condition_value?: number | null;
|
||||
conditionValue?: number | null;
|
||||
start_date?: Date | string | null;
|
||||
startDate?: Date | string | null;
|
||||
end_date?: Date | string | null;
|
||||
endDate?: Date | string | null;
|
||||
priority?: number;
|
||||
combinable?: boolean;
|
||||
usage_limit?: number | null;
|
||||
usageLimit?: number | null;
|
||||
}
|
||||
|
||||
export interface UpdateDiscountRuleDto {
|
||||
name?: string;
|
||||
description?: string | null;
|
||||
discount_type?: DiscountType | 'percentage' | 'fixed' | 'price_override';
|
||||
discountType?: DiscountType | 'percentage' | 'fixed' | 'price_override';
|
||||
discount_value?: number;
|
||||
discountValue?: number;
|
||||
max_discount_amount?: number | null;
|
||||
maxDiscountAmount?: number | null;
|
||||
applies_to?: DiscountAppliesTo | 'all' | 'category' | 'product' | 'customer' | 'customer_group';
|
||||
appliesTo?: DiscountAppliesTo | 'all' | 'category' | 'product' | 'customer' | 'customer_group';
|
||||
applies_to_id?: string | null;
|
||||
appliesToId?: string | null;
|
||||
condition_type?: DiscountCondition | 'none' | 'min_quantity' | 'min_amount' | 'date_range' | 'first_purchase';
|
||||
conditionType?: DiscountCondition | 'none' | 'min_quantity' | 'min_amount' | 'date_range' | 'first_purchase';
|
||||
condition_value?: number | null;
|
||||
conditionValue?: number | null;
|
||||
start_date?: Date | string | null;
|
||||
startDate?: Date | string | null;
|
||||
end_date?: Date | string | null;
|
||||
endDate?: Date | string | null;
|
||||
priority?: number;
|
||||
combinable?: boolean;
|
||||
usage_limit?: number | null;
|
||||
usageLimit?: number | null;
|
||||
is_active?: boolean;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export interface ApplyDiscountContext {
|
||||
productId?: string;
|
||||
categoryId?: string;
|
||||
customerId?: string;
|
||||
customerGroupId?: string;
|
||||
quantity: number;
|
||||
unitPrice: number;
|
||||
totalAmount: number;
|
||||
isFirstPurchase?: boolean;
|
||||
}
|
||||
|
||||
export interface DiscountResult {
|
||||
ruleId: string;
|
||||
ruleCode: string;
|
||||
ruleName: string;
|
||||
discountType: DiscountType;
|
||||
discountAmount: number;
|
||||
discountPercent: number;
|
||||
originalAmount: number;
|
||||
finalAmount: number;
|
||||
}
|
||||
|
||||
export interface ApplyDiscountsResult {
|
||||
appliedDiscounts: DiscountResult[];
|
||||
totalDiscount: number;
|
||||
originalAmount: number;
|
||||
finalAmount: number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SERVICE
|
||||
// ============================================================================
|
||||
|
||||
class DiscountRulesService {
|
||||
private repository: Repository<DiscountRule>;
|
||||
|
||||
constructor() {
|
||||
this.repository = AppDataSource.getRepository(DiscountRule);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply applicable discount rules to a context
|
||||
*/
|
||||
async applyDiscounts(
|
||||
tenantId: string,
|
||||
context: ApplyDiscountContext
|
||||
): Promise<ApplyDiscountsResult> {
|
||||
logger.debug('Applying discounts', { tenantId, context });
|
||||
|
||||
const applicableRules = await this.findApplicableRules(tenantId, context);
|
||||
const appliedDiscounts: DiscountResult[] = [];
|
||||
let runningAmount = context.totalAmount;
|
||||
let totalDiscount = 0;
|
||||
|
||||
// Sort by priority (lower = higher priority)
|
||||
const sortedRules = applicableRules.sort((a, b) => a.priority - b.priority);
|
||||
|
||||
for (const rule of sortedRules) {
|
||||
// Check if rule can be combined with already applied discounts
|
||||
if (appliedDiscounts.length > 0 && !rule.combinable) {
|
||||
logger.debug('Skipping non-combinable rule', { ruleCode: rule.code });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if previous discounts are non-combinable
|
||||
const hasNonCombinable = appliedDiscounts.some(
|
||||
(d) => !sortedRules.find((r) => r.id === d.ruleId)?.combinable
|
||||
);
|
||||
if (hasNonCombinable && !rule.combinable) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check usage limit
|
||||
if (rule.usageLimit && rule.usageCount >= rule.usageLimit) {
|
||||
logger.debug('Rule usage limit reached', { ruleCode: rule.code });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Calculate discount
|
||||
const discountResult = this.calculateDiscount(rule, runningAmount, context);
|
||||
|
||||
if (discountResult.discountAmount > 0) {
|
||||
appliedDiscounts.push(discountResult);
|
||||
totalDiscount += discountResult.discountAmount;
|
||||
runningAmount = discountResult.finalAmount;
|
||||
|
||||
// Increment usage count
|
||||
await this.incrementUsageCount(rule.id);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
appliedDiscounts,
|
||||
totalDiscount,
|
||||
originalAmount: context.totalAmount,
|
||||
finalAmount: context.totalAmount - totalDiscount,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate discount for a single rule
|
||||
*/
|
||||
private calculateDiscount(
|
||||
rule: DiscountRule,
|
||||
amount: number,
|
||||
context: ApplyDiscountContext
|
||||
): DiscountResult {
|
||||
let discountAmount = 0;
|
||||
let discountPercent = 0;
|
||||
|
||||
switch (rule.discountType) {
|
||||
case DiscountType.PERCENTAGE:
|
||||
discountPercent = Number(rule.discountValue);
|
||||
discountAmount = (amount * discountPercent) / 100;
|
||||
break;
|
||||
|
||||
case DiscountType.FIXED:
|
||||
discountAmount = Math.min(Number(rule.discountValue), amount);
|
||||
discountPercent = (discountAmount / amount) * 100;
|
||||
break;
|
||||
|
||||
case DiscountType.PRICE_OVERRIDE:
|
||||
const newPrice = Number(rule.discountValue);
|
||||
const totalNewAmount = newPrice * context.quantity;
|
||||
discountAmount = Math.max(0, amount - totalNewAmount);
|
||||
discountPercent = (discountAmount / amount) * 100;
|
||||
break;
|
||||
}
|
||||
|
||||
// Apply max discount cap
|
||||
if (rule.maxDiscountAmount && discountAmount > Number(rule.maxDiscountAmount)) {
|
||||
discountAmount = Number(rule.maxDiscountAmount);
|
||||
discountPercent = (discountAmount / amount) * 100;
|
||||
}
|
||||
|
||||
return {
|
||||
ruleId: rule.id,
|
||||
ruleCode: rule.code,
|
||||
ruleName: rule.name,
|
||||
discountType: rule.discountType,
|
||||
discountAmount: Math.round(discountAmount * 100) / 100,
|
||||
discountPercent: Math.round(discountPercent * 100) / 100,
|
||||
originalAmount: amount,
|
||||
finalAmount: Math.round((amount - discountAmount) * 100) / 100,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all applicable rules for a context
|
||||
*/
|
||||
private async findApplicableRules(
|
||||
tenantId: string,
|
||||
context: ApplyDiscountContext
|
||||
): Promise<DiscountRule[]> {
|
||||
const now = new Date();
|
||||
|
||||
const queryBuilder = this.repository
|
||||
.createQueryBuilder('dr')
|
||||
.where('dr.tenant_id = :tenantId', { tenantId })
|
||||
.andWhere('dr.is_active = :isActive', { isActive: true })
|
||||
.andWhere('(dr.start_date IS NULL OR dr.start_date <= :now)', { now })
|
||||
.andWhere('(dr.end_date IS NULL OR dr.end_date >= :now)', { now });
|
||||
|
||||
const allRules = await queryBuilder.getMany();
|
||||
|
||||
// Filter by applies_to and condition
|
||||
return allRules.filter((rule) => {
|
||||
// Check applies_to
|
||||
if (!this.checkAppliesTo(rule, context)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check condition
|
||||
if (!this.checkCondition(rule, context)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if rule applies to the context
|
||||
*/
|
||||
private checkAppliesTo(rule: DiscountRule, context: ApplyDiscountContext): boolean {
|
||||
switch (rule.appliesTo) {
|
||||
case DiscountAppliesTo.ALL:
|
||||
return true;
|
||||
|
||||
case DiscountAppliesTo.PRODUCT:
|
||||
return rule.appliesToId === context.productId;
|
||||
|
||||
case DiscountAppliesTo.CATEGORY:
|
||||
return rule.appliesToId === context.categoryId;
|
||||
|
||||
case DiscountAppliesTo.CUSTOMER:
|
||||
return rule.appliesToId === context.customerId;
|
||||
|
||||
case DiscountAppliesTo.CUSTOMER_GROUP:
|
||||
return rule.appliesToId === context.customerGroupId;
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if rule condition is met
|
||||
*/
|
||||
private checkCondition(rule: DiscountRule, context: ApplyDiscountContext): boolean {
|
||||
switch (rule.conditionType) {
|
||||
case DiscountCondition.NONE:
|
||||
return true;
|
||||
|
||||
case DiscountCondition.MIN_QUANTITY:
|
||||
return context.quantity >= Number(rule.conditionValue || 0);
|
||||
|
||||
case DiscountCondition.MIN_AMOUNT:
|
||||
return context.totalAmount >= Number(rule.conditionValue || 0);
|
||||
|
||||
case DiscountCondition.DATE_RANGE:
|
||||
// Already handled in query
|
||||
return true;
|
||||
|
||||
case DiscountCondition.FIRST_PURCHASE:
|
||||
return context.isFirstPurchase === true;
|
||||
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment usage count for a rule
|
||||
*/
|
||||
private async incrementUsageCount(ruleId: string): Promise<void> {
|
||||
await this.repository.increment({ id: ruleId }, 'usageCount', 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all discount rules for a tenant
|
||||
*/
|
||||
async findAll(tenantId: string, activeOnly: boolean = false): Promise<DiscountRule[]> {
|
||||
logger.debug('Finding all discount rules', { tenantId, activeOnly });
|
||||
|
||||
const query = this.repository
|
||||
.createQueryBuilder('dr')
|
||||
.where('dr.tenant_id = :tenantId', { tenantId })
|
||||
.orderBy('dr.priority', 'ASC')
|
||||
.addOrderBy('dr.name', 'ASC');
|
||||
|
||||
if (activeOnly) {
|
||||
query.andWhere('dr.is_active = :isActive', { isActive: true });
|
||||
}
|
||||
|
||||
return query.getMany();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific discount rule by ID
|
||||
*/
|
||||
async findById(id: string, tenantId: string): Promise<DiscountRule> {
|
||||
logger.debug('Finding discount rule by id', { id, tenantId });
|
||||
|
||||
const rule = await this.repository.findOne({
|
||||
where: { id, tenantId },
|
||||
});
|
||||
|
||||
if (!rule) {
|
||||
throw new NotFoundError('Regla de descuento no encontrada');
|
||||
}
|
||||
|
||||
return rule;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific discount rule by code
|
||||
*/
|
||||
async findByCode(code: string, tenantId: string): Promise<DiscountRule | null> {
|
||||
logger.debug('Finding discount rule by code', { code, tenantId });
|
||||
|
||||
return this.repository.findOne({
|
||||
where: { code, tenantId },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new discount rule
|
||||
*/
|
||||
async create(
|
||||
dto: CreateDiscountRuleDto,
|
||||
tenantId: string,
|
||||
userId?: string
|
||||
): Promise<DiscountRule> {
|
||||
logger.debug('Creating discount rule', { dto, tenantId });
|
||||
|
||||
// Check for existing
|
||||
const existing = await this.findByCode(dto.code, tenantId);
|
||||
if (existing) {
|
||||
throw new ConflictError(`Ya existe una regla de descuento con código ${dto.code}`);
|
||||
}
|
||||
|
||||
// Normalize inputs
|
||||
const discountTypeRaw = dto.discount_type ?? dto.discountType ?? 'percentage';
|
||||
const discountType = discountTypeRaw as DiscountType;
|
||||
const discountValue = dto.discount_value ?? dto.discountValue;
|
||||
const maxDiscountAmount = dto.max_discount_amount ?? dto.maxDiscountAmount ?? null;
|
||||
const appliesToRaw = dto.applies_to ?? dto.appliesTo ?? 'all';
|
||||
const appliesTo = appliesToRaw as DiscountAppliesTo;
|
||||
const appliesToId = dto.applies_to_id ?? dto.appliesToId ?? null;
|
||||
const conditionTypeRaw = dto.condition_type ?? dto.conditionType ?? 'none';
|
||||
const conditionType = conditionTypeRaw as DiscountCondition;
|
||||
const conditionValue = dto.condition_value ?? dto.conditionValue ?? null;
|
||||
const startDate = dto.start_date ?? dto.startDate ?? null;
|
||||
const endDate = dto.end_date ?? dto.endDate ?? null;
|
||||
const usageLimit = dto.usage_limit ?? dto.usageLimit ?? null;
|
||||
|
||||
if (discountValue === undefined) {
|
||||
throw new ValidationError('discount_value es requerido');
|
||||
}
|
||||
|
||||
const rule = this.repository.create({
|
||||
tenantId,
|
||||
code: dto.code,
|
||||
name: dto.name,
|
||||
description: dto.description || null,
|
||||
discountType,
|
||||
discountValue,
|
||||
maxDiscountAmount,
|
||||
appliesTo,
|
||||
appliesToId,
|
||||
conditionType,
|
||||
conditionValue,
|
||||
startDate: startDate ? new Date(startDate) : null,
|
||||
endDate: endDate ? new Date(endDate) : null,
|
||||
priority: dto.priority ?? 10,
|
||||
combinable: dto.combinable ?? true,
|
||||
usageLimit,
|
||||
createdBy: userId || null,
|
||||
});
|
||||
|
||||
const saved = await this.repository.save(rule);
|
||||
|
||||
logger.info('Discount rule created', { id: saved.id, code: dto.code, tenantId });
|
||||
|
||||
return saved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a discount rule
|
||||
*/
|
||||
async update(
|
||||
id: string,
|
||||
dto: UpdateDiscountRuleDto,
|
||||
tenantId: string,
|
||||
userId?: string
|
||||
): Promise<DiscountRule> {
|
||||
logger.debug('Updating discount rule', { id, dto, tenantId });
|
||||
|
||||
const existing = await this.findById(id, tenantId);
|
||||
|
||||
// Normalize inputs
|
||||
const discountTypeRaw = dto.discount_type ?? dto.discountType;
|
||||
const discountValue = dto.discount_value ?? dto.discountValue;
|
||||
const maxDiscountAmount = dto.max_discount_amount ?? dto.maxDiscountAmount;
|
||||
const appliesToRaw = dto.applies_to ?? dto.appliesTo;
|
||||
const appliesToId = dto.applies_to_id ?? dto.appliesToId;
|
||||
const conditionTypeRaw = dto.condition_type ?? dto.conditionType;
|
||||
const conditionValue = dto.condition_value ?? dto.conditionValue;
|
||||
const startDate = dto.start_date ?? dto.startDate;
|
||||
const endDate = dto.end_date ?? dto.endDate;
|
||||
const usageLimit = dto.usage_limit ?? dto.usageLimit;
|
||||
const isActive = dto.is_active ?? dto.isActive;
|
||||
|
||||
if (dto.name !== undefined) existing.name = dto.name;
|
||||
if (dto.description !== undefined) existing.description = dto.description;
|
||||
if (discountTypeRaw !== undefined) existing.discountType = discountTypeRaw as DiscountType;
|
||||
if (discountValue !== undefined) existing.discountValue = discountValue;
|
||||
if (maxDiscountAmount !== undefined) existing.maxDiscountAmount = maxDiscountAmount;
|
||||
if (appliesToRaw !== undefined) existing.appliesTo = appliesToRaw as DiscountAppliesTo;
|
||||
if (appliesToId !== undefined) existing.appliesToId = appliesToId;
|
||||
if (conditionTypeRaw !== undefined) existing.conditionType = conditionTypeRaw as DiscountCondition;
|
||||
if (conditionValue !== undefined) existing.conditionValue = conditionValue;
|
||||
if (startDate !== undefined) existing.startDate = startDate ? new Date(startDate) : null;
|
||||
if (endDate !== undefined) existing.endDate = endDate ? new Date(endDate) : null;
|
||||
if (dto.priority !== undefined) existing.priority = dto.priority;
|
||||
if (dto.combinable !== undefined) existing.combinable = dto.combinable;
|
||||
if (usageLimit !== undefined) existing.usageLimit = usageLimit;
|
||||
if (isActive !== undefined) existing.isActive = isActive;
|
||||
|
||||
existing.updatedBy = userId || null;
|
||||
|
||||
const updated = await this.repository.save(existing);
|
||||
|
||||
logger.info('Discount rule updated', { id, tenantId });
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft delete a discount rule
|
||||
*/
|
||||
async delete(id: string, tenantId: string, userId?: string): Promise<void> {
|
||||
logger.debug('Deleting discount rule', { id, tenantId });
|
||||
|
||||
const existing = await this.findById(id, tenantId);
|
||||
|
||||
existing.deletedAt = new Date();
|
||||
existing.deletedBy = userId || null;
|
||||
|
||||
await this.repository.save(existing);
|
||||
|
||||
logger.info('Discount rule deleted', { id, tenantId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset usage count for a rule
|
||||
*/
|
||||
async resetUsageCount(id: string, tenantId: string): Promise<DiscountRule> {
|
||||
logger.debug('Resetting usage count', { id, tenantId });
|
||||
|
||||
const rule = await this.findById(id, tenantId);
|
||||
rule.usageCount = 0;
|
||||
|
||||
return this.repository.save(rule);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find rules by product
|
||||
*/
|
||||
async findByProduct(productId: string, tenantId: string): Promise<DiscountRule[]> {
|
||||
return this.repository.find({
|
||||
where: [
|
||||
{ tenantId, appliesTo: DiscountAppliesTo.PRODUCT, appliesToId: productId, isActive: true },
|
||||
{ tenantId, appliesTo: DiscountAppliesTo.ALL, isActive: true },
|
||||
],
|
||||
order: { priority: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find rules by customer
|
||||
*/
|
||||
async findByCustomer(customerId: string, tenantId: string): Promise<DiscountRule[]> {
|
||||
return this.repository.find({
|
||||
where: [
|
||||
{ tenantId, appliesTo: DiscountAppliesTo.CUSTOMER, appliesToId: customerId, isActive: true },
|
||||
{ tenantId, appliesTo: DiscountAppliesTo.ALL, isActive: true },
|
||||
],
|
||||
order: { priority: 'ASC' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const discountRulesService = new DiscountRulesService();
|
||||
163
src/modules/core/entities/discount-rule.entity.ts
Normal file
163
src/modules/core/entities/discount-rule.entity.ts
Normal file
@ -0,0 +1,163 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
DeleteDateColumn,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
|
||||
/**
|
||||
* Tipo de descuento
|
||||
*/
|
||||
export enum DiscountType {
|
||||
PERCENTAGE = 'percentage', // Porcentaje del total
|
||||
FIXED = 'fixed', // Monto fijo
|
||||
PRICE_OVERRIDE = 'price_override', // Precio especial
|
||||
}
|
||||
|
||||
/**
|
||||
* Aplicación del descuento
|
||||
*/
|
||||
export enum DiscountAppliesTo {
|
||||
ALL = 'all', // Todos los productos
|
||||
CATEGORY = 'category', // Categoría específica
|
||||
PRODUCT = 'product', // Producto específico
|
||||
CUSTOMER = 'customer', // Cliente específico
|
||||
CUSTOMER_GROUP = 'customer_group', // Grupo de clientes
|
||||
}
|
||||
|
||||
/**
|
||||
* Condición de activación
|
||||
*/
|
||||
export enum DiscountCondition {
|
||||
NONE = 'none', // Sin condición
|
||||
MIN_QUANTITY = 'min_quantity', // Cantidad mínima
|
||||
MIN_AMOUNT = 'min_amount', // Monto mínimo
|
||||
DATE_RANGE = 'date_range', // Rango de fechas
|
||||
FIRST_PURCHASE = 'first_purchase', // Primera compra
|
||||
}
|
||||
|
||||
/**
|
||||
* Regla de descuento
|
||||
*/
|
||||
@Entity({ schema: 'core', name: 'discount_rules' })
|
||||
@Index('idx_discount_rules_tenant_id', ['tenantId'])
|
||||
@Index('idx_discount_rules_code_tenant', ['tenantId', 'code'], { unique: true })
|
||||
@Index('idx_discount_rules_active', ['tenantId', 'isActive'])
|
||||
@Index('idx_discount_rules_dates', ['tenantId', 'startDate', 'endDate'])
|
||||
@Index('idx_discount_rules_priority', ['tenantId', 'priority'])
|
||||
export class DiscountRule {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ type: 'uuid', nullable: false, name: 'tenant_id' })
|
||||
tenantId: string;
|
||||
|
||||
@Column({ type: 'uuid', nullable: true, name: 'company_id' })
|
||||
companyId: string | null;
|
||||
|
||||
@Column({ type: 'varchar', length: 50, nullable: false })
|
||||
code: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 255, nullable: false })
|
||||
name: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
description: string | null;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: DiscountType,
|
||||
default: DiscountType.PERCENTAGE,
|
||||
name: 'discount_type',
|
||||
})
|
||||
discountType: DiscountType;
|
||||
|
||||
@Column({
|
||||
type: 'decimal',
|
||||
precision: 15,
|
||||
scale: 4,
|
||||
nullable: false,
|
||||
name: 'discount_value',
|
||||
})
|
||||
discountValue: number;
|
||||
|
||||
@Column({
|
||||
type: 'decimal',
|
||||
precision: 15,
|
||||
scale: 2,
|
||||
nullable: true,
|
||||
name: 'max_discount_amount',
|
||||
})
|
||||
maxDiscountAmount: number | null;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: DiscountAppliesTo,
|
||||
default: DiscountAppliesTo.ALL,
|
||||
name: 'applies_to',
|
||||
})
|
||||
appliesTo: DiscountAppliesTo;
|
||||
|
||||
@Column({ type: 'uuid', nullable: true, name: 'applies_to_id' })
|
||||
appliesToId: string | null;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: DiscountCondition,
|
||||
default: DiscountCondition.NONE,
|
||||
name: 'condition_type',
|
||||
})
|
||||
conditionType: DiscountCondition;
|
||||
|
||||
@Column({
|
||||
type: 'decimal',
|
||||
precision: 15,
|
||||
scale: 4,
|
||||
nullable: true,
|
||||
name: 'condition_value',
|
||||
})
|
||||
conditionValue: number | null;
|
||||
|
||||
@Column({ type: 'timestamp', nullable: true, name: 'start_date' })
|
||||
startDate: Date | null;
|
||||
|
||||
@Column({ type: 'timestamp', nullable: true, name: 'end_date' })
|
||||
endDate: Date | null;
|
||||
|
||||
@Column({ type: 'integer', nullable: false, default: 10 })
|
||||
priority: number;
|
||||
|
||||
@Column({ type: 'boolean', nullable: false, default: true, name: 'combinable' })
|
||||
combinable: boolean;
|
||||
|
||||
@Column({ type: 'integer', nullable: true, name: 'usage_limit' })
|
||||
usageLimit: number | null;
|
||||
|
||||
@Column({ type: 'integer', nullable: false, default: 0, name: 'usage_count' })
|
||||
usageCount: number;
|
||||
|
||||
@Column({ type: 'boolean', nullable: false, default: true, name: 'is_active' })
|
||||
isActive: boolean;
|
||||
|
||||
// Audit fields
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
|
||||
createdAt: Date;
|
||||
|
||||
@Column({ type: 'uuid', nullable: true, name: 'created_by' })
|
||||
createdBy: string | null;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at', type: 'timestamp', nullable: true })
|
||||
updatedAt: Date | null;
|
||||
|
||||
@Column({ type: 'uuid', nullable: true, name: 'updated_by' })
|
||||
updatedBy: string | null;
|
||||
|
||||
@DeleteDateColumn({ name: 'deleted_at', type: 'timestamptz', nullable: true })
|
||||
deletedAt: Date | null;
|
||||
|
||||
@Column({ type: 'uuid', nullable: true, name: 'deleted_by' })
|
||||
deletedBy: string | null;
|
||||
}
|
||||
@ -4,3 +4,5 @@ export { UomCategory } from './uom-category.entity.js';
|
||||
export { Uom, UomType } from './uom.entity.js';
|
||||
export { ProductCategory } from './product-category.entity.js';
|
||||
export { Sequence, ResetPeriod } from './sequence.entity.js';
|
||||
export { PaymentTerm, PaymentTermLine, PaymentTermLineType } from './payment-term.entity.js';
|
||||
export { DiscountRule, DiscountType, DiscountAppliesTo, DiscountCondition } from './discount-rule.entity.js';
|
||||
|
||||
144
src/modules/core/entities/payment-term.entity.ts
Normal file
144
src/modules/core/entities/payment-term.entity.ts
Normal file
@ -0,0 +1,144 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
DeleteDateColumn,
|
||||
Index,
|
||||
OneToMany,
|
||||
} from 'typeorm';
|
||||
|
||||
/**
|
||||
* Tipo de cálculo para la línea del término de pago
|
||||
*/
|
||||
export enum PaymentTermLineType {
|
||||
BALANCE = 'balance', // Saldo restante
|
||||
PERCENT = 'percent', // Porcentaje del total
|
||||
FIXED = 'fixed', // Monto fijo
|
||||
}
|
||||
|
||||
/**
|
||||
* Línea de término de pago (para términos con múltiples vencimientos)
|
||||
*/
|
||||
@Entity({ schema: 'core', name: 'payment_term_lines' })
|
||||
@Index('idx_payment_term_lines_term', ['paymentTermId'])
|
||||
export class PaymentTermLine {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ type: 'uuid', nullable: false, name: 'payment_term_id' })
|
||||
paymentTermId: string;
|
||||
|
||||
@Column({ type: 'integer', nullable: false, default: 1 })
|
||||
sequence: number;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: PaymentTermLineType,
|
||||
default: PaymentTermLineType.BALANCE,
|
||||
name: 'line_type',
|
||||
})
|
||||
lineType: PaymentTermLineType;
|
||||
|
||||
@Column({
|
||||
type: 'decimal',
|
||||
precision: 5,
|
||||
scale: 2,
|
||||
nullable: true,
|
||||
name: 'value_percent',
|
||||
})
|
||||
valuePercent: number | null;
|
||||
|
||||
@Column({
|
||||
type: 'decimal',
|
||||
precision: 15,
|
||||
scale: 2,
|
||||
nullable: true,
|
||||
name: 'value_amount',
|
||||
})
|
||||
valueAmount: number | null;
|
||||
|
||||
@Column({ type: 'integer', nullable: false, default: 0 })
|
||||
days: number;
|
||||
|
||||
@Column({ type: 'integer', nullable: true, name: 'day_of_month' })
|
||||
dayOfMonth: number | null;
|
||||
|
||||
@Column({ type: 'boolean', nullable: false, default: false, name: 'end_of_month' })
|
||||
endOfMonth: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Término de pago (Net 30, 50% advance + 50% on delivery, etc.)
|
||||
*/
|
||||
@Entity({ schema: 'core', name: 'payment_terms' })
|
||||
@Index('idx_payment_terms_tenant_id', ['tenantId'])
|
||||
@Index('idx_payment_terms_code_tenant', ['tenantId', 'code'], { unique: true })
|
||||
@Index('idx_payment_terms_active', ['tenantId', 'isActive'])
|
||||
export class PaymentTerm {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ type: 'uuid', nullable: false, name: 'tenant_id' })
|
||||
tenantId: string;
|
||||
|
||||
@Column({ type: 'uuid', nullable: true, name: 'company_id' })
|
||||
companyId: string | null;
|
||||
|
||||
@Column({ type: 'varchar', length: 50, nullable: false })
|
||||
code: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 255, nullable: false })
|
||||
name: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
description: string | null;
|
||||
|
||||
@Column({ type: 'integer', nullable: false, default: 0, name: 'due_days' })
|
||||
dueDays: number;
|
||||
|
||||
@Column({
|
||||
type: 'decimal',
|
||||
precision: 5,
|
||||
scale: 2,
|
||||
nullable: true,
|
||||
default: 0,
|
||||
name: 'discount_percent',
|
||||
})
|
||||
discountPercent: number | null;
|
||||
|
||||
@Column({ type: 'integer', nullable: true, default: 0, name: 'discount_days' })
|
||||
discountDays: number | null;
|
||||
|
||||
@Column({ type: 'boolean', nullable: false, default: false, name: 'is_immediate' })
|
||||
isImmediate: boolean;
|
||||
|
||||
@Column({ type: 'boolean', nullable: false, default: true, name: 'is_active' })
|
||||
isActive: boolean;
|
||||
|
||||
@Column({ type: 'integer', nullable: false, default: 0 })
|
||||
sequence: number;
|
||||
|
||||
@OneToMany(() => PaymentTermLine, (line) => line.paymentTermId, { eager: true })
|
||||
lines: PaymentTermLine[];
|
||||
|
||||
// Audit fields
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
|
||||
createdAt: Date;
|
||||
|
||||
@Column({ type: 'uuid', nullable: true, name: 'created_by' })
|
||||
createdBy: string | null;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at', type: 'timestamp', nullable: true })
|
||||
updatedAt: Date | null;
|
||||
|
||||
@Column({ type: 'uuid', nullable: true, name: 'updated_by' })
|
||||
updatedBy: string | null;
|
||||
|
||||
@DeleteDateColumn({ name: 'deleted_at', type: 'timestamptz', nullable: true })
|
||||
deletedAt: Date | null;
|
||||
|
||||
@Column({ type: 'uuid', nullable: true, name: 'deleted_by' })
|
||||
deletedBy: string | null;
|
||||
}
|
||||
@ -3,6 +3,8 @@ export * from './countries.service.js';
|
||||
export * from './uom.service.js';
|
||||
export * from './product-categories.service.js';
|
||||
export * from './sequences.service.js';
|
||||
export * from './payment-terms.service.js';
|
||||
export * from './discount-rules.service.js';
|
||||
export * from './entities/index.js';
|
||||
export * from './core.controller.js';
|
||||
export { default as coreRoutes } from './core.routes.js';
|
||||
|
||||
461
src/modules/core/payment-terms.service.ts
Normal file
461
src/modules/core/payment-terms.service.ts
Normal file
@ -0,0 +1,461 @@
|
||||
import { Repository } from 'typeorm';
|
||||
import { AppDataSource } from '../../config/typeorm.js';
|
||||
import {
|
||||
PaymentTerm,
|
||||
PaymentTermLine,
|
||||
PaymentTermLineType,
|
||||
} from './entities/payment-term.entity.js';
|
||||
import { NotFoundError, ValidationError, ConflictError } from '../../shared/errors/index.js';
|
||||
import { logger } from '../../shared/utils/logger.js';
|
||||
|
||||
// ============================================================================
|
||||
// TYPES
|
||||
// ============================================================================
|
||||
|
||||
export interface CreatePaymentTermLineDto {
|
||||
sequence?: number;
|
||||
line_type?: PaymentTermLineType | 'balance' | 'percent' | 'fixed';
|
||||
lineType?: PaymentTermLineType | 'balance' | 'percent' | 'fixed';
|
||||
value_percent?: number;
|
||||
valuePercent?: number;
|
||||
value_amount?: number;
|
||||
valueAmount?: number;
|
||||
days?: number;
|
||||
day_of_month?: number;
|
||||
dayOfMonth?: number;
|
||||
end_of_month?: boolean;
|
||||
endOfMonth?: boolean;
|
||||
}
|
||||
|
||||
export interface CreatePaymentTermDto {
|
||||
code: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
due_days?: number;
|
||||
dueDays?: number;
|
||||
discount_percent?: number;
|
||||
discountPercent?: number;
|
||||
discount_days?: number;
|
||||
discountDays?: number;
|
||||
is_immediate?: boolean;
|
||||
isImmediate?: boolean;
|
||||
lines?: CreatePaymentTermLineDto[];
|
||||
}
|
||||
|
||||
export interface UpdatePaymentTermDto {
|
||||
name?: string;
|
||||
description?: string | null;
|
||||
due_days?: number;
|
||||
dueDays?: number;
|
||||
discount_percent?: number | null;
|
||||
discountPercent?: number | null;
|
||||
discount_days?: number | null;
|
||||
discountDays?: number | null;
|
||||
is_immediate?: boolean;
|
||||
isImmediate?: boolean;
|
||||
is_active?: boolean;
|
||||
isActive?: boolean;
|
||||
lines?: CreatePaymentTermLineDto[];
|
||||
}
|
||||
|
||||
export interface DueDateResult {
|
||||
dueDate: Date;
|
||||
discountDate: Date | null;
|
||||
discountAmount: number;
|
||||
lines: Array<{
|
||||
dueDate: Date;
|
||||
amount: number;
|
||||
percent: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SERVICE
|
||||
// ============================================================================
|
||||
|
||||
class PaymentTermsService {
|
||||
private repository: Repository<PaymentTerm>;
|
||||
private lineRepository: Repository<PaymentTermLine>;
|
||||
|
||||
constructor() {
|
||||
this.repository = AppDataSource.getRepository(PaymentTerm);
|
||||
this.lineRepository = AppDataSource.getRepository(PaymentTermLine);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate due date(s) based on payment term
|
||||
*/
|
||||
calculateDueDate(
|
||||
paymentTerm: PaymentTerm,
|
||||
invoiceDate: Date,
|
||||
totalAmount: number
|
||||
): DueDateResult {
|
||||
logger.debug('Calculating due date', {
|
||||
termCode: paymentTerm.code,
|
||||
invoiceDate,
|
||||
totalAmount,
|
||||
});
|
||||
|
||||
const baseDate = new Date(invoiceDate);
|
||||
const lines: DueDateResult['lines'] = [];
|
||||
|
||||
// If immediate payment
|
||||
if (paymentTerm.isImmediate) {
|
||||
return {
|
||||
dueDate: baseDate,
|
||||
discountDate: null,
|
||||
discountAmount: 0,
|
||||
lines: [{ dueDate: baseDate, amount: totalAmount, percent: 100 }],
|
||||
};
|
||||
}
|
||||
|
||||
// If payment term has lines, use them
|
||||
if (paymentTerm.lines && paymentTerm.lines.length > 0) {
|
||||
let remainingAmount = totalAmount;
|
||||
let lastDueDate = baseDate;
|
||||
|
||||
for (const line of paymentTerm.lines.sort((a, b) => a.sequence - b.sequence)) {
|
||||
let lineAmount = 0;
|
||||
let linePercent = 0;
|
||||
|
||||
if (line.lineType === PaymentTermLineType.BALANCE) {
|
||||
lineAmount = remainingAmount;
|
||||
linePercent = (lineAmount / totalAmount) * 100;
|
||||
} else if (line.lineType === PaymentTermLineType.PERCENT && line.valuePercent) {
|
||||
linePercent = Number(line.valuePercent);
|
||||
lineAmount = (totalAmount * linePercent) / 100;
|
||||
} else if (line.lineType === PaymentTermLineType.FIXED && line.valueAmount) {
|
||||
lineAmount = Math.min(Number(line.valueAmount), remainingAmount);
|
||||
linePercent = (lineAmount / totalAmount) * 100;
|
||||
}
|
||||
|
||||
const lineDueDate = this.calculateLineDueDate(baseDate, line);
|
||||
lastDueDate = lineDueDate;
|
||||
|
||||
lines.push({
|
||||
dueDate: lineDueDate,
|
||||
amount: lineAmount,
|
||||
percent: linePercent,
|
||||
});
|
||||
|
||||
remainingAmount -= lineAmount;
|
||||
}
|
||||
|
||||
// Calculate discount date if applicable
|
||||
let discountDate: Date | null = null;
|
||||
let discountAmount = 0;
|
||||
|
||||
if (paymentTerm.discountPercent && paymentTerm.discountDays) {
|
||||
discountDate = new Date(baseDate);
|
||||
discountDate.setDate(discountDate.getDate() + paymentTerm.discountDays);
|
||||
discountAmount = (totalAmount * Number(paymentTerm.discountPercent)) / 100;
|
||||
}
|
||||
|
||||
return {
|
||||
dueDate: lastDueDate,
|
||||
discountDate,
|
||||
discountAmount,
|
||||
lines,
|
||||
};
|
||||
}
|
||||
|
||||
// Simple due days calculation
|
||||
const dueDate = new Date(baseDate);
|
||||
dueDate.setDate(dueDate.getDate() + paymentTerm.dueDays);
|
||||
|
||||
let discountDate: Date | null = null;
|
||||
let discountAmount = 0;
|
||||
|
||||
if (paymentTerm.discountPercent && paymentTerm.discountDays) {
|
||||
discountDate = new Date(baseDate);
|
||||
discountDate.setDate(discountDate.getDate() + paymentTerm.discountDays);
|
||||
discountAmount = (totalAmount * Number(paymentTerm.discountPercent)) / 100;
|
||||
}
|
||||
|
||||
return {
|
||||
dueDate,
|
||||
discountDate,
|
||||
discountAmount,
|
||||
lines: [{ dueDate, amount: totalAmount, percent: 100 }],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate due date for a specific line
|
||||
*/
|
||||
private calculateLineDueDate(baseDate: Date, line: PaymentTermLine): Date {
|
||||
const result = new Date(baseDate);
|
||||
result.setDate(result.getDate() + line.days);
|
||||
|
||||
// If specific day of month
|
||||
if (line.dayOfMonth) {
|
||||
result.setDate(line.dayOfMonth);
|
||||
// If the calculated date is before base + days, move to next month
|
||||
const minDate = new Date(baseDate);
|
||||
minDate.setDate(minDate.getDate() + line.days);
|
||||
if (result < minDate) {
|
||||
result.setMonth(result.getMonth() + 1);
|
||||
}
|
||||
}
|
||||
|
||||
// If end of month
|
||||
if (line.endOfMonth) {
|
||||
result.setMonth(result.getMonth() + 1);
|
||||
result.setDate(0); // Last day of previous month
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all payment terms for a tenant
|
||||
*/
|
||||
async findAll(tenantId: string, activeOnly: boolean = false): Promise<PaymentTerm[]> {
|
||||
logger.debug('Finding all payment terms', { tenantId, activeOnly });
|
||||
|
||||
const query = this.repository
|
||||
.createQueryBuilder('pt')
|
||||
.leftJoinAndSelect('pt.lines', 'lines')
|
||||
.where('pt.tenant_id = :tenantId', { tenantId })
|
||||
.orderBy('pt.sequence', 'ASC')
|
||||
.addOrderBy('pt.name', 'ASC');
|
||||
|
||||
if (activeOnly) {
|
||||
query.andWhere('pt.is_active = :isActive', { isActive: true });
|
||||
}
|
||||
|
||||
return query.getMany();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific payment term by ID
|
||||
*/
|
||||
async findById(id: string, tenantId: string): Promise<PaymentTerm> {
|
||||
logger.debug('Finding payment term by id', { id, tenantId });
|
||||
|
||||
const paymentTerm = await this.repository.findOne({
|
||||
where: { id, tenantId },
|
||||
relations: ['lines'],
|
||||
});
|
||||
|
||||
if (!paymentTerm) {
|
||||
throw new NotFoundError('Término de pago no encontrado');
|
||||
}
|
||||
|
||||
return paymentTerm;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific payment term by code
|
||||
*/
|
||||
async findByCode(code: string, tenantId: string): Promise<PaymentTerm | null> {
|
||||
logger.debug('Finding payment term by code', { code, tenantId });
|
||||
|
||||
return this.repository.findOne({
|
||||
where: { code, tenantId },
|
||||
relations: ['lines'],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new payment term
|
||||
*/
|
||||
async create(
|
||||
dto: CreatePaymentTermDto,
|
||||
tenantId: string,
|
||||
userId?: string
|
||||
): Promise<PaymentTerm> {
|
||||
logger.debug('Creating payment term', { dto, tenantId });
|
||||
|
||||
// Check for existing
|
||||
const existing = await this.findByCode(dto.code, tenantId);
|
||||
if (existing) {
|
||||
throw new ConflictError(`Ya existe un término de pago con código ${dto.code}`);
|
||||
}
|
||||
|
||||
// Normalize inputs (accept both snake_case and camelCase)
|
||||
const dueDays = dto.due_days ?? dto.dueDays ?? 0;
|
||||
const discountPercent = dto.discount_percent ?? dto.discountPercent ?? null;
|
||||
const discountDays = dto.discount_days ?? dto.discountDays ?? null;
|
||||
const isImmediate = dto.is_immediate ?? dto.isImmediate ?? false;
|
||||
|
||||
const paymentTerm = this.repository.create({
|
||||
tenantId,
|
||||
code: dto.code,
|
||||
name: dto.name,
|
||||
description: dto.description || null,
|
||||
dueDays,
|
||||
discountPercent,
|
||||
discountDays,
|
||||
isImmediate,
|
||||
createdBy: userId || null,
|
||||
});
|
||||
|
||||
const saved = await this.repository.save(paymentTerm);
|
||||
|
||||
// Create lines if provided
|
||||
if (dto.lines && dto.lines.length > 0) {
|
||||
await this.createLines(saved.id, dto.lines);
|
||||
// Reload with lines
|
||||
return this.findById(saved.id, tenantId);
|
||||
}
|
||||
|
||||
logger.info('Payment term created', { id: saved.id, code: dto.code, tenantId });
|
||||
|
||||
return saved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create payment term lines
|
||||
*/
|
||||
private async createLines(
|
||||
paymentTermId: string,
|
||||
lines: CreatePaymentTermLineDto[]
|
||||
): Promise<void> {
|
||||
for (let index = 0; index < lines.length; index++) {
|
||||
const line = lines[index];
|
||||
const lineTypeRaw = line.line_type ?? line.lineType ?? 'balance';
|
||||
const lineType = lineTypeRaw as PaymentTermLineType;
|
||||
const valuePercent = line.value_percent ?? line.valuePercent ?? null;
|
||||
const valueAmount = line.value_amount ?? line.valueAmount ?? null;
|
||||
const dayOfMonth = line.day_of_month ?? line.dayOfMonth ?? null;
|
||||
const endOfMonth = line.end_of_month ?? line.endOfMonth ?? false;
|
||||
|
||||
const lineEntity = this.lineRepository.create({
|
||||
paymentTermId,
|
||||
sequence: line.sequence ?? index + 1,
|
||||
lineType,
|
||||
valuePercent,
|
||||
valueAmount,
|
||||
days: line.days ?? 0,
|
||||
dayOfMonth,
|
||||
endOfMonth,
|
||||
});
|
||||
await this.lineRepository.save(lineEntity);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a payment term
|
||||
*/
|
||||
async update(
|
||||
id: string,
|
||||
dto: UpdatePaymentTermDto,
|
||||
tenantId: string,
|
||||
userId?: string
|
||||
): Promise<PaymentTerm> {
|
||||
logger.debug('Updating payment term', { id, dto, tenantId });
|
||||
|
||||
const existing = await this.findById(id, tenantId);
|
||||
|
||||
// Normalize inputs
|
||||
const dueDays = dto.due_days ?? dto.dueDays;
|
||||
const discountPercent = dto.discount_percent ?? dto.discountPercent;
|
||||
const discountDays = dto.discount_days ?? dto.discountDays;
|
||||
const isImmediate = dto.is_immediate ?? dto.isImmediate;
|
||||
const isActive = dto.is_active ?? dto.isActive;
|
||||
|
||||
if (dto.name !== undefined) {
|
||||
existing.name = dto.name;
|
||||
}
|
||||
if (dto.description !== undefined) {
|
||||
existing.description = dto.description;
|
||||
}
|
||||
if (dueDays !== undefined) {
|
||||
existing.dueDays = dueDays;
|
||||
}
|
||||
if (discountPercent !== undefined) {
|
||||
existing.discountPercent = discountPercent;
|
||||
}
|
||||
if (discountDays !== undefined) {
|
||||
existing.discountDays = discountDays;
|
||||
}
|
||||
if (isImmediate !== undefined) {
|
||||
existing.isImmediate = isImmediate;
|
||||
}
|
||||
if (isActive !== undefined) {
|
||||
existing.isActive = isActive;
|
||||
}
|
||||
|
||||
existing.updatedBy = userId || null;
|
||||
|
||||
const updated = await this.repository.save(existing);
|
||||
|
||||
// Update lines if provided
|
||||
if (dto.lines !== undefined) {
|
||||
// Remove existing lines
|
||||
await this.lineRepository.delete({ paymentTermId: id });
|
||||
// Create new lines
|
||||
if (dto.lines.length > 0) {
|
||||
await this.createLines(id, dto.lines);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('Payment term updated', { id, tenantId });
|
||||
|
||||
return this.findById(id, tenantId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft delete a payment term
|
||||
*/
|
||||
async delete(id: string, tenantId: string, userId?: string): Promise<void> {
|
||||
logger.debug('Deleting payment term', { id, tenantId });
|
||||
|
||||
const existing = await this.findById(id, tenantId);
|
||||
|
||||
existing.deletedAt = new Date();
|
||||
existing.deletedBy = userId || null;
|
||||
|
||||
await this.repository.save(existing);
|
||||
|
||||
logger.info('Payment term deleted', { id, tenantId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get common/standard payment terms
|
||||
*/
|
||||
getStandardTerms(): Array<{ code: string; name: string; dueDays: number; discountPercent?: number; discountDays?: number }> {
|
||||
return [
|
||||
{ code: 'IMMEDIATE', name: 'Pago Inmediato', dueDays: 0 },
|
||||
{ code: 'NET15', name: 'Neto 15 días', dueDays: 15 },
|
||||
{ code: 'NET30', name: 'Neto 30 días', dueDays: 30 },
|
||||
{ code: 'NET45', name: 'Neto 45 días', dueDays: 45 },
|
||||
{ code: 'NET60', name: 'Neto 60 días', dueDays: 60 },
|
||||
{ code: 'NET90', name: 'Neto 90 días', dueDays: 90 },
|
||||
{ code: '2/10NET30', name: '2% 10 días, Neto 30', dueDays: 30, discountPercent: 2, discountDays: 10 },
|
||||
{ code: '1/10NET30', name: '1% 10 días, Neto 30', dueDays: 30, discountPercent: 1, discountDays: 10 },
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize standard payment terms for a tenant
|
||||
*/
|
||||
async initializeForTenant(tenantId: string, userId?: string): Promise<void> {
|
||||
logger.debug('Initializing payment terms for tenant', { tenantId });
|
||||
|
||||
const standardTerms = this.getStandardTerms();
|
||||
|
||||
for (const term of standardTerms) {
|
||||
const existing = await this.findByCode(term.code, tenantId);
|
||||
if (!existing) {
|
||||
await this.create(
|
||||
{
|
||||
code: term.code,
|
||||
name: term.name,
|
||||
dueDays: term.dueDays,
|
||||
discountPercent: term.discountPercent,
|
||||
discountDays: term.discountDays,
|
||||
isImmediate: term.dueDays === 0,
|
||||
},
|
||||
tenantId,
|
||||
userId
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('Payment terms initialized for tenant', { tenantId, count: standardTerms.length });
|
||||
}
|
||||
}
|
||||
|
||||
export const paymentTermsService = new PaymentTermsService();
|
||||
@ -1,25 +1,26 @@
|
||||
// Core Inventory Entities
|
||||
export { Product } from './product.entity';
|
||||
export { Warehouse } from './warehouse.entity';
|
||||
export { Location } from './location.entity';
|
||||
export { StockQuant } from './stock-quant.entity';
|
||||
export { Lot } from './lot.entity';
|
||||
export { Product } from './product.entity.js';
|
||||
// Re-export Warehouse from canonical location in warehouses module
|
||||
export { Warehouse } from '../../warehouses/entities/warehouse.entity.js';
|
||||
export { Location } from './location.entity.js';
|
||||
export { StockQuant } from './stock-quant.entity.js';
|
||||
export { Lot } from './lot.entity.js';
|
||||
|
||||
// Stock Operations
|
||||
export { Picking } from './picking.entity';
|
||||
export { StockMove } from './stock-move.entity';
|
||||
export { StockLevel } from './stock-level.entity';
|
||||
export { StockMovement } from './stock-movement.entity';
|
||||
export { Picking } from './picking.entity.js';
|
||||
export { StockMove } from './stock-move.entity.js';
|
||||
export { StockLevel } from './stock-level.entity.js';
|
||||
export { StockMovement } from './stock-movement.entity.js';
|
||||
|
||||
// Inventory Management
|
||||
export { InventoryCount } from './inventory-count.entity';
|
||||
export { InventoryCountLine } from './inventory-count-line.entity';
|
||||
export { InventoryAdjustment } from './inventory-adjustment.entity';
|
||||
export { InventoryAdjustmentLine } from './inventory-adjustment-line.entity';
|
||||
export { InventoryCount } from './inventory-count.entity.js';
|
||||
export { InventoryCountLine } from './inventory-count-line.entity.js';
|
||||
export { InventoryAdjustment } from './inventory-adjustment.entity.js';
|
||||
export { InventoryAdjustmentLine } from './inventory-adjustment-line.entity.js';
|
||||
|
||||
// Transfers
|
||||
export { TransferOrder } from './transfer-order.entity';
|
||||
export { TransferOrderLine } from './transfer-order-line.entity';
|
||||
export { TransferOrder } from './transfer-order.entity.js';
|
||||
export { TransferOrderLine } from './transfer-order-line.entity.js';
|
||||
|
||||
// Valuation
|
||||
export { StockValuationLayer } from './stock-valuation-layer.entity';
|
||||
export { StockValuationLayer } from './stock-valuation-layer.entity.js';
|
||||
|
||||
@ -9,7 +9,7 @@ import {
|
||||
OneToMany,
|
||||
JoinColumn,
|
||||
} from 'typeorm';
|
||||
import { Warehouse } from './warehouse.entity.js';
|
||||
import { Warehouse } from '../../warehouses/entities/warehouse.entity.js';
|
||||
import { StockQuant } from './stock-quant.entity.js';
|
||||
|
||||
export enum LocationType {
|
||||
|
||||
@ -1,79 +0,0 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
Index,
|
||||
ManyToOne,
|
||||
OneToMany,
|
||||
JoinColumn,
|
||||
} from 'typeorm';
|
||||
import { Company } from '../../auth/entities/company.entity.js';
|
||||
import { Location } from './location.entity.js';
|
||||
|
||||
/**
|
||||
* @deprecated This entity is duplicated with warehouses/entities/warehouse.entity.ts
|
||||
* Both map to inventory.warehouses table but have different column definitions.
|
||||
*
|
||||
* PLANNED UNIFICATION:
|
||||
* - The warehouses module version has more comprehensive fields (address, capacity, settings)
|
||||
* - This version has Company and Location relations
|
||||
* - Future: Merge into single entity in warehouses module with all fields and relations
|
||||
*
|
||||
* For new code, prefer using: import { Warehouse } from '@modules/warehouses/entities'
|
||||
*/
|
||||
@Entity({ schema: 'inventory', name: 'warehouses' })
|
||||
@Index('idx_warehouses_tenant_id', ['tenantId'])
|
||||
@Index('idx_warehouses_company_id', ['companyId'])
|
||||
@Index('idx_warehouses_code_company', ['companyId', 'code'], { unique: true })
|
||||
export class Warehouse {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ type: 'uuid', nullable: false, name: 'tenant_id' })
|
||||
tenantId: string;
|
||||
|
||||
@Column({ type: 'uuid', nullable: false, name: 'company_id' })
|
||||
companyId: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 255, nullable: false })
|
||||
name: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 20, nullable: false })
|
||||
code: string;
|
||||
|
||||
@Column({ type: 'uuid', nullable: true, name: 'address_id' })
|
||||
addressId: string | null;
|
||||
|
||||
@Column({ type: 'boolean', default: false, nullable: false, name: 'is_default' })
|
||||
isDefault: boolean;
|
||||
|
||||
@Column({ type: 'boolean', default: true, nullable: false })
|
||||
active: boolean;
|
||||
|
||||
// Relations
|
||||
@ManyToOne(() => Company)
|
||||
@JoinColumn({ name: 'company_id' })
|
||||
company: Company;
|
||||
|
||||
@OneToMany(() => Location, (location) => location.warehouse)
|
||||
locations: Location[];
|
||||
|
||||
// Auditoría
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
|
||||
createdAt: Date;
|
||||
|
||||
@Column({ type: 'uuid', nullable: true, name: 'created_by' })
|
||||
createdBy: string | null;
|
||||
|
||||
@UpdateDateColumn({
|
||||
name: 'updated_at',
|
||||
type: 'timestamp',
|
||||
nullable: true,
|
||||
})
|
||||
updatedAt: Date | null;
|
||||
|
||||
@Column({ type: 'uuid', nullable: true, name: 'updated_by' })
|
||||
updatedBy: string | null;
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
import { Repository, IsNull } from 'typeorm';
|
||||
import { AppDataSource } from '../../config/typeorm.js';
|
||||
import { Warehouse } from './entities/warehouse.entity.js';
|
||||
import { Warehouse } from '../warehouses/entities/warehouse.entity.js';
|
||||
import { Location } from './entities/location.entity.js';
|
||||
import { StockQuant } from './entities/stock-quant.entity.js';
|
||||
import { NotFoundError, ValidationError, ConflictError } from '../../shared/types/index.js';
|
||||
@ -9,23 +9,31 @@ import { logger } from '../../shared/utils/logger.js';
|
||||
// ===== Interfaces =====
|
||||
|
||||
export interface CreateWarehouseDto {
|
||||
companyId: string;
|
||||
companyId?: string;
|
||||
name: string;
|
||||
code: string;
|
||||
addressId?: string;
|
||||
description?: string;
|
||||
addressLine1?: string;
|
||||
city?: string;
|
||||
state?: string;
|
||||
postalCode?: string;
|
||||
isDefault?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateWarehouseDto {
|
||||
name?: string;
|
||||
addressId?: string | null;
|
||||
description?: string;
|
||||
addressLine1?: string;
|
||||
city?: string;
|
||||
state?: string;
|
||||
postalCode?: string;
|
||||
isDefault?: boolean;
|
||||
active?: boolean;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export interface WarehouseFilters {
|
||||
companyId?: string;
|
||||
active?: boolean;
|
||||
isActive?: boolean;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}
|
||||
@ -52,7 +60,7 @@ class WarehousesService {
|
||||
filters: WarehouseFilters = {}
|
||||
): Promise<{ data: WarehouseWithRelations[]; total: number }> {
|
||||
try {
|
||||
const { companyId, active, page = 1, limit = 50 } = filters;
|
||||
const { companyId, isActive, page = 1, limit = 50 } = filters;
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const queryBuilder = this.warehouseRepository
|
||||
@ -64,8 +72,8 @@ class WarehousesService {
|
||||
queryBuilder.andWhere('warehouse.companyId = :companyId', { companyId });
|
||||
}
|
||||
|
||||
if (active !== undefined) {
|
||||
queryBuilder.andWhere('warehouse.active = :active', { active });
|
||||
if (isActive !== undefined) {
|
||||
queryBuilder.andWhere('warehouse.isActive = :isActive', { isActive });
|
||||
}
|
||||
|
||||
const total = await queryBuilder.getCount();
|
||||
@ -142,15 +150,20 @@ class WarehousesService {
|
||||
);
|
||||
}
|
||||
|
||||
const warehouse = this.warehouseRepository.create({
|
||||
const warehouseData: Partial<Warehouse> = {
|
||||
tenantId,
|
||||
companyId: dto.companyId,
|
||||
name: dto.name,
|
||||
code: dto.code,
|
||||
addressId: dto.addressId || null,
|
||||
description: dto.description,
|
||||
addressLine1: dto.addressLine1,
|
||||
city: dto.city,
|
||||
state: dto.state,
|
||||
postalCode: dto.postalCode,
|
||||
isDefault: dto.isDefault || false,
|
||||
createdBy: userId,
|
||||
});
|
||||
};
|
||||
const warehouse = this.warehouseRepository.create(warehouseData as Warehouse);
|
||||
|
||||
await this.warehouseRepository.save(warehouse);
|
||||
|
||||
@ -189,12 +202,15 @@ class WarehousesService {
|
||||
}
|
||||
|
||||
if (dto.name !== undefined) existing.name = dto.name;
|
||||
if (dto.addressId !== undefined) existing.addressId = dto.addressId;
|
||||
if (dto.description !== undefined) existing.description = dto.description;
|
||||
if (dto.addressLine1 !== undefined) existing.addressLine1 = dto.addressLine1;
|
||||
if (dto.city !== undefined) existing.city = dto.city;
|
||||
if (dto.state !== undefined) existing.state = dto.state;
|
||||
if (dto.postalCode !== undefined) existing.postalCode = dto.postalCode;
|
||||
if (dto.isDefault !== undefined) existing.isDefault = dto.isDefault;
|
||||
if (dto.active !== undefined) existing.active = dto.active;
|
||||
if (dto.isActive !== undefined) existing.isActive = dto.isActive;
|
||||
|
||||
existing.updatedBy = userId;
|
||||
existing.updatedAt = new Date();
|
||||
|
||||
await this.warehouseRepository.save(existing);
|
||||
|
||||
|
||||
@ -46,6 +46,18 @@ export class Product {
|
||||
@JoinColumn({ name: 'category_id' })
|
||||
category: ProductCategory;
|
||||
|
||||
/**
|
||||
* Optional link to inventory.products for unified stock management.
|
||||
* This allows the commerce product to be linked to its inventory counterpart
|
||||
* for stock tracking, valuation (FIFO/AVERAGE), and warehouse operations.
|
||||
*
|
||||
* The inventory product handles: stock levels, lot/serial tracking, valuation layers
|
||||
* This commerce product handles: pricing, taxes, SAT compliance, commercial data
|
||||
*/
|
||||
@Index()
|
||||
@Column({ name: 'inventory_product_id', type: 'uuid', nullable: true })
|
||||
inventoryProductId: string | null;
|
||||
|
||||
// Identificacion
|
||||
@Index()
|
||||
@Column({ type: 'varchar', length: 50 })
|
||||
|
||||
@ -1,3 +1,3 @@
|
||||
export { Warehouse } from './warehouse.entity';
|
||||
export { WarehouseLocation } from './warehouse-location.entity';
|
||||
export { WarehouseZone } from './warehouse-zone.entity';
|
||||
export { Warehouse } from './warehouse.entity.js';
|
||||
export { WarehouseLocation } from './warehouse-location.entity.js';
|
||||
export { WarehouseZone } from './warehouse-zone.entity.js';
|
||||
|
||||
@ -6,9 +6,25 @@ import {
|
||||
UpdateDateColumn,
|
||||
DeleteDateColumn,
|
||||
Index,
|
||||
ManyToOne,
|
||||
OneToMany,
|
||||
JoinColumn,
|
||||
} from 'typeorm';
|
||||
import { Company } from '../../auth/entities/company.entity.js';
|
||||
|
||||
/**
|
||||
* Warehouse Entity (schema: inventory.warehouses)
|
||||
*
|
||||
* This is the CANONICAL warehouse entity for the ERP system.
|
||||
* All warehouse-related imports should use this entity.
|
||||
*
|
||||
* Note: The deprecated entity at inventory/entities/warehouse.entity.ts
|
||||
* has been superseded by this one and should not be used for new code.
|
||||
*/
|
||||
@Entity({ name: 'warehouses', schema: 'inventory' })
|
||||
@Index('idx_warehouses_tenant_id', ['tenantId'])
|
||||
@Index('idx_warehouses_company_id', ['companyId'])
|
||||
@Index('idx_warehouses_code_company', ['companyId', 'code'], { unique: true })
|
||||
export class Warehouse {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
@ -17,6 +33,14 @@ export class Warehouse {
|
||||
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||
tenantId: string;
|
||||
|
||||
@Index()
|
||||
@Column({ name: 'company_id', type: 'uuid', nullable: true })
|
||||
companyId: string | null;
|
||||
|
||||
@ManyToOne(() => Company)
|
||||
@JoinColumn({ name: 'company_id' })
|
||||
company: Company;
|
||||
|
||||
@Index()
|
||||
@Column({ name: 'branch_id', type: 'uuid', nullable: true })
|
||||
branchId: string;
|
||||
@ -90,6 +114,12 @@ export class Warehouse {
|
||||
autoReorder?: boolean;
|
||||
};
|
||||
|
||||
// Relations (lazy-loaded to avoid circular dependency with inventory module)
|
||||
// Note: Location entity in inventory module references this entity via warehouse_id FK
|
||||
// Use: await warehouse.locations to load related locations
|
||||
@OneToMany('Location', 'warehouse')
|
||||
locations: Promise<any[]>;
|
||||
|
||||
// Estado
|
||||
@Column({ name: 'is_active', type: 'boolean', default: true })
|
||||
isActive: boolean;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user