erp-retail/orchestration/planes/fase-3-implementacion/PLAN-IMPL-BACKEND.md

1992 lines
61 KiB
Markdown

# PLAN DE IMPLEMENTACION - BACKEND
**Fecha:** 2025-12-18
**Fase:** 3 - Plan de Implementaciones
**Capa:** Backend (Node.js + Express + TypeScript + TypeORM)
---
## 1. RESUMEN EJECUTIVO
### 1.1 Alcance
- **Servicios a heredar:** 12
- **Servicios a extender:** 8
- **Servicios nuevos:** 28
- **Controllers nuevos:** 15
- **Middleware nuevo:** 5
### 1.2 Arquitectura
```
┌─────────────────────────────────────────────────────────────┐
│ RETAIL BACKEND │
├─────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │
│ │ Controllers │ │ Middleware │ │ WebSocket Gateway │ │
│ └──────┬──────┘ └──────┬──────┘ └──────────┬──────────┘ │
│ │ │ │ │
│ ┌──────┴──────────────────────────────────────┴──────┐ │
│ │ Services │ │
│ │ ┌──────────────────────────────────────────────┐ │ │
│ │ │ Retail Services (new) │ │ │
│ │ │ POSService, CashService, LoyaltyService... │ │ │
│ │ └──────────────────────────────────────────────┘ │ │
│ │ ┌──────────────────────────────────────────────┐ │ │
│ │ │ Extended Services │ │ │
│ │ │ RetailProductsService extends ProductsService│ │ │
│ │ └──────────────────────────────────────────────┘ │ │
│ │ ┌──────────────────────────────────────────────┐ │ │
│ │ │ Core Services (inherited) │ │ │
│ │ │ AuthService, PartnersService, TaxesService │ │ │
│ │ └──────────────────────────────────────────────┘ │ │
│ └────────────────────────┬───────────────────────────┘ │
│ │ │
│ ┌────────────────────────┴───────────────────────────┐ │
│ │ Repositories │ │
│ │ TypeORM EntityRepository │ │
│ └────────────────────────┬───────────────────────────┘ │
│ │ │
│ ┌────────────────────────┴───────────────────────────┐ │
│ │ PostgreSQL + RLS │ │
│ └─────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
```
---
## 2. ESTRUCTURA DE PROYECTO
### 2.1 Estructura de Carpetas
```
retail/backend/
├── src/
│ ├── config/
│ │ ├── database.ts
│ │ ├── redis.ts
│ │ └── app.ts
│ │
│ ├── modules/
│ │ ├── auth/ # Heredado + extensiones
│ │ │ ├── entities/
│ │ │ ├── services/
│ │ │ ├── controllers/
│ │ │ └── middleware/
│ │ │
│ │ ├── branches/ # NUEVO
│ │ │ ├── entities/
│ │ │ │ ├── branch.entity.ts
│ │ │ │ ├── cash-register.entity.ts
│ │ │ │ └── branch-user.entity.ts
│ │ │ ├── services/
│ │ │ │ └── branches.service.ts
│ │ │ ├── controllers/
│ │ │ │ └── branches.controller.ts
│ │ │ └── dto/
│ │ │
│ │ ├── pos/ # NUEVO
│ │ │ ├── entities/
│ │ │ │ ├── pos-session.entity.ts
│ │ │ │ ├── pos-order.entity.ts
│ │ │ │ ├── pos-order-line.entity.ts
│ │ │ │ └── pos-payment.entity.ts
│ │ │ ├── services/
│ │ │ │ ├── pos-session.service.ts
│ │ │ │ ├── pos-order.service.ts
│ │ │ │ └── pos-sync.service.ts
│ │ │ ├── controllers/
│ │ │ │ └── pos.controller.ts
│ │ │ └── gateway/
│ │ │ └── pos.gateway.ts
│ │ │
│ │ ├── cash/ # NUEVO
│ │ │ ├── entities/
│ │ │ │ ├── cash-movement.entity.ts
│ │ │ │ ├── cash-closing.entity.ts
│ │ │ │ └── cash-count.entity.ts
│ │ │ ├── services/
│ │ │ │ ├── cash-session.service.ts
│ │ │ │ └── cash-closing.service.ts
│ │ │ └── controllers/
│ │ │
│ │ ├── inventory/ # EXTENDIDO
│ │ │ ├── entities/
│ │ │ │ ├── stock-transfer.entity.ts
│ │ │ │ └── stock-adjustment.entity.ts
│ │ │ ├── services/
│ │ │ │ ├── retail-stock.service.ts # extiende
│ │ │ │ ├── transfers.service.ts
│ │ │ │ └── adjustments.service.ts
│ │ │ └── controllers/
│ │ │
│ │ ├── customers/ # EXTENDIDO
│ │ │ ├── entities/
│ │ │ │ ├── loyalty-program.entity.ts
│ │ │ │ ├── membership-level.entity.ts
│ │ │ │ ├── loyalty-transaction.entity.ts
│ │ │ │ └── customer-membership.entity.ts
│ │ │ ├── services/
│ │ │ │ ├── retail-customers.service.ts
│ │ │ │ └── loyalty.service.ts
│ │ │ └── controllers/
│ │ │
│ │ ├── pricing/ # EXTENDIDO
│ │ │ ├── entities/
│ │ │ │ ├── promotion.entity.ts
│ │ │ │ ├── coupon.entity.ts
│ │ │ │ └── coupon-redemption.entity.ts
│ │ │ ├── services/
│ │ │ │ ├── price-engine.service.ts
│ │ │ │ ├── promotions.service.ts
│ │ │ │ └── coupons.service.ts
│ │ │ └── controllers/
│ │ │
│ │ ├── purchases/ # EXTENDIDO
│ │ │ ├── entities/
│ │ │ │ ├── purchase-suggestion.entity.ts
│ │ │ │ ├── supplier-order.entity.ts
│ │ │ │ └── goods-receipt.entity.ts
│ │ │ ├── services/
│ │ │ │ ├── purchase-suggestions.service.ts
│ │ │ │ └── supplier-orders.service.ts
│ │ │ └── controllers/
│ │ │
│ │ ├── invoicing/ # NUEVO
│ │ │ ├── entities/
│ │ │ │ ├── cfdi-config.entity.ts
│ │ │ │ └── cfdi.entity.ts
│ │ │ ├── services/
│ │ │ │ ├── cfdi.service.ts
│ │ │ │ ├── cfdi-builder.service.ts
│ │ │ │ ├── pac.service.ts
│ │ │ │ ├── xml.service.ts
│ │ │ │ └── pdf.service.ts
│ │ │ └── controllers/
│ │ │ ├── cfdi.controller.ts
│ │ │ └── autofactura.controller.ts
│ │ │
│ │ ├── ecommerce/ # NUEVO
│ │ │ ├── entities/
│ │ │ │ ├── cart.entity.ts
│ │ │ │ ├── ecommerce-order.entity.ts
│ │ │ │ └── shipping-rate.entity.ts
│ │ │ ├── services/
│ │ │ │ ├── catalog.service.ts
│ │ │ │ ├── cart.service.ts
│ │ │ │ ├── checkout.service.ts
│ │ │ │ ├── payment-gateway.service.ts
│ │ │ │ └── shipping.service.ts
│ │ │ └── controllers/
│ │ │ ├── storefront.controller.ts
│ │ │ └── ecommerce-admin.controller.ts
│ │ │
│ │ └── reports/ # EXTENDIDO
│ │ ├── services/
│ │ │ ├── dashboard.service.ts
│ │ │ ├── sales-report.service.ts
│ │ │ ├── product-report.service.ts
│ │ │ └── cash-report.service.ts
│ │ └── controllers/
│ │
│ ├── shared/
│ │ ├── entities/
│ │ │ └── base.entity.ts
│ │ ├── services/
│ │ │ └── base.service.ts
│ │ ├── dto/
│ │ │ └── pagination.dto.ts
│ │ ├── interfaces/
│ │ │ └── tenant-context.interface.ts
│ │ └── utils/
│ │ ├── sequence.util.ts
│ │ └── decimal.util.ts
│ │
│ ├── middleware/
│ │ ├── tenant.middleware.ts
│ │ ├── auth.middleware.ts
│ │ ├── branch.middleware.ts
│ │ └── error.middleware.ts
│ │
│ ├── integrations/
│ │ ├── pac/
│ │ │ ├── finkok.provider.ts
│ │ │ ├── facturama.provider.ts
│ │ │ └── pac.interface.ts
│ │ ├── payments/
│ │ │ ├── stripe.provider.ts
│ │ │ ├── conekta.provider.ts
│ │ │ └── payment.interface.ts
│ │ └── shipping/
│ │ ├── fedex.provider.ts
│ │ └── shipping.interface.ts
│ │
│ ├── migrations/
│ │ └── ...
│ │
│ └── server.ts
├── package.json
├── tsconfig.json
└── .env.example
```
---
## 3. SERVICIOS POR MODULO
### 3.1 RT-001 Fundamentos (Herencia 100%)
```typescript
// HEREDADOS - Solo configurar importaciones
import { AuthService } from '@erp-core/auth';
import { UsersService } from '@erp-core/users';
import { TenantsService } from '@erp-core/tenants';
import { RolesService } from '@erp-core/roles';
```
**Servicios:**
| Servicio | Accion | Fuente |
|----------|--------|--------|
| AuthService | HEREDAR | @erp-core/auth |
| UsersService | HEREDAR | @erp-core/users |
| TenantsService | HEREDAR | @erp-core/tenants |
| RolesService | HEREDAR | @erp-core/roles |
---
### 3.2 RT-002 POS (20% herencia)
**Servicios Nuevos:**
```typescript
// 1. POSSessionService
@Injectable()
export class POSSessionService {
constructor(
@InjectRepository(POSSession)
private sessionRepo: Repository<POSSession>,
private cashRegisterService: CashRegisterService,
) {}
async openSession(dto: OpenSessionDto): Promise<POSSession> {
// 1. Verificar caja disponible
// 2. Crear sesion
// 3. Registrar apertura
return session;
}
async closeSession(sessionId: string, dto: CloseSessionDto): Promise<POSSession> {
// 1. Calcular totales esperados
// 2. Validar declaracion
// 3. Crear corte
// 4. Cerrar sesion
return session;
}
async getActiveSession(userId: string): Promise<POSSession | null> {
return this.sessionRepo.findOne({
where: { userId, status: In(['opening', 'open']) }
});
}
}
// 2. POSOrderService
@Injectable()
export class POSOrderService {
constructor(
@InjectRepository(POSOrder)
private orderRepo: Repository<POSOrder>,
private priceEngine: PriceEngineService,
private stockService: RetailStockService,
private loyaltyService: LoyaltyService,
) {}
async createOrder(sessionId: string): Promise<POSOrder> {
const orderNumber = await this.generateOrderNumber();
return this.orderRepo.save({
sessionId,
orderNumber,
status: 'draft',
});
}
async addLine(orderId: string, dto: AddLineDto): Promise<POSOrderLine> {
// 1. Calcular precio con motor de precios
const priceResult = await this.priceEngine.calculatePrice({
productId: dto.productId,
quantity: dto.quantity,
branchId: order.session.branchId,
});
// 2. Crear linea
// 3. Recalcular totales
return line;
}
async confirmOrder(orderId: string, dto: ConfirmOrderDto): Promise<POSOrder> {
// 1. Validar stock
// 2. Procesar pagos
// 3. Descontar inventario
// 4. Otorgar puntos lealtad
// 5. Aplicar cupon si existe
// 6. Marcar como done
return order;
}
async refundOrder(orderId: string, dto: RefundDto): Promise<POSOrder> {
// 1. Validar orden
// 2. Revertir inventario
// 3. Revertir puntos
// 4. Crear registro de devolucion
return order;
}
}
// 3. POSSyncService (para offline)
@Injectable()
export class POSSyncService {
constructor(
private orderService: POSOrderService,
private redis: Redis,
) {}
async syncOfflineOrders(orders: OfflineOrder[]): Promise<SyncResult> {
const results: SyncResult = { synced: [], failed: [] };
for (const offlineOrder of orders) {
try {
// Verificar si ya fue sincronizada
const exists = await this.orderService.findByOfflineId(offlineOrder.offlineId);
if (exists) {
results.synced.push({ offlineId: offlineOrder.offlineId, orderId: exists.id });
continue;
}
// Crear orden
const order = await this.orderService.createFromOffline(offlineOrder);
results.synced.push({ offlineId: offlineOrder.offlineId, orderId: order.id });
} catch (error) {
results.failed.push({ offlineId: offlineOrder.offlineId, error: error.message });
}
}
return results;
}
}
```
**Servicios:**
| Servicio | Accion | Dependencias |
|----------|--------|--------------|
| POSSessionService | NUEVO | CashRegisterService |
| POSOrderService | NUEVO | PriceEngineService, RetailStockService, LoyaltyService |
| POSPaymentService | NUEVO | - |
| POSSyncService | NUEVO | POSOrderService, Redis |
---
### 3.3 RT-003 Inventario (60% herencia)
**Servicios Extendidos:**
```typescript
// 1. RetailStockService (extiende StockService del core)
@Injectable()
export class RetailStockService extends StockService {
constructor(
@InjectRepository(StockQuant)
stockQuantRepo: Repository<StockQuant>,
@InjectRepository(Branch)
private branchRepo: Repository<Branch>,
) {
super(stockQuantRepo);
}
// Metodos heredados: getStock, reserveStock, etc.
// Metodos nuevos para retail
async getStockByBranch(branchId: string, productId?: string): Promise<BranchStock[]> {
const branch = await this.branchRepo.findOne({
where: { id: branchId },
relations: ['warehouse'],
});
return this.stockQuantRepo.find({
where: {
warehouseId: branch.warehouse.id,
...(productId && { productId }),
},
});
}
async getMultiBranchStock(productId: string): Promise<BranchStockSummary[]> {
// Stock del producto en todas las sucursales
return this.stockQuantRepo
.createQueryBuilder('sq')
.select(['b.id as branchId', 'b.name as branchName', 'sq.quantity'])
.innerJoin('retail.branches', 'b', 'b.warehouse_id = sq.warehouse_id')
.where('sq.product_id = :productId', { productId })
.getRawMany();
}
async decrementStock(branchId: string, productId: string, quantity: number): Promise<void> {
const branch = await this.branchRepo.findOne({ where: { id: branchId } });
await this.stockQuantRepo.decrement(
{ warehouseId: branch.warehouseId, productId },
'quantity',
quantity
);
}
}
// 2. TransfersService
@Injectable()
export class TransfersService {
async createTransfer(dto: CreateTransferDto): Promise<StockTransfer> {
const transfer = await this.transferRepo.save({
...dto,
transferNumber: await this.generateNumber('TRF'),
status: 'draft',
});
return transfer;
}
async confirmTransfer(id: string): Promise<StockTransfer> {
// 1. Validar stock en origen
// 2. Reservar stock
// 3. Cambiar status a 'in_transit'
return transfer;
}
async receiveTransfer(id: string, dto: ReceiveDto): Promise<StockTransfer> {
// 1. Descontar de origen
// 2. Agregar a destino
// 3. Registrar diferencias si las hay
// 4. Cambiar status a 'received'
return transfer;
}
}
// 3. AdjustmentsService
@Injectable()
export class AdjustmentsService {
async createAdjustment(dto: CreateAdjustmentDto): Promise<StockAdjustment> {
// Similar a transferencias
}
async confirmAdjustment(id: string): Promise<StockAdjustment> {
// Aplicar diferencias al stock
}
}
```
**Servicios:**
| Servicio | Accion | Hereda de |
|----------|--------|-----------|
| RetailStockService | EXTENDER | StockService |
| TransfersService | NUEVO | - |
| AdjustmentsService | NUEVO | - |
---
### 3.4 RT-004 Compras (80% herencia)
**Servicios Extendidos:**
```typescript
// 1. RetailPurchaseService (extiende PurchaseService)
@Injectable()
export class RetailPurchaseService extends PurchaseService {
// Hereda: createOrder, confirmOrder, receiveGoods
async suggestRestock(branchId: string): Promise<PurchaseSuggestion[]> {
// Algoritmo de sugerencia:
// 1. Productos con stock < minimo
// 2. Prioridad basada en dias de stock
// 3. Cantidad sugerida basada en historico de ventas
const suggestions = await this.stockRepo
.createQueryBuilder('sq')
.select([
'sq.product_id',
'sq.quantity as current_stock',
'p.min_stock',
'p.max_stock',
'p.default_supplier_id',
])
.innerJoin('inventory.products', 'p', 'p.id = sq.product_id')
.innerJoin('retail.branches', 'b', 'b.warehouse_id = sq.warehouse_id')
.where('b.id = :branchId', { branchId })
.andWhere('sq.quantity <= p.min_stock')
.getRawMany();
return suggestions.map(s => ({
productId: s.product_id,
currentStock: s.current_stock,
minStock: s.min_stock,
maxStock: s.max_stock,
suggestedQty: s.max_stock - s.current_stock,
supplierId: s.default_supplier_id,
priority: this.calculatePriority(s),
}));
}
private calculatePriority(s: any): 'critical' | 'high' | 'medium' | 'low' {
const daysOfStock = s.current_stock / s.avg_daily_sales;
if (daysOfStock <= 0) return 'critical';
if (daysOfStock <= 3) return 'high';
if (daysOfStock <= 7) return 'medium';
return 'low';
}
}
// 2. GoodsReceiptService
@Injectable()
export class GoodsReceiptService {
async createReceipt(dto: CreateReceiptDto): Promise<GoodsReceipt> {
// 1. Crear recepcion
// 2. Vincular con orden de compra si existe
return receipt;
}
async confirmReceipt(id: string): Promise<GoodsReceipt> {
// 1. Agregar stock al warehouse de la sucursal
// 2. Actualizar costos si es necesario
// 3. Actualizar orden de compra
return receipt;
}
}
```
---
### 3.5 RT-005 Clientes (40% herencia)
**Servicios Extendidos:**
```typescript
// 1. RetailCustomersService (extiende PartnersService)
@Injectable()
export class RetailCustomersService extends PartnersService {
constructor(
partnersRepo: Repository<Partner>,
private membershipRepo: Repository<CustomerMembership>,
private loyaltyService: LoyaltyService,
) {
super(partnersRepo);
}
async createCustomer(dto: CreateCustomerDto): Promise<Partner> {
// 1. Crear partner base
const customer = await super.create({ ...dto, type: 'customer' });
// 2. Inscribir en programa de lealtad si esta activo
if (dto.enrollInLoyalty) {
await this.loyaltyService.enrollCustomer(customer.id);
}
return customer;
}
async getCustomerWithMembership(customerId: string): Promise<CustomerWithMembership> {
const customer = await this.findById(customerId);
const membership = await this.membershipRepo.findOne({
where: { customerId },
relations: ['program', 'level'],
});
return { ...customer, membership };
}
}
// 2. LoyaltyService
@Injectable()
export class LoyaltyService {
async enrollCustomer(customerId: string, programId?: string): Promise<CustomerMembership> {
const program = programId
? await this.programRepo.findOne({ where: { id: programId } })
: await this.programRepo.findOne({ where: { isActive: true } });
const membershipNumber = await this.generateMembershipNumber();
return this.membershipRepo.save({
customerId,
programId: program.id,
membershipNumber,
currentPoints: 0,
lifetimePoints: 0,
status: 'active',
});
}
async earnPoints(customerId: string, orderId: string, amount: number): Promise<LoyaltyTransaction> {
const membership = await this.membershipRepo.findOne({
where: { customerId },
relations: ['program', 'level'],
});
if (!membership) return null;
// Calcular puntos con multiplicador de nivel
const basePoints = Math.floor(amount * membership.program.pointsPerCurrency);
const multiplier = membership.level?.pointsMultiplier || 1;
const points = Math.floor(basePoints * multiplier);
// Crear transaccion
const transaction = await this.transactionRepo.save({
customerId,
programId: membership.programId,
orderId,
transactionType: 'earn',
points,
balanceAfter: membership.currentPoints + points,
expiresAt: this.calculateExpiry(),
});
// Actualizar balance
await this.membershipRepo.update(membership.id, {
currentPoints: () => `current_points + ${points}`,
lifetimePoints: () => `lifetime_points + ${points}`,
lastActivityAt: new Date(),
});
// Verificar upgrade de nivel
await this.checkLevelUpgrade(membership);
return transaction;
}
async redeemPoints(customerId: string, orderId: string, points: number): Promise<RedemptionResult> {
const membership = await this.membershipRepo.findOne({
where: { customerId },
relations: ['program'],
});
// Validaciones
if (points > membership.currentPoints) {
throw new BadRequestException('Insufficient points');
}
if (points < membership.program.minPointsRedeem) {
throw new BadRequestException(`Minimum redemption is ${membership.program.minPointsRedeem} points`);
}
// Calcular descuento
const discount = points * membership.program.currencyPerPoint;
// Crear transaccion
await this.transactionRepo.save({
customerId,
programId: membership.programId,
orderId,
transactionType: 'redeem',
points: -points,
balanceAfter: membership.currentPoints - points,
});
// Actualizar balance
await this.membershipRepo.decrement({ id: membership.id }, 'currentPoints', points);
return { points, discount };
}
private async checkLevelUpgrade(membership: CustomerMembership): Promise<void> {
const newLevel = await this.levelRepo
.createQueryBuilder('level')
.where('level.program_id = :programId', { programId: membership.programId })
.andWhere('level.min_points <= :points', { points: membership.lifetimePoints })
.orderBy('level.min_points', 'DESC')
.getOne();
if (newLevel && newLevel.id !== membership.levelId) {
await this.membershipRepo.update(membership.id, { levelId: newLevel.id });
// TODO: Notificar al cliente del upgrade
}
}
}
```
---
### 3.6 RT-006 Precios (30% herencia)
**Servicios Nuevos y Extendidos:**
```typescript
// 1. PriceEngineService (CORE - Motor de precios)
@Injectable()
export class PriceEngineService {
constructor(
private pricelistService: PricelistsService,
private promotionsService: PromotionsService,
private couponsService: CouponsService,
private loyaltyService: LoyaltyService,
) {}
async calculatePrice(context: PriceContext): Promise<PriceResult> {
const { productId, quantity, branchId, customerId, couponCode } = context;
// 1. Precio base del producto
const product = await this.productRepo.findOne({ where: { id: productId } });
const basePrice = product.salePrice;
// 2. Aplicar lista de precios (si existe para el canal)
const pricelistPrice = await this.applyPricelist(basePrice, context);
// 3. Evaluar promociones activas
const promotions = await this.promotionsService.getActiveForProduct(productId, branchId);
const bestPromotion = this.selectBestPromotion(promotions, pricelistPrice, quantity);
const promotionDiscount = bestPromotion?.discount || 0;
// 4. Calcular precio despues de promocion
let finalPrice = pricelistPrice - promotionDiscount;
// 5. Aplicar cupon si existe
let couponDiscount = 0;
if (couponCode) {
const couponResult = await this.couponsService.calculateDiscount(couponCode, finalPrice);
couponDiscount = couponResult.discount;
finalPrice -= couponDiscount;
}
// 6. Calcular descuento por puntos de lealtad (si aplica)
let loyaltyDiscount = 0;
if (context.loyaltyPointsToUse && customerId) {
const loyaltyResult = await this.loyaltyService.calculateRedemption(
customerId,
context.loyaltyPointsToUse
);
loyaltyDiscount = loyaltyResult.discount;
finalPrice -= loyaltyDiscount;
}
// 7. Calcular impuestos
const taxes = await this.calculateTaxes(finalPrice, product.taxIds);
return {
basePrice,
pricelistPrice,
discounts: [
...(bestPromotion ? [{ type: 'promotion', name: bestPromotion.name, amount: promotionDiscount }] : []),
...(couponDiscount > 0 ? [{ type: 'coupon', name: couponCode, amount: couponDiscount }] : []),
...(loyaltyDiscount > 0 ? [{ type: 'loyalty', name: 'Puntos', amount: loyaltyDiscount }] : []),
],
subtotal: finalPrice,
taxes,
total: finalPrice + taxes.reduce((sum, t) => sum + t.amount, 0),
};
}
private selectBestPromotion(
promotions: Promotion[],
price: number,
quantity: number
): Promotion | null {
const applicable = promotions.filter(p => this.isPromotionApplicable(p, price, quantity));
if (applicable.length === 0) return null;
// Ordenar por descuento mayor
return applicable.sort((a, b) => {
const discountA = this.calculatePromotionDiscount(a, price, quantity);
const discountB = this.calculatePromotionDiscount(b, price, quantity);
return discountB - discountA;
})[0];
}
private calculatePromotionDiscount(promo: Promotion, price: number, qty: number): number {
switch (promo.promotionType) {
case 'percentage':
return price * (promo.discountValue / 100);
case 'fixed_amount':
return Math.min(promo.discountValue, price);
case 'buy_x_get_y':
const freeItems = Math.floor(qty / promo.buyQuantity) * (promo.buyQuantity - promo.getQuantity);
return (price / qty) * freeItems;
default:
return 0;
}
}
}
// 2. PromotionsService
@Injectable()
export class PromotionsService {
async create(dto: CreatePromotionDto): Promise<Promotion> {
const promotion = await this.promotionRepo.save({
...dto,
code: dto.code || this.generateCode(),
});
// Agregar productos si se especificaron
if (dto.productIds?.length) {
await this.addProducts(promotion.id, dto.productIds);
}
return promotion;
}
async getActiveForProduct(productId: string, branchId: string): Promise<Promotion[]> {
const today = new Date();
return this.promotionRepo
.createQueryBuilder('p')
.leftJoin('retail.promotion_products', 'pp', 'pp.promotion_id = p.id')
.where('p.is_active = true')
.andWhere('p.start_date <= :today', { today })
.andWhere('p.end_date >= :today', { today })
.andWhere('(p.applies_to_all = true OR pp.product_id = :productId)', { productId })
.andWhere('(p.branch_ids IS NULL OR :branchId = ANY(p.branch_ids))', { branchId })
.andWhere('(p.max_uses IS NULL OR p.current_uses < p.max_uses)')
.getMany();
}
async incrementUse(promotionId: string): Promise<void> {
await this.promotionRepo.increment({ id: promotionId }, 'currentUses', 1);
}
}
// 3. CouponsService
@Injectable()
export class CouponsService {
async generate(dto: GenerateCouponsDto): Promise<Coupon[]> {
const coupons: Coupon[] = [];
for (let i = 0; i < dto.quantity; i++) {
coupons.push(await this.couponRepo.save({
code: this.generateCode(dto.prefix),
couponType: dto.couponType,
discountValue: dto.discountValue,
minPurchase: dto.minPurchase,
maxDiscount: dto.maxDiscount,
validFrom: dto.validFrom,
validUntil: dto.validUntil,
maxUses: dto.maxUses || 1,
customerId: dto.customerId,
}));
}
return coupons;
}
async validate(code: string, orderTotal: number): Promise<CouponValidation> {
const coupon = await this.couponRepo.findOne({ where: { code } });
if (!coupon) return { valid: false, error: 'Cupon no encontrado' };
if (!coupon.isActive) return { valid: false, error: 'Cupon inactivo' };
if (coupon.timesUsed >= coupon.maxUses) return { valid: false, error: 'Cupon agotado' };
if (new Date() < coupon.validFrom) return { valid: false, error: 'Cupon no vigente' };
if (new Date() > coupon.validUntil) return { valid: false, error: 'Cupon expirado' };
if (coupon.minPurchase && orderTotal < coupon.minPurchase) {
return { valid: false, error: `Compra minima: $${coupon.minPurchase}` };
}
const discount = this.calculateDiscount(coupon, orderTotal);
return { valid: true, coupon, discount };
}
async redeem(code: string, orderId: string, discount: number): Promise<CouponRedemption> {
const coupon = await this.couponRepo.findOne({ where: { code } });
await this.couponRepo.increment({ id: coupon.id }, 'timesUsed', 1);
return this.redemptionRepo.save({
couponId: coupon.id,
orderId,
discountApplied: discount,
});
}
private calculateDiscount(coupon: Coupon, total: number): number {
let discount = coupon.couponType === 'percentage'
? total * (coupon.discountValue / 100)
: coupon.discountValue;
if (coupon.maxDiscount) {
discount = Math.min(discount, coupon.maxDiscount);
}
return discount;
}
}
```
---
### 3.7 RT-007 Caja (10% herencia)
**Servicios Nuevos:**
```typescript
// 1. CashSessionService
@Injectable()
export class CashSessionService {
async getSummary(sessionId: string): Promise<SessionSummary> {
const session = await this.sessionRepo.findOne({
where: { id: sessionId },
relations: ['orders', 'orders.payments', 'movements'],
});
// Calcular esperados
const cashSales = session.orders
.filter(o => o.status === 'done')
.flatMap(o => o.payments)
.filter(p => p.paymentMethod === 'cash')
.reduce((sum, p) => sum + Number(p.amount), 0);
const changeGiven = session.orders
.filter(o => o.status === 'done')
.flatMap(o => o.payments)
.filter(p => p.paymentMethod === 'cash')
.reduce((sum, p) => sum + Number(p.changeAmount || 0), 0);
const cashIn = session.movements
.filter(m => m.movementType === 'in')
.reduce((sum, m) => sum + Number(m.amount), 0);
const cashOut = session.movements
.filter(m => m.movementType === 'out')
.reduce((sum, m) => sum + Number(m.amount), 0);
const cardSales = session.orders
.filter(o => o.status === 'done')
.flatMap(o => o.payments)
.filter(p => p.paymentMethod === 'card')
.reduce((sum, p) => sum + Number(p.amount), 0);
const transferSales = session.orders
.filter(o => o.status === 'done')
.flatMap(o => o.payments)
.filter(p => p.paymentMethod === 'transfer')
.reduce((sum, p) => sum + Number(p.amount), 0);
const expectedCash = Number(session.openingBalance) + cashSales - changeGiven + cashIn - cashOut;
return {
sessionId,
openingBalance: session.openingBalance,
cashSales,
changeGiven,
cashIn,
cashOut,
expectedCash,
cardSales,
transferSales,
totalOrders: session.orders.filter(o => o.status === 'done').length,
};
}
}
// 2. CashMovementService
@Injectable()
export class CashMovementService {
async createMovement(dto: CreateMovementDto): Promise<CashMovement> {
const session = await this.sessionRepo.findOne({
where: { id: dto.sessionId, status: 'open' },
});
if (!session) {
throw new BadRequestException('Session not found or not open');
}
// Si es retiro alto, requerir autorizacion
if (dto.movementType === 'out' && dto.amount > this.config.maxWithdrawalWithoutAuth) {
if (!dto.authorizedBy) {
throw new BadRequestException('Authorization required for this amount');
}
// Validar que el autorizador tenga permiso
await this.validateAuthorizer(dto.authorizedBy);
}
return this.movementRepo.save({
sessionId: dto.sessionId,
movementType: dto.movementType,
amount: dto.amount,
reason: dto.reason,
notes: dto.notes,
authorizedBy: dto.authorizedBy,
});
}
}
// 3. CashClosingService
@Injectable()
export class CashClosingService {
async prepareClosing(sessionId: string): Promise<ClosingPreparation> {
const summary = await this.sessionService.getSummary(sessionId);
return {
...summary,
denominations: this.getDenominationTemplate(),
};
}
async createClosing(sessionId: string, dto: CreateClosingDto): Promise<CashClosing> {
const summary = await this.sessionService.getSummary(sessionId);
// Calcular total declarado de denominaciones
const declaredFromDenominations = this.calculateDenominations(dto.denominationDetail);
// Validar que coincida con el declarado
if (Math.abs(declaredFromDenominations - dto.declaredCash) > 0.01) {
throw new BadRequestException('Denomination detail does not match declared cash');
}
const closing = await this.closingRepo.save({
sessionId,
closingDate: new Date(),
expectedCash: summary.expectedCash,
expectedCard: summary.cardSales,
expectedTransfer: summary.transferSales,
declaredCash: dto.declaredCash,
declaredCard: dto.declaredCard,
declaredTransfer: dto.declaredTransfer,
denominationDetail: dto.denominationDetail,
notes: dto.notes,
closedBy: dto.userId,
});
// Cerrar sesion
await this.sessionRepo.update(sessionId, {
status: 'closed',
closingBalance: dto.declaredCash,
closedAt: new Date(),
});
// Si hay diferencia significativa, marcar para aprobacion
const tolerance = this.config.cashDifferenceTolerance || 10;
if (Math.abs(closing.cashDifference) > tolerance) {
// TODO: Notificar a supervisor
}
return closing;
}
private calculateDenominations(detail: DenominationDetail): number {
let total = 0;
for (const [denom, count] of Object.entries(detail.bills)) {
total += Number(denom) * count;
}
for (const [denom, count] of Object.entries(detail.coins)) {
total += Number(denom) * count;
}
return total;
}
private getDenominationTemplate(): DenominationTemplate {
return {
bills: { '1000': 0, '500': 0, '200': 0, '100': 0, '50': 0, '20': 0 },
coins: { '20': 0, '10': 0, '5': 0, '2': 0, '1': 0, '0.50': 0 },
};
}
}
```
---
### 3.8 RT-008 Reportes (70% herencia)
**Servicios Extendidos:**
```typescript
// 1. DashboardService
@Injectable()
export class DashboardService {
async getDashboard(branchId?: string): Promise<RetailDashboard> {
const today = new Date();
today.setHours(0, 0, 0, 0);
const baseWhere = branchId ? { branchId } : {};
// KPIs del dia
const todayStats = await this.getStatsForPeriod(today, new Date(), branchId);
// Comparativos
const yesterday = new Date(today);
yesterday.setDate(yesterday.getDate() - 1);
const lastWeek = new Date(today);
lastWeek.setDate(lastWeek.getDate() - 7);
const lastMonth = new Date(today);
lastMonth.setMonth(lastMonth.getMonth() - 1);
const yesterdayStats = await this.getStatsForPeriod(yesterday, today, branchId);
const lastWeekStats = await this.getStatsForPeriod(lastWeek, new Date(lastWeek.getTime() + 86400000), branchId);
// Graficos
const salesByHour = await this.getSalesByHour(today, branchId);
const salesByPaymentMethod = await this.getSalesByPaymentMethod(today, branchId);
const topProducts = await this.getTopProducts(today, branchId, 10);
// Alertas
const lowStockCount = await this.stockService.getLowStockCount(branchId);
const pendingTransfers = await this.transferService.getPendingCount(branchId);
return {
today: todayStats,
comparison: {
vsYesterday: this.calculateChange(todayStats.totalSales, yesterdayStats.totalSales),
vsLastWeek: this.calculateChange(todayStats.totalSales, lastWeekStats.totalSales),
},
charts: {
salesByHour,
salesByPaymentMethod,
topProducts,
},
alerts: {
lowStock: lowStockCount,
pendingTransfers,
},
};
}
private async getStatsForPeriod(from: Date, to: Date, branchId?: string) {
// Usar vista materializada si es de dias anteriores
const result = await this.orderRepo
.createQueryBuilder('o')
.select([
'SUM(o.total) as totalSales',
'COUNT(*) as totalTransactions',
'AVG(o.total) as avgTicket',
])
.innerJoin('retail.pos_sessions', 's', 's.id = o.session_id')
.where('o.order_date >= :from', { from })
.andWhere('o.order_date < :to', { to })
.andWhere('o.status = :status', { status: 'done' })
.andWhere(branchId ? 's.branch_id = :branchId' : '1=1', { branchId })
.getRawOne();
return {
totalSales: Number(result.totalSales) || 0,
totalTransactions: Number(result.totalTransactions) || 0,
avgTicket: Number(result.avgTicket) || 0,
};
}
}
// 2. SalesReportService
@Injectable()
export class SalesReportService extends ReportsService {
async generate(filters: SalesReportFilters): Promise<SalesReport> {
const { from, to, branchId, groupBy } = filters;
let query = this.orderRepo
.createQueryBuilder('o')
.innerJoin('retail.pos_sessions', 's', 's.id = o.session_id')
.where('o.order_date >= :from', { from })
.andWhere('o.order_date <= :to', { to })
.andWhere('o.status = :status', { status: 'done' });
if (branchId) {
query = query.andWhere('s.branch_id = :branchId', { branchId });
}
// Agrupar segun criterio
switch (groupBy) {
case 'day':
query = query.select([
'DATE(o.order_date) as date',
'SUM(o.total) as totalSales',
'COUNT(*) as transactions',
]).groupBy('DATE(o.order_date)');
break;
case 'branch':
query = query.select([
's.branch_id',
'b.name as branchName',
'SUM(o.total) as totalSales',
'COUNT(*) as transactions',
])
.innerJoin('retail.branches', 'b', 'b.id = s.branch_id')
.groupBy('s.branch_id, b.name');
break;
// ... otros casos
}
const details = await query.getRawMany();
// Calcular sumario
const summary = {
totalSales: details.reduce((sum, d) => sum + Number(d.totalSales), 0),
totalTransactions: details.reduce((sum, d) => sum + Number(d.transactions), 0),
avgTicket: 0,
};
summary.avgTicket = summary.totalSales / summary.totalTransactions || 0;
return {
period: { from, to },
groupBy,
summary,
details,
};
}
}
```
---
### 3.9 RT-009 E-commerce (20% herencia)
**Servicios Nuevos:**
```typescript
// 1. CatalogService
@Injectable()
export class CatalogService {
async search(filters: CatalogFilters): Promise<PaginatedProducts> {
let query = this.productRepo
.createQueryBuilder('p')
.where('p.is_active = true')
.andWhere('p.is_sellable = true');
if (filters.categoryId) {
query = query.andWhere('p.category_id = :categoryId', { categoryId: filters.categoryId });
}
if (filters.search) {
query = query.andWhere(
'(p.name ILIKE :search OR p.default_code ILIKE :search)',
{ search: `%${filters.search}%` }
);
}
if (filters.minPrice) {
query = query.andWhere('p.sale_price >= :minPrice', { minPrice: filters.minPrice });
}
if (filters.maxPrice) {
query = query.andWhere('p.sale_price <= :maxPrice', { maxPrice: filters.maxPrice });
}
// Ordenamiento
const orderBy = filters.sortBy || 'name';
const orderDir = filters.sortDir || 'ASC';
query = query.orderBy(`p.${orderBy}`, orderDir);
// Paginacion
const [items, total] = await query
.skip(filters.offset || 0)
.take(filters.limit || 20)
.getManyAndCount();
return { items, total, page: Math.floor((filters.offset || 0) / (filters.limit || 20)) + 1 };
}
async getProduct(productId: string): Promise<ProductDetail> {
const product = await this.productRepo.findOne({
where: { id: productId },
relations: ['category', 'images'],
});
// Stock disponible
const stock = await this.stockService.getAvailableStock(productId);
// Promociones activas
const promotions = await this.promotionsService.getActiveForProduct(productId);
return { ...product, stock, promotions };
}
}
// 2. CartService
@Injectable()
export class CartService {
async getOrCreateCart(customerId?: string, sessionId?: string): Promise<Cart> {
let cart: Cart;
if (customerId) {
cart = await this.cartRepo.findOne({
where: { customerId },
relations: ['items', 'items.product'],
});
} else if (sessionId) {
cart = await this.cartRepo.findOne({
where: { sessionId },
relations: ['items', 'items.product'],
});
}
if (!cart) {
cart = await this.cartRepo.save({
customerId,
sessionId,
subtotal: 0,
expiresAt: this.calculateExpiry(),
});
cart.items = [];
}
return cart;
}
async addItem(cartId: string, dto: AddItemDto): Promise<Cart> {
const cart = await this.cartRepo.findOne({
where: { id: cartId },
relations: ['items'],
});
// Verificar stock
const stock = await this.stockService.getAvailableStock(dto.productId);
if (stock < dto.quantity) {
throw new BadRequestException('Insufficient stock');
}
// Obtener precio
const product = await this.productRepo.findOne({ where: { id: dto.productId } });
const price = product.salePrice;
// Buscar si ya existe en carrito
const existingItem = cart.items.find(i => i.productId === dto.productId);
if (existingItem) {
existingItem.quantity += dto.quantity;
existingItem.total = existingItem.quantity * existingItem.unitPrice;
await this.cartItemRepo.save(existingItem);
} else {
await this.cartItemRepo.save({
cartId,
productId: dto.productId,
quantity: dto.quantity,
unitPrice: price,
total: dto.quantity * price,
});
}
// Recalcular subtotal
await this.recalculateSubtotal(cartId);
return this.getCart(cartId);
}
async updateItem(cartId: string, itemId: string, quantity: number): Promise<Cart> {
if (quantity <= 0) {
await this.cartItemRepo.delete(itemId);
} else {
const item = await this.cartItemRepo.findOne({ where: { id: itemId } });
item.quantity = quantity;
item.total = quantity * item.unitPrice;
await this.cartItemRepo.save(item);
}
await this.recalculateSubtotal(cartId);
return this.getCart(cartId);
}
private async recalculateSubtotal(cartId: string): Promise<void> {
const items = await this.cartItemRepo.find({ where: { cartId } });
const subtotal = items.reduce((sum, i) => sum + Number(i.total), 0);
await this.cartRepo.update(cartId, { subtotal, updatedAt: new Date() });
}
}
// 3. CheckoutService
@Injectable()
export class CheckoutService {
async validate(cartId: string, dto: CheckoutDto): Promise<CheckoutValidation> {
const cart = await this.cartService.getCart(cartId);
const errors: string[] = [];
// Validar items
for (const item of cart.items) {
const stock = await this.stockService.getAvailableStock(item.productId);
if (stock < item.quantity) {
errors.push(`Stock insuficiente para ${item.product.name}`);
}
}
// Validar direccion si es envio
if (dto.deliveryMethod === 'shipping' && !dto.shippingAddress) {
errors.push('Direccion de envio requerida');
}
// Validar sucursal si es pickup
if (dto.deliveryMethod === 'pickup' && !dto.pickupBranchId) {
errors.push('Sucursal de recoleccion requerida');
}
return {
valid: errors.length === 0,
errors,
summary: await this.calculateTotals(cart, dto),
};
}
async complete(cartId: string, dto: CompleteCheckoutDto): Promise<EcommerceOrder> {
const cart = await this.cartService.getCart(cartId);
// Validar una vez mas
const validation = await this.validate(cartId, dto);
if (!validation.valid) {
throw new BadRequestException(validation.errors.join(', '));
}
// Crear orden
const order = await this.orderRepo.save({
orderNumber: await this.generateOrderNumber(),
customerId: cart.customerId || dto.customerId,
status: 'pending',
orderDate: new Date(),
subtotal: cart.subtotal,
discountAmount: validation.summary.discountAmount,
shippingCost: validation.summary.shippingCost,
taxAmount: validation.summary.taxAmount,
total: validation.summary.total,
paymentStatus: 'pending',
deliveryMethod: dto.deliveryMethod,
pickupBranchId: dto.pickupBranchId,
shippingAddress: dto.shippingAddress,
});
// Crear lineas
for (const item of cart.items) {
await this.orderLineRepo.save({
orderId: order.id,
productId: item.productId,
productName: item.product.name,
quantity: item.quantity,
unitPrice: item.unitPrice,
total: item.total,
});
}
// Reservar stock
for (const item of cart.items) {
await this.stockService.reserveStock(item.productId, item.quantity);
}
// Limpiar carrito
await this.cartService.clear(cartId);
return order;
}
}
// 4. PaymentGatewayService
@Injectable()
export class PaymentGatewayService {
private gateways: Map<string, PaymentGateway>;
constructor(
private stripeGateway: StripeGateway,
private conektaGateway: ConektaGateway,
private mercadoPagoGateway: MercadoPagoGateway,
) {
this.gateways = new Map([
['stripe', stripeGateway],
['conekta', conektaGateway],
['mercadopago', mercadoPagoGateway],
]);
}
async createPayment(orderId: string, gateway: string): Promise<PaymentIntent> {
const order = await this.orderRepo.findOne({ where: { id: orderId } });
const provider = this.gateways.get(gateway);
if (!provider) {
throw new BadRequestException(`Gateway ${gateway} not supported`);
}
return provider.createPayment(order);
}
async handleWebhook(gateway: string, payload: any): Promise<void> {
const provider = this.gateways.get(gateway);
const event = await provider.parseWebhook(payload);
switch (event.type) {
case 'payment.succeeded':
await this.handlePaymentSuccess(event.orderId);
break;
case 'payment.failed':
await this.handlePaymentFailed(event.orderId);
break;
}
}
private async handlePaymentSuccess(orderId: string): Promise<void> {
await this.orderRepo.update(orderId, {
paymentStatus: 'paid',
status: 'paid',
});
// Confirmar stock (quitar reserva y decrementar)
const lines = await this.orderLineRepo.find({ where: { orderId } });
for (const line of lines) {
await this.stockService.confirmReservation(line.productId, line.quantity);
}
// TODO: Enviar email de confirmacion
}
}
```
---
### 3.10 RT-010 Facturacion (60% herencia)
**Servicios Nuevos:**
```typescript
// 1. CFDIService
@Injectable()
export class CFDIService {
constructor(
private builderService: CFDIBuilderService,
private pacService: PACService,
private xmlService: XMLService,
private pdfService: PDFService,
) {}
async generateFromPOS(orderId: string, dto: CFDIRequestDto): Promise<CFDI> {
// 1. Obtener orden
const order = await this.posOrderRepo.findOne({
where: { id: orderId },
relations: ['lines', 'lines.product'],
});
// 2. Construir CFDI
const cfdiData = await this.builderService.fromPOSOrder(order, dto);
// 3. Generar XML
const xml = this.xmlService.buildXML(cfdiData);
// 4. Firmar con certificado
const signedXml = await this.xmlService.sign(xml);
// 5. Timbrar con PAC
const timbrado = await this.pacService.timbrar(signedXml);
// 6. Guardar CFDI
const cfdi = await this.cfdiRepo.save({
sourceType: 'pos_order',
sourceId: orderId,
serie: cfdiData.serie,
folio: cfdiData.folio,
uuid: timbrado.uuid,
fechaEmision: new Date(),
tipoComprobante: 'I',
formaPago: dto.formaPago,
metodoPago: dto.metodoPago,
receptorRfc: dto.receptorRfc,
receptorNombre: dto.receptorNombre,
receptorRegimen: dto.receptorRegimen,
receptorCp: dto.receptorCp,
usoCfdi: dto.usoCfdi,
subtotal: order.subtotal,
descuento: order.discountAmount,
totalImpuestos: order.taxAmount,
total: order.total,
status: 'vigente',
xmlContent: timbrado.xml,
fechaTimbrado: timbrado.fechaTimbrado,
rfcPac: timbrado.rfcProvCertif,
selloCfd: timbrado.selloCFD,
selloSat: timbrado.selloSAT,
noCertificadoSat: timbrado.noCertificadoSAT,
});
// 7. Generar PDF
const pdf = await this.pdfService.generate(cfdi);
await this.cfdiRepo.update(cfdi.id, { pdfPath: pdf.path });
// 8. Marcar orden como facturada
await this.posOrderRepo.update(orderId, {
isInvoiced: true,
invoiceId: cfdi.id,
});
return cfdi;
}
async generatePublicInvoice(orderId: string): Promise<CFDI> {
// Factura a publico general
return this.generateFromPOS(orderId, {
receptorRfc: 'XAXX010101000',
receptorNombre: 'PUBLICO EN GENERAL',
receptorRegimen: '616',
receptorCp: this.config.emisorCp,
usoCfdi: 'S01',
formaPago: '99',
metodoPago: 'PUE',
});
}
async cancel(cfdiId: string, dto: CancelDto): Promise<CancelResult> {
const cfdi = await this.cfdiRepo.findOne({ where: { id: cfdiId } });
// Validar que se puede cancelar
if (cfdi.status !== 'vigente') {
throw new BadRequestException('CFDI ya esta cancelado');
}
// Enviar cancelacion al PAC
const result = await this.pacService.cancelar(cfdi.uuid, dto.motivo);
if (result.success) {
await this.cfdiRepo.update(cfdiId, {
status: 'cancelado',
cancelDate: new Date(),
cancelReason: dto.motivo,
});
}
return result;
}
}
// 2. CFDIBuilderService
@Injectable()
export class CFDIBuilderService {
async fromPOSOrder(order: POSOrder, dto: CFDIRequestDto): Promise<CFDI40> {
const config = await this.getConfig();
return {
version: '4.0',
serie: config.serieFactura,
folio: await this.getNextFolio(),
fecha: new Date(),
formaPago: dto.formaPago,
metodoPago: dto.metodoPago,
tipoDeComprobante: 'I',
lugarExpedicion: config.emisorCp,
emisor: {
rfc: config.emisorRfc,
nombre: config.emisorNombre,
regimenFiscal: config.emisorRegimen,
},
receptor: {
rfc: dto.receptorRfc,
nombre: dto.receptorNombre,
domicilioFiscalReceptor: dto.receptorCp,
regimenFiscalReceptor: dto.receptorRegimen,
usoCFDI: dto.usoCfdi,
},
conceptos: order.lines.map(line => ({
claveProdServ: line.product.satProductCode || '01010101',
noIdentificacion: line.product.defaultCode,
cantidad: line.quantity,
claveUnidad: line.product.satUnitCode || 'H87',
unidad: line.product.uom?.name || 'Pieza',
descripcion: line.productName,
valorUnitario: Number(line.unitPrice),
importe: Number(line.total) - Number(line.taxAmount),
objetoImp: '02',
impuestos: {
traslados: [{
base: Number(line.total) - Number(line.taxAmount),
impuesto: '002',
tipoFactor: 'Tasa',
tasaOCuota: 0.16,
importe: Number(line.taxAmount),
}],
},
})),
impuestos: {
totalImpuestosTrasladados: Number(order.taxAmount),
totalImpuestosRetenidos: 0,
traslados: [{
base: Number(order.subtotal) - Number(order.discountAmount),
impuesto: '002',
tipoFactor: 'Tasa',
tasaOCuota: 0.16,
importe: Number(order.taxAmount),
}],
},
subTotal: Number(order.subtotal),
descuento: Number(order.discountAmount),
total: Number(order.total),
};
}
}
// 3. PACService
@Injectable()
export class PACService {
private providers: Map<string, PACProvider>;
constructor(
private finkokProvider: FinkokPAC,
private facturamaProvider: FacturamaPAC,
) {
this.providers = new Map([
['finkok', finkokProvider],
['facturama', facturamaProvider],
]);
}
async timbrar(xml: string): Promise<TimbradoResult> {
const config = await this.getConfig();
const primaryProvider = this.providers.get(config.pacProvider);
const backupProvider = this.providers.get(config.pacBackup);
try {
return await primaryProvider.timbrar(xml);
} catch (error) {
if (backupProvider) {
return await backupProvider.timbrar(xml);
}
throw error;
}
}
async cancelar(uuid: string, motivo: string): Promise<CancelResult> {
const config = await this.getConfig();
const provider = this.providers.get(config.pacProvider);
return provider.cancelar(uuid, motivo);
}
}
// 4. AutofacturaService
@Injectable()
export class AutofacturaService {
async validateTicket(ticketNumber: string): Promise<TicketValidation> {
const order = await this.posOrderRepo.findOne({
where: { orderNumber: ticketNumber },
relations: ['lines'],
});
if (!order) {
return { valid: false, error: 'Ticket no encontrado' };
}
if (order.isInvoiced) {
return { valid: false, error: 'Ticket ya fue facturado' };
}
// Validar plazo (30 dias por defecto)
const config = await this.getConfig();
const maxDays = config.autofacturaDias || 30;
const daysSinceOrder = this.daysBetween(order.orderDate, new Date());
if (daysSinceOrder > maxDays) {
return { valid: false, error: `Plazo de ${maxDays} dias excedido` };
}
return {
valid: true,
order: {
orderNumber: order.orderNumber,
orderDate: order.orderDate,
subtotal: order.subtotal,
tax: order.taxAmount,
total: order.total,
items: order.lines.map(l => ({
name: l.productName,
quantity: l.quantity,
unitPrice: l.unitPrice,
total: l.total,
})),
},
};
}
async generateFromTicket(dto: AutofacturaDto): Promise<CFDI> {
// Validar ticket
const validation = await this.validateTicket(dto.ticketNumber);
if (!validation.valid) {
throw new BadRequestException(validation.error);
}
// Buscar orden
const order = await this.posOrderRepo.findOne({
where: { orderNumber: dto.ticketNumber },
});
// Generar factura
return this.cfdiService.generateFromPOS(order.id, {
receptorRfc: dto.rfc,
receptorNombre: dto.nombre,
receptorRegimen: dto.regimenFiscal,
receptorCp: dto.codigoPostal,
usoCfdi: dto.usoCfdi,
formaPago: dto.formaPago || '99',
metodoPago: 'PUE',
});
}
}
```
---
## 4. MIDDLEWARE
### 4.1 TenantMiddleware
```typescript
@Injectable()
export class TenantMiddleware implements NestMiddleware {
async use(req: Request, res: Response, next: NextFunction) {
const tenantId = req.headers['x-tenant-id'] as string;
if (!tenantId) {
throw new UnauthorizedException('Tenant ID required');
}
// Validar que el tenant existe
const tenant = await this.tenantRepo.findOne({ where: { id: tenantId } });
if (!tenant || !tenant.isActive) {
throw new UnauthorizedException('Invalid tenant');
}
// Establecer variable de sesion para RLS
await this.dataSource.query(
`SELECT set_config('app.current_tenant_id', $1, false)`,
[tenantId]
);
req['tenant'] = tenant;
next();
}
}
```
### 4.2 BranchMiddleware
```typescript
@Injectable()
export class BranchMiddleware implements NestMiddleware {
async use(req: Request, res: Response, next: NextFunction) {
const branchId = req.headers['x-branch-id'] as string;
if (branchId) {
const branch = await this.branchRepo.findOne({
where: { id: branchId },
});
if (!branch || !branch.isActive) {
throw new UnauthorizedException('Invalid branch');
}
req['branch'] = branch;
}
next();
}
}
```
---
## 5. WEBSOCKET GATEWAY (Para sincronizacion POS)
```typescript
@WebSocketGateway({
namespace: 'pos',
cors: { origin: '*' },
})
export class POSGateway {
@WebSocketServer()
server: Server;
@SubscribeMessage('sync:orders')
async handleSyncOrders(
@MessageBody() data: { orders: OfflineOrder[] },
@ConnectedSocket() client: Socket,
) {
const result = await this.syncService.syncOfflineOrders(data.orders);
client.emit('sync:result', result);
}
@SubscribeMessage('order:created')
async handleOrderCreated(
@MessageBody() data: { orderId: string; branchId: string },
) {
// Notificar a otros clientes de la misma sucursal
this.server.to(`branch:${data.branchId}`).emit('order:new', data);
}
handleConnection(client: Socket) {
const branchId = client.handshake.query.branchId as string;
if (branchId) {
client.join(`branch:${branchId}`);
}
}
}
```
---
## 6. CHECKLIST DE IMPLEMENTACION
### Modulo RT-001 (Fundamentos)
- [ ] Configurar importaciones de @erp-core
- [ ] Verificar middleware de tenant
- [ ] Configurar autenticacion
### Modulo RT-002 (POS)
- [ ] POSSessionService
- [ ] POSOrderService
- [ ] POSPaymentService
- [ ] POSSyncService
- [ ] POSController
- [ ] POSGateway (WebSocket)
### Modulo RT-003 (Inventario)
- [ ] RetailStockService
- [ ] TransfersService
- [ ] AdjustmentsService
- [ ] InventoryController
### Modulo RT-004 (Compras)
- [ ] RetailPurchaseService
- [ ] GoodsReceiptService
- [ ] PurchaseController
### Modulo RT-005 (Clientes)
- [ ] RetailCustomersService
- [ ] LoyaltyService
- [ ] CustomersController
### Modulo RT-006 (Precios)
- [ ] PriceEngineService
- [ ] PromotionsService
- [ ] CouponsService
- [ ] PricingController
### Modulo RT-007 (Caja)
- [ ] CashSessionService
- [ ] CashMovementService
- [ ] CashClosingService
- [ ] CashController
### Modulo RT-008 (Reportes)
- [ ] DashboardService
- [ ] SalesReportService
- [ ] ProductReportService
- [ ] CashReportService
- [ ] ReportsController
### Modulo RT-009 (E-commerce)
- [ ] CatalogService
- [ ] CartService
- [ ] CheckoutService
- [ ] PaymentGatewayService
- [ ] ShippingService
- [ ] StorefrontController
- [ ] EcommerceAdminController
### Modulo RT-010 (Facturacion)
- [ ] CFDIService
- [ ] CFDIBuilderService
- [ ] PACService
- [ ] XMLService
- [ ] PDFService
- [ ] AutofacturaService
- [ ] CFDIController
- [ ] AutofacturaController
---
**Estado:** PLAN COMPLETO
**Total servicios:** 48 (12 heredados, 8 extendidos, 28 nuevos)
**Total controllers:** 15