michangarrito/apps/backend/src/modules/marketplace/marketplace.service.ts
rckrdmrd 928eb795e6 [SIMCO-V38] feat: Actualizar a SIMCO v3.8.0 + cambios apps
- HERENCIA-SIMCO.md actualizado con directivas v3.7 y v3.8
- Cambios en backend y frontend

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 08:53:05 -06:00

456 lines
13 KiB
TypeScript

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<Supplier>,
@InjectRepository(SupplierProduct)
private readonly productRepo: Repository<SupplierProduct>,
@InjectRepository(SupplierOrder)
private readonly orderRepo: Repository<SupplierOrder>,
@InjectRepository(SupplierOrderItem)
private readonly orderItemRepo: Repository<SupplierOrderItem>,
@InjectRepository(SupplierReview)
private readonly reviewRepo: Repository<SupplierReview>,
private readonly dataSource: DataSource,
) {}
// ==================== SUPPLIERS ====================
async findSuppliers(options?: {
category?: string;
zipCode?: string;
search?: string;
limit?: number;
}): Promise<Supplier[]> {
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<Supplier> {
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<SupplierProduct[]> {
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<SupplierOrder> {
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<SupplierOrder> {
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<SupplierOrder[]> {
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<SupplierOrder> {
const order = await this.getOrder(id);
// Validate status transitions
const validTransitions: Record<SupplierOrderStatus, SupplierOrderStatus[]> = {
[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<SupplierOrder> {
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<SupplierReview> {
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<SupplierReview[]> {
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<void> {
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<void> {
await this.dataSource.query(
`DELETE FROM marketplace.supplier_favorites WHERE tenant_id = $1 AND supplier_id = $2`,
[tenantId, supplierId],
);
}
async getFavorites(tenantId: string): Promise<Supplier[]> {
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,
};
}
}