import { Injectable, NotFoundException, BadRequestException, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository, DataSource } from 'typeorm'; import { Supplier, SupplierStatus } from './entities/supplier.entity'; import { SupplierProduct } from './entities/supplier-product.entity'; import { SupplierOrder, SupplierOrderStatus } from './entities/supplier-order.entity'; import { SupplierOrderItem } from './entities/supplier-order-item.entity'; import { SupplierReview } from './entities/supplier-review.entity'; import { CreateSupplierOrderDto } from './dto/create-supplier-order.dto'; import { CreateSupplierReviewDto } from './dto/create-supplier-review.dto'; @Injectable() export class MarketplaceService { constructor( @InjectRepository(Supplier) private readonly supplierRepo: Repository, @InjectRepository(SupplierProduct) private readonly productRepo: Repository, @InjectRepository(SupplierOrder) private readonly orderRepo: Repository, @InjectRepository(SupplierOrderItem) private readonly orderItemRepo: Repository, @InjectRepository(SupplierReview) private readonly reviewRepo: Repository, private readonly dataSource: DataSource, ) {} // ==================== SUPPLIERS ==================== async findSuppliers(options?: { category?: string; zipCode?: string; search?: string; limit?: number; }): Promise { const query = this.supplierRepo.createQueryBuilder('supplier') .where('supplier.status = :status', { status: SupplierStatus.ACTIVE }) .orderBy('supplier.rating', 'DESC') .addOrderBy('supplier.total_orders', 'DESC'); if (options?.category) { query.andWhere(':category = ANY(supplier.categories)', { category: options.category, }); } if (options?.zipCode) { query.andWhere( '(supplier.coverage_zones = \'{}\' OR :zipCode = ANY(supplier.coverage_zones))', { zipCode: options.zipCode }, ); } if (options?.search) { query.andWhere( '(supplier.name ILIKE :search OR supplier.description ILIKE :search)', { search: `%${options.search}%` }, ); } if (options?.limit) { query.limit(options.limit); } return query.getMany(); } async getSupplier(id: string): Promise { const supplier = await this.supplierRepo.findOne({ where: { id }, relations: ['products', 'reviews'], }); if (!supplier) { throw new NotFoundException('Proveedor no encontrado'); } return supplier; } async getSupplierProducts( supplierId: string, options?: { category?: string; search?: string; inStock?: boolean; }, ): Promise { const query = this.productRepo.createQueryBuilder('product') .where('product.supplier_id = :supplierId', { supplierId }) .andWhere('product.active = true') .orderBy('product.category', 'ASC') .addOrderBy('product.name', 'ASC'); if (options?.category) { query.andWhere('product.category = :category', { category: options.category }); } if (options?.search) { query.andWhere( '(product.name ILIKE :search OR product.description ILIKE :search)', { search: `%${options.search}%` }, ); } if (options?.inStock !== undefined) { query.andWhere('product.in_stock = :inStock', { inStock: options.inStock }); } return query.getMany(); } // ==================== ORDERS ==================== async createOrder( tenantId: string, dto: CreateSupplierOrderDto, ): Promise { const supplier = await this.supplierRepo.findOne({ where: { id: dto.supplierId, status: SupplierStatus.ACTIVE }, }); if (!supplier) { throw new NotFoundException('Proveedor no encontrado o no activo'); } // Get products and calculate totals const productIds = dto.items.map((item) => item.productId); const products = await this.productRepo.findByIds(productIds); if (products.length !== productIds.length) { throw new BadRequestException('Algunos productos no fueron encontrados'); } // Create product map for easy lookup const productMap = new Map(products.map((p) => [p.id, p])); // Validate min quantities and calculate subtotal let subtotal = 0; for (const item of dto.items) { const product = productMap.get(item.productId); if (!product) { throw new BadRequestException(`Producto ${item.productId} no encontrado`); } if (item.quantity < product.minQuantity) { throw new BadRequestException( `Cantidad minima para ${product.name} es ${product.minQuantity}`, ); } if (!product.inStock) { throw new BadRequestException(`${product.name} no esta disponible`); } // Calculate price based on tiered pricing let unitPrice = Number(product.unitPrice); if (product.tieredPricing && product.tieredPricing.length > 0) { for (const tier of product.tieredPricing.sort((a, b) => b.min - a.min)) { if (item.quantity >= tier.min) { unitPrice = tier.price; break; } } } subtotal += unitPrice * item.quantity; } // Calculate delivery fee let deliveryFee = Number(supplier.deliveryFee); if (supplier.freeDeliveryMin && subtotal >= Number(supplier.freeDeliveryMin)) { deliveryFee = 0; } // Check minimum order if (subtotal < Number(supplier.minOrderAmount)) { throw new BadRequestException( `Pedido minimo es $${supplier.minOrderAmount}`, ); } const total = subtotal + deliveryFee; // Create order const order = this.orderRepo.create({ tenantId, supplierId: dto.supplierId, status: SupplierOrderStatus.PENDING, subtotal, deliveryFee, total, deliveryAddress: dto.deliveryAddress, deliveryCity: dto.deliveryCity, deliveryZip: dto.deliveryZip, deliveryPhone: dto.deliveryPhone, deliveryContact: dto.deliveryContact, requestedDate: dto.requestedDate ? new Date(dto.requestedDate) : null, notes: dto.notes, }); await this.orderRepo.save(order); // Create order items for (const item of dto.items) { const product = productMap.get(item.productId); let unitPrice = Number(product.unitPrice); if (product.tieredPricing && product.tieredPricing.length > 0) { for (const tier of product.tieredPricing.sort((a, b) => b.min - a.min)) { if (item.quantity >= tier.min) { unitPrice = tier.price; break; } } } const orderItem = this.orderItemRepo.create({ orderId: order.id, productId: item.productId, productName: product.name, productSku: product.sku, quantity: item.quantity, unitPrice, total: unitPrice * item.quantity, notes: item.notes, }); await this.orderItemRepo.save(orderItem); } return this.getOrder(order.id); } async getOrder(id: string): Promise { const order = await this.orderRepo.findOne({ where: { id }, relations: ['items', 'supplier'], }); if (!order) { throw new NotFoundException('Pedido no encontrado'); } return order; } async getOrders( tenantId: string, options?: { status?: SupplierOrderStatus; supplierId?: string; limit?: number; }, ): Promise { const query = this.orderRepo.createQueryBuilder('order') .where('order.tenant_id = :tenantId', { tenantId }) .leftJoinAndSelect('order.supplier', 'supplier') .leftJoinAndSelect('order.items', 'items') .orderBy('order.created_at', 'DESC'); if (options?.status) { query.andWhere('order.status = :status', { status: options.status }); } if (options?.supplierId) { query.andWhere('order.supplier_id = :supplierId', { supplierId: options.supplierId }); } if (options?.limit) { query.limit(options.limit); } return query.getMany(); } async updateOrderStatus( id: string, status: SupplierOrderStatus, notes?: string, ): Promise { const order = await this.getOrder(id); // Validate status transitions const validTransitions: Record = { [SupplierOrderStatus.PENDING]: [SupplierOrderStatus.CONFIRMED, SupplierOrderStatus.CANCELLED, SupplierOrderStatus.REJECTED], [SupplierOrderStatus.CONFIRMED]: [SupplierOrderStatus.PREPARING, SupplierOrderStatus.CANCELLED], [SupplierOrderStatus.PREPARING]: [SupplierOrderStatus.SHIPPED, SupplierOrderStatus.CANCELLED], [SupplierOrderStatus.SHIPPED]: [SupplierOrderStatus.DELIVERED, SupplierOrderStatus.CANCELLED], [SupplierOrderStatus.DELIVERED]: [], [SupplierOrderStatus.CANCELLED]: [], [SupplierOrderStatus.REJECTED]: [], }; if (!validTransitions[order.status].includes(status)) { throw new BadRequestException( `No se puede cambiar estado de ${order.status} a ${status}`, ); } order.status = status; if (status === SupplierOrderStatus.CONFIRMED) { order.confirmedDate = new Date(); } if (status === SupplierOrderStatus.DELIVERED) { order.deliveredAt = new Date(); } if (status === SupplierOrderStatus.CANCELLED || status === SupplierOrderStatus.REJECTED) { order.cancelledAt = new Date(); order.cancelReason = notes; } if (notes) { order.supplierNotes = notes; } return this.orderRepo.save(order); } async cancelOrder( id: string, tenantId: string, reason: string, ): Promise { const order = await this.getOrder(id); if (order.tenantId !== tenantId) { throw new BadRequestException('No autorizado'); } if (![SupplierOrderStatus.PENDING, SupplierOrderStatus.CONFIRMED].includes(order.status)) { throw new BadRequestException('No se puede cancelar el pedido en este estado'); } order.status = SupplierOrderStatus.CANCELLED; order.cancelledAt = new Date(); order.cancelReason = reason; order.cancelledBy = 'tenant'; return this.orderRepo.save(order); } // ==================== REVIEWS ==================== async createReview( tenantId: string, dto: CreateSupplierReviewDto, ): Promise { const supplier = await this.supplierRepo.findOne({ where: { id: dto.supplierId }, }); if (!supplier) { throw new NotFoundException('Proveedor no encontrado'); } // Check if order exists and belongs to tenant let verified = false; if (dto.orderId) { const order = await this.orderRepo.findOne({ where: { id: dto.orderId, tenantId, supplierId: dto.supplierId }, }); if (!order) { throw new BadRequestException('Orden no encontrada'); } if (order.status === SupplierOrderStatus.DELIVERED) { verified = true; } } const review = this.reviewRepo.create({ tenantId, supplierId: dto.supplierId, orderId: dto.orderId, rating: dto.rating, title: dto.title, comment: dto.comment, ratingQuality: dto.ratingQuality, ratingDelivery: dto.ratingDelivery, ratingPrice: dto.ratingPrice, verified, }); return this.reviewRepo.save(review); } async getReviews( supplierId: string, options?: { limit?: number; offset?: number; }, ): Promise { return this.reviewRepo.find({ where: { supplierId, status: 'active' }, order: { createdAt: 'DESC' }, take: options?.limit || 20, skip: options?.offset || 0, }); } // ==================== FAVORITES ==================== async addFavorite(tenantId: string, supplierId: string): Promise { await this.dataSource.query( `INSERT INTO marketplace.supplier_favorites (tenant_id, supplier_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`, [tenantId, supplierId], ); } async removeFavorite(tenantId: string, supplierId: string): Promise { await this.dataSource.query( `DELETE FROM marketplace.supplier_favorites WHERE tenant_id = $1 AND supplier_id = $2`, [tenantId, supplierId], ); } async getFavorites(tenantId: string): Promise { const result = await this.dataSource.query( `SELECT s.* FROM marketplace.suppliers s JOIN marketplace.supplier_favorites f ON s.id = f.supplier_id WHERE f.tenant_id = $1`, [tenantId], ); return result; } // ==================== STATS ==================== async getMarketplaceStats() { const result = await this.dataSource.query( `SELECT * FROM marketplace.get_marketplace_stats()`, ); return result[0] || { total_suppliers: 0, active_suppliers: 0, total_products: 0, total_orders: 0, total_gmv: 0, avg_rating: 0, }; } }