- 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>
456 lines
13 KiB
TypeScript
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,
|
|
};
|
|
}
|
|
}
|