1992 lines
61 KiB
Markdown
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
|